Compare commits

...

13 Commits

Author SHA1 Message Date
9a6fcd8bb1 CE-1482: POC of workflow library 2024-08-09 11:44:54 -05:00
d31215f6c0 Added dev for build, not test 2024-07-09 10:09:25 -05:00
262855b9c0 Increase version to 0.21.0-SNAPSHOT 2024-07-09 10:08:32 -05:00
4d082c3c57 Update revision to 0.20.0, to publish that release version 2024-07-05 20:38:36 -05:00
47fb7cc2e3 Merge pull request #65 from Kingsrook/feature/CE-1402-field-case-change-behaviors
Feature/ce 1402 field case change behaviors
2024-07-03 16:29:05 -05:00
647c63f5a3 Add ErrorBoundary, and wrap HelpContent with it 2024-06-25 13:32:13 -05:00
f545649882 CE-1402 Make consistent naming 'behaviors', not 'fieldBehaviors' 2024-06-25 08:40:30 -05:00
4d4610801f Add "&& npm dedupe --force" to clean-and-install 2024-06-25 07:58:29 -05:00
3ec43fbbd3 CE-1402 Only do flushSync and setSelectionRange after a toUpper/Lower and add a try-catch, just in case (specifically, because failed on input type=number) 2024-06-25 07:58:12 -05:00
28bc07cce4 CE-1402 Look for toUpperCase/toLowerCase behaviors on fields, and apply those transforms (plus cursor adjustments) in input fields and filter criteria values 2024-06-24 20:44:15 -05:00
c7d31fa39e Better matching for multi-word search terms ("one th" now matches "one two three") 2024-06-19 16:43:26 -05:00
69f1cfe92f Merge pull request #64 from Kingsrook/feature/fix-process-pvs-display-value
Fix to fetch possible-values when switching screens, to display label…
2024-06-19 16:32:38 -05:00
2ed95ff77a Update to not submit 'undefined' values to backend 2024-06-14 11:38:55 -05:00
20 changed files with 5785 additions and 1935 deletions

View File

@ -115,7 +115,7 @@ workflows:
context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ]
filters:
branches:
ignore: /(main|integration.*)/
ignore: /(main|dev|integration.*)/
tags:
ignore: /(version|snapshot)-.*/
deploy:
@ -124,7 +124,7 @@ workflows:
context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ]
filters:
branches:
only: /(main|integration.*)/
only: /(main|dev|integration.*)/
tags:
only: /(version|snapshot)-.*/

5750
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
"@auth0/auth0-react": "1.10.2",
"@emotion/react": "11.7.1",
"@emotion/styled": "11.6.0",
"@kingsrook/qqq-frontend-core": "1.0.102",
"@kingsrook/qqq-frontend-core": "1.0.104",
"@mui/icons-material": "5.4.1",
"@mui/material": "5.11.1",
"@mui/styles": "5.11.1",
@ -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"
},
@ -59,7 +63,7 @@
"build": "react-scripts build",
"clean": "rm -rf node_modules package-lock.json lib",
"eject": "react-scripts eject",
"clean-and-install": "rm -rf node_modules/ && rm -rf package-lock.json && rm -rf lib/ && npm install --legacy-peer-deps",
"clean-and-install": "rm -rf node_modules/ && rm -rf package-lock.json && rm -rf lib/ && npm install --legacy-peer-deps && npm dedupe --force",
"npm-install": "npm install --legacy-peer-deps",
"prepublishOnly": "tsc -p ./ --outDir lib/",
"start": "BROWSER=none react-scripts --max-http-header-size=65535 start",

View File

@ -29,7 +29,7 @@
<packaging>jar</packaging>
<properties>
<revision>0.20.0-SNAPSHOT</revision>
<revision>0.21.0-SNAPSHOT</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

View File

@ -72,7 +72,7 @@ const CommandMenu = ({metaData}: Props) =>
const navigate = useNavigate();
const pathParts = location.pathname.replace(/\/+$/, "").split("/");
const {accentColor, tableMetaData, dotMenuOpen, setDotMenuOpen, keyboardHelpOpen, setKeyboardHelpOpen, setTableMetaData, tableProcesses} = useContext(QContext);
const {accentColor, tableMetaData, dotMenuOpen, setDotMenuOpen, keyboardHelpOpen, setKeyboardHelpOpen, setTableMetaData, tableProcesses, recordAnalytics} = useContext(QContext);
const classes = useStyles();
@ -87,6 +87,7 @@ const CommandMenu = ({metaData}: Props) =>
if (e.key === "." && !keyboardHelpOpen)
{
e.preventDefault();
recordAnalytics({category: "globalEvents", action: "dotMenuKeyboardShortcut"});
setDotMenuOpen(true);
}
else if (e.key === "?" && !dotMenuOpen)
@ -423,9 +424,20 @@ const CommandMenu = ({metaData}: Props) =>
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// iterate over the search parts - if any don't match the corresponding value parts, then it's a non-match //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
let valueIndex = 0;
for (let i = 0; i < searchParts.length; i++)
{
if (!valueParts[i].includes(searchParts[i]))
let foundMatch = false;
for (; valueIndex < valueParts.length; valueIndex++)
{
if (valueParts[valueIndex].includes(searchParts[i]))
{
foundMatch = true;
break;
}
}
if (!foundMatch)
{
return (0);
}

View File

@ -19,16 +19,17 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {InputAdornment, InputLabel} from "@mui/material";
import Box from "@mui/material/Box";
import {Box, InputAdornment, InputLabel} from "@mui/material";
import Switch from "@mui/material/Switch";
import {ErrorMessage, Field, useFormikContext} from "formik";
import React, {useState} from "react";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import React, {useMemo, useState} from "react";
import AceEditor from "react-ace";
import colors from "qqq/assets/theme/base/colors";
import BooleanFieldSwitch from "qqq/components/forms/BooleanFieldSwitch";
import MDInput from "qqq/components/legacy/MDInput";
import MDTypography from "qqq/components/legacy/MDTypography";
import {flushSync} from "react-dom";
// Declaring props types for FormField
interface Props
@ -85,6 +86,51 @@ function QDynamicFormField({
}
};
///////////////////////////////////////////////////////////////////////////////////////
// check the field meta data for behavior that says to do toUpperCase or toLowerCase //
///////////////////////////////////////////////////////////////////////////////////////
let isToUpperCase = useMemo(() => DynamicFormUtils.isToUpperCase(formFieldObject?.fieldMetaData), [formFieldObject]);
let isToLowerCase = useMemo(() => DynamicFormUtils.isToLowerCase(formFieldObject?.fieldMetaData), [formFieldObject]);
////////////////////////////////////////////////////////////////////////
// if the field has a toUpperCase or toLowerCase behavior on it, then //
// apply that rule. But also, to avoid the cursor always jumping to //
// the end of the input, do some manipulation of the selection. //
// See: https://giacomocerquone.com/blog/keep-input-cursor-still //
// Note, we only want an onChange handle if we're doing one of these //
// behaviors, (because teh flushSync is potentially slow). hence, we //
// put the onChange in an object and assign it with a spread //
////////////////////////////////////////////////////////////////////////
let onChange: any = {};
if (isToUpperCase || isToLowerCase)
{
onChange.onChange = (e: any) =>
{
const beforeStart = e.target.selectionStart;
const beforeEnd = e.target.selectionEnd;
flushSync(() =>
{
let newValue = e.currentTarget.value;
if (isToUpperCase)
{
newValue = newValue.toUpperCase();
}
if (isToLowerCase)
{
newValue = newValue.toLowerCase();
}
setFieldValue(name, newValue);
});
const input = document.getElementById(name) as HTMLInputElement;
if (input)
{
input.setSelectionRange(beforeStart, beforeEnd);
}
};
}
let field;
let getsBulkEditHtmlLabel = true;
if (type === "checkbox")
@ -102,7 +148,7 @@ function QDynamicFormField({
else if (type === "ace")
{
let mode = "text";
if(formFieldObject && formFieldObject.languageMode)
if (formFieldObject && formFieldObject.languageMode)
{
mode = formFieldObject.languageMode;
}
@ -133,7 +179,7 @@ function QDynamicFormField({
{
field = (
<>
<Field {...rest} onWheel={handleOnWheel} name={name} type={type} as={MDInput} variant="outlined" label={label} InputLabelProps={inputLabelProps} InputProps={inputProps} fullWidth disabled={isDisabled}
<Field {...rest} {...onChange} onWheel={handleOnWheel} name={name} type={type} as={MDInput} variant="outlined" label={label} InputLabelProps={inputLabelProps} InputProps={inputProps} fullWidth disabled={isDisabled}
onKeyPress={(e: any) =>
{
if (e.key === "Enter")
@ -173,7 +219,8 @@ function QDynamicFormField({
id={`bulkEditSwitch-${name}`}
checked={switchChecked}
onClick={bulkEditSwitchChanged}
sx={{top: "-4px",
sx={{
top: "-4px",
"& .MuiSwitch-track": {
height: 20,
borderRadius: 10,

View File

@ -176,7 +176,7 @@ class DynamicFormUtils
initialDisplayValue: initialDisplayValue,
};
}
else if(processName)
else if (processName)
{
dynamicFormFields[field.name].possibleValueProps =
{
@ -214,7 +214,7 @@ class DynamicFormUtils
if (Array.isArray(disabledFields))
{
return (disabledFields.indexOf(fieldName) > -1)
return (disabledFields.indexOf(fieldName) > -1);
}
else
{
@ -222,6 +222,44 @@ class DynamicFormUtils
}
}
/***************************************************************************
* check if a field has the TO_UPPER_CASE behavior on it.
***************************************************************************/
public static isToUpperCase(fieldMetaData: QFieldMetaData): boolean
{
return this.hasBehavior(fieldMetaData, "TO_UPPER_CASE");
}
/***************************************************************************
* check if a field has the TO_LOWER_CASE behavior on it.
***************************************************************************/
public static isToLowerCase(fieldMetaData: QFieldMetaData): boolean
{
return this.hasBehavior(fieldMetaData, "TO_LOWER_CASE");
}
/***************************************************************************
* check if a field has a specific behavior name on it.
***************************************************************************/
private static hasBehavior(fieldMetaData: QFieldMetaData, behaviorName: string): boolean
{
if (fieldMetaData && fieldMetaData.behaviors)
{
for (let i = 0; i < fieldMetaData.behaviors.length; i++)
{
if (fieldMetaData.behaviors[i] == behaviorName)
{
return (true);
}
}
}
return (false);
}
}
export default DynamicFormUtils;

View File

@ -0,0 +1,74 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 React, {Component, ErrorInfo} from "react";
interface Props
{
errorElement?: React.ReactNode;
children: React.ReactNode;
}
interface State
{
hasError: boolean;
}
/*******************************************************************************
** Component that you can wrap around other components that might throw an error,
** to give some isolation, rather than breaking a whole page.
** Credit: https://medium.com/@bobjunior542/how-to-use-error-boundaries-in-react-js-with-typescript-ee90ec814bf1
*******************************************************************************/
class ErrorBoundary extends Component<Props, State>
{
/***************************************************************************
*
***************************************************************************/
constructor(props: Props)
{
super(props);
this.state = {hasError: false};
}
/***************************************************************************
*
***************************************************************************/
componentDidCatch(error: Error, errorInfo: ErrorInfo)
{
console.error("ErrorBoundary caught an error: ", error, errorInfo);
this.setState({hasError: true});
}
/***************************************************************************
*
***************************************************************************/
render()
{
if (this.state.hasError)
{
return this.props.errorElement ?? <span>(Error)</span>;
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@ -22,6 +22,7 @@
import {QHelpContent} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QHelpContent";
import Box from "@mui/material/Box";
import parse from "html-react-parser";
import ErrorBoundary from "qqq/components/misc/ErrorBoundary";
import React, {useContext} from "react";
import Markdown from "react-markdown";
import QContext from "QContext";
@ -128,6 +129,7 @@ function HelpContent({helpContents, roles, heading, helpContentKey}: Props): JSX
let selectedHelpContent = getMatchingHelpContent(helpContentsArray, roles);
let content = null;
let errorContent = "Error rendering help content.";
if (helpHelpActive)
{
if (!selectedHelpContent)
@ -135,6 +137,7 @@ function HelpContent({helpContents, roles, heading, helpContentKey}: Props): JSX
selectedHelpContent = new QHelpContent({content: ""});
}
content = selectedHelpContent.content + ` [${helpContentKey ?? "?"}]`;
errorContent += ` [${helpContentKey ?? "?"}]`;
}
else if(selectedHelpContent)
{
@ -148,7 +151,9 @@ function HelpContent({helpContents, roles, heading, helpContentKey}: Props): JSX
{
return <Box display="inline" className="helpContent">
{heading && <span className="header">{heading}</span>}
{formatHelpContent(content, selectedHelpContent.format)}
<ErrorBoundary errorElement={<i>{errorContent}</i>}>
{formatHelpContent(content, selectedHelpContent.format)}
</ErrorBoundary>
</Box>;
}

View File

@ -30,6 +30,7 @@ import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment/InputAdornment";
import TextField from "@mui/material/TextField";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import AssignFilterVariable from "qqq/components/query/AssignFilterVariable";
import CriteriaDateField, {NoWrapTooltip} from "qqq/components/query/CriteriaDateField";
@ -40,6 +41,7 @@ import {OperatorOption, ValueMode} from "qqq/components/query/FilterCriteriaRow"
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {SyntheticEvent, useReducer} from "react";
import {flushSync} from "react-dom";
interface Props
{
@ -58,6 +60,10 @@ FilterCriteriaRowValues.defaultProps =
initiallyOpenMultiValuePvs: false
};
/***************************************************************************
* get the type to use for an <input> from a QFieldMetaData
***************************************************************************/
export const getTypeForTextField = (field: QFieldMetaData): string =>
{
let type = "search";
@ -78,10 +84,15 @@ export const getTypeForTextField = (field: QFieldMetaData): string =>
return (type);
};
/***************************************************************************
* Make an <input type=text> (actually, might be a different type, but that's
* the gist of it), for a field.
***************************************************************************/
export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWithId, valueChangeHandler?: (event: (React.ChangeEvent | React.SyntheticEvent), valueIndex?: (number | "all"), newValue?: any) => void, valueIndex: number = 0, label = "Value", idPrefix = "value-", allowVariables = false) =>
{
const isExpression = criteria.values && criteria.values[valueIndex] && criteria.values[valueIndex].type;
const inputId = `${idPrefix}${criteria.id}`;
let type = getTypeForTextField(field);
const inputLabelProps: any = {};
@ -96,10 +107,13 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
value = ValueUtils.formatDateTimeValueForForm(value);
}
/***************************************************************************
* Event handler for the clear 'x'.
***************************************************************************/
const clearValue = (event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) =>
{
valueChangeHandler(event, index, "");
document.getElementById(`${idPrefix}${criteria.id}`).focus();
document.getElementById(inputId).focus();
};
@ -120,6 +134,10 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
};
/***************************************************************************
* make a version of the text field for when the criteria's value is set to
* be a "variable"
***************************************************************************/
const makeFilterVariableTextField = (expression: FilterVariableExpression, valueIndex: number = 0, label = "Value", idPrefix = "value-") =>
{
const clearValue = (event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) =>
@ -149,6 +167,10 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
/></NoWrapTooltip>;
};
///////////////////////////////////////////////////////////////////////////
// set up an 'x' icon as an end-adornment, to clear value from the field //
///////////////////////////////////////////////////////////////////////////
const inputProps: any = {};
inputProps.endAdornment = (
<InputAdornment position="end">
@ -158,18 +180,64 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
</InputAdornment>
);
/***************************************************************************
* onChange event handler. deals with, if the field has a to upper/lower
* case rule on it, to apply that transform, and adjust the cursor.
* See: https://giacomocerquone.com/blog/keep-input-cursor-still
***************************************************************************/
function onChange(event: any)
{
const beforeStart = event.target.selectionStart;
const beforeEnd = event.target.selectionEnd;
let isToUpperCase = DynamicFormUtils.isToUpperCase(field);
let isToLowerCase = DynamicFormUtils.isToLowerCase(field);
if (isToUpperCase || isToLowerCase)
{
flushSync(() =>
{
let newValue = event.currentTarget.value;
if (isToUpperCase)
{
newValue = newValue.toUpperCase();
}
if (isToLowerCase)
{
newValue = newValue.toLowerCase();
}
event.currentTarget.value = newValue;
});
const input = document.getElementById(inputId);
if (input)
{
// @ts-ignore
input.setSelectionRange(beforeStart, beforeEnd);
}
}
valueChangeHandler(event, valueIndex);
}
////////////////////////
// return the element //
////////////////////////
return <Box sx={{margin: 0, padding: 0, display: "flex"}}>
{
isExpression ? (
makeFilterVariableTextField(criteria.values[valueIndex], valueIndex, label, idPrefix)
) : (
<TextField
id={`${idPrefix}${criteria.id}`}
id={inputId}
label={label}
variant="standard"
autoComplete="off"
type={type}
onChange={(event) => valueChangeHandler(event, valueIndex)}
onChange={onChange}
onKeyDown={handleKeyDown}
value={value}
InputLabelProps={inputLabelProps}
@ -188,6 +256,10 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
};
/***************************************************************************
* Component that is the "values" portion of a FilterCriteria Row in the
* advanced query filter editor.
***************************************************************************/
function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler, initiallyOpenMultiValuePvs, queryScreenUsage, allowVariables}: Props): JSX.Element
{
const [, forceUpdate] = useReducer((x) => x + 1, 0);
@ -197,6 +269,10 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
return null;
}
/***************************************************************************
* Callback for the Save button from the paste-values modal
***************************************************************************/
function saveNewPasterValues(newValues: any[])
{
if (criteria.values)
@ -222,6 +298,9 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
const isExpression = criteria.values && criteria.values[0] && criteria.values[0].type;
//////////////////////////////////////////////////////////////////////////////
// render different form element9s) based on operator option's "value mode" //
//////////////////////////////////////////////////////////////////////////////
switch (operatorOption.valueMode)
{
case ValueMode.NONE:

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

@ -1423,7 +1423,10 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const formData = new FormData();
Object.keys(values).forEach((key) =>
{
formData.append(key, values[key]);
if (values[key] !== undefined)
{
formData.append(key, values[key]);
}
});
if (tableVariantLocalStorageKey && localStorage.getItem(tableVariantLocalStorageKey))

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;
}