Feature/view completed user forms (#464)

* added modal and table to view completed forms w/ burnettk

* avoid making api calls for tab components on instance show page w/ burnettk

* show id when no task name and fix cognitive complexity warning in an embarrassing way

* removed some commented out code

* made human task attributes optional and noted them in frontend interfaces w/ burnettk

* removed draft completed tasks component 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-09-07 12:04:10 -04:00 committed by GitHub
parent 25540f32e0
commit 6944f87c8a
7 changed files with 254 additions and 57 deletions

View File

@ -1613,6 +1613,41 @@ paths:
items: items:
$ref: "#/components/schemas/Task" $ref: "#/components/schemas/Task"
/tasks/completed-by-me/{process_instance_id}:
parameters:
- name: page
in: query
required: false
description: The page number to return. Defaults to page 1.
schema:
type: integer
- name: per_page
in: query
required: false
description: The page number to return. Defaults to page 1.
schema:
type: integer
- name: process_instance_id
in: path
required: true
description: The unique id of an existing process instance.
schema:
type: integer
get:
tags:
- Process Instances
operationId: spiffworkflow_backend.routes.tasks_controller.task_list_completed_by_me
summary: returns the list of tasks for that the current user has completed
responses:
"200":
description: list of tasks
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Task"
/users/search: /users/search:
parameters: parameters:
- name: username_prefix - name: username_prefix

View File

@ -139,6 +139,31 @@ def task_list_my_tasks(
return make_response(jsonify(response_json), 200) return make_response(jsonify(response_json), 200)
def task_list_completed_by_me(process_instance_id: int, page: int = 1, per_page: int = 100) -> flask.wrappers.Response:
user_id = g.user.id
human_tasks_query = db.session.query(HumanTaskModel).filter(
HumanTaskModel.completed == True, # noqa: E712
HumanTaskModel.completed_by_user_id == user_id,
HumanTaskModel.process_instance_id == process_instance_id,
)
human_tasks = human_tasks_query.order_by(desc(HumanTaskModel.id)).paginate( # type: ignore
page=page, per_page=per_page, error_out=False
)
response_json = {
"results": human_tasks.items,
"pagination": {
"count": len(human_tasks.items),
"total": human_tasks.total,
"pages": human_tasks.pages,
},
}
return make_response(jsonify(response_json), 200)
def task_list_for_my_open_processes(page: int = 1, per_page: int = 100) -> flask.wrappers.Response: def task_list_for_my_open_processes(page: int = 1, per_page: int = 100) -> flask.wrappers.Response:
return _get_tasks(page=page, per_page=per_page) return _get_tasks(page=page, per_page=per_page)

View File

@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'; import { ReactElement, useEffect, useState } from 'react';
// @ts-ignore import { Button, Table, Modal, Stack } from '@carbon/react';
import { Button, Table } from '@carbon/react';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
// @ts-ignore
import { TimeAgo } from '../helpers/timeago';
import UserService from '../services/UserService'; import UserService from '../services/UserService';
import PaginationForTable from './PaginationForTable'; import PaginationForTable from './PaginationForTable';
import { import {
@ -13,15 +14,16 @@ import {
REFRESH_TIMEOUT_SECONDS, REFRESH_TIMEOUT_SECONDS,
} from '../helpers'; } from '../helpers';
import HttpService from '../services/HttpService'; import HttpService from '../services/HttpService';
import { PaginationObject, ProcessInstanceTask } from '../interfaces'; import { PaginationObject, ProcessInstanceTask, Task } from '../interfaces';
import TableCellWithTimeAgoInWords from './TableCellWithTimeAgoInWords'; import TableCellWithTimeAgoInWords from './TableCellWithTimeAgoInWords';
import CustomForm from './CustomForm';
const PER_PAGE_FOR_TASKS_ON_HOME_PAGE = 5; const PER_PAGE_FOR_TASKS_ON_HOME_PAGE = 5;
type OwnProps = { type OwnProps = {
apiPath: string; apiPath: string;
tableTitle: string; tableTitle?: string;
tableDescription: string; tableDescription?: string;
additionalParams?: string; additionalParams?: string;
paginationQueryParamPrefix?: string; paginationQueryParamPrefix?: string;
paginationClassName?: string; paginationClassName?: string;
@ -37,6 +39,8 @@ type OwnProps = {
showLastUpdated?: boolean; showLastUpdated?: boolean;
hideIfNoTasks?: boolean; hideIfNoTasks?: boolean;
canCompleteAllTasks?: boolean; canCompleteAllTasks?: boolean;
showActionsColumn?: boolean;
showViewFormDataButton?: boolean;
}; };
export default function TaskListTable({ export default function TaskListTable({
@ -58,10 +62,15 @@ export default function TaskListTable({
showLastUpdated = true, showLastUpdated = true,
hideIfNoTasks = false, hideIfNoTasks = false,
canCompleteAllTasks = false, canCompleteAllTasks = false,
showActionsColumn = true,
showViewFormDataButton = false,
}: OwnProps) { }: OwnProps) {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [tasks, setTasks] = useState<ProcessInstanceTask[] | null>(null); const [tasks, setTasks] = useState<ProcessInstanceTask[] | null>(null);
const [pagination, setPagination] = useState<PaginationObject | null>(null); const [pagination, setPagination] = useState<PaginationObject | null>(null);
const [formSubmissionTask, setFormSubmissionTask] = useState<Task | null>(
null
);
const preferredUsername = UserService.getPreferredUsername(); const preferredUsername = UserService.getPreferredUsername();
const userEmail = UserService.getUserEmail(); const userEmail = UserService.getUserEmail();
@ -126,35 +135,81 @@ export default function TaskListTable({
return <span title={fullUsernameString}>{shortUsernameString}</span>; return <span title={fullUsernameString}>{shortUsernameString}</span>;
}; };
const getTableRow = (processInstanceTask: ProcessInstanceTask) => { const formSubmissionModal = () => {
const taskUrl = `/tasks/${processInstanceTask.process_instance_id}/${processInstanceTask.task_id}`; if (formSubmissionTask) {
return (
<Modal
open={!!formSubmissionTask}
passiveModal
onRequestClose={() => setFormSubmissionTask(null)}
modalHeading={`${formSubmissionTask.name_for_display}
`}
>
<div className="indented-content explanatory-message">
You completed this form{' '}
{TimeAgo.inWords(formSubmissionTask.end_in_seconds)}
<div>
<Stack orientation="horizontal" gap={2}>
Guid: {formSubmissionTask.guid}
</Stack>
</div>
</div>
<hr />
<CustomForm
id={formSubmissionTask.guid}
formData={formSubmissionTask.data}
schema={formSubmissionTask.form_schema}
uiSchema={formSubmissionTask.form_ui_schema}
disabled
>
{/* this hides the submit button */}
{true}
</CustomForm>
</Modal>
);
}
return null;
};
const getFormSubmissionDataForTask = (
processInstanceTask: ProcessInstanceTask
) => {
HttpService.makeCallToBackend({
path: `/tasks/${processInstanceTask.process_instance_id}/${processInstanceTask.task_id}?with_form_data=true`,
httpMethod: 'GET',
successCallback: (result: Task) => setFormSubmissionTask(result),
});
};
const processIdRowElement = (processInstanceTask: ProcessInstanceTask) => {
const modifiedProcessModelIdentifier = modifyProcessIdentifierForPathParam( const modifiedProcessModelIdentifier = modifyProcessIdentifierForPathParam(
processInstanceTask.process_model_identifier processInstanceTask.process_model_identifier
); );
return (
<td>
<Link
data-qa="process-instance-show-link-id"
to={`/admin/process-instances/for-me/${modifiedProcessModelIdentifier}/${processInstanceTask.process_instance_id}`}
title={`View process instance ${processInstanceTask.process_instance_id}`}
>
{processInstanceTask.process_instance_id}
</Link>
</td>
);
};
const regex = new RegExp(`\\b(${preferredUsername}|${userEmail})\\b`); const dealWithProcessCells = (
let hasAccessToCompleteTask = false; rowElements: ReactElement[],
if ( processInstanceTask: ProcessInstanceTask
canCompleteAllTasks || ) => {
(processInstanceTask.potential_owner_usernames || '').match(regex)
) {
hasAccessToCompleteTask = true;
}
const rowElements = [];
if (showProcessId) { if (showProcessId) {
rowElements.push( rowElements.push(processIdRowElement(processInstanceTask));
<td>
<Link
data-qa="process-instance-show-link-id"
to={`/admin/process-instances/for-me/${modifiedProcessModelIdentifier}/${processInstanceTask.process_instance_id}`}
title={`View process instance ${processInstanceTask.process_instance_id}`}
>
{processInstanceTask.process_instance_id}
</Link>
</td>
);
} }
if (showProcessModelIdentifier) { if (showProcessModelIdentifier) {
const modifiedProcessModelIdentifier =
modifyProcessIdentifierForPathParam(
processInstanceTask.process_model_identifier
);
rowElements.push( rowElements.push(
<td> <td>
<Link <Link
@ -167,12 +222,30 @@ export default function TaskListTable({
</td> </td>
); );
} }
};
const getTableRow = (processInstanceTask: ProcessInstanceTask) => {
const taskUrl = `/tasks/${processInstanceTask.process_instance_id}/${processInstanceTask.task_id}`;
const regex = new RegExp(`\\b(${preferredUsername}|${userEmail})\\b`);
let hasAccessToCompleteTask = false;
if (
canCompleteAllTasks ||
(processInstanceTask.potential_owner_usernames || '').match(regex)
) {
hasAccessToCompleteTask = true;
}
const rowElements: ReactElement[] = [];
dealWithProcessCells(rowElements, processInstanceTask);
rowElements.push( rowElements.push(
<td <td
title={`task id: ${processInstanceTask.name}, spiffworkflow task guid: ${processInstanceTask.id}`} title={`task id: ${processInstanceTask.name}, spiffworkflow task guid: ${processInstanceTask.id}`}
> >
{processInstanceTask.task_title} {processInstanceTask.task_title
? processInstanceTask.task_title
: processInstanceTask.task_name}
</td> </td>
); );
if (showStartedBy) { if (showStartedBy) {
@ -201,9 +274,13 @@ export default function TaskListTable({
/> />
); );
} }
rowElements.push( if (showActionsColumn) {
<td> const actions = [];
{processInstanceTask.process_instance_status === 'suspended' ? null : ( if (
processInstanceTask.process_instance_status in
['suspended', 'completed', 'error']
) {
actions.push(
<Button <Button
variant="primary" variant="primary"
href={taskUrl} href={taskUrl}
@ -212,9 +289,20 @@ export default function TaskListTable({
> >
Go Go
</Button> </Button>
)} );
</td> }
); if (showViewFormDataButton) {
actions.push(
<Button
variant="primary"
onClick={() => getFormSubmissionDataForTask(processInstanceTask)}
>
View form
</Button>
);
}
rowElements.push(<td>{actions}</td>);
}
return <tr key={processInstanceTask.id}>{rowElements}</tr>; return <tr key={processInstanceTask.id}>{rowElements}</tr>;
}; };
@ -239,7 +327,9 @@ export default function TaskListTable({
if (showLastUpdated) { if (showLastUpdated) {
tableHeaders.push('Last Updated'); tableHeaders.push('Last Updated');
} }
tableHeaders = tableHeaders.concat(['Actions']); if (showActionsColumn) {
tableHeaders = tableHeaders.concat(['Actions']);
}
return tableHeaders; return tableHeaders;
}; };
@ -299,6 +389,9 @@ export default function TaskListTable({
}; };
const tableAndDescriptionElement = () => { const tableAndDescriptionElement = () => {
if (!tableTitle) {
return null;
}
if (showTableDescriptionAsTooltip) { if (showTableDescriptionAsTooltip) {
return <h2 title={tableDescription}>{tableTitle}</h2>; return <h2 title={tableDescription}>{tableTitle}</h2>;
} }
@ -313,6 +406,7 @@ export default function TaskListTable({
if (tasks && (tasks.length > 0 || hideIfNoTasks === false)) { if (tasks && (tasks.length > 0 || hideIfNoTasks === false)) {
return ( return (
<> <>
{formSubmissionModal()}
{tableAndDescriptionElement()} {tableAndDescriptionElement()}
{tasksComponent()} {tasksComponent()}
</> </>

View File

@ -730,6 +730,10 @@ hr {
display: none; display: none;
} }
.my-completed-forms-header {
font-style: italic;
}
fieldset legend.header { fieldset legend.header {
margin-bottom: 32px; margin-bottom: 32px;
} }

View File

@ -71,6 +71,8 @@ export interface BasicTask {
name_for_display: string; name_for_display: string;
can_complete: boolean; can_complete: boolean;
start_in_seconds: number;
end_in_seconds: number;
extensions?: any; extensions?: any;
} }
@ -106,7 +108,6 @@ export interface ProcessInstanceTask {
process_model_identifier: string; process_model_identifier: string;
properties: any; properties: any;
state: string; state: string;
task_title: string;
title: string; title: string;
type: string; type: string;
updated_at_in_seconds: number; updated_at_in_seconds: number;
@ -114,6 +115,10 @@ export interface ProcessInstanceTask {
potential_owner_usernames?: string; potential_owner_usernames?: string;
assigned_user_group_identifier?: string; assigned_user_group_identifier?: string;
error_message?: string; error_message?: string;
// these are actually from HumanTaskModel on the backend
task_title?: string;
task_name?: string;
} }
export interface ProcessReference { export interface ProcessReference {

View File

@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable unused-imports/no-unused-vars */
import MDEditor from '@uiw/react-md-editor'; import MDEditor from '@uiw/react-md-editor';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
@ -14,11 +16,6 @@ interface widgetArgs {
label?: string; label?: string;
} }
// NOTE: To properly validate that both start and end dates are specified
// use this pattern in schemaJson for that field:
// "pattern": "\\d{4}-\\d{2}-\\d{2}:::\\d{4}-\\d{2}-\\d{2}"
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function MarkDownFieldWidget({ export default function MarkDownFieldWidget({
id, id,
value, value,

View File

@ -112,6 +112,8 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
User[] | null User[] | null
>(null); >(null);
const [selectedTabIndex, setSelectedTabIndex] = useState<number>(0);
const { addError, removeError } = useAPIError(); const { addError, removeError } = useAPIError();
const unModifiedProcessModelId = unModifyProcessIdentifierForPathParam( const unModifiedProcessModelId = unModifyProcessIdentifierForPathParam(
`${params.process_model_id}` `${params.process_model_id}`
@ -1256,11 +1258,16 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
); );
}; };
const updateSelectedTab = (newTabIndex: any) => {
setSelectedTabIndex(newTabIndex.selectedIndex);
};
if (processInstance && (tasks || tasksCallHadError) && permissionsLoaded) { if (processInstance && (tasks || tasksCallHadError) && permissionsLoaded) {
const processModelId = unModifyProcessIdentifierForPathParam( const processModelId = unModifyProcessIdentifierForPathParam(
params.process_model_id ? params.process_model_id : '' params.process_model_id ? params.process_model_id : ''
); );
// eslint-disable-next-line sonarjs/cognitive-complexity
const getTabs = () => { const getTabs = () => {
const canViewLogs = ability.can( const canViewLogs = ability.can(
'GET', 'GET',
@ -1279,32 +1286,62 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
}; };
return ( return (
<Tabs> <Tabs selectedIndex={selectedTabIndex} onChange={updateSelectedTab}>
<TabList aria-label="List of tabs"> <TabList aria-label="List of tabs">
<Tab>Diagram</Tab> <Tab>Diagram</Tab>
<Tab disabled={!canViewLogs}>Milestones</Tab> <Tab disabled={!canViewLogs}>Milestones</Tab>
<Tab disabled={!canViewLogs}>Events</Tab> <Tab disabled={!canViewLogs}>Events</Tab>
<Tab disabled={!canViewMsgs}>Messages</Tab> <Tab disabled={!canViewMsgs}>Messages</Tab>
<Tab>My Forms</Tab>
</TabList> </TabList>
<TabPanels> <TabPanels>
<TabPanel>{diagramArea(processModelId)}</TabPanel>
<TabPanel> <TabPanel>
<ProcessInstanceLogList {selectedTabIndex === 0 ? (
variant={variant} <TabPanel>{diagramArea(processModelId)}</TabPanel>
isEventsView={false} ) : null}
processModelId={modifiedProcessModelId || ''}
processInstanceId={processInstance.id}
/>
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<ProcessInstanceLogList {selectedTabIndex === 1 ? (
variant={variant} <ProcessInstanceLogList
isEventsView variant={variant}
processModelId={modifiedProcessModelId || ''} isEventsView={false}
processInstanceId={processInstance.id} processModelId={modifiedProcessModelId || ''}
/> processInstanceId={processInstance.id}
/>
) : null}
</TabPanel>
<TabPanel>
{selectedTabIndex === 2 ? (
<ProcessInstanceLogList
variant={variant}
isEventsView
processModelId={modifiedProcessModelId || ''}
processInstanceId={processInstance.id}
/>
) : null}
</TabPanel>
<TabPanel>
{selectedTabIndex === 3 ? getMessageDisplay() : null}
</TabPanel>
<TabPanel>
{selectedTabIndex === 4 ? (
<TaskListTable
apiPath={`/tasks/completed-by-me/${processInstance.id}`}
paginationClassName="with-large-bottom-margin"
textToShowIfEmpty="There are no tasks you can complete for this process instance."
shouldPaginateTable={false}
showProcessModelIdentifier={false}
showProcessId={false}
showStartedBy={false}
showTableDescriptionAsTooltip
showDateStarted={false}
hideIfNoTasks
showWaitingOn={false}
canCompleteAllTasks={false}
showViewFormDataButton
/>
) : null}
</TabPanel> </TabPanel>
<TabPanel>{getMessageDisplay()}</TabPanel>
</TabPanels> </TabPanels>
</Tabs> </Tabs>
); );