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:
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:

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", 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

View File

@ -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]

View File

@ -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"):

View File

@ -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,

View File

@ -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,

View File

@ -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(

View File

@ -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]:

View File

@ -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"),

View File

@ -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)

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.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()

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.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",

View File

@ -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]

View File

@ -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(

View File

@ -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 },
],
},
},

View File

@ -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",

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

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]}
reportIdentifier="system_report_completed_instances_initiated_by_me"
showReports={false}
showActionsColumn
/>
);
}

View File

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

View File

@ -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;

View File

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

View File

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

View File

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

View File

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