pi show page somewhat works w/ burnettk danfunk

This commit is contained in:
jasquat 2025-02-11 14:08:21 -05:00
parent abb38e3d27
commit cb38a5ec76
No known key found for this signature in database
14 changed files with 3686 additions and 0 deletions

View File

@ -0,0 +1,9 @@
export default class ProcessInstanceClass {
static terminalStatuses() {
return ['complete', 'error', 'terminated'];
}
static nonErrorTerminalStatuses() {
return ['complete', 'terminated'];
}
}

View File

@ -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<boolean>(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(
<IconButton
onClick={copyReportLink}
color="primary"
aria-label="Copy shareable link"
>
<LinkIcon />
</IconButton>,
);
}
elements.push(
<IconButton
data-qa="filter-section-expand-toggle"
color="primary"
aria-label="Filter Options"
onClick={toggleShowFilterOptions}
>
<FilterIcon />
</IconButton>,
);
if (copiedReportLinkToClipboard) {
elements.push(
<Snackbar
open={copiedReportLinkToClipboard}
autoHideDuration={2000}
onClose={() => setCopiedReportLinkToClipboard(false)}
message="Copied link to clipboard"
/>,
);
}
return elements;
};
if (filtersEnabled) {
let reportSearchSection = null;
if (reportSearchComponent) {
reportSearchSection = (
<Grid item xs={12} sm={6} md={8}>
{reportSearchComponent()}
</Grid>
);
}
return (
<>
<Grid container spacing={2}>
{reportSearchSection}
<Grid item xs={12} sm={6} md={4} className="filter-icon">
{buttonElements()}
</Grid>
</Grid>
{filterOptions()}
</>
);
}
return null;
}

View File

@ -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<any>(null);
const [task, setTask] = useState<ProcessInstanceTask | null>(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 (
<div>
{/* Use MUI Alert component */}
<Alert severity={severity}>
<strong>{title}</strong> {subtitle}
</Alert>
</div>
);
};
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 (
<div>
<InstructionsForEndUser task={task} allowCollapse />
</div>
);
};
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 <div className="user_instructions">{userMessage()}</div>;
}
return null;
}

View File

@ -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<boolean>(false);
const [processInstanceLogs, setProcessInstanceLogs] = useState<
ProcessInstanceLogEntry[]
>([]);
const [pagination, setPagination] = useState(null);
const [searchParams, setSearchParams] = useSearchParams();
const [taskTypes, setTaskTypes] = useState<string[]>([]);
const [eventTypes, setEventTypes] = useState<string[]>([]);
const [taskBpmnNames, setTaskBpmnNames] = useState<string[]>([]);
const [taskBpmnIdentifiers, setTaskBpmnIdentifiers] = useState<string[]>([]);
const [eventForModal, setEventForModal] =
useState<ProcessInstanceLogEntry | null>(null);
const [eventErrorDetails, setEventErrorDetails] =
useState<ProcessInstanceEventErrorDetail | null>(null);
const { targetUris } = useUriListForPermissions();
const permissionRequestData: PermissionsToCheck = {
[targetUris.processInstanceErrorEventDetails]: ['GET'],
};
const { ability } = usePermissionFetcher(permissionRequestData);
const [showFilterOptions, setShowFilterOptions] = useState<boolean>(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 = (
<CircularProgress className="some-class" size={20} />
);
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 (
<Modal
open={!!eventForModal}
onClose={handleErrorEventModalClose}
aria-labelledby="modal-heading"
aria-describedby="modal-description"
>
<div>
<h2 id="modal-heading">{modalHeading}</h2>
<p id="modal-description">Error Details</p>
{errorMessageTag}
</div>
</Modal>
);
}
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 = (
<>
&nbsp;
<ErrorOutline className="red-icon" />
</>
);
return (
<Button
variant="text"
onClick={() => getErrorDetailsForEvent(logEntry)}
title={errorTitle}
>
{logEntry.event_type}
{errorIcon}
</Button>
);
}
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 = <TableCell>{taskName}</TableCell>;
const bpmnProcessCell = (
<TableCell>
{logEntry.bpmn_process_definition_name ||
logEntry.bpmn_process_definition_identifier}
</TableCell>
);
if (isEventsView) {
tableRow.push(
<>
<TableCell data-qa="paginated-entity-id">{logEntry.id}</TableCell>
{bpmnProcessCell}
{taskNameCell}
</>,
);
} else {
tableRow.push(
<>
{taskNameCell}
{bpmnProcessCell}
</>,
);
}
if (isEventsView) {
tableRow.push(
<>
<TableCell>{logEntry.task_definition_identifier}</TableCell>
<TableCell>{logEntry.bpmn_task_type}</TableCell>
<TableCell>{eventTypeCell(logEntry)}</TableCell>
<TableCell>
{logEntry.username || (
<span className="system-user-log-entry">system</span>
)}
</TableCell>
</>,
);
}
let timestampComponent = (
<TableCell>
{DateAndTimeService.convertSecondsToFormattedDateTime(
logEntry.timestamp,
)}
</TableCell>
);
if (logEntry.spiff_task_guid && logEntry.event_type !== 'task_cancelled') {
timestampComponent = (
<TableCell>
<Link
reloadDocument
data-qa="process-instance-show-link"
to={`${processInstanceShowPageBaseUrl}/${logEntry.process_instance_id}/${logEntry.spiff_task_guid}`}
title="View state when task was completed"
>
{DateAndTimeService.convertSecondsToFormattedDateTime(
logEntry.timestamp,
)}
</Link>
</TableCell>
);
}
tableRow.push(timestampComponent);
return <TableRow key={logEntry.id}>{tableRow}</TableRow>;
};
const buildTable = () => {
const rows = processInstanceLogs.map(
(logEntry: ProcessInstanceLogEntry) => {
return getTableRow(logEntry);
},
);
const tableHeaders = [];
if (isEventsView) {
tableHeaders.push(
<>
<TableCell>Id</TableCell>
<TableCell>Bpmn process</TableCell>
<TableCell>{taskNameHeader}</TableCell>
</>,
);
} else {
tableHeaders.push(
<>
<TableCell>{taskNameHeader}</TableCell>
<TableCell>Bpmn process</TableCell>
</>,
);
}
if (isEventsView) {
tableHeaders.push(
<>
<TableCell>Task identifier</TableCell>
<TableCell>Task type</TableCell>
<TableCell>Event type</TableCell>
<TableCell>User</TableCell>
</>,
);
}
tableHeaders.push(<TableCell>Timestamp</TableCell>);
return (
<TableContainer>
<Table size="medium">
<TableHead>
<TableRow>{tableHeaders}</TableRow>
</TableHead>
<TableBody>{rows}</TableBody>
</Table>
</TableContainer>
);
};
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(
<Grid item md={4}>
<FormControl fullWidth>
<InputLabel id="task-name-filter-label">{taskNameHeader}</InputLabel>
<Select
labelId="task-name-filter-label"
id="task-name-filter"
value={searchParams.get('bpmn_name') || ''}
onChange={(event) => {
updateFilterValue(event.target.value, 'bpmn_name');
}}
>
{taskBpmnNames.map((name) => (
<MenuItem key={name} value={name}>
{name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>,
);
if (isEventsView) {
filterElements.push(
<>
<Grid item md={4}>
<FormControl fullWidth>
<InputLabel id="task-identifier-filter-label">
Task identifier
</InputLabel>
<Select
labelId="task-identifier-filter-label"
id="task-identifier-filter"
value={searchParams.get('bpmn_identifier') || ''}
onChange={(event) => {
updateFilterValue(event.target.value, 'bpmn_identifier');
}}
>
{taskBpmnIdentifiers.map((identifier) => (
<MenuItem key={identifier} value={identifier}>
{identifier}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item md={4}>
<FormControl fullWidth>
<InputLabel id="task-type-select-label">Task type</InputLabel>
<Select
labelId="task-type-select-label"
id="task-type-select"
value={searchParams.get('task_type') || ''}
onChange={(event) => {
updateFilterValue(event.target.value, 'task_type');
}}
>
{taskTypes.map((type) => (
<MenuItem key={type} value={type}>
{type}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item md={4}>
<FormControl fullWidth>
<InputLabel id="event-type-select-label">Event type</InputLabel>
<Select
labelId="event-type-select-label"
id="event-type-select"
value={searchParams.get('event_type') || ''}
onChange={(event) => {
updateFilterValue(event.target.value, 'event_type');
}}
>
{eventTypes.map((type) => (
<MenuItem key={type} value={type}>
{type}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</>,
);
}
return (
<>
<Grid container spacing={2} className="with-bottom-margin">
{filterElements}
</Grid>
<Grid container spacing={2} className="with-bottom-margin">
<Grid item sm={4} md={4} lg={8}>
<Button
variant="outlined"
onClick={resetFiltersAndRun}
>
Reset
</Button>
{shouldDisplayClearButton && (
<Button
variant="outlined"
onClick={clearFilters}
>
Clear
</Button>
)}
</Grid>
</Grid>
</>
);
};
const { page, perPage } = getPageInfoFromSearchParams(
searchParams,
undefined,
undefined,
paginationQueryParamPrefix,
);
if (clearAll) {
return <p>Page cleared 👍</p>;
}
return (
<>
{errorEventModal()}
<Filters
filterOptions={filterOptions}
showFilterOptions={showFilterOptions}
setShowFilterOptions={setShowFilterOptions}
filtersEnabled
/>
<br />
<PaginationForTable
page={page}
perPage={perPage}
pagination={pagination}
tableToDisplay={buildTable()}
paginationQueryParamPrefix={paginationQueryParamPrefix}
paginationDataQATag={`pagination-options-${tableType}`}
/>
</>
);
}

View File

@ -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
<td
title={
DateAndTimeService.convertSecondsToFormattedDateTime(timeInSeconds) ||
'-'
}
onClick={onClick}
onKeyDown={onKeyDown}
>
{timeInSeconds ? TimeAgo.inWords(timeInSeconds) : '-'}
</td>
);
}

View File

@ -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<ProcessInstanceTask[] | null>(null);
const [pagination, setPagination] = useState<PaginationObject | null>(null);
const [formSubmissionTask, setFormSubmissionTask] = useState<Task | null>(
null,
);
const preferredUsername = UserService.getPreferredUsername();
const userEmail = UserService.getUserEmail();
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 <span title={fullUsernameString}>{shortUsernameString}</span>;
};
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 (
<Modal
open={!!formSubmissionTask}
onClose={() => setFormSubmissionTask(null)}
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<div className="completed-task-modal">
<h2 id="modal-title">{formSubmissionTask.name_for_display}</h2>
<div className="indented-content explanatory-message">
You completed this task{' '}
{TimeAgo.inWords(formSubmissionTask.end_in_seconds)}
<div>
<Stack direction="row" spacing={2}>
Guid: {formSubmissionTask.guid}
</Stack>
</div>
</div>
<hr />
<div className="with-bottom-margin">
<InstructionsForEndUser task={formSubmissionTask} allowCollapse />
</div>
<CustomForm
id={formSubmissionTask.guid}
key={formSubmissionTask.guid}
formData={formSubmissionTask.data}
schema={jsonSchema}
uiSchema={formUiSchema}
disabled
>
{/* this hides the submit button */}
{true}
</CustomForm>
</div>
</Modal>
);
}
return null;
};
const getFormSubmissionDataForTask = (
processInstanceTask: ProcessInstanceTask,
) => {
HttpService.makeCallToBackend({
path: `/tasks/${processInstanceTask.process_instance_id}/${processInstanceTask.task_id}?with_form_data=true`,
httpMethod: 'GET',
successCallback: (result: Task) => setFormSubmissionTask(result),
});
};
const processIdRowElement = (processInstanceTask: ProcessInstanceTask) => {
const modifiedProcessModelIdentifier = modifyProcessIdentifierForPathParam(
processInstanceTask.process_model_identifier,
);
return (
<td>
<Link
data-qa="process-instance-show-link-id"
to={`/process-instances/for-me/${modifiedProcessModelIdentifier}/${processInstanceTask.process_instance_id}`}
title={`View process instance ${processInstanceTask.process_instance_id}`}
>
{processInstanceTask.process_instance_id}
</Link>
</td>
);
};
const dealWithProcessCells = (
rowElements: ReactElement[],
processInstanceTask: ProcessInstanceTask,
) => {
if (showProcessId) {
rowElements.push(processIdRowElement(processInstanceTask));
}
if (showProcessModelIdentifier) {
const modifiedProcessModelIdentifier =
modifyProcessIdentifierForPathParam(
processInstanceTask.process_model_identifier,
);
rowElements.push(
<td>
<Link
data-qa="process-model-show-link"
to={`/process-models/${modifiedProcessModelIdentifier}`}
title={processInstanceTask.process_model_identifier}
>
{processInstanceTask.process_model_display_name}
</Link>
</td>,
);
}
};
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(
<Button
variant="contained"
href={taskUrl}
disabled={!hasAccessToCompleteTask}
size="small"
>
Go
</Button>,
);
}
if (showViewFormDataButton) {
actions.push(
<Button
variant="contained"
onClick={() => getFormSubmissionDataForTask(processInstanceTask)}
>
View task
</Button>,
);
}
return actions;
};
const getTableRow = (processInstanceTask: ProcessInstanceTask) => {
const rowElements: ReactElement[] = [];
dealWithProcessCells(rowElements, processInstanceTask);
rowElements.push(
<td
title={`task id: ${processInstanceTask.name}, spiffworkflow task guid: ${processInstanceTask.id}`}
>
{processInstanceTask.task_title
? processInstanceTask.task_title
: processInstanceTask.task_name}
</td>,
);
if (showStartedBy) {
rowElements.push(
<td>{processInstanceTask.process_initiator_username}</td>,
);
}
if (showWaitingOn) {
rowElements.push(
<td>{getWaitingForTableCellComponent(processInstanceTask)}</td>,
);
}
if (showCompletedBy) {
rowElements.push(<td>{processInstanceTask.completed_by_username}</td>);
}
if (showDateStarted) {
rowElements.push(
<td>
{DateAndTimeService.convertSecondsToFormattedDateTime(
processInstanceTask.created_at_in_seconds,
) || '-'}
</td>,
);
}
if (showLastUpdated) {
rowElements.push(
<TableCellWithTimeAgoInWords
timeInSeconds={processInstanceTask.updated_at_in_seconds}
/>,
);
}
if (showActionsColumn) {
rowElements.push(<td>{getActionButtons(processInstanceTask)}</td>);
}
return <tr key={processInstanceTask.id}>{rowElements}</tr>;
};
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 (
<Table>
<thead>
<tr>
{tableHeaders.map((tableHeader: string) => {
return <th>{tableHeader}</th>;
})}
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
);
};
const tasksComponent = () => {
if (pagination && pagination.total < 1) {
return (
<p className="no-results-message with-large-bottom-margin">
{textToShowIfEmpty}
</p>
);
}
const { page, perPage } = getPageInfoFromSearchParams(
searchParams,
defaultPerPage,
undefined,
paginationQueryParamPrefix,
);
let tableElement = (
<div className={paginationClassName}>{buildTable()}</div>
);
if (shouldPaginateTable) {
tableElement = (
<PaginationForTable
page={page}
perPage={perPage}
perPageOptions={[2, defaultPerPage, 25]}
pagination={pagination}
tableToDisplay={buildTable()}
paginationQueryParamPrefix={paginationQueryParamPrefix}
paginationClassName={paginationClassName}
/>
);
}
return tableElement;
};
const tableAndDescriptionElement = () => {
if (!tableTitle) {
return null;
}
if (showTableDescriptionAsTooltip) {
return <h2 title={tableDescription}>{tableTitle}</h2>;
}
return (
<>
<h2>{tableTitle}</h2>
<p className="data-table-description">{tableDescription}</p>
</>
);
};
if (tasks && (tasks.length > 0 || hideIfNoTasks === false)) {
return (
<>
{formSubmissionModal()}
{tableAndDescriptionElement()}
{tasksComponent()}
</>
);
}
return null;
}

View File

@ -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<string>();
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [userList, setUserList] = useState<User[]>([]);
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 (
<Autocomplete
onInputChange={(event, value) => 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) => (
<TextField {...params} label={label} placeholder="Start typing username" />
)}
value={selectedUser}
/>
);
}

View File

@ -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;

View File

@ -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<string, number> = {
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 };
})();

View File

@ -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<boolean>(false);
// useRef to avoid a constant re-render on keydown and keyup.
const keySequence = useRef<string[]>([]);
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 (
<p>
<div className="shortcut-description">
{keyboardShortcuts[key].label}:{' '}
</div>
<div className="shortcut-key-group">
{key.split(',').map((keyString) => (
<span className="shortcut-key">{keyString}</span>
))}
</div>
</p>
);
});
return (
<Dialog
open={helpControlOpen}
onClose={() => setHelpControlOpen(false)}
maxWidth="sm"
>
<DialogTitle>Keyboard shortcuts</DialogTitle>
<DialogContent>
<p>
<div className="shortcut-description">
Open keyboard shortcut help control:
</div>
<div className="shortcut-key-group">
<span className="shortcut-key">Shift</span>
<span className="shortcut-key">?</span>
</div>
</p>
{keyboardShortcutList}
</DialogContent>
</Dialog>
);
}, [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;

View File

@ -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 };
}

View File

@ -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 (
<Routes>
{/* <Route path="/" element={<ProcessInstanceList variant="for-me" />} /> */}
{/* <Route path="for-me" element={<ProcessInstanceList variant="for-me" />} /> */}
{/* <Route path="all" element={<ProcessInstanceList variant="all" />} /> */}
<Route
path="for-me/:process_model_id/:process_instance_id"
element={<ProcessInstanceShow variant="for-me" />}
/>
<Route
path="for-me/:process_model_id/:process_instance_id/:to_task_guid"
element={<ProcessInstanceShow variant="for-me" />}
/>
{/* <Route */}
{/* path="for-me/:process_model_id/:process_instance_id/interstitial" */}
{/* element={<ProcessInterstitialPage variant="for-me" />} */}
{/* /> */}
{/* <Route */}
{/* path=":process_model_id/:process_instance_id/interstitial" */}
{/* element={<ProcessInterstitialPage variant="all" />} */}
{/* /> */}
{/* <Route */}
{/* path="for-me/:process_model_id/:process_instance_id/progress" */}
{/* element={<ProcessInstanceProgressPage variant="for-me" />} */}
{/* /> */}
{/* <Route */}
{/* path=":process_model_id/:process_instance_id/progress" */}
{/* element={<ProcessInstanceProgressPage variant="all" />} */}
{/* /> */}
{/* <Route */}
{/* path=":process_model_id/:process_instance_id/migrate" */}
{/* element={<ProcessInstanceMigratePage />} */}
{/* /> */}
<Route
path=":process_model_id/:process_instance_id"
element={<ProcessInstanceShow variant="all" />}
/>
<Route
path=":process_model_id/:process_instance_id/:to_task_guid"
element={<ProcessInstanceShow variant="all" />}
/>
{/* <Route path="reports" element={<ProcessInstanceReportList />} /> */}
{/* <Route path="reports/new" element={<ProcessInstanceReportNew />} /> */}
{/* <Route */}
{/* path="reports/:report_identifier/edit" */}
{/* element={<ProcessInstanceReportEdit />} */}
{/* /> */}
{/* <Route path="find-by-id" element={<ProcessInstanceFindById />} /> */}
</Routes>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -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={<ReactFormEditor />}
/>
<Route
path="process-instances/*"
element={<ProcessInstanceRoutes />}
/>
</Routes>
</Box>
</Box>