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/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 ;
+ case 'REDIRECTING':
+ return ;
+ case 'WAITING':
+ return ;
+ case 'COMPLETED':
+ return ;
+ 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 (
+ <>
+
+
+
+
+ {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() {