Merge remote-tracking branch 'origin/main' into feature/multiple_editor_users

This commit is contained in:
jasquat 2023-05-03 17:38:49 -04:00
commit 8655ca0cd0
21 changed files with 1093 additions and 397 deletions

View File

@ -0,0 +1,10 @@
# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
version: v1.25.0
# ignores vulnerabilities until expiry date; change duration by modifying expiry date
ignore:
SNYK-PYTHON-FLASK-5490129:
- '*':
reason: Filed ticket to upgrade flask
expires: 2024-06-02T14:48:14.372Z
created: 2023-05-03T14:48:14.379Z
patch: {}

View File

@ -0,0 +1,41 @@
#!/usr/bin/env bash
function error_handler() {
>&2 echo "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}."
exit "$2"
}
trap 'error_handler ${LINENO} $?' ERR
set -o errtrace -o errexit -o nounset -o pipefail
script_dir="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )"
if [[ -z "${KEYCLOAK_BASE_URL:-}" ]]; then
export KEYCLOAK_BASE_URL=http://localhost:7002
fi
if [[ -z "${BACKEND_BASE_URL:-}" ]]; then
export BACKEND_BASE_URL=http://localhost:7000
fi
message_identifier="${1:-start_test}"
username="${2:-admin}"
password="${3:-admin}"
realm_name="${4:-spiffworkflow}"
if [[ -z "${message_identifier}" ]]; then
>&2 echo "usage: $(basename "$0") [message_identifier] [username: OPTONAL] [password: OPTONAL] [realm_name: OPTONAL]"
exit 1
fi
function check_result_for_error() {
local result="$1"
error_code=$(jq '.error_code' <<<"$result")
if [[ -n "$error_code" && "$error_code" != "null" ]]; then
>&2 echo "ERROR: Failed to run process instance. Received error: $result"
exit 1
fi
}
access_token=$("${script_dir}/get_token" "$username" "$password" "$realm_name")
curl --silent -X POST "${BACKEND_BASE_URL}/v1.0/login_with_access_token?access_token=${access_token}" -H "Authorization: Bearer $access_token" >/dev/null
result=$(curl --silent -X POST "${BACKEND_BASE_URL}/v1.0/messages/${message_identifier}" -H "Authorization: Bearer $access_token" -d '{"payload": {"email": "HEY@example.com"}}' -H 'Content-type: application/json')
check_result_for_error "$result"
echo "$result"

View File

@ -2,6 +2,7 @@
import enum
from dataclasses import dataclass
from typing import Any
from typing import List
from typing import Optional
from typing import TYPE_CHECKING
from typing import Union
@ -85,6 +86,7 @@ class TaskModel(SpiffworkflowBaseDBModel):
can_complete: Optional[bool] = None
extensions: Optional[dict] = None
name_for_display: Optional[str] = None
signal_buttons: Optional[List[dict]] = None
def get_data(self) -> dict:
return {**self.python_env_data(), **self.json_data()}

View File

@ -69,7 +69,6 @@ def message_send(
message_name: str,
body: Dict[str, Any],
) -> flask.wrappers.Response:
"""Message_start."""
if "payload" not in body:
raise (
ApiError(

View File

@ -18,7 +18,6 @@ from spiffworkflow_backend.exceptions.process_entity_not_found_error import (
)
from spiffworkflow_backend.models.principal import PrincipalModel
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
from spiffworkflow_backend.models.process_instance import ProcessInstanceModelSchema
from spiffworkflow_backend.models.process_instance_file_data import (
ProcessInstanceFileDataModel,
)
@ -32,6 +31,7 @@ from spiffworkflow_backend.services.process_caller_service import ProcessCallerS
from spiffworkflow_backend.services.process_instance_processor import (
ProcessInstanceProcessor,
)
from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService
from spiffworkflow_backend.services.process_model_service import ProcessModelService
@ -199,16 +199,13 @@ def send_bpmn_event(
if process_instance:
processor = ProcessInstanceProcessor(process_instance)
processor.send_bpmn_event(body)
task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task())
return make_response(jsonify(task), 200)
else:
raise ApiError(
error_code="send_bpmn_event_error",
message=f"Could not send event to Instance: {process_instance_id}",
)
return Response(
json.dumps(ProcessInstanceModelSchema().dump(process_instance)),
status=200,
mimetype="application/json",
)
def _commit_and_push_to_git(message: str) -> None:

View File

@ -289,6 +289,7 @@ def task_show(process_instance_id: int, task_guid: str = "next") -> flask.wrappe
task_model = _get_task_model_from_guid_or_raise(task_guid, process_instance_id)
task_definition = task_model.task_definition
extensions = TaskService.get_extensions_from_task_model(task_model)
task_model.signal_buttons = TaskService.get_ready_signals_with_button_labels(process_instance_id)
if "properties" in extensions:
properties = extensions["properties"]

View File

@ -7,7 +7,7 @@ from spiffworkflow_backend.models.script_attributes_context import (
from spiffworkflow_backend.scripts.script import Script
class GetProcessInfo(Script):
class GetToplevelProcessInfo(Script):
"""GetProcessInfo."""
@staticmethod

View File

@ -34,7 +34,11 @@ from spiffworkflow_backend.services.process_model_service import ProcessModelSer
class ProcessInstanceReportNotFoundError(Exception):
"""ProcessInstanceReportNotFoundError."""
pass
class ProcessInstanceReportMetadataInvalidError(Exception):
pass
class ProcessInstanceReportService:
@ -53,15 +57,15 @@ class ProcessInstanceReportService:
}
system_report_completed_instances_initiated_by_me: ReportMetadata = {
"columns": [
{"Header": "id", "accessor": "id", "filterable": False},
{"Header": "Id", "accessor": "id", "filterable": False},
{
"Header": "process_model_display_name",
"Header": "Process",
"accessor": "process_model_display_name",
"filterable": False,
},
{"Header": "start_in_seconds", "accessor": "start_in_seconds", "filterable": False},
{"Header": "end_in_seconds", "accessor": "end_in_seconds", "filterable": False},
{"Header": "status", "accessor": "status", "filterable": False},
{"Header": "Start Time", "accessor": "start_in_seconds", "filterable": False},
{"Header": "End Time", "accessor": "end_in_seconds", "filterable": False},
{"Header": "Status", "accessor": "status", "filterable": False},
],
"filter_by": [
{"field_name": "initiated_by_me", "field_value": True, "operator": "equals"},
@ -72,7 +76,7 @@ class ProcessInstanceReportService:
system_report_completed_instances_with_tasks_completed_by_me: ReportMetadata = {
"columns": cls.builtin_column_options(),
"filter_by": [
{"field_name": "with_tasks_completed_by_me", "field_value": True, "operator": "equals"},
{"field_name": "instances_with_tasks_completed_by_me", "field_value": True, "operator": "equals"},
{"field_name": "process_status", "field_value": terminal_status_values, "operator": "equals"},
],
"order_by": ["-start_in_seconds", "-id"],
@ -86,9 +90,9 @@ class ProcessInstanceReportService:
}
system_report_in_progress_instances_initiated_by_me: ReportMetadata = {
"columns": [
{"Header": "id", "accessor": "id", "filterable": False},
{"Header": "Id", "accessor": "id", "filterable": False},
{
"Header": "process_model_display_name",
"Header": "Process",
"accessor": "process_model_display_name",
"filterable": False,
},
@ -96,7 +100,7 @@ class ProcessInstanceReportService:
{"Header": "Waiting For", "accessor": "waiting_for", "filterable": False},
{"Header": "Started", "accessor": "start_in_seconds", "filterable": False},
{"Header": "Last Updated", "accessor": "task_updated_at_in_seconds", "filterable": False},
{"Header": "status", "accessor": "status", "filterable": False},
{"Header": "Status", "accessor": "status", "filterable": False},
],
"filter_by": [
{"field_name": "initiated_by_me", "field_value": True, "operator": "equals"},
@ -111,9 +115,9 @@ class ProcessInstanceReportService:
}
system_report_in_progress_instances_with_tasks_for_me: ReportMetadata = {
"columns": [
{"Header": "id", "accessor": "id", "filterable": False},
{"Header": "Id", "accessor": "id", "filterable": False},
{
"Header": "process_model_display_name",
"Header": "Process",
"accessor": "process_model_display_name",
"filterable": False,
},
@ -123,7 +127,7 @@ class ProcessInstanceReportService:
{"Header": "Last Updated", "accessor": "task_updated_at_in_seconds", "filterable": False},
],
"filter_by": [
{"field_name": "with_tasks_i_can_complete", "field_value": True, "operator": "equals"},
{"field_name": "instances_with_tasks_waiting_for_me", "field_value": True, "operator": "equals"},
{"field_name": "process_status", "field_value": active_status_values, "operator": "equals"},
{
"field_name": "with_oldest_open_task",
@ -135,9 +139,9 @@ class ProcessInstanceReportService:
}
system_report_in_progress_instances_with_tasks: ReportMetadata = {
"columns": [
{"Header": "id", "accessor": "id", "filterable": False},
{"Header": "Id", "accessor": "id", "filterable": False},
{
"Header": "process_model_display_name",
"Header": "Process",
"accessor": "process_model_display_name",
"filterable": False,
},
@ -414,17 +418,17 @@ class ProcessInstanceReportService:
process_initiator_id = initiator.id
process_instance_query = process_instance_query.filter_by(process_initiator_id=process_initiator_id)
with_tasks_completed_by_me = cls.get_filter_value(filters, "with_tasks_completed_by_me")
with_tasks_i_can_complete = cls.get_filter_value(filters, "with_tasks_i_can_complete")
instances_with_tasks_completed_by_me = cls.get_filter_value(filters, "instances_with_tasks_completed_by_me")
instances_with_tasks_waiting_for_me = cls.get_filter_value(filters, "instances_with_tasks_waiting_for_me")
user_group_identifier = cls.get_filter_value(filters, "user_group_identifier")
# builtin only - for the for-me paths
with_relation_to_me = cls.get_filter_value(filters, "with_relation_to_me")
if (
not with_tasks_completed_by_me
not instances_with_tasks_completed_by_me
and not user_group_identifier
and not with_tasks_i_can_complete
and not instances_with_tasks_waiting_for_me
and with_relation_to_me is True
):
process_instance_query = process_instance_query.outerjoin(HumanTaskModel).outerjoin(
@ -441,7 +445,16 @@ class ProcessInstanceReportService:
)
)
if with_tasks_completed_by_me is True:
if instances_with_tasks_completed_by_me is True and instances_with_tasks_waiting_for_me is True:
raise ProcessInstanceReportMetadataInvalidError(
"Cannot set both 'instances_with_tasks_completed_by_me' and 'instances_with_tasks_waiting_for_me' to"
" true. You must choose one."
)
# ensure we only join with HumanTaskModel once
human_task_already_joined = False
if instances_with_tasks_completed_by_me is True:
process_instance_query = process_instance_query.filter(
ProcessInstanceModel.process_initiator_id != user.id
)
@ -452,10 +465,11 @@ class ProcessInstanceReportService:
HumanTaskModel.completed_by_user_id == user.id,
),
)
human_task_already_joined = True
# this excludes some tasks you can complete, because that's the way the requirements were described.
# if it's assigned to one of your groups, it does not get returned by this query.
if with_tasks_i_can_complete is True:
if instances_with_tasks_waiting_for_me is True:
process_instance_query = process_instance_query.filter(
ProcessInstanceModel.process_initiator_id != user.id
)
@ -470,13 +484,15 @@ class ProcessInstanceReportService:
HumanTaskUserModel,
and_(HumanTaskUserModel.human_task_id == HumanTaskModel.id, HumanTaskUserModel.user_id == user.id),
)
human_task_already_joined = True
if user_group_identifier is not None:
group_model_join_conditions = [GroupModel.id == HumanTaskModel.lane_assignment_id]
if user_group_identifier:
group_model_join_conditions.append(GroupModel.identifier == user_group_identifier)
process_instance_query = process_instance_query.join(HumanTaskModel)
if human_task_already_joined is False:
process_instance_query = process_instance_query.join(HumanTaskModel)
if process_status is not None:
non_active_statuses = [
s for s in process_status.split(",") if s not in ProcessInstanceModel.active_statuses()

View File

@ -2,6 +2,7 @@ import copy
import json
import time
from hashlib import sha256
from typing import List
from typing import Optional
from typing import Tuple
from typing import TypedDict
@ -607,6 +608,28 @@ class TaskService:
)
return extensions
@classmethod
def get_ready_signals_with_button_labels(cls, process_instance_id: int) -> list[dict]:
waiting_tasks: List[TaskModel] = TaskModel.query.filter_by(
state="WAITING", process_instance_id=process_instance_id
).all()
result = []
for task_model in waiting_tasks:
task_definition = task_model.task_definition
extensions: dict = (
task_definition.properties_json["extensions"]
if "extensions" in task_definition.properties_json
else {}
)
event_definition: dict = (
task_definition.properties_json["event_definition"]
if "event_definition" in task_definition.properties_json
else {}
)
if "signalButtonLabel" in extensions and "name" in event_definition:
result.append({"event": event_definition, "label": extensions["signalButtonLabel"]})
return result
@classmethod
def get_spec_reference_from_bpmn_process(cls, bpmn_process: BpmnProcessModel) -> SpecReferenceCache:
"""Get the bpmn file for a given task model.

View File

@ -0,0 +1,86 @@
<?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:process id="SpiffCatchEventExtensions" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_0elszck</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_0elszck" sourceRef="StartEvent_1" targetRef="Activity_0cmmlen" />
<bpmn:endEvent id="Event_1mjvim4">
<bpmn:incoming>Flow_1akz8b3</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1akz8b3" sourceRef="Activity_0cmmlen" targetRef="Event_1mjvim4" />
<bpmn:sequenceFlow id="Flow_0uenxs3" sourceRef="SpamEvent" targetRef="Activity_1u4om4i" />
<bpmn:endEvent id="Event_1dvll15">
<bpmn:incoming>Flow_16bzuvz</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_16bzuvz" sourceRef="Activity_1u4om4i" targetRef="Event_1dvll15" />
<bpmn:manualTask id="Activity_0cmmlen" name="My Manual Task">
<bpmn:extensionElements>
<spiffworkflow:instructionsForEndUser># Welcome
This manual task has Two Buttons! The first is standard submit button that will take you to the end. The second button will fire a signal event and take you to a different manual task.</spiffworkflow:instructionsForEndUser>
</bpmn:extensionElements>
<bpmn:incoming>Flow_0elszck</bpmn:incoming>
<bpmn:outgoing>Flow_1akz8b3</bpmn:outgoing>
</bpmn:manualTask>
<bpmn:manualTask id="Activity_1u4om4i" name="Spam Message">
<bpmn:extensionElements>
<spiffworkflow:instructionsForEndUser># Spam Eaten!
Congratulations! You have selected the Eat Additional Spam option, which opens up new doors to vast previously uncharted culinary eating experiences! Oh the Joy! Oh the Reward! Sweet savory wonderful Spam! </spiffworkflow:instructionsForEndUser>
</bpmn:extensionElements>
<bpmn:incoming>Flow_0uenxs3</bpmn:incoming>
<bpmn:outgoing>Flow_16bzuvz</bpmn:outgoing>
</bpmn:manualTask>
<bpmn:boundaryEvent id="SpamEvent" name="Spam Event" attachedToRef="Activity_0cmmlen">
<bpmn:extensionElements>
<spiffworkflow:signalButtonLabel>Eat Spam</spiffworkflow:signalButtonLabel>
</bpmn:extensionElements>
<bpmn:outgoing>Flow_0uenxs3</bpmn:outgoing>
<bpmn:signalEventDefinition id="SignalEventDefinition_11tlwya" signalRef="Signal_17t90lm" />
</bpmn:boundaryEvent>
</bpmn:process>
<bpmn:signal id="Signal_17t90lm" name="eat_spam" />
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_z1jgvu5">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_1mjvim4_di" bpmnElement="Event_1mjvim4">
<dc:Bounds x="432" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_1dvll15_di" bpmnElement="Event_1dvll15">
<dc:Bounds x="562" y="282" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0zxmtux_di" bpmnElement="Activity_0cmmlen">
<dc:Bounds x="270" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0tll58x_di" bpmnElement="Activity_1u4om4i">
<dc:Bounds x="410" y="260" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_0vnraxp_di" bpmnElement="SpamEvent">
<dc:Bounds x="322" y="199" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="311" y="242" width="61" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_0elszck_di" bpmnElement="Flow_0elszck">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1akz8b3_di" bpmnElement="Flow_1akz8b3">
<di:waypoint x="370" y="177" />
<di:waypoint x="432" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0uenxs3_di" bpmnElement="Flow_0uenxs3">
<di:waypoint x="340" y="235" />
<di:waypoint x="340" y="300" />
<di:waypoint x="410" y="300" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_16bzuvz_di" bpmnElement="Flow_16bzuvz">
<di:waypoint x="510" y="300" />
<di:waypoint x="562" y="300" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -2649,7 +2649,8 @@ class TestProcessApi(BaseTest):
)
assert response.status_code == 200
assert response.json is not None
assert response.json["status"] == "complete"
assert response.json["type"] == "End Event"
assert response.json["state"] == "COMPLETED"
response = client.get(
f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model_identifier)}/{process_instance_id}/task-info?all_tasks=true",

View File

@ -1,4 +1,5 @@
"""Test_process_instance_report_service."""
import pytest
from flask import Flask
from flask.testing import FlaskClient
from tests.spiffworkflow_backend.helpers.base_test import BaseTest
@ -7,9 +8,9 @@ from tests.spiffworkflow_backend.helpers.test_data import load_test_spec
from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.group import GroupModel
from spiffworkflow_backend.models.human_task import HumanTaskModel
from spiffworkflow_backend.services.process_instance_report_service import (
ProcessInstanceReportService,
)
from spiffworkflow_backend.models.process_instance_report import ReportMetadata
from spiffworkflow_backend.services.process_instance_report_service import ProcessInstanceReportMetadataInvalidError
from spiffworkflow_backend.services.process_instance_report_service import ProcessInstanceReportService
from spiffworkflow_backend.services.user_service import UserService
@ -52,6 +53,62 @@ class TestProcessInstanceReportService(BaseTest):
assert response_json["results"][0]["status"] == "complete"
assert response_json["results"][1]["status"] == "complete"
def test_raises_if_filtering_with_both_task_i_can_complete_and_tasks_completed_by_me(
self,
app: Flask,
client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,
) -> None:
user_one = self.find_or_create_user(username="user_one")
report_metadata: ReportMetadata = {
"columns": [],
"filter_by": [
{"field_name": "instances_with_tasks_waiting_for_me", "field_value": True, "operator": "equals"},
{"field_name": "instances_with_tasks_completed_by_me", "field_value": True, "operator": "equals"},
],
"order_by": [],
}
with pytest.raises(ProcessInstanceReportMetadataInvalidError):
ProcessInstanceReportService.run_process_instance_report(
report_metadata=report_metadata,
user=user_one,
)
def test_with_group_identifier_does_not_conflict_with_system_filters(
self,
app: Flask,
client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,
) -> None:
user_one = self.find_or_create_user(username="user_one")
report_metadata: ReportMetadata = {
"columns": [],
"filter_by": [
{"field_name": "instances_with_tasks_waiting_for_me", "field_value": True, "operator": "equals"},
{"field_name": "user_group_identifier", "field_value": "group_one", "operator": "equals"},
],
"order_by": [],
}
result = ProcessInstanceReportService.run_process_instance_report(
report_metadata=report_metadata,
user=user_one,
)
assert result is not None
report_metadata = {
"columns": [],
"filter_by": [
{"field_name": "instances_with_tasks_completed_by_me", "field_value": True, "operator": "equals"},
{"field_name": "user_group_identifier", "field_value": "group_one", "operator": "equals"},
],
"order_by": [],
}
result = ProcessInstanceReportService.run_process_instance_report(
report_metadata=report_metadata,
user=user_one,
)
assert result is not None
def test_can_filter_by_completed_instances_with_tasks_completed_by_me(
self,
app: Flask,

View File

@ -156,3 +156,30 @@ class TestTaskService(BaseTest):
assert task_model_level_3 is not None
bpmn_process = TaskService.bpmn_process_for_called_activity_or_top_level_process(task_model_level_3)
assert bpmn_process.bpmn_process_definition.bpmn_identifier == "Level3"
def test_get_button_labels_for_waiting_signal_event_tasks(
self,
app: Flask,
with_db_and_bpmn_file_cleanup: None,
) -> None:
process_model = load_test_spec(
"test_group/signal_event_extensions",
process_model_source_directory="signal_event_extensions",
bpmn_file_name="signal_event_extensions",
)
load_test_spec(
"test_group/SpiffCatchEventExtensions",
process_model_source_directory="call_activity_nested",
bpmn_file_name="SpiffCatchEventExtensions",
)
process_instance = self.create_process_instance_from_process_model(process_model)
processor = ProcessInstanceProcessor(process_instance)
processor.do_engine_steps(save=True, execution_strategy_name="greedy")
events = TaskService.get_ready_signals_with_button_labels(process_instance.id)
assert len(events) == 1
signal_event = events[0]
assert signal_event["event"]["name"] == "eat_spam"
assert signal_event["event"]["typename"] == "SignalEventDefinition"
assert signal_event["label"] == "Eat Spam"
print(events)

File diff suppressed because it is too large Load Diff

View File

@ -31,14 +31,14 @@
"autoprefixer": "10.4.8",
"axios": "^0.27.2",
"bootstrap": "^5.2.0",
"bpmn-js": "^9.3.2",
"bpmn-js-properties-panel": "^1.10.0",
"bpmn-js": "^13.0.0",
"bpmn-js-properties-panel": "^1.22.0",
"bpmn-js-spiffworkflow": "github:sartography/bpmn-js-spiffworkflow#main",
"cookie": "^0.5.0",
"craco": "^0.0.3",
"cypress-slow-down": "^1.2.1",
"date-fns": "^2.28.0",
"diagram-js": "^8.5.0",
"diagram-js": "^11.9.1",
"dmn-js": "^12.2.0",
"dmn-js-properties-panel": "^1.1",
"dmn-js-shared": "^12.1.1",

View File

@ -138,7 +138,9 @@ export default function ProcessInstanceListTable({
);
const canSearchUsers: boolean = ability.can('GET', targetUris.userSearch);
const [processInstances, setProcessInstances] = useState([]);
const [processInstances, setProcessInstances] = useState<ProcessInstance[]>(
[]
);
const [reportMetadata, setReportMetadata] = useState<ReportMetadata | null>();
const [pagination, setPagination] = useState<PaginationObject | null>(null);
@ -215,7 +217,10 @@ export default function ProcessInstanceListTable({
);
const [userGroups, setUserGroups] = useState<string[]>([]);
const systemReportOptions: string[] = useMemo(() => {
return ['with_tasks_i_can_complete', 'with_tasks_completed_by_me'];
return [
'instances_with_tasks_waiting_for_me',
'instances_with_tasks_completed_by_me',
];
}, []);
const [reportHash, setReportHash] = useState<string | null>(null);
@ -846,6 +851,9 @@ export default function ProcessInstanceListTable({
setEndToDate('');
setEndToTime('');
setProcessInitiatorSelection(null);
setWithOldestOpenTask(false);
setSystemReport(null);
setSelectedUserGroup(null);
setRequiresRefilter(true);
if (reportMetadata) {
reportMetadata.filter_by = [];
@ -1213,9 +1221,9 @@ export default function ProcessInstanceListTable({
<Column md={4} lg={8} sm={2}>
<Dropdown
id="system-report-dropdown"
titleText="System Report"
titleText="System report"
items={['', ...systemReportOptions]}
itemToString={(item: any) => item}
itemToString={(item: any) => titleizeString(item)}
selectedItem={systemReport}
onChange={(value: any) => {
setSystemReport(value.selectedItem);
@ -1226,7 +1234,7 @@ export default function ProcessInstanceListTable({
<Column md={4} lg={8} sm={2}>
<Dropdown
id="user-group-dropdown"
titleText="User Group"
titleText="Assigned user group"
items={['', ...userGroups]}
itemToString={(item: any) => item}
selectedItem={selectedUserGroup}
@ -1262,6 +1270,7 @@ export default function ProcessInstanceListTable({
onRequestSubmit={handleAdvancedOptionsClose}
onRequestClose={handleAdvancedOptionsClose}
hasScrollingContent
size="lg"
>
{formElements}
</Modal>
@ -1336,7 +1345,7 @@ export default function ProcessInstanceListTable({
return null;
}}
placeholder="Start typing username"
titleText="Started By"
titleText="Started by"
selectedItem={processInitiatorSelection}
/>
);
@ -1345,7 +1354,7 @@ export default function ProcessInstanceListTable({
<TextInput
id="process-instance-initiator-search"
placeholder="Enter username"
labelText="Started By"
labelText="Started by"
invalid={processInitiatorNotFoundErrorText !== ''}
invalidText={processInitiatorNotFoundErrorText}
onChange={(event: any) => {
@ -1509,7 +1518,7 @@ export default function ProcessInstanceListTable({
return value;
};
const formattedColumn = (row: any, column: any) => {
const formattedColumn = (row: ProcessInstance, column: ReportColumn) => {
const reportColumnFormatters: Record<string, any> = {
id: formatProcessInstanceId,
process_model_identifier: formatProcessModelIdentifier,
@ -1520,40 +1529,41 @@ export default function ProcessInstanceListTable({
updated_at_in_seconds: formatSecondsForDisplay,
task_updated_at_in_seconds: formatSecondsForDisplay,
};
const columnAccessor = column.accessor as keyof ProcessInstance;
const formatter =
reportColumnFormatters[column.accessor] ?? defaultFormatter;
const value = row[column.accessor];
reportColumnFormatters[columnAccessor] ?? defaultFormatter;
const value = row[columnAccessor];
if (column.accessor === 'status') {
if (columnAccessor === 'status') {
return (
<td data-qa={`process-instance-status-${value}`}>
{formatter(row, value)}
</td>
);
}
if (column.accessor === 'process_model_display_name') {
if (columnAccessor === 'process_model_display_name') {
return <td> {formatter(row, value)} </td>;
}
if (column.accessor === 'waiting_for') {
if (columnAccessor === 'waiting_for') {
return <td> {getWaitingForTableCellComponent(row)} </td>;
}
if (column.accessor === 'updated_at_in_seconds') {
if (columnAccessor === 'updated_at_in_seconds') {
return (
<TableCellWithTimeAgoInWords
timeInSeconds={row.updated_at_in_seconds}
/>
);
}
if (column.accessor === 'task_updated_at_in_seconds') {
if (columnAccessor === 'task_updated_at_in_seconds') {
return (
<TableCellWithTimeAgoInWords
timeInSeconds={row.task_updated_at_in_seconds}
timeInSeconds={row.task_updated_at_in_seconds || 0}
/>
);
}
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<td data-qa={`process-instance-show-link-${column.accessor}`}>
<td data-qa={`process-instance-show-link-${columnAccessor}`}>
{formatter(row, value)}
</td>
);
@ -1561,27 +1571,15 @@ export default function ProcessInstanceListTable({
// eslint-disable-next-line sonarjs/cognitive-complexity
const buildTable = () => {
const headerLabels: Record<string, string> = {
id: 'Id',
process_model_identifier: 'Process',
process_model_display_name: 'Process',
start_in_seconds: 'Start Time',
end_in_seconds: 'End Time',
status: 'Status',
process_initiator_username: 'Started By',
};
const getHeaderLabel = (header: string) => {
return headerLabels[header] ?? header;
};
const headers = reportColumns().map((column: any) => {
return getHeaderLabel((column as any).Header);
const headers = reportColumns().map((column: ReportColumn) => {
return column.Header;
});
if (showActionsColumn) {
headers.push('Action');
}
const rows = processInstances.map((row: any) => {
const currentRow = reportColumns().map((column: any) => {
const rows = processInstances.map((row: ProcessInstance) => {
const currentRow = reportColumns().map((column: ReportColumn) => {
return formattedColumn(row, column);
});
if (showActionsColumn) {

View File

@ -33,9 +33,14 @@ export default function ProcessModelSearch({
const getProcessModelLabelForDisplay = (processModel: ProcessModel) => {
let processModelId = processModel.id;
if (truncateProcessModelDisplayName) {
processModelId = processModelId.split('/').slice(-2).join('/');
let processModelIdArray = processModelId.split('/');
if (processModelIdArray.length > 2) {
processModelIdArray = processModelIdArray.slice(-2);
processModelIdArray.unshift('...');
}
processModelId = processModelIdArray.join('/');
}
return `${processModel.display_name} (${processModelId})`;
return `${processModel.display_name} - ${processModelId}`;
};
const getProcessModelLabelForSearch = (processModel: ProcessModel) => {

View File

@ -1,7 +1,5 @@
/* eslint-disable sonarjs/cognitive-complexity */
// @ts-expect-error TS(7016) FIXME: Could not find a declaration file for module 'bpmn... Remove this comment to see the full error message
import BpmnModeler from 'bpmn-js/lib/Modeler';
// @ts-expect-error TS(7016) FIXME: Could not find a declaration file for module 'bpmn... Remove this comment to see the full error message
import BpmnViewer from 'bpmn-js/lib/Viewer';
import {
BpmnPropertiesPanelModule,
@ -144,11 +142,14 @@ export default function ReactDiagramEditor({
}
const temp = document.createElement('template');
const panelId: string =
diagramType === 'readonly'
? 'hidden-properties-panel'
: 'js-properties-panel';
temp.innerHTML = `
<div class="content with-diagram" id="js-drop-zone">
<div class="canvas ${canvasClass}" id="canvas"
></div>
<div class="properties-panel-parent" id="js-properties-panel"></div>
<div class="canvas ${canvasClass}" id="canvas"></div>
<div class="properties-panel-parent" id="${panelId}"></div>
</div>
`;
const frag = temp.content;

View File

@ -37,6 +37,11 @@ export interface EventDefinition {
message_var?: string;
}
export interface SignalButton {
label: string;
event: EventDefinition;
}
// TODO: merge with ProcessInstanceTask
export interface Task {
id: number;
@ -60,6 +65,7 @@ export interface Task {
can_complete: boolean;
form_schema: any;
form_ui_schema: any;
signal_buttons: SignalButton[];
}
export interface ProcessInstanceTask {
@ -138,6 +144,12 @@ export interface ProcessInstance {
bpmn_version_control_type: string;
process_metadata?: ProcessInstanceMetadata[];
process_model_with_diagram_identifier?: string;
// from tasks
potential_owner_usernames?: string;
task_id?: string;
task_updated_at_in_seconds?: number;
waiting_for?: string;
}
export interface MessageCorrelationProperties {

View File

@ -58,7 +58,6 @@ export default function ProcessInterstitial() {
// 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}`);
@ -103,9 +102,9 @@ export default function ProcessInterstitial() {
const getReturnHomeButton = (index: number) => {
if (
index === 0 &&
state !== 'REDIRECTING' &&
!shouldRedirect(lastTask) &&
['WAITING', 'ERROR', 'LOCKED', 'COMPLETED', 'READY'].includes(getStatus())
)
) {
return (
<div style={{ padding: '10px 0 0 0' }}>
<Button kind="secondary" onClick={() => navigate(`/tasks`)}>
@ -113,6 +112,7 @@ export default function ProcessInterstitial() {
</Button>
</div>
);
}
return '';
};
@ -165,7 +165,7 @@ export default function ProcessInterstitial() {
/** In the event there is no task information and the connection closed,
* redirect to the home page. */
if (state === 'closed' && lastTask === null) {
if (state === 'CLOSED' && lastTask === null) {
navigate(`/tasks`);
}
if (lastTask) {

View File

@ -18,7 +18,7 @@ import Form from '../themes/carbon';
import HttpService from '../services/HttpService';
import useAPIError from '../hooks/UseApiError';
import { modifyProcessIdentifierForPathParam } from '../helpers';
import { Task } from '../interfaces';
import { EventDefinition, Task } from '../interfaces';
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
import InstructionsForEndUser from '../components/InstructionsForEndUser';
@ -195,6 +195,23 @@ export default function TaskShow() {
});
};
const handleSignalSubmit = (event: EventDefinition) => {
if (disabled || !task) {
return;
}
HttpService.makeCallToBackend({
path: `/send-event/${modifyProcessIdentifierForPathParam(
task.process_model_identifier
)}/${params.process_instance_id}`,
successCallback: processSubmitResult,
failureCallback: (error: any) => {
addError(error);
},
httpMethod: 'POST',
postBody: event,
});
};
const buildTaskNavigation = () => {
let userTasksElement;
let selectedTabIndex = 0;
@ -355,6 +372,17 @@ export default function TaskShow() {
{submitButtonText}
</Button>
{saveAsDraftButton}
<>
{task.signal_buttons.map((signal) => (
<Button
name="signal.signal"
disabled={disabled}
onClick={() => handleSignalSubmit(signal.event)}
>
{signal.label}
</Button>
))}
</>
</ButtonSet>
);
}