diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py index 062d0ef7..5245313a 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py @@ -7,6 +7,7 @@ from typing import Optional from flask import current_app from flask_bpmn.api.api_error import ApiError from flask_bpmn.models.db import db +from SpiffWorkflow.util.deep_merge import DeepMerge # type: ignore from SpiffWorkflow.task import Task as SpiffTask # type: ignore from spiffworkflow_backend.models.process_instance import ProcessInstanceApi @@ -105,6 +106,20 @@ class ProcessInstanceService: title=title_value, ) + next_task_trying_again = next_task + if ( + not next_task + ): # The Next Task can be requested to be a certain task, useful for parallel tasks. + # This may or may not work, sometimes there is no next task to complete. + next_task_trying_again = processor.next_task() + + if next_task_trying_again is not None: + process_instance_api.next_task = ( + ProcessInstanceService.spiff_task_to_api_task( + next_task_trying_again, add_docs_and_forms=True + ) + ) + return process_instance_api def get_process_instance(self, process_instance_id: int) -> Any: diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py index 394db261..0de61b12 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py @@ -1123,7 +1123,7 @@ class TestProcessApi(BaseTest): ) assert response.json is not None - # assert response.json['next_task'] is not None + assert response.json['next_task'] is not None active_tasks = ( db.session.query(ActiveTaskModel) diff --git a/spiffworkflow-frontend/src/components/ButtonWithConfirmation.tsx b/spiffworkflow-frontend/src/components/ButtonWithConfirmation.tsx index 25aaadc1..88386919 100644 --- a/spiffworkflow-frontend/src/components/ButtonWithConfirmation.tsx +++ b/spiffworkflow-frontend/src/components/ButtonWithConfirmation.tsx @@ -4,10 +4,14 @@ import { Button, Modal } from '@carbon/react'; type OwnProps = { description?: string; - buttonLabel: string; + buttonLabel?: string; onConfirmation: (..._args: any[]) => any; title?: string; confirmButtonLabel?: string; + kind?: string; + renderIcon?: boolean; + iconDescription?: string | null; + hasIconOnly?: boolean; }; export default function ButtonWithConfirmation({ @@ -16,6 +20,10 @@ export default function ButtonWithConfirmation({ onConfirmation, title = 'Are you sure?', confirmButtonLabel = 'OK', + kind = 'danger', + renderIcon = false, + iconDescription = null, + hasIconOnly = false, }: OwnProps) { const [showConfirmationPrompt, setShowConfirmationPrompt] = useState(false); @@ -49,7 +57,13 @@ export default function ButtonWithConfirmation({ return ( <> - {confirmationDialog()} diff --git a/spiffworkflow-frontend/src/components/ProcessBreadcrumb.tsx b/spiffworkflow-frontend/src/components/ProcessBreadcrumb.tsx index 241aaeb7..d5d2eda2 100644 --- a/spiffworkflow-frontend/src/components/ProcessBreadcrumb.tsx +++ b/spiffworkflow-frontend/src/components/ProcessBreadcrumb.tsx @@ -1,12 +1,12 @@ -import { Link } from 'react-router-dom'; -import Breadcrumb from 'react-bootstrap/Breadcrumb'; -import { BreadcrumbItem } from '../interfaces'; +// @ts-ignore +import { Breadcrumb, BreadcrumbItem } from '@carbon/react'; +import { HotCrumbItem } from '../interfaces'; type OwnProps = { processModelId?: string; processGroupId?: string; linkProcessModel?: boolean; - hotCrumbs?: BreadcrumbItem[]; + hotCrumbs?: HotCrumbItem[]; }; export default function ProcessBreadcrumb({ @@ -22,18 +22,20 @@ export default function ProcessBreadcrumb({ if (lastItem === undefined) { return null; } - const lastCrumb = {lastItem[0]}; - const leadingCrumbLinks = hotCrumbs.map((crumb) => { + const lastCrumb = ( + {lastItem[0]} + ); + const leadingCrumbLinks = hotCrumbs.map((crumb: any) => { const valueLabel = crumb[0]; const url = crumb[1]; return ( - + {valueLabel} - + ); }); return ( - + {leadingCrumbLinks} {lastCrumb} @@ -42,42 +44,38 @@ export default function ProcessBreadcrumb({ if (processModelId) { if (linkProcessModel) { processModelBreadcrumb = ( - - Process Model: {processModelId} - + {`Process Model: ${processModelId}`} + ); } else { processModelBreadcrumb = ( - - Process Model: {processModelId} - + + {`Process Model: ${processModelId}`} + ); } processGroupBreadcrumb = ( - - Process Group: {processGroupId} - + {`Process Group: ${processGroupId}`} + ); } else if (processGroupId) { processGroupBreadcrumb = ( - Process Group: {processGroupId} + + {`Process Group: ${processGroupId}`} + ); } return ( - - - Process Groups - + + Process Groups {processGroupBreadcrumb} {processModelBreadcrumb} diff --git a/spiffworkflow-frontend/src/index.css b/spiffworkflow-frontend/src/index.css index 988fae27..c9ddd249 100644 --- a/spiffworkflow-frontend/src/index.css +++ b/spiffworkflow-frontend/src/index.css @@ -40,6 +40,10 @@ span.bjs-crumb { opacity: .4; } +.accordion-item-label { + vertical-align: middle; +} + .diagram-editor-canvas { border:1px solid #000000; height:70vh; diff --git a/spiffworkflow-frontend/src/index.scss b/spiffworkflow-frontend/src/index.scss index ee9ca2c2..2adade11 100644 --- a/spiffworkflow-frontend/src/index.scss +++ b/spiffworkflow-frontend/src/index.scss @@ -1,10 +1,22 @@ // @use '@carbon/react/scss/themes'; // @use '@carbon/react/scss/theme' with ($theme: themes.$g100); + +// @use '@carbon/react/scss/theme' with +// ( +// $theme: ( +// cds-link-primary: #525252 +// ) +// ); + @use '@carbon/react'; @use '@carbon/styles'; // @include grid.flex-grid(); +@use '@carbon/colors'; // @use '@carbon/react/scss/colors'; +@use '@carbon/react/scss/themes'; + +// var(--cds-link-text-color, var(--cds-link-primary, #0f62fe)) // site is mainly using white theme. // header is mainly using g100 @@ -13,3 +25,60 @@ // background-color: colors.$gray-100; color: white; } + +.cds--breadcrumb-item a.cds--link:hover { + color: #525252; +} +.cds--breadcrumb-item a.cds--link:visited { + color: #525252; +} +.cds--breadcrumb-item a.cds--link:visited:hover { + color: #525252; +} +.cds--breadcrumb-item a.cds--link { + color: #525252; +} + +.cds--btn--ghost { + color: black; +} +.cds--btn--ghost:visited { + color: black; +} +.cds--btn--ghost:hover { + color: black; +} +.cds--btn--ghost:visited:hover { + color: black; +} + +$slightly-lighter-gray: #474747; +$spiff-header-background-color: #161616; + +.cds--header__global .cds--btn--primary { + background-color: $spiff-header-background-color; +} +.cds--btn--primary { + background-color: #393939; +} +.cds--btn--primary:hover { + background-color: $slightly-lighter-gray; +} +// .cds--btn--ghost:visited { +// color: black; +// } +// .cds--btn--ghost:hover { +// color: black; +// } +// .cds--btn--ghost:visited:hover { +// color: black; +// } + + +// :root { +// --cds-link-primary: #525252; +// } +// .card { +// background: var(--orange); +// --orange: hsl(255, 72%, var(--lightness)); +// } diff --git a/spiffworkflow-frontend/src/interfaces.ts b/spiffworkflow-frontend/src/interfaces.ts index 9f49e715..342785ec 100644 --- a/spiffworkflow-frontend/src/interfaces.ts +++ b/spiffworkflow-frontend/src/interfaces.ts @@ -32,10 +32,12 @@ export interface ProcessFile { references: ProcessFileReference[]; size: number; type: string; + file_contents?: string; } export interface ProcessModel { id: string; + description: string; process_group_id: string; display_name: string; primary_file_name: string; @@ -43,7 +45,7 @@ export interface ProcessModel { } // tuple of display value and URL -export type BreadcrumbItem = [displayValue: string, url?: string]; +export type HotCrumbItem = [displayValue: string, url?: string]; export interface ErrorForDisplay { message: string; diff --git a/spiffworkflow-frontend/src/routes/ProcessModelEdit.tsx b/spiffworkflow-frontend/src/routes/ProcessModelEdit.tsx index 58375a88..3f3dfb78 100644 --- a/spiffworkflow-frontend/src/routes/ProcessModelEdit.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessModelEdit.tsx @@ -49,6 +49,7 @@ export default function ProcessModelEdit() { }); }; + // share with or delete from ProcessModelEditDiagram const deleteProcessModel = () => { setErrorMessage(null); const processModelToUse = processModel as any; diff --git a/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx b/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx index 30f81e6d..15db9518 100644 --- a/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx @@ -1,12 +1,44 @@ import { useContext, useEffect, useState } from 'react'; -import { Link, useParams } from 'react-router-dom'; -// @ts-ignore -import { Button, Stack } from '@carbon/react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { + Add, + Upload, + Download, + TrashCan, + Favorite, + Edit, + // @ts-ignore +} from '@carbon/icons-react'; +import { + Accordion, + AccordionItem, + Dropdown, + Button, + Stack, + ButtonSet, + Modal, + FileUploader, + Table, + TableHead, + TableHeader, + TableRow, + TableCell, + TableBody, + // @ts-ignore +} from '@carbon/react'; import ProcessBreadcrumb from '../components/ProcessBreadcrumb'; -import FileInput from '../components/FileInput'; import HttpService from '../services/HttpService'; import ErrorContext from '../contexts/ErrorContext'; -import { RecentProcessModel } from '../interfaces'; +import { ProcessFile, ProcessModel, RecentProcessModel } from '../interfaces'; +import ButtonWithConfirmation from '../components/ButtonWithConfirmation'; + +interface ProcessModelFileCarbonDropdownItem { + label: string; + action: string; + processModelFile: ProcessFile; + needsConfirmation: boolean; + icon: any; +} const storeRecentProcessModelInLocalStorage = ( processModelForStorage: any, @@ -66,12 +98,16 @@ export default function ProcessModelShow() { const params = useParams(); const setErrorMessage = (useContext as any)(ErrorContext)[1]; - const [processModel, setProcessModel] = useState({}); + const [processModel, setProcessModel] = useState(null); const [processInstanceResult, setProcessInstanceResult] = useState(null); - const [reloadModel, setReloadModel] = useState(false); + const [reloadModel, setReloadModel] = useState(false); + const [filesToUpload, setFilesToUpload] = useState(null); + const [showFileUploadModal, setShowFileUploadModal] = + useState(false); + const navigate = useNavigate(); useEffect(() => { - const processResult = (result: object) => { + const processResult = (result: ProcessModel) => { setProcessModel(result); setReloadModel(false); storeRecentProcessModelInLocalStorage(result, params); @@ -100,96 +136,243 @@ export default function ProcessModelShow() { }); }; - let processInstanceResultTag = null; - if (processInstanceResult) { - let takeMeToMyTaskBlurb = null; - // FIXME: ensure that the task is actually for the current user as well - const processInstanceId = (processInstanceResult as any).id; - const nextTask = (processInstanceResult as any).next_task; - if (nextTask && nextTask.state === 'READY') { - takeMeToMyTaskBlurb = ( - - You have a task to complete. Go to{' '} - my task - . - + const processInstanceResultTag = () => { + if (processModel && processInstanceResult) { + let takeMeToMyTaskBlurb = null; + // FIXME: ensure that the task is actually for the current user as well + const processInstanceId = (processInstanceResult as any).id; + const nextTask = (processInstanceResult as any).next_task; + if (nextTask && nextTask.state === 'READY') { + takeMeToMyTaskBlurb = ( + + You have a task to complete. Go to{' '} + + my task + + . + + ); + } + return ( +
+

+ Process Instance {processInstanceId} kicked off ( + + view + + ). {takeMeToMyTaskBlurb} +

+
); } - processInstanceResultTag = ( -
-

- Process Instance {processInstanceId} kicked off ( - - view - - ). {takeMeToMyTaskBlurb} -

-
- ); - } + return null; + }; const onUploadedCallback = () => { setReloadModel(true); }; + const reloadModelOhYeah = (_httpResult: any) => { + setReloadModel(!reloadModel); + }; - const processModelFileList = () => { - let constructedTag; - const tags = (processModel as any).files.map((processModelFile: any) => { + // Remove this code from + const onDeleteFile = (fileName: string) => { + const url = `/process-models/${params.process_group_id}/${params.process_model_id}/files/${fileName}`; + const httpMethod = 'DELETE'; + HttpService.makeCallToBackend({ + path: url, + successCallback: reloadModelOhYeah, + httpMethod, + }); + }; + + const onProcessModelFileAction = (selection: any) => { + const { selectedItem } = selection; + if (selectedItem.action === 'delete') { + onDeleteFile(selectedItem.processModelFile.name); + } + }; + + const onSetPrimaryFile = (fileName: string) => { + const url = `/process-models/${params.process_group_id}/${params.process_model_id}`; + const httpMethod = 'PUT'; + + const processModelToPass = { + primary_file_name: fileName, + }; + HttpService.makeCallToBackend({ + path: url, + successCallback: onUploadedCallback, + httpMethod, + postBody: processModelToPass, + }); + }; + const handleProcessModelFileResult = (processModelFile: ProcessFile) => { + if ( + !('file_contents' in processModelFile) || + processModelFile.file_contents === undefined + ) { + setErrorMessage({ + message: `Could not file file contents for file: ${processModelFile.name}`, + }); + return; + } + let contentType = 'application/xml'; + if (processModelFile.type === 'json') { + contentType = 'application/json'; + } + const element = document.createElement('a'); + const file = new Blob([processModelFile.file_contents], { + type: contentType, + }); + const downloadFileName = processModelFile.name; + element.href = URL.createObjectURL(file); + element.download = downloadFileName; + document.body.appendChild(element); + element.click(); + }; + + const downloadFile = (fileName: string) => { + setErrorMessage(null); + const processModelPath = `process-models/${params.process_group_id}/${params.process_model_id}`; + HttpService.makeCallToBackend({ + path: `/${processModelPath}/files/${fileName}`, + successCallback: handleProcessModelFileResult, + }); + }; + + const navigateToFileEdit = (processModelFile: ProcessFile) => { + if (processModel) { if (processModelFile.name.match(/\.(dmn|bpmn)$/)) { - let primarySuffix = ''; - if (processModelFile.name === (processModel as any).primary_file_name) { - primarySuffix = '- Primary File'; - } - constructedTag = ( -
  • - - {processModelFile.name} - - {primarySuffix} -
  • + navigate( + `/admin/process-models/${processModel.process_group_id}/${processModel.id}/files/${processModelFile.name}` ); } else if (processModelFile.name.match(/\.(json|md)$/)) { - constructedTag = ( -
  • - - {processModelFile.name} - -
  • - ); - } else { - constructedTag = ( -
  • {processModelFile.name}
  • + navigate( + `/admin/process-models/${processModel.process_group_id}/${processModel.id}/form/${processModelFile.name}` ); } + } + }; + + const renderButtonElements = ( + processModelFile: ProcessFile, + isPrimaryBpmnFile: boolean + ) => { + const elements = []; + elements.push( + - - - - - - + + setFilesToUpload(event.target.files)} + /> + ); }; - if (Object.keys(processModel).length > 1) { + const processModelButtons = () => { + if (!processModel) { + return null; + } + return ( + + + + + + + } + > + + + + + + + +
    + {processModelFileList()} +
    +
    + ); + }; + + if (processModel) { return ( <> + {fileUploadModal()} - {processInstanceResultTag} - +

    {processModel.display_name}

    +

    {processModel.description}

    + + + +
    +
    + {processInstanceResultTag()} {processModelButtons()}

    Process Instances

    {processInstancesUl()} -
    -
    -

    Files

    - {processModelFileList()} ); }