CE-1482: POC of workflow library

This commit is contained in:
Tim Chamberlain
2024-08-09 11:44:54 -05:00
parent d31215f6c0
commit 9a6fcd8bb1
11 changed files with 5506 additions and 1914 deletions

5750
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,8 +18,8 @@
"@react-jvectormap/core": "1.0.1",
"@react-jvectormap/unitedstates": "1.0.1",
"@react-oauth/google": "0.2.8",
"@types/prop-types": "^15.7.5",
"@types/react": "18.0.0",
"@types/prop-types": "15.7.5",
"@types/react": "18.2.0",
"@types/react-dom": "18.0.0",
"@types/react-router-hash-link": "2.4.5",
"ace-builds": "1.12.3",
@ -33,7 +33,7 @@
"form-data": "4.0.0",
"formik": "2.2.9",
"html-react-parser": "1.4.8",
"html-to-text": "^9.0.5",
"html-to-text": "9.0.5",
"http-proxy-middleware": "2.0.6",
"jwt-decode": "3.1.2",
"rapidoc": "9.3.4",
@ -46,12 +46,16 @@
"react-dom": "18.0.0",
"react-ga4": "2.1.0",
"react-github-btn": "1.2.1",
"react-google-drive-picker": "^1.2.0",
"react-google-drive-picker": "1.2.0",
"react-markdown": "9.0.1",
"react-router-dom": "6.2.1",
"react-router-hash-link": "2.4.3",
"react-table": "7.7.0",
"sass": "1.63.4",
"sequential-workflow-designer": "0.22.0",
"sequential-workflow-designer-react": "0.22.0",
"sequential-workflow-editor": "0.13.2",
"sequential-workflow-editor-model": "0.13.2",
"ts-md5": "1.2.11",
"yup": "0.32.11"
},

View File

@ -47,6 +47,7 @@ import RecordGridWidget from "qqq/components/widgets/misc/RecordGridWidget";
import ScriptViewer from "qqq/components/widgets/misc/ScriptViewer";
import StepperCard from "qqq/components/widgets/misc/StepperCard";
import USMapWidget from "qqq/components/widgets/misc/USMapWidget";
import WorkflowViewer from "qqq/components/widgets/misc/WorkflowViewer";
import ParentWidget from "qqq/components/widgets/ParentWidget";
import MultiStatisticsCard from "qqq/components/widgets/statistics/MultiStatisticsCard";
import StatisticsCard from "qqq/components/widgets/statistics/StatisticsCard";
@ -581,6 +582,14 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
</Widget>
)
}
{
widgetMetaData.type === "workflow" && (
widgetData && widgetData[i] && widgetData[i].queryParams &&
<Widget widgetMetaData={widgetMetaData}>
<WorkflowViewer workflowId={widgetData[i].queryParams.id} />
</Widget>
)
}
{
widgetMetaData.type === "dataBagViewer" && (
widgetData && widgetData[i] && widgetData[i].queryParams &&

View File

@ -0,0 +1,383 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {Chip} from "@mui/material";
import Alert from "@mui/material/Alert";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider";
import Grid from "@mui/material/Grid";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemAvatar from "@mui/material/ListItemAvatar";
import ListItemText from "@mui/material/ListItemText";
import Modal from "@mui/material/Modal";
import Snackbar from "@mui/material/Snackbar";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import Typography from "@mui/material/Typography";
import TabPanel from "qqq/components/misc/TabPanel";
import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip";
import WorkflowEditor, {WorkflowEditorProps} from "qqq/components/workflows/WorkflowEditor";
import WorkflowPreview from "qqq/components/workflows/WorkflowPreview";
import {LoadingState} from "qqq/models/LoadingState";
import DeveloperModeUtils from "qqq/utils/DeveloperModeUtils";
import Client from "qqq/utils/qqq/Client";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import "ace-builds/src-noconflict/mode-java";
import "ace-builds/src-noconflict/mode-javascript";
import "ace-builds/src-noconflict/mode-json";
import "ace-builds/src-noconflict/theme-github";
import React, {useReducer, useState} from "react";
import AceEditor from "react-ace";
import "ace-builds/src-noconflict/ext-language_tools";
const qController = Client.getInstance();
// Declaring props types for ViewForm
interface Props
{
workflowId?: number;
}
WorkflowViewer.defaultProps =
{};
export default function WorkflowViewer({workflowId}: Props): JSX.Element
{
const [workflowRecord, setWorkflowRecord] = useState(null as QRecord);
const [asyncLoadInited, setAsyncLoadInited] = useState(false);
const [versionRecordList, setVersionRecordList] = useState(null as QRecord[]);
const [selectedRevisionRecord, setSelectedRevisionRecord] = useState(null as QRecord);
const [currentVersionId, setCurrentVersionId] = useState(null as number);
const [notFoundMessage, setNotFoundMessage] = useState(null);
const [selectedTab, setSelectedTab] = useState(0);
const [editorProps, setEditorProps] = useState(null as WorkflowEditorProps);
const [successText, setSuccessText] = useState(null as string);
const [failText, setFailText] = useState(null as string);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [loadingSelectedVersion, _] = useState(new LoadingState(forceUpdate, "loading"));
if (!asyncLoadInited)
{
setAsyncLoadInited(true);
(async () =>
{
try
{
const workflowRecord = await qController.get("workflow", workflowId);
setWorkflowRecord(workflowRecord);
const criteria = [new QFilterCriteria("workflowId", QCriteriaOperator.EQUALS, [workflowId])];
const orderBys = [new QFilterOrderBy("sequenceNo", false)];
const filter = new QQueryFilter(criteria, orderBys, null, "AND", 0, 25);
const versions = await qController.query("workflowRevision", filter);
console.log("Fetched versions:");
console.log(versions);
setVersionRecordList(versions);
if (versions && versions.length > 0)
{
setCurrentVersionId(versions[0].values.get("id"));
const latestVersion = await qController.get("workflowRevision", versions[0].values.get("id"));
console.log("Fetched latestVersion:");
console.log(latestVersion);
setSelectedRevisionRecord(latestVersion);
loadingSelectedVersion.setNotLoading();
forceUpdate();
}
}
catch (e)
{
if (e instanceof QException)
{
if ((e as QException).status === 404)
{
setNotFoundMessage("Workflow data could not be found.");
return;
}
}
setNotFoundMessage("Error loading workflow data: " + e);
}
})();
}
const editContents = (contents: string) =>
{
const editorProps = {} as WorkflowEditorProps;
editorProps.title = (contents ? "Editing Workflow: " : "Initializing Workflow: ") + workflowRecord?.values?.get("name");
editorProps.contents = contents;
editorProps.workflowId = workflowId;
setEditorProps(editorProps);
};
const closeEditingWorkflow = (event: object, reason: string, alert: string = null) =>
{
if (reason === "backdropClick" || reason === "escapeKeyDown")
{
return;
}
if (reason === "saved")
{
setAsyncLoadInited(false);
forceUpdate();
if (alert)
{
setSuccessText(alert);
}
}
else if (reason === "failed")
{
setAsyncLoadInited(false);
forceUpdate();
if (alert)
{
setFailText(alert);
}
}
setEditorProps(null);
};
const changeTab = (newValue: number) =>
{
setSelectedTab(newValue);
forceUpdate();
};
const selectVersion = (version: QRecord) =>
{
(async () =>
{
// fetch the full version
setSelectedRevisionRecord(version);
loadingSelectedVersion.setLoading();
const selectedVersion = await qController.get("workflowRevision", version.values.get("id"));
console.log("Fetched selectedVersion:");
console.log(selectedVersion);
setSelectedRevisionRecord(selectedVersion);
loadingSelectedVersion.setNotLoading();
forceUpdate();
})();
};
function getVersionsList(versionRecordList: QRecord[], selectedVersionRecord: QRecord)
{
return <List sx={{pl: 3, height: "400px", overflow: "auto"}}>
{
(versionRecordList == null || versionRecordList.length == 0) ?
<Typography variant="body2">
There are not any versions of this workflow.
</Typography>
: <></>
}
{
versionRecordList?.map((version: any) => (
<React.Fragment key={version.values.get("id")}>
<ListItem sx={{p: 1}} alignItems="flex-start" selected={selectedVersionRecord?.values?.get("id") == version.values.get("id")} onClick={(event) => selectVersion(version)}>
<ListItemAvatar>
<Avatar sx={{bgcolor: DeveloperModeUtils.revToColor("", workflowId, version.values.get("sequenceNo"))}}>{`${version.values.get("sequenceNo")}`}</Avatar>
</ListItemAvatar>
<ListItemText
primaryTypographyProps={{fontSize: "1rem"}}
secondaryTypographyProps={{fontSize: ".85rem"}}
primary={
<div style={{overflow: "hidden", textOverflow: "ellipsis", maxHeight: "5rem"}} title={version.values.get("commitMessage")}>
{currentVersionId == version?.values?.get("id") && <Chip label="CURRENT" color="success" variant="outlined" size="small" sx={{mr: 1, fontSize: "0.75rem"}} />}
{version.values.get("commitMessage")}
</div>
}
secondary={
<>
{ValueUtils.formatDateTime(version.values.get("createDate"))}
<br />
{version.values.get("author")}
</>
}
/>
</ListItem>
<Divider sx={{my: 0.5}} variant="inset" component="li" />
</React.Fragment>
))
}
</List>;
}
let editButtonTooltip = "";
let editButtonText = "Create New Version";
if (currentVersionId)
{
if (currentVersionId === selectedRevisionRecord?.values?.get("id"))
{
editButtonTooltip = "If you make any changes to this workflow, a new version will be created when you hit Save.";
editButtonText = "Edit";
}
else
{
editButtonTooltip = "If you want to make this previous Version active, bring up the Edit window, make any changes " +
"to the old Version if they are needed, then click Save. A new Version will be created, and set as Current.";
editButtonText = "Edit and Activate";
}
}
return (
<Grid container>
<Grid item xs={12}>
<Box>
{
<Box>
{
successText ? (
<Snackbar open={successText !== null && successText !== ""} autoHideDuration={6000} onClose={() => setSuccessText(null)} anchorOrigin={{vertical: "top", horizontal: "center"}}>
<Alert color="success" onClose={() => setSuccessText(null)}>
{successText}
</Alert>
</Snackbar>
) : ("")
}
{
failText ? (
<Snackbar open={failText !== null && failText !== ""} autoHideDuration={6000} onClose={() => setFailText(null)} anchorOrigin={{vertical: "top", horizontal: "center"}}>
<Alert color="error" onClose={() => setFailText(null)}>
{failText}
</Alert>
</Snackbar>
) : ("")
}
<Grid container spacing={3}>
<Grid item xs={12}>
<>
<Tabs
sx={{m: 0, mb: 1, mt: 0}}
value={selectedTab}
onChange={(event, newValue) => changeTab(newValue)}
variant="standard"
>
<Tab label="Versions" id="simple-tab-0" aria-controls="simple-tabpanel-0" />
<Tab label="Preview" id="simple-tab-1" aria-controls="simple-tabpanel-1" />
<Tab label="Something Else" id="simple-tab-2" aria-controls="simple-tabpanel-2" />
</Tabs>
<TabPanel index={0} value={selectedTab}>
<Grid container>
<Grid item xs={4}>
<Box display="flex" alignItems="center" gap={2} pb={1} height="40px">
<Typography variant="h6" pl={3}>Versions</Typography>
</Box>
{getVersionsList(versionRecordList, selectedRevisionRecord)}
</Grid>
<Grid item xs={8}>
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2} pb={1} height="40px">
{
selectedRevisionRecord ?
<Typography variant="h6">
Version {selectedRevisionRecord.values.get("sequenceNo")}
{
currentVersionId === selectedRevisionRecord.values.get("id")
? (<> (Current)</>)
: <></>
}
</Typography>
: <></>
}
<CustomWidthTooltip title={editButtonTooltip}>
<Button sx={{py: 0}} onClick={() => editContents(selectedRevisionRecord?.values?.get("contents"))}>
{editButtonText}
</Button>
</CustomWidthTooltip>
</Box>
<WorkflowPreview />
</Grid>
</Grid>
</TabPanel>
<TabPanel index={1} value={selectedTab}>
<Grid container height="440px">
<Grid item xs={4}>
<Box display="flex" alignItems="center" gap={2} pb={1} height="40px">
<Typography variant="h6" pl={3}>Versions</Typography>
</Box>
{getVersionsList(versionRecordList, selectedRevisionRecord)}
</Grid>
<Grid item xs={8}>
<Box display="flex" alignItems="center" gap={2} pb={1} height="40px">
<Typography variant="h6" pl={3}>Data Preview (Version {selectedRevisionRecord?.values?.get("sequenceNo")})</Typography>
</Box>
<Box height="400px" overflow="auto" ml={1} fontSize="14px">
{
loadingSelectedVersion.isNotLoading() && selectedRevisionRecord && selectedRevisionRecord.values.get("contents") ? (
<>
<AceEditor
mode="json"
theme="github"
name={"viewData"}
readOnly
highlightActiveLine={false}
setOptions={{useWorker: false}}
editorProps={{$blockScrolling: true}}
width="100%"
height="400px"
value={selectedRevisionRecord?.values?.get("contents")}
/>
</>
) : null
}
{
loadingSelectedVersion.isLoadingSlow() && selectedRevisionRecord && <Box fontSize="14px" pl={3}>Loading...</Box>
}
</Box>
</Grid>
</Grid>
</TabPanel>
</>
</Grid>
</Grid>
{
editorProps &&
<Modal open={editorProps !== null} onClose={(event, reason) => closeEditingWorkflow(event, reason)}>
<WorkflowEditor
closeCallback={closeEditingWorkflow}
{...editorProps}
/>
</Modal>
}
</Box>
}
</Box>
</Grid>
</Grid>
);
}

View File

@ -0,0 +1,21 @@
import {ChangeEvent} from "react";
import {useRootEditor} from "sequential-workflow-designer-react";
import {WorkflowDefinition} from "./model";
export function RootEditor()
{
const {properties, setProperty, isReadonly} = useRootEditor<WorkflowDefinition>();
function onAlfaChanged(e: ChangeEvent)
{
setProperty("alfa", (e.target as HTMLInputElement).value);
}
return (
<>
<h2>Optimization Workflow Editor</h2>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.<br /><br />Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
</>
);
}

View File

@ -0,0 +1,69 @@
import {ChangeEvent} from "react";
import {useStepEditor} from "sequential-workflow-designer-react";
import {SwitchStep, TaskStep, WarehouseOptimizationStep} from "./model";
export function StepEditor()
{
const {type, name, step, properties, isReadonly, setName, setProperty, notifyPropertiesChanged, notifyChildrenChanged} =
useStepEditor<TaskStep | SwitchStep | WarehouseOptimizationStep>();
function onNameChanged(e: ChangeEvent)
{
setName((e.target as HTMLInputElement).value);
}
function onXChanged(e: ChangeEvent)
{
setProperty("warehouse", (e.target as HTMLInputElement).value);
}
function onYChanged(e: ChangeEvent)
{
properties["wmsConnection"] = (e.target as HTMLInputElement).value;
notifyPropertiesChanged();
}
function toggleExtraBranch()
{
const switchStep = step as SwitchStep;
if (switchStep.branches["extra"])
{
delete switchStep.branches["extra"];
}
else
{
switchStep.branches["extra"] = [];
}
notifyChildrenChanged();
}
return (
<>
<h2>Step Editor</h2>
<h3>{type}</h3>
<h4>Pre-Script</h4>
<select>
<option>Pre Script #1</option>
<option>Pre Script #2</option>
<option>Pre Script #3</option>
</select>
<h4>Post-Script</h4>
<select>
<option>Post Script #1</option>
<option>Post Script #2</option>
<option>Post Script #3</option>
</select>
{type === "switch" && (
<>
<h4>Extra branch</h4>
<button onClick={toggleExtraBranch} disabled={isReadonly}>
Toggle branch
</button>
</>
)}
</>
);
}

View File

@ -0,0 +1,214 @@
import {Branches, Uid} from "sequential-workflow-designer";
import {ContainerStep, OptimizationStepType, SwitchStep, TaskStep, WarehouseOptimizationStep} from "./model";
export function createTaskStep(): TaskStep
{
return {
id: Uid.next(),
componentType: "task",
type: "task",
name: "blah",
properties: {}
};
}
//////////////////////
// define all steps //
//////////////////////
export function createDetermineWarehouseRoutingStep(): WarehouseOptimizationStep
{
return createStep("Determine Warehouse", "determineWarehouseRouting");
}
export function createDetermineLineHaulLaneStep(): WarehouseOptimizationStep
{
return createStep("Determine Line Haul Lane", "determineLineHaulLane");
}
export function createValidateLineItemsStep(): WarehouseOptimizationStep
{
return createStep("Validate Line Items", "validateLineItems");
}
export function createDetermineCoolingCategoryStep(): WarehouseOptimizationStep
{
return createStep("Determine Cooling Category", "determineCoolingCategory");
}
export function createValidateOptimizationRulesStep(): WarehouseOptimizationStep
{
return createStep("Validate Optimization Rules", "validateOptimizationRules");
}
export function createValidateAddressStep(): WarehouseOptimizationStep
{
return createStep("Validate Address", "validateAddress");
}
export function createDetermineCarrierServiceStep(): WarehouseOptimizationStep
{
return createStep("Determine Carrier Service", "determineCarrierService");
}
export function createDetermineTNTStep(): WarehouseOptimizationStep
{
return createStep("Determine TNT ", "determineTNT");
}
export function createDetermineOrderServiceDatesStep(): WarehouseOptimizationStep
{
return createStep("Determine Order Service Dates ", "determineOrderServiceDates");
}
export function createOrderMatchesFilterSelectorStep(): WarehouseOptimizationStep
{
return createStep("Order Matches Filter Selector", "orderMatchesFilterSelector");
}
////////////////////////
// define all outputs //
////////////////////////
export function createDetermineWarehouseRoutingOuptut(): SwitchStep
{
return (createOutput("Output", {Edison: [], Patterson: [], Stockton: []}));
}
export function createDetermineLineHaulLaneOutput(): SwitchStep
{
return (createOutput("Output", {Chicago: [], Dallas: [], Sheboygan: []}));
}
export function createValidateLineItemsOutput(): SwitchStep
{
return (createOutput("Output", {"Is Valid": [], "Not Valid": []}));
}
export function createDetermineCoolingCategoryOutput(): SwitchStep
{
return (createOutput("Output", {"Ambient": [], "Frozen": [], "Other": []}));
}
export function createValidateOptimizationRulesOutput(): SwitchStep
{
return (createOutput("Output", {"Is Valid": [], "Not Valid": []}));
}
export function createAddressValidationOutput(): SwitchStep
{
return (createOutput("Output", {"Is Valid": [], "Not Valid": []}));
}
export function createDetermineCarrierServiceOutput(): SwitchStep
{
return (createOutput("Output", {"Fedex Ground": [], "UPS Ground": [], "OnTrac Ground": []}));
}
export function createDetermineTNTOutput(): SwitchStep
{
return (createOutput("Output", {1: [], 2: [], 3: [], "4+": []}));
}
export function createDetermineOrderServiceDatesOutput(): SwitchStep
{
return (createOutput("Output", {Monday: [], Tuesday: [], Wednesday: []}));
}
export function createOrderMatchesFilterSelectorOutput(): SwitchStep
{
return (createOutput("Output", {"Matches": [], "No Match": []}));
}
//////////////////////////////
// groups of steps + output //
//////////////////////////////
export function createDetermineWarehouseRoutingGroup(): ContainerStep
{
return (createGroup("Determine Warehouse Routing", [createDetermineWarehouseRoutingStep(), createDetermineWarehouseRoutingOuptut()]));
}
export function createDetermineLineHaulLaneGroup(): ContainerStep
{
return (createGroup("Determine Line Haul Lane", [createDetermineLineHaulLaneStep(), createDetermineLineHaulLaneOutput()]));
}
export function createValidateLineItemsGroup(): ContainerStep
{
return (createGroup("Validate Line Items", [createValidateLineItemsStep(), createValidateLineItemsOutput()]));
}
export function createDetermineCoolingCategoryGroup(): ContainerStep
{
return (createGroup("Determine Cooling Category", [createDetermineCoolingCategoryStep(), createDetermineCoolingCategoryOutput()]));
}
export function createValidateOptimizationRulesGroup(): ContainerStep
{
return (createGroup("Validate Optimization Rules", [createValidateOptimizationRulesStep(), createValidateOptimizationRulesOutput()]));
}
export function createValidateAddressGroup(): ContainerStep
{
return (createGroup("Validate Address", [createValidateAddressStep(), createAddressValidationOutput()]));
}
export function createDetermineCarrierServiceGroup(): ContainerStep
{
return (createGroup("Determine Carrier Service", [createDetermineCarrierServiceStep(), createDetermineCarrierServiceOutput()]));
}
export function createDetermineTNTGroup(): ContainerStep
{
return (createGroup("Determine TNT", [createDetermineTNTStep(), createDetermineTNTOutput()]));
}
export function createDetermineOrderServiceDatesGroup(): ContainerStep
{
return (createGroup("Determine Order Service Dates", [createDetermineOrderServiceDatesStep(), createDetermineOrderServiceDatesOutput()]));
}
export function createOrderMatchesFilterSelector(): ContainerStep
{
return (createGroup("Order Matches Filter Selector", [createOrderMatchesFilterSelectorStep(), createOrderMatchesFilterSelectorOutput()]));
}
///////////
// utils //
///////////
export function createStep(name: string, type: OptimizationStepType): WarehouseOptimizationStep
{
return {
id: Uid.next(),
componentType: "task",
type: type,
name: name,
properties: {}
};
}
export function createOutput(name: string, branches: Branches): SwitchStep
{
return {
id: Uid.next(),
componentType: "switch",
type: "switch",
name: name,
properties: {},
branches: branches
};
}
export function createGroup(name: string, sequence: (WarehouseOptimizationStep | SwitchStep)[]): ContainerStep
{
return {
id: Uid.next(),
componentType: "container",
type: "container",
name: name,
properties: {},
sequence: sequence
};
}

View File

@ -0,0 +1,187 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
import {ToggleButtonGroup, Typography} from "@mui/material";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
import Grid from "@mui/material/Grid";
import Snackbar from "@mui/material/Snackbar";
import TextField from "@mui/material/TextField";
import FormData from "form-data";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import WorkflowPreview from "qqq/components/workflows/WorkflowPreview";
import Client from "qqq/utils/qqq/Client";
import React, {useReducer, useState} from "react";
export interface WorkflowEditorProps
{
title: string;
workflowId: number;
contents: string;
closeCallback: any;
}
const qController = Client.getInstance();
function WorkflowEditor({title, workflowId, contents, closeCallback}: WorkflowEditorProps): JSX.Element
{
const [closing, setClosing] = useState(false);
const [updatedCode, setUpdatedCode] = useState(contents);
const [commitMessage, setCommitMessage] = useState("");
const [openTool, setOpenTool] = useState(null);
const [errorAlert, setErrorAlert] = useState("");
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const changeOpenTool = (event: React.MouseEvent<HTMLElement>, newValue: string | null) =>
{
setOpenTool(newValue);
/////////////////////////////////////////////////
// need this to make Ace recognize new height. //
/////////////////////////////////////////////////
setTimeout(() =>
{
window.dispatchEvent(new Event("resize"));
}, 100);
};
const saveClicked = () =>
{
try
{
JSON.parse(updatedCode);
}
catch (e)
{
setErrorAlert("Cannot save Workflow Contents. Invalid json: " + e);
return;
}
setClosing(true);
(async () =>
{
const formData = new FormData();
formData.append("workflowId", workflowId);
formData.append("contents", updatedCode);
formData.append("commitMessage", commitMessage);
//////////////////////////////////////////////////////////////////
// we don't want this job to go async, so, pass a large timeout //
//////////////////////////////////////////////////////////////////
formData.append("_qStepTimeoutMillis", 60 * 1000);
const formDataHeaders = {
"content-type": "multipart/form-data; boundary=--------------------------320289315924586491558366",
};
const processResult = await qController.processInit("storeWorkflowVersionProcess", formData, formDataHeaders);
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
closeCallback(null, "failed", jobError.userFacingError ?? jobError.error);
}
console.log("process result");
console.log(processResult);
closeCallback(null, "saved", "Saved New Workflow Version");
})();
};
const cancelClicked = () =>
{
setClosing(true);
closeCallback(null, "cancelled");
};
const updateCode = (value: string, event: any) =>
{
console.log("Updating code");
setUpdatedCode(value);
forceUpdate();
};
const updateCommitMessage = (event: React.ChangeEvent<HTMLInputElement>) =>
{
setCommitMessage(event.target.value);
};
return (
<Box sx={{position: "absolute", overflowY: "auto", height: "100%", width: "100%"}} p={6}>
<Card sx={{height: "100%", p: 3}}>
<Snackbar open={errorAlert !== null && errorAlert !== ""} onClose={(event?: React.SyntheticEvent | Event, reason?: string) =>
{
if (reason === "clickaway")
{
return;
}
setErrorAlert("");
}} anchorOrigin={{vertical: "top", horizontal: "center"}}>
<Alert color="error" onClose={() => setErrorAlert("")}>
{errorAlert}
</Alert>
</Snackbar>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h5" pb={1}>
{title}
</Typography>
<Box>
<Typography variant="body2" display="inline" pr={1}>
Tools:
</Typography>
<ToggleButtonGroup
value={openTool}
exclusive
onChange={changeOpenTool}
size="small"
sx={{pb: 1}}
>
</ToggleButtonGroup>
</Box>
</Box>
<Box sx={{height: openTool ? "45%" : "100%"}}>
<WorkflowPreview />
</Box>
<Box pt={1}>
<Grid container alignItems="flex-end">
<Box width="50%">
<TextField id="commitMessage" label="Commit Message" variant="standard" fullWidth value={commitMessage} onChange={updateCommitMessage} />
</Box>
<Grid container justifyContent="flex-end" spacing={3}>
<QCancelButton disabled={closing} onClickHandler={cancelClicked} />
<QSaveButton disabled={closing} onClickHandler={saveClicked} />
</Grid>
</Grid>
</Box>
</Card>
</Box>
);
}
export default WorkflowEditor;

View File

@ -0,0 +1,199 @@
import {useEffect, useMemo, useState} from "react";
import {ObjectCloner, Step, StepsConfiguration, ToolboxConfiguration, ValidatorConfiguration} from "sequential-workflow-designer";
import {SequentialWorkflowDesigner, useSequentialWorkflowDesignerController, wrapDefinition} from "sequential-workflow-designer-react";
import {WorkflowDefinition} from "./model";
import {RootEditor} from "./RootEditor";
import {StepEditor} from "./StepEditor";
import {createDetermineCarrierServiceGroup, createDetermineCoolingCategoryGroup, createDetermineLineHaulLaneGroup, createDetermineOrderServiceDatesGroup, createDetermineTNTGroup, createDetermineWarehouseRoutingGroup, createOrderMatchesFilterSelector, createTaskStep, createValidateAddressGroup, createValidateLineItemsGroup, createValidateOptimizationRulesGroup} from "./StepUtils";
const startDefinition: WorkflowDefinition = {
properties: {
alfa: "bravo"
},
sequence: []
};
function WorkflowPreview()
{
const controller = useSequentialWorkflowDesignerController();
const toolboxConfiguration: ToolboxConfiguration = useMemo(
() => ({
groups: [{
name: "Optimization Steps", steps: [
createDetermineCarrierServiceGroup(),
createDetermineCoolingCategoryGroup(),
createDetermineLineHaulLaneGroup(),
createDetermineOrderServiceDatesGroup(),
createDetermineTNTGroup(),
createDetermineWarehouseRoutingGroup()
]
},
{
name: "Validators", steps: [
createValidateAddressGroup(),
createValidateLineItemsGroup(),
createValidateOptimizationRulesGroup()
]
},
{
name: "Utilities", steps: [
createOrderMatchesFilterSelector()
]
}
]
}),
[]
);
const stepsConfiguration: StepsConfiguration = useMemo(
() => ({
iconUrlProvider: () => null
}),
[]
);
const validatorConfiguration: ValidatorConfiguration = useMemo(
() => ({
step: (step: Step) => Boolean(step.name),
root: (definition: WorkflowDefinition) => Boolean(definition.properties.alfa)
}),
[]
);
const [isVisible, setIsVisible] = useState(true);
const [isToolboxCollapsed, setIsToolboxCollapsed] = useState(false);
const [isEditorCollapsed, setIsEditorCollapsed] = useState(false);
const [definition, setDefinition] = useState(() => wrapDefinition(startDefinition));
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
const [isReadonly, setIsReadonly] = useState(false);
const [moveViewportToStep, setMoveViewportToStep] = useState<string | null>(null);
const definitionJson = JSON.stringify(definition.value, null, 2);
useEffect(() =>
{
console.log(`definition updated, isValid=${definition.isValid}`);
}, [definition]);
useEffect(() =>
{
if (moveViewportToStep)
{
if (controller.isReady())
{
controller.moveViewportToStep(moveViewportToStep);
}
setMoveViewportToStep(null);
}
}, [controller, moveViewportToStep]);
function toggleVisibilityClicked()
{
setIsVisible(!isVisible);
}
function toggleSelectionClicked()
{
const id = definition.value.sequence[0].id;
setSelectedStepId(selectedStepId ? null : id);
}
function toggleIsReadonlyClicked()
{
setIsReadonly(!isReadonly);
}
function toggleToolboxClicked()
{
setIsToolboxCollapsed(!isToolboxCollapsed);
}
function toggleEditorClicked()
{
setIsEditorCollapsed(!isEditorCollapsed);
}
function moveViewportToFirstStepClicked()
{
const fistStep = definition.value.sequence[0];
if (fistStep)
{
setMoveViewportToStep(fistStep.id);
}
}
async function appendStepClicked()
{
const newStep = createTaskStep();
const newDefinition = ObjectCloner.deepClone(definition.value);
newDefinition.sequence.push(newStep);
// We need to wait for the controller to finish the operation before we can select the new step
await controller.replaceDefinition(newDefinition);
setSelectedStepId(newStep.id);
setMoveViewportToStep(newStep.id);
}
function reloadDefinitionClicked()
{
const newDefinition = ObjectCloner.deepClone(startDefinition);
setSelectedStepId(null);
setDefinition(wrapDefinition(newDefinition));
}
function yesOrNo(value: boolean)
{
return value ? "✅ Yes" : "⛔ No";
}
return (
<>
{isVisible && (
<SequentialWorkflowDesigner
undoStackSize={10}
definition={definition}
onDefinitionChange={setDefinition}
selectedStepId={selectedStepId}
isReadonly={isReadonly}
onSelectedStepIdChanged={setSelectedStepId}
toolboxConfiguration={toolboxConfiguration}
isToolboxCollapsed={isToolboxCollapsed}
onIsToolboxCollapsedChanged={setIsToolboxCollapsed}
stepsConfiguration={stepsConfiguration}
validatorConfiguration={validatorConfiguration}
controlBar={true}
rootEditor={<RootEditor />}
stepEditor={<StepEditor />}
isEditorCollapsed={isEditorCollapsed}
onIsEditorCollapsedChanged={setIsEditorCollapsed}
controller={controller}
/>
)}
<ul>
<li>Definition: {definitionJson.length} bytes</li>
<li>Selected step: {selectedStepId}</li>
<li>Is readonly: {yesOrNo(isReadonly)}</li>
<li>Is valid: {definition.isValid === undefined ? "?" : yesOrNo(definition.isValid)}</li>
<li>Is toolbox collapsed: {yesOrNo(isToolboxCollapsed)}</li>
<li>Is editor collapsed: {yesOrNo(isEditorCollapsed)}</li>
</ul>
<div>
<button onClick={toggleVisibilityClicked}>Toggle visibility</button>
<button onClick={reloadDefinitionClicked}>Reload definition</button>
<button onClick={toggleSelectionClicked}>Toggle selection</button>
<button onClick={toggleIsReadonlyClicked}>Toggle readonly</button>
<button onClick={toggleToolboxClicked}>Toggle toolbox</button>
<button onClick={toggleEditorClicked}>Toggle editor</button>
<button onClick={moveViewportToFirstStepClicked}>Move viewport to first step</button>
<button onClick={appendStepClicked}>Append step</button>
</div>
<div>
<textarea value={definitionJson} readOnly={true} cols={100} rows={15} />
</div>
</>
);
}
export default WorkflowPreview;

View File

@ -0,0 +1,75 @@
import {BranchedStep, Definition, Step} from "sequential-workflow-designer";
export interface WorkflowDefinition extends Definition
{
properties: {
alfa?: string;
};
}
export interface TaskStep extends Step
{
componentType: "task";
type: "task";
properties: {
x?: string;
y?: string;
warehouse?: string;
wmsConnection?: string;
wmsSystem?: string;
};
}
export type OptimizationStepType =
"determineWarehouseRouting" |
"determineLineHaulLane" |
"validateLineItems" |
"determineCoolingCategory" |
"validateOptimizationRules" |
"validateAddress" |
"determineCarrierService" |
"determineTNT" |
"determineOrderServiceDates" |
"orderMatchesFilterSelector";
export interface WarehouseOptimizationStep extends Step
{
componentType: "task";
type: OptimizationStepType;
properties: {
x?: string;
y?: string;
warehouse?: string;
wmsConnection?: string;
wmsSystem?: string;
isValid?: boolean;
};
}
export interface SwitchStep extends BranchedStep
{
componentType: "switch";
type: "switch";
properties: {
x?: string;
y?: string;
warehouse?: string;
wmsConnection?: string;
wmsSystem?: string;
};
}
export interface ContainerStep extends Step
{
componentType: "container";
type: "container";
properties: {
x?: string;
y?: string;
warehouse?: string;
wmsConnection?: string;
wmsSystem?: string;
};
sequence: (WarehouseOptimizationStep | SwitchStep)[];
}

View File

@ -787,3 +787,504 @@ input[type="search"]::-webkit-search-results-decoration
{
margin: 2rem 1rem;
}
.sqd-designer-react {
width: 100vw;
height: 90vh;
}
.sqd-editor {
padding: 10px;
}
input:read-only {
opacity: 0.35;
}
.sqd-editor {
padding: 10px;
}
input:read-only {
opacity: 0.35;
}
/* internal */
.sqd-theme-light .sqd-toolbox {
background: #fff;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.15);
border-radius: 10px;
}
.sqd-theme-light .sqd-toolbox-header-title {
color: #000;
}
.sqd-theme-light .sqd-toolbox-filter {
background: #fff;
color: #000;
border: 1px solid #c3c3c3;
border-radius: 10px;
}
.sqd-theme-light .sqd-toolbox-filter:focus {
border-color: #939393;
}
.sqd-theme-light .sqd-toolbox-group-title {
color: #000;
background: #e5e5e5;
border-radius: 10px;
}
.sqd-theme-light .sqd-toolbox-item {
color: #000;
border: 1px solid #c3c3c3;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.15);
background: #fff;
border-radius: 5px;
}
.sqd-theme-light .sqd-toolbox-item:hover {
border-color: #939393;
background: #fff;
}
.sqd-theme-light .sqd-toolbox-item .sqd-toolbox-item-icon.sqd-no-icon {
background: #c6c6c6;
border-radius: 4px;
}
.sqd-theme-light .sqd-control-bar {
background: #fff;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.15);
border-radius: 10px;
}
.sqd-theme-light .sqd-control-bar-button {
border: 1px solid #c3c3c3;
background: #fff;
border-radius: 5px;
}
.sqd-theme-light .sqd-control-bar-button:hover {
border-color: #939393;
background: #fff;
}
.sqd-theme-light .sqd-control-bar-button .sqd-icon-path {
fill: #000;
}
.sqd-theme-light .sqd-control-bar-button.sqd-delete .sqd-icon-path {
fill: #e01a24;
}
.sqd-theme-light .sqd-smart-editor {
background: #fff;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15);
}
.sqd-theme-light .sqd-smart-editor-toggle {
background: #fff;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15);
}
.sqd-theme-light.sqd-context-menu {
background: #fff;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.sqd-theme-light .sqd-context-menu-group {
color: #888;
}
.sqd-theme-light .sqd-context-menu-item {
color: #000;
border-radius: 4px;
}
.sqd-theme-light .sqd-context-menu-item:hover {
background: #eee;
}
.sqd-theme-light.sqd-designer {
background: #f9f9f9;
}
.sqd-theme-light .sqd-line-grid-path {
stroke: #e3e3e3;
stroke-width: 1;
}
.sqd-theme-light .sqd-join {
stroke-width: 2;
stroke: #000;
}
.sqd-theme-light .sqd-region {
stroke: #cecece;
stroke-width: 2;
stroke-dasharray: 3;
}
.sqd-theme-light .sqd-region.sqd-selected {
stroke: #ed4800;
stroke-width: 2;
stroke-dasharray: 0;
}
.sqd-theme-light .sqd-placeholder .sqd-placeholder-rect {
fill: #d8d8d8;
stroke: #6a6a6a;
stroke-width: 1;
stroke-dasharray: 3;
}
.sqd-theme-light .sqd-placeholder.sqd-hover .sqd-placeholder-rect {
fill: #ed4800;
}
.sqd-theme-light .sqd-placeholder-icon-path {
fill: #2b2b2b;
}
.sqd-theme-light .sqd-placeholder.sqd-hover .sqd-placeholder-icon-path {
fill: #fff;
}
.sqd-theme-light .sqd-validation-error {
fill: #ffa200;
}
.sqd-theme-light .sqd-validation-error-icon-path {
fill: #000;
}
.sqd-theme-light .sqd-root-start-stop-circle {
fill: #2c18df;
}
.sqd-theme-light .sqd-root-start-stop-icon {
fill: #fff;
}
.sqd-theme-light .sqd-step-task .sqd-step-task-rect {
fill: #fff;
stroke-width: 1;
stroke: #c3c3c3;
filter: drop-shadow(0 1.5px 1.5px rgba(0, 0, 0, 0.15));
}
.sqd-theme-light .sqd-step-task .sqd-step-task-rect.sqd-selected {
stroke: #ed4800;
stroke-width: 2;
}
.sqd-theme-light .sqd-step-task .sqd-step-task-text {
fill: #000;
}
.sqd-theme-light .sqd-step-task .sqd-step-task-empty-icon {
fill: #c6c6c6;
}
.sqd-theme-light .sqd-step-task .sqd-input {
fill: #fff;
stroke-width: 2;
stroke: #000;
}
.sqd-theme-light .sqd-step-task .sqd-output {
fill: #000;
stroke-width: 0;
}
.sqd-theme-light .sqd-step-switch > .sqd-label-primary > .sqd-label-text {
fill: #fff;
}
.sqd-theme-light .sqd-step-switch > .sqd-label-primary > .sqd-label-rect {
fill: #2411db;
stroke-width: 0;
}
.sqd-theme-light .sqd-step-switch > .sqd-label-secondary > .sqd-label-rect {
fill: #000;
stroke-width: 0;
}
.sqd-theme-light .sqd-step-switch > .sqd-label-secondary > .sqd-label-text {
fill: #fff;
}
.sqd-theme-light .sqd-step-switch > g > .sqd-input {
fill: #fff;
stroke-width: 2;
stroke: #000;
}
.sqd-theme-light .sqd-step-container > .sqd-label > .sqd-label-text {
fill: #fff;
}
.sqd-theme-light .sqd-step-container > .sqd-label > .sqd-label-rect {
fill: #2411db;
stroke-width: 0;
}
.sqd-theme-light .sqd-step-container > g > .sqd-input {
fill: #fff;
stroke-width: 2;
stroke: #000;
}
/* .sqd-designer */
.sqd-designer {
position: relative;
display: flex;
width: 100%;
height: 100%;
}
.sqd-designer,
.sqd-drag,
.sqd-context-menu {
font-size: 13px;
line-height: 1em;
}
.sqd-hidden {
display: none !important;
}
.sqd-disabled {
opacity: 0.25;
}
/* .sqd-toolbox */
.sqd-toolbox,
.sqd-toolbox-filter {
font-size: 11px;
line-height: 1.2em;
}
.sqd-toolbox {
position: absolute;
top: 10px;
left: 10px;
z-index: 20;
box-sizing: border-box;
width: 250px;
-webkit-user-select: none;
user-select: none;
}
.sqd-toolbox-header {
position: relative;
padding: 15px 10px;
cursor: pointer;
}
.sqd-toolbox-header-title {
display: block;
font-size: 1.2em;
line-height: 1em;
font-weight: bold;
}
.sqd-toolbox-toggle-icon {
position: absolute;
top: 50%;
right: 10px;
width: 16px;
height: 16px;
margin: -8px 0 0;
}
.sqd-toolbox-header:hover .sqd-toolbox-toggle-icon {
opacity: 0.6;
}
.sqd-scrollbox {
position: relative;
overflow: hidden;
}
.sqd-scrollbox-body {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.sqd-toolbox-filter {
display: block;
box-sizing: border-box;
padding: 6px 8px;
outline: none;
width: 110px;
margin: 0 10px 10px;
box-sizing: border-box;
}
.sqd-toolbox-group-title {
text-align: center;
padding: 5px 0;
margin: 0 10px 10px;
}
.sqd-toolbox-item {
position: relative;
box-sizing: border-box;
margin: 0 10px 10px;
cursor: move;
width: 90%;
}
.sqd-toolbox-item-icon {
position: absolute;
top: 50%;
left: 5px;
margin-top: -10px;
width: 20px;
height: 20px;
}
.sqd-toolbox-item-icon-image {
width: 100%;
height: 100%;
}
.sqd-toolbox-item-text {
position: relative;
display: block;
padding: 10px 10px 10px 30px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.sqd-drag {
position: absolute;
z-index: 9999999;
pointer-events: none;
}
/* .sqd-control-bar */
.sqd-control-bar {
position: absolute;
bottom: 10px;
left: 10px;
z-index: 20;
padding: 8px 0 8px 8px;
white-space: nowrap;
}
.sqd-control-bar-button {
display: inline-block;
width: 32px;
height: 32px;
margin-right: 8px;
cursor: pointer;
box-sizing: border-box;
}
.sqd-control-bar-button-icon {
width: 24px;
height: 24px;
margin: 3px 0 0 3px;
}
.sqd-control-bar-button.sqd-disabled .sqd-control-bar-button-icon {
opacity: 0.2;
}
/* .sqd-smart-editor */
.sqd-smart-editor-toggle {
position: absolute;
top: 0;
z-index: 29;
width: 36px;
height: 64px;
border-bottom-left-radius: 10px;
cursor: pointer;
}
.sqd-smart-editor-toggle-icon {
position: absolute;
top: 50%;
left: 50%;
width: 24px;
height: 24px;
margin: -12px 0 0 -12px;
}
.sqd-smart-editor-toggle:hover .sqd-smart-editor-toggle-icon {
opacity: 0.6;
}
.sqd-smart-editor {
z-index: 30;
}
.sqd-layout-desktop .sqd-smart-editor {
position: relative;
width: 300px;
}
.sqd-layout-desktop .sqd-smart-editor-toggle {
right: 300px;
}
.sqd-layout-desktop .sqd-smart-editor-toggle.sqd-collapsed {
right: 0;
}
.sqd-layout-mobile .sqd-smart-editor {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 41px;
}
.sqd-layout-mobile .sqd-smart-editor-toggle {
left: 5px;
}
.sqd-layout-mobile .sqd-smart-editor-toggle.sqd-collapsed {
left: auto;
right: 0;
}
/* .sqd-context-menu */
.sqd-context-menu {
position: absolute;
z-index: 2000000000;
overflow: hidden;
padding: 5px;
}
.sqd-context-menu-group,
.sqd-context-menu-item {
width: 130px;
padding: 8px 10px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.sqd-context-menu-group {
font-size: 11px;
line-height: 1em;
}
.sqd-context-menu-item {
cursor: pointer;
transition: background 70ms;
}
/* .sqd-workspace */
.sqd-workspace {
flex: 1;
position: relative;
display: block;
-webkit-user-select: none;
user-select: none;
}
.sqd-workspace-canvas {
position: absolute;
top: 0;
left: 0;
cursor: move;
}
.sqd-label-text {
text-anchor: middle;
dominant-baseline: central;
}
.sqd-placeholder .sqd-placeholder-rect {
transition: fill 100ms;
}
.sqd-step-task-text {
text-anchor: left;
dominant-baseline: central;
}