moved some components to new ui w/ burnettk

This commit is contained in:
jasquat 2025-01-03 15:23:50 -05:00
parent 818fbcb217
commit f018faf505
No known key found for this signature in database
7 changed files with 1629 additions and 0 deletions

View File

@ -0,0 +1,211 @@
import { useEffect, useState } from 'react';
// @ts-ignore
import { ErrorOutline } from '@carbon/icons-react';
// @ts-ignore
import { Table, Modal, Button } from '@carbon/react';
import { Link, useSearchParams } from 'react-router-dom';
import PaginationForTable from '../PaginationForTable';
import ProcessBreadcrumb from '../ProcessBreadcrumb';
import {
getPageInfoFromSearchParams,
modifyProcessIdentifierForPathParam,
} from '../../helpers';
import HttpService from '../../services/HttpService';
import { FormatProcessModelDisplayName } from '../MiniComponents';
import { MessageInstance } from '../../interfaces';
import DateAndTimeService from '../../services/DateAndTimeService';
type OwnProps = {
processInstanceId?: number;
};
const paginationQueryParamPrefix = 'message-list';
export default function MessageInstanceList({ processInstanceId }: OwnProps) {
const [messageInstances, setMessageInstances] = useState([]);
const [pagination, setPagination] = useState(null);
const [searchParams] = useSearchParams();
const [messageInstanceForModal, setMessageInstanceForModal] =
useState<MessageInstance | null>(null);
useEffect(() => {
const setMessageInstanceListFromResult = (result: any) => {
setMessageInstances(result.results);
setPagination(result.pagination);
};
const { page, perPage } = getPageInfoFromSearchParams(
searchParams,
undefined,
undefined,
paginationQueryParamPrefix,
);
let queryParamString = `per_page=${perPage}&page=${page}`;
if (processInstanceId) {
queryParamString += `&process_instance_id=${processInstanceId}`;
}
HttpService.makeCallToBackend({
path: `/messages?${queryParamString}`,
successCallback: setMessageInstanceListFromResult,
});
}, [processInstanceId, searchParams]);
const handleCorrelationDisplayClose = () => {
setMessageInstanceForModal(null);
};
const correlationsDisplayModal = () => {
if (messageInstanceForModal) {
let failureCausePre = null;
if (messageInstanceForModal.failure_cause) {
failureCausePre = (
<>
<p className="failure-string">
{messageInstanceForModal.failure_cause}
</p>
<br />
</>
);
}
return (
<Modal
open={!!messageInstanceForModal}
passiveModal
onRequestClose={handleCorrelationDisplayClose}
modalHeading={`Message ${messageInstanceForModal.id} (${messageInstanceForModal.name}) ${messageInstanceForModal.message_type} data:`}
modalLabel="Details"
>
{failureCausePre}
<p>Correlations:</p>
<pre>
{JSON.stringify(messageInstanceForModal.correlation_keys, null, 2)}
</pre>
</Modal>
);
}
return null;
};
const buildTable = () => {
const rows = messageInstances.map((row: MessageInstance) => {
let errorIcon = null;
let errorTitle = null;
if (row.failure_cause) {
errorTitle = 'Instance has an error';
errorIcon = (
<>
&nbsp;
<ErrorOutline className="red-icon" />
</>
);
}
let processLink = <span>External Call</span>;
let instanceLink = <span />;
if (row.process_instance_id != null) {
processLink = FormatProcessModelDisplayName(row);
instanceLink = (
<Link
data-qa="process-instance-show-link"
to={`/process-instances/${modifyProcessIdentifierForPathParam(
row.process_model_identifier,
)}/${row.process_instance_id}`}
>
{row.process_instance_id}
</Link>
);
}
return (
<tr key={row.id}>
<td>{row.id}</td>
<td>{processLink}</td>
<td>{instanceLink}</td>
<td>{row.name}</td>
<td>{row.message_type}</td>
<td>{row.counterpart_id}</td>
<td>
<Button
kind="ghost"
className="button-link"
onClick={() => setMessageInstanceForModal(row)}
title={errorTitle}
>
View
{errorIcon}
</Button>
</td>
<td>{row.status}</td>
<td>
{DateAndTimeService.convertSecondsToFormattedDateTime(
row.created_at_in_seconds,
)}
</td>
</tr>
);
});
return (
<Table striped bordered>
<thead>
<tr>
<th>Id</th>
<th>Process</th>
<th>Process instance</th>
<th>Name</th>
<th>Type</th>
<th>Corresponding Message Instance</th>
<th>Details</th>
<th>Status</th>
<th>Created at</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
);
};
if (pagination) {
const { page, perPage } = getPageInfoFromSearchParams(
searchParams,
undefined,
undefined,
paginationQueryParamPrefix,
);
let breadcrumbElement = null;
if (searchParams.get('process_instance_id')) {
breadcrumbElement = (
<ProcessBreadcrumb
hotCrumbs={[
['Process Groups', '/process-groups'],
{
entityToExplode: searchParams.get('process_model_id') || '',
entityType: 'process-model-id',
linkLastItem: true,
},
[
`Process Instance: ${searchParams.get('process_instance_id')}`,
`/process-instances/${searchParams.get(
'process_model_id',
)}/${searchParams.get('process_instance_id')}`,
],
['Messages'],
]}
/>
);
}
return (
<>
{breadcrumbElement}
{correlationsDisplayModal()}
<PaginationForTable
page={page}
perPage={perPage}
perPageOptions={[10, 50, 100, 500, 1000]}
pagination={pagination}
tableToDisplay={buildTable()}
paginationQueryParamPrefix={paginationQueryParamPrefix}
/>
</>
);
}
return null;
}

View File

@ -0,0 +1,330 @@
import { Buffer } from 'buffer';
import { createRoot } from 'react-dom/client';
import { flushSync } from 'react-dom';
import { ReactElement } from 'react';
import { ElementForArray, ProcessInstance } from './interfaces';
export const DEFAULT_PER_PAGE = 50;
export const DEFAULT_PAGE = 1;
export const doNothing = () => {
return undefined;
};
export const matchNumberRegex = /^[0-9,.-]*$/;
// https://www.30secondsofcode.org/js/s/slugify
export const slugifyString = (str: string) => {
return str
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+/g, '')
.replace(/-+$/g, '');
};
export const HUMAN_TASK_TYPES = [
'User Task',
'Manual Task',
'UserTask',
'ManualTask',
'Task',
// HumanTaskModel.task_type is sometimes set to this and it is the same as "Task"
'NoneTask',
];
export const MULTI_INSTANCE_TASK_TYPES = [
'ParallelMultiInstanceTask',
'SequentialMultiInstanceTask',
];
export const LOOP_TASK_TYPES = ['StandardLoopTask'];
export const underscorizeString = (inputString: string) => {
return slugifyString(inputString).replace(/-/g, '_');
};
export const getKeyByValue = (
object: any,
value: string,
onAttribute?: string,
) => {
return Object.keys(object).find((key) => {
if (onAttribute) {
return object[key][onAttribute] === value;
}
return object[key] === value;
});
};
// NOTE: rjsf sets blanks values to undefined and JSON.stringify removes keys with undefined values
// so we convert undefined values to null recursively so that we can unset values in form fields
export const recursivelyChangeNullAndUndefined = (obj: any, newValue: any) => {
if (obj === null || obj === undefined) {
return newValue;
}
if (Array.isArray(obj)) {
obj.forEach((value: any, index: number) => {
// eslint-disable-next-line no-param-reassign
obj[index] = recursivelyChangeNullAndUndefined(value, newValue);
});
} else if (typeof obj === 'object') {
Object.entries(obj).forEach(([key, value]) => {
// eslint-disable-next-line no-param-reassign
obj[key] = recursivelyChangeNullAndUndefined(value, newValue);
});
}
return obj;
};
export const selectKeysFromSearchParams = (obj: any, keys: string[]) => {
const newSearchParams: { [key: string]: string } = {};
keys.forEach((key: string) => {
const value = obj.get(key);
if (value) {
newSearchParams[key] = value;
}
});
return newSearchParams;
};
export const capitalizeFirstLetter = (string: any) => {
return string.charAt(0).toUpperCase() + string.slice(1);
};
export const titleizeString = (string: any) => {
return capitalizeFirstLetter((string || '').replaceAll('_', ' '));
};
export const objectIsEmpty = (obj: object) => {
return Object.keys(obj).length === 0;
};
export const getPageInfoFromSearchParams = (
searchParams: any,
defaultPerPage: string | number = DEFAULT_PER_PAGE,
defaultPage: string | number = DEFAULT_PAGE,
paginationQueryParamPrefix: string | null = null,
) => {
const paginationQueryParamPrefixToUse = paginationQueryParamPrefix
? `${paginationQueryParamPrefix}_`
: '';
const page = parseInt(
searchParams.get(`${paginationQueryParamPrefixToUse}page`) ||
defaultPage.toString(),
10,
);
const perPage = parseInt(
searchParams.get(`${paginationQueryParamPrefixToUse}per_page`) ||
defaultPerPage.toString(),
10,
);
return { page, perPage };
};
// https://stackoverflow.com/a/1349426/6090676
export const makeid = (length: number) => {
let result = '';
const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
for (let i = 0; i < length; i += 1) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
};
export const getProcessModelFullIdentifierFromSearchParams = (
searchParams: any,
) => {
let processModelFullIdentifier = null;
if (searchParams.get('process_model_identifier')) {
processModelFullIdentifier = `${searchParams.get(
'process_model_identifier',
)}`;
}
return processModelFullIdentifier;
};
// https://stackoverflow.com/a/71352046/6090676
export const truncateString = (text: string, len: number) => {
if (text.length > len && text.length > 0) {
return `${text.split('').slice(0, len).join('')} ...`;
}
return text;
};
export const pathFromFullUrl = (fullUrl: string) => {
const parsedURL = new URL(fullUrl);
return parsedURL.pathname;
};
// Because of limitations in the way openapi defines parameters, we have to modify process models ids
// which are basically paths to the models
export const modifyProcessIdentifierForPathParam = (path: string) => {
return path.replace(/\//g, ':') || '';
};
export const unModifyProcessIdentifierForPathParam = (path: string) => {
return path.replace(/:/g, '/') || '';
};
export const getGroupFromModifiedModelId = (modifiedId: string) => {
const finalSplitIndex = modifiedId.lastIndexOf(':');
return modifiedId.slice(0, finalSplitIndex);
};
export const splitProcessModelId = (processModelId: string) => {
return processModelId.split('/');
};
export const refreshAtInterval = (
interval: number,
timeout: number,
periodicFunction: Function,
cleanupFunction?: Function,
) => {
const intervalRef = setInterval(() => periodicFunction(), interval * 1000);
const timeoutRef = setTimeout(() => {
clearInterval(intervalRef);
if (cleanupFunction) {
cleanupFunction();
}
}, timeout * 1000);
return () => {
clearInterval(intervalRef);
if (cleanupFunction) {
cleanupFunction();
}
clearTimeout(timeoutRef);
};
};
// bpmn:SubProcess shape elements do not have children
// but their moddle elements / businessOjects have flowElements
// that can include the moddleElement of the subprocesses
const getChildProcessesFromModdleElement = (bpmnModdleElement: any) => {
let elements: string[] = [];
bpmnModdleElement.flowElements.forEach((c: any) => {
if (c.$type === 'bpmn:SubProcess') {
elements.push(c.id);
elements = [...elements, ...getChildProcessesFromModdleElement(c)];
}
});
return elements;
};
const getChildProcesses = (bpmnElement: any) => {
let elements: string[] = [];
bpmnElement.children.forEach((c: any) => {
if (c.type === 'bpmn:Participant') {
if (c.businessObject.processRef) {
elements.push(c.businessObject.processRef.id);
}
elements = [...elements, ...getChildProcesses(c)];
} else if (c.type === 'bpmn:SubProcess') {
elements.push(c.id);
elements = [
...elements,
...getChildProcessesFromModdleElement(c.businessObject),
];
}
});
return elements;
};
export const getBpmnProcessIdentifiers = (rootBpmnElement: any) => {
const childProcesses = getChildProcesses(rootBpmnElement);
childProcesses.push(rootBpmnElement.businessObject.id);
return childProcesses;
};
export const setPageTitle = (items: Array<string>) => {
document.title = ['SpiffWorkflow'].concat(items).join(' - ');
};
// calling it isANumber to avoid confusion with other libraries
// that have isNumber methods
export const isANumber = (str: string | number | null) => {
if (str === undefined || str === null) {
return false;
}
return /^\d+(\.\d+)?$/.test(str.toString());
};
export const encodeBase64 = (data: string) => {
return Buffer.from(data).toString('base64');
};
export const decodeBase64 = (data: string) => {
return Buffer.from(data, 'base64').toString('ascii');
};
export const getLastMilestoneFromProcessInstance = (
processInstance: ProcessInstance,
value: any,
) => {
let valueToUse = value;
if (!valueToUse) {
if (processInstance.status === 'not_started') {
valueToUse = 'Created';
} else if (
['complete', 'error', 'terminated'].includes(processInstance.status)
) {
valueToUse = 'Completed';
} else {
valueToUse = 'Started';
}
}
let truncatedValue = valueToUse;
const milestoneLengthLimit = 20;
if (truncatedValue.length > milestoneLengthLimit) {
truncatedValue = `${truncatedValue.substring(
0,
milestoneLengthLimit - 3,
)}...`;
}
return [valueToUse, truncatedValue];
};
export const parseTaskShowUrl = (url: string) => {
const path = pathFromFullUrl(url);
// expected url pattern:
// /tasks/[process_instance_id]/[task_guid]
return path.match(
/^\/tasks\/(\d+)\/([0-9a-z]{8}-([0-9a-z]{4}-){3}[0-9a-z]{12})$/,
);
};
// https://stackoverflow.com/a/15855457/6090676
export const isURL = (str: string) => {
const urlRegex =
// eslint-disable-next-line max-len, sonarjs/regex-complexity
/^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,}))?)(?::\d{2,5})?(?:[/?#]\S*)?$/i;
return urlRegex.test(str);
};
// this will help maintain order when using an array of elements.
// React needs to have a key for each component to ensure correct elements are added
// and removed when that array is changed.
// https://stackoverflow.com/questions/46735483/error-do-not-use-array-index-in-keys/46735689#46735689
export const renderElementsForArray = (elements: ElementForArray[]) => {
return elements.map((element: any) => (
<div key={element.key}>{element.component}</div>
));
};
export const convertSvgElementToHtmlString = (svgElement: ReactElement) => {
// vite with svgr imports svg files as react components but we need the html string value at times
// so this converts the component to html string
const div = document.createElement('div');
const root = createRoot(div);
flushSync(() => {
root.render(svgElement);
});
return div.innerHTML;
};

View File

@ -0,0 +1,578 @@
import { ReactElement } from 'react';
export interface User {
id: number;
username: string;
}
export interface ApiAction {
path: string;
method: string;
}
export interface ApiActions {
[key: string]: ApiAction;
}
export interface Secret {
id: number;
key: string;
creator_user_id: string;
value?: string;
}
export interface Onboarding {
type?: string;
value?: string;
process_instance_id?: string;
task_id?: string;
instructions: string;
}
export interface ProcessData {
process_data_identifier: string;
process_data_value: any;
authorized?: boolean;
}
export interface RecentProcessModel {
processGroupIdentifier?: string;
processModelIdentifier: string;
processModelDisplayName: string;
}
export interface TaskPropertiesJson {
parent: string;
last_state_change: number;
}
export interface EventDefinition {
typename: string;
payload: any;
event_definitions: [EventDefinition];
message_var?: string;
}
export interface TaskDefinitionPropertiesJson {
spec: string;
event_definition: EventDefinition;
}
export interface SignalButton {
label: string;
event: EventDefinition;
}
// Task withouth task data and form info - just the basics
export interface BasicTask {
id: number;
guid: string;
process_instance_id: number;
bpmn_identifier: string;
bpmn_name?: string;
bpmn_process_direct_parent_guid: string;
bpmn_process_definition_identifier: string;
state: string;
typename: string;
properties_json: TaskPropertiesJson;
task_definition_properties_json: TaskDefinitionPropertiesJson;
process_model_display_name: string;
process_model_identifier: string;
name_for_display: string;
can_complete: boolean;
start_in_seconds: number;
end_in_seconds: number;
extensions?: any;
process_model_uses_queued_execution?: boolean;
}
// TODO: merge with ProcessInstanceTask
// Currently used like TaskModel in backend
export interface Task extends BasicTask {
data: any;
form_schema: any;
form_ui_schema: any;
signal_buttons: SignalButton[];
event_definition?: EventDefinition;
saved_form_data?: any;
runtime_info?: any;
}
// Currently used like ApiTask in backend
export interface ProcessInstanceTask {
id: string;
task_id: string;
can_complete: boolean;
created_at_in_seconds: number;
current_user_is_potential_owner: number;
data: any;
form_schema: any;
form_ui_schema: any;
lane_assignment_id: string;
last_milestone_bpmn_name: string;
name: string; // bpmn_identifier
process_identifier: string;
process_initiator_username: string;
process_instance_id: number;
process_instance_status: string;
process_instance_summary: string;
process_model_display_name: string;
process_model_identifier: string;
properties: any;
state: string;
title: string; // bpmn_name
type: string;
updated_at_in_seconds: number;
potential_owner_usernames?: string;
assigned_user_group_identifier?: string;
error_message?: string;
// these are actually from HumanTaskModel on the backend
task_title?: string;
task_name?: string;
completed?: boolean;
// gets shoved onto HumanTaskModel in result
completed_by_username?: string;
}
export interface ProcessReference {
identifier: string; // The unique id of the process
display_name: string;
relative_location: string;
type: string; // either "decision" or "process"
file_name: string;
properties: any;
}
export type ObjectWithStringKeysAndValues = { [key: string]: string };
export interface FilterOperator {
id: string;
requires_value: boolean;
}
export interface FilterOperatorMapping {
[key: string]: FilterOperator;
}
export interface FilterDisplayTypeMapping {
[key: string]: string;
}
export interface ProcessFile {
content_type: string;
last_modified: string;
name: string;
process_model_id: string;
references: ProcessReference[];
size: number;
type: string;
file_contents?: string;
file_contents_hash?: string;
bpmn_process_ids?: string[];
}
export interface ProcessInstanceMetadata {
id: number;
key: string;
value: string;
}
export interface ProcessInstance {
id: number;
actions?: ApiActions;
bpmn_version_control_identifier: string;
bpmn_version_control_type: string;
bpmn_xml_file_contents?: string;
bpmn_xml_file_contents_retrieval_error?: string;
created_at_in_seconds: number;
end_in_seconds: number | null;
last_milestone_bpmn_name?: string;
process_initiator_username: string;
process_metadata?: ProcessInstanceMetadata[];
process_model_display_name: string;
process_model_identifier: string;
process_model_with_diagram_identifier?: string;
start_in_seconds: number | null;
status: string;
summary?: string;
updated_at_in_seconds: number;
// from tasks
assigned_user_group_identifier?: string;
potential_owner_usernames?: string;
task_id?: string;
task_name?: string;
task_title?: string;
task_updated_at_in_seconds?: number;
waiting_for?: string;
// from api instance
process_model_uses_queued_execution?: boolean;
}
export interface CorrelationProperty {
retrieval_expression: string;
}
export interface CorrelationProperties {
[key: string]: CorrelationProperty;
}
export interface MessageDefinition {
correlation_properties: CorrelationProperties;
schema: any;
}
export interface Messages {
[key: string]: MessageDefinition;
}
type ReferenceCacheType = 'decision' | 'process' | 'data_store' | 'message';
export interface ReferenceCache {
identifier: string;
display_name: string;
relative_location: string;
type: ReferenceCacheType;
file_name: string;
properties: any;
}
export interface MessageInstance {
correlation_keys: any;
counterpart_id: number;
created_at_in_seconds: number;
failure_cause: string;
id: number;
message_type: string;
name: string;
process_instance_id: number;
process_model_display_name: string;
process_model_identifier: string;
status: string;
}
export interface ReportFilter {
field_name: string;
// using any here so we can use this as a string and boolean
field_value: any;
operator?: string;
}
export interface ReportColumn {
Header: string;
accessor: string;
filterable: boolean;
display_type?: string;
}
export interface ReportColumnForEditing extends ReportColumn {
filter_field_value: string;
filter_operator: string;
}
export interface ReportMetadata {
columns: ReportColumn[];
filter_by: ReportFilter[];
order_by: string[];
}
export interface ProcessInstanceReport {
id: number;
identifier: string;
name: string;
report_metadata: ReportMetadata;
}
export interface ProcessGroupLite {
id: string;
display_name: string;
description: string;
// eslint-disable-next-line no-use-before-define
process_models?: ProcessModel[];
process_groups?: ProcessGroupLite[];
}
export interface MetadataExtractionPath {
key: string;
path: string;
}
export interface ProcessModel {
id: string;
description: string;
display_name: string;
primary_file_name: string;
primary_process_id: string;
files: ProcessFile[];
parent_groups?: ProcessGroupLite[];
metadata_extraction_paths?: MetadataExtractionPath[];
fault_or_suspend_on_exception?: string;
exception_notification_addresses?: string[];
bpmn_version_control_identifier?: string;
is_executable?: boolean;
actions?: ApiActions;
}
export interface ProcessGroup {
id: string;
display_name: string;
description?: string | null;
process_models?: ProcessModel[];
process_groups?: ProcessGroup[];
parent_groups?: ProcessGroupLite[];
messages?: Messages;
}
export interface HotCrumbItemObject {
entityToExplode: ProcessModel | ProcessGroup | string;
entityType: string;
linkLastItem?: boolean;
checkPermission?: boolean;
}
export type HotCrumbItemArray = [displayValue: string, url?: string];
// tuple of display value and URL
export type HotCrumbItem = HotCrumbItemArray | HotCrumbItemObject;
export interface ErrorForDisplay {
message: string;
error_code?: string;
error_line?: string;
file_name?: string;
line_number?: number;
messageClassName?: string;
sentry_link?: string;
stacktrace?: string[];
task_id?: string;
task_name?: string;
task_trace?: string[];
task_type?: string;
output_data?: any;
expected_data?: any;
}
export interface AuthenticationParam {
id: string;
type: string;
required: boolean;
}
export interface AuthenticationItem {
id: string;
parameters: AuthenticationParam[];
}
export interface PaginationObject {
count: number;
total: number;
pages: number;
}
export interface CarbonComboBoxSelection {
selectedItem: any;
}
export interface CarbonComboBoxProcessSelection {
selectedItem: ProcessReference;
}
export interface PermissionsToCheck {
[key: string]: string[];
}
export interface PermissionVerbResults {
[key: string]: boolean;
}
export interface PermissionCheckResult {
[key: string]: PermissionVerbResults;
}
export interface PermissionCheckResponseBody {
results: PermissionCheckResult;
}
export interface ProcessInstanceEventErrorDetail {
id: number;
message: string;
stacktrace: string[];
task_line_contents?: string;
task_line_number?: number;
task_offset?: number;
task_trace?: string[];
}
export interface ProcessInstanceLogEntry {
bpmn_process_definition_identifier: string;
bpmn_process_definition_name: string;
bpmn_task_type: string;
event_type: string;
spiff_task_guid: string;
task_definition_identifier: string;
task_guid: string;
timestamp: number;
id: number;
process_instance_id: number;
task_definition_name?: string;
user_id?: number;
username?: string;
}
export interface ProcessModelCaller {
display_name: string;
process_model_id: string;
}
export interface UserGroup {}
type InterstitialPageResponseType =
| 'task_update'
| 'error'
| 'unrunnable_instance';
export interface InterstitialPageResponse {
type: InterstitialPageResponseType;
error?: any;
task?: ProcessInstanceTask;
process_instance?: ProcessInstance;
}
export interface TestCaseErrorDetails {
error_messages: string[];
stacktrace?: string[];
task_bpmn_identifier?: string;
task_bpmn_name?: string;
task_line_contents?: string;
task_line_number?: number;
task_trace?: string[];
task_bpmn_type?: string;
output_data?: any;
expected_data?: any;
}
export interface TestCaseResult {
bpmn_file: string;
passed: boolean;
test_case_identifier: string;
test_case_error_details?: TestCaseErrorDetails;
}
export interface TestCaseResults {
all_passed: boolean;
failing: TestCaseResult[];
passing: TestCaseResult[];
}
export interface DataStoreRecords {
results: any[];
pagination: PaginationObject;
}
export interface DataStore {
name: string;
type: string;
id: string;
schema: string;
description?: string | null;
location?: string | null;
}
export interface DataStoreType {
type: string;
name: string;
description: string;
}
export interface JsonSchemaExample {
schema: any;
ui: any;
data: any;
}
export interface AuthenticationOption {
identifier: string;
label: string;
uri: string;
}
export interface TaskInstructionForEndUser {
task_guid: string;
process_instance_id: number;
instruction: string;
}
export interface ProcessInstanceProgressResponse {
error_details?: ProcessInstanceEventErrorDetail;
instructions: TaskInstructionForEndUser[];
process_instance?: ProcessInstance;
process_instance_event?: ProcessInstanceLogEntry;
task?: ProcessInstanceTask;
}
export interface KeyboardShortcut {
function: Function;
label: string;
}
export interface KeyboardShortcuts {
[key: string]: KeyboardShortcut;
}
export interface SpiffTab {
path: string;
display_name: string;
tooltip?: string;
}
export interface SpiffTableHeader {
tooltip_text?: string;
text: string;
}
export interface ElementForArray {
key: string;
component: ReactElement | null;
}
export interface PublicTaskForm {
form_schema: any;
form_ui_schema: any;
instructions_for_end_user?: string;
}
export interface PublicTask {
form: PublicTaskForm;
task_guid: string;
process_instance_id: number;
confirmation_message_markdown: string;
}
export interface RJSFFormObject {
formData: any;
}
export interface MigrationEvent {
id: number;
initial_bpmn_process_hash: string;
initial_git_revision: string;
target_bpmn_process_hash: string;
target_git_revision: string;
timestamp: string;
username: string;
}
export interface MigrationCheckResult {
can_migrate: boolean;
process_instance_id: number;
current_git_revision: string;
current_bpmn_process_hash: string;
}

View File

@ -0,0 +1,275 @@
import { Duration, format, parse } from 'date-fns';
import {
DATE_TIME_FORMAT,
DATE_FORMAT,
TIME_FORMAT_HOURS_MINUTES,
} from '../config';
import { isANumber } from '../helpers';
const MINUTES_IN_HOUR = 60;
const SECONDS_IN_MINUTE = 60;
const SECONDS_IN_HOUR = MINUTES_IN_HOUR * SECONDS_IN_MINUTE;
const FOUR_HOURS_IN_SECONDS = SECONDS_IN_HOUR * 4;
const REFRESH_INTERVAL_SECONDS = 5;
const REFRESH_TIMEOUT_SECONDS = FOUR_HOURS_IN_SECONDS;
const stringLooksLikeADate = (dateString: string): boolean => {
// We had been useing date-fns parse to really check if a date is valid however it attempts to parse dates like 14-06-2 because it thinks it is 14-06-0002.
// This results in a validate date but has a negative time with getTime which is a valid number however we do not want dates like this at all.
// Checking for negative numbers seem wrong so use a regex to see if it looks anything like a date.
return (
(dateString.match(/^\d{4}[-/.]\d{2}[-/.]\d{2}$/) ||
dateString.match(/^(\d{1,2}|\w+)[-/.](\d{1,2}|\w+)[-/.]\d{4}$/) ||
dateString.match(/^\w+ +\d+, +\d{4}$/) ||
dateString.match(/^\d+ +\w+ +\d{4}$/)) !== null
);
};
const convertDateToSeconds = (date: any, onChangeFunction: any = null) => {
let dateInSeconds = date;
if (date !== null) {
let dateInMilliseconds = date;
if (typeof date.getTime === 'function') {
dateInMilliseconds = date.getTime();
}
dateInSeconds = Math.floor(dateInMilliseconds / 1000);
}
if (onChangeFunction) {
onChangeFunction(dateInSeconds);
} else {
return dateInSeconds;
}
return null;
};
const convertDateObjectToFormattedString = (dateObject: Date) => {
if (dateObject) {
return format(dateObject, DATE_FORMAT);
}
return null;
};
const dateStringToYMDFormat = (dateString: string) => {
if (dateString === undefined || dateString === null) {
return dateString;
}
if (
dateString.match(/^\d{4}-\d{2}-\d{2}$/) ||
!stringLooksLikeADate(dateString)
) {
return dateString;
}
const newDate = parse(dateString, DATE_FORMAT, new Date());
// getTime returns NaN if the date is invalid
if (Number.isNaN(newDate.getTime())) {
return dateString;
}
return format(newDate, 'yyyy-MM-dd');
};
const convertDateAndTimeStringsToDate = (
dateString: string,
timeString: string,
) => {
if (dateString && timeString) {
return new Date(`${dateStringToYMDFormat(dateString)}T${timeString}`);
}
return null;
};
const convertDateAndTimeStringsToSeconds = (
dateString: string,
timeString: string,
) => {
const dateObject = convertDateAndTimeStringsToDate(dateString, timeString);
if (dateObject) {
return convertDateToSeconds(dateObject);
}
return null;
};
const convertStringToDate = (dateString: string) => {
return convertDateAndTimeStringsToDate(dateString, '00:10:00');
};
const ymdDateStringToConfiguredFormat = (dateString: string) => {
const dateObject = convertStringToDate(dateString);
if (dateObject) {
return convertDateObjectToFormattedString(dateObject);
}
return null;
};
const convertSecondsToDateObject = (seconds: number) => {
if (seconds) {
return new Date(seconds * 1000);
}
return null;
};
const convertSecondsToFormattedDateTime = (seconds: number) => {
const dateObject = convertSecondsToDateObject(seconds);
if (dateObject) {
return format(dateObject, DATE_TIME_FORMAT);
}
return null;
};
const convertDateObjectToFormattedHoursMinutes = (dateObject: Date) => {
if (dateObject) {
return format(dateObject, TIME_FORMAT_HOURS_MINUTES);
}
return null;
};
const convertSecondsToFormattedTimeHoursMinutes = (seconds: number) => {
const dateObject = convertSecondsToDateObject(seconds);
if (dateObject) {
return convertDateObjectToFormattedHoursMinutes(dateObject);
}
return null;
};
const convertSecondsToFormattedDateString = (seconds: number) => {
const dateObject = convertSecondsToDateObject(seconds);
if (dateObject) {
return convertDateObjectToFormattedString(dateObject);
}
return null;
};
const convertDateStringToSeconds = (dateString: string) => {
const dateObject = convertStringToDate(dateString);
if (dateObject) {
return convertDateToSeconds(dateObject);
}
return null;
};
// logic from https://stackoverflow.com/a/28510323/6090676
const secondsToDuration = (secNum: number) => {
const days = Math.floor(secNum / 86400);
const hours = Math.floor(secNum / 3600) % 24;
const minutes = Math.floor(secNum / 60) % 60;
const seconds = secNum % 60;
const duration: Duration = {
days,
hours,
minutes,
seconds,
};
return duration;
};
const attemptToConvertUnknownDateStringFormatToKnownFormat = (
dateString: string,
targetDateFormat?: string,
) => {
let dateFormat = targetDateFormat;
if (!dateFormat) {
dateFormat = DATE_FORMAT;
}
let newDateString = dateString;
// if the date starts with 4 digits then assume in y-m-d format and avoid all of this
if (stringLooksLikeADate(dateString) && !dateString.match(/^\d{4}/)) {
// if the date format should contain month names or abbreviations but does not have letters
// then attempt to parse in the same format but with digit months instead of letters
if (!dateString.match(/[a-zA-Z]+/) && dateFormat.match(/MMM/)) {
const numericalDateFormat = dateFormat.replaceAll(/MMM*/g, 'MM');
const dateFormatRegex = new RegExp(
numericalDateFormat
.replace('dd', '\\d{2}')
.replace('MM', '\\d{2}')
.replace('yyyy', '\\d{4}'),
);
const normalizedDateString = dateString.replaceAll(/[.-/]+/g, '-');
if (normalizedDateString.match(dateFormatRegex)) {
const newDate = parse(
normalizedDateString,
numericalDateFormat,
new Date(),
);
newDateString = convertDateObjectToFormattedString(newDate) || '';
}
} else {
// NOTE: do not run Date.parse with y-m-d formats since it returns dates in a different timezone from other formats
const newDate = new Date(Date.parse(`${dateString}`));
newDateString = convertDateObjectToFormattedString(newDate) || '';
}
}
return newDateString;
};
const formatDurationForDisplay = (value: any) => {
if (value === undefined) {
return undefined;
}
const duration = secondsToDuration(parseInt(value, 10));
const durationTimes = [];
if (duration.seconds !== undefined && duration.seconds > 0) {
durationTimes.unshift(`${duration.seconds}s`);
}
if (duration.minutes !== undefined && duration.minutes > 0) {
durationTimes.unshift(`${duration.minutes}m`);
}
if (duration.hours !== undefined && duration.hours > 0) {
durationTimes.unshift(`${duration.hours}h`);
}
if (duration.days !== undefined && duration.days > 0) {
durationTimes.unshift(`${duration.days}d`);
}
if (durationTimes.length < 1) {
durationTimes.push('0s');
}
return durationTimes.join(' ');
};
const formatDateTime = (value: any) => {
if (value === undefined || value === null) {
return value;
}
let dateInSeconds = value;
if (!isANumber(value)) {
const timeArgs = value.split('T');
dateInSeconds = convertDateAndTimeStringsToSeconds(
timeArgs[0],
timeArgs[1],
);
}
if (dateInSeconds) {
return convertSecondsToFormattedDateTime(dateInSeconds);
}
return null;
};
const DateAndTimeService = {
REFRESH_INTERVAL_SECONDS,
REFRESH_TIMEOUT_SECONDS,
attemptToConvertUnknownDateStringFormatToKnownFormat,
convertDateAndTimeStringsToDate,
convertDateAndTimeStringsToSeconds,
convertDateObjectToFormattedHoursMinutes,
convertDateObjectToFormattedString,
convertDateStringToSeconds,
convertDateToSeconds,
convertSecondsToDateObject,
convertSecondsToFormattedDateString,
convertSecondsToFormattedDateTime,
convertSecondsToFormattedTimeHoursMinutes,
convertStringToDate,
dateStringToYMDFormat,
formatDateTime,
formatDurationForDisplay,
secondsToDuration,
stringLooksLikeADate,
ymdDateStringToConfiguredFormat,
};
export default DateAndTimeService;

View File

@ -0,0 +1,200 @@
/* eslint-disable max-classes-per-file */
import { BACKEND_BASE_URL } from '../config';
import { objectIsEmpty } from '../helpers';
import UserService from './UserService';
const HttpMethods = {
GET: 'GET',
POST: 'POST',
DELETE: 'DELETE',
};
export const getBasicHeaders = (): Record<string, string> => {
if (UserService.isLoggedIn()) {
return {
Authorization: `Bearer ${UserService.getAccessToken()}`,
'SpiffWorkflow-Authentication-Identifier':
UserService.getAuthenticationIdentifier() || 'default',
};
}
return {};
};
type backendCallProps = {
path: string;
successCallback: Function;
failureCallback?: Function;
onUnauthorized?: Function;
httpMethod?: string;
extraHeaders?: object;
postBody?: any;
};
export class UnauthenticatedError extends Error {
constructor(message: string) {
super(message);
this.name = 'UnauthenticatedError';
}
}
export class UnexpectedResponseError extends Error {
constructor(message: string) {
super(message);
this.name = 'UnexpectedResponseError';
}
}
const messageForHttpError = (statusCode: number, statusText: string) => {
let errorMessage = `HTTP Error ${statusCode}`;
if (statusText) {
errorMessage += `: ${statusText}`;
} else {
let httpTextForCode = '';
switch (statusCode) {
case 400:
httpTextForCode = 'Bad Request';
break;
case 401:
httpTextForCode = 'Unauthorized';
break;
case 403:
httpTextForCode = 'Forbidden';
break;
case 404:
httpTextForCode = 'Not Found';
break;
case 413:
httpTextForCode = 'Payload Too Large';
break;
case 500:
httpTextForCode = 'Internal Server Error';
break;
case 502:
httpTextForCode = 'Bad Gateway';
break;
case 503:
httpTextForCode = 'Service Unavailable';
break;
default:
break;
}
if (httpTextForCode) {
errorMessage += `: ${httpTextForCode}`;
}
}
return errorMessage;
};
const makeCallToBackend = ({
path,
successCallback,
failureCallback,
onUnauthorized,
httpMethod = 'GET',
extraHeaders = {},
postBody = {},
}: // eslint-disable-next-line sonarjs/cognitive-complexity
backendCallProps) => {
const headers = getBasicHeaders();
if (!objectIsEmpty(extraHeaders)) {
Object.assign(headers, extraHeaders);
}
const httpArgs = {};
if (postBody instanceof FormData) {
Object.assign(httpArgs, { body: postBody });
} else if (typeof postBody === 'object') {
if (!objectIsEmpty(postBody)) {
// NOTE: stringify strips out keys with value undefined
Object.assign(httpArgs, { body: JSON.stringify(postBody) });
Object.assign(headers, { 'Content-Type': 'application/json' });
}
} else {
Object.assign(httpArgs, { body: postBody });
}
Object.assign(httpArgs, {
headers: new Headers(headers as any),
method: httpMethod,
credentials: 'include',
});
const updatedPath = path.replace(/^\/v1\.0/, '');
fetch(`${BACKEND_BASE_URL}${updatedPath}`, httpArgs)
.then((response) => {
if (response.status === 401) {
throw new UnauthenticatedError('You must be authenticated to do this.');
}
return response.text().then((result: any) => {
return { response, text: result };
});
})
// eslint-disable-next-line sonarjs/cognitive-complexity
.then((result: any) => {
let jsonResult = null;
try {
jsonResult = JSON.parse(result.text);
} catch (error) {
const httpStatusMesage = messageForHttpError(
result.response.status,
result.response.statusText,
);
const baseMessage = `Received unexpected response from server. ${httpStatusMesage}.`;
console.error(`${baseMessage} Body: ${result.text}`);
if (error instanceof SyntaxError) {
throw new UnexpectedResponseError(baseMessage);
}
throw error;
}
if (result.response.status === 403) {
if (onUnauthorized) {
onUnauthorized(jsonResult);
} else if (UserService.isPublicUser()) {
window.location.href = '/public/sign-out';
} else {
// Hopefully we can make this service a hook and use the error message context directly
// eslint-disable-next-line no-alert
alert(jsonResult.message);
}
} else if (!result.response.ok) {
if (failureCallback) {
failureCallback(jsonResult);
} else {
let message = 'A server error occurred.';
if (jsonResult.message) {
message = jsonResult.message;
}
console.error(message);
// eslint-disable-next-line no-alert
alert(message);
}
} else {
successCallback(jsonResult);
}
})
.catch((error) => {
if (error.name !== 'UnauthenticatedError') {
if (failureCallback) {
failureCallback(error);
} else {
console.error(error.message);
}
} else if (
!UserService.isLoggedIn() &&
window.location.pathname !== '/login'
) {
window.location.href = `/login?original_url=${UserService.getCurrentLocation()}`;
}
});
};
const HttpService = {
HttpMethods,
makeCallToBackend,
messageForHttpError,
};
export default HttpService;

View File

@ -0,0 +1,33 @@
import React from 'react';
import { Typography, Tabs, Tab, Box } from '@mui/material';
import MessageInstanceList from '../components/messages/MessageInstanceList';
import { setPageTitle } from '../../helpers';
export default function MessageListPage() {
setPageTitle(['Messages']);
const [value, setValue] = React.useState(0);
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
};
return (
<>
<Typography variant="h4" component="h1" gutterBottom>
Messages
</Typography>
<Tabs value={value} onChange={handleChange} aria-label="Message tabs">
<Tab label="Message Instances" />
<Tab label="Message Models" />
</Tabs>
<Box role="tabpanel" hidden={value !== 0}>
<MessageInstanceList />
</Box>
<Box role="tabpanel" hidden={value !== 1}>
{/* Placeholder for MessageModelList */}
<Typography variant="body1">Message Models content goes here.</Typography>
</Box>
</>
);
}

View File

@ -28,6 +28,7 @@ import ErrorDisplay from '../components/ErrorDisplay';
import About from '../a-spiffui-v3/views/About';
import useAPIError from '../hooks/UseApiError';
import ComingSoon from '../components/ComingSoon';
import MessageListPage from '../a-spiffui-v3/views/MessageListPage';
const fadeIn = 'fadeIn';
const fadeOutImmediate = 'fadeOutImmediate';
@ -247,6 +248,7 @@ export default function SpiffUIV3() {
{/* path="process-instances/:process_model_id/:process_instance_id/progress" */}
{/* element={<ProcessInstanceProgressPage variant="all" />} */}
{/* /> */}
<Route path="/messages" element={<MessageListPage />} />
</Routes>
</Box>
</Box>