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:
$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:
parameters:
- name: username_prefix

View File

@ -139,6 +139,31 @@ def task_list_my_tasks(
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:
return _get_tasks(page=page, per_page=per_page)

View File

@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
// @ts-ignore
import { Button, Table } from '@carbon/react';
import { ReactElement, useEffect, useState } from 'react';
import { Button, Table, Modal, Stack } from '@carbon/react';
import { Link, useSearchParams } from 'react-router-dom';
// @ts-ignore
import { TimeAgo } from '../helpers/timeago';
import UserService from '../services/UserService';
import PaginationForTable from './PaginationForTable';
import {
@ -13,15 +14,16 @@ import {
REFRESH_TIMEOUT_SECONDS,
} from '../helpers';
import HttpService from '../services/HttpService';
import { PaginationObject, ProcessInstanceTask } from '../interfaces';
import { PaginationObject, ProcessInstanceTask, Task } from '../interfaces';
import TableCellWithTimeAgoInWords from './TableCellWithTimeAgoInWords';
import CustomForm from './CustomForm';
const PER_PAGE_FOR_TASKS_ON_HOME_PAGE = 5;
type OwnProps = {
apiPath: string;
tableTitle: string;
tableDescription: string;
tableTitle?: string;
tableDescription?: string;
additionalParams?: string;
paginationQueryParamPrefix?: string;
paginationClassName?: string;
@ -37,6 +39,8 @@ type OwnProps = {
showLastUpdated?: boolean;
hideIfNoTasks?: boolean;
canCompleteAllTasks?: boolean;
showActionsColumn?: boolean;
showViewFormDataButton?: boolean;
};
export default function TaskListTable({
@ -58,10 +62,15 @@ export default function TaskListTable({
showLastUpdated = true,
hideIfNoTasks = false,
canCompleteAllTasks = false,
showActionsColumn = true,
showViewFormDataButton = false,
}: OwnProps) {
const [searchParams] = useSearchParams();
const [tasks, setTasks] = useState<ProcessInstanceTask[] | null>(null);
const [pagination, setPagination] = useState<PaginationObject | null>(null);
const [formSubmissionTask, setFormSubmissionTask] = useState<Task | null>(
null
);
const preferredUsername = UserService.getPreferredUsername();
const userEmail = UserService.getUserEmail();
@ -126,35 +135,81 @@ export default function TaskListTable({
return <span title={fullUsernameString}>{shortUsernameString}</span>;
};
const getTableRow = (processInstanceTask: ProcessInstanceTask) => {
const taskUrl = `/tasks/${processInstanceTask.process_instance_id}/${processInstanceTask.task_id}`;
const formSubmissionModal = () => {
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(
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`);
let hasAccessToCompleteTask = false;
if (
canCompleteAllTasks ||
(processInstanceTask.potential_owner_usernames || '').match(regex)
) {
hasAccessToCompleteTask = true;
}
const rowElements = [];
const dealWithProcessCells = (
rowElements: ReactElement[],
processInstanceTask: ProcessInstanceTask
) => {
if (showProcessId) {
rowElements.push(
<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>
);
rowElements.push(processIdRowElement(processInstanceTask));
}
if (showProcessModelIdentifier) {
const modifiedProcessModelIdentifier =
modifyProcessIdentifierForPathParam(
processInstanceTask.process_model_identifier
);
rowElements.push(
<td>
<Link
@ -167,12 +222,30 @@ export default function TaskListTable({
</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(
<td
title={`task id: ${processInstanceTask.name}, spiffworkflow task guid: ${processInstanceTask.id}`}
>
{processInstanceTask.task_title}
{processInstanceTask.task_title
? processInstanceTask.task_title
: processInstanceTask.task_name}
</td>
);
if (showStartedBy) {
@ -201,9 +274,13 @@ export default function TaskListTable({
/>
);
}
rowElements.push(
<td>
{processInstanceTask.process_instance_status === 'suspended' ? null : (
if (showActionsColumn) {
const actions = [];
if (
processInstanceTask.process_instance_status in
['suspended', 'completed', 'error']
) {
actions.push(
<Button
variant="primary"
href={taskUrl}
@ -212,9 +289,20 @@ export default function TaskListTable({
>
Go
</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>;
};
@ -239,7 +327,9 @@ export default function TaskListTable({
if (showLastUpdated) {
tableHeaders.push('Last Updated');
}
tableHeaders = tableHeaders.concat(['Actions']);
if (showActionsColumn) {
tableHeaders = tableHeaders.concat(['Actions']);
}
return tableHeaders;
};
@ -299,6 +389,9 @@ export default function TaskListTable({
};
const tableAndDescriptionElement = () => {
if (!tableTitle) {
return null;
}
if (showTableDescriptionAsTooltip) {
return <h2 title={tableDescription}>{tableTitle}</h2>;
}
@ -313,6 +406,7 @@ export default function TaskListTable({
if (tasks && (tasks.length > 0 || hideIfNoTasks === false)) {
return (
<>
{formSubmissionModal()}
{tableAndDescriptionElement()}
{tasksComponent()}
</>

View File

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

View File

@ -71,6 +71,8 @@ export interface BasicTask {
name_for_display: string;
can_complete: boolean;
start_in_seconds: number;
end_in_seconds: number;
extensions?: any;
}
@ -106,7 +108,6 @@ export interface ProcessInstanceTask {
process_model_identifier: string;
properties: any;
state: string;
task_title: string;
title: string;
type: string;
updated_at_in_seconds: number;
@ -114,6 +115,10 @@ export interface ProcessInstanceTask {
potential_owner_usernames?: string;
assigned_user_group_identifier?: string;
error_message?: string;
// these are actually from HumanTaskModel on the backend
task_title?: string;
task_name?: string;
}
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 React, { useCallback } from 'react';
@ -14,11 +16,6 @@ interface widgetArgs {
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({
id,
value,

View File

@ -112,6 +112,8 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
User[] | null
>(null);
const [selectedTabIndex, setSelectedTabIndex] = useState<number>(0);
const { addError, removeError } = useAPIError();
const unModifiedProcessModelId = unModifyProcessIdentifierForPathParam(
`${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) {
const processModelId = unModifyProcessIdentifierForPathParam(
params.process_model_id ? params.process_model_id : ''
);
// eslint-disable-next-line sonarjs/cognitive-complexity
const getTabs = () => {
const canViewLogs = ability.can(
'GET',
@ -1279,32 +1286,62 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
};
return (
<Tabs>
<Tabs selectedIndex={selectedTabIndex} onChange={updateSelectedTab}>
<TabList aria-label="List of tabs">
<Tab>Diagram</Tab>
<Tab disabled={!canViewLogs}>Milestones</Tab>
<Tab disabled={!canViewLogs}>Events</Tab>
<Tab disabled={!canViewMsgs}>Messages</Tab>
<Tab>My Forms</Tab>
</TabList>
<TabPanels>
<TabPanel>{diagramArea(processModelId)}</TabPanel>
<TabPanel>
<ProcessInstanceLogList
variant={variant}
isEventsView={false}
processModelId={modifiedProcessModelId || ''}
processInstanceId={processInstance.id}
/>
{selectedTabIndex === 0 ? (
<TabPanel>{diagramArea(processModelId)}</TabPanel>
) : null}
</TabPanel>
<TabPanel>
<ProcessInstanceLogList
variant={variant}
isEventsView
processModelId={modifiedProcessModelId || ''}
processInstanceId={processInstance.id}
/>
{selectedTabIndex === 1 ? (
<ProcessInstanceLogList
variant={variant}
isEventsView={false}
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>{getMessageDisplay()}</TabPanel>
</TabPanels>
</Tabs>
);