diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/components/ProcessGroupForm.tsx b/spiffworkflow-frontend/src/a-spiffui-v3/components/ProcessGroupForm.tsx new file mode 100644 index 000000000..c6d476fe1 --- /dev/null +++ b/spiffworkflow-frontend/src/a-spiffui-v3/components/ProcessGroupForm.tsx @@ -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(false); + const [idHasBeenUpdatedByUser, setIdHasBeenUpdatedByUser] = + useState(false); + const [displayNameInvalid, setDisplayNameInvalid] = useState(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 = [ + onDisplayNameChanged(event.target.value)} + />, + ]; + + if (mode === 'new') { + textInputs.push( + { + updateProcessGroup({ id: event.target.value }); + // was invalid, and now valid + if (identifierInvalid && hasValidIdentifier(event.target.value)) { + setIdentifierInvalid(false); + } + setIdHasBeenUpdatedByUser(true); + }} + />, + ); + } + + textInputs.push( + + updateProcessGroup({ description: event.target.value }) + } + />, + ); + return textInputs; + }; + + const formButtons = () => { + return ( + + ); + }; + + return ( +
+ + {formElements()} + {formButtons()} + +
+ ); +} diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/components/SideNav.tsx b/spiffworkflow-frontend/src/a-spiffui-v3/components/SideNav.tsx index ecda2d571..23178f012 100644 --- a/spiffworkflow-frontend/src/a-spiffui-v3/components/SideNav.tsx +++ b/spiffworkflow-frontend/src/a-spiffui-v3/components/SideNav.tsx @@ -267,7 +267,7 @@ function SideNav({ sx={{ ml: isCollapsed ? 'auto' : 0 }} > {isMobile ? : collapseOrExpandIcon} - {' '} + {navItems.map((item) => ( diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/views/BaseRoutes.tsx b/spiffworkflow-frontend/src/a-spiffui-v3/views/BaseRoutes.tsx index 123944226..913fcfc71 100644 --- a/spiffworkflow-frontend/src/a-spiffui-v3/views/BaseRoutes.tsx +++ b/spiffworkflow-frontend/src/a-spiffui-v3/views/BaseRoutes.tsx @@ -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={} /> + } /> + } + /> ); diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/views/ProcessGroupEdit.tsx b/spiffworkflow-frontend/src/a-spiffui-v3/views/ProcessGroupEdit.tsx new file mode 100644 index 000000000..840aabf00 --- /dev/null +++ b/spiffworkflow-frontend/src/a-spiffui-v3/views/ProcessGroupEdit.tsx @@ -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(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 ( + <> + +

Edit Process Group: {(processGroup as any).id}

+ + + ); + } + return null; +} diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/views/ProcessGroupNew.tsx b/spiffworkflow-frontend/src/a-spiffui-v3/views/ProcessGroupNew.tsx new file mode 100644 index 000000000..efd43fa38 --- /dev/null +++ b/spiffworkflow-frontend/src/a-spiffui-v3/views/ProcessGroupNew.tsx @@ -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({ + 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 ( + <> + +

Add Process Group

+ + + ); +} diff --git a/spiffworkflow-frontend/src/a-spiffui-v3/views/StartProcess/ProcessModelTreePage.tsx b/spiffworkflow-frontend/src/a-spiffui-v3/views/StartProcess/ProcessModelTreePage.tsx index 416548e0e..a609e822e 100644 --- a/spiffworkflow-frontend/src/a-spiffui-v3/views/StartProcess/ProcessModelTreePage.tsx +++ b/spiffworkflow-frontend/src/a-spiffui-v3/views/StartProcess/ProcessModelTreePage.tsx @@ -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>({}); + const [currentProcessGroup, setCurrentProcessGroup] = useState | null>(null); const [crumbs, setCrumbs] = useState([]); // 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, flattened: Record, @@ -116,7 +136,7 @@ export default function ProcessModelTreePage({ const handleClickStream = (item: Record) => { 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 ( <> @@ -429,6 +475,57 @@ export default function ProcessModelTreePage({ )} + {currentProcessGroup ? ( + + + Process Group: {currentProcessGroup.display_name} + + + + + + + + } + iconDescription="Delete Process Group" + hasIconOnly + description={`Delete process group: ${currentProcessGroup.display_name}`} + onConfirmation={deleteProcessGroup} + confirmButtonLabel="Delete" + /> + + + ) : null} + {currentProcessGroup ? ( + + {currentProcessGroup.description} + + ) : null} setModelsExpanded((prev) => !prev)} @@ -438,6 +535,12 @@ export default function ProcessModelTreePage({ aria-controls="Process Models Accordion" > ({models.length}) Process Models + + + @@ -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 + + +