mirror of
https://github.com/sartography/spiff-arena.git
synced 2025-02-22 22:28:15 +00:00
pi show page somewhat works w/ burnettk danfunk
This commit is contained in:
parent
abb38e3d27
commit
cb38a5ec76
@ -0,0 +1,9 @@
|
||||
export default class ProcessInstanceClass {
|
||||
static terminalStatuses() {
|
||||
return ['complete', 'error', 'terminated'];
|
||||
}
|
||||
|
||||
static nonErrorTerminalStatuses() {
|
||||
return ['complete', 'terminated'];
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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 = (
|
||||
<>
|
||||
|
||||
<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}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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;
|
79
spiffworkflow-frontend/src/a-spiffui-v3/helpers/timeago.ts
Normal file
79
spiffworkflow-frontend/src/a-spiffui-v3/helpers/timeago.ts
Normal 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 };
|
||||
})();
|
@ -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;
|
@ -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 };
|
||||
}
|
@ -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
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user