Feature/task assignment (#352)

* added an api to assign a list of users to a task w/ burnettk

* use the modal submit and close buttons when saving task data on the instance show page w/ burnettk

* switch save and cancel buttons on secrets new page w/ burnettk

* add some icons, tho still missing event stuff

* finished adding imporoved icons and fixing up task modal w/ burnettk

* added some user search options to assig tasks to w/ burnettk

* cleaned up task details modal and added call to backend to add potential users w/ burnettk

* fixed broken tests w/ burnettk

* removed some merge comments w/ burnettk

* process instance id is an int not a str w/ burnettk

---------

Co-authored-by: jasquat <jasquat@users.noreply.github.com>
Co-authored-by: burnettk <burnettk@users.noreply.github.com>
This commit is contained in:
jasquat 2023-07-12 10:14:01 -04:00 committed by GitHub
parent 93b8c09e90
commit 841f3ccc8c
14 changed files with 645 additions and 245 deletions

View File

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

View File

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

View File

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

View File

@ -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/*"))

View File

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

View File

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

View File

@ -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<ProcessInstance | null>(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.`

View File

@ -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<string>();
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [userList, setUserList] = useState<User[]>([]);
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 (
<ComboBox
onInputChange={addDebouncedSearchUser}
className={className}
onChange={(selection: CarbonComboBoxSelection) => {
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}
/>
);
}

View File

@ -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, '_');
};

View File

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

View File

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

View File

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

View File

@ -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<ProcessInstance | null>(null);
const [tasks, setTasks] = useState<Task[] | null>(null);
@ -89,6 +102,12 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
const [eventTextEditorEnabled, setEventTextEditorEnabled] =
useState<boolean>(false);
const [addingPotentialOwners, setAddingPotentialOwners] =
useState<boolean>(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(
<Button
kind="ghost"
align="top-left"
renderIcon={RuleDraft}
iconDescription="Create Script Unit Test"
hasIconOnly
data-qa="create-script-unit-test-button"
onClick={createScriptUnitTest}
>
Create Script Unit Test
</Button>
/>
);
}
@ -749,90 +815,94 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
);
}
if (editingTaskData) {
buttons.push(
<Button data-qa="save-task-data-button" onClick={saveTaskData}>
Save
</Button>
);
if (canEditTaskData(task)) {
buttons.push(
<Button
data-qa="cancel-task-data-edit-button"
onClick={cancelUpdatingTask}
>
Cancel
</Button>
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(
<Button
kind="ghost"
renderIcon={UserFollow}
align="top-left"
iconDescription="Assign user"
title="Allow an additional user to complete this task"
hasIconOnly
data-qa="add-potential-owners-button"
onClick={() => setAddingPotentialOwners(true)}
/>
);
}
if (canCompleteTask(task)) {
buttons.push(
<Button
kind="ghost"
renderIcon={Play}
align="top-left"
iconDescription="Execute Task"
hasIconOnly
data-qa="execute-task-complete-button"
onClick={() => completeTask(true)}
>
Execute Task
</Button>
);
buttons.push(
<Button
kind="ghost"
renderIcon={SkipForward}
align="top-left"
iconDescription="Skip Task"
hasIconOnly
data-qa="mark-task-complete-button"
onClick={() => completeTask(false)}
>
Skip Task
</Button>
);
}
if (canSendEvent(task)) {
buttons.push(
<Button
kind="ghost"
renderIcon={Send}
align="top-left"
iconDescription="Send Event"
hasIconOnly
data-qa="select-event-button"
onClick={() => setSelectingEvent(true)}
>
Send Event
</Button>
);
}
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(
<Button
kind="ghost"
renderIcon={Reset}
hasIconOnly
iconDescription="Reset Process Here"
title={titleText}
data-qa="reset-process-button"
onClick={() => resetProcessInstance()}
/>
);
} else if (selectingEvent) {
buttons.push(
<Button data-qa="send-event-button" onClick={sendEvent}>
Send
</Button>
);
buttons.push(
<Button
data-qa="cancel-task-data-edit-button"
onClick={cancelUpdatingTask}
>
Cancel
</Button>
);
} else {
if (canEditTaskData(task)) {
buttons.push(
<Button
data-qa="edit-task-data-button"
onClick={() => setEditingTaskData(true)}
>
Edit
</Button>
);
}
if (canCompleteTask(task)) {
buttons.push(
<Button
data-qa="mark-task-complete-button"
onClick={() => completeTask(false)}
>
Skip Task
</Button>
);
buttons.push(
<Button
data-qa="execute-task-complete-button"
onClick={() => completeTask(true)}
>
Execute Task
</Button>
);
}
if (canSendEvent(task)) {
buttons.push(
<Button
data-qa="select-event-button"
onClick={() => setSelectingEvent(true)}
>
Send Event
</Button>
);
}
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(
<Button
title={titleText}
data-qa="reset-process-button"
onClick={() => resetProcessInstance()}
>
Reset Process Here
</Button>
);
}
}
return buttons;
};
@ -841,104 +911,211 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
if (taskDataToDisplay.startsWith('ERROR:')) {
taskDataClassName = 'failure-string';
}
return editingTaskData ? (
<Editor
height={600}
width="auto"
defaultLanguage="json"
defaultValue={taskDataToDisplay}
onChange={(value) => 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 ? (
<Loading className="some-class" withOverlay={false} small />
) : null}
<pre className={taskDataClassName}>{taskDataToDisplay}</pre>
{taskDataClassName !== '' ? (
<pre className={taskDataClassName}>{taskDataToDisplay}</pre>
) : (
<>
<h3 className={taskDataHeaderClassName}>{taskDataHeader}</h3>
<Editor
height={`${heightInEm}rem`}
width="auto"
defaultLanguage="json"
defaultValue={taskDataToDisplay}
onChange={(value) => {
setTaskDataToDisplay(value || '');
}}
options={{
readOnly: editorReadOnly,
scrollBeyondLastLine: scrollEnabled,
minimap: { enabled: minimapEnabled },
}}
/>
</>
)}
</>
);
};
const eventSelector = (candidateEvents: any) => {
const editor = (
<Editor
height={300}
width="auto"
defaultLanguage="json"
defaultValue={eventPayload}
onChange={(value: any) => setEventPayload(value || '{}')}
options={{ readOnly: !eventTextEditorEnabled }}
/>
);
return selectingEvent ? (
const potentialOwnerSelector = () => {
return (
<Stack orientation="vertical">
<Dropdown
id="process-instance-select-event"
titleText="Event"
label="Select Event"
items={candidateEvents}
itemToString={(item: any) => item.name || item.label || item.typename}
onChange={(value: any) => {
setEventToSend(value.selectedItem);
setEventTextEditorEnabled(
value.selectedItem.typename === 'MessageEventDefinition'
);
}}
/>
{editor}
<h3 className="task-data-details-header">Update task ownership</h3>
<div className="indented-content">
<p className="explanatory-message with-tiny-bottom-margin">
Select a user who should be allowed to complete this task
</p>
<UserSearch
className="modal-dropdown"
onSelectedUser={(user: User) => {
setAdditionalPotentialOwners([user]);
}}
/>
</div>
</Stack>
) : (
taskDataContainer()
);
};
const eventSelector = (candidateEvents: any) => {
let editor = null;
let className = 'modal-dropdown';
if (eventTextEditorEnabled) {
className = '';
editor = (
<Editor
height={300}
width="auto"
defaultLanguage="json"
defaultValue={eventPayload}
onChange={(value: any) => setEventPayload(value || '{}')}
options={{ readOnly: !eventTextEditorEnabled }}
/>
);
}
return (
<Stack orientation="vertical">
<h3 className="task-data-details-header">Choose event to send</h3>
<div className="indented-content">
<p className="explanatory-message with-tiny-bottom-margin">
Select an event to send. A message event will require a body as
well.
</p>
<Dropdown
id="process-instance-select-event"
className={className}
label="Select Event"
items={candidateEvents}
itemToString={(item: any) =>
item.name || item.label || item.typename
}
onChange={(value: any) => {
setEventToSend(value.selectedItem);
setEventTextEditorEnabled(
eventsThatNeedPayload.includes(value.selectedItem.typename)
);
}}
/>
{editor}
</div>
</Stack>
);
};
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 (
<Modal
open={!!taskToUse}
passiveModal
onRequestClose={handleTaskDataDisplayClose}
>
<Stack orientation="horizontal" gap={2}>
<span title={taskTitleText}>{taskToUse.bpmn_identifier}</span> (
{taskToUse.typename}
): {taskToUse.state}
{taskDisplayButtons(taskToUse)}
</Stack>
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 (
<Modal
open={!!taskToUse}
danger={dangerous}
primaryButtonText={primaryButtonText}
secondaryButtonText={secondaryButtonText}
onRequestClose={handleTaskDataDisplayClose}
onSecondarySubmit={onSecondarySubmit}
onRequestSubmit={onRequestSubmit}
modalHeading={`${taskToUse.bpmn_identifier} (${taskToUse.typename}
): ${taskToUse.state}`}
>
<div className="indented-content explanatory-message">
{taskToUse.bpmn_name ? (
<div>
<Stack orientation="horizontal" gap={2}>
Name: {taskToUse.bpmn_name}
</Stack>
</div>
) : null}
<div>
<Stack orientation="horizontal" gap={2}>
Guid: {taskToUse.guid}
</Stack>
</div>
{taskToUse.state === 'COMPLETED' ? (
<div>
<Stack orientation="horizontal" gap={2}>
{completionViewLink(
'View process instance at the time when this task was active.',
taskToUse.guid
)}
</Stack>
<br />
<br />
</div>
) : null}
{selectingEvent
? eventSelector(candidateEvents)
: taskDataContainer()}
</Modal>
);
}
return null;
</div>
<ButtonSet>{taskDisplayButtons(taskToUse)}</ButtonSet>
{taskToUse.state === 'COMPLETED' ? (
<div>
<Stack orientation="horizontal" gap={2}>
{completionViewLink(
'View process instance at the time when this task was active.',
taskToUse.guid
)}
</Stack>
<br />
<br />
</div>
) : null}
{taskActionDetails()}
</Modal>
);
};
const buttonIcons = () => {

View File

@ -71,9 +71,6 @@ export default function SecretNew() {
}}
/>
<ButtonSet>
<Button kind="primary" type="submit">
Submit
</Button>
<Button
kind=""
className="button-white-background"
@ -81,6 +78,9 @@ export default function SecretNew() {
>
Cancel
</Button>
<Button kind="primary" type="submit">
Submit
</Button>
</ButtonSet>
</Stack>
</Form>