diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index e4ef39a09..807b0c14a 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -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: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py index 3a806fa2f..8c308e03f 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py @@ -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 diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/acceptance_tests.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/acceptance_tests.yml index 29d3c9c04..0382f389b 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/acceptance_tests.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/acceptance_tests.yml @@ -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] diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/human_task.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/human_task.py index 6a26e5c55..bca4cb81e 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/human_task.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/human_task.py @@ -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"): diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/task.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/task.py index bc2fcff31..c9bf311b4 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/task.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/task.py @@ -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, diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py index b8217fa33..5b74ba038 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py @@ -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, diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py index 2a4ffc1de..b5dc62070 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py @@ -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,12 +279,16 @@ 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) - spiff_task = _get_spiff_task_from_process_instance(task_guid, process_instance, processor=processor) + 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 if "properties" in 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( diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py index 5e63fd099..224e20fec 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py @@ -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]: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py index 3a6111d94..db3e62e1d 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py @@ -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"), diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/workflow_execution_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/workflow_execution_service.py index c8e6dd001..ddf75de4f 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/workflow_execution_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/workflow_execution_service.py @@ -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( - [ - t - for t in bpmn_process_instance.get_tasks(TaskState.READY) - if bpmn_process_instance._is_engine_task(t.task_spec) - ] - ) +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 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) diff --git a/spiffworkflow-backend/tests/data/interstitial/interstitial.bpmn b/spiffworkflow-backend/tests/data/interstitial/interstitial.bpmn new file mode 100644 index 000000000..576f49e9d --- /dev/null +++ b/spiffworkflow-backend/tests/data/interstitial/interstitial.bpmn @@ -0,0 +1,123 @@ + + + + + + + + + StartEvent_1 + Activity_16m8jvv + Activity_1qrme8m + Activity_0bi0v5d + Event_1vyxv42 + + + Activity_02ldrj6 + + + + Flow_1rnrr8l + + + + + + + + Flow_1rnrr8l + Flow_011ysja + x = 2 + + + + I am Script Task {{x}} + + Flow_011ysja + Flow_1rab9xv + x = 2 + + + + + I am a manual task + + Flow_1rab9xv + Flow_1icul0s + + + + + I am a manual task in another lane + + Flow_1icul0s + Flow_06qy6r3 + + + + + I am the end task + + Flow_06qy6r3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_for_good_errors.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_for_good_errors.py index 90cbac876..f0a9e973d 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_for_good_errors.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_for_good_errors.py @@ -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() diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py index 0c3488ea1..51a584c39 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py @@ -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", diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_processor.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_processor.py index 0dc65e9d0..2268e0e50 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_processor.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_processor.py @@ -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] diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model.py index 40a9c96ee..d1b744848 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model.py @@ -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( diff --git a/spiffworkflow-frontend/craco.config.js b/spiffworkflow-frontend/craco.config.js index 9d12ee47c..51c3e1439 100644 --- a/spiffworkflow-frontend/craco.config.js +++ b/spiffworkflow-frontend/craco.config.js @@ -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 }, ], }, }, diff --git a/spiffworkflow-frontend/package-lock.json b/spiffworkflow-frontend/package-lock.json index 70463c7a0..f8ed39866 100644 --- a/spiffworkflow-frontend/package-lock.json +++ b/spiffworkflow-frontend/package-lock.json @@ -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", diff --git a/spiffworkflow-frontend/package.json b/spiffworkflow-frontend/package.json index 11193434f..ee23a6864 100644 --- a/spiffworkflow-frontend/package.json +++ b/spiffworkflow-frontend/package.json @@ -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", diff --git a/spiffworkflow-frontend/public/interstitial/completed.png b/spiffworkflow-frontend/public/interstitial/completed.png new file mode 100644 index 000000000..e5cf35100 Binary files /dev/null and b/spiffworkflow-frontend/public/interstitial/completed.png differ diff --git a/spiffworkflow-frontend/public/interstitial/locked.png b/spiffworkflow-frontend/public/interstitial/locked.png new file mode 100644 index 000000000..fd39fb0d1 Binary files /dev/null and b/spiffworkflow-frontend/public/interstitial/locked.png differ diff --git a/spiffworkflow-frontend/public/interstitial/redirect.png b/spiffworkflow-frontend/public/interstitial/redirect.png new file mode 100644 index 000000000..b9de365bc Binary files /dev/null and b/spiffworkflow-frontend/public/interstitial/redirect.png differ diff --git a/spiffworkflow-frontend/public/interstitial/waiting.png b/spiffworkflow-frontend/public/interstitial/waiting.png new file mode 100644 index 000000000..e646611d4 Binary files /dev/null and b/spiffworkflow-frontend/public/interstitial/waiting.png differ diff --git a/spiffworkflow-frontend/src/App.tsx b/spiffworkflow-frontend/src/App.tsx index ddaa68ae2..8031802c0 100644 --- a/spiffworkflow-frontend/src/App.tsx +++ b/spiffworkflow-frontend/src/App.tsx @@ -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() { } /> } /> + } /> } /> diff --git a/spiffworkflow-frontend/src/components/InstructionsForEndUser.tsx b/spiffworkflow-frontend/src/components/InstructionsForEndUser.tsx new file mode 100644 index 000000000..921d52a61 --- /dev/null +++ b/spiffworkflow-frontend/src/components/InstructionsForEndUser.tsx @@ -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 ( +
+ {/* + 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. + */} +
+ +
+
+ ); +} diff --git a/spiffworkflow-frontend/src/components/MyCompletedInstances.tsx b/spiffworkflow-frontend/src/components/MyCompletedInstances.tsx index 47042e910..f0f9705e9 100644 --- a/spiffworkflow-frontend/src/components/MyCompletedInstances.tsx +++ b/spiffworkflow-frontend/src/components/MyCompletedInstances.tsx @@ -10,6 +10,7 @@ export default function MyCompletedInstances() { perPageOptions={[2, 5, 25]} reportIdentifier="system_report_completed_instances_initiated_by_me" showReports={false} + showActionsColumn /> ); } diff --git a/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx b/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx index fe335e539..6a1562af6 100644 --- a/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx +++ b/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx @@ -1458,28 +1458,28 @@ export default function ProcessInstanceListTable({ }); if (showActionsColumn) { let buttonElement = null; - if (row.task_id) { - const taskUrl = `/tasks/${row.id}/${row.task_id}`; - const regex = new RegExp(`\\b(${preferredUsername}|${userEmail})\\b`); - let hasAccessToCompleteTask = false; - if ( - canCompleteAllTasks || - (row.potential_owner_usernames || '').match(regex) - ) { - hasAccessToCompleteTask = true; - } - buttonElement = ( - - ); + const interstitialUrl = `/process/${modifyProcessIdentifierForPathParam( + row.process_model_identifier + )}/${row.id}/interstitial`; + const regex = new RegExp(`\\b(${preferredUsername}|${userEmail})\\b`); + let hasAccessToCompleteTask = false; + if ( + canCompleteAllTasks || + (row.potential_owner_usernames || '').match(regex) + ) { + hasAccessToCompleteTask = true; } + buttonElement = ( + + ); currentRow.push({buttonElement}); } diff --git a/spiffworkflow-frontend/src/interfaces.ts b/spiffworkflow-frontend/src/interfaces.ts index bdbc9251e..e482bede7 100644 --- a/spiffworkflow-frontend/src/interfaces.ts +++ b/spiffworkflow-frontend/src/interfaces.ts @@ -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; diff --git a/spiffworkflow-frontend/src/routes/CompletedInstances.tsx b/spiffworkflow-frontend/src/routes/CompletedInstances.tsx index 78f73e92f..6d43020df 100644 --- a/spiffworkflow-frontend/src/routes/CompletedInstances.tsx +++ b/spiffworkflow-frontend/src/routes/CompletedInstances.tsx @@ -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 />

{groupTableComponents()} diff --git a/spiffworkflow-frontend/src/routes/HomePageRoutes.tsx b/spiffworkflow-frontend/src/routes/HomePageRoutes.tsx index e425c1258..eee6ad377 100644 --- a/spiffworkflow-frontend/src/routes/HomePageRoutes.tsx +++ b/spiffworkflow-frontend/src/routes/HomePageRoutes.tsx @@ -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() { } /> } /> } /> + } + /> } /> } /> diff --git a/spiffworkflow-frontend/src/routes/ProcessInstanceList.tsx b/spiffworkflow-frontend/src/routes/ProcessInstanceList.tsx index ca69eb7d3..d46778434 100644 --- a/spiffworkflow-frontend/src/routes/ProcessInstanceList.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessInstanceList.tsx @@ -51,7 +51,7 @@ export default function ProcessInstanceList({ variant }: OwnProps) {
{processInstanceBreadcrumbElement()} {processInstanceTitleElement()} - + ); } diff --git a/spiffworkflow-frontend/src/routes/ProcessInterstitial.tsx b/spiffworkflow-frontend/src/routes/ProcessInterstitial.tsx new file mode 100644 index 000000000..b465cf117 --- /dev/null +++ b/spiffworkflow-frontend/src/routes/ProcessInterstitial.tsx @@ -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([]); + const [lastTask, setLastTask] = useState(null); + const [state, setState] = useState('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 ( + + ); + case 'LOCKED': + return Locked; + case 'REDIRECTING': + return Redirecting ....; + case 'WAITING': + return Waiting ....; + case 'COMPLETED': + return Completed; + default: + return
; + } + }; + + 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
This next task must be completed by a different person.
; + } + if (shouldRedirect(myTask)) { + return
Redirecting you to the next task now ...
; + } + return ( +
+ +
+ ); + }; + + /** 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 ( + <> + +
+ {getStatusImage()} +
+

+ {lastTask.process_model_display_name}:{' '} + {lastTask.process_instance_id} +

+
Status: {capitalize(getStatus())}
+
+
+
+
+ {data.map((d) => ( + + + Task: {d.title} + + + {userMessage(d)} + + + ))} + + ); + } + return null; +} diff --git a/spiffworkflow-frontend/src/routes/ProcessRoutes.tsx b/spiffworkflow-frontend/src/routes/ProcessRoutes.tsx new file mode 100644 index 000000000..c4ad03524 --- /dev/null +++ b/spiffworkflow-frontend/src/routes/ProcessRoutes.tsx @@ -0,0 +1,14 @@ +import { Route, Routes } from 'react-router-dom'; +// @ts-ignore +import ProcessInterstitial from './ProcessInterstitial'; + +export default function ProcessRoutes() { + return ( + + } + /> + + ); +} diff --git a/spiffworkflow-frontend/src/routes/TaskShow.tsx b/spiffworkflow-frontend/src/routes/TaskShow.tsx index 0f7811dee..56ea9bc22 100644 --- a/spiffworkflow-frontend/src/routes/TaskShow.tsx +++ b/spiffworkflow-frontend/src/routes/TaskShow.tsx @@ -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({}); 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) { - navigate(`/tasks/${result.process_instance_id}/${result.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 ); - } else { - throw new UnexpectedHumanTaskType( - `Invalid task type given: ${task.type}. Only supported types: ${supportedHumanTaskTypes}` - ); } reactFragmentToHideSubmitButton = ( @@ -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 ( -
- {/* - 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. - */} -
- -
-
- ); - }; - if (task) { let statusString = ''; if (task.state !== 'READY') { @@ -430,7 +411,7 @@ export default function TaskShow() {

Task: {task.title} ({task.process_model_display_name}){statusString}

- {instructionsElement()} + {formElement()} ); diff --git a/spiffworkflow-frontend/src/services/HttpService.ts b/spiffworkflow-frontend/src/services/HttpService.ts index ed2e51494..83d71af8c 100644 --- a/spiffworkflow-frontend/src/services/HttpService.ts +++ b/spiffworkflow-frontend/src/services/HttpService.ts @@ -8,7 +8,7 @@ const HttpMethods = { DELETE: 'DELETE', }; -const getBasicHeaders = (): object => { +export const getBasicHeaders = (): Record => { if (UserService.isLoggedIn()) { return { Authorization: `Bearer ${UserService.getAccessToken()}`,