mirror of
https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
synced 2025-07-21 22:58:43 +00:00
Compare commits
13 Commits
snapshot-f
...
wip/CE-148
Author | SHA1 | Date | |
---|---|---|---|
9a6fcd8bb1 | |||
d31215f6c0 | |||
262855b9c0 | |||
4d082c3c57 | |||
47fb7cc2e3 | |||
647c63f5a3 | |||
f545649882 | |||
4d4610801f | |||
3ec43fbbd3 | |||
28bc07cce4 | |||
c7d31fa39e | |||
69f1cfe92f | |||
2ed95ff77a |
@ -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
5750
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@ -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",
|
||||
|
2
pom.xml
2
pom.xml
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
74
src/qqq/components/misc/ErrorBoundary.tsx
Normal file
74
src/qqq/components/misc/ErrorBoundary.tsx
Normal 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;
|
@ -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>;
|
||||
}
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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 &&
|
||||
|
383
src/qqq/components/widgets/misc/WorkflowViewer.tsx
Normal file
383
src/qqq/components/widgets/misc/WorkflowViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
21
src/qqq/components/workflows/RootEditor.tsx
Normal file
21
src/qqq/components/workflows/RootEditor.tsx
Normal 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.
|
||||
</>
|
||||
);
|
||||
}
|
69
src/qqq/components/workflows/StepEditor.tsx
Normal file
69
src/qqq/components/workflows/StepEditor.tsx
Normal 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
214
src/qqq/components/workflows/StepUtils.ts
Normal file
214
src/qqq/components/workflows/StepUtils.ts
Normal 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
|
||||
};
|
||||
}
|
187
src/qqq/components/workflows/WorkflowEditor.tsx
Normal file
187
src/qqq/components/workflows/WorkflowEditor.tsx
Normal 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;
|
199
src/qqq/components/workflows/WorkflowPreview.tsx
Normal file
199
src/qqq/components/workflows/WorkflowPreview.tsx
Normal 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;
|
75
src/qqq/components/workflows/model.ts
Normal file
75
src/qqq/components/workflows/model.ts
Normal 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)[];
|
||||
}
|
@ -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))
|
||||
|
@ -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;
|
||||
}
|
||||
|
Reference in New Issue
Block a user