Merge pull request #221 from sartography/feature/interstitial
Feature/interstitial
This commit is contained in:
commit
3dca7d2ed4
|
@ -1842,6 +1842,27 @@ paths:
|
|||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServiceTask"
|
||||
/tasks/{process_instance_id}:
|
||||
parameters:
|
||||
- name: process_instance_id
|
||||
in: path
|
||||
required: true
|
||||
description: The unique id of an existing process instance.
|
||||
schema:
|
||||
type: integer
|
||||
get:
|
||||
tags:
|
||||
- Tasks
|
||||
operationId: spiffworkflow_backend.routes.tasks_controller.interstitial
|
||||
summary: An SSE (Server Sent Events) endpoint that returns what tasks are currently active (running, waiting, or the final END event)
|
||||
responses:
|
||||
"200":
|
||||
description: One task
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Task"
|
||||
|
||||
|
||||
/tasks/{process_instance_id}/{task_guid}:
|
||||
parameters:
|
||||
|
|
|
@ -140,7 +140,7 @@ SPIFFWORKFLOW_BACKEND_ENGINE_STEP_DEFAULT_STRATEGY_BACKGROUND = environ.get(
|
|||
)
|
||||
|
||||
SPIFFWORKFLOW_BACKEND_ENGINE_STEP_DEFAULT_STRATEGY_WEB = environ.get(
|
||||
"SPIFFWORKFLOW_BACKEND_ENGINE_STEP_DEFAULT_STRATEGY_WEB", default="greedy"
|
||||
"SPIFFWORKFLOW_BACKEND_ENGINE_STEP_DEFAULT_STRATEGY_WEB", default="run_until_user_message"
|
||||
)
|
||||
|
||||
# this is only used in CI. use SPIFFWORKFLOW_BACKEND_DATABASE_URI instead for real configuration
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
users:
|
||||
ciadmin1:
|
||||
service: local_open_id
|
||||
email: ciadmin1@spiffworkflow.org
|
||||
password: ciadmin1
|
||||
preferred_username: ciadmin1
|
||||
|
||||
groups:
|
||||
admin:
|
||||
users: [ciadmin1@spiffworkflow.org]
|
||||
|
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from flask import g
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
|
@ -68,12 +69,19 @@ class HumanTaskModel(SpiffworkflowBaseDBModel):
|
|||
@classmethod
|
||||
def to_task(cls, task: HumanTaskModel) -> Task:
|
||||
"""To_task."""
|
||||
can_complete = False
|
||||
for user in task.human_task_users:
|
||||
if user.user_id == g.user.id:
|
||||
can_complete = True
|
||||
break
|
||||
|
||||
new_task = Task(
|
||||
task.task_id,
|
||||
task.task_name,
|
||||
task.task_title,
|
||||
task.task_type,
|
||||
task.task_status,
|
||||
can_complete,
|
||||
process_instance_id=task.process_instance_id,
|
||||
)
|
||||
if hasattr(task, "process_model_display_name"):
|
||||
|
|
|
@ -88,6 +88,7 @@ class Task:
|
|||
title: str,
|
||||
type: str,
|
||||
state: str,
|
||||
can_complete: bool,
|
||||
lane: Union[str, None] = None,
|
||||
form: None = None,
|
||||
documentation: str = "",
|
||||
|
@ -116,6 +117,7 @@ class Task:
|
|||
self.title = title
|
||||
self.type = type
|
||||
self.state = state
|
||||
self.can_complete = can_complete
|
||||
self.form = form
|
||||
self.documentation = documentation
|
||||
self.lane = lane
|
||||
|
@ -160,6 +162,7 @@ class Task:
|
|||
"type": self.type,
|
||||
"state": self.state,
|
||||
"lane": self.lane,
|
||||
"can_complete": self.can_complete,
|
||||
"form": self.form,
|
||||
"documentation": self.documentation,
|
||||
"data": self.data,
|
||||
|
|
|
@ -124,7 +124,7 @@ def process_instance_run(
|
|||
|
||||
processor = None
|
||||
try:
|
||||
processor = ProcessInstanceService.run_process_intance_with_processor(process_instance)
|
||||
processor = ProcessInstanceService.run_process_instance_with_processor(process_instance)
|
||||
except (
|
||||
ApiError,
|
||||
ProcessInstanceIsNotEnqueuedError,
|
||||
|
|
|
@ -5,6 +5,7 @@ import uuid
|
|||
from sys import exc_info
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import Generator
|
||||
from typing import Optional
|
||||
from typing import TypedDict
|
||||
from typing import Union
|
||||
|
@ -16,6 +17,7 @@ from flask import current_app
|
|||
from flask import g
|
||||
from flask import jsonify
|
||||
from flask import make_response
|
||||
from flask import stream_with_context
|
||||
from flask.wrappers import Response
|
||||
from jinja2 import TemplateSyntaxError
|
||||
from SpiffWorkflow.exceptions import WorkflowTaskException # type: ignore
|
||||
|
@ -262,7 +264,7 @@ def manual_complete_task(
|
|||
)
|
||||
|
||||
|
||||
def task_show(process_instance_id: int, task_guid: str) -> flask.wrappers.Response:
|
||||
def task_show(process_instance_id: int, task_guid: str = "next") -> flask.wrappers.Response:
|
||||
"""Task_show."""
|
||||
process_instance = _find_process_instance_by_id_or_raise(process_instance_id)
|
||||
|
||||
|
@ -277,11 +279,15 @@ def task_show(process_instance_id: int, task_guid: str) -> flask.wrappers.Respon
|
|||
process_instance.process_model_identifier,
|
||||
)
|
||||
|
||||
_find_human_task_or_raise(process_instance_id, task_guid)
|
||||
# _find_human_task_or_raise(process_instance_id, task_guid)
|
||||
|
||||
form_schema_file_name = ""
|
||||
form_ui_schema_file_name = ""
|
||||
processor = ProcessInstanceProcessor(process_instance)
|
||||
if task_guid == "next":
|
||||
spiff_task = processor.next_task()
|
||||
task_guid = spiff_task.id
|
||||
else:
|
||||
spiff_task = _get_spiff_task_from_process_instance(task_guid, process_instance, processor=processor)
|
||||
extensions = spiff_task.task_spec.extensions
|
||||
|
||||
|
@ -344,17 +350,22 @@ def task_show(process_instance_id: int, task_guid: str) -> flask.wrappers.Respon
|
|||
task.form_ui_schema = ui_form_contents
|
||||
|
||||
_munge_form_ui_schema_based_on_hidden_fields_in_task_data(task)
|
||||
_render_instructions_for_end_user(spiff_task, task)
|
||||
return make_response(jsonify(task), 200)
|
||||
|
||||
|
||||
def _render_instructions_for_end_user(spiff_task: SpiffTask, task: Task) -> str:
|
||||
"""Assure any instructions for end user are processed for jinja syntax."""
|
||||
if task.properties and "instructionsForEndUser" in task.properties:
|
||||
if task.properties["instructionsForEndUser"]:
|
||||
try:
|
||||
task.properties["instructionsForEndUser"] = _render_jinja_template(
|
||||
task.properties["instructionsForEndUser"], spiff_task
|
||||
)
|
||||
instructions = _render_jinja_template(task.properties["instructionsForEndUser"], spiff_task)
|
||||
task.properties["instructionsForEndUser"] = instructions
|
||||
return instructions
|
||||
except WorkflowTaskException as wfe:
|
||||
wfe.add_note("Failed to render instructions for end user.")
|
||||
raise ApiError.from_workflow_exception("instructions_error", str(wfe), exp=wfe) from wfe
|
||||
return make_response(jsonify(task), 200)
|
||||
return ""
|
||||
|
||||
|
||||
def process_data_show(
|
||||
|
@ -381,6 +392,36 @@ def process_data_show(
|
|||
)
|
||||
|
||||
|
||||
def _interstitial_stream(process_instance_id: int) -> Generator[str, Optional[str], None]:
|
||||
process_instance = _find_process_instance_by_id_or_raise(process_instance_id)
|
||||
processor = ProcessInstanceProcessor(process_instance)
|
||||
reported_ids = [] # bit of an issue with end tasks showing as getting completed twice.
|
||||
spiff_task = processor.next_task()
|
||||
last_task = None
|
||||
while last_task != spiff_task:
|
||||
task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task())
|
||||
instructions = _render_instructions_for_end_user(spiff_task, task)
|
||||
if instructions and spiff_task.id not in reported_ids:
|
||||
reported_ids.append(spiff_task.id)
|
||||
yield f"data: {current_app.json.dumps(task)} \n\n"
|
||||
last_task = spiff_task
|
||||
processor.do_engine_steps(execution_strategy_name="run_until_user_message")
|
||||
processor.do_engine_steps(execution_strategy_name="one_at_a_time")
|
||||
spiff_task = processor.next_task()
|
||||
# Note, this has to be done in case someone leaves the page,
|
||||
# which can otherwise cancel this function and leave completed tasks un-registered.
|
||||
processor.save() # Fixme - maybe find a way not to do this on every loop?
|
||||
if len(reported_ids) == 0:
|
||||
# Always provide some response, in the event no instructions were provided.
|
||||
task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task())
|
||||
yield f"data: {current_app.json.dumps(task)} \n\n"
|
||||
|
||||
|
||||
def interstitial(process_instance_id: int) -> Response:
|
||||
"""A Server Side Events Stream for watching the execution of engine tasks."""
|
||||
return Response(stream_with_context(_interstitial_stream(process_instance_id)), mimetype="text/event-stream")
|
||||
|
||||
|
||||
def _task_submit_shared(
|
||||
process_instance_id: int,
|
||||
task_guid: str,
|
||||
|
@ -462,8 +503,21 @@ def _task_submit_shared(
|
|||
)
|
||||
if next_human_task_assigned_to_me:
|
||||
return make_response(jsonify(HumanTaskModel.to_task(next_human_task_assigned_to_me)), 200)
|
||||
elif processor.next_task():
|
||||
task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task())
|
||||
return make_response(jsonify(task), 200)
|
||||
|
||||
return Response(json.dumps({"ok": True}), status=202, mimetype="application/json")
|
||||
return Response(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"process_model_identifier": process_instance.process_model_identifier,
|
||||
"process_instance_id": process_instance_id,
|
||||
}
|
||||
),
|
||||
status=202,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
|
||||
def task_submit(
|
||||
|
|
|
@ -1687,7 +1687,7 @@ class ProcessInstanceProcessor:
|
|||
self._script_engine.environment.finalize_result,
|
||||
self.save,
|
||||
)
|
||||
execution_service.run(exit_at, save)
|
||||
execution_service.run_and_save(exit_at, save)
|
||||
|
||||
@classmethod
|
||||
def get_tasks_with_data(cls, bpmn_process_instance: BpmnWorkflow) -> List[SpiffTask]:
|
||||
|
|
|
@ -11,6 +11,7 @@ from urllib.parse import unquote
|
|||
|
||||
import sentry_sdk
|
||||
from flask import current_app
|
||||
from flask import g
|
||||
from SpiffWorkflow.bpmn.specs.events.IntermediateEvent import _BoundaryEventParent # type: ignore
|
||||
from SpiffWorkflow.task import Task as SpiffTask # type: ignore
|
||||
|
||||
|
@ -27,6 +28,8 @@ from spiffworkflow_backend.models.process_model import ProcessModelInfo
|
|||
from spiffworkflow_backend.models.task import Task
|
||||
from spiffworkflow_backend.models.user import UserModel
|
||||
from spiffworkflow_backend.services.authorization_service import AuthorizationService
|
||||
from spiffworkflow_backend.services.authorization_service import HumanTaskNotFoundError
|
||||
from spiffworkflow_backend.services.authorization_service import UserDoesNotHaveAccessToTaskError
|
||||
from spiffworkflow_backend.services.git_service import GitCommandError
|
||||
from spiffworkflow_backend.services.git_service import GitService
|
||||
from spiffworkflow_backend.services.process_instance_processor import (
|
||||
|
@ -112,10 +115,13 @@ class ProcessInstanceService:
|
|||
.filter(ProcessInstanceModel.id.in_(process_instance_ids_to_check)) # type: ignore
|
||||
.all()
|
||||
)
|
||||
execution_strategy_name = current_app.config["SPIFFWORKFLOW_BACKEND_ENGINE_STEP_DEFAULT_STRATEGY_BACKGROUND"]
|
||||
for process_instance in records:
|
||||
current_app.logger.info(f"Processing process_instance {process_instance.id}")
|
||||
try:
|
||||
cls.run_process_intance_with_processor(process_instance, status_value=status_value)
|
||||
cls.run_process_instance_with_processor(
|
||||
process_instance, status_value=status_value, execution_strategy_name=execution_strategy_name
|
||||
)
|
||||
except ProcessInstanceIsAlreadyLockedError:
|
||||
continue
|
||||
except Exception as e:
|
||||
|
@ -127,8 +133,11 @@ class ProcessInstanceService:
|
|||
current_app.logger.error(error_message)
|
||||
|
||||
@classmethod
|
||||
def run_process_intance_with_processor(
|
||||
cls, process_instance: ProcessInstanceModel, status_value: Optional[str] = None
|
||||
def run_process_instance_with_processor(
|
||||
cls,
|
||||
process_instance: ProcessInstanceModel,
|
||||
status_value: Optional[str] = None,
|
||||
execution_strategy_name: Optional[str] = None,
|
||||
) -> Optional[ProcessInstanceProcessor]:
|
||||
processor = None
|
||||
with ProcessInstanceQueueService.dequeued(process_instance):
|
||||
|
@ -139,9 +148,6 @@ class ProcessInstanceService:
|
|||
|
||||
db.session.refresh(process_instance)
|
||||
if status_value is None or process_instance.status == status_value:
|
||||
execution_strategy_name = current_app.config[
|
||||
"SPIFFWORKFLOW_BACKEND_ENGINE_STEP_DEFAULT_STRATEGY_BACKGROUND"
|
||||
]
|
||||
processor.do_engine_steps(save=True, execution_strategy_name=execution_strategy_name)
|
||||
|
||||
return processor
|
||||
|
@ -432,6 +438,19 @@ class ProcessInstanceService:
|
|||
else:
|
||||
lane = None
|
||||
|
||||
# Check for a human task, and if it exists, check to see if the current user
|
||||
# can complete it.
|
||||
can_complete = False
|
||||
try:
|
||||
AuthorizationService.assert_user_can_complete_spiff_task(
|
||||
processor.process_instance_model.id, spiff_task, g.user
|
||||
)
|
||||
can_complete = True
|
||||
except HumanTaskNotFoundError:
|
||||
can_complete = False
|
||||
except UserDoesNotHaveAccessToTaskError:
|
||||
can_complete = False
|
||||
|
||||
if hasattr(spiff_task.task_spec, "spec"):
|
||||
call_activity_process_identifier = spiff_task.task_spec.spec
|
||||
else:
|
||||
|
@ -449,8 +468,12 @@ class ProcessInstanceService:
|
|||
spiff_task.task_spec.description,
|
||||
task_type,
|
||||
spiff_task.get_state_name(),
|
||||
can_complete=can_complete,
|
||||
lane=lane,
|
||||
process_identifier=spiff_task.task_spec._wf_spec.name,
|
||||
process_instance_id=processor.process_instance_model.id,
|
||||
process_model_identifier=processor.process_model_identifier,
|
||||
process_model_display_name=processor.process_model_display_name,
|
||||
properties=props,
|
||||
parent=parent_id,
|
||||
event_definition=serialized_task_spec.get("event_definition"),
|
||||
|
|
|
@ -90,6 +90,15 @@ class ExecutionStrategy:
|
|||
def save(self, bpmn_process_instance: BpmnWorkflow) -> None:
|
||||
self.delegate.save(bpmn_process_instance)
|
||||
|
||||
def get_ready_engine_steps(self, bpmn_process_instance: BpmnWorkflow) -> list[SpiffTask]:
|
||||
return list(
|
||||
[
|
||||
t
|
||||
for t in bpmn_process_instance.get_tasks(TaskState.READY)
|
||||
if bpmn_process_instance._is_engine_task(t.task_spec)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TaskModelSavingDelegate(EngineStepDelegate):
|
||||
"""Engine step delegate that takes care of saving a task model to the database.
|
||||
|
@ -275,30 +284,55 @@ class RunUntilServiceTaskExecutionStrategy(ExecutionStrategy):
|
|||
"""
|
||||
|
||||
def spiff_run(self, bpmn_process_instance: BpmnWorkflow, exit_at: None = None) -> None:
|
||||
self.bpmn_process_instance = bpmn_process_instance
|
||||
engine_steps = list(
|
||||
[
|
||||
t
|
||||
for t in bpmn_process_instance.get_tasks(TaskState.READY)
|
||||
if bpmn_process_instance._is_engine_task(t.task_spec)
|
||||
]
|
||||
)
|
||||
engine_steps = self.get_ready_engine_steps(bpmn_process_instance)
|
||||
while engine_steps:
|
||||
for spiff_task in engine_steps:
|
||||
if spiff_task.task_spec.spec_type == "Service Task":
|
||||
return
|
||||
self.delegate.will_complete_task(spiff_task)
|
||||
spiff_task.complete()
|
||||
spiff_task.run()
|
||||
self.delegate.did_complete_task(spiff_task)
|
||||
bpmn_process_instance.refresh_waiting_tasks()
|
||||
engine_steps = self.get_ready_engine_steps(bpmn_process_instance)
|
||||
self.delegate.after_engine_steps(bpmn_process_instance)
|
||||
|
||||
engine_steps = list(
|
||||
|
||||
class RunUntilUserTaskOrMessageExecutionStrategy(ExecutionStrategy):
|
||||
"""When you want to run tasks until you hit something to report to the end user."""
|
||||
|
||||
def get_engine_steps(self, bpmn_process_instance: BpmnWorkflow) -> list[SpiffTask]:
|
||||
return list(
|
||||
[
|
||||
t
|
||||
for t in bpmn_process_instance.get_tasks(TaskState.READY)
|
||||
if bpmn_process_instance._is_engine_task(t.task_spec)
|
||||
if t.task_spec.spec_type not in ["User Task", "Manual Task"]
|
||||
and not (
|
||||
hasattr(t.task_spec, "extensions") and t.task_spec.extensions.get("instructionsForEndUser", None)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
def spiff_run(self, bpmn_process_instance: BpmnWorkflow, exit_at: None = None) -> None:
|
||||
engine_steps = self.get_engine_steps(bpmn_process_instance)
|
||||
while engine_steps:
|
||||
for task in engine_steps:
|
||||
self.delegate.will_complete_task(task)
|
||||
task.run()
|
||||
self.delegate.did_complete_task(task)
|
||||
engine_steps = self.get_engine_steps(bpmn_process_instance)
|
||||
self.delegate.after_engine_steps(bpmn_process_instance)
|
||||
|
||||
|
||||
class OneAtATimeExecutionStrategy(ExecutionStrategy):
|
||||
"""When you want to run only one engine step at a time."""
|
||||
|
||||
def spiff_run(self, bpmn_process_instance: BpmnWorkflow, exit_at: None = None) -> None:
|
||||
engine_steps = self.get_ready_engine_steps(bpmn_process_instance)
|
||||
if len(engine_steps) > 0:
|
||||
spiff_task = engine_steps[0]
|
||||
self.delegate.will_complete_task(spiff_task)
|
||||
spiff_task.run()
|
||||
self.delegate.did_complete_task(spiff_task)
|
||||
self.delegate.after_engine_steps(bpmn_process_instance)
|
||||
|
||||
|
||||
|
@ -306,9 +340,11 @@ def execution_strategy_named(name: str, delegate: EngineStepDelegate) -> Executi
|
|||
cls = {
|
||||
"greedy": GreedyExecutionStrategy,
|
||||
"run_until_service_task": RunUntilServiceTaskExecutionStrategy,
|
||||
"run_until_user_message": RunUntilUserTaskOrMessageExecutionStrategy,
|
||||
"one_at_a_time": OneAtATimeExecutionStrategy,
|
||||
}[name]
|
||||
|
||||
return cls(delegate)
|
||||
return cls(delegate) # type: ignore
|
||||
|
||||
|
||||
ProcessInstanceCompleter = Callable[[BpmnWorkflow], None]
|
||||
|
@ -338,7 +374,7 @@ class WorkflowExecutionService:
|
|||
# run
|
||||
# execution_strategy.spiff_run
|
||||
# spiff.[some_run_task_method]
|
||||
def run(self, exit_at: None = None, save: bool = False) -> None:
|
||||
def run_and_save(self, exit_at: None = None, save: bool = False) -> None:
|
||||
"""Do_engine_steps."""
|
||||
with safe_assertion(ProcessInstanceLockService.has_lock(self.process_instance_model.id)) as tripped:
|
||||
if tripped:
|
||||
|
@ -384,7 +420,8 @@ class WorkflowExecutionService:
|
|||
for bpmn_message in bpmn_messages:
|
||||
message_instance = MessageInstanceModel(
|
||||
process_instance_id=self.process_instance_model.id,
|
||||
user_id=self.process_instance_model.process_initiator_id, # TODO: use the correct swimlane user when that is set up
|
||||
user_id=self.process_instance_model.process_initiator_id,
|
||||
# TODO: use the correct swimlane user when that is set up
|
||||
message_type="send",
|
||||
name=bpmn_message.name,
|
||||
payload=bpmn_message.payload,
|
||||
|
@ -449,11 +486,11 @@ class WorkflowExecutionService:
|
|||
class ProfiledWorkflowExecutionService(WorkflowExecutionService):
|
||||
"""A profiled version of the workflow execution service."""
|
||||
|
||||
def run(self, exit_at: None = None, save: bool = False) -> None:
|
||||
def run_and_save(self, exit_at: None = None, save: bool = False) -> None:
|
||||
"""__do_engine_steps."""
|
||||
import cProfile
|
||||
from pstats import SortKey
|
||||
|
||||
with cProfile.Profile() as pr:
|
||||
super().run(exit_at=exit_at, save=save)
|
||||
super().run_and_save(exit_at=exit_at, save=save)
|
||||
pr.print_stats(sort=SortKey.CUMULATIVE)
|
||||
|
|
|
@ -0,0 +1,123 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
|
||||
<bpmn:collaboration id="Collaboration_1ullb3f">
|
||||
<bpmn:participant id="Participant_1mug4yn" processRef="Process_a6ss9w7" />
|
||||
</bpmn:collaboration>
|
||||
<bpmn:process id="Process_a6ss9w7" isExecutable="true">
|
||||
<bpmn:laneSet id="LaneSet_1m2geb1">
|
||||
<bpmn:lane id="Lane_0518vyo">
|
||||
<bpmn:flowNodeRef>StartEvent_1</bpmn:flowNodeRef>
|
||||
<bpmn:flowNodeRef>Activity_16m8jvv</bpmn:flowNodeRef>
|
||||
<bpmn:flowNodeRef>Activity_1qrme8m</bpmn:flowNodeRef>
|
||||
<bpmn:flowNodeRef>Activity_0bi0v5d</bpmn:flowNodeRef>
|
||||
<bpmn:flowNodeRef>Event_1vyxv42</bpmn:flowNodeRef>
|
||||
</bpmn:lane>
|
||||
<bpmn:lane id="Lane_0mx423x" name="Finance Team">
|
||||
<bpmn:flowNodeRef>Activity_02ldrj6</bpmn:flowNodeRef>
|
||||
</bpmn:lane>
|
||||
</bpmn:laneSet>
|
||||
<bpmn:startEvent id="StartEvent_1">
|
||||
<bpmn:outgoing>Flow_1rnrr8l</bpmn:outgoing>
|
||||
</bpmn:startEvent>
|
||||
<bpmn:sequenceFlow id="Flow_1rnrr8l" sourceRef="StartEvent_1" targetRef="Activity_16m8jvv" />
|
||||
<bpmn:sequenceFlow id="Flow_011ysja" sourceRef="Activity_16m8jvv" targetRef="Activity_1qrme8m" />
|
||||
<bpmn:scriptTask id="Activity_16m8jvv" name="Script Task #1">
|
||||
<bpmn:extensionElements>
|
||||
<spiffworkflow:instructionsForEndUser />
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:incoming>Flow_1rnrr8l</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_011ysja</bpmn:outgoing>
|
||||
<bpmn:script>x = 2</bpmn:script>
|
||||
</bpmn:scriptTask>
|
||||
<bpmn:scriptTask id="Activity_1qrme8m" name="Script Task #2">
|
||||
<bpmn:extensionElements>
|
||||
<spiffworkflow:instructionsForEndUser>I am Script Task {{x}}</spiffworkflow:instructionsForEndUser>
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:incoming>Flow_011ysja</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_1rab9xv</bpmn:outgoing>
|
||||
<bpmn:script>x = 2</bpmn:script>
|
||||
</bpmn:scriptTask>
|
||||
<bpmn:sequenceFlow id="Flow_1rab9xv" sourceRef="Activity_1qrme8m" targetRef="Activity_0bi0v5d" />
|
||||
<bpmn:manualTask id="Activity_0bi0v5d" name="Manual Task">
|
||||
<bpmn:extensionElements>
|
||||
<spiffworkflow:instructionsForEndUser>I am a manual task</spiffworkflow:instructionsForEndUser>
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:incoming>Flow_1rab9xv</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_1icul0s</bpmn:outgoing>
|
||||
</bpmn:manualTask>
|
||||
<bpmn:sequenceFlow id="Flow_1icul0s" sourceRef="Activity_0bi0v5d" targetRef="Activity_02ldrj6" />
|
||||
<bpmn:manualTask id="Activity_02ldrj6" name="Please Approve">
|
||||
<bpmn:extensionElements>
|
||||
<spiffworkflow:instructionsForEndUser>I am a manual task in another lane</spiffworkflow:instructionsForEndUser>
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:incoming>Flow_1icul0s</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_06qy6r3</bpmn:outgoing>
|
||||
</bpmn:manualTask>
|
||||
<bpmn:sequenceFlow id="Flow_06qy6r3" sourceRef="Activity_02ldrj6" targetRef="Event_1vyxv42" />
|
||||
<bpmn:endEvent id="Event_1vyxv42">
|
||||
<bpmn:extensionElements>
|
||||
<spiffworkflow:instructionsForEndUser>I am the end task</spiffworkflow:instructionsForEndUser>
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:incoming>Flow_06qy6r3</bpmn:incoming>
|
||||
</bpmn:endEvent>
|
||||
</bpmn:process>
|
||||
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
||||
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Collaboration_1ullb3f">
|
||||
<bpmndi:BPMNShape id="Participant_1mug4yn_di" bpmnElement="Participant_1mug4yn" isHorizontal="true">
|
||||
<dc:Bounds x="129" y="130" width="971" height="250" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Lane_0mx423x_di" bpmnElement="Lane_0mx423x" isHorizontal="true">
|
||||
<dc:Bounds x="159" y="255" width="941" height="125" />
|
||||
<bpmndi:BPMNLabel />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Lane_0518vyo_di" bpmnElement="Lane_0518vyo" isHorizontal="true">
|
||||
<dc:Bounds x="159" y="130" width="941" height="125" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
|
||||
<dc:Bounds x="179" y="172" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Activity_0lp6dyb_di" bpmnElement="Activity_16m8jvv">
|
||||
<dc:Bounds x="270" y="150" width="100" height="80" />
|
||||
<bpmndi:BPMNLabel />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Activity_1dlfog4_di" bpmnElement="Activity_1qrme8m">
|
||||
<dc:Bounds x="430" y="150" width="100" height="80" />
|
||||
<bpmndi:BPMNLabel />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Activity_0bpymtg_di" bpmnElement="Activity_0bi0v5d">
|
||||
<dc:Bounds x="580" y="150" width="100" height="80" />
|
||||
<bpmndi:BPMNLabel />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Activity_06oz8dg_di" bpmnElement="Activity_02ldrj6">
|
||||
<dc:Bounds x="730" y="280" width="100" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Event_1vyxv42_di" bpmnElement="Event_1vyxv42">
|
||||
<dc:Bounds x="872" y="172" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="Flow_1rnrr8l_di" bpmnElement="Flow_1rnrr8l">
|
||||
<di:waypoint x="215" y="190" />
|
||||
<di:waypoint x="270" y="190" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_011ysja_di" bpmnElement="Flow_011ysja">
|
||||
<di:waypoint x="370" y="190" />
|
||||
<di:waypoint x="430" y="190" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_1rab9xv_di" bpmnElement="Flow_1rab9xv">
|
||||
<di:waypoint x="530" y="190" />
|
||||
<di:waypoint x="580" y="190" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_1icul0s_di" bpmnElement="Flow_1icul0s">
|
||||
<di:waypoint x="680" y="190" />
|
||||
<di:waypoint x="705" y="190" />
|
||||
<di:waypoint x="705" y="320" />
|
||||
<di:waypoint x="730" y="320" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_06qy6r3_di" bpmnElement="Flow_06qy6r3">
|
||||
<di:waypoint x="830" y="320" />
|
||||
<di:waypoint x="851" y="320" />
|
||||
<di:waypoint x="851" y="190" />
|
||||
<di:waypoint x="872" y="190" />
|
||||
</bpmndi:BPMNEdge>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
</bpmn:definitions>
|
|
@ -9,6 +9,7 @@ from tests.spiffworkflow_backend.helpers.test_data import load_test_spec
|
|||
from spiffworkflow_backend import db
|
||||
from spiffworkflow_backend.models.human_task import HumanTaskModel
|
||||
from spiffworkflow_backend.models.user import UserModel
|
||||
from spiffworkflow_backend.routes.tasks_controller import _interstitial_stream
|
||||
|
||||
|
||||
class TestForGoodErrors(BaseTest):
|
||||
|
@ -20,6 +21,9 @@ class TestForGoodErrors(BaseTest):
|
|||
client: FlaskClient,
|
||||
with_super_admin_user: UserModel,
|
||||
) -> Any:
|
||||
# Call this to assure all engine-steps are fully processed before we search for human tasks.
|
||||
_interstitial_stream(process_instance_id)
|
||||
|
||||
"""Returns the next available user task for a given process instance, if possible."""
|
||||
human_tasks = (
|
||||
db.session.query(HumanTaskModel).filter(HumanTaskModel.process_instance_id == process_instance_id).all()
|
||||
|
|
|
@ -33,6 +33,7 @@ from spiffworkflow_backend.models.process_model import ProcessModelInfoSchema
|
|||
from spiffworkflow_backend.models.spec_reference import SpecReferenceCache
|
||||
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
|
||||
from spiffworkflow_backend.models.user import UserModel
|
||||
from spiffworkflow_backend.routes.tasks_controller import _interstitial_stream
|
||||
from spiffworkflow_backend.services.authorization_service import AuthorizationService
|
||||
from spiffworkflow_backend.services.file_system_service import FileSystemService
|
||||
from spiffworkflow_backend.services.process_caller_service import ProcessCallerService
|
||||
|
@ -1628,7 +1629,8 @@ class TestProcessApi(BaseTest):
|
|||
f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model_identifier)}/{process_instance_id}/run",
|
||||
headers=self.logged_in_headers(with_super_admin_user),
|
||||
)
|
||||
|
||||
# Call this to assure all engine-steps are fully processed.
|
||||
_interstitial_stream(process_instance_id)
|
||||
assert response.json is not None
|
||||
assert response.json["next_task"] is not None
|
||||
|
||||
|
@ -1653,6 +1655,91 @@ class TestProcessApi(BaseTest):
|
|||
"veryImportantFieldButOnlySometimes": {"ui:widget": "hidden"},
|
||||
}
|
||||
|
||||
def test_interstitial_page(
|
||||
self,
|
||||
app: Flask,
|
||||
client: FlaskClient,
|
||||
with_db_and_bpmn_file_cleanup: None,
|
||||
with_super_admin_user: UserModel,
|
||||
) -> None:
|
||||
process_group_id = "my_process_group"
|
||||
process_model_id = "interstitial"
|
||||
bpmn_file_location = "interstitial"
|
||||
# Assure we have someone in the finance team
|
||||
finance_user = self.find_or_create_user("testuser2")
|
||||
AuthorizationService.import_permissions_from_yaml_file()
|
||||
process_model_identifier = self.create_group_and_model_with_bpmn(
|
||||
client,
|
||||
with_super_admin_user,
|
||||
process_group_id=process_group_id,
|
||||
process_model_id=process_model_id,
|
||||
bpmn_file_location=bpmn_file_location,
|
||||
)
|
||||
headers = self.logged_in_headers(with_super_admin_user)
|
||||
response = self.create_process_instance_from_process_model_id_with_api(
|
||||
client, process_model_identifier, headers
|
||||
)
|
||||
assert response.json is not None
|
||||
process_instance_id = response.json["id"]
|
||||
|
||||
response = client.post(
|
||||
f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model_identifier)}/{process_instance_id}/run",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert response.json is not None
|
||||
assert response.json["next_task"] is not None
|
||||
assert response.json["next_task"]["state"] == "READY"
|
||||
assert response.json["next_task"]["title"] == "Script Task #2"
|
||||
|
||||
# Rather that call the API and deal with the Server Side Events, call the loop directly and covert it to
|
||||
# a list. It tests all of our code. No reason to test Flasks SSE support.
|
||||
stream_results = _interstitial_stream(process_instance_id)
|
||||
results = list(stream_results)
|
||||
# strip the "data:" prefix and convert remaining string to dict.
|
||||
json_results = list(map(lambda x: json.loads(x[5:]), results)) # type: ignore
|
||||
# There should be 2 results back -
|
||||
# the first script task should not be returned (it contains no end user instructions)
|
||||
# The second script task should produce rendered jinja text
|
||||
# The Manual Task should then return a message as well.
|
||||
assert len(results) == 2
|
||||
assert json_results[0]["state"] == "READY"
|
||||
assert json_results[0]["title"] == "Script Task #2"
|
||||
assert json_results[0]["properties"]["instructionsForEndUser"] == "I am Script Task 2"
|
||||
assert json_results[1]["state"] == "READY"
|
||||
assert json_results[1]["title"] == "Manual Task"
|
||||
|
||||
response = client.put(
|
||||
f"/v1.0/tasks/{process_instance_id}/{json_results[1]['id']}",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert response.json is not None
|
||||
|
||||
# we should now be on a task that does not belong to the original user, and the interstitial page should know this.
|
||||
results = list(_interstitial_stream(process_instance_id))
|
||||
json_results = list(map(lambda x: json.loads(x[5:]), results)) # type: ignore
|
||||
assert len(results) == 1
|
||||
assert json_results[0]["state"] == "READY"
|
||||
assert json_results[0]["can_complete"] is False
|
||||
assert json_results[0]["title"] == "Please Approve"
|
||||
assert json_results[0]["properties"]["instructionsForEndUser"] == "I am a manual task in another lane"
|
||||
|
||||
# Complete task as the finance user.
|
||||
response = client.put(
|
||||
f"/v1.0/tasks/{process_instance_id}/{json_results[0]['id']}",
|
||||
headers=self.logged_in_headers(finance_user),
|
||||
)
|
||||
|
||||
# We should now be on the end task with a valid message, even after loading it many times.
|
||||
list(_interstitial_stream(process_instance_id))
|
||||
list(_interstitial_stream(process_instance_id))
|
||||
results = list(_interstitial_stream(process_instance_id))
|
||||
json_results = list(map(lambda x: json.loads(x[5:]), results)) # type: ignore
|
||||
assert len(json_results) == 1
|
||||
assert json_results[0]["state"] == "COMPLETED"
|
||||
assert json_results[0]["properties"]["instructionsForEndUser"] == "I am the end task"
|
||||
|
||||
def test_process_instance_list_with_default_list(
|
||||
self,
|
||||
app: Flask,
|
||||
|
@ -2347,7 +2434,7 @@ class TestProcessApi(BaseTest):
|
|||
f"/v1.0/tasks/{process_instance_id}/{task_id}",
|
||||
headers=self.logged_in_headers(initiator_user),
|
||||
)
|
||||
assert response.status_code == 202
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.get(
|
||||
"/v1.0/tasks",
|
||||
|
|
|
@ -371,12 +371,13 @@ class TestProcessInstanceProcessor(BaseTest):
|
|||
)
|
||||
assert top_level_subprocess_script_spiff_task is not None
|
||||
processor.resume()
|
||||
processor.do_engine_steps(save=True)
|
||||
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||
|
||||
assert len(process_instance.active_human_tasks) == 1
|
||||
human_task_one = process_instance.active_human_tasks[0]
|
||||
spiff_manual_task = processor.bpmn_process_instance.get_task_from_id(UUID(human_task_one.task_id))
|
||||
ProcessInstanceService.complete_form_task(processor, spiff_manual_task, {}, initiator_user, human_task_one)
|
||||
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||
|
||||
assert process_instance.status == "complete"
|
||||
|
||||
|
@ -405,7 +406,7 @@ class TestProcessInstanceProcessor(BaseTest):
|
|||
process_model=process_model, user=initiator_user
|
||||
)
|
||||
processor = ProcessInstanceProcessor(process_instance)
|
||||
processor.do_engine_steps(save=True)
|
||||
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||
assert len(process_instance.active_human_tasks) == 1
|
||||
initial_human_task_id = process_instance.active_human_tasks[0].id
|
||||
|
||||
|
@ -424,9 +425,11 @@ class TestProcessInstanceProcessor(BaseTest):
|
|||
|
||||
process_instance = ProcessInstanceModel.query.filter_by(id=process_instance.id).first()
|
||||
processor = ProcessInstanceProcessor(process_instance)
|
||||
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||
human_task_one = process_instance.active_human_tasks[0]
|
||||
spiff_manual_task = processor.bpmn_process_instance.get_task_from_id(UUID(human_task_one.task_id))
|
||||
ProcessInstanceService.complete_form_task(processor, spiff_manual_task, {}, initiator_user, human_task_one)
|
||||
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||
human_task_one = process_instance.active_human_tasks[0]
|
||||
spiff_manual_task = processor.bpmn_process_instance.get_task_from_id(UUID(human_task_one.task_id))
|
||||
ProcessInstanceService.complete_form_task(processor, spiff_manual_task, {}, initiator_user, human_task_one)
|
||||
|
@ -434,6 +437,8 @@ class TestProcessInstanceProcessor(BaseTest):
|
|||
# recreate variables to ensure all bpmn json was recreated from scratch from the db
|
||||
process_instance_relookup = ProcessInstanceModel.query.filter_by(id=process_instance.id).first()
|
||||
processor_final = ProcessInstanceProcessor(process_instance_relookup)
|
||||
processor_final.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||
|
||||
assert process_instance_relookup.status == "complete"
|
||||
|
||||
data_set_1 = {"set_in_top_level_script": 1}
|
||||
|
@ -603,7 +608,7 @@ class TestProcessInstanceProcessor(BaseTest):
|
|||
)
|
||||
assert task_models_that_are_predicted_count == 0
|
||||
|
||||
assert processor.get_data() == data_set_7
|
||||
assert processor_final.get_data() == data_set_7
|
||||
|
||||
def test_does_not_recreate_human_tasks_on_multiple_saves(
|
||||
self,
|
||||
|
@ -690,7 +695,7 @@ class TestProcessInstanceProcessor(BaseTest):
|
|||
process_model=process_model, user=initiator_user
|
||||
)
|
||||
processor = ProcessInstanceProcessor(process_instance)
|
||||
processor.do_engine_steps(save=True)
|
||||
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||
|
||||
assert len(process_instance.active_human_tasks) == 1
|
||||
assert len(process_instance.human_tasks) == 1
|
||||
|
@ -700,6 +705,7 @@ class TestProcessInstanceProcessor(BaseTest):
|
|||
ProcessInstanceService.complete_form_task(processor, spiff_task, {}, initiator_user, human_task_one)
|
||||
|
||||
processor = ProcessInstanceProcessor(process_instance)
|
||||
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||
assert len(process_instance.active_human_tasks) == 1
|
||||
assert len(process_instance.human_tasks) == 2
|
||||
human_task_two = process_instance.active_human_tasks[0]
|
||||
|
@ -708,6 +714,7 @@ class TestProcessInstanceProcessor(BaseTest):
|
|||
|
||||
# ensure this does not raise a KeyError
|
||||
processor = ProcessInstanceProcessor(process_instance)
|
||||
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||
assert len(process_instance.active_human_tasks) == 1
|
||||
assert len(process_instance.human_tasks) == 3
|
||||
human_task_three = process_instance.active_human_tasks[0]
|
||||
|
|
|
@ -43,7 +43,7 @@ class TestProcessModel(BaseTest):
|
|||
|
||||
process_instance = self.create_process_instance_from_process_model(process_model)
|
||||
processor = ProcessInstanceProcessor(process_instance)
|
||||
processor.do_engine_steps(save=True)
|
||||
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||
assert process_instance.status == "complete"
|
||||
|
||||
def test_can_run_process_model_with_call_activities_when_not_in_same_directory(
|
||||
|
@ -74,7 +74,7 @@ class TestProcessModel(BaseTest):
|
|||
)
|
||||
process_instance = self.create_process_instance_from_process_model(process_model)
|
||||
processor = ProcessInstanceProcessor(process_instance)
|
||||
processor.do_engine_steps(save=True)
|
||||
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||
assert process_instance.status == "complete"
|
||||
|
||||
def test_can_run_process_model_with_call_activities_when_process_identifier_is_not_in_database(
|
||||
|
@ -110,7 +110,7 @@ class TestProcessModel(BaseTest):
|
|||
db.session.query(SpecReferenceCache).delete()
|
||||
db.session.commit()
|
||||
processor = ProcessInstanceProcessor(process_instance)
|
||||
processor.do_engine_steps(save=True)
|
||||
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
|
||||
assert process_instance.status == "complete"
|
||||
|
||||
def test_extract_metadata(
|
||||
|
|
|
@ -21,6 +21,12 @@ module.exports = {
|
|||
importSource: '@bpmn-io/properties-panel/preact',
|
||||
runtime: 'automatic',
|
||||
},
|
||||
'@babel/plugin-proposal-class-properties',
|
||||
{ loose: true },
|
||||
'@babel/plugin-proposal-private-methods',
|
||||
{ loose: true },
|
||||
'@babel/plugin-proposal-private-property-in-object',
|
||||
{ loose: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
"@carbon/styles": "^1.16.0",
|
||||
"@casl/ability": "^6.3.2",
|
||||
"@casl/react": "^3.1.0",
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@monaco-editor/react": "^4.4.5",
|
||||
"@mui/material": "^5.10.14",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
|
@ -4486,6 +4487,11 @@
|
|||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/fetch-event-source": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz",
|
||||
"integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="
|
||||
},
|
||||
"node_modules/@monaco-editor/loader": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.3.3.tgz",
|
||||
|
@ -8337,7 +8343,7 @@
|
|||
},
|
||||
"node_modules/bpmn-js-spiffworkflow": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#69135655f8a5282bcdaef82705c3d522ef5b4464",
|
||||
"resolved": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#2214ac6432dec77cb2d1362615e6320bfea7df1f",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.4",
|
||||
|
@ -35363,6 +35369,11 @@
|
|||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"@microsoft/fetch-event-source": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz",
|
||||
"integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="
|
||||
},
|
||||
"@monaco-editor/loader": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.3.3.tgz",
|
||||
|
@ -38204,7 +38215,7 @@
|
|||
}
|
||||
},
|
||||
"bpmn-js-spiffworkflow": {
|
||||
"version": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#69135655f8a5282bcdaef82705c3d522ef5b4464",
|
||||
"version": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#2214ac6432dec77cb2d1362615e6320bfea7df1f",
|
||||
"from": "bpmn-js-spiffworkflow@sartography/bpmn-js-spiffworkflow#main",
|
||||
"requires": {
|
||||
"inherits": "^2.0.4",
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
"@carbon/styles": "^1.16.0",
|
||||
"@casl/ability": "^6.3.2",
|
||||
"@casl/react": "^3.1.0",
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@monaco-editor/react": "^4.4.5",
|
||||
"@mui/material": "^5.10.14",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.7 KiB |
|
@ -8,6 +8,7 @@ import NavigationBar from './components/NavigationBar';
|
|||
import HomePageRoutes from './routes/HomePageRoutes';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import AdminRoutes from './routes/AdminRoutes';
|
||||
import ProcessRoutes from './routes/ProcessRoutes';
|
||||
|
||||
import { AbilityContext } from './contexts/Can';
|
||||
import UserService from './services/UserService';
|
||||
|
@ -35,6 +36,7 @@ export default function App() {
|
|||
<Routes>
|
||||
<Route path="/*" element={<HomePageRoutes />} />
|
||||
<Route path="/tasks/*" element={<HomePageRoutes />} />
|
||||
<Route path="/process/*" element={<ProcessRoutes />} />
|
||||
<Route path="/admin/*" element={<AdminRoutes />} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
// @ts-ignore
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
|
||||
export default function InstructionsForEndUser({ task }: any) {
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
let instructions =
|
||||
'There is no additional instructions or information for this task.';
|
||||
const { properties } = task;
|
||||
const { instructionsForEndUser } = properties;
|
||||
if (instructionsForEndUser) {
|
||||
instructions = instructionsForEndUser;
|
||||
}
|
||||
return (
|
||||
<div className="markdown">
|
||||
{/*
|
||||
https://www.npmjs.com/package/@uiw/react-md-editor switches to dark mode by default by respecting @media (prefers-color-scheme: dark)
|
||||
This makes it look like our site is broken, so until the rest of the site supports dark mode, turn off dark mode for this component.
|
||||
*/}
|
||||
<div data-color-mode="light">
|
||||
<MDEditor.Markdown source={instructions} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -10,6 +10,7 @@ export default function MyCompletedInstances() {
|
|||
perPageOptions={[2, 5, 25]}
|
||||
reportIdentifier="system_report_completed_instances_initiated_by_me"
|
||||
showReports={false}
|
||||
showActionsColumn
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1458,8 +1458,9 @@ export default function ProcessInstanceListTable({
|
|||
});
|
||||
if (showActionsColumn) {
|
||||
let buttonElement = null;
|
||||
if (row.task_id) {
|
||||
const taskUrl = `/tasks/${row.id}/${row.task_id}`;
|
||||
const interstitialUrl = `/process/${modifyProcessIdentifierForPathParam(
|
||||
row.process_model_identifier
|
||||
)}/${row.id}/interstitial`;
|
||||
const regex = new RegExp(`\\b(${preferredUsername}|${userEmail})\\b`);
|
||||
let hasAccessToCompleteTask = false;
|
||||
if (
|
||||
|
@ -1468,18 +1469,17 @@ export default function ProcessInstanceListTable({
|
|||
) {
|
||||
hasAccessToCompleteTask = true;
|
||||
}
|
||||
|
||||
buttonElement = (
|
||||
<Button
|
||||
variant="primary"
|
||||
href={taskUrl}
|
||||
hidden={row.status === 'suspended'}
|
||||
disabled={!hasAccessToCompleteTask}
|
||||
kind={
|
||||
hasAccessToCompleteTask && row.task_id ? 'secondary' : 'tertiary'
|
||||
}
|
||||
href={interstitialUrl}
|
||||
>
|
||||
Go
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
currentRow.push(<td>{buttonElement}</td>);
|
||||
}
|
||||
|
||||
|
|
|
@ -42,9 +42,7 @@ export interface Task {
|
|||
id: number;
|
||||
guid: string;
|
||||
bpmn_identifier: string;
|
||||
|
||||
bpmn_name?: string;
|
||||
|
||||
bpmn_process_direct_parent_guid: string;
|
||||
bpmn_process_definition_identifier: string;
|
||||
data: any;
|
||||
|
@ -59,7 +57,7 @@ export interface Task {
|
|||
export interface ProcessInstanceTask {
|
||||
id: string;
|
||||
task_id: string;
|
||||
|
||||
can_complete: boolean;
|
||||
calling_subprocess_task_id: string;
|
||||
created_at_in_seconds: number;
|
||||
current_user_is_potential_owner: number;
|
||||
|
|
|
@ -33,6 +33,7 @@ export default function CompletedInstances() {
|
|||
showReports={false}
|
||||
textToShowIfEmpty="This group has no completed instances at this time."
|
||||
additionalParams={`user_group_identifier=${userGroup}`}
|
||||
showActionsColumn
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
@ -61,6 +62,7 @@ export default function CompletedInstances() {
|
|||
textToShowIfEmpty="You have no completed instances at this time."
|
||||
paginationClassName="with-large-bottom-margin"
|
||||
autoReload
|
||||
showActionsColumn
|
||||
/>
|
||||
<h2
|
||||
title={withTasksCompletedByMeTitleText}
|
||||
|
@ -76,6 +78,7 @@ export default function CompletedInstances() {
|
|||
showReports={false}
|
||||
textToShowIfEmpty="You have no completed instances at this time."
|
||||
paginationClassName="with-large-bottom-margin"
|
||||
showActionsColumn
|
||||
/>
|
||||
{groupTableComponents()}
|
||||
</>
|
||||
|
|
|
@ -7,6 +7,7 @@ import MyTasks from './MyTasks';
|
|||
import CompletedInstances from './CompletedInstances';
|
||||
import CreateNewInstance from './CreateNewInstance';
|
||||
import InProgressInstances from './InProgressInstances';
|
||||
import ProcessInterstitial from './ProcessInterstitial';
|
||||
|
||||
export default function HomePageRoutes() {
|
||||
const location = useLocation();
|
||||
|
@ -55,6 +56,10 @@ export default function HomePageRoutes() {
|
|||
<Route path="my-tasks" element={<MyTasks />} />
|
||||
<Route path=":process_instance_id/:task_id" element={<TaskShow />} />
|
||||
<Route path="grouped" element={<InProgressInstances />} />
|
||||
<Route
|
||||
path="process/:process_instance_id/interstitial"
|
||||
element={<ProcessInterstitial />}
|
||||
/>
|
||||
<Route path="completed-instances" element={<CompletedInstances />} />
|
||||
<Route path="create-new-instance" element={<CreateNewInstance />} />
|
||||
</Routes>
|
||||
|
|
|
@ -51,7 +51,7 @@ export default function ProcessInstanceList({ variant }: OwnProps) {
|
|||
<br />
|
||||
{processInstanceBreadcrumbElement()}
|
||||
{processInstanceTitleElement()}
|
||||
<ProcessInstanceListTable variant={variant} />
|
||||
<ProcessInstanceListTable variant={variant} showActionsColumn />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,161 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
||||
// @ts-ignore
|
||||
import { Loading, Grid, Column } from '@carbon/react';
|
||||
import { BACKEND_BASE_URL } from '../config';
|
||||
import { getBasicHeaders } from '../services/HttpService';
|
||||
|
||||
// @ts-ignore
|
||||
import InstructionsForEndUser from '../components/InstructionsForEndUser';
|
||||
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
|
||||
import { ProcessInstanceTask } from '../interfaces';
|
||||
|
||||
export default function ProcessInterstitial() {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [lastTask, setLastTask] = useState<any>(null);
|
||||
const [state, setState] = useState<string>('RUNNING');
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const userTasks = useMemo(() => {
|
||||
return ['User Task', 'Manual Task'];
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEventSource(
|
||||
`${BACKEND_BASE_URL}/tasks/${params.process_instance_id}`,
|
||||
{
|
||||
headers: getBasicHeaders(),
|
||||
onmessage(ev) {
|
||||
console.log(data, ev.data);
|
||||
const task = JSON.parse(ev.data);
|
||||
setData((prevData) => [...prevData, task]);
|
||||
setLastTask(task);
|
||||
},
|
||||
onclose() {
|
||||
setState('CLOSED');
|
||||
},
|
||||
}
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // it is critical to only run this once.
|
||||
|
||||
const shouldRedirect = useCallback(
|
||||
(myTask: ProcessInstanceTask): boolean => {
|
||||
return myTask && myTask.can_complete && userTasks.includes(myTask.type);
|
||||
},
|
||||
[userTasks]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Added this seperate use effect so that the timer interval will be cleared if
|
||||
// we end up redirecting back to the TaskShow page.
|
||||
if (shouldRedirect(lastTask)) {
|
||||
setState('REDIRECTING');
|
||||
lastTask.properties.instructionsForEndUser = '';
|
||||
const timerId = setInterval(() => {
|
||||
navigate(`/tasks/${lastTask.process_instance_id}/${lastTask.id}`);
|
||||
}, 2000);
|
||||
return () => clearInterval(timerId);
|
||||
}
|
||||
return undefined;
|
||||
}, [lastTask, navigate, userTasks, shouldRedirect]);
|
||||
|
||||
const getStatus = (): string => {
|
||||
if (!lastTask.can_complete && userTasks.includes(lastTask.type)) {
|
||||
return 'LOCKED';
|
||||
}
|
||||
if (state === 'CLOSED') {
|
||||
return lastTask.state;
|
||||
}
|
||||
console.log('The State is: ', state);
|
||||
return state;
|
||||
};
|
||||
|
||||
const getStatusImage = () => {
|
||||
switch (getStatus()) {
|
||||
case 'RUNNING':
|
||||
return (
|
||||
<Loading description="Active loading indicator" withOverlay={false} />
|
||||
);
|
||||
case 'LOCKED':
|
||||
return <img src="/interstitial/locked.png" alt="Locked" />;
|
||||
case 'REDIRECTING':
|
||||
return <img src="/interstitial/redirect.png" alt="Redirecting ...." />;
|
||||
case 'WAITING':
|
||||
return <img src="/interstitial/waiting.png" alt="Waiting ...." />;
|
||||
case 'COMPLETED':
|
||||
return <img src="/interstitial/completed.png" alt="Completed" />;
|
||||
default:
|
||||
return <br />;
|
||||
}
|
||||
};
|
||||
|
||||
function capitalize(str: string): string {
|
||||
console.log('Capitalizing: ', str);
|
||||
if (str && str.length > 0) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const userMessage = (myTask: ProcessInstanceTask) => {
|
||||
if (!myTask.can_complete && userTasks.includes(myTask.type)) {
|
||||
return <div>This next task must be completed by a different person.</div>;
|
||||
}
|
||||
if (shouldRedirect(myTask)) {
|
||||
return <div>Redirecting you to the next task now ...</div>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<InstructionsForEndUser task={myTask} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** In the event there is no task information and the connection closed,
|
||||
* redirect to the home page. */
|
||||
if (state === 'closed' && lastTask === null) {
|
||||
navigate(`/tasks`);
|
||||
}
|
||||
if (lastTask) {
|
||||
return (
|
||||
<>
|
||||
<ProcessBreadcrumb
|
||||
hotCrumbs={[
|
||||
['Process Groups', '/admin'],
|
||||
{
|
||||
entityToExplode: lastTask.process_model_identifier,
|
||||
entityType: 'process-model-id',
|
||||
linkLastItem: true,
|
||||
},
|
||||
[`Process Instance Id: ${lastTask.process_instance_id}`],
|
||||
]}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{getStatusImage()}
|
||||
<div>
|
||||
<h1 style={{ marginBottom: '0em' }}>
|
||||
{lastTask.process_model_display_name}:{' '}
|
||||
{lastTask.process_instance_id}
|
||||
</h1>
|
||||
<div>Status: {capitalize(getStatus())}</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
{data.map((d) => (
|
||||
<Grid fullWidth style={{ marginBottom: '1em' }}>
|
||||
<Column md={2} lg={4} sm={2}>
|
||||
Task: <em>{d.title}</em>
|
||||
</Column>
|
||||
<Column md={6} lg={8} sm={4}>
|
||||
{userMessage(d)}
|
||||
</Column>
|
||||
</Grid>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { Route, Routes } from 'react-router-dom';
|
||||
// @ts-ignore
|
||||
import ProcessInterstitial from './ProcessInterstitial';
|
||||
|
||||
export default function ProcessRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path=":process_model_identifier/:process_instance_id/interstitial"
|
||||
element={<ProcessInterstitial />}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
|
@ -13,7 +13,6 @@ import {
|
|||
ButtonSet,
|
||||
} from '@carbon/react';
|
||||
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
// eslint-disable-next-line import/no-named-as-default
|
||||
import Form from '../themes/carbon';
|
||||
import HttpService from '../services/HttpService';
|
||||
|
@ -21,6 +20,7 @@ import useAPIError from '../hooks/UseApiError';
|
|||
import { modifyProcessIdentifierForPathParam } from '../helpers';
|
||||
import { ProcessInstanceTask } from '../interfaces';
|
||||
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
|
||||
import InstructionsForEndUser from '../components/InstructionsForEndUser';
|
||||
|
||||
// TODO: move this somewhere else
|
||||
function TypeAheadWidget({
|
||||
|
@ -89,13 +89,6 @@ function TypeAheadWidget({
|
|||
);
|
||||
}
|
||||
|
||||
class UnexpectedHumanTaskType extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'UnexpectedHumanTaskType';
|
||||
}
|
||||
}
|
||||
|
||||
enum FormSubmitType {
|
||||
Default,
|
||||
Draft,
|
||||
|
@ -107,19 +100,28 @@ export default function TaskShow() {
|
|||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
|
||||
// save current form data so that we can avoid validations in certain situations
|
||||
const [currentFormObject, setCurrentFormObject] = useState<any>({});
|
||||
|
||||
const { addError, removeError } = useAPIError();
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
const supportedHumanTaskTypes = ['User Task', 'Manual Task'];
|
||||
const navigateToInterstitial = (myTask: ProcessInstanceTask) => {
|
||||
navigate(
|
||||
`/process/${modifyProcessIdentifierForPathParam(
|
||||
myTask.process_model_identifier
|
||||
)}/${myTask.process_instance_id}/interstitial`
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const processResult = (result: ProcessInstanceTask) => {
|
||||
setTask(result);
|
||||
setDisabled(false);
|
||||
|
||||
if (!result.can_complete) {
|
||||
navigateToInterstitial(result);
|
||||
}
|
||||
|
||||
/* Disable call to load previous tasks -- do not display menu.
|
||||
const url = `/v1.0/process-instances/for-me/${modifyProcessIdentifierForPathParam(
|
||||
result.process_model_identifier
|
||||
|
@ -156,7 +158,11 @@ export default function TaskShow() {
|
|||
if (result.ok) {
|
||||
navigate(`/tasks`);
|
||||
} else if (result.process_instance_id) {
|
||||
if (result.can_complete) {
|
||||
navigate(`/tasks/${result.process_instance_id}/${result.id}`);
|
||||
} else {
|
||||
navigateToInterstitial(result);
|
||||
}
|
||||
} else {
|
||||
addError(result);
|
||||
}
|
||||
|
@ -342,10 +348,6 @@ export default function TaskShow() {
|
|||
Save as draft
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
throw new UnexpectedHumanTaskType(
|
||||
`Invalid task type given: ${task.type}. Only supported types: ${supportedHumanTaskTypes}`
|
||||
);
|
||||
}
|
||||
reactFragmentToHideSubmitButton = (
|
||||
<ButtonSet>
|
||||
|
@ -386,27 +388,6 @@ export default function TaskShow() {
|
|||
);
|
||||
};
|
||||
|
||||
const instructionsElement = () => {
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
let instructions = '';
|
||||
if (task.properties.instructionsForEndUser) {
|
||||
instructions = task.properties.instructionsForEndUser;
|
||||
}
|
||||
return (
|
||||
<div className="markdown">
|
||||
{/*
|
||||
https://www.npmjs.com/package/@uiw/react-md-editor switches to dark mode by default by respecting @media (prefers-color-scheme: dark)
|
||||
This makes it look like our site is broken, so until the rest of the site supports dark mode, turn off dark mode for this component.
|
||||
*/}
|
||||
<div data-color-mode="light">
|
||||
<MDEditor.Markdown source={instructions} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (task) {
|
||||
let statusString = '';
|
||||
if (task.state !== 'READY') {
|
||||
|
@ -430,7 +411,7 @@ export default function TaskShow() {
|
|||
<h3>
|
||||
Task: {task.title} ({task.process_model_display_name}){statusString}
|
||||
</h3>
|
||||
{instructionsElement()}
|
||||
<InstructionsForEndUser task={task} />
|
||||
{formElement()}
|
||||
</main>
|
||||
);
|
||||
|
|
|
@ -8,7 +8,7 @@ const HttpMethods = {
|
|||
DELETE: 'DELETE',
|
||||
};
|
||||
|
||||
const getBasicHeaders = (): object => {
|
||||
export const getBasicHeaders = (): Record<string, string> => {
|
||||
if (UserService.isLoggedIn()) {
|
||||
return {
|
||||
Authorization: `Bearer ${UserService.getAccessToken()}`,
|
||||
|
|
Loading…
Reference in New Issue