Merge pull request #221 from sartography/feature/interstitial

Feature/interstitial
This commit is contained in:
Dan Funk 2023-04-21 09:28:41 -04:00 committed by GitHub
commit 3dca7d2ed4
34 changed files with 696 additions and 112 deletions

View File

@ -1842,6 +1842,27 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/ServiceTask" $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}: /tasks/{process_instance_id}/{task_guid}:
parameters: parameters:

View File

@ -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 = 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 # this is only used in CI. use SPIFFWORKFLOW_BACKEND_DATABASE_URI instead for real configuration

View File

@ -1,3 +1,10 @@
users:
ciadmin1:
service: local_open_id
email: ciadmin1@spiffworkflow.org
password: ciadmin1
preferred_username: ciadmin1
groups: groups:
admin: admin:
users: [ciadmin1@spiffworkflow.org] users: [ciadmin1@spiffworkflow.org]

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from flask import g
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@ -68,12 +69,19 @@ class HumanTaskModel(SpiffworkflowBaseDBModel):
@classmethod @classmethod
def to_task(cls, task: HumanTaskModel) -> Task: def to_task(cls, task: HumanTaskModel) -> Task:
"""To_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( new_task = Task(
task.task_id, task.task_id,
task.task_name, task.task_name,
task.task_title, task.task_title,
task.task_type, task.task_type,
task.task_status, task.task_status,
can_complete,
process_instance_id=task.process_instance_id, process_instance_id=task.process_instance_id,
) )
if hasattr(task, "process_model_display_name"): if hasattr(task, "process_model_display_name"):

View File

@ -88,6 +88,7 @@ class Task:
title: str, title: str,
type: str, type: str,
state: str, state: str,
can_complete: bool,
lane: Union[str, None] = None, lane: Union[str, None] = None,
form: None = None, form: None = None,
documentation: str = "", documentation: str = "",
@ -116,6 +117,7 @@ class Task:
self.title = title self.title = title
self.type = type self.type = type
self.state = state self.state = state
self.can_complete = can_complete
self.form = form self.form = form
self.documentation = documentation self.documentation = documentation
self.lane = lane self.lane = lane
@ -160,6 +162,7 @@ class Task:
"type": self.type, "type": self.type,
"state": self.state, "state": self.state,
"lane": self.lane, "lane": self.lane,
"can_complete": self.can_complete,
"form": self.form, "form": self.form,
"documentation": self.documentation, "documentation": self.documentation,
"data": self.data, "data": self.data,

View File

@ -124,7 +124,7 @@ def process_instance_run(
processor = None processor = None
try: try:
processor = ProcessInstanceService.run_process_intance_with_processor(process_instance) processor = ProcessInstanceService.run_process_instance_with_processor(process_instance)
except ( except (
ApiError, ApiError,
ProcessInstanceIsNotEnqueuedError, ProcessInstanceIsNotEnqueuedError,

View File

@ -5,6 +5,7 @@ import uuid
from sys import exc_info from sys import exc_info
from typing import Any from typing import Any
from typing import Dict from typing import Dict
from typing import Generator
from typing import Optional from typing import Optional
from typing import TypedDict from typing import TypedDict
from typing import Union from typing import Union
@ -16,6 +17,7 @@ from flask import current_app
from flask import g from flask import g
from flask import jsonify from flask import jsonify
from flask import make_response from flask import make_response
from flask import stream_with_context
from flask.wrappers import Response from flask.wrappers import Response
from jinja2 import TemplateSyntaxError from jinja2 import TemplateSyntaxError
from SpiffWorkflow.exceptions import WorkflowTaskException # type: ignore 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.""" """Task_show."""
process_instance = _find_process_instance_by_id_or_raise(process_instance_id) 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, 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_schema_file_name = ""
form_ui_schema_file_name = "" form_ui_schema_file_name = ""
processor = ProcessInstanceProcessor(process_instance) 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 extensions = spiff_task.task_spec.extensions
if "properties" in 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 task.form_ui_schema = ui_form_contents
_munge_form_ui_schema_based_on_hidden_fields_in_task_data(task) _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 and "instructionsForEndUser" in task.properties:
if task.properties["instructionsForEndUser"]: if task.properties["instructionsForEndUser"]:
try: try:
task.properties["instructionsForEndUser"] = _render_jinja_template( instructions = _render_jinja_template(task.properties["instructionsForEndUser"], spiff_task)
task.properties["instructionsForEndUser"], spiff_task task.properties["instructionsForEndUser"] = instructions
) return instructions
except WorkflowTaskException as wfe: except WorkflowTaskException as wfe:
wfe.add_note("Failed to render instructions for end user.") wfe.add_note("Failed to render instructions for end user.")
raise ApiError.from_workflow_exception("instructions_error", str(wfe), exp=wfe) from wfe raise ApiError.from_workflow_exception("instructions_error", str(wfe), exp=wfe) from wfe
return make_response(jsonify(task), 200) return ""
def process_data_show( 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( def _task_submit_shared(
process_instance_id: int, process_instance_id: int,
task_guid: str, task_guid: str,
@ -462,8 +503,21 @@ def _task_submit_shared(
) )
if next_human_task_assigned_to_me: if next_human_task_assigned_to_me:
return make_response(jsonify(HumanTaskModel.to_task(next_human_task_assigned_to_me)), 200) 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( def task_submit(

View File

@ -1687,7 +1687,7 @@ class ProcessInstanceProcessor:
self._script_engine.environment.finalize_result, self._script_engine.environment.finalize_result,
self.save, self.save,
) )
execution_service.run(exit_at, save) execution_service.run_and_save(exit_at, save)
@classmethod @classmethod
def get_tasks_with_data(cls, bpmn_process_instance: BpmnWorkflow) -> List[SpiffTask]: def get_tasks_with_data(cls, bpmn_process_instance: BpmnWorkflow) -> List[SpiffTask]:

View File

@ -11,6 +11,7 @@ from urllib.parse import unquote
import sentry_sdk import sentry_sdk
from flask import current_app from flask import current_app
from flask import g
from SpiffWorkflow.bpmn.specs.events.IntermediateEvent import _BoundaryEventParent # type: ignore from SpiffWorkflow.bpmn.specs.events.IntermediateEvent import _BoundaryEventParent # type: ignore
from SpiffWorkflow.task import Task as SpiffTask # 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.task import Task
from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.services.authorization_service import AuthorizationService 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 GitCommandError
from spiffworkflow_backend.services.git_service import GitService from spiffworkflow_backend.services.git_service import GitService
from spiffworkflow_backend.services.process_instance_processor import ( 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 .filter(ProcessInstanceModel.id.in_(process_instance_ids_to_check)) # type: ignore
.all() .all()
) )
execution_strategy_name = current_app.config["SPIFFWORKFLOW_BACKEND_ENGINE_STEP_DEFAULT_STRATEGY_BACKGROUND"]
for process_instance in records: for process_instance in records:
current_app.logger.info(f"Processing process_instance {process_instance.id}") current_app.logger.info(f"Processing process_instance {process_instance.id}")
try: 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: except ProcessInstanceIsAlreadyLockedError:
continue continue
except Exception as e: except Exception as e:
@ -127,8 +133,11 @@ class ProcessInstanceService:
current_app.logger.error(error_message) current_app.logger.error(error_message)
@classmethod @classmethod
def run_process_intance_with_processor( def run_process_instance_with_processor(
cls, process_instance: ProcessInstanceModel, status_value: Optional[str] = None cls,
process_instance: ProcessInstanceModel,
status_value: Optional[str] = None,
execution_strategy_name: Optional[str] = None,
) -> Optional[ProcessInstanceProcessor]: ) -> Optional[ProcessInstanceProcessor]:
processor = None processor = None
with ProcessInstanceQueueService.dequeued(process_instance): with ProcessInstanceQueueService.dequeued(process_instance):
@ -139,9 +148,6 @@ class ProcessInstanceService:
db.session.refresh(process_instance) db.session.refresh(process_instance)
if status_value is None or process_instance.status == status_value: 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) processor.do_engine_steps(save=True, execution_strategy_name=execution_strategy_name)
return processor return processor
@ -432,6 +438,19 @@ class ProcessInstanceService:
else: else:
lane = None 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"): if hasattr(spiff_task.task_spec, "spec"):
call_activity_process_identifier = spiff_task.task_spec.spec call_activity_process_identifier = spiff_task.task_spec.spec
else: else:
@ -449,8 +468,12 @@ class ProcessInstanceService:
spiff_task.task_spec.description, spiff_task.task_spec.description,
task_type, task_type,
spiff_task.get_state_name(), spiff_task.get_state_name(),
can_complete=can_complete,
lane=lane, lane=lane,
process_identifier=spiff_task.task_spec._wf_spec.name, 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, properties=props,
parent=parent_id, parent=parent_id,
event_definition=serialized_task_spec.get("event_definition"), event_definition=serialized_task_spec.get("event_definition"),

View File

@ -90,6 +90,15 @@ class ExecutionStrategy:
def save(self, bpmn_process_instance: BpmnWorkflow) -> None: def save(self, bpmn_process_instance: BpmnWorkflow) -> None:
self.delegate.save(bpmn_process_instance) 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): class TaskModelSavingDelegate(EngineStepDelegate):
"""Engine step delegate that takes care of saving a task model to the database. """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: def spiff_run(self, bpmn_process_instance: BpmnWorkflow, exit_at: None = None) -> None:
self.bpmn_process_instance = bpmn_process_instance engine_steps = self.get_ready_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)
]
)
while engine_steps: while engine_steps:
for spiff_task in engine_steps: for spiff_task in engine_steps:
if spiff_task.task_spec.spec_type == "Service Task": if spiff_task.task_spec.spec_type == "Service Task":
return return
self.delegate.will_complete_task(spiff_task) self.delegate.will_complete_task(spiff_task)
spiff_task.complete() spiff_task.run()
self.delegate.did_complete_task(spiff_task) 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) self.delegate.after_engine_steps(bpmn_process_instance)
@ -306,9 +340,11 @@ def execution_strategy_named(name: str, delegate: EngineStepDelegate) -> Executi
cls = { cls = {
"greedy": GreedyExecutionStrategy, "greedy": GreedyExecutionStrategy,
"run_until_service_task": RunUntilServiceTaskExecutionStrategy, "run_until_service_task": RunUntilServiceTaskExecutionStrategy,
"run_until_user_message": RunUntilUserTaskOrMessageExecutionStrategy,
"one_at_a_time": OneAtATimeExecutionStrategy,
}[name] }[name]
return cls(delegate) return cls(delegate) # type: ignore
ProcessInstanceCompleter = Callable[[BpmnWorkflow], None] ProcessInstanceCompleter = Callable[[BpmnWorkflow], None]
@ -338,7 +374,7 @@ class WorkflowExecutionService:
# run # run
# execution_strategy.spiff_run # execution_strategy.spiff_run
# spiff.[some_run_task_method] # 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.""" """Do_engine_steps."""
with safe_assertion(ProcessInstanceLockService.has_lock(self.process_instance_model.id)) as tripped: with safe_assertion(ProcessInstanceLockService.has_lock(self.process_instance_model.id)) as tripped:
if tripped: if tripped:
@ -384,7 +420,8 @@ class WorkflowExecutionService:
for bpmn_message in bpmn_messages: for bpmn_message in bpmn_messages:
message_instance = MessageInstanceModel( message_instance = MessageInstanceModel(
process_instance_id=self.process_instance_model.id, 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", message_type="send",
name=bpmn_message.name, name=bpmn_message.name,
payload=bpmn_message.payload, payload=bpmn_message.payload,
@ -449,11 +486,11 @@ class WorkflowExecutionService:
class ProfiledWorkflowExecutionService(WorkflowExecutionService): class ProfiledWorkflowExecutionService(WorkflowExecutionService):
"""A profiled version of the workflow execution service.""" """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.""" """__do_engine_steps."""
import cProfile import cProfile
from pstats import SortKey from pstats import SortKey
with cProfile.Profile() as pr: 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) pr.print_stats(sort=SortKey.CUMULATIVE)

View File

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:collaboration id="Collaboration_1ullb3f">
<bpmn:participant id="Participant_1mug4yn" processRef="Process_a6ss9w7" />
</bpmn:collaboration>
<bpmn:process id="Process_a6ss9w7" isExecutable="true">
<bpmn:laneSet id="LaneSet_1m2geb1">
<bpmn:lane id="Lane_0518vyo">
<bpmn:flowNodeRef>StartEvent_1</bpmn:flowNodeRef>
<bpmn:flowNodeRef>Activity_16m8jvv</bpmn:flowNodeRef>
<bpmn:flowNodeRef>Activity_1qrme8m</bpmn:flowNodeRef>
<bpmn:flowNodeRef>Activity_0bi0v5d</bpmn:flowNodeRef>
<bpmn:flowNodeRef>Event_1vyxv42</bpmn:flowNodeRef>
</bpmn:lane>
<bpmn:lane id="Lane_0mx423x" name="Finance Team">
<bpmn:flowNodeRef>Activity_02ldrj6</bpmn:flowNodeRef>
</bpmn:lane>
</bpmn:laneSet>
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_1rnrr8l</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_1rnrr8l" sourceRef="StartEvent_1" targetRef="Activity_16m8jvv" />
<bpmn:sequenceFlow id="Flow_011ysja" sourceRef="Activity_16m8jvv" targetRef="Activity_1qrme8m" />
<bpmn:scriptTask id="Activity_16m8jvv" name="Script Task #1">
<bpmn:extensionElements>
<spiffworkflow:instructionsForEndUser />
</bpmn:extensionElements>
<bpmn:incoming>Flow_1rnrr8l</bpmn:incoming>
<bpmn:outgoing>Flow_011ysja</bpmn:outgoing>
<bpmn:script>x = 2</bpmn:script>
</bpmn:scriptTask>
<bpmn:scriptTask id="Activity_1qrme8m" name="Script Task #2">
<bpmn:extensionElements>
<spiffworkflow:instructionsForEndUser>I am Script Task {{x}}</spiffworkflow:instructionsForEndUser>
</bpmn:extensionElements>
<bpmn:incoming>Flow_011ysja</bpmn:incoming>
<bpmn:outgoing>Flow_1rab9xv</bpmn:outgoing>
<bpmn:script>x = 2</bpmn:script>
</bpmn:scriptTask>
<bpmn:sequenceFlow id="Flow_1rab9xv" sourceRef="Activity_1qrme8m" targetRef="Activity_0bi0v5d" />
<bpmn:manualTask id="Activity_0bi0v5d" name="Manual Task">
<bpmn:extensionElements>
<spiffworkflow:instructionsForEndUser>I am a manual task</spiffworkflow:instructionsForEndUser>
</bpmn:extensionElements>
<bpmn:incoming>Flow_1rab9xv</bpmn:incoming>
<bpmn:outgoing>Flow_1icul0s</bpmn:outgoing>
</bpmn:manualTask>
<bpmn:sequenceFlow id="Flow_1icul0s" sourceRef="Activity_0bi0v5d" targetRef="Activity_02ldrj6" />
<bpmn:manualTask id="Activity_02ldrj6" name="Please Approve">
<bpmn:extensionElements>
<spiffworkflow:instructionsForEndUser>I am a manual task in another lane</spiffworkflow:instructionsForEndUser>
</bpmn:extensionElements>
<bpmn:incoming>Flow_1icul0s</bpmn:incoming>
<bpmn:outgoing>Flow_06qy6r3</bpmn:outgoing>
</bpmn:manualTask>
<bpmn:sequenceFlow id="Flow_06qy6r3" sourceRef="Activity_02ldrj6" targetRef="Event_1vyxv42" />
<bpmn:endEvent id="Event_1vyxv42">
<bpmn:extensionElements>
<spiffworkflow:instructionsForEndUser>I am the end task</spiffworkflow:instructionsForEndUser>
</bpmn:extensionElements>
<bpmn:incoming>Flow_06qy6r3</bpmn:incoming>
</bpmn:endEvent>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Collaboration_1ullb3f">
<bpmndi:BPMNShape id="Participant_1mug4yn_di" bpmnElement="Participant_1mug4yn" isHorizontal="true">
<dc:Bounds x="129" y="130" width="971" height="250" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Lane_0mx423x_di" bpmnElement="Lane_0mx423x" isHorizontal="true">
<dc:Bounds x="159" y="255" width="941" height="125" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Lane_0518vyo_di" bpmnElement="Lane_0518vyo" isHorizontal="true">
<dc:Bounds x="159" y="130" width="941" height="125" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="172" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0lp6dyb_di" bpmnElement="Activity_16m8jvv">
<dc:Bounds x="270" y="150" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1dlfog4_di" bpmnElement="Activity_1qrme8m">
<dc:Bounds x="430" y="150" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0bpymtg_di" bpmnElement="Activity_0bi0v5d">
<dc:Bounds x="580" y="150" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_06oz8dg_di" bpmnElement="Activity_02ldrj6">
<dc:Bounds x="730" y="280" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_1vyxv42_di" bpmnElement="Event_1vyxv42">
<dc:Bounds x="872" y="172" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_1rnrr8l_di" bpmnElement="Flow_1rnrr8l">
<di:waypoint x="215" y="190" />
<di:waypoint x="270" y="190" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_011ysja_di" bpmnElement="Flow_011ysja">
<di:waypoint x="370" y="190" />
<di:waypoint x="430" y="190" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1rab9xv_di" bpmnElement="Flow_1rab9xv">
<di:waypoint x="530" y="190" />
<di:waypoint x="580" y="190" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1icul0s_di" bpmnElement="Flow_1icul0s">
<di:waypoint x="680" y="190" />
<di:waypoint x="705" y="190" />
<di:waypoint x="705" y="320" />
<di:waypoint x="730" y="320" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_06qy6r3_di" bpmnElement="Flow_06qy6r3">
<di:waypoint x="830" y="320" />
<di:waypoint x="851" y="320" />
<di:waypoint x="851" y="190" />
<di:waypoint x="872" y="190" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -9,6 +9,7 @@ from tests.spiffworkflow_backend.helpers.test_data import load_test_spec
from spiffworkflow_backend import db from spiffworkflow_backend import db
from spiffworkflow_backend.models.human_task import HumanTaskModel from spiffworkflow_backend.models.human_task import HumanTaskModel
from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.routes.tasks_controller import _interstitial_stream
class TestForGoodErrors(BaseTest): class TestForGoodErrors(BaseTest):
@ -20,6 +21,9 @@ class TestForGoodErrors(BaseTest):
client: FlaskClient, client: FlaskClient,
with_super_admin_user: UserModel, with_super_admin_user: UserModel,
) -> Any: ) -> 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.""" """Returns the next available user task for a given process instance, if possible."""
human_tasks = ( human_tasks = (
db.session.query(HumanTaskModel).filter(HumanTaskModel.process_instance_id == process_instance_id).all() db.session.query(HumanTaskModel).filter(HumanTaskModel.process_instance_id == process_instance_id).all()

View File

@ -33,6 +33,7 @@ from spiffworkflow_backend.models.process_model import ProcessModelInfoSchema
from spiffworkflow_backend.models.spec_reference import SpecReferenceCache from spiffworkflow_backend.models.spec_reference import SpecReferenceCache
from spiffworkflow_backend.models.task import TaskModel # noqa: F401 from spiffworkflow_backend.models.task import TaskModel # noqa: F401
from spiffworkflow_backend.models.user import UserModel 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.authorization_service import AuthorizationService
from spiffworkflow_backend.services.file_system_service import FileSystemService from spiffworkflow_backend.services.file_system_service import FileSystemService
from spiffworkflow_backend.services.process_caller_service import ProcessCallerService 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", 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), 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 is not None
assert response.json["next_task"] is not None assert response.json["next_task"] is not None
@ -1653,6 +1655,91 @@ class TestProcessApi(BaseTest):
"veryImportantFieldButOnlySometimes": {"ui:widget": "hidden"}, "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( def test_process_instance_list_with_default_list(
self, self,
app: Flask, app: Flask,
@ -2347,7 +2434,7 @@ class TestProcessApi(BaseTest):
f"/v1.0/tasks/{process_instance_id}/{task_id}", f"/v1.0/tasks/{process_instance_id}/{task_id}",
headers=self.logged_in_headers(initiator_user), headers=self.logged_in_headers(initiator_user),
) )
assert response.status_code == 202 assert response.status_code == 200
response = client.get( response = client.get(
"/v1.0/tasks", "/v1.0/tasks",

View File

@ -371,12 +371,13 @@ class TestProcessInstanceProcessor(BaseTest):
) )
assert top_level_subprocess_script_spiff_task is not None assert top_level_subprocess_script_spiff_task is not None
processor.resume() 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 assert len(process_instance.active_human_tasks) == 1
human_task_one = process_instance.active_human_tasks[0] 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)) 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) 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" assert process_instance.status == "complete"
@ -405,7 +406,7 @@ class TestProcessInstanceProcessor(BaseTest):
process_model=process_model, user=initiator_user process_model=process_model, user=initiator_user
) )
processor = ProcessInstanceProcessor(process_instance) 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.active_human_tasks) == 1
initial_human_task_id = process_instance.active_human_tasks[0].id 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() process_instance = ProcessInstanceModel.query.filter_by(id=process_instance.id).first()
processor = ProcessInstanceProcessor(process_instance) processor = ProcessInstanceProcessor(process_instance)
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
human_task_one = process_instance.active_human_tasks[0] 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)) 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) 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] 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)) 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) 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 # 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() process_instance_relookup = ProcessInstanceModel.query.filter_by(id=process_instance.id).first()
processor_final = ProcessInstanceProcessor(process_instance_relookup) processor_final = ProcessInstanceProcessor(process_instance_relookup)
processor_final.do_engine_steps(save=True, execution_strategy_name="greedy")
assert process_instance_relookup.status == "complete" assert process_instance_relookup.status == "complete"
data_set_1 = {"set_in_top_level_script": 1} 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 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( def test_does_not_recreate_human_tasks_on_multiple_saves(
self, self,
@ -690,7 +695,7 @@ class TestProcessInstanceProcessor(BaseTest):
process_model=process_model, user=initiator_user process_model=process_model, user=initiator_user
) )
processor = ProcessInstanceProcessor(process_instance) 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.active_human_tasks) == 1
assert len(process_instance.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) ProcessInstanceService.complete_form_task(processor, spiff_task, {}, initiator_user, human_task_one)
processor = ProcessInstanceProcessor(process_instance) 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.active_human_tasks) == 1
assert len(process_instance.human_tasks) == 2 assert len(process_instance.human_tasks) == 2
human_task_two = process_instance.active_human_tasks[0] human_task_two = process_instance.active_human_tasks[0]
@ -708,6 +714,7 @@ class TestProcessInstanceProcessor(BaseTest):
# ensure this does not raise a KeyError # ensure this does not raise a KeyError
processor = ProcessInstanceProcessor(process_instance) 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.active_human_tasks) == 1
assert len(process_instance.human_tasks) == 3 assert len(process_instance.human_tasks) == 3
human_task_three = process_instance.active_human_tasks[0] human_task_three = process_instance.active_human_tasks[0]

View File

@ -43,7 +43,7 @@ class TestProcessModel(BaseTest):
process_instance = self.create_process_instance_from_process_model(process_model) process_instance = self.create_process_instance_from_process_model(process_model)
processor = ProcessInstanceProcessor(process_instance) 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" assert process_instance.status == "complete"
def test_can_run_process_model_with_call_activities_when_not_in_same_directory( 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) process_instance = self.create_process_instance_from_process_model(process_model)
processor = ProcessInstanceProcessor(process_instance) 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" assert process_instance.status == "complete"
def test_can_run_process_model_with_call_activities_when_process_identifier_is_not_in_database( 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.query(SpecReferenceCache).delete()
db.session.commit() db.session.commit()
processor = ProcessInstanceProcessor(process_instance) 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" assert process_instance.status == "complete"
def test_extract_metadata( def test_extract_metadata(

View File

@ -21,6 +21,12 @@ module.exports = {
importSource: '@bpmn-io/properties-panel/preact', importSource: '@bpmn-io/properties-panel/preact',
runtime: 'automatic', 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 },
], ],
}, },
}, },

View File

@ -16,6 +16,7 @@
"@carbon/styles": "^1.16.0", "@carbon/styles": "^1.16.0",
"@casl/ability": "^6.3.2", "@casl/ability": "^6.3.2",
"@casl/react": "^3.1.0", "@casl/react": "^3.1.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@monaco-editor/react": "^4.4.5", "@monaco-editor/react": "^4.4.5",
"@mui/material": "^5.10.14", "@mui/material": "^5.10.14",
"@react-icons/all-files": "^4.1.0", "@react-icons/all-files": "^4.1.0",
@ -4486,6 +4487,11 @@
"@lezer/highlight": "^1.0.0" "@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": { "node_modules/@monaco-editor/loader": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.3.3.tgz",
@ -8337,7 +8343,7 @@
}, },
"node_modules/bpmn-js-spiffworkflow": { "node_modules/bpmn-js-spiffworkflow": {
"version": "0.0.8", "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", "license": "MIT",
"dependencies": { "dependencies": {
"inherits": "^2.0.4", "inherits": "^2.0.4",
@ -35363,6 +35369,11 @@
"@lezer/highlight": "^1.0.0" "@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": { "@monaco-editor/loader": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.3.3.tgz",
@ -38204,7 +38215,7 @@
} }
}, },
"bpmn-js-spiffworkflow": { "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", "from": "bpmn-js-spiffworkflow@sartography/bpmn-js-spiffworkflow#main",
"requires": { "requires": {
"inherits": "^2.0.4", "inherits": "^2.0.4",

View File

@ -11,6 +11,7 @@
"@carbon/styles": "^1.16.0", "@carbon/styles": "^1.16.0",
"@casl/ability": "^6.3.2", "@casl/ability": "^6.3.2",
"@casl/react": "^3.1.0", "@casl/react": "^3.1.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@monaco-editor/react": "^4.4.5", "@monaco-editor/react": "^4.4.5",
"@mui/material": "^5.10.14", "@mui/material": "^5.10.14",
"@react-icons/all-files": "^4.1.0", "@react-icons/all-files": "^4.1.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -8,6 +8,7 @@ import NavigationBar from './components/NavigationBar';
import HomePageRoutes from './routes/HomePageRoutes'; import HomePageRoutes from './routes/HomePageRoutes';
import ErrorBoundary from './components/ErrorBoundary'; import ErrorBoundary from './components/ErrorBoundary';
import AdminRoutes from './routes/AdminRoutes'; import AdminRoutes from './routes/AdminRoutes';
import ProcessRoutes from './routes/ProcessRoutes';
import { AbilityContext } from './contexts/Can'; import { AbilityContext } from './contexts/Can';
import UserService from './services/UserService'; import UserService from './services/UserService';
@ -35,6 +36,7 @@ export default function App() {
<Routes> <Routes>
<Route path="/*" element={<HomePageRoutes />} /> <Route path="/*" element={<HomePageRoutes />} />
<Route path="/tasks/*" element={<HomePageRoutes />} /> <Route path="/tasks/*" element={<HomePageRoutes />} />
<Route path="/process/*" element={<ProcessRoutes />} />
<Route path="/admin/*" element={<AdminRoutes />} /> <Route path="/admin/*" element={<AdminRoutes />} />
</Routes> </Routes>
</ErrorBoundary> </ErrorBoundary>

View File

@ -0,0 +1,27 @@
import React from 'react';
// @ts-ignore
import MDEditor from '@uiw/react-md-editor';
export default function InstructionsForEndUser({ task }: any) {
if (!task) {
return null;
}
let instructions =
'There is no additional instructions or information for this task.';
const { properties } = task;
const { instructionsForEndUser } = properties;
if (instructionsForEndUser) {
instructions = instructionsForEndUser;
}
return (
<div className="markdown">
{/*
https://www.npmjs.com/package/@uiw/react-md-editor switches to dark mode by default by respecting @media (prefers-color-scheme: dark)
This makes it look like our site is broken, so until the rest of the site supports dark mode, turn off dark mode for this component.
*/}
<div data-color-mode="light">
<MDEditor.Markdown source={instructions} />
</div>
</div>
);
}

View File

@ -10,6 +10,7 @@ export default function MyCompletedInstances() {
perPageOptions={[2, 5, 25]} perPageOptions={[2, 5, 25]}
reportIdentifier="system_report_completed_instances_initiated_by_me" reportIdentifier="system_report_completed_instances_initiated_by_me"
showReports={false} showReports={false}
showActionsColumn
/> />
); );
} }

View File

@ -1458,28 +1458,28 @@ export default function ProcessInstanceListTable({
}); });
if (showActionsColumn) { if (showActionsColumn) {
let buttonElement = null; let buttonElement = null;
if (row.task_id) { const interstitialUrl = `/process/${modifyProcessIdentifierForPathParam(
const taskUrl = `/tasks/${row.id}/${row.task_id}`; row.process_model_identifier
const regex = new RegExp(`\\b(${preferredUsername}|${userEmail})\\b`); )}/${row.id}/interstitial`;
let hasAccessToCompleteTask = false; const regex = new RegExp(`\\b(${preferredUsername}|${userEmail})\\b`);
if ( let hasAccessToCompleteTask = false;
canCompleteAllTasks || if (
(row.potential_owner_usernames || '').match(regex) canCompleteAllTasks ||
) { (row.potential_owner_usernames || '').match(regex)
hasAccessToCompleteTask = true; ) {
} hasAccessToCompleteTask = true;
buttonElement = (
<Button
variant="primary"
href={taskUrl}
hidden={row.status === 'suspended'}
disabled={!hasAccessToCompleteTask}
>
Go
</Button>
);
} }
buttonElement = (
<Button
kind={
hasAccessToCompleteTask && row.task_id ? 'secondary' : 'tertiary'
}
href={interstitialUrl}
>
Go
</Button>
);
currentRow.push(<td>{buttonElement}</td>); currentRow.push(<td>{buttonElement}</td>);
} }

View File

@ -42,9 +42,7 @@ export interface Task {
id: number; id: number;
guid: string; guid: string;
bpmn_identifier: string; bpmn_identifier: string;
bpmn_name?: string; bpmn_name?: string;
bpmn_process_direct_parent_guid: string; bpmn_process_direct_parent_guid: string;
bpmn_process_definition_identifier: string; bpmn_process_definition_identifier: string;
data: any; data: any;
@ -59,7 +57,7 @@ export interface Task {
export interface ProcessInstanceTask { export interface ProcessInstanceTask {
id: string; id: string;
task_id: string; task_id: string;
can_complete: boolean;
calling_subprocess_task_id: string; calling_subprocess_task_id: string;
created_at_in_seconds: number; created_at_in_seconds: number;
current_user_is_potential_owner: number; current_user_is_potential_owner: number;

View File

@ -33,6 +33,7 @@ export default function CompletedInstances() {
showReports={false} showReports={false}
textToShowIfEmpty="This group has no completed instances at this time." textToShowIfEmpty="This group has no completed instances at this time."
additionalParams={`user_group_identifier=${userGroup}`} additionalParams={`user_group_identifier=${userGroup}`}
showActionsColumn
/> />
</> </>
); );
@ -61,6 +62,7 @@ export default function CompletedInstances() {
textToShowIfEmpty="You have no completed instances at this time." textToShowIfEmpty="You have no completed instances at this time."
paginationClassName="with-large-bottom-margin" paginationClassName="with-large-bottom-margin"
autoReload autoReload
showActionsColumn
/> />
<h2 <h2
title={withTasksCompletedByMeTitleText} title={withTasksCompletedByMeTitleText}
@ -76,6 +78,7 @@ export default function CompletedInstances() {
showReports={false} showReports={false}
textToShowIfEmpty="You have no completed instances at this time." textToShowIfEmpty="You have no completed instances at this time."
paginationClassName="with-large-bottom-margin" paginationClassName="with-large-bottom-margin"
showActionsColumn
/> />
{groupTableComponents()} {groupTableComponents()}
</> </>

View File

@ -7,6 +7,7 @@ import MyTasks from './MyTasks';
import CompletedInstances from './CompletedInstances'; import CompletedInstances from './CompletedInstances';
import CreateNewInstance from './CreateNewInstance'; import CreateNewInstance from './CreateNewInstance';
import InProgressInstances from './InProgressInstances'; import InProgressInstances from './InProgressInstances';
import ProcessInterstitial from './ProcessInterstitial';
export default function HomePageRoutes() { export default function HomePageRoutes() {
const location = useLocation(); const location = useLocation();
@ -55,6 +56,10 @@ export default function HomePageRoutes() {
<Route path="my-tasks" element={<MyTasks />} /> <Route path="my-tasks" element={<MyTasks />} />
<Route path=":process_instance_id/:task_id" element={<TaskShow />} /> <Route path=":process_instance_id/:task_id" element={<TaskShow />} />
<Route path="grouped" element={<InProgressInstances />} /> <Route path="grouped" element={<InProgressInstances />} />
<Route
path="process/:process_instance_id/interstitial"
element={<ProcessInterstitial />}
/>
<Route path="completed-instances" element={<CompletedInstances />} /> <Route path="completed-instances" element={<CompletedInstances />} />
<Route path="create-new-instance" element={<CreateNewInstance />} /> <Route path="create-new-instance" element={<CreateNewInstance />} />
</Routes> </Routes>

View File

@ -51,7 +51,7 @@ export default function ProcessInstanceList({ variant }: OwnProps) {
<br /> <br />
{processInstanceBreadcrumbElement()} {processInstanceBreadcrumbElement()}
{processInstanceTitleElement()} {processInstanceTitleElement()}
<ProcessInstanceListTable variant={variant} /> <ProcessInstanceListTable variant={variant} showActionsColumn />
</> </>
); );
} }

View File

@ -0,0 +1,161 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { fetchEventSource } from '@microsoft/fetch-event-source';
// @ts-ignore
import { Loading, Grid, Column } from '@carbon/react';
import { BACKEND_BASE_URL } from '../config';
import { getBasicHeaders } from '../services/HttpService';
// @ts-ignore
import InstructionsForEndUser from '../components/InstructionsForEndUser';
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
import { ProcessInstanceTask } from '../interfaces';
export default function ProcessInterstitial() {
const [data, setData] = useState<any[]>([]);
const [lastTask, setLastTask] = useState<any>(null);
const [state, setState] = useState<string>('RUNNING');
const params = useParams();
const navigate = useNavigate();
const userTasks = useMemo(() => {
return ['User Task', 'Manual Task'];
}, []);
useEffect(() => {
fetchEventSource(
`${BACKEND_BASE_URL}/tasks/${params.process_instance_id}`,
{
headers: getBasicHeaders(),
onmessage(ev) {
console.log(data, ev.data);
const task = JSON.parse(ev.data);
setData((prevData) => [...prevData, task]);
setLastTask(task);
},
onclose() {
setState('CLOSED');
},
}
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // it is critical to only run this once.
const shouldRedirect = useCallback(
(myTask: ProcessInstanceTask): boolean => {
return myTask && myTask.can_complete && userTasks.includes(myTask.type);
},
[userTasks]
);
useEffect(() => {
// Added this seperate use effect so that the timer interval will be cleared if
// we end up redirecting back to the TaskShow page.
if (shouldRedirect(lastTask)) {
setState('REDIRECTING');
lastTask.properties.instructionsForEndUser = '';
const timerId = setInterval(() => {
navigate(`/tasks/${lastTask.process_instance_id}/${lastTask.id}`);
}, 2000);
return () => clearInterval(timerId);
}
return undefined;
}, [lastTask, navigate, userTasks, shouldRedirect]);
const getStatus = (): string => {
if (!lastTask.can_complete && userTasks.includes(lastTask.type)) {
return 'LOCKED';
}
if (state === 'CLOSED') {
return lastTask.state;
}
console.log('The State is: ', state);
return state;
};
const getStatusImage = () => {
switch (getStatus()) {
case 'RUNNING':
return (
<Loading description="Active loading indicator" withOverlay={false} />
);
case 'LOCKED':
return <img src="/interstitial/locked.png" alt="Locked" />;
case 'REDIRECTING':
return <img src="/interstitial/redirect.png" alt="Redirecting ...." />;
case 'WAITING':
return <img src="/interstitial/waiting.png" alt="Waiting ...." />;
case 'COMPLETED':
return <img src="/interstitial/completed.png" alt="Completed" />;
default:
return <br />;
}
};
function capitalize(str: string): string {
console.log('Capitalizing: ', str);
if (str && str.length > 0) {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}
return '';
}
const userMessage = (myTask: ProcessInstanceTask) => {
if (!myTask.can_complete && userTasks.includes(myTask.type)) {
return <div>This next task must be completed by a different person.</div>;
}
if (shouldRedirect(myTask)) {
return <div>Redirecting you to the next task now ...</div>;
}
return (
<div>
<InstructionsForEndUser task={myTask} />
</div>
);
};
/** In the event there is no task information and the connection closed,
* redirect to the home page. */
if (state === 'closed' && lastTask === null) {
navigate(`/tasks`);
}
if (lastTask) {
return (
<>
<ProcessBreadcrumb
hotCrumbs={[
['Process Groups', '/admin'],
{
entityToExplode: lastTask.process_model_identifier,
entityType: 'process-model-id',
linkLastItem: true,
},
[`Process Instance Id: ${lastTask.process_instance_id}`],
]}
/>
<div style={{ display: 'flex', alignItems: 'center' }}>
{getStatusImage()}
<div>
<h1 style={{ marginBottom: '0em' }}>
{lastTask.process_model_display_name}:{' '}
{lastTask.process_instance_id}
</h1>
<div>Status: {capitalize(getStatus())}</div>
</div>
</div>
<br />
<br />
{data.map((d) => (
<Grid fullWidth style={{ marginBottom: '1em' }}>
<Column md={2} lg={4} sm={2}>
Task: <em>{d.title}</em>
</Column>
<Column md={6} lg={8} sm={4}>
{userMessage(d)}
</Column>
</Grid>
))}
</>
);
}
return null;
}

View File

@ -0,0 +1,14 @@
import { Route, Routes } from 'react-router-dom';
// @ts-ignore
import ProcessInterstitial from './ProcessInterstitial';
export default function ProcessRoutes() {
return (
<Routes>
<Route
path=":process_model_identifier/:process_instance_id/interstitial"
element={<ProcessInterstitial />}
/>
</Routes>
);
}

View File

@ -13,7 +13,6 @@ import {
ButtonSet, ButtonSet,
} from '@carbon/react'; } from '@carbon/react';
import MDEditor from '@uiw/react-md-editor';
// eslint-disable-next-line import/no-named-as-default // eslint-disable-next-line import/no-named-as-default
import Form from '../themes/carbon'; import Form from '../themes/carbon';
import HttpService from '../services/HttpService'; import HttpService from '../services/HttpService';
@ -21,6 +20,7 @@ import useAPIError from '../hooks/UseApiError';
import { modifyProcessIdentifierForPathParam } from '../helpers'; import { modifyProcessIdentifierForPathParam } from '../helpers';
import { ProcessInstanceTask } from '../interfaces'; import { ProcessInstanceTask } from '../interfaces';
import ProcessBreadcrumb from '../components/ProcessBreadcrumb'; import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
import InstructionsForEndUser from '../components/InstructionsForEndUser';
// TODO: move this somewhere else // TODO: move this somewhere else
function TypeAheadWidget({ function TypeAheadWidget({
@ -89,13 +89,6 @@ function TypeAheadWidget({
); );
} }
class UnexpectedHumanTaskType extends Error {
constructor(message: string) {
super(message);
this.name = 'UnexpectedHumanTaskType';
}
}
enum FormSubmitType { enum FormSubmitType {
Default, Default,
Draft, Draft,
@ -107,19 +100,28 @@ export default function TaskShow() {
const params = useParams(); const params = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [disabled, setDisabled] = useState(false); const [disabled, setDisabled] = useState(false);
// save current form data so that we can avoid validations in certain situations // save current form data so that we can avoid validations in certain situations
const [currentFormObject, setCurrentFormObject] = useState<any>({}); const [currentFormObject, setCurrentFormObject] = useState<any>({});
const { addError, removeError } = useAPIError(); const { addError, removeError } = useAPIError();
// eslint-disable-next-line sonarjs/no-duplicate-string const navigateToInterstitial = (myTask: ProcessInstanceTask) => {
const supportedHumanTaskTypes = ['User Task', 'Manual Task']; navigate(
`/process/${modifyProcessIdentifierForPathParam(
myTask.process_model_identifier
)}/${myTask.process_instance_id}/interstitial`
);
};
useEffect(() => { useEffect(() => {
const processResult = (result: ProcessInstanceTask) => { const processResult = (result: ProcessInstanceTask) => {
setTask(result); setTask(result);
setDisabled(false); setDisabled(false);
if (!result.can_complete) {
navigateToInterstitial(result);
}
/* Disable call to load previous tasks -- do not display menu. /* Disable call to load previous tasks -- do not display menu.
const url = `/v1.0/process-instances/for-me/${modifyProcessIdentifierForPathParam( const url = `/v1.0/process-instances/for-me/${modifyProcessIdentifierForPathParam(
result.process_model_identifier result.process_model_identifier
@ -156,7 +158,11 @@ export default function TaskShow() {
if (result.ok) { if (result.ok) {
navigate(`/tasks`); navigate(`/tasks`);
} else if (result.process_instance_id) { } 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 { } else {
addError(result); addError(result);
} }
@ -342,10 +348,6 @@ export default function TaskShow() {
Save as draft Save as draft
</Button> </Button>
); );
} else {
throw new UnexpectedHumanTaskType(
`Invalid task type given: ${task.type}. Only supported types: ${supportedHumanTaskTypes}`
);
} }
reactFragmentToHideSubmitButton = ( reactFragmentToHideSubmitButton = (
<ButtonSet> <ButtonSet>
@ -386,27 +388,6 @@ export default function TaskShow() {
); );
}; };
const instructionsElement = () => {
if (!task) {
return null;
}
let instructions = '';
if (task.properties.instructionsForEndUser) {
instructions = task.properties.instructionsForEndUser;
}
return (
<div className="markdown">
{/*
https://www.npmjs.com/package/@uiw/react-md-editor switches to dark mode by default by respecting @media (prefers-color-scheme: dark)
This makes it look like our site is broken, so until the rest of the site supports dark mode, turn off dark mode for this component.
*/}
<div data-color-mode="light">
<MDEditor.Markdown source={instructions} />
</div>
</div>
);
};
if (task) { if (task) {
let statusString = ''; let statusString = '';
if (task.state !== 'READY') { if (task.state !== 'READY') {
@ -430,7 +411,7 @@ export default function TaskShow() {
<h3> <h3>
Task: {task.title} ({task.process_model_display_name}){statusString} Task: {task.title} ({task.process_model_display_name}){statusString}
</h3> </h3>
{instructionsElement()} <InstructionsForEndUser task={task} />
{formElement()} {formElement()}
</main> </main>
); );

View File

@ -8,7 +8,7 @@ const HttpMethods = {
DELETE: 'DELETE', DELETE: 'DELETE',
}; };
const getBasicHeaders = (): object => { export const getBasicHeaders = (): Record<string, string> => {
if (UserService.isLoggedIn()) { if (UserService.isLoggedIn()) {
return { return {
Authorization: `Bearer ${UserService.getAccessToken()}`, Authorization: `Bearer ${UserService.getAccessToken()}`,