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 (
+
+
+
{modalHeading}
+
Error Details
+ {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 (
+ getErrorDetailsForEvent(logEntry)}
+ title={errorTitle}
+ >
+ {logEntry.event_type}
+ {errorIcon}
+
+ );
+ }
+ 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}
+ {
+ updateFilterValue(event.target.value, 'bpmn_name');
+ }}
+ >
+ {taskBpmnNames.map((name) => (
+
+ {name}
+
+ ))}
+
+
+ ,
+ );
+
+ if (isEventsView) {
+ filterElements.push(
+ <>
+
+
+
+ Task identifier
+
+ {
+ updateFilterValue(event.target.value, 'bpmn_identifier');
+ }}
+ >
+ {taskBpmnIdentifiers.map((identifier) => (
+
+ {identifier}
+
+ ))}
+
+
+
+
+
+ Task type
+ {
+ updateFilterValue(event.target.value, 'task_type');
+ }}
+ >
+ {taskTypes.map((type) => (
+
+ {type}
+
+ ))}
+
+
+
+
+
+ Event type
+ {
+ updateFilterValue(event.target.value, 'event_type');
+ }}
+ >
+ {eventTypes.map((type) => (
+
+ {type}
+
+ ))}
+
+
+
+ >,
+ );
+ }
+
+ return (
+ <>
+
+ {filterElements}
+
+
+
+
+ Reset
+
+ {shouldDisplayClearButton && (
+
+ Clear
+
+ )}
+
+
+ >
+ );
+ };
+
+ 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"
+ >
+
+
{formSubmissionTask.name_for_display}
+
+ ✅ 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(
+
+ Go
+ ,
+ );
+ }
+ if (showViewFormDataButton) {
+ actions.push(
+ getFormSubmissionDataForTask(processInstanceTask)}
+ >
+ View task
+ ,
+ );
+ }
+ 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 {tableHeader} ;
+ })}
+
+
+ {rows}
+
+ );
+ };
+
+ 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 (
+
+ );
+ }
+ return
;
+ };
+ const migrateButton = () => {
+ if (processInstance && processInstance.status === 'suspended') {
+ return (
+
+ );
+ }
+ return
;
+ };
+
+ const copyProcessInstanceShortLinkButton = () => {
+ return (
+
+ );
+ };
+
+ const resumeButton = () => {
+ if (processInstance && processInstance.status === 'suspended') {
+ return (
+
+ );
+ }
+ return
;
+ };
+
+ const deleteButton = () => {
+ if (
+ processInstance &&
+ ProcessInstanceClass.terminalStatuses().includes(processInstance.status)
+ ) {
+ return (
+
+ );
+ }
+ return
;
+ };
+
+ const initializeTaskInstancesToDisplay = useCallback(
+ (task: Task | null) => {
+ if (!task) {
+ return;
+ }
+ HttpService.makeCallToBackend({
+ path: `/tasks/${params.process_instance_id}/${task.guid}/task-instances`,
+ httpMethod: 'GET',
+ // reverse operates on self as well as return the new ordered array so reverse it right away
+ successCallback: (results: Task[]) =>
+ setTaskInstancesToDisplay(results.reverse()),
+ failureCallback: (error: any) => {
+ setTaskDataToDisplay(`ERROR: ${error.message}`);
+ },
+ });
+ },
+ [params.process_instance_id],
+ );
+
+ const processTaskResult = (result: Task) => {
+ if (result == null) {
+ setTaskDataToDisplay('');
+ } else {
+ setTaskDataToDisplay(JSON.stringify(result.data, null, 2));
+ }
+ setShowTaskDataLoading(false);
+ };
+
+ const initializeTaskDataToDisplay = useCallback(
+ (task: Task | null) => {
+ if (
+ task &&
+ ['COMPLETED', 'ERROR', 'READY'].includes(task.state) &&
+ ability.can('GET', targetUris.processInstanceTaskDataPath)
+ ) {
+ setShowTaskDataLoading(true);
+ HttpService.makeCallToBackend({
+ path: `${targetUris.processInstanceTaskDataPath}/${task.guid}`,
+ httpMethod: 'GET',
+ successCallback: processTaskResult,
+ failureCallback: (error: any) => {
+ setTaskDataToDisplay(`ERROR: ${error.message}`);
+ setShowTaskDataLoading(false);
+ },
+ });
+ } else {
+ setTaskDataToDisplay('');
+ }
+ },
+ [ability, targetUris.processInstanceTaskDataPath],
+ );
+
+ const handleProcessDataDisplayClose = () => {
+ setProcessDataToDisplay(null);
+ };
+
+ const processDataDisplayArea = () => {
+ if (processDataToDisplay) {
+ let bodyComponent = (
+ <>
+ Value:
+ {JSON.stringify(processDataToDisplay.process_data_value)}
+ >
+ );
+ if (processDataToDisplay.authorized === false) {
+ bodyComponent = (
+ <>
+ {childrenForErrorObject(
+ errorForDisplayFromString(
+ processDataToDisplay.process_data_value,
+ ),
+ )}
+ >
+ );
+ }
+ return (
+
+ Data Object: {processDataToDisplay.process_data_identifier}
+
+ {bodyComponent}
+
+ );
+ }
+ return null;
+ };
+
+ const handleProcessDataShowResponse = (processData: ProcessData) => {
+ setProcessDataToDisplay(processData);
+ };
+
+ const handleProcessDataShowReponseUnauthorized = (
+ dataObjectIdentifer: string,
+ result: any,
+ ) => {
+ const processData: ProcessData = {
+ process_data_identifier: dataObjectIdentifer,
+ process_data_value: result.message,
+ authorized: false,
+ };
+ setProcessDataToDisplay(processData);
+ };
+
+ const makeProcessDataCallFromShapeElement = useCallback(
+ (shapeElement: any) => {
+ const { dataObjectRef } = shapeElement.businessObject;
+ let category = 'default';
+ if ('extensionElements' in dataObjectRef) {
+ const categoryExtension = dataObjectRef.extensionElements.values.find(
+ (extension: any) => {
+ return extension.$type === 'spiffworkflow:category';
+ },
+ );
+ if (categoryExtension) {
+ category = categoryExtension.$body;
+ }
+ }
+ const dataObjectIdentifer = dataObjectRef.id;
+ const parentProcess = shapeElement.businessObject.$parent;
+ const parentProcessIdentifier = parentProcess.id;
+
+ let additionalParams = '';
+ if (tasks) {
+ const matchingTask: Task | undefined = tasks.find((task: Task) => {
+ return task.bpmn_identifier === parentProcessIdentifier;
+ });
+ if (matchingTask) {
+ additionalParams = `?process_identifier=${parentProcessIdentifier}&bpmn_process_guid=${matchingTask.guid}`;
+ } else if (processIdentifier && bpmnProcessGuid) {
+ additionalParams = `?process_identifier=${processIdentifier}&bpmn_process_guid=${bpmnProcessGuid}`;
+ }
+ }
+
+ HttpService.makeCallToBackend({
+ path: `/process-data/${category}/${params.process_model_id}/${dataObjectIdentifer}/${params.process_instance_id}${additionalParams}`,
+ httpMethod: 'GET',
+ successCallback: handleProcessDataShowResponse,
+ failureCallback: addError,
+ onUnauthorized: (result: any) =>
+ handleProcessDataShowReponseUnauthorized(dataObjectIdentifer, result),
+ });
+ },
+ [
+ addError,
+ params.process_instance_id,
+ params.process_model_id,
+ tasks,
+ bpmnProcessGuid,
+ processIdentifier,
+ ],
+ );
+
+ const findMatchingTaskFromShapeElement = useCallback(
+ (shapeElement: any, bpmnProcessIdentifiers: any) => {
+ if (tasks) {
+ const matchingTask: Task | undefined = tasks.find((task: Task) => {
+ return (
+ task.bpmn_identifier === shapeElement.id &&
+ bpmnProcessIdentifiers.includes(
+ task.bpmn_process_definition_identifier,
+ )
+ );
+ });
+ return matchingTask;
+ }
+ return undefined;
+ },
+ [tasks],
+ );
+
+ const handleCallActivityNavigate = useCallback(
+ (task: Task, event: any) => {
+ if (
+ task &&
+ task.typename === 'CallActivity' &&
+ !['FUTURE', 'LIKELY', 'MAYBE'].includes(task.state)
+ ) {
+ const processIdentifierToUse =
+ task.task_definition_properties_json.spec;
+ const url = `${window.location.pathname}?process_identifier=${processIdentifierToUse}&bpmn_process_guid=${task.guid}`;
+ if (event.type === 'auxclick') {
+ window.open(url);
+ } else {
+ setTasks(null);
+ setProcessInstance(null);
+ navigate(url);
+ }
+ }
+ },
+ [navigate],
+ );
+
+ const handleClickedDiagramTask = useCallback(
+ (shapeElement: any, bpmnProcessIdentifiers: any) => {
+ if (shapeElement.type === 'bpmn:DataObjectReference') {
+ makeProcessDataCallFromShapeElement(shapeElement);
+ } else if (tasks) {
+ const matchingTask = findMatchingTaskFromShapeElement(
+ shapeElement,
+ bpmnProcessIdentifiers,
+ );
+ if (matchingTask) {
+ setTaskToDisplay(matchingTask);
+ initializeTaskDataToDisplay(matchingTask);
+ initializeTaskInstancesToDisplay(matchingTask);
+ }
+ }
+ },
+ [
+ findMatchingTaskFromShapeElement,
+ initializeTaskDataToDisplay,
+ initializeTaskInstancesToDisplay,
+ makeProcessDataCallFromShapeElement,
+ tasks,
+ ],
+ );
+
+ const resetTaskActionDetails = () => {
+ setEditingTaskData(false);
+ setSelectingEvent(false);
+ setAddingPotentialOwners(false);
+ initializeTaskDataToDisplay(taskToDisplay);
+ initializeTaskInstancesToDisplay(taskToDisplay);
+ setEventPayload('{}');
+ setAdditionalPotentialOwners(null);
+ removeError();
+ };
+
+ const handleTaskDataDisplayClose = () => {
+ setTaskToDisplay(null);
+ initializeTaskDataToDisplay(null);
+ initializeTaskInstancesToDisplay(null);
+ if (editingTaskData || selectingEvent || addingPotentialOwners) {
+ resetTaskActionDetails();
+ }
+ };
+
+ const getTaskById = (taskId: string) => {
+ if (tasks !== null) {
+ return tasks.find((task: Task) => task.guid === taskId) || null;
+ }
+ return null;
+ };
+
+ const processScriptUnitTestCreateResult = (result: any) => {
+ console.log('result', result);
+ };
+
+ const getParentTaskFromTask = (task: Task) => {
+ return task.properties_json.parent;
+ };
+
+ const createScriptUnitTest = () => {
+ if (taskToDisplay) {
+ const previousTask: Task | null = getTaskById(
+ getParentTaskFromTask(taskToDisplay),
+ );
+ HttpService.makeCallToBackend({
+ path: `/process-models/${modifiedProcessModelId}/script-unit-tests`,
+ httpMethod: 'POST',
+ successCallback: processScriptUnitTestCreateResult,
+ postBody: {
+ bpmn_task_identifier: taskToDisplay.bpmn_identifier,
+ input_json: previousTask ? previousTask.data : '',
+ expected_output_json: taskToDisplay.data,
+ },
+ });
+ }
+ };
+
+ const isActiveTask = (task: Task) => {
+ const subprocessTypes = [
+ 'Subprocess',
+ 'CallActivity',
+ 'Transactional Subprocess',
+ ];
+ return (
+ (task.state === 'WAITING' &&
+ subprocessTypes.filter((t) => t === task.typename).length > 0) ||
+ task.state === 'READY' ||
+ (processInstance &&
+ processInstance.status === 'suspended' &&
+ task.state === 'ERROR')
+ );
+ };
+
+ const canEditTaskData = (task: Task) => {
+ return (
+ processInstance &&
+ ability.can('PUT', targetUris.processInstanceTaskDataPath) &&
+ isActiveTask(task) &&
+ processInstance.status === 'suspended' &&
+ showingActiveTask()
+ );
+ };
+
+ const canSendEvent = (task: Task) => {
+ // We actually could allow this for any waiting events
+ const taskTypes = ['EventBasedGateway'];
+ return (
+ !selectingEvent &&
+ processInstance &&
+ processInstance.status === 'waiting' &&
+ ability.can('POST', targetUris.processInstanceSendEventPath) &&
+ taskTypes.filter((t) => t === task.typename).length > 0 &&
+ task.state === 'WAITING' &&
+ showingActiveTask()
+ );
+ };
+
+ const canCompleteTask = (task: Task) => {
+ return (
+ processInstance &&
+ processInstance.status === 'suspended' &&
+ ability.can('POST', targetUris.processInstanceCompleteTaskPath) &&
+ isActiveTask(task) &&
+ showingActiveTask()
+ );
+ };
+
+ const canAddPotentialOwners = (task: Task) => {
+ return (
+ HUMAN_TASK_TYPES.includes(task.typename) &&
+ processInstance &&
+ processInstance.status === 'suspended' &&
+ ability.can('POST', targetUris.processInstanceTaskAssignPath) &&
+ isActiveTask(task) &&
+ showingActiveTask()
+ );
+ };
+
+ const canResetProcess = (task: Task) => {
+ return (
+ ability.can('POST', targetUris.processInstanceResetPath) &&
+ processInstance &&
+ processInstance.status === 'suspended' &&
+ task.state === 'READY' &&
+ !showingActiveTask()
+ );
+ };
+
+ const getEvents = (task: Task) => {
+ const handleMessage = (eventDefinition: EventDefinition) => {
+ if (eventsThatNeedPayload.includes(eventDefinition.typename)) {
+ const newEvent = eventDefinition;
+ delete newEvent.message_var;
+ newEvent.payload = {};
+ return newEvent;
+ }
+ return eventDefinition;
+ };
+ const eventDefinition =
+ task.task_definition_properties_json.event_definition;
+ if (eventDefinition && eventDefinition.event_definitions) {
+ return eventDefinition.event_definitions.map((e: EventDefinition) =>
+ handleMessage(e),
+ );
+ }
+ if (eventDefinition) {
+ return [handleMessage(eventDefinition)];
+ }
+ return [];
+ };
+
+ const taskDataStringToObject = (dataString: string) => {
+ return JSON.parse(dataString);
+ };
+
+ const saveTaskDataResult = (_: any) => {
+ setEditingTaskData(false);
+ const dataObject = taskDataStringToObject(taskDataToDisplay);
+ if (taskToDisplay) {
+ const taskToDisplayCopy: Task = {
+ ...taskToDisplay,
+ data: dataObject,
+ }; // spread operator
+ setTaskToDisplay(taskToDisplayCopy);
+ }
+ };
+
+ const saveTaskData = () => {
+ if (!taskToDisplay) {
+ return;
+ }
+ removeError();
+
+ // taskToUse is copy of taskToDisplay, with taskDataToDisplay in data attribute
+ const taskToUse: Task = { ...taskToDisplay, data: taskDataToDisplay };
+ HttpService.makeCallToBackend({
+ path: `${targetUris.processInstanceTaskDataPath}/${taskToUse.guid}`,
+ httpMethod: 'PUT',
+ successCallback: saveTaskDataResult,
+ failureCallback: addError,
+ postBody: {
+ new_task_data: taskToUse.data,
+ },
+ });
+ };
+
+ const addPotentialOwners = () => {
+ if (!additionalPotentialOwners) {
+ return;
+ }
+ if (!taskToDisplay) {
+ return;
+ }
+ removeError();
+
+ const userIds = additionalPotentialOwners.map((user: User) => user.id);
+
+ HttpService.makeCallToBackend({
+ path: `${targetUris.processInstanceTaskAssignPath}/${taskToDisplay.guid}`,
+ httpMethod: 'POST',
+ successCallback: resetTaskActionDetails,
+ failureCallback: addError,
+ postBody: {
+ user_ids: userIds,
+ },
+ });
+ };
+
+ const sendEvent = () => {
+ if ('payload' in eventToSend) {
+ eventToSend.payload = JSON.parse(eventPayload);
+ }
+ HttpService.makeCallToBackend({
+ path: targetUris.processInstanceSendEventPath,
+ httpMethod: 'POST',
+ successCallback: refreshPage,
+ failureCallback: addError,
+ postBody: eventToSend,
+ });
+ };
+
+ const completeTask = (execute: boolean) => {
+ if (taskToDisplay) {
+ HttpService.makeCallToBackend({
+ path: `/task-complete/${modifiedProcessModelId}/${params.process_instance_id}/${taskToDisplay.guid}`,
+ httpMethod: 'POST',
+ successCallback: returnToProcessInstance,
+ postBody: { execute },
+ });
+ }
+ };
+
+ const taskDisplayButtons = (task: Task) => {
+ const buttons = [];
+ if (editingTaskData || addingPotentialOwners || selectingEvent) {
+ return null;
+ }
+
+ if (
+ task.typename === 'ScriptTask' &&
+ ability.can('PUT', targetUris.processModelShowPath)
+ ) {
+ buttons.push(
+ ,
+ );
+ }
+
+ if (
+ task.typename === 'CallActivity' &&
+ !['FUTURE', 'LIKELY', 'MAYBE'].includes(task.state)
+ ) {
+ buttons.push(
+ {
+ handleCallActivityNavigate(task, event);
+ }}
+ onClick={(event: any) => {
+ setTaskToDisplay(null);
+ handleCallActivityNavigate(task, event);
+ }}
+ >
+ View Call Activity Diagram
+ ,
+ );
+ }
+
+ if (canEditTaskData(task)) {
+ buttons.push(
+ setEditingTaskData(true)}
+ />,
+ );
+ }
+ if (canAddPotentialOwners(task)) {
+ buttons.push(
+ setAddingPotentialOwners(true)}
+ />,
+ );
+ }
+ if (canCompleteTask(task)) {
+ buttons.push(
+ completeTask(true)}
+ >
+ Execute Task
+ ,
+ );
+ buttons.push(
+ completeTask(false)}
+ >
+ Skip Task
+ ,
+ );
+ }
+ if (canSendEvent(task)) {
+ buttons.push(
+ setSelectingEvent(true)}
+ >
+ Send Event
+ ,
+ );
+ }
+ 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(
+ resetProcessInstance()}
+ />,
+ );
+ }
+ return buttons;
+ };
+
+ const taskDataContainer = () => {
+ let taskDataClassName = '';
+ if (taskDataToDisplay.startsWith('ERROR:')) {
+ taskDataClassName = 'failure-string';
+ }
+ const numberOfLines = taskDataToDisplay.split('\n').length;
+ let heightInEm = numberOfLines + 5;
+ let scrollEnabled = false;
+ let minimapEnabled = false;
+ if (heightInEm > 30) {
+ heightInEm = 30;
+ scrollEnabled = true;
+ minimapEnabled = true;
+ }
+ let taskDataHeader = 'Task data';
+ let editorReadOnly = true;
+ let taskDataHeaderClassName = 'with-half-rem-bottom-margin';
+
+ if (editingTaskData) {
+ editorReadOnly = false;
+ taskDataHeader = 'Edit task data';
+ taskDataHeaderClassName = 'task-data-details-header';
+ }
+
+ if (!taskDataToDisplay) {
+ return null;
+ }
+
+ return (
+ <>
+ {showTaskDataLoading ? (
+
+ ) : null}
+ {taskDataClassName !== '' ? (
+ {taskDataToDisplay}
+ ) : (
+ <>
+ {taskDataHeader}
+ {
+ setTaskDataToDisplay(value || '');
+ }}
+ options={{
+ readOnly: editorReadOnly,
+ scrollBeyondLastLine: scrollEnabled,
+ minimap: { enabled: minimapEnabled },
+ }}
+ />
+ >
+ )}
+ >
+ );
+ };
+
+ const potentialOwnerSelector = () => {
+ return (
+
+ Update task ownership
+
+
+ Select a user who should be allowed to complete this task
+
+
{
+ setAdditionalPotentialOwners([user]);
+ }}
+ />
+
+
+ );
+ };
+
+ const eventSelector = (candidateEvents: any) => {
+ let editor = null;
+ let className = 'modal-dropdown';
+ if (eventTextEditorEnabled) {
+ className = '';
+ editor = (
+ setEventPayload(value || '{}')}
+ options={{ readOnly: !eventTextEditorEnabled }}
+ />
+ );
+ }
+ return (
+
+ Choose event to send
+
+
+ Select an event to send. A message event will require a body as
+ well.
+
+
+ item.name || item.label || item.typename
+ }
+ onChange={(value: any) => {
+ setEventToSend(value.selectedItem);
+ setEventTextEditorEnabled(
+ eventsThatNeedPayload.includes(value.selectedItem.typename),
+ );
+ }}
+ />
+ {editor}
+
+
+ );
+ };
+
+ const taskIsInstanceOfMultiInstanceTask = (task: Task) => {
+ // this is the same check made in the backend in the _process_instance_task_list method to determine
+ // if the given task is an instance of a multi-instance or loop task.
+ // we need to avoid resetting the task instance list since the list may not be the same as we need
+ return 'instance' in task.runtime_info || 'iteration' in task.runtime_info;
+ };
+
+ const taskActionDetails = () => {
+ if (!taskToDisplay) {
+ return null;
+ }
+ let dataArea = taskDataContainer();
+ if (selectingEvent) {
+ const candidateEvents: any = getEvents(taskToDisplay);
+ dataArea = eventSelector(candidateEvents);
+ } else if (addingPotentialOwners) {
+ dataArea = potentialOwnerSelector();
+ }
+ return dataArea;
+ };
+
+ const switchToTask = (taskGuid: string, taskListToUse: Task[] | null) => {
+ if (taskListToUse && taskToDisplay) {
+ const task = taskListToUse.find((t: Task) => t.guid === taskGuid);
+ if (task) {
+ // set to null right away to hopefully avoid using the incorrect task later
+ setTaskToDisplay(null);
+ setTaskToDisplay(task);
+ initializeTaskDataToDisplay(task);
+ }
+ }
+ };
+ const createButtonSetForTaskInstances = () => {
+ if (taskInstancesToDisplay.length === 0 || !taskToDisplay) {
+ return null;
+ }
+ return (
+ <>
+ {taskInstancesToDisplay.map((task: Task, index: number) => {
+ const buttonClass =
+ task.guid === taskToDisplay.guid ? 'selected-task-instance' : null;
+ return (
+
+
+
+ switchToTask(task.guid, taskInstancesToDisplay)
+ }
+ >
+ View
+
+
+
+
+ {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 (
+
+ switchToTask(taskToDisplay.runtime_info.instance_map[v], tasks)
+ }
+ >
+ {v + 1}
+
+ );
+ });
+ };
+
+ 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={ }
/>
+ }
+ />