can add, remove, delete process groups and models w/ burnettk

This commit is contained in:
jasquat 2025-02-12 15:49:59 -05:00
parent 7dccce611e
commit 2bba376ab3
No known key found for this signature in database
6 changed files with 384 additions and 4 deletions

View File

@ -0,0 +1,178 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, TextField, Stack } from '@mui/material';
import { modifyProcessIdentifierForPathParam, slugifyString } from '../helpers';
import HttpService from '../services/HttpService';
import { ProcessGroup } from '../interfaces';
type OwnProps = {
mode: string;
processGroup: ProcessGroup;
setProcessGroup: (..._args: any[]) => any;
};
export default function ProcessGroupForm({
mode,
processGroup,
setProcessGroup,
}: OwnProps) {
const [identifierInvalid, setIdentifierInvalid] = useState<boolean>(false);
const [idHasBeenUpdatedByUser, setIdHasBeenUpdatedByUser] =
useState<boolean>(false);
const [displayNameInvalid, setDisplayNameInvalid] = useState<boolean>(false);
const navigate = useNavigate();
let newProcessGroupId = processGroup.id;
const handleProcessGroupUpdateResponse = (_result: any) => {
if (newProcessGroupId) {
navigate(
`/newui/process-groups/${modifyProcessIdentifierForPathParam(
newProcessGroupId,
)}`,
);
}
};
const hasValidIdentifier = (identifierToCheck: string) => {
return identifierToCheck.match(/^[a-z0-9][0-9a-z-]*[a-z0-9]$/);
};
const handleFormSubmission = (event: any) => {
const searchParams = new URLSearchParams(document.location.search);
const parentGroupId = searchParams.get('parentGroupId');
event.preventDefault();
let hasErrors = false;
if (mode === 'new' && !hasValidIdentifier(processGroup.id)) {
setIdentifierInvalid(true);
hasErrors = true;
}
if (processGroup.display_name === '') {
setDisplayNameInvalid(true);
hasErrors = true;
}
if (hasErrors) {
return;
}
let path = '/process-groups';
if (mode === 'edit') {
path = `/process-groups/${modifyProcessIdentifierForPathParam(
processGroup.id,
)}`;
}
let httpMethod = 'POST';
if (mode === 'edit') {
httpMethod = 'PUT';
}
const postBody = {
display_name: processGroup.display_name,
description: processGroup.description,
messages: processGroup.messages,
};
if (mode === 'new') {
if (parentGroupId) {
newProcessGroupId = `${parentGroupId}/${processGroup.id}`;
}
Object.assign(postBody, {
id: parentGroupId
? `${parentGroupId}/${processGroup.id}`
: `${processGroup.id}`,
});
}
HttpService.makeCallToBackend({
path,
successCallback: handleProcessGroupUpdateResponse,
httpMethod,
postBody,
});
};
const updateProcessGroup = (newValues: any) => {
const processGroupToCopy = {
...processGroup,
};
Object.assign(processGroupToCopy, newValues);
setProcessGroup(processGroupToCopy);
};
const onDisplayNameChanged = (newDisplayName: any) => {
setDisplayNameInvalid(false);
const updateDict = { display_name: newDisplayName };
if (!idHasBeenUpdatedByUser && mode === 'new') {
Object.assign(updateDict, { id: slugifyString(newDisplayName) });
}
updateProcessGroup(updateDict);
};
const formElements = () => {
const textInputs = [
<TextField
id="process-group-display-name"
data-qa="process-group-display-name-input"
name="display_name"
error={displayNameInvalid}
helperText={displayNameInvalid ? 'Display Name is required.' : ''}
label="Display Name*"
value={processGroup.display_name}
onChange={(event: any) => onDisplayNameChanged(event.target.value)}
/>,
];
if (mode === 'new') {
textInputs.push(
<TextField
id="process-group-identifier"
name="id"
error={identifierInvalid}
helperText={
identifierInvalid
? 'Identifier is required and must be all lowercase characters and hyphens.'
: ''
}
label="Identifier*"
value={processGroup.id}
onChange={(event: any) => {
updateProcessGroup({ id: event.target.value });
// was invalid, and now valid
if (identifierInvalid && hasValidIdentifier(event.target.value)) {
setIdentifierInvalid(false);
}
setIdHasBeenUpdatedByUser(true);
}}
/>,
);
}
textInputs.push(
<TextField
id="process-group-description"
name="description"
label="Description"
multiline
value={processGroup.description}
onChange={(event: any) =>
updateProcessGroup({ description: event.target.value })
}
/>,
);
return textInputs;
};
const formButtons = () => {
return (
<Button type="submit" variant="contained">
Submit
</Button>
);
};
return (
<form onSubmit={handleFormSubmission}>
<Stack spacing={2}>
{formElements()}
{formButtons()}
</Stack>
</form>
);
}

View File

@ -267,7 +267,7 @@ function SideNav({
sx={{ ml: isCollapsed ? 'auto' : 0 }}
>
{isMobile ? <CloseIcon /> : collapseOrExpandIcon}
</IconButton>{' '}
</IconButton>
</Box>
<List>
{navItems.map((item) => (

View File

@ -33,6 +33,8 @@ import ProcessInstanceList from './ProcessInstanceList'; // Import the new compo
import { UiSchemaUxElement } from '../extension_ui_schema_interfaces';
import { extensionUxElementMap } from '../components/ExtensionUxElementForDisplay';
import Extension from './Extension';
import ProcessGroupNew from './ProcessGroupNew';
import ProcessGroupEdit from './ProcessGroupEdit';
type OwnProps = {
setAdditionalNavElement: Function;
@ -181,6 +183,11 @@ export default function BaseRoutes({
path="/process-instances"
element={<ProcessInstanceList variant="all" />}
/>
<Route path="/process-groups/new" element={<ProcessGroupNew />} />
<Route
path="/process-groups/:process_group_id/edit"
element={<ProcessGroupEdit />}
/>
</Routes>
</Box>
);

View File

@ -0,0 +1,49 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
// @ts-ignore
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
import HttpService from '../services/HttpService';
import ProcessGroupForm from '../components/ProcessGroupForm';
import { ProcessGroup } from '../interfaces';
import { setPageTitle } from '../helpers';
export default function ProcessGroupEdit() {
const params = useParams();
const [processGroup, setProcessGroup] = useState<ProcessGroup | null>(null);
useEffect(() => {
const setProcessGroupsFromResult = (result: any) => {
setProcessGroup(result);
};
HttpService.makeCallToBackend({
path: `/process-groups/${params.process_group_id}`,
successCallback: setProcessGroupsFromResult,
});
}, [params.process_group_id]);
if (processGroup) {
setPageTitle([`Editing ${processGroup.display_name}`]);
return (
<>
<ProcessBreadcrumb
hotCrumbs={[
['Process Groups', '/process-groups'],
{
entityToExplode: processGroup,
entityType: 'process-group',
linkLastItem: true,
},
]}
/>
<h1>Edit Process Group: {(processGroup as any).id}</h1>
<ProcessGroupForm
mode="edit"
processGroup={processGroup}
setProcessGroup={setProcessGroup}
/>
</>
);
}
return null;
}

View File

@ -0,0 +1,37 @@
import { useState } from 'react';
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
import ProcessGroupForm from '../components/ProcessGroupForm';
import { ProcessGroup, HotCrumbItem } from '../interfaces';
import { setPageTitle } from '../helpers';
export default function ProcessGroupNew() {
const searchParams = new URLSearchParams(document.location.search);
const parentGroupId = searchParams.get('parentGroupId');
const [processGroup, setProcessGroup] = useState<ProcessGroup>({
id: '',
display_name: '',
description: '',
});
setPageTitle(['New Process Group']);
const hotCrumbs: HotCrumbItem[] = [['Process Groups', '/process-groups']];
if (parentGroupId) {
hotCrumbs.push({
entityToExplode: parentGroupId,
entityType: 'process-group-id',
linkLastItem: true,
});
}
return (
<>
<ProcessBreadcrumb hotCrumbs={hotCrumbs} />
<h1>Add Process Group</h1>
<ProcessGroupForm
mode="new"
processGroup={processGroup}
setProcessGroup={setProcessGroup}
/>
</>
);
}

View File

@ -4,13 +4,16 @@ import {
AccordionSummary,
Box,
Container,
IconButton,
Stack,
Typography,
} from '@mui/material';
import { useEffect, useRef, useState } from 'react';
import { Can } from '@casl/react';
import { Subject, Subscription } from 'rxjs';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import StarRateIcon from '@mui/icons-material/StarRate';
import { Delete, Edit, Add } from '@mui/icons-material';
import { useDebouncedCallback } from 'use-debounce';
import { useParams, useNavigate } from 'react-router';
import useProcessGroups from '../../hooks/useProcessGroups';
@ -25,11 +28,16 @@ import {
import SpiffBreadCrumbs, { Crumb, SPIFF_ID } from './SpiffBreadCrumbs';
import { modifyProcessIdentifierForPathParam } from '../../../helpers';
import {
PermissionsToCheck,
ProcessGroup,
ProcessGroupLite,
ProcessModelAction,
} from '../../interfaces';
import { unModifyProcessIdentifierForPathParam } from '../../helpers';
import { useUriListForPermissions } from '../../hooks/UriListForPermissions';
import { usePermissionFetcher } from '../../hooks/PermissionService';
import ButtonWithConfirmation from '../../components/ButtonWithConfirmation';
import HttpService from '../../services/HttpService';
type OwnProps = {
setNavElementCallback?: Function;
@ -76,7 +84,10 @@ export default function ProcessModelTreePage({
// On load, there are always groups and never models, expand accordingly.
const [groupsExpanded, setGroupsExpanded] = useState(true);
const [modelsExpanded, setModelsExpanded] = useState(false);
const [lastSelected, setLastSelected] = useState<Record<string, any>>({});
const [currentProcessGroup, setCurrentProcessGroup] = useState<Record<
string,
any
> | null>(null);
const [crumbs, setCrumbs] = useState<Crumb[]>([]);
// const [treeCollapsed, setTreeCollapsed] = useState(false);
const [treeCollapsed] = useState(false);
@ -92,6 +103,15 @@ export default function ProcessModelTreePage({
gridGap: 20,
};
const { targetUris } = useUriListForPermissions();
const permissionRequestData: PermissionsToCheck = {
[targetUris.dataStoreListPath]: ['POST'],
[targetUris.processGroupListPath]: ['POST'],
[targetUris.processGroupShowPath]: ['PUT', 'DELETE'],
[targetUris.processModelCreatePath]: ['POST'],
};
const { ability } = usePermissionFetcher(permissionRequestData);
const processCrumbs = (
item: Record<string, any>,
flattened: Record<string, any>,
@ -116,7 +136,7 @@ export default function ProcessModelTreePage({
const handleClickStream = (item: Record<string, any>) => {
setCrumbs(processCrumbs(item, flatItems));
setLastSelected(item);
setCurrentProcessGroup(item);
let itemToUse: any = { ...item };
// Duck type to find out if this is a model ore a group.
// If model, we want its parent group, which can be found in the id.
@ -233,6 +253,7 @@ export default function ProcessModelTreePage({
setModelsExpanded(false);
setGroupsExpanded(true);
setCrumbs([]);
setCurrentProcessGroup(null);
treeRef.current?.clearExpanded();
navigate('/newui/process-groups');
return;
@ -242,6 +263,7 @@ export default function ProcessModelTreePage({
if (found) {
clickStream.next(found);
const processEntityId = modifyProcessIdentifierForPathParam(crumb.id);
setCurrentProcessGroup(null);
navigate(`/newui/process-groups/${processEntityId}`);
}
};
@ -324,6 +346,7 @@ export default function ProcessModelTreePage({
setGroups(foundProcessGroup.process_groups || null);
setModels(foundProcessGroup.process_models || []);
setCrumbs(processCrumbs(foundProcessGroup, flattened));
setCurrentProcessGroup(foundProcessGroup);
} else {
setGroups(processGroupsLite);
setCrumbs([]);
@ -351,6 +374,29 @@ export default function ProcessModelTreePage({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clickStream]);
const deleteProcessGroup = () => {
if (currentProcessGroup) {
const modifiedGroupId = modifyProcessIdentifierForPathParam(
currentProcessGroup.id,
);
let parendGroupId = modifiedGroupId.replace(/:[^:]+$/, '');
// this means it was a root group
if (parendGroupId === modifiedGroupId) {
parendGroupId = SPIFF_ID;
}
HttpService.makeCallToBackend({
path: `/process-groups/${modifiedGroupId}`,
successCallback: () => {
handleCrumbClick({
id: parendGroupId.replaceAll(':', '/'),
displayName: 'NOT_USED',
});
},
httpMethod: 'DELETE',
});
}
};
return (
<>
<Typography variant="h1" sx={{ mb: 2 }}>
@ -429,6 +475,57 @@ export default function ProcessModelTreePage({
)}
<SpiffBreadCrumbs crumbs={crumbs} callback={handleCrumbClick} />
</Stack>
{currentProcessGroup ? (
<Stack
direction="row"
sx={{
width: '100%',
paddingBottom: 2,
}}
>
<Typography variant="h3" className="with-icons">
Process Group: {currentProcessGroup.display_name}
</Typography>
<Can
I="PUT"
a={targetUris.processGroupShowPath}
ability={ability}
>
<IconButton
data-qa="edit-process-group-button"
href={`/newui/process-groups/${modifyProcessIdentifierForPathParam(currentProcessGroup.id)}/edit`}
>
<Edit />
</IconButton>
</Can>
<Can
I="DELETE"
a={targetUris.processGroupShowPath}
ability={ability}
>
<ButtonWithConfirmation
data-qa="delete-process-group-button"
renderIcon={<Delete />}
iconDescription="Delete Process Group"
hasIconOnly
description={`Delete process group: ${currentProcessGroup.display_name}`}
onConfirmation={deleteProcessGroup}
confirmButtonLabel="Delete"
/>
</Can>
</Stack>
) : null}
{currentProcessGroup ? (
<Stack
direction="row"
sx={{
width: '100%',
paddingBottom: 2,
}}
>
{currentProcessGroup.description}
</Stack>
) : null}
<Accordion
expanded={modelsExpanded}
onChange={() => setModelsExpanded((prev) => !prev)}
@ -438,6 +535,12 @@ export default function ProcessModelTreePage({
aria-controls="Process Models Accordion"
>
({models.length}) Process Models
<IconButton
data-qa="add-process-model-button"
href={`/newui/process-models/${modifyProcessIdentifierForPathParam(currentProcessGroup ? currentProcessGroup.id : '')}/new`}
>
<Add />
</IconButton>
</AccordionSummary>
<AccordionDetails>
<Box sx={gridProps}>
@ -446,7 +549,7 @@ export default function ProcessModelTreePage({
key={model.id}
model={model}
stream={clickStream}
lastSelected={lastSelected}
lastSelected={currentProcessGroup || {}}
processModelAction={processModelAction}
onStartProcess={() => {
if (setNavElementCallback) {
@ -473,6 +576,12 @@ export default function ProcessModelTreePage({
aria-controls="Process Groups Accordion"
>
({groups?.length}) Process Groups
<IconButton
data-qa="add-process-group-button"
href={`/newui/process-groups/new${currentProcessGroup ? `?parentGroupId=${currentProcessGroup.id}` : ''}`}
>
<Add />
</IconButton>
</AccordionSummary>
<AccordionDetails>
<Box sx={gridProps}>