diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index a738a202..2a132cf9 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -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 diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py index 144658d3..fb65469f 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py @@ -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) diff --git a/spiffworkflow-frontend/src/components/TaskListTable.tsx b/spiffworkflow-frontend/src/components/TaskListTable.tsx index 8365ee8a..55644536 100644 --- a/spiffworkflow-frontend/src/components/TaskListTable.tsx +++ b/spiffworkflow-frontend/src/components/TaskListTable.tsx @@ -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(null); const [pagination, setPagination] = useState(null); + const [formSubmissionTask, setFormSubmissionTask] = useState( + null + ); const preferredUsername = UserService.getPreferredUsername(); const userEmail = UserService.getUserEmail(); @@ -126,35 +135,81 @@ export default function TaskListTable({ return {shortUsernameString}; }; - const getTableRow = (processInstanceTask: ProcessInstanceTask) => { - const taskUrl = `/tasks/${processInstanceTask.process_instance_id}/${processInstanceTask.task_id}`; + const formSubmissionModal = () => { + if (formSubmissionTask) { + return ( + setFormSubmissionTask(null)} + modalHeading={`${formSubmissionTask.name_for_display} + `} + > +
+ ✅ You completed this form{' '} + {TimeAgo.inWords(formSubmissionTask.end_in_seconds)} +
+ + Guid: {formSubmissionTask.guid} + +
+
+
+ + {/* this hides the submit button */} + {true} + +
+ ); + } + 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 ( + + + {processInstanceTask.process_instance_id} + + + ); + }; - 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( - - - {processInstanceTask.process_instance_id} - - - ); + rowElements.push(processIdRowElement(processInstanceTask)); } if (showProcessModelIdentifier) { + const modifiedProcessModelIdentifier = + modifyProcessIdentifierForPathParam( + processInstanceTask.process_model_identifier + ); rowElements.push( ); } + }; + + 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( - {processInstanceTask.task_title} + {processInstanceTask.task_title + ? processInstanceTask.task_title + : processInstanceTask.task_name} ); if (showStartedBy) { @@ -201,9 +274,13 @@ export default function TaskListTable({ /> ); } - rowElements.push( - - {processInstanceTask.process_instance_status === 'suspended' ? null : ( + if (showActionsColumn) { + const actions = []; + if ( + processInstanceTask.process_instance_status in + ['suspended', 'completed', 'error'] + ) { + actions.push( - )} - - ); + ); + } + if (showViewFormDataButton) { + actions.push( + + ); + } + rowElements.push({actions}); + } return {rowElements}; }; @@ -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

{tableTitle}

; } @@ -313,6 +406,7 @@ export default function TaskListTable({ if (tasks && (tasks.length > 0 || hideIfNoTasks === false)) { return ( <> + {formSubmissionModal()} {tableAndDescriptionElement()} {tasksComponent()} diff --git a/spiffworkflow-frontend/src/index.css b/spiffworkflow-frontend/src/index.css index 99fbee3b..a82f7a20 100644 --- a/spiffworkflow-frontend/src/index.css +++ b/spiffworkflow-frontend/src/index.css @@ -730,6 +730,10 @@ hr { display: none; } +.my-completed-forms-header { + font-style: italic; +} + fieldset legend.header { margin-bottom: 32px; } diff --git a/spiffworkflow-frontend/src/interfaces.ts b/spiffworkflow-frontend/src/interfaces.ts index 33ead303..5b86217d 100644 --- a/spiffworkflow-frontend/src/interfaces.ts +++ b/spiffworkflow-frontend/src/interfaces.ts @@ -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 { diff --git a/spiffworkflow-frontend/src/rjsf/custom_widgets/MarkDownFieldWidget/MarkDownFieldWidget.tsx b/spiffworkflow-frontend/src/rjsf/custom_widgets/MarkDownFieldWidget/MarkDownFieldWidget.tsx index 009adc02..1cb3b13f 100644 --- a/spiffworkflow-frontend/src/rjsf/custom_widgets/MarkDownFieldWidget/MarkDownFieldWidget.tsx +++ b/spiffworkflow-frontend/src/rjsf/custom_widgets/MarkDownFieldWidget/MarkDownFieldWidget.tsx @@ -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, diff --git a/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx b/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx index 9eae75d5..02c29bd1 100644 --- a/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx @@ -112,6 +112,8 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { User[] | null >(null); + const [selectedTabIndex, setSelectedTabIndex] = useState(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 ( - + Diagram Milestones Events Messages + My Forms - {diagramArea(processModelId)} - + {selectedTabIndex === 0 ? ( + {diagramArea(processModelId)} + ) : null} - + {selectedTabIndex === 1 ? ( + + ) : null} + + + {selectedTabIndex === 2 ? ( + + ) : null} + + + {selectedTabIndex === 3 ? getMessageDisplay() : null} + + + {selectedTabIndex === 4 ? ( + + ) : null} - {getMessageDisplay()} );