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:
parent
25540f32e0
commit
6944f87c8a
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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,23 +135,57 @@ 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
|
||||
);
|
||||
|
||||
const regex = new RegExp(`\\b(${preferredUsername}|${userEmail})\\b`);
|
||||
let hasAccessToCompleteTask = false;
|
||||
if (
|
||||
canCompleteAllTasks ||
|
||||
(processInstanceTask.potential_owner_usernames || '').match(regex)
|
||||
) {
|
||||
hasAccessToCompleteTask = true;
|
||||
}
|
||||
const rowElements = [];
|
||||
if (showProcessId) {
|
||||
rowElements.push(
|
||||
return (
|
||||
<td>
|
||||
<Link
|
||||
data-qa="process-instance-show-link-id"
|
||||
|
@ -153,8 +196,20 @@ export default function TaskListTable({
|
|||
</Link>
|
||||
</td>
|
||||
);
|
||||
};
|
||||
|
||||
const dealWithProcessCells = (
|
||||
rowElements: ReactElement[],
|
||||
processInstanceTask: ProcessInstanceTask
|
||||
) => {
|
||||
if (showProcessId) {
|
||||
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');
|
||||
}
|
||||
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()}
|
||||
</>
|
||||
|
|
|
@ -730,6 +730,10 @@ hr {
|
|||
display: none;
|
||||
}
|
||||
|
||||
.my-completed-forms-header {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
fieldset legend.header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
{selectedTabIndex === 0 ? (
|
||||
<TabPanel>{diagramArea(processModelId)}</TabPanel>
|
||||
) : null}
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
{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>
|
||||
);
|
||||
|
|
Loading…
Reference in New Issue