From cb38a5ec76b7473b4f37386b375bfad995bce393 Mon Sep 17 00:00:00 2001 From: jasquat Date: Tue, 11 Feb 2025 14:08:21 -0500 Subject: [PATCH] pi show page somewhat works w/ burnettk danfunk --- .../classes/ProcessInstanceClass.tsx | 9 + .../src/a-spiffui-v3/components/Filters.tsx | 95 + .../ProcessInstanceCurrentTaskInfo.tsx | 113 + .../components/ProcessInstanceLogList.tsx | 569 +++++ .../TableCellWithTimeAgoInWords.tsx | 29 + .../a-spiffui-v3/components/TaskListTable.tsx | 461 ++++ .../a-spiffui-v3/components/UserSearch.tsx | 69 + .../a-spiffui-v3/helpers/appVersionInfo.ts | 22 + .../src/a-spiffui-v3/helpers/timeago.ts | 79 + .../hooks/useKeyboardShortcut.tsx | 152 ++ .../hooks/useProcessInstanceNavigate.tsx | 43 + .../views/ProcessInstanceRoutes.tsx | 63 + .../views/ProcessInstanceShow.tsx | 1977 +++++++++++++++++ .../src/routes/SpiffUIV3.tsx | 5 + 14 files changed, 3686 insertions(+) create mode 100644 spiffworkflow-frontend/src/a-spiffui-v3/classes/ProcessInstanceClass.tsx create mode 100644 spiffworkflow-frontend/src/a-spiffui-v3/components/Filters.tsx create mode 100644 spiffworkflow-frontend/src/a-spiffui-v3/components/ProcessInstanceCurrentTaskInfo.tsx create mode 100644 spiffworkflow-frontend/src/a-spiffui-v3/components/ProcessInstanceLogList.tsx create mode 100644 spiffworkflow-frontend/src/a-spiffui-v3/components/TableCellWithTimeAgoInWords.tsx create mode 100644 spiffworkflow-frontend/src/a-spiffui-v3/components/TaskListTable.tsx create mode 100644 spiffworkflow-frontend/src/a-spiffui-v3/components/UserSearch.tsx create mode 100644 spiffworkflow-frontend/src/a-spiffui-v3/helpers/appVersionInfo.ts create mode 100644 spiffworkflow-frontend/src/a-spiffui-v3/helpers/timeago.ts create mode 100644 spiffworkflow-frontend/src/a-spiffui-v3/hooks/useKeyboardShortcut.tsx create mode 100644 spiffworkflow-frontend/src/a-spiffui-v3/hooks/useProcessInstanceNavigate.tsx create mode 100644 spiffworkflow-frontend/src/a-spiffui-v3/views/ProcessInstanceRoutes.tsx create mode 100644 spiffworkflow-frontend/src/a-spiffui-v3/views/ProcessInstanceShow.tsx diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/classes/ProcessInstanceClass.tsx b/spiffworkflow-frontend/src/a-spiffui-v3/classes/ProcessInstanceClass.tsx new file mode 100644 index 000000000..a959a002e --- /dev/null +++ b/spiffworkflow-frontend/src/a-spiffui-v3/classes/ProcessInstanceClass.tsx @@ -0,0 +1,9 @@ +export default class ProcessInstanceClass { + static terminalStatuses() { + return ['complete', 'error', 'terminated']; + } + + static nonErrorTerminalStatuses() { + return ['complete', 'terminated']; + } +} diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/components/Filters.tsx b/spiffworkflow-frontend/src/a-spiffui-v3/components/Filters.tsx new file mode 100644 index 000000000..908c42604 --- /dev/null +++ b/spiffworkflow-frontend/src/a-spiffui-v3/components/Filters.tsx @@ -0,0 +1,95 @@ +import { Filter as FilterIcon, Link as LinkIcon } from '@mui/icons-material'; +import { Button, Grid, IconButton, Snackbar } from '@mui/material'; +import { useState } from 'react'; + +type OwnProps = { + showFilterOptions: boolean; + setShowFilterOptions: Function; + filterOptions: Function; + filtersEnabled?: boolean; + reportSearchComponent?: Function | null; + reportHash?: string | null; +}; + +export default function Filters({ + showFilterOptions, + setShowFilterOptions, + filterOptions, + reportSearchComponent = null, + filtersEnabled = true, + reportHash, +}: OwnProps) { + const toggleShowFilterOptions = () => { + setShowFilterOptions(!showFilterOptions); + }; + + const [copiedReportLinkToClipboard, setCopiedReportLinkToClipboard] = + useState(false); + + const copyReportLink = () => { + if (reportHash) { + const piShortLink = `${window.location.origin}${window.location.pathname}?report_hash=${reportHash}`; + navigator.clipboard.writeText(piShortLink); + setCopiedReportLinkToClipboard(true); + } + }; + + const buttonElements = () => { + const elements = []; + if (reportHash && showFilterOptions) { + elements.push( + + + , + ); + } + elements.push( + + + , + ); + if (copiedReportLinkToClipboard) { + elements.push( + setCopiedReportLinkToClipboard(false)} + message="Copied link to clipboard" + />, + ); + } + return elements; + }; + + if (filtersEnabled) { + let reportSearchSection = null; + if (reportSearchComponent) { + reportSearchSection = ( + + {reportSearchComponent()} + + ); + } + return ( + <> + + {reportSearchSection} + + {buttonElements()} + + + {filterOptions()} + + ); + } + return null; +} diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/components/ProcessInstanceCurrentTaskInfo.tsx b/spiffworkflow-frontend/src/a-spiffui-v3/components/ProcessInstanceCurrentTaskInfo.tsx new file mode 100644 index 000000000..5e9bbcd55 --- /dev/null +++ b/spiffworkflow-frontend/src/a-spiffui-v3/components/ProcessInstanceCurrentTaskInfo.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState } from 'react'; +// Import Alert from MUI +import Alert from '@mui/material/Alert'; + +import InstructionsForEndUser from './InstructionsForEndUser'; +import { ProcessInstance, ProcessInstanceTask } from '../interfaces'; +import { HUMAN_TASK_TYPES } from '../helpers'; +import HttpService from '../services/HttpService'; + +type OwnProps = { + processInstance: ProcessInstance; +}; + +export default function ProcessInstanceCurrentTaskInfo({ + processInstance, +}: OwnProps) { + const [taskResult, setTaskResult] = useState(null); + const [task, setTask] = useState(null); + + useEffect(() => { + const processTaskResult = (result: any) => { + setTaskResult(result); + setTask(result.task); + }; + HttpService.makeCallToBackend({ + path: `/tasks/${processInstance.id}/instruction`, + successCallback: processTaskResult, + failureCallback: (error: any) => console.error(error.message), + }); + }, [processInstance]); + + const inlineMessage = ( + title: string, + subtitle: string, + severity: 'info' | 'warning' | 'error' = 'info', + ) => { + return ( +
+ {/* Use MUI Alert component */} + + {title} {subtitle} + +
+ ); + }; + + const taskUserMessage = () => { + if (!task) { + return null; + } + + if (!task.can_complete && HUMAN_TASK_TYPES.includes(task.type)) { + let message = 'This next task is assigned to a different person or team.'; + if (task.assigned_user_group_identifier) { + message = `This next task is assigned to group: ${task.assigned_user_group_identifier}.`; + } else if (task.potential_owner_usernames) { + let potentialOwnerArray = task.potential_owner_usernames.split(','); + if (potentialOwnerArray.length > 2) { + potentialOwnerArray = potentialOwnerArray.slice(0, 2).concat(['...']); + } + message = `This next task is assigned to user(s): ${potentialOwnerArray.join( + ', ', + )}.`; + } + + return inlineMessage( + '', + `${message} There is no action for you to take at this time.`, + ); + } + if (task && task.can_complete && HUMAN_TASK_TYPES.includes(task.type)) { + return null; + } + return ( +
+ +
+ ); + }; + + const userMessage = () => { + if (!processInstance) { + return null; + } + if (['terminated', 'suspended'].includes(processInstance.status)) { + return inlineMessage( + `Process ${processInstance.status}`, + `This process instance was ${processInstance.status} by an administrator. Please get in touch with them for more information.`, + 'warning', + ); + } + if (processInstance.status === 'error') { + let errMessage = `This process instance experienced an unexpected error and cannot continue. Please get in touch with an administrator for more information and next steps.`; + if (task && task.error_message) { + errMessage = ` ${errMessage.concat(task.error_message)}`; + } + return inlineMessage(`Process Error`, errMessage, 'error'); + } + + if (task) { + return taskUserMessage(); + } + const defaultMsg = + 'There are no additional instructions or information for this process.'; + return inlineMessage(`Process Error`, defaultMsg, 'info'); + }; + + if (processInstance && taskResult) { + return
{userMessage()}
; + } + + return null; +} diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/components/ProcessInstanceLogList.tsx b/spiffworkflow-frontend/src/a-spiffui-v3/components/ProcessInstanceLogList.tsx new file mode 100644 index 000000000..ba089d1b0 --- /dev/null +++ b/spiffworkflow-frontend/src/a-spiffui-v3/components/ProcessInstanceLogList.tsx @@ -0,0 +1,569 @@ +import { useEffect, useState } from 'react'; +import { ErrorOutline } from '@mui/icons-material'; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Grid, + Button, + Modal, + CircularProgress, + Select, + MenuItem, + FormControl, + InputLabel, +} from '@mui/material'; +import { createSearchParams, Link, useSearchParams } from 'react-router-dom'; +import PaginationForTable from './PaginationForTable'; +import { + getPageInfoFromSearchParams, + selectKeysFromSearchParams, +} from '../helpers'; +import HttpService from '../services/HttpService'; +import { useUriListForPermissions } from '../hooks/UriListForPermissions'; +import { + ObjectWithStringKeysAndValues, + PermissionsToCheck, + ProcessInstanceEventErrorDetail, + ProcessInstanceLogEntry, +} from '../interfaces'; +import Filters from './Filters'; +import { usePermissionFetcher } from '../hooks/PermissionService'; +import { + childrenForErrorObject, + errorForDisplayFromProcessInstanceErrorDetail, +} from './ErrorDisplay'; +import DateAndTimeService from '../services/DateAndTimeService'; + +type OwnProps = { + variant: string; // 'all' or 'for-me' + isEventsView: boolean; + processModelId: string; + processInstanceId: number; +}; + +export default function ProcessInstanceLogList({ + variant, + isEventsView = true, + processModelId, + processInstanceId, +}: OwnProps) { + const [clearAll, setClearAll] = useState(false); + const [processInstanceLogs, setProcessInstanceLogs] = useState< + ProcessInstanceLogEntry[] + >([]); + const [pagination, setPagination] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + + const [taskTypes, setTaskTypes] = useState([]); + const [eventTypes, setEventTypes] = useState([]); + const [taskBpmnNames, setTaskBpmnNames] = useState([]); + const [taskBpmnIdentifiers, setTaskBpmnIdentifiers] = useState([]); + + const [eventForModal, setEventForModal] = + useState(null); + const [eventErrorDetails, setEventErrorDetails] = + useState(null); + + const { targetUris } = useUriListForPermissions(); + const permissionRequestData: PermissionsToCheck = { + [targetUris.processInstanceErrorEventDetails]: ['GET'], + }; + const { ability } = usePermissionFetcher(permissionRequestData); + + const [showFilterOptions, setShowFilterOptions] = useState(false); + const randomNumberBetween0and1 = Math.random(); + + let shouldDisplayClearButton = false; + if (randomNumberBetween0and1 < 0.05) { + // 5% chance of being here + shouldDisplayClearButton = true; + } + + let processInstanceShowPageBaseUrl = `/process-instances/for-me/${processModelId}`; + if (variant === 'all') { + processInstanceShowPageBaseUrl = `/process-instances/${processModelId}`; + } + const taskNameHeader = isEventsView ? 'Task name' : 'Milestone'; + const tableType = isEventsView ? 'events' : 'milestones'; + const paginationQueryParamPrefix = `log-list-${tableType}`; + + const updateSearchParams = (newValues: ObjectWithStringKeysAndValues) => { + Object.keys(newValues).forEach((key: string) => { + const value = newValues[key]; + if (value === undefined || value === null) { + searchParams.delete(key); + } else { + searchParams.set(key, value); + } + }); + setSearchParams(searchParams); + }; + + const updateFilterValue = (value: string, key: string) => { + const newValues: ObjectWithStringKeysAndValues = {}; + newValues[`${paginationQueryParamPrefix}_page`] = '1'; + newValues[key] = value; + updateSearchParams(newValues); + }; + + useEffect(() => { + // Clear out any previous results to avoid a "flicker" effect where columns + // are updated above the incorrect data. + setProcessInstanceLogs([]); + + const setProcessInstanceLogListFromResult = (result: any) => { + setProcessInstanceLogs(result.results); + setPagination(result.pagination); + }; + + const searchParamsToInclude = [ + 'bpmn_name', + 'bpmn_identifier', + 'task_type', + 'event_type', + ]; + const pickedSearchParams = selectKeysFromSearchParams( + searchParams, + searchParamsToInclude, + ); + + const { page, perPage } = getPageInfoFromSearchParams( + searchParams, + undefined, + undefined, + paginationQueryParamPrefix, + ); + + const eventsQueryParam = isEventsView ? 'true' : 'false'; + + HttpService.makeCallToBackend({ + path: `${targetUris.processInstanceLogListPath}?${createSearchParams( + pickedSearchParams, + )}&page=${page}&per_page=${perPage}&events=${eventsQueryParam}`, + successCallback: setProcessInstanceLogListFromResult, + }); + + let typeaheadQueryParamString = ''; + if (!isEventsView) { + typeaheadQueryParamString = '?task_type=IntermediateThrowEvent'; + } + HttpService.makeCallToBackend({ + path: `/v1.0/logs/typeahead-filter-values/${processModelId}/${processInstanceId}${typeaheadQueryParamString}`, + successCallback: (result: any) => { + setTaskTypes(result.task_types); + setEventTypes(result.event_types); + setTaskBpmnNames(result.task_bpmn_names); + setTaskBpmnIdentifiers(result.task_bpmn_identifiers); + }, + }); + }, [ + searchParams, + processInstanceId, + processModelId, + targetUris.processInstanceLogListPath, + isEventsView, + paginationQueryParamPrefix, + ]); + + const handleErrorEventModalClose = () => { + setEventForModal(null); + setEventErrorDetails(null); + }; + + const errorEventModal = () => { + if (eventForModal) { + const modalHeading = 'Event Error Details'; + let errorMessageTag = ( + + ); + if (eventErrorDetails) { + const errorForDisplay = errorForDisplayFromProcessInstanceErrorDetail( + eventForModal, + eventErrorDetails, + ); + const errorChildren = childrenForErrorObject(errorForDisplay); + // eslint-disable-next-line react/jsx-no-useless-fragment, sonarjs/jsx-no-useless-fragment + errorMessageTag = <>{errorChildren}; + } + return ( + +
+ + + {errorMessageTag} +
+
+ ); + } + return null; + }; + + const handleErrorDetailsReponse = ( + results: ProcessInstanceEventErrorDetail, + ) => { + setEventErrorDetails(results); + }; + + const getErrorDetailsForEvent = (logEntry: ProcessInstanceLogEntry) => { + setEventForModal(logEntry); + if (ability.can('GET', targetUris.processInstanceErrorEventDetails)) { + HttpService.makeCallToBackend({ + path: `${targetUris.processInstanceErrorEventDetails}/${logEntry.id}`, + httpMethod: 'GET', + successCallback: handleErrorDetailsReponse, + failureCallback: (error: any) => { + const errorObject: ProcessInstanceEventErrorDetail = { + id: 0, + message: `ERROR retrieving error details: ${error.message}`, + stacktrace: [], + }; + setEventErrorDetails(errorObject); + }, + }); + } else { + const notAuthorized: ProcessInstanceEventErrorDetail = { + id: 0, + message: 'You are not authorized to view error details', + stacktrace: [], + }; + setEventErrorDetails(notAuthorized); + } + }; + + const eventTypeCell = (logEntry: ProcessInstanceLogEntry) => { + if ( + ['process_instance_error', 'task_failed'].includes(logEntry.event_type) + ) { + const errorTitle = 'Event has an error'; + const errorIcon = ( + <> +   + + + ); + return ( + + ); + } + return logEntry.event_type; + }; + + const getTableRow = (logEntry: ProcessInstanceLogEntry) => { + const tableRow = []; + let taskName = logEntry.task_definition_name; + if (!taskName && !isEventsView) { + if (logEntry.bpmn_task_type === 'StartEvent') { + taskName = 'Started'; + } else if (logEntry.bpmn_task_type === 'EndEvent') { + taskName = 'Completed'; + } + } + const taskNameCell = {taskName}; + const bpmnProcessCell = ( + + {logEntry.bpmn_process_definition_name || + logEntry.bpmn_process_definition_identifier} + + ); + if (isEventsView) { + tableRow.push( + <> + {logEntry.id} + {bpmnProcessCell} + {taskNameCell} + , + ); + } else { + tableRow.push( + <> + {taskNameCell} + {bpmnProcessCell} + , + ); + } + if (isEventsView) { + tableRow.push( + <> + {logEntry.task_definition_identifier} + {logEntry.bpmn_task_type} + {eventTypeCell(logEntry)} + + {logEntry.username || ( + system + )} + + , + ); + } + + let timestampComponent = ( + + {DateAndTimeService.convertSecondsToFormattedDateTime( + logEntry.timestamp, + )} + + ); + if (logEntry.spiff_task_guid && logEntry.event_type !== 'task_cancelled') { + timestampComponent = ( + + + {DateAndTimeService.convertSecondsToFormattedDateTime( + logEntry.timestamp, + )} + + + ); + } + tableRow.push(timestampComponent); + + return {tableRow}; + }; + + const buildTable = () => { + const rows = processInstanceLogs.map( + (logEntry: ProcessInstanceLogEntry) => { + return getTableRow(logEntry); + }, + ); + + const tableHeaders = []; + if (isEventsView) { + tableHeaders.push( + <> + Id + Bpmn process + {taskNameHeader} + , + ); + } else { + tableHeaders.push( + <> + {taskNameHeader} + Bpmn process + , + ); + } + if (isEventsView) { + tableHeaders.push( + <> + Task identifier + Task type + Event type + User + , + ); + } + tableHeaders.push(Timestamp); + return ( + + + + {tableHeaders} + + {rows} +
+
+ ); + }; + + const resetFilters = () => { + ['bpmn_name', 'bpmn_identifier', 'task_type', 'event_type'].forEach( + (value: string) => searchParams.delete(value), + ); + }; + + const resetFiltersAndRun = () => { + resetFilters(); + setSearchParams(searchParams); + }; + + const clearFilters = () => { + setClearAll(true); + }; + + const shouldFilterStringItem = (options: any) => { + const stringItem = options.item; + let { inputValue } = options; + if (!inputValue) { + inputValue = ''; + } + return stringItem.toLowerCase().includes(inputValue.toLowerCase()); + }; + + const filterOptions = () => { + if (!showFilterOptions) { + return null; + } + + const filterElements = []; + let taskNameFilterPlaceholder = 'Choose a milestone'; + if (isEventsView) { + taskNameFilterPlaceholder = 'Choose a task bpmn name'; + } + filterElements.push( + + + {taskNameHeader} + + + , + ); + + if (isEventsView) { + filterElements.push( + <> + + + + Task identifier + + + + + + + Task type + + + + + + Event type + + + + , + ); + } + + return ( + <> + + {filterElements} + + + + + {shouldDisplayClearButton && ( + + )} + + + + ); + }; + + const { page, perPage } = getPageInfoFromSearchParams( + searchParams, + undefined, + undefined, + paginationQueryParamPrefix, + ); + if (clearAll) { + return

Page cleared 👍

; + } + + return ( + <> + {errorEventModal()} + +
+ + + ); +} diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/components/TableCellWithTimeAgoInWords.tsx b/spiffworkflow-frontend/src/a-spiffui-v3/components/TableCellWithTimeAgoInWords.tsx new file mode 100644 index 000000000..64486f0e8 --- /dev/null +++ b/spiffworkflow-frontend/src/a-spiffui-v3/components/TableCellWithTimeAgoInWords.tsx @@ -0,0 +1,29 @@ +// @ts-ignore +import { TimeAgo } from '../helpers/timeago'; +import DateAndTimeService from '../services/DateAndTimeService'; + +type OwnProps = { + timeInSeconds: number; + onClick?: any; + onKeyDown?: any; +}; + +export default function TableCellWithTimeAgoInWords({ + timeInSeconds, + onClick = null, + onKeyDown = null, +}: OwnProps) { + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions + + {timeInSeconds ? TimeAgo.inWords(timeInSeconds) : '-'} + + ); +} diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/components/TaskListTable.tsx b/spiffworkflow-frontend/src/a-spiffui-v3/components/TaskListTable.tsx new file mode 100644 index 000000000..fcc84a0c9 --- /dev/null +++ b/spiffworkflow-frontend/src/a-spiffui-v3/components/TaskListTable.tsx @@ -0,0 +1,461 @@ +import { ReactElement, useEffect, useState } from 'react'; +import { Button, Table, Modal, Stack } from '@mui/material'; +import { Link, useSearchParams } from 'react-router-dom'; +import { TimeAgo } from '../helpers/timeago'; +import UserService from '../services/UserService'; +import PaginationForTable from './PaginationForTable'; +import { + getPageInfoFromSearchParams, + modifyProcessIdentifierForPathParam, + refreshAtInterval, +} from '../helpers'; +import HttpService from '../services/HttpService'; +import { PaginationObject, ProcessInstanceTask, Task } from '../interfaces'; +import TableCellWithTimeAgoInWords from './TableCellWithTimeAgoInWords'; +import CustomForm from './CustomForm'; +import InstructionsForEndUser from './InstructionsForEndUser'; +import DateAndTimeService from '../services/DateAndTimeService'; + +type OwnProps = { + apiPath: string; + + additionalParams?: string; + autoReload?: boolean; + canCompleteAllTasks?: boolean; + defaultPerPage?: number; + hideIfNoTasks?: boolean; + paginationClassName?: string; + paginationQueryParamPrefix?: string; + shouldPaginateTable?: boolean; + showActionsColumn?: boolean; + showCompletedBy?: boolean; + showDateStarted?: boolean; + showLastUpdated?: boolean; + showProcessId?: boolean; + showProcessModelIdentifier?: boolean; + showStartedBy?: boolean; + showTableDescriptionAsTooltip?: boolean; + showViewFormDataButton?: boolean; + showWaitingOn?: boolean; + tableDescription?: string; + tableTitle?: string; + textToShowIfEmpty?: string; +}; + +export default function TaskListTable({ + apiPath, + + additionalParams, + autoReload = false, + canCompleteAllTasks = false, + defaultPerPage = 5, + hideIfNoTasks = false, + paginationClassName, + paginationQueryParamPrefix, + shouldPaginateTable = true, + showActionsColumn = true, + showCompletedBy = false, + showDateStarted = true, + showLastUpdated = true, + showProcessId = true, + showProcessModelIdentifier = true, + showStartedBy = true, + showTableDescriptionAsTooltip = false, + showViewFormDataButton = false, + showWaitingOn = true, + tableDescription, + tableTitle, + textToShowIfEmpty, +}: 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(); + + useEffect(() => { + const getTasks = () => { + const { page, perPage } = getPageInfoFromSearchParams( + searchParams, + defaultPerPage, + undefined, + paginationQueryParamPrefix, + ); + const setTasksFromResult = (result: any) => { + setTasks(result.results); + setPagination(result.pagination); + }; + let params = `?per_page=${perPage}&page=${page}`; + if (additionalParams) { + params += `&${additionalParams}`; + } + HttpService.makeCallToBackend({ + path: `${apiPath}${params}`, + successCallback: setTasksFromResult, + }); + }; + getTasks(); + if (autoReload) { + return refreshAtInterval( + DateAndTimeService.REFRESH_INTERVAL_SECONDS, + DateAndTimeService.REFRESH_TIMEOUT_SECONDS, + getTasks, + ); + } + return undefined; + }, [ + additionalParams, + apiPath, + autoReload, + defaultPerPage, + paginationQueryParamPrefix, + searchParams, + ]); + + const getWaitingForTableCellComponent = ( + processInstanceTask: ProcessInstanceTask, + ) => { + let fullUsernameString = ''; + let shortUsernameString = ''; + if (processInstanceTask.potential_owner_usernames) { + fullUsernameString = processInstanceTask.potential_owner_usernames; + const usernames = + processInstanceTask.potential_owner_usernames.split(','); + const firstTwoUsernames = usernames.slice(0, 2); + if (usernames.length > 2) { + firstTwoUsernames.push('...'); + } + shortUsernameString = firstTwoUsernames.join(','); + } + if (processInstanceTask.assigned_user_group_identifier) { + fullUsernameString = processInstanceTask.assigned_user_group_identifier; + shortUsernameString = processInstanceTask.assigned_user_group_identifier; + } + return {shortUsernameString}; + }; + + const formSubmissionModal = () => { + if (formSubmissionTask) { + // TODO: move this and the code from TaskShow to new component to handle instructions and manual tasks + let formUiSchema; + let jsonSchema = formSubmissionTask.form_schema; + if (formSubmissionTask.typename !== 'UserTask') { + jsonSchema = { + type: 'object', + required: [], + properties: { + isManualTask: { + type: 'boolean', + title: 'Is ManualTask', + default: true, + }, + }, + }; + formUiSchema = { + isManualTask: { + 'ui:widget': 'hidden', + }, + }; + } else if (formSubmissionTask.form_ui_schema) { + formUiSchema = formSubmissionTask.form_ui_schema; + } + return ( + setFormSubmissionTask(null)} + aria-labelledby="modal-title" + aria-describedby="modal-description" + > +
+ +
+ ✅ You completed this task{' '} + {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 dealWithProcessCells = ( + rowElements: ReactElement[], + processInstanceTask: ProcessInstanceTask, + ) => { + if (showProcessId) { + rowElements.push(processIdRowElement(processInstanceTask)); + } + if (showProcessModelIdentifier) { + const modifiedProcessModelIdentifier = + modifyProcessIdentifierForPathParam( + processInstanceTask.process_model_identifier, + ); + rowElements.push( + + + {processInstanceTask.process_model_display_name} + + , + ); + } + }; + + const getActionButtons = (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 actions = []; + if ( + !( + processInstanceTask.process_instance_status in + ['suspended', 'completed', 'error'] + ) && + !processInstanceTask.completed + ) { + actions.push( + , + ); + } + if (showViewFormDataButton) { + actions.push( + , + ); + } + return actions; + }; + + const getTableRow = (processInstanceTask: ProcessInstanceTask) => { + const rowElements: ReactElement[] = []; + + dealWithProcessCells(rowElements, processInstanceTask); + + rowElements.push( + + {processInstanceTask.task_title + ? processInstanceTask.task_title + : processInstanceTask.task_name} + , + ); + if (showStartedBy) { + rowElements.push( + {processInstanceTask.process_initiator_username}, + ); + } + if (showWaitingOn) { + rowElements.push( + {getWaitingForTableCellComponent(processInstanceTask)}, + ); + } + if (showCompletedBy) { + rowElements.push({processInstanceTask.completed_by_username}); + } + if (showDateStarted) { + rowElements.push( + + {DateAndTimeService.convertSecondsToFormattedDateTime( + processInstanceTask.created_at_in_seconds, + ) || '-'} + , + ); + } + if (showLastUpdated) { + rowElements.push( + , + ); + } + if (showActionsColumn) { + rowElements.push({getActionButtons(processInstanceTask)}); + } + return {rowElements}; + }; + + const getTableHeaders = () => { + let tableHeaders = []; + if (showProcessId) { + tableHeaders.push('Id'); + } + if (showProcessModelIdentifier) { + tableHeaders.push('Process'); + } + tableHeaders.push('Task'); + if (showStartedBy) { + tableHeaders.push('Started by'); + } + if (showWaitingOn) { + tableHeaders.push('Waiting for'); + } + if (showCompletedBy) { + tableHeaders.push('Completed by'); + } + if (showDateStarted) { + tableHeaders.push('Date started'); + } + if (showLastUpdated) { + tableHeaders.push('Last updated'); + } + if (showActionsColumn) { + tableHeaders = tableHeaders.concat(['Actions']); + } + return tableHeaders; + }; + + const buildTable = () => { + if (!tasks) { + return null; + } + const tableHeaders = getTableHeaders(); + const rows = tasks.map((processInstanceTask: ProcessInstanceTask) => { + return getTableRow(processInstanceTask); + }); + return ( + + + + {tableHeaders.map((tableHeader: string) => { + return ; + })} + + + {rows} +
{tableHeader}
+ ); + }; + + const tasksComponent = () => { + if (pagination && pagination.total < 1) { + return ( +

+ {textToShowIfEmpty} +

+ ); + } + const { page, perPage } = getPageInfoFromSearchParams( + searchParams, + defaultPerPage, + undefined, + paginationQueryParamPrefix, + ); + let tableElement = ( +
{buildTable()}
+ ); + if (shouldPaginateTable) { + tableElement = ( + + ); + } + return tableElement; + }; + + const tableAndDescriptionElement = () => { + if (!tableTitle) { + return null; + } + if (showTableDescriptionAsTooltip) { + return

{tableTitle}

; + } + return ( + <> +

{tableTitle}

+

{tableDescription}

+ + ); + }; + + if (tasks && (tasks.length > 0 || hideIfNoTasks === false)) { + return ( + <> + {formSubmissionModal()} + {tableAndDescriptionElement()} + {tasksComponent()} + + ); + } + return null; +} diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/components/UserSearch.tsx b/spiffworkflow-frontend/src/a-spiffui-v3/components/UserSearch.tsx new file mode 100644 index 000000000..b82e0934d --- /dev/null +++ b/spiffworkflow-frontend/src/a-spiffui-v3/components/UserSearch.tsx @@ -0,0 +1,69 @@ +import { Autocomplete, TextField } from '@mui/material'; +import { useRef, useState } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; +import { 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(); + const [selectedUser, setSelectedUser] = useState(null); + const [userList, setUserList] = useState([]); + + 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 ( + addDebouncedSearchUser(value)} + className={className} + onChange={(event, value) => { + onSelectedUser(value); + }} + id="user-search" + data-qa="user-search" + options={userList} + getOptionLabel={(option: User) => option.username || ''} + renderInput={(params) => ( + + )} + value={selectedUser} + /> + ); +} diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/helpers/appVersionInfo.ts b/spiffworkflow-frontend/src/a-spiffui-v3/helpers/appVersionInfo.ts new file mode 100644 index 000000000..86319c2ef --- /dev/null +++ b/spiffworkflow-frontend/src/a-spiffui-v3/helpers/appVersionInfo.ts @@ -0,0 +1,22 @@ +import { ObjectWithStringKeysAndValues } from '../interfaces'; + +const appVersionInfo = () => { + const versionInfoFromHtmlMetaTag = document.querySelector( + 'meta[name="version-info"]', + ); + let versionInfo: ObjectWithStringKeysAndValues = {}; + if (versionInfoFromHtmlMetaTag) { + const versionInfoContentString = + versionInfoFromHtmlMetaTag.getAttribute('content'); + if ( + versionInfoContentString && + versionInfoContentString !== '%VITE_VERSION_INFO%' + ) { + versionInfo = JSON.parse(versionInfoContentString); + } + } + + return versionInfo; +}; + +export default appVersionInfo; diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/helpers/timeago.ts b/spiffworkflow-frontend/src/a-spiffui-v3/helpers/timeago.ts new file mode 100644 index 000000000..397ede100 --- /dev/null +++ b/spiffworkflow-frontend/src/a-spiffui-v3/helpers/timeago.ts @@ -0,0 +1,79 @@ +/* eslint-disable no-restricted-syntax */ +// https://gist.github.com/caiotarifa/30ae974f2293c761f3139dd194abd9e5 +type Locales = { + prefix: string; + sufix: string; + seconds: string; + minute: string; + minutes: string; + hour: string; + hours: string; + day: string; + days: string; + month: string; + months: string; + year: string; + years: string; + separator?: string; +}; + +export const TimeAgo = (function awesomeFunc() { + const locales: Locales = { + prefix: '', + sufix: 'ago', + + seconds: 'less than a minute', + minute: 'about a minute', + minutes: '%d minutes', + hour: 'about an hour', + hours: 'about %d hours', + day: 'a day', + days: '%d days', + month: 'about a month', + months: '%d months', + year: 'about a year', + years: '%d years', + }; + + function inWords(timeAgo: number): string { + const milliseconds = timeAgo * 1000; + const seconds = Math.floor( + (new Date().getTime() - parseInt(milliseconds.toString(), 10)) / 1000, + ); + const separator = locales.separator || ' '; + let words = locales.prefix + separator; + let interval = 0; + const intervals: Record = { + year: seconds / 31536000, + month: seconds / 2592000, + day: seconds / 86400, + hour: seconds / 3600, + minute: seconds / 60, + }; + + let distance: any = null; + Object.keys(intervals).forEach((key: string) => { + if (distance !== null) { + return; + } + interval = Math.floor(intervals[key]); + + if (interval > 1) { + distance = locales[`${key}s` as keyof Locales]; + } + if (interval === 1) { + distance = locales[key as keyof Locales]; + } + }); + if (distance === null) { + distance = locales.seconds; + } + + distance = distance.replace(/%d/i, interval.toString()); + words += distance + separator + locales.sufix; + + return words.trim(); + } + + return { locales, inWords }; +})(); diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/hooks/useKeyboardShortcut.tsx b/spiffworkflow-frontend/src/a-spiffui-v3/hooks/useKeyboardShortcut.tsx new file mode 100644 index 000000000..3e6129237 --- /dev/null +++ b/spiffworkflow-frontend/src/a-spiffui-v3/hooks/useKeyboardShortcut.tsx @@ -0,0 +1,152 @@ +// from https://raw.githubusercontent.com/arthurtyukayev/use-keyboard-shortcut/develop/lib/useKeyboardShortcut.js +import { useEffect, useCallback, useRef, useState } from 'react'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import { KeyboardShortcuts } from '../interfaces'; + +export const overrideSystemHandling = (e: KeyboardEvent) => { + if (e) { + if (e.preventDefault) { + e.preventDefault(); + } + if (e.stopPropagation) { + e.stopPropagation(); + } + } +}; + +export const uniqFast = (a: any[]) => { + return [...new Set(a)]; +}; + +const EXCLUDE_LIST_DOM_TARGETS = ['TEXTAREA', 'INPUT']; + +const DEFAULT_OPTIONS = { + ignoreInputFields: true, +}; + +const useKeyboardShortcut = ( + keyboardShortcuts: KeyboardShortcuts, + userOptions?: any, +) => { + let options = DEFAULT_OPTIONS; + if (userOptions) { + options = { ...options, ...userOptions }; + } + + const [helpControlOpen, setHelpControlOpen] = useState(false); + + // useRef to avoid a constant re-render on keydown and keyup. + const keySequence = useRef([]); + + const shortcutKeys = Object.keys(keyboardShortcuts); + const lengthsOfShortcutKeys = shortcutKeys.map( + (shortcutKey: string) => shortcutKey.length, + ); + const numberOfKeysToKeep = Math.max(...lengthsOfShortcutKeys); + + const openKeyboardShortcutHelpControl = useCallback(() => { + const keyboardShortcutList = shortcutKeys.map((key: string) => { + return ( +

+

+ {keyboardShortcuts[key].label}:{' '} +
+
+ {key.split(',').map((keyString) => ( + {keyString} + ))} +
+

+ ); + }); + + return ( + setHelpControlOpen(false)} + maxWidth="sm" + > + Keyboard shortcuts + +

+

+ Open keyboard shortcut help control: +
+
+ Shift + ? +
+

+ {keyboardShortcutList} +
+
+ ); + }, [keyboardShortcuts, helpControlOpen, shortcutKeys]); + + const keydownListener = useCallback( + (keydownEvent: KeyboardEvent) => { + if (keydownEvent.key === '?' && keydownEvent.shiftKey) { + overrideSystemHandling(keydownEvent); + keySequence.current = []; + setHelpControlOpen(true); + return false; + } + if ( + keydownEvent.ctrlKey || + keydownEvent.shiftKey || + keydownEvent.altKey + ) { + return undefined; + } + const loweredKey = String(keydownEvent.key).toLowerCase(); + + if ( + options.ignoreInputFields && + EXCLUDE_LIST_DOM_TARGETS.indexOf( + (keydownEvent.target as any).tagName, + ) >= 0 + ) { + return undefined; + } + + keySequence.current.push(loweredKey); + const keySequenceString = keySequence.current.join(','); + const shortcutKey = shortcutKeys.find((sk: string) => + keySequenceString.endsWith(sk), + ); + + if (shortcutKey) { + overrideSystemHandling(keydownEvent); + keySequence.current = []; + keyboardShortcuts[shortcutKey].function(); + return false; + } + + if (keySequence.current.length >= numberOfKeysToKeep) { + keySequence.current.shift(); + } + + return false; + }, + [ + options.ignoreInputFields, + keyboardShortcuts, + numberOfKeysToKeep, + shortcutKeys, + ], + ); + + useEffect(() => { + window.addEventListener('keydown', keydownListener); + return () => { + window.removeEventListener('keydown', keydownListener); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [keydownListener]); + + return openKeyboardShortcutHelpControl(); +}; + +export default useKeyboardShortcut; diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/hooks/useProcessInstanceNavigate.tsx b/spiffworkflow-frontend/src/a-spiffui-v3/hooks/useProcessInstanceNavigate.tsx new file mode 100644 index 000000000..9db180e33 --- /dev/null +++ b/spiffworkflow-frontend/src/a-spiffui-v3/hooks/useProcessInstanceNavigate.tsx @@ -0,0 +1,43 @@ +import { useNavigate } from 'react-router-dom'; +import { modifyProcessIdentifierForPathParam } from '../helpers'; +import { ProcessInstance } from '../interfaces'; +import HttpService from '../services/HttpService'; + +type OwnProps = { + processInstanceId: number; + suffix?: string; +}; +export default function useProcessInstanceNavigate() { + const navigate = useNavigate(); + + const handleProcessInstanceNavigation = ( + result: any, + processInstanceId: number, + suffix: string | undefined, + ) => { + const processInstanceResult: ProcessInstance = result.process_instance; + let path = '/process-instances'; + if (result.uri_type === 'for-me') { + path += '/for-me'; + } + path += `/${modifyProcessIdentifierForPathParam( + processInstanceResult.process_model_identifier, + )}/${processInstanceResult.id}`; + if (suffix !== undefined) { + path += suffix; + } + const queryParams = window.location.search; + path += queryParams; + navigate(path); + }; + + const navigateToInstance = ({ processInstanceId, suffix }: OwnProps) => { + HttpService.makeCallToBackend({ + path: `/process-instances/find-by-id/${processInstanceId}`, + successCallback: (result: any) => + handleProcessInstanceNavigation(result, processInstanceId, suffix), + }); + }; + + return { navigateToInstance }; +} diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/views/ProcessInstanceRoutes.tsx b/spiffworkflow-frontend/src/a-spiffui-v3/views/ProcessInstanceRoutes.tsx new file mode 100644 index 000000000..03c2f6b4a --- /dev/null +++ b/spiffworkflow-frontend/src/a-spiffui-v3/views/ProcessInstanceRoutes.tsx @@ -0,0 +1,63 @@ +import { Route, Routes } from 'react-router-dom'; +// import ProcessInstanceList from './ProcessInstanceList'; +import ProcessInstanceShow from './ProcessInstanceShow'; +// import ProcessInstanceReportList from './ProcessInstanceReportList'; +// import ProcessInstanceReportNew from './ProcessInstanceReportNew'; +// import ProcessInstanceReportEdit from './ProcessInstanceReportEdit'; +// import ProcessInstanceFindById from './ProcessInstanceFindById'; +// import ProcessInterstitialPage from './ProcessInterstitialPage'; +// import ProcessInstanceProgressPage from './ProcessInstanceProgressPage'; +// import ProcessInstanceMigratePage from './ProcessInstanceMigratePage'; + +export default function ProcessInstanceRoutes() { + return ( + + {/* } /> */} + {/* } /> */} + {/* } /> */} + } + /> + } + /> + {/* } */} + {/* /> */} + {/* } */} + {/* /> */} + {/* } */} + {/* /> */} + {/* } */} + {/* /> */} + {/* } */} + {/* /> */} + } + /> + } + /> + {/* } /> */} + {/* } /> */} + {/* } */} + {/* /> */} + {/* } /> */} + + ); +} diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/views/ProcessInstanceShow.tsx b/spiffworkflow-frontend/src/a-spiffui-v3/views/ProcessInstanceShow.tsx new file mode 100644 index 000000000..a3b3f057f --- /dev/null +++ b/spiffworkflow-frontend/src/a-spiffui-v3/views/ProcessInstanceShow.tsx @@ -0,0 +1,1977 @@ +import { ReactElement, useCallback, useEffect, useState } from 'react'; +import Editor from '@monaco-editor/react'; +import { + useParams, + useNavigate, + Link, + useSearchParams, +} from 'react-router-dom'; +import { + Send, + Checkmark, + Edit, + InProgress, + PauseOutline, + UserFollow, + Play, + PlayOutline, + Reset, + RuleDraft, + SkipForward, + StopOutline, + TrashCan, + Warning, + Link as LinkIcon, + View, + Migrate, +} from '@carbon/icons-react'; +import { + Accordion, + AccordionItem, + Grid, + Column, + Button, + Tag, + Modal, + Dropdown, + Stack, + Loading, + Tabs, + Tab, + TabList, + TabPanels, + TabPanel, +} from '@carbon/react'; +import ProcessBreadcrumb from '../components/ProcessBreadcrumb'; +import HttpService from '../services/HttpService'; +import ReactDiagramEditor from '../components/ReactDiagramEditor'; +import { + getLastMilestoneFromProcessInstance, + HUMAN_TASK_TYPES, + modifyProcessIdentifierForPathParam, + truncateString, + unModifyProcessIdentifierForPathParam, + setPageTitle, + MULTI_INSTANCE_TASK_TYPES, + LOOP_TASK_TYPES, + titleizeString, + isURL, +} from '../helpers'; +import ButtonWithConfirmation from '../components/ButtonWithConfirmation'; +import { useUriListForPermissions } from '../hooks/UriListForPermissions'; +import { + ErrorForDisplay, + EventDefinition, + KeyboardShortcuts, + PermissionsToCheck, + ProcessData, + ProcessInstance, + ProcessModel, + Task, + User, +} from '../interfaces'; +import { usePermissionFetcher } from '../hooks/PermissionService'; +import ProcessInstanceClass from '../classes/ProcessInstanceClass'; +import TaskListTable from '../components/TaskListTable'; +import useAPIError from '../hooks/UseApiError'; +import UserSearch from '../components/UserSearch'; +import ProcessInstanceLogList from '../components/ProcessInstanceLogList'; +import MessageInstanceList from '../components/messages/MessageInstanceList'; +import { + childrenForErrorObject, + errorForDisplayFromString, +} from '../components/ErrorDisplay'; +import { Notification } from '../components/Notification'; +import DateAndTimeService from '../services/DateAndTimeService'; +import ProcessInstanceCurrentTaskInfo from '../components/ProcessInstanceCurrentTaskInfo'; +import useKeyboardShortcut from '../hooks/useKeyboardShortcut'; +import useProcessInstanceNavigate from '../hooks/useProcessInstanceNavigate'; + +type OwnProps = { + variant: string; +}; + +export default function ProcessInstanceShow({ variant }: OwnProps) { + const navigate = useNavigate(); + const params = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); + const { navigateToInstance } = useProcessInstanceNavigate(); + + const eventsThatNeedPayload = ['MessageEventDefinition']; + + const [processInstance, setProcessInstance] = + useState(null); + const [tasks, setTasks] = useState(null); + const [tasksCallHadError, setTasksCallHadError] = useState(false); + const [taskToDisplay, setTaskToDisplay] = useState(null); + const [taskToTimeTravelTo, setTaskToTimeTravelTo] = useState( + null, + ); + const [taskDataToDisplay, setTaskDataToDisplay] = useState(''); + const [taskInstancesToDisplay, setTaskInstancesToDisplay] = useState( + [], + ); + const [showTaskDataLoading, setShowTaskDataLoading] = + useState(false); + + const [processDataToDisplay, setProcessDataToDisplay] = + useState(null); + const [editingTaskData, setEditingTaskData] = useState(false); + const [selectingEvent, setSelectingEvent] = useState(false); + const [eventToSend, setEventToSend] = useState({}); + const [eventPayload, setEventPayload] = useState('{}'); + const [eventTextEditorEnabled, setEventTextEditorEnabled] = + useState(false); + + const [addingPotentialOwners, setAddingPotentialOwners] = + useState(false); + const [additionalPotentialOwners, setAdditionalPotentialOwners] = useState< + User[] | null + >(null); + + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + const [selectedTaskTabSubTab, setSelectedTaskTabSubTab] = useState(0); + const [copiedShortLinkToClipboard, setCopiedShortLinkToClipboard] = + useState(false); + + const { addError, removeError } = useAPIError(); + const unModifiedProcessModelId = unModifyProcessIdentifierForPathParam( + `${params.process_model_id}`, + ); + + const modifiedProcessModelId = params.process_model_id; + const processModelId = unModifyProcessIdentifierForPathParam( + params.process_model_id ? params.process_model_id : '', + ); + + const { targetUris } = useUriListForPermissions(); + const taskListPath = + variant === 'all' + ? targetUris.processInstanceTaskListPath + : targetUris.processInstanceTaskListForMePath; + + const permissionRequestData: PermissionsToCheck = { + [`${targetUris.processInstanceMigratePath}`]: ['POST'], + [`${targetUris.processInstanceResumePath}`]: ['POST'], + [`${targetUris.processInstanceSuspendPath}`]: ['POST'], + [`${targetUris.processInstanceTerminatePath}`]: ['POST'], + [targetUris.processInstanceResetPath]: ['POST'], + [targetUris.messageInstanceListPath]: ['GET'], + [targetUris.processInstanceActionPath]: ['DELETE', 'GET', 'POST'], + [targetUris.processInstanceLogListPath]: ['GET'], + [targetUris.processInstanceTaskAssignPath]: ['POST'], + [targetUris.processInstanceTaskDataPath]: ['GET', 'PUT'], + [targetUris.processInstanceSendEventPath]: ['POST'], + [targetUris.processInstanceCompleteTaskPath]: ['POST'], + [targetUris.processModelShowPath]: ['PUT'], + [targetUris.processModelFileCreatePath]: ['GET'], + [taskListPath]: ['GET'], + }; + const { ability, permissionsLoaded } = usePermissionFetcher( + permissionRequestData, + ); + + const navigateToProcessInstances = (_result: any) => { + navigate( + `/process-instances?process_model_identifier=${unModifiedProcessModelId}`, + ); + }; + + const onProcessInstanceForceRun = ( + processInstanceResult: ProcessInstance, + ) => { + if (processInstanceResult.process_model_uses_queued_execution) { + navigateToInstance({ + processInstanceId: processInstanceResult.id, + suffix: '/progress', + }); + } else { + navigateToInstance({ + processInstanceId: processInstanceResult.id, + suffix: '/interstitial', + }); + } + }; + + const forceRunProcessInstance = () => { + if (ability.can('POST', targetUris.processInstanceActionPath)) { + HttpService.makeCallToBackend({ + path: `${targetUris.processInstanceActionPath}/run?force_run=true`, + successCallback: onProcessInstanceForceRun, + httpMethod: 'POST', + }); + } + }; + + const shortcutLoadPrimaryFile = () => { + if (ability.can('GET', targetUris.processInstanceActionPath)) { + const processResult = (result: ProcessModel) => { + const primaryFileName = result.primary_file_name; + if (!primaryFileName) { + // this should be very unlikely, since we are in the context of an instance, + // but it's techically possible for the file to have been subsequently deleted or something. + console.error('Primary file name not found for the process model.'); + return; + } + navigate( + `/editor/process-models/${modifiedProcessModelId}/files/${primaryFileName}`, + ); + }; + HttpService.makeCallToBackend({ + path: `/process-models/${modifiedProcessModelId}`, + successCallback: processResult, + }); + } + }; + + const keyboardShortcuts: KeyboardShortcuts = { + 'f,r,enter': { + function: forceRunProcessInstance, + label: '[F]orce [r]un process instance', + }, + 'd,enter': { + function: shortcutLoadPrimaryFile, + label: 'View process model [d]iagram', + }, + }; + const keyboardShortcutArea = useKeyboardShortcut(keyboardShortcuts); + + let processInstanceShowPageBaseUrl = `/process-instances/for-me/${params.process_model_id}/${params.process_instance_id}`; + const processInstanceShowPageBaseUrlAllVariant = `/process-instances/${params.process_model_id}/${params.process_instance_id}`; + if (variant === 'all') { + processInstanceShowPageBaseUrl = processInstanceShowPageBaseUrlAllVariant; + } + + const bpmnProcessGuid = searchParams.get('bpmn_process_guid'); + const tab = searchParams.get('tab'); + const taskSubTab = searchParams.get('taskSubTab'); + const processIdentifier = searchParams.get('process_identifier'); + + const handleAddErrorInUseEffect = useCallback((value: ErrorForDisplay) => { + addError(value); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const getActionableTaskList = useCallback(() => { + const processTaskFailure = (result: any) => { + setTasksCallHadError(true); + handleAddErrorInUseEffect(result); + }; + const processTasksSuccess = (results: Task[]) => { + if (params.to_task_guid) { + const matchingTask = results.find( + (task: Task) => task.guid === params.to_task_guid, + ); + if (matchingTask) { + setTaskToTimeTravelTo(matchingTask); + } + } + setTasks(results); + }; + let taskParams = '?most_recent_tasks_only=true'; + if (typeof params.to_task_guid !== 'undefined') { + taskParams = `${taskParams}&to_task_guid=${params.to_task_guid}`; + } + if (bpmnProcessGuid) { + taskParams = `${taskParams}&bpmn_process_guid=${bpmnProcessGuid}`; + } + let taskPath = ''; + if (ability.can('GET', taskListPath)) { + taskPath = `${taskListPath}${taskParams}`; + } + if (taskPath) { + HttpService.makeCallToBackend({ + path: taskPath, + successCallback: processTasksSuccess, + failureCallback: processTaskFailure, + }); + } else { + setTasksCallHadError(true); + } + }, [ + ability, + handleAddErrorInUseEffect, + params.to_task_guid, + taskListPath, + bpmnProcessGuid, + ]); + + const getProcessInstance = useCallback(() => { + let queryParams = ''; + if (processIdentifier) { + queryParams = `?process_identifier=${processIdentifier}`; + } + let apiPath = '/process-instances/for-me'; + if (variant === 'all') { + apiPath = '/process-instances'; + } + HttpService.makeCallToBackend({ + path: `${apiPath}/${modifiedProcessModelId}/${params.process_instance_id}${queryParams}`, + successCallback: (p: ProcessInstance) => { + setProcessInstance(p); + }, + }); + }, [ + params.process_instance_id, + modifiedProcessModelId, + variant, + processIdentifier, + ]); + + useEffect(() => { + if (processInstance) { + setPageTitle([ + processInstance.process_model_display_name, + `Process Instance ${processInstance.id}`, + ]); + } + return undefined; + }, [processInstance]); + + useEffect(() => { + if (!permissionsLoaded) { + return undefined; + } + getProcessInstance(); + getActionableTaskList(); + + if (tab) { + setSelectedTabIndex(parseInt(tab || '0', 10)); + } + if (taskSubTab) { + setSelectedTaskTabSubTab(parseInt(taskSubTab || '0', 10)); + } + return undefined; + }, [ + permissionsLoaded, + getActionableTaskList, + getProcessInstance, + tab, + taskSubTab, + ]); + + const updateSearchParams = (value: string, key: string) => { + if (value !== undefined) { + searchParams.set(key, value); + } else { + searchParams.delete(key); + } + setSearchParams(searchParams); + }; + + const deleteProcessInstance = () => { + HttpService.makeCallToBackend({ + path: targetUris.processInstanceActionPath, + successCallback: navigateToProcessInstances, + httpMethod: 'DELETE', + }); + }; + + const queryParams = () => { + const queryParamArray = []; + if (processIdentifier) { + queryParamArray.push(`process_identifier=${processIdentifier}`); + } + if (bpmnProcessGuid) { + queryParamArray.push(`bpmn_process_guid=${bpmnProcessGuid}`); + } + let queryParamString = ''; + if (queryParamArray.length > 0) { + queryParamString = `?${queryParamArray.join('&')}`; + } + return queryParamString; + }; + + // to force update the diagram since it could have changed + const refreshPage = () => { + // redirect to the all variant page if possible to avoid potential user/task association issues. + // such as terminating a process instance with a task that the current user is assigned to which + // will remove the task assigned to them and could potentially remove that users association to the process instance + if (ability.can('GET', targetUris.processInstanceActionPath)) { + window.location.href = `${processInstanceShowPageBaseUrlAllVariant}${queryParams()}`; + } else { + window.location.reload(); + } + }; + + const terminateProcessInstance = () => { + HttpService.makeCallToBackend({ + path: `${targetUris.processInstanceTerminatePath}`, + successCallback: refreshPage, + httpMethod: 'POST', + }); + }; + + const suspendProcessInstance = () => { + HttpService.makeCallToBackend({ + path: `${targetUris.processInstanceSuspendPath}`, + successCallback: refreshPage, + httpMethod: 'POST', + }); + }; + + const resumeProcessInstance = () => { + HttpService.makeCallToBackend({ + path: `${targetUris.processInstanceResumePath}`, + successCallback: refreshPage, + httpMethod: 'POST', + }); + }; + + const currentToTaskGuid = () => { + if (taskToTimeTravelTo) { + return taskToTimeTravelTo.guid; + } + return null; + }; + + // right now this just assume if taskToTimeTravelTo was passed in then + // this cannot be the active task. + // we may need a better way to figure this out. + const showingActiveTask = () => { + return !taskToTimeTravelTo; + }; + + const completionViewLink = (label: any, taskGuid: string) => { + return ( + + {label} + + ); + }; + + const returnToProcessInstance = () => { + window.location.href = `${processInstanceShowPageBaseUrl}${queryParams()}`; + }; + + const resetProcessInstance = () => { + HttpService.makeCallToBackend({ + path: `${targetUris.processInstanceResetPath}/${currentToTaskGuid()}`, + successCallback: returnToProcessInstance, + httpMethod: 'POST', + }); + }; + + const formatMetadataValue = (key: string, value: string) => { + if (isURL(value)) { + return ( + + {key} link + + ); + } + return value; + }; + + const getInfoTag = () => { + if (!processInstance) { + return null; + } + let lastUpdatedTimeLabel = 'Updated'; + let lastUpdatedTime = processInstance.task_updated_at_in_seconds; + if (processInstance.end_in_seconds) { + lastUpdatedTimeLabel = 'Completed'; + lastUpdatedTime = processInstance.end_in_seconds; + } + const lastUpdatedTimeTag = ( +
+
{lastUpdatedTimeLabel}:
+
+ {DateAndTimeService.convertSecondsToFormattedDateTime( + lastUpdatedTime || 0, + ) || 'N/A'} +
+
+ ); + + let statusIcon = ; + let statusColor = 'gray'; + if (processInstance.status === 'suspended') { + statusIcon = ; + } else if (processInstance.status === 'complete') { + statusIcon = ; + statusColor = 'green'; + } else if (processInstance.status === 'terminated') { + statusIcon = ; + } else if (processInstance.status === 'error') { + statusIcon = ; + statusColor = 'red'; + } + + const [lastMilestoneFullValue, lastMilestoneTruncatedValue] = + getLastMilestoneFromProcessInstance( + processInstance, + processInstance.last_milestone_bpmn_name, + ); + + return ( + + +
+
Status:
+
+ + {processInstance.status} {statusIcon} + +
+
+
+
Started by:
+
{processInstance.process_initiator_username}
+
+ {processInstance.process_model_with_diagram_identifier ? ( +
+
Current diagram:
+
+ + {processInstance.process_model_with_diagram_identifier} + +
+
+ ) : null} +
+
Started:
+
+ {DateAndTimeService.convertSecondsToFormattedDateTime( + processInstance.start_in_seconds || 0, + )} +
+
+ {lastUpdatedTimeTag} +
+
Last milestone:
+
+ {lastMilestoneTruncatedValue} +
+
+
+
Revision:
+
+ {processInstance.bpmn_version_control_identifier} ( + {processInstance.bpmn_version_control_type}) +
+
+
+ + {(processInstance.process_metadata || []).map( + (processInstanceMetadata) => ( +
+
+ {truncateString(processInstanceMetadata.key, 50)}: +
+
+ {formatMetadataValue( + processInstanceMetadata.key, + processInstanceMetadata.value, + )} +
+
+ ), + )} +
+
+ ); + }; + + const copyProcessInstanceShortLink = () => { + if (processInstance) { + const piShortLink = `${window.location.origin}/i/${processInstance.id}`; + navigator.clipboard.writeText(piShortLink); + setCopiedShortLinkToClipboard(true); + } + }; + + const navigateToProcessInstanceMigratePage = () => { + navigate( + `/process-instances/${params.process_model_id}/${params.process_instance_id}/migrate`, + ); + }; + + const terminateButton = () => { + if ( + processInstance && + !ProcessInstanceClass.terminalStatuses().includes(processInstance.status) + ) { + return ( + + ); + } + return
; + }; + + // you cannot suspend an instance that is done. except if it has status error, since + // you might want to perform admin actions to recover from an errored instance. + const suspendButton = () => { + if ( + processInstance && + !ProcessInstanceClass.nonErrorTerminalStatuses() + .concat(['suspended']) + .includes(processInstance.status) + ) { + return ( + , + ); + } + + if (canEditTaskData(task)) { + buttons.push( + , + ); + buttons.push( + , + ); + } + if (canSendEvent(task)) { + buttons.push( + , + ); + } + 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( + + + +
+ {index + 1} {': '} + {DateAndTimeService.convertSecondsToFormattedDateTime( + task.properties_json.last_state_change, + )}{' '} + {' - '} {task.state} +
+
+ + ); + })} + + ); + }; + + const createButtonsForMultiTasks = ( + instances: number[], + infoType: string, + ) => { + if (!tasks || !taskToDisplay) { + return []; + } + return instances.map((v: any) => { + return ( + + ); + }); + }; + + const taskInstanceSelector = () => { + if (!taskToDisplay) { + return null; + } + + const accordionItems = []; + + if ( + !taskIsInstanceOfMultiInstanceTask(taskToDisplay) && + taskInstancesToDisplay.length > 0 + ) { + accordionItems.push( + + {createButtonSetForTaskInstances()} + , + ); + } + + if (MULTI_INSTANCE_TASK_TYPES.includes(taskToDisplay.typename)) { + ['completed', 'running', 'future'].forEach((infoType: string) => { + let taskInstances: ReactElement[] = []; + const infoArray = taskToDisplay.runtime_info[infoType]; + taskInstances = createButtonsForMultiTasks(infoArray, infoType); + accordionItems.push( + + {taskInstances} + , + ); + }); + } + if (LOOP_TASK_TYPES.includes(taskToDisplay.typename)) { + const loopTaskInstanceIndexes = [ + ...Array(taskToDisplay.runtime_info.iterations_completed).keys(), + ]; + const buttons = createButtonsForMultiTasks( + loopTaskInstanceIndexes, + 'mi-loop-iterations', + ); + let text = ''; + if ( + typeof taskToDisplay.runtime_info.iterations_remaining !== + 'undefined' && + taskToDisplay.state !== 'COMPLETED' + ) { + text += `${taskToDisplay.runtime_info.iterations_remaining} remaining`; + } + accordionItems.push( + +
{text}
+
{buttons}
+
, + ); + } + if (accordionItems.length > 0) { + return {accordionItems}; + } + return null; + }; + + const taskUpdateDisplayArea = () => { + if (!taskToDisplay) { + return null; + } + const taskToUse: Task = { ...taskToDisplay, data: taskDataToDisplay }; + + 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; + } + if (taskToUse.runtime_info) { + if (typeof taskToUse.runtime_info.instance !== 'undefined') { + secondaryButtonText = 'Return to MultiInstance Task'; + onSecondarySubmit = () => { + switchToTask(taskToUse.properties_json.parent, [ + ...(tasks || []), + ...taskInstancesToDisplay, + ]); + }; + } else if (typeof taskToUse.runtime_info.iteration !== 'undefined') { + secondaryButtonText = 'Return to Loop Task'; + onSecondarySubmit = () => { + switchToTask(taskToUse.properties_json.parent, [ + ...(tasks || []), + ...taskInstancesToDisplay, + ]); + }; + } + } + + return ( + +
+ {taskToUse.bpmn_name ? ( +
+ + Name: {taskToUse.bpmn_name} + +
+ ) : null} + +
+ + Guid: {taskToUse.guid} + +
+
+ {taskDisplayButtons(taskToUse)} + {taskToUse.state === 'COMPLETED' ? ( +
+ + {completionViewLink( + 'View process instance at the time when this task was active.', + taskToUse.guid, + )} + +
+
+ ) : null} +
+ {taskActionDetails()} + {taskInstanceSelector()} +
+ ); + }; + + const buttonIcons = () => { + if (!processInstance) { + return null; + } + const elements = []; + elements.push(copyProcessInstanceShortLinkButton()); + if (ability.can('POST', `${targetUris.processInstanceTerminatePath}`)) { + elements.push(terminateButton()); + } + if (ability.can('POST', `${targetUris.processInstanceSuspendPath}`)) { + elements.push(suspendButton()); + } + if (ability.can('POST', `${targetUris.processInstanceMigratePath}`)) { + elements.push(migrateButton()); + } + if (ability.can('POST', `${targetUris.processInstanceResumePath}`)) { + elements.push(resumeButton()); + } + if (ability.can('DELETE', targetUris.processInstanceActionPath)) { + elements.push(deleteButton()); + } + let toast = null; + if (copiedShortLinkToClipboard) { + toast = ( + setCopiedShortLinkToClipboard(false)} + type="success" + title="Copied link to clipboard" + timeout={3000} + hideCloseButton + withBottomMargin={false} + /> + ); + elements.push(toast); + } + return elements; + }; + + const viewMostRecentStateComponent = () => { + if (!taskToTimeTravelTo) { + return null; + } + const title = `${taskToTimeTravelTo.id}: ${taskToTimeTravelTo.guid}: ${taskToTimeTravelTo.bpmn_identifier}`; + return ( + <> + + +

+ Viewing process instance at the time when{' '} + + + {taskToTimeTravelTo.bpmn_name || + taskToTimeTravelTo.bpmn_identifier} + + {' '} + was active.{' '} + + View current process instance state. + +

+
+
+
+ + ); + }; + + const diagramArea = () => { + if (!processInstance) { + return null; + } + if (!tasks && !tasksCallHadError) { + return ; + } + + const detailsComponent = ( + <> + {childrenForErrorObject( + errorForDisplayFromString( + processInstance.bpmn_xml_file_contents_retrieval_error || '', + ), + )} + + ); + return processInstance.bpmn_xml_file_contents_retrieval_error ? ( + + {detailsComponent} + + ) : ( + <> + +
+ + ); + }; + + const updateSelectedTab = (newTabIndex: any) => { + // this causes the process instance and task list to render again as well + // it'd be nice if we could find a way to avoid that + updateSearchParams(newTabIndex.selectedIndex, 'tab'); + }; + + const updateSelectedTaskTabSubTab = (newTabIndex: any) => { + updateSearchParams(newTabIndex.selectedIndex, 'taskSubTab'); + }; + + const taskTabSubTabs = () => { + if (!processInstance) { + return null; + } + + return ( + + + Completed by me + All completed + + + + {selectedTaskTabSubTab === 0 ? ( + + ) : null} + + + {selectedTaskTabSubTab === 1 ? ( + + ) : null} + + + + ); + }; + + // eslint-disable-next-line sonarjs/cognitive-complexity + const getTabs = () => { + if (!processInstance) { + return null; + } + + const canViewLogs = ability.can( + 'GET', + targetUris.processInstanceLogListPath, + ); + const canViewMsgs = ability.can('GET', targetUris.messageInstanceListPath); + + const getMessageDisplay = () => { + if (canViewMsgs) { + return ; + } + return null; + }; + + return ( + + + Diagram + Milestones + Events + Messages + Tasks + + + {selectedTabIndex === 0 ? diagramArea() : null} + + {selectedTabIndex === 1 ? ( + + ) : null} + + + {selectedTabIndex === 2 ? ( + + ) : null} + + + {selectedTabIndex === 3 ? getMessageDisplay() : null} + + + {selectedTabIndex === 4 ? taskTabSubTabs() : null} + + + + ); + }; + + if (processInstance && permissionsLoaded) { + return ( + <> + + {keyboardShortcutArea} + {taskUpdateDisplayArea()} + {processDataDisplayArea()} + {viewMostRecentStateComponent()} + +

+ Process Instance Id: {processInstance.id} +

+ {buttonIcons()} +
+ {getInfoTag()} +
+ +
+ + {getTabs()} + + ); + } + + return ( + + ); +} diff --git a/spiffworkflow-frontend/src/routes/SpiffUIV3.tsx b/spiffworkflow-frontend/src/routes/SpiffUIV3.tsx index ddd54940e..a2756db4d 100644 --- a/spiffworkflow-frontend/src/routes/SpiffUIV3.tsx +++ b/spiffworkflow-frontend/src/routes/SpiffUIV3.tsx @@ -43,6 +43,7 @@ import ProcessModelNew from '../a-spiffui-v3/views/ProcessModelNew'; import ProcessModelEdit from '../a-spiffui-v3/views/ProcessModelEdit'; // Import the edited component import ProcessModelEditDiagram from '../a-spiffui-v3/views/ProcessModelEditDiagram'; import ReactFormEditor from '../a-spiffui-v3/views/ReactFormEditor'; // Import the new component +import ProcessInstanceRoutes from '../a-spiffui-v3/views/ProcessInstanceRoutes'; const fadeIn = 'fadeIn'; const fadeOutImmediate = 'fadeOutImmediate'; @@ -327,6 +328,10 @@ export default function SpiffUIV3() { path="/process-models/:process_model_id/form" element={} /> + } + />