diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index 780e50810..716b1dee5 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -1618,6 +1618,44 @@ paths: schema: $ref: "#/components/schemas/Workflow" + /task-assign/{modified_process_model_identifier}/{process_instance_id}/{task_guid}: + parameters: + - name: modified_process_model_identifier + in: path + required: true + description: The modified id of an existing process model + schema: + type: string + - name: process_instance_id + in: path + required: true + description: The unique id of an existing process instance. + schema: + type: integer + - name: task_guid + in: path + required: true + description: The unique id of the task. + schema: + type: string + post: + operationId: spiffworkflow_backend.routes.tasks_controller.task_assign + summary: Assign a given task to a list of additional users + tags: + - Process Instances + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/User" + responses: + "200": + description: "ok: true" + content: + application/json: + schema: + $ref: "#/components/schemas/OkTrue" + /process-data/{modified_process_model_identifier}/{process_instance_id}/{process_data_identifier}: parameters: - name: modified_process_model_identifier @@ -1724,7 +1762,7 @@ paths: required: true description: The unique id of the process instance schema: - type: string + type: integer - name: task_guid in: path required: true diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/db.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/db.py index 87dee6d3a..c7e1862a2 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/db.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/db.py @@ -47,6 +47,23 @@ class SpiffworkflowBaseDBModel(db.Model): # type: ignore return m_type.value + @classmethod + def commit_with_rollback_on_exception(cls) -> None: + """Attempts to commit the session and rolls back if it fails. + + We may need to add other error handling here as we go. But sqlalchemy insists that we + "frame" our commits. + + https://docs.sqlalchemy.org/en/20/errors.html#error-7s2a + https://docs.sqlalchemy.org/en/20/faq/sessions.html#faq-session-rollback + https://docs.sqlalchemy.org/en/20/orm/session_basics.html#session-faq-whentocreate + """ + try: + db.session.commit() + except Exception: + db.session.rollback() + raise + def update_created_modified_on_create_listener( mapper: Mapper, _connection: Connection, target: SpiffworkflowBaseDBModel diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py index 9b5f0bd20..5aa52ce77 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py @@ -29,6 +29,7 @@ from sqlalchemy.orm import aliased from sqlalchemy.orm.util import AliasedClass from spiffworkflow_backend.exceptions.api_error import ApiError +from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.human_task import HumanTaskModel @@ -173,13 +174,12 @@ def task_data_show( def task_data_update( - process_instance_id: str, + process_instance_id: int, modified_process_model_identifier: str, task_guid: str, body: dict, ) -> Response: - """Update task data.""" - process_instance = ProcessInstanceModel.query.filter(ProcessInstanceModel.id == int(process_instance_id)).first() + process_instance = ProcessInstanceModel.query.filter(ProcessInstanceModel.id == process_instance_id).first() if process_instance: if process_instance.status != "suspended": raise ProcessInstanceTaskDataCannotBeUpdatedError( @@ -227,13 +227,13 @@ def task_data_update( def manual_complete_task( modified_process_model_identifier: str, - process_instance_id: str, + process_instance_id: int, task_guid: str, body: dict, ) -> Response: """Mark a task complete without executing it.""" execute = body.get("execute", True) - process_instance = ProcessInstanceModel.query.filter(ProcessInstanceModel.id == int(process_instance_id)).first() + process_instance = ProcessInstanceModel.query.filter(ProcessInstanceModel.id == process_instance_id).first() if process_instance: processor = ProcessInstanceProcessor(process_instance) processor.manual_complete_task(task_guid, execute, g.user) @@ -249,6 +249,57 @@ def manual_complete_task( ) +def task_assign( + modified_process_model_identifier: str, + process_instance_id: int, + task_guid: str, + body: dict, +) -> Response: + process_instance = _find_process_instance_by_id_or_raise(process_instance_id) + + if process_instance.status != ProcessInstanceStatus.suspended.value: + raise ApiError( + error_code="error_not_suspended", + message="The process instance must be suspended to perform this operation", + status_code=400, + ) + + if "user_ids" not in body: + raise ApiError( + error_code="malformed_request", + message="user_ids as an array must be given in the body of the request.", + status_code=400, + ) + + _get_process_model( + process_instance.process_model_identifier, + ) + + task_model = _get_task_model_from_guid_or_raise(task_guid, process_instance_id) + human_tasks = HumanTaskModel.query.filter_by( + process_instance_id=process_instance.id, task_id=task_model.guid + ).all() + + if len(human_tasks) > 1: + raise ApiError( + error_code="multiple_tasks_found", + message="More than one ready tasks were found. This should never happen.", + status_code=400, + ) + + human_task = human_tasks[0] + + for user_id in body["user_ids"]: + human_task_user = HumanTaskUserModel.query.filter_by(user_id=user_id, human_task=human_task).first() + if human_task_user is None: + human_task_user = HumanTaskUserModel(user_id=user_id, human_task=human_task) + db.session.add(human_task_user) + + SpiffworkflowBaseDBModel.commit_with_rollback_on_exception() + + return make_response(jsonify({"ok": True}), 200) + + def task_show(process_instance_id: int, task_guid: str = "next") -> flask.wrappers.Response: process_instance = _find_process_instance_by_id_or_raise(process_instance_id) @@ -353,6 +404,15 @@ def task_show(process_instance_id: int, task_guid: str = "next") -> flask.wrappe return make_response(jsonify(task_model), 200) +def task_submit( + process_instance_id: int, + task_guid: str, + body: dict[str, Any], +) -> flask.wrappers.Response: + with sentry_sdk.start_span(op="controller_action", description="tasks_controller.task_submit"): + return _task_submit_shared(process_instance_id, task_guid, body) + + def _render_instructions_for_end_user(task_model: TaskModel, extensions: dict | None = None) -> str: """Assure any instructions for end user are processed for jinja syntax.""" if extensions is None: @@ -424,7 +484,7 @@ def _interstitial_stream( return # path used by the interstitial page while executing tasks - ie the background processor is not executing them - ready_engine_task_count = get_ready_engine_step_count(processor.bpmn_process_instance) + ready_engine_task_count = _get_ready_engine_step_count(processor.bpmn_process_instance) if execute_tasks and ready_engine_task_count == 0: break @@ -467,7 +527,7 @@ def _interstitial_stream( yield _render_data("task", task) -def get_ready_engine_step_count(bpmn_process_instance: BpmnWorkflow) -> int: +def _get_ready_engine_step_count(bpmn_process_instance: BpmnWorkflow) -> int: return len([t for t in bpmn_process_instance.get_tasks(TaskState.READY) if not t.task_spec.manual]) @@ -656,15 +716,6 @@ def _task_submit_shared( ) -def task_submit( - process_instance_id: int, - task_guid: str, - body: dict[str, Any], -) -> flask.wrappers.Response: - with sentry_sdk.start_span(op="controller_action", description="tasks_controller.task_submit"): - return _task_submit_shared(process_instance_id, task_guid, body) - - def _get_tasks( processes_started_by_user: bool = True, has_lane_assignment_id: bool = True, diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py index ba92f0e2e..7c2198d05 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py @@ -86,6 +86,7 @@ PATH_SEGMENTS_FOR_PERMISSION_ALL = [ {"path": "/process-model-natural-language", "relevant_permissions": ["create"]}, {"path": "/process-model-publish", "relevant_permissions": ["create"]}, {"path": "/process-model-tests", "relevant_permissions": ["create"]}, + {"path": "/task-assign", "relevant_permissions": ["create"]}, {"path": "/task-data", "relevant_permissions": ["read", "update"]}, ] @@ -546,6 +547,7 @@ class AuthorizationService: permissions_to_assign.append(PermissionToAssign(permission="create", target_uri="/task-complete/*")) # read comes from PG and PM ALL permissions as well + permissions_to_assign.append(PermissionToAssign(permission="create", target_uri="/task-assign/*")) permissions_to_assign.append(PermissionToAssign(permission="update", target_uri="/task-data/*")) permissions_to_assign.append(PermissionToAssign(permission="read", target_uri="/event-error-details/*")) permissions_to_assign.append(PermissionToAssign(permission="read", target_uri="/logs/*")) diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py index a32bb75fe..e754b1ed3 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py @@ -2493,6 +2493,7 @@ class TestProcessApi(BaseTest): content_type="application/json", data=json.dumps({"execute": False}), ) + assert response.json["status"] == "suspended" task_model = TaskModel.query.filter_by(guid=human_task["guid"]).first() assert task_model is not None diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py index 9e3a2c0cc..60cef8b2e 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py @@ -138,6 +138,7 @@ class TestAuthorizationService(BaseTest): ("/process-models/some-process-group:some-process-model:*", "delete"), ("/process-models/some-process-group:some-process-model:*", "read"), ("/process-models/some-process-group:some-process-model:*", "update"), + ("/task-assign/some-process-group:some-process-model:*", "create"), ("/task-data/some-process-group:some-process-model:*", "read"), ("/task-data/some-process-group:some-process-model:*", "update"), ] @@ -223,6 +224,7 @@ class TestAuthorizationService(BaseTest): ("/process-models/some-process-group:some-process-model/*", "delete"), ("/process-models/some-process-group:some-process-model/*", "read"), ("/process-models/some-process-group:some-process-model/*", "update"), + ("/task-assign/some-process-group:some-process-model/*", "create"), ("/task-data/some-process-group:some-process-model/*", "read"), ("/task-data/some-process-group:some-process-model/*", "update"), ] @@ -333,6 +335,7 @@ class TestAuthorizationService(BaseTest): ("/secrets/*", "read"), ("/secrets/*", "update"), ("/send-event/*", "create"), + ("/task-assign/*", "create"), ("/task-complete/*", "create"), ("/task-data/*", "update"), ("/task-data/*", "read"), diff --git a/spiffworkflow-frontend/src/components/ProcessInterstitial.tsx b/spiffworkflow-frontend/src/components/ProcessInterstitial.tsx index 2e79fa0c3..e236972d3 100644 --- a/spiffworkflow-frontend/src/components/ProcessInterstitial.tsx +++ b/spiffworkflow-frontend/src/components/ProcessInterstitial.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { fetchEventSource } from '@microsoft/fetch-event-source'; // @ts-ignore @@ -10,6 +10,7 @@ import { getBasicHeaders } from '../services/HttpService'; import InstructionsForEndUser from './InstructionsForEndUser'; import { ProcessInstance, ProcessInstanceTask } from '../interfaces'; import useAPIError from '../hooks/UseApiError'; +import { HUMAN_TASK_TYPES } from '../helpers'; type OwnProps = { processInstanceId: number; @@ -35,9 +36,6 @@ export default function ProcessInterstitial({ useState(null); const navigate = useNavigate(); - const userTasks = useMemo(() => { - return ['User Task', 'Manual Task']; - }, []); const { addError } = useAPIError(); useEffect(() => { @@ -77,10 +75,10 @@ export default function ProcessInterstitial({ !processInstance && myTask && myTask.can_complete && - userTasks.includes(myTask.type) + HUMAN_TASK_TYPES.includes(myTask.type) ); }, - [allowRedirect, processInstance, userTasks] + [allowRedirect, processInstance] ); const shouldRedirectToProcessInstance = useCallback((): boolean => { @@ -105,7 +103,6 @@ export default function ProcessInterstitial({ }, [ lastTask, navigate, - userTasks, shouldRedirectToTask, processInstanceId, processInstanceShowPageUrl, @@ -181,7 +178,7 @@ export default function ProcessInterstitial({ return userMessageForProcessInstance(processInstance, myTask); } - if (!myTask.can_complete && userTasks.includes(myTask.type)) { + if (!myTask.can_complete && HUMAN_TASK_TYPES.includes(myTask.type)) { return inlineMessage( '', `This next task is assigned to a different person or team. There is no action for you to take at this time.` @@ -190,7 +187,11 @@ export default function ProcessInterstitial({ if (shouldRedirectToTask(myTask)) { return inlineMessage('', `Redirecting ...`); } - if (myTask && myTask.can_complete && userTasks.includes(myTask.type)) { + if ( + myTask && + myTask.can_complete && + HUMAN_TASK_TYPES.includes(myTask.type) + ) { return inlineMessage( '', `The task "${myTask.title}" is ready for you to complete.` diff --git a/spiffworkflow-frontend/src/components/UserSearch.tsx b/spiffworkflow-frontend/src/components/UserSearch.tsx new file mode 100644 index 000000000..93bd1a95a --- /dev/null +++ b/spiffworkflow-frontend/src/components/UserSearch.tsx @@ -0,0 +1,72 @@ +import { ComboBox } from '@carbon/react'; +import { useRef, useState } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; +import { CarbonComboBoxSelection, User } from '../interfaces'; +import HttpService from '../services/HttpService'; + +type OwnProps = { + onSelectedUser: Function; + label?: string; + className?: string; +}; + +export default function UserSearch({ + onSelectedUser, + className, + label = 'User', +}: OwnProps) { + const lastRequestedInitatorSearchTerm = useRef(); + const [selectedUser, setSelectedUser] = useState(null); + const [userList, setUserList] = useState([]); + + const handleUserSearchResult = (result: any, inputText: string) => { + if (lastRequestedInitatorSearchTerm.current === result.username_prefix) { + setUserList(result.users); + result.users.forEach((user: User) => { + if (user.username === inputText) { + setSelectedUser(user); + } + }); + } + }; + + const searchForUser = (inputText: string) => { + if (inputText) { + lastRequestedInitatorSearchTerm.current = inputText; + HttpService.makeCallToBackend({ + path: `/users/search?username_prefix=${inputText}`, + successCallback: (result: any) => + handleUserSearchResult(result, inputText), + }); + } + }; + + const addDebouncedSearchUser = useDebouncedCallback( + (value: string) => { + searchForUser(value); + }, + // delay in ms + 250 + ); + return ( + { + onSelectedUser(selection.selectedItem); + }} + id="user-search" + data-qa="user-search" + items={userList} + itemToString={(processInstanceInitatorOption: User) => { + if (processInstanceInitatorOption) { + return processInstanceInitatorOption.username; + } + return null; + }} + placeholder="Start typing username" + titleText={label} + selectedItem={selectedUser} + /> + ); +} diff --git a/spiffworkflow-frontend/src/helpers.tsx b/spiffworkflow-frontend/src/helpers.tsx index 150e31991..394b36aa5 100644 --- a/spiffworkflow-frontend/src/helpers.tsx +++ b/spiffworkflow-frontend/src/helpers.tsx @@ -26,6 +26,13 @@ export const slugifyString = (str: any) => { .replace(/-+$/g, ''); }; +export const HUMAN_TASK_TYPES = [ + 'User Task', + 'Manual Task', + 'UserTask', + 'ManualTask', +]; + export const underscorizeString = (inputString: string) => { return slugifyString(inputString).replace(/-/g, '_'); }; diff --git a/spiffworkflow-frontend/src/hooks/UriListForPermissions.tsx b/spiffworkflow-frontend/src/hooks/UriListForPermissions.tsx index ed9446c6d..dfce135d0 100644 --- a/spiffworkflow-frontend/src/hooks/UriListForPermissions.tsx +++ b/spiffworkflow-frontend/src/hooks/UriListForPermissions.tsx @@ -21,6 +21,7 @@ export const useUriListForPermissions = () => { processInstanceResumePath: `/v1.0/process-instance-resume/${params.process_model_id}/${params.process_instance_id}`, processInstanceSendEventPath: `/v1.0/send-event/${params.process_model_id}/${params.process_instance_id}`, processInstanceSuspendPath: `/v1.0/process-instance-suspend/${params.process_model_id}/${params.process_instance_id}`, + processInstanceTaskAssignPath: `/v1.0/task-assign/${params.process_model_id}/${params.process_instance_id}`, processInstanceTaskDataPath: `/v1.0/task-data/${params.process_model_id}/${params.process_instance_id}`, processInstanceTaskListForMePath: `/v1.0/process-instances/for-me/${params.process_model_id}/${params.process_instance_id}/task-info`, processInstanceTaskListPath: `/v1.0/process-instances/${params.process_model_id}/${params.process_instance_id}/task-info`, diff --git a/spiffworkflow-frontend/src/index.css b/spiffworkflow-frontend/src/index.css index 0e1725291..1b7da7c86 100644 --- a/spiffworkflow-frontend/src/index.css +++ b/spiffworkflow-frontend/src/index.css @@ -44,7 +44,7 @@ h1 { font-size: 28px; line-height: 36px; color: #161616; - margin-bottom: 1em + margin-bottom: 1rem } h2 { @@ -54,6 +54,13 @@ h2 { color: #161616; } +h3 { + font-weight: 400; + font-size: 18px; + line-height: 20px; + color: #161616; +} + .span-tag { color: black; } @@ -137,12 +144,12 @@ code { .app-logo { height: 37px; width: 152px; - margin-top: 1em; - margin-bottom: 1em; + margin-top: 1rem; + margin-bottom: 1rem; } .spiffworkflow-header-container { - margin-bottom: 2em; + margin-bottom: 2rem; } .active-task-highlight:not(.djs-connection) .djs-visual > :nth-child(1) { @@ -168,11 +175,11 @@ code { } .cds--breadcrumb { - margin-bottom: 1em; + margin-bottom: 2rem; } .process-description { - margin-bottom: 2em; + margin-bottom: 2rem; } h1.with-icons { @@ -211,7 +218,7 @@ dl dd { } .with-bottom-margin { - margin-bottom: 1em; + margin-bottom: 1rem; } .user-profile-toggletip-content { @@ -253,15 +260,15 @@ dl dd { } .with-top-margin { - margin-top: 1em; + margin-top: 1rem; } .with-extra-top-margin { - margin-top: 1.3em; + margin-top: 1.3rem; } .with-top-margin-for-label-next-to-text-input { - margin-top: 2.3em; + margin-top: 2.3rem; } .with-tiny-top-margin { @@ -273,13 +280,17 @@ dl dd { } .with-large-bottom-margin { - margin-bottom: 3em; + margin-bottom: 3rem; } .with-tiny-bottom-margin { margin-bottom: 4px; } +.with-half-rem-bottom-margin { + margin-bottom: .5rem; +} + .diagram-viewer-canvas { border:1px solid #000000; height:70vh; @@ -288,7 +299,7 @@ dl dd { } .breadcrumb { - font-size: 1.5em; + font-size: 1.5rem; } .breadcrumb-item.active { @@ -296,7 +307,7 @@ dl dd { } .container .nav-tabs { - margin-top: 1em; + margin-top: 1rem; } @@ -314,7 +325,7 @@ dl dd { } .markdown table th, .markdown table td { - padding: .5em; + padding: .5rem; border: 1px solid lightgrey; } /* Zebra Table Style */ @@ -323,7 +334,7 @@ dl dd { } .form-instructions { - margin-bottom: 10em; + margin-bottom: 10rem; } /* Json Web Form CSS Fix - Bootstrap now requries that each li have a "list-inline-item." Also have a PR @@ -347,13 +358,13 @@ in on this with the react-jsonschema-form repo. This is just a patch fix to allo .tile-process-group-content-container { width: 320px; height: 264px; - padding: 1em; + padding: 1rem; position: relative; } .tile-process-group-display-name { - margin-top: 2em; - margin-bottom: 1em; + margin-top: 2rem; + margin-bottom: 1rem; font-size: 20px; line-height: 28px; color: #161616; @@ -361,7 +372,7 @@ in on this with the react-jsonschema-form repo. This is just a patch fix to allo } .tile-title-top { - margin-bottom: 2em; + margin-bottom: 2rem; font-size: 20px; line-height: 28px; color: #161616; @@ -386,7 +397,7 @@ in on this with the react-jsonschema-form repo. This is just a patch fix to allo .tile-pin-bottom { position: absolute; - bottom: 1em; + bottom: 1rem; } .cds--tabs .cds--tabs__nav-link { @@ -398,7 +409,7 @@ in on this with the react-jsonschema-form repo. This is just a patch fix to allo } td.actions-cell { - width: 1em; + width: 1rem; } .process-instance-list-table { @@ -411,12 +422,12 @@ td.actions-cell { } .process-instance-table-header { - margin-bottom: 1em; + margin-bottom: 1rem; } .no-results-message { font-style: italic; - margin-left: 2em; + margin-left: 2rem; font-size: 14px; } @@ -425,12 +436,12 @@ td.actions-cell { line-height: 18px; letter-spacing: 0.16px; color: #525252; - margin-bottom: 1em; + margin-bottom: 1rem; } /* top and bottom margin since this is sort of the middle of three sections on the process model show page */ .process-model-files-section { - margin: 2em 0; + margin: 2rem 0; } .filterIcon { @@ -525,7 +536,7 @@ svg.notification-icon { } .please-press-filter-button { - margin-bottom: 1em; + margin-bottom: 1rem; font-weight: bold; } @@ -537,28 +548,28 @@ svg.notification-icon { .user_instructions_0 { filter: opacity(1); - font-size: 1.2em; + font-size: 1.2rem; margin-bottom: 30px; } .user_instructions_1 { filter: opacity(60%); - font-size: 1.1em; + font-size: 1.1rem; } .user_instructions_2 { filter: opacity(40%); - font-size: 1em; + font-size: 1rem; } .user_instructions_3 { filter: opacity(20%); - font-size: 9em; + font-size: 9rem; } .user_instructions_4 { filter: opacity(10%); - font-size: 8em; + font-size: 8rem; } .float-right { @@ -649,7 +660,25 @@ hr { font-style: italic; } +.modal-dropdown { + height: 20rem; + width: "auto"; +} + +.task-data-details-header { + margin-top: 1.5rem; + margin-bottom: .5rem; +} + +.explanatory-message { + font-style: italic; + font-size: 14px; +} + +.indented-content { + margin-left: 1rem; +} + #hidden-form-for-autosave { display: none; } - diff --git a/spiffworkflow-frontend/src/interfaces.ts b/spiffworkflow-frontend/src/interfaces.ts index 13bf9e7c6..d08ae2194 100644 --- a/spiffworkflow-frontend/src/interfaces.ts +++ b/spiffworkflow-frontend/src/interfaces.ts @@ -26,10 +26,6 @@ export interface TaskPropertiesJson { last_state_change: number; } -export interface TaskDefinitionPropertiesJson { - spec: string; -} - export interface EventDefinition { typename: string; payload: any; @@ -38,6 +34,11 @@ export interface EventDefinition { message_var?: string; } +export interface TaskDefinitionPropertiesJson { + spec: string; + event_definition: EventDefinition; +} + export interface SignalButton { label: string; event: EventDefinition; @@ -286,7 +287,7 @@ export interface PaginationObject { } export interface CarbonComboBoxSelection { - selectedItem: ProcessModel; + selectedItem: any; } export interface CarbonComboBoxProcessSelection { diff --git a/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx b/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx index fc715076e..06d1d3289 100644 --- a/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx @@ -7,12 +7,20 @@ import { useSearchParams, } from 'react-router-dom'; import { - TrashCan, - StopOutline, - PauseOutline, - PlayOutline, - InProgress, + Send, + ButtonSet, Checkmark, + Edit, + InProgress, + PauseOutline, + UserFollow, + Play, + PlayOutline, + Reset, + RuleDraft, + SkipForward, + StopOutline, + TrashCan, Warning, // @ts-ignore } from '@carbon/icons-react'; @@ -37,6 +45,7 @@ import HttpService from '../services/HttpService'; import ReactDiagramEditor from '../components/ReactDiagramEditor'; import { convertSecondsToFormattedDateTime, + HUMAN_TASK_TYPES, modifyProcessIdentifierForPathParam, unModifyProcessIdentifierForPathParam, } from '../helpers'; @@ -50,12 +59,14 @@ import { ProcessInstance, Task, TaskDefinitionPropertiesJson, + User, } from '../interfaces'; import { usePermissionFetcher } from '../hooks/PermissionService'; import ProcessInstanceClass from '../classes/ProcessInstanceClass'; import TaskListTable from '../components/TaskListTable'; import useAPIError from '../hooks/UseApiError'; import ProcessInterstitial from '../components/ProcessInterstitial'; +import UserSearch from '../components/UserSearch'; import ProcessInstanceLogList from '../components/ProcessInstanceLogList'; import MessageInstanceList from '../components/MessageInstanceList'; @@ -68,6 +79,8 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { const params = useParams(); const [searchParams] = useSearchParams(); + const eventsThatNeedPayload = ['MessageEventDefinition']; + const [processInstance, setProcessInstance] = useState(null); const [tasks, setTasks] = useState(null); @@ -89,6 +102,12 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { const [eventTextEditorEnabled, setEventTextEditorEnabled] = useState(false); + const [addingPotentialOwners, setAddingPotentialOwners] = + useState(false); + const [additionalPotentialOwners, setAdditionalPotentialOwners] = useState< + User[] | null + >(null); + const { addError, removeError } = useAPIError(); const unModifiedProcessModelId = unModifyProcessIdentifierForPathParam( `${params.process_model_id}` @@ -109,6 +128,7 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { [targetUris.messageInstanceListPath]: ['GET'], [targetUris.processInstanceActionPath]: ['DELETE', 'GET'], [targetUris.processInstanceLogListPath]: ['GET'], + [targetUris.processInstanceTaskAssignPath]: ['POST'], [targetUris.processInstanceTaskDataPath]: ['GET', 'PUT'], [targetUris.processInstanceSendEventPath]: ['POST'], [targetUris.processInstanceCompleteTaskPath]: ['POST'], @@ -539,9 +559,22 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { } }; + const resetTaskActionDetails = () => { + setEditingTaskData(false); + setSelectingEvent(false); + setAddingPotentialOwners(false); + initializeTaskDataToDisplay(taskToDisplay); + setEventPayload('{}'); + setAdditionalPotentialOwners(null); + removeError(); + }; + const handleTaskDataDisplayClose = () => { setTaskToDisplay(null); initializeTaskDataToDisplay(null); + if (editingTaskData || selectingEvent || addingPotentialOwners) { + resetTaskActionDetails(); + } }; const getTaskById = (taskId: string) => { @@ -602,8 +635,9 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { const canSendEvent = (task: Task) => { // We actually could allow this for any waiting events - const taskTypes = ['Event Based Gateway']; + const taskTypes = ['EventBasedGateway']; return ( + !selectingEvent && processInstance && processInstance.status === 'waiting' && ability.can('POST', targetUris.processInstanceSendEventPath) && @@ -623,6 +657,17 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { ); }; + const canAddPotentialOwners = (task: Task) => { + return ( + HUMAN_TASK_TYPES.includes(task.typename) && + processInstance && + processInstance.status === 'suspended' && + ability.can('POST', targetUris.processInstanceTaskAssignPath) && + isActiveTask(task) && + showingActiveTask() + ); + }; + const canResetProcess = (task: Task) => { return ( ability.can('POST', targetUris.processInstanceResetPath) && @@ -635,7 +680,7 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { const getEvents = (task: Task) => { const handleMessage = (eventDefinition: EventDefinition) => { - if (eventDefinition.typename === 'MessageEventDefinition') { + if (eventsThatNeedPayload.includes(eventDefinition.typename)) { const newEvent = eventDefinition; delete newEvent.message_var; newEvent.payload = {}; @@ -643,22 +688,16 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { } return eventDefinition; }; - if (task.event_definition && task.event_definition.event_definitions) - return task.event_definition.event_definitions.map((e: EventDefinition) => + const eventDefinition = + task.task_definition_properties_json.event_definition; + if (eventDefinition && eventDefinition.event_definitions) + return eventDefinition.event_definitions.map((e: EventDefinition) => handleMessage(e) ); - if (task.event_definition) return [handleMessage(task.event_definition)]; + if (eventDefinition) return [handleMessage(eventDefinition)]; return []; }; - const cancelUpdatingTask = () => { - setEditingTaskData(false); - setSelectingEvent(false); - initializeTaskDataToDisplay(taskToDisplay); - setEventPayload('{}'); - removeError(); - }; - const taskDataStringToObject = (dataString: string) => { return JSON.parse(dataString); }; @@ -673,7 +712,6 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { }; // spread operator setTaskToDisplay(taskToDisplayCopy); } - refreshPage(); }; const saveTaskData = () => { @@ -695,13 +733,35 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { }); }; + const addPotentialOwners = () => { + if (!additionalPotentialOwners) { + return; + } + if (!taskToDisplay) { + return; + } + removeError(); + + const userIds = additionalPotentialOwners.map((user: User) => user.id); + + HttpService.makeCallToBackend({ + path: `${targetUris.processInstanceTaskAssignPath}/${taskToDisplay.guid}`, + httpMethod: 'POST', + successCallback: resetTaskActionDetails, + failureCallback: addError, + postBody: { + user_ids: userIds, + }, + }); + }; + const sendEvent = () => { if ('payload' in eventToSend) eventToSend.payload = JSON.parse(eventPayload); HttpService.makeCallToBackend({ path: targetUris.processInstanceSendEventPath, httpMethod: 'POST', - successCallback: saveTaskDataResult, + successCallback: refreshPage, failureCallback: addError, postBody: eventToSend, }); @@ -720,18 +780,24 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { const taskDisplayButtons = (task: Task) => { const buttons = []; + if (editingTaskData || addingPotentialOwners || selectingEvent) { + return null; + } if ( - task.typename === 'Script Task' && + task.typename === 'ScriptTask' && ability.can('PUT', targetUris.processModelShowPath) ) { buttons.push( + /> ); } @@ -749,90 +815,94 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { ); } - if (editingTaskData) { - buttons.push( - - ); + if (canEditTaskData(task)) { buttons.push( + kind="ghost" + renderIcon={Edit} + align="top-left" + iconDescription="Edit Task Data" + hasIconOnly + data-qa="edit-task-data-button" + onClick={() => setEditingTaskData(true)} + /> + ); + } + if (canAddPotentialOwners(task)) { + buttons.push( + + ); + buttons.push( + + ); + } + if (canSendEvent(task)) { + buttons.push( + + ); + } + if (canResetProcess(task)) { + let titleText = + 'This will reset (rewind) the process to put it into a state as if the execution of the process never went past this task. '; + titleText += 'Yes, we invented a time machine. '; + titleText += + 'And no, you cannot change your mind after using this feature.'; + buttons.push( + - ); - buttons.push( - - ); - } else { - if (canEditTaskData(task)) { - buttons.push( - - ); - } - if (canCompleteTask(task)) { - buttons.push( - - ); - buttons.push( - - ); - } - if (canSendEvent(task)) { - buttons.push( - - ); - } - if (canResetProcess(task)) { - let titleText = - 'This will reset (rewind) the process to put it into a state as if the execution of the process never went past this task. '; - titleText += 'Yes, we invented a time machine. '; - titleText += 'And no, you cannot go back after using this feature.'; - buttons.push( - - ); - } } - return buttons; }; @@ -841,104 +911,211 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { if (taskDataToDisplay.startsWith('ERROR:')) { taskDataClassName = 'failure-string'; } - return editingTaskData ? ( - setTaskDataToDisplay(value || '')} - /> - ) : ( + const numberOfLines = taskDataToDisplay.split('\n').length; + let heightInEm = numberOfLines + 5; + let scrollEnabled = false; + let minimapEnabled = false; + if (heightInEm > 30) { + heightInEm = 30; + scrollEnabled = true; + minimapEnabled = true; + } + let taskDataHeader = 'Task data'; + let editorReadOnly = true; + let taskDataHeaderClassName = 'with-half-rem-bottom-margin'; + + if (editingTaskData) { + editorReadOnly = false; + taskDataHeader = 'Edit task data'; + taskDataHeaderClassName = 'task-data-details-header'; + } + + if (!taskDataToDisplay) { + return null; + } + + return ( <> {showTaskDataLoading ? ( ) : null} -
{taskDataToDisplay}
+ {taskDataClassName !== '' ? ( +
{taskDataToDisplay}
+ ) : ( + <> +

{taskDataHeader}

+ { + setTaskDataToDisplay(value || ''); + }} + options={{ + readOnly: editorReadOnly, + scrollBeyondLastLine: scrollEnabled, + minimap: { enabled: minimapEnabled }, + }} + /> + + )} ); }; - const eventSelector = (candidateEvents: any) => { - const editor = ( - setEventPayload(value || '{}')} - options={{ readOnly: !eventTextEditorEnabled }} - /> - ); - return selectingEvent ? ( + const potentialOwnerSelector = () => { + return ( - item.name || item.label || item.typename} - onChange={(value: any) => { - setEventToSend(value.selectedItem); - setEventTextEditorEnabled( - value.selectedItem.typename === 'MessageEventDefinition' - ); - }} - /> - {editor} +

Update task ownership

+
+

+ Select a user who should be allowed to complete this task +

+ { + setAdditionalPotentialOwners([user]); + }} + /> +
- ) : ( - taskDataContainer() ); }; + const eventSelector = (candidateEvents: any) => { + let editor = null; + let className = 'modal-dropdown'; + if (eventTextEditorEnabled) { + className = ''; + editor = ( + setEventPayload(value || '{}')} + options={{ readOnly: !eventTextEditorEnabled }} + /> + ); + } + return ( + +

Choose event to send

+
+

+ Select an event to send. A message event will require a body as + well. +

+ + item.name || item.label || item.typename + } + onChange={(value: any) => { + setEventToSend(value.selectedItem); + setEventTextEditorEnabled( + eventsThatNeedPayload.includes(value.selectedItem.typename) + ); + }} + /> + {editor} +
+
+ ); + }; + + const taskActionDetails = () => { + if (!taskToDisplay) { + return null; + } + let dataArea = taskDataContainer(); + if (selectingEvent) { + const candidateEvents: any = getEvents(taskToDisplay); + dataArea = eventSelector(candidateEvents); + } else if (addingPotentialOwners) { + dataArea = potentialOwnerSelector(); + } + return dataArea; + }; + const taskUpdateDisplayArea = () => { if (!taskToDisplay) { return null; } const taskToUse: Task = { ...taskToDisplay, data: taskDataToDisplay }; - const candidateEvents: any = getEvents(taskToUse); - if (taskToDisplay) { - let taskTitleText = taskToUse.guid; - if (taskToUse.bpmn_name) { - taskTitleText += ` (${taskToUse.bpmn_name})`; - } - return ( - - - {taskToUse.bpmn_identifier} ( - {taskToUse.typename} - ): {taskToUse.state} - {taskDisplayButtons(taskToUse)} - + + let primaryButtonText = 'Close'; + let secondaryButtonText = null; + let onRequestSubmit = handleTaskDataDisplayClose; + let onSecondarySubmit = handleTaskDataDisplayClose; + let dangerous = false; + if (editingTaskData) { + primaryButtonText = 'Save'; + secondaryButtonText = 'Cancel'; + onSecondarySubmit = resetTaskActionDetails; + onRequestSubmit = saveTaskData; + dangerous = true; + } else if (selectingEvent) { + primaryButtonText = 'Send'; + secondaryButtonText = 'Cancel'; + onSecondarySubmit = resetTaskActionDetails; + onRequestSubmit = sendEvent; + dangerous = true; + } else if (addingPotentialOwners) { + primaryButtonText = 'Add'; + secondaryButtonText = 'Cancel'; + onSecondarySubmit = resetTaskActionDetails; + onRequestSubmit = addPotentialOwners; + dangerous = true; + } + + return ( + +
+ {taskToUse.bpmn_name ? ( +
+ + Name: {taskToUse.bpmn_name} + +
+ ) : null} +
Guid: {taskToUse.guid}
- {taskToUse.state === 'COMPLETED' ? ( -
- - {completionViewLink( - 'View process instance at the time when this task was active.', - taskToUse.guid - )} - -
-
-
- ) : null} - {selectingEvent - ? eventSelector(candidateEvents) - : taskDataContainer()} - - ); - } - return null; +
+ {taskDisplayButtons(taskToUse)} + {taskToUse.state === 'COMPLETED' ? ( +
+ + {completionViewLink( + 'View process instance at the time when this task was active.', + taskToUse.guid + )} + +
+
+
+ ) : null} + {taskActionDetails()} +
+ ); }; const buttonIcons = () => { diff --git a/spiffworkflow-frontend/src/routes/SecretNew.tsx b/spiffworkflow-frontend/src/routes/SecretNew.tsx index 96116e7b2..a209c86d8 100644 --- a/spiffworkflow-frontend/src/routes/SecretNew.tsx +++ b/spiffworkflow-frontend/src/routes/SecretNew.tsx @@ -71,9 +71,6 @@ export default function SecretNew() { }} /> - +