From c9702e0f80ea4f6af9bfbd22b2ba330f5cd48e6e Mon Sep 17 00:00:00 2001 From: Debove Christopher Date: Tue, 7 Jan 2025 12:06:09 +0100 Subject: [PATCH] feat: case name templating (#647) * Implement string template ast node on builder and workflow * chore: update translations * Fix PR feedbacks * remove 'datetime' value from ReturnValueType for ast-validation --------- Co-authored-by: Christopher DEBOVE --- .../OperandEditModal/OperandEditModal.tsx | 13 ++ .../StringTemplateEdit.hook.ts | 108 ++++++++++++++ .../StringTemplateEdit/StringTemplateEdit.tsx | 81 ++++++++++ .../StringTemplateEditForm.tsx | 84 +++++++++++ .../StringTemplateEdit/VariableOperand.tsx | 54 +++++++ .../DetailPanel/AddToCaseIfPossibleNode.tsx | 22 +-- .../DetailPanel/CaseNameEditor.hook.ts | 23 +++ .../Workflow/DetailPanel/CaseNameEditor.tsx | 139 ++++++++++++++++++ .../Workflow/DetailPanel/CreateCaseNode.tsx | 17 +-- .../Workflow/DetailPanel/DetailPanel.tsx | 2 + .../Scenario/Workflow/models/nodes.ts | 3 + .../Scenario/Workflow/models/validation.ts | 4 + .../app-builder/src/locales/ar/scenarios.json | 6 +- .../app-builder/src/locales/ar/workflows.json | 6 +- .../app-builder/src/locales/en/scenarios.json | 4 + .../app-builder/src/locales/en/workflows.json | 4 +- .../app-builder/src/locales/fr/scenarios.json | 6 +- .../app-builder/src/locales/fr/workflows.json | 6 +- packages/app-builder/src/models/ast-node.ts | 40 ++++- .../app-builder/src/models/node-evaluation.ts | 2 + packages/app-builder/src/models/scenario.ts | 20 +++ .../src/repositories/ScenarioRepository.ts | 20 +++ .../scenarios+/$scenarioId+/_layout.tsx | 10 +- .../scenarios+/$scenarioId+/workflow.tsx | 76 ++++++++-- .../validate-with-given-trigger-or-rule.tsx | 6 +- .../scenarios+/$scenarioId+/validate-ast.tsx | 58 ++++++++ .../services/ast-node/getAstNodeDataType.ts | 5 + .../ast-node/getAstNodeDisplayName.ts | 17 +++ .../ast-node/getAstNodeOperandType.ts | 4 +- .../app-builder/src/utils/routes/routes.ts | 5 + .../app-builder/src/utils/routes/types.ts | 2 + .../marble-api/openapis/marblecore-api.yaml | 59 ++++++++ .../src/generated/marblecore-api.ts | 46 +++++- 33 files changed, 889 insertions(+), 63 deletions(-) create mode 100644 packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/StringTemplateEdit.hook.ts create mode 100644 packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/StringTemplateEdit.tsx create mode 100644 packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/StringTemplateEditForm.tsx create mode 100644 packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/VariableOperand.tsx create mode 100644 packages/app-builder/src/components/Scenario/Workflow/DetailPanel/CaseNameEditor.hook.ts create mode 100644 packages/app-builder/src/components/Scenario/Workflow/DetailPanel/CaseNameEditor.tsx create mode 100644 packages/app-builder/src/routes/ressources+/scenarios+/$scenarioId+/validate-ast.tsx diff --git a/packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/OperandEditModal.tsx b/packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/OperandEditModal.tsx index 99b37775..5f285f61 100644 --- a/packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/OperandEditModal.tsx +++ b/packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/OperandEditModal.tsx @@ -2,6 +2,7 @@ import { isAggregation, isFuzzyMatchComparator, isIsMultipleOf, + isStringTemplateAstNode, isTimeAdd, isTimestampExtract, } from '@app-builder/models'; @@ -19,6 +20,7 @@ import { import { AggregationEdit } from './AggregationEdit/AggregationEdit'; import { FuzzyMatchComparatorEdit } from './FuzzyMatchComparatorEdit/FuzzyMatchComparatorEdit'; import { IsMultipleOfEdit } from './IsMultipleOfEdit/IsMultipleOfEdit'; +import { StringTemplateEdit } from './StringTemplateEdit/StringTemplateEdit'; import { TimeAddEdit } from './TimeAddEdit/TimeAddEdit'; import { TimestampExtractEdit } from './TimestampExtract/TimestampExtract'; @@ -109,6 +111,17 @@ export function OperandEditModal() { ); } + if (isStringTemplateAstNode(initialEditableAstNode)) { + return ( + + + + ); + } assertNever( '[OperandEditModal] Unsupported astNode type', initialEditableAstNode, diff --git a/packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/StringTemplateEdit.hook.ts b/packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/StringTemplateEdit.hook.ts new file mode 100644 index 00000000..65d61112 --- /dev/null +++ b/packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/StringTemplateEdit.hook.ts @@ -0,0 +1,108 @@ +import { + type AstNode, + NewUndefinedAstNode, + type StringTemplateAstNode, +} from '@app-builder/models'; +import { type AstNodeErrors } from '@app-builder/services/validation/ast-node-validation'; +import { useMemo, useReducer } from 'react'; + +export const STRING_TEMPLATE_VARIABLE_REGEXP = /%([a-z0-9_]+)%/gim; +export const STRING_TEMPLATE_VARIABLE_CAPTURE_REGEXP = /(%[a-z0-9_]+%)/gim; + +export const extractVariablesNamesFromTemplate = (template: string) => { + const res = template.matchAll(STRING_TEMPLATE_VARIABLE_REGEXP).toArray(); + + return res.reduce((acc, match) => { + return match[1] && !acc.includes(match[1]) ? [...acc, match[1]] : acc; + }, [] as string[]); +}; + +type EditStringTemplateState = { + template: string; + variables: Record; +}; + +type EditStringTemplateAction = + | { type: 'setTemplate'; payload: { template: string } } + | { type: 'setVariable'; payload: { name: string; data: AstNode } }; + +const editStringTemplateReducer = ( + prevState: EditStringTemplateState, + action: EditStringTemplateAction, +): EditStringTemplateState => { + switch (action.type) { + case 'setTemplate': { + const nextState = { ...prevState, template: action.payload.template }; + const variablesNames = extractVariablesNamesFromTemplate( + action.payload.template, + ); + const variables = { ...prevState.variables }; + if (hasEmptyVariable(variables, variablesNames)) { + for (const variableName of variablesNames) { + if (variables[variableName] === undefined) { + variables[variableName] = NewUndefinedAstNode(); + } + } + return { ...nextState, variables }; + } + return nextState; + } + case 'setVariable': + return { + ...prevState, + variables: { + ...prevState.variables, + [action.payload.name]: action.payload.data, + }, + }; + } +}; + +const hasEmptyVariable = ( + variables: Record, + variableNames: string[], +) => { + return ( + variableNames.filter((variableName) => !variables[variableName]).length > 0 + ); +}; + +const adaptStringTemplateEditState = ({ + initialNode, + initialErrors: _, +}: { + initialNode: StringTemplateAstNode; + initialErrors: AstNodeErrors | undefined; +}) => { + return { + template: initialNode.children[0]?.constant ?? '', + variables: initialNode.namedChildren, + }; +}; + +export const useStringTemplateEditState = ( + initialNode: StringTemplateAstNode, + initialErrors: AstNodeErrors | undefined, +) => { + const [state, dispatch] = useReducer( + editStringTemplateReducer, + { initialNode, initialErrors }, + adaptStringTemplateEditState, + ); + const variableNames = useMemo( + () => extractVariablesNamesFromTemplate(state.template), + [state.template], + ); + + return { + template: state.template, + setTemplate: (template: string) => { + dispatch({ type: 'setTemplate', payload: { template } }); + }, + variableNames, + variables: state.variables, + setVariable: (name: string, data: AstNode) => { + dispatch({ type: 'setVariable', payload: { name, data } }); + }, + }; +}; diff --git a/packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/StringTemplateEdit.tsx b/packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/StringTemplateEdit.tsx new file mode 100644 index 00000000..a44bb17f --- /dev/null +++ b/packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/StringTemplateEdit.tsx @@ -0,0 +1,81 @@ +import { + type AstNode, + NewStringTemplateAstNode, + type StringTemplateAstNode, +} from '@app-builder/models'; +import { + type AstNodeErrors, + computeLineErrors, +} from '@app-builder/services/validation/ast-node-validation'; +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import * as R from 'remeda'; +import { Button, ModalV2 } from 'ui-design-system'; + +import { + extractVariablesNamesFromTemplate, + useStringTemplateEditState, +} from './StringTemplateEdit.hook'; +import { StringTemplateEditForm } from './StringTemplateEditForm'; + +export type StringTemplateEditProps = { + initialNode: StringTemplateAstNode; + initialErrors?: AstNodeErrors; + onSave: (node: AstNode) => void; + onEdit?: (node: AstNode) => void; +}; + +export const StringTemplateEdit = ({ + initialNode, + initialErrors, + onSave, + onEdit, +}: StringTemplateEditProps) => { + const { t } = useTranslation(['scenarios', 'common']); + const state = useStringTemplateEditState(initialNode, initialErrors); + const newNodeRef = useRef(initialNode); + const hasErrors = initialErrors + ? computeLineErrors(newNodeRef.current, initialErrors).length > 0 + : false; + + useEffect(() => { + const template = state.template; + const variableNames = extractVariablesNamesFromTemplate(template); + const variables = R.pick(state.variables, variableNames); + + newNodeRef.current = NewStringTemplateAstNode(template, variables); + + onEdit?.(newNodeRef.current); + }, [state.template, state.variables, onEdit]); + + const handleSave = () => { + onSave(newNodeRef.current); + }; + + return ( + <> + {t('scenarios:edit_string_template.title')} +
+ +
+ + } + > + {t('common:cancel')} + + +
+
+ + ); +}; diff --git a/packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/StringTemplateEditForm.tsx b/packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/StringTemplateEditForm.tsx new file mode 100644 index 00000000..3c26d0fe --- /dev/null +++ b/packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/StringTemplateEditForm.tsx @@ -0,0 +1,84 @@ +import { EvaluationErrors } from '@app-builder/components/Scenario/ScenarioValidationError'; +import { type AstNode, NewUndefinedAstNode } from '@app-builder/models'; +import { + adaptEvaluationErrorViewModels, + useGetNodeEvaluationErrorMessage, +} from '@app-builder/services/validation'; +import { type AstNodeErrors } from '@app-builder/services/validation/ast-node-validation'; +import { type ChangeEvent, Fragment } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Input } from 'ui-design-system'; + +import { VariableOperand } from './VariableOperand'; + +export type StringTemplateEditFormProps = { + template: string; + setTemplate: (template: string) => void; + variables: Record; + setVariable: (name: string, data: AstNode) => void; + variableNames: string[]; + errors?: AstNodeErrors; +}; + +export const StringTemplateEditForm = ({ + template, + setTemplate, + variables, + setVariable, + variableNames, + errors, +}: StringTemplateEditFormProps) => { + const { t } = useTranslation(['scenarios', 'common']); + const handleTemplateChange = (event: ChangeEvent) => { + setTemplate(event.target.value); + }; + const getCommonError = useGetNodeEvaluationErrorMessage(); + const templateErrors = adaptEvaluationErrorViewModels( + errors?.errors.filter((e) => e.argumentIndex === 0) ?? [], + ); + + return ( +
+
+ {t('scenarios:edit_string_template.template_field.label')} + + +
+ {variableNames.length > 0 ? ( +
+ {t('scenarios:edit_string_template.variables.label')} +
+ {variableNames.map((name) => ( + +
+ + {name} + +
+
+ setVariable(name, node)} + /> + +
+
+ ))} +
+
+ ) : null} +
+ ); +}; diff --git a/packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/VariableOperand.tsx b/packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/VariableOperand.tsx new file mode 100644 index 00000000..ee05f249 --- /dev/null +++ b/packages/app-builder/src/components/Scenario/AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/VariableOperand.tsx @@ -0,0 +1,54 @@ +import { type AstNode } from '@app-builder/models'; +import { + useGetAstNodeOption, + useOperandOptions, +} from '@app-builder/services/editor/options'; +import { + type AstNodeErrors, + type ValidationStatus, +} from '@app-builder/services/validation/ast-node-validation'; +import { useMemo } from 'react'; + +import { Operand } from '../../../Operand'; + +export type VariableOperandProps = { + astNode: AstNode; + astNodeErrors: AstNodeErrors | undefined; + validationStatus: ValidationStatus; + onChange: (node: AstNode) => void; +}; + +export const VariableOperand = ({ + astNode, + astNodeErrors, + validationStatus, + onChange, +}: VariableOperandProps) => { + const options = useOperandOptions([]); + const leftOptions = useMemo( + () => + options.filter( + (option) => + option.dataType === 'String' || + option.dataType === 'Int' || + option.dataType === 'Float', + ), + [options], + ); + + const getAstNodeOption = useGetAstNodeOption(); + const operandProps = useMemo( + () => getAstNodeOption(astNode), + [astNode, getAstNodeOption], + ); + + return ( + + ); +}; diff --git a/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/AddToCaseIfPossibleNode.tsx b/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/AddToCaseIfPossibleNode.tsx index 2951e3aa..13b70216 100644 --- a/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/AddToCaseIfPossibleNode.tsx +++ b/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/AddToCaseIfPossibleNode.tsx @@ -10,8 +10,8 @@ import { useWorkflowData, useWorkflowDataFeatureAccess, } from '../WorkflowProvider'; +import { CaseNameEditor } from './CaseNameEditor'; import { SelectInbox } from './SelectInbox'; -import { defaultCaseName } from './shared'; export function AddToCaseIfPossibleNode({ id, @@ -60,16 +60,16 @@ export function AddToCaseIfPossibleNode({ inboxes={inboxes} isCreateInboxAvailable={isCreateInboxAvailable} /> -

- - {t( - 'workflows:detail_panel.add_to_case_if_possible.default_name.helper', - )} - - - {defaultCaseName} - -

+ + { + updateNode(id, { ...data, caseName: astNode }); + }} + /> ); } diff --git a/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/CaseNameEditor.hook.ts b/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/CaseNameEditor.hook.ts new file mode 100644 index 00000000..0ed32164 --- /dev/null +++ b/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/CaseNameEditor.hook.ts @@ -0,0 +1,23 @@ +import { + NewPayloadAstNode, + NewStringTemplateAstNode, +} from '@app-builder/models'; +import { useMemo } from 'react'; + +import { defaultCaseName } from './shared'; + +export const useDefaultCaseName = (triggerObjectType: string) => { + const defaultCaseTemplate = defaultCaseName.replace( + '%trigger_object_type%', + triggerObjectType, + ); + const defaultCaseNameNode = useMemo(() => { + return NewStringTemplateAstNode(defaultCaseTemplate, { + object_id: NewPayloadAstNode('object_id'), + }); + }, [defaultCaseTemplate]); + + return { + defaultCaseNameNode, + }; +}; diff --git a/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/CaseNameEditor.tsx b/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/CaseNameEditor.tsx new file mode 100644 index 00000000..7540ac50 --- /dev/null +++ b/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/CaseNameEditor.tsx @@ -0,0 +1,139 @@ +import { + type AstNode, + isStringTemplateAstNode, + NewStringTemplateAstNode, + type StringTemplateAstNode, +} from '@app-builder/models'; +import { useCurrentScenario } from '@app-builder/routes/_builder+/scenarios+/$scenarioId+/_layout'; +import { useAstValidationFetcher } from '@app-builder/routes/ressources+/scenarios+/$scenarioId+/validate-ast'; +import { useTriggerObjectTable } from '@app-builder/services/editor/options'; +import { + Fragment, + type ReactNode, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import * as R from 'remeda'; +import { Button, ModalV2 } from 'ui-design-system'; +import { Icon } from 'ui-icons'; + +import { StringTemplateEdit } from '../../AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/StringTemplateEdit'; +import { + STRING_TEMPLATE_VARIABLE_CAPTURE_REGEXP, + STRING_TEMPLATE_VARIABLE_REGEXP, +} from '../../AstBuilder/AstBuilderNode/Operand/OperandEditor/OperandEditModal/StringTemplateEdit/StringTemplateEdit.hook'; +import { useDefaultCaseName } from './CaseNameEditor.hook'; + +export type CaseNameEditorProps = { + label: string; + value: StringTemplateAstNode | null | undefined; + onChange: (astNode: StringTemplateAstNode | null) => void; +}; + +export const CaseNameEditor = ({ + label, + value, + onChange, +}: CaseNameEditorProps) => { + const { t } = useTranslation(['scenarios']); + const triggerObjectTable = useTriggerObjectTable(); + const [isEditing, setIsEditing] = useState(false); + const { defaultCaseNameNode } = useDefaultCaseName(triggerObjectTable.name); + const currentScenario = useCurrentScenario(); + const { validate, validation } = useAstValidationFetcher(currentScenario.id); + const initialValueRef = useRef(value); + const handleValidation = useMemo(() => { + return R.debounce((astNode: AstNode) => validate(astNode, 'string'), { + waitMs: 300, + }).call; + }, [validate]); + + const caseNameContent = value ? getAstNodeDisplayElement(value) : ''; + const isDefaultCaseName = useMemo(() => { + return value + ? R.isDeepEqual(value, initialValueRef.current ?? defaultCaseNameNode) + : true; + }, [value, initialValueRef, defaultCaseNameNode]); + + const handleAstNodeChange = (newAstNode: AstNode) => { + if (isStringTemplateAstNode(newAstNode)) { + onChange(newAstNode); + } + setIsEditing(false); + }; + + useEffect(() => { + if (!value) { + onChange(defaultCaseNameNode); + } + }, [onChange, value, defaultCaseNameNode]); + + return ( + <> +
{label}
+
+ + {!isDefaultCaseName ? ( + + ) : null} + { + event.stopPropagation(); + // Prevent people from losing their work by clicking accidentally outside the modal + return false; + }} + open={isEditing} + onClose={() => setIsEditing(false)} + size="medium" + > + + +
+ + ); +}; + +function getAstNodeDisplayElement(astNode: StringTemplateAstNode): ReactNode { + const template = astNode.children[0]?.constant ?? ''; + const splittedTemplate = template.split( + STRING_TEMPLATE_VARIABLE_CAPTURE_REGEXP, + ); + + return ( + + {splittedTemplate.map((el, i) => + STRING_TEMPLATE_VARIABLE_REGEXP.test(el) ? ( + + {el} + + ) : ( + {el} + ), + )} + + ); +} diff --git a/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/CreateCaseNode.tsx b/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/CreateCaseNode.tsx index ce83bc57..326e6f99 100644 --- a/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/CreateCaseNode.tsx +++ b/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/CreateCaseNode.tsx @@ -8,8 +8,8 @@ import { useWorkflowData, useWorkflowDataFeatureAccess, } from '../WorkflowProvider'; +import { CaseNameEditor } from './CaseNameEditor'; import { SelectInbox } from './SelectInbox'; -import { defaultCaseName } from './shared'; export function CreateCaseNode({ id, @@ -34,14 +34,13 @@ export function CreateCaseNode({ inboxes={inboxes} isCreateInboxAvailable={isCreateInboxAvailable} /> -

- - {t('workflows:detail_panel.create_case.default_name.helper')} - - - {defaultCaseName} - -

+ { + updateNode(id, { ...data, caseName: astNode }); + }} + /> ); } diff --git a/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/DetailPanel.tsx b/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/DetailPanel.tsx index c8a0ccac..8c3c44e9 100644 --- a/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/DetailPanel.tsx +++ b/packages/app-builder/src/components/Scenario/Workflow/DetailPanel/DetailPanel.tsx @@ -237,6 +237,7 @@ function CreateActionNode({ id }: { id: string }) { updateNode(id, { type: 'create-case', inboxId: null, + caseName: null, }); }} > @@ -250,6 +251,7 @@ function CreateActionNode({ id }: { id: string }) { updateNode(id, { type: 'add-to-case-if-possible', inboxId: null, + caseName: null, }); }} > diff --git a/packages/app-builder/src/components/Scenario/Workflow/models/nodes.ts b/packages/app-builder/src/components/Scenario/Workflow/models/nodes.ts index 433861de..c3be69f8 100644 --- a/packages/app-builder/src/components/Scenario/Workflow/models/nodes.ts +++ b/packages/app-builder/src/components/Scenario/Workflow/models/nodes.ts @@ -1,3 +1,4 @@ +import { type StringTemplateAstNode } from '@app-builder/models'; import { type Outcome } from '@app-builder/models/outcome'; import { nanoid } from 'nanoid'; import { useTranslation } from 'react-i18next'; @@ -28,6 +29,7 @@ export function isTriggerData(data: NodeData): data is TriggerData { export interface CreateCaseAction { type: 'create-case'; inboxId: string | null; + caseName?: StringTemplateAstNode | null; } export function isCreateCaseAction(data: NodeData): data is CreateCaseAction { @@ -37,6 +39,7 @@ export function isCreateCaseAction(data: NodeData): data is CreateCaseAction { export interface AddToCaseIfPossibleAction { type: 'add-to-case-if-possible'; inboxId: string | null; + caseName?: StringTemplateAstNode | null; } export function isAddToCaseIfPossibleAction( diff --git a/packages/app-builder/src/components/Scenario/Workflow/models/validation.ts b/packages/app-builder/src/components/Scenario/Workflow/models/validation.ts index 85947fbf..1700d4ae 100644 --- a/packages/app-builder/src/components/Scenario/Workflow/models/validation.ts +++ b/packages/app-builder/src/components/Scenario/Workflow/models/validation.ts @@ -117,11 +117,13 @@ export type ValidDecisionCreatedTrigger = z.infer< export const createCaseActionSchema = z.object({ inboxId: z.string(), + caseName: z.any(), }); export type ValidCreateCaseAction = z.infer; export const addToCaseIfPossibleActionSchema = z.object({ inboxId: z.string(), + caseName: z.any(), }); export type ValidAddToCaseIfPossibleAction = z.infer< typeof addToCaseIfPossibleActionSchema @@ -164,6 +166,7 @@ export function adaptValidWorkflow( }, action: { inboxId: scenario.decisionToCaseInboxId, + caseName: scenario.decisionToCaseNameTemplate, }, }; } @@ -187,6 +190,7 @@ export function adaptScenarioUpdateWorkflowInput( return { decisionToCaseWorkflowType: workflow.type, decisionToCaseInboxId: workflow.action.inboxId, + decisionToCaseNameTemplate: workflow.action.caseName, decisionToCaseOutcomes: workflow.trigger.outcomes, }; } diff --git a/packages/app-builder/src/locales/ar/scenarios.json b/packages/app-builder/src/locales/ar/scenarios.json index 534860b5..ef035a07 100644 --- a/packages/app-builder/src/locales/ar/scenarios.json +++ b/packages/app-builder/src/locales/ar/scenarios.json @@ -361,5 +361,9 @@ "edit_is_multiple_of.title": "متعددة من العتبة", "testrun.cancel.callout": "هل أنت متأكد أنك تريد إلغاء الاختبار الحالي؟", "testrun.cancel": "أوقف الاختبار", - "testrun.description": "اختبار ومقارنة نسخة السيناريو مع نسخة حية" + "testrun.description": "اختبار ومقارنة نسخة السيناريو مع نسخة حية", + "edit_string_template.template_field.label": "نموذج", + "edit_string_template.template_field.placeholder": "اكتب نصًا يحتوي على %variable% فيه", + "edit_string_template.title": "قالب السلسلة", + "edit_string_template.variables.label": "المتغيرات" } diff --git a/packages/app-builder/src/locales/ar/workflows.json b/packages/app-builder/src/locales/ar/workflows.json index 23413520..44d6f784 100644 --- a/packages/app-builder/src/locales/ar/workflows.json +++ b/packages/app-builder/src/locales/ar/workflows.json @@ -30,10 +30,8 @@ "detail_panel.inbox.label": "الصندوق الوارد", "detail_panel.inbox.placeholder": "اختيار الصندوق الوارد", "detail_panel.inbox.need_inbox_contact_admin": "يرجى التواصل مع المسؤول لإنشاء صندوق وارد", - "detail_panel.create_case.default_name.helper": "الاسم الافتراضي يجب أن يتبع هذا النموذج:", "detail_panel.add_to_case_if_possible.description": "في الصندوق الوارد المحدد، إذا كانت الحالة المفتوحة تتناسب مع قيمة المحور للقرار، أضف القرار إليها. إذا لم توجد حالة مطابقة، ستُنشأ حالة جديدة تلقائيًا باسم افتراضي.", "detail_panel.add_to_case_if_possible.no_pivot": "تحتاج إلى تحديد قيمة المحور لتضمين القرار في الحالة.", - "detail_panel.add_to_case_if_possible.default_name.helper": "إذا لم يتم العثور على حالة متطابقة، تتبع اسم الحالة المُنشأة لهذا القالب:", "detail_panel.checklist.title": "قائمة التدقيق", "detail_panel.checklist.description": "التاكد من معالجة جميع المشاكل قبل حفظ سير العمل.", "detail_panel.checklist.no_issues": "لا توجد مشكلة", @@ -53,5 +51,7 @@ "detail_panel.confirm_delete_workflow.title": "حذف سير العمل", "detail_panel.confirm_delete_workflow.description": "هل أنت متأكد من حذف سير العمل هذا؟", "toast.success.create_workflow": " تم إعداد سير العمل بنجاح.", - "toast.success.delete_workflow": "تم حذف سير العمل بنجاح." + "toast.success.delete_workflow": "تم حذف سير العمل بنجاح.", + "detail_panel.add_to_case_if_possible.case_name.label": "اسم الحالة إذا لم يتم العثور على حالة مطابقة", + "detail_panel.create_case.case_name.label": "اسم الحالة" } diff --git a/packages/app-builder/src/locales/en/scenarios.json b/packages/app-builder/src/locales/en/scenarios.json index 34056f39..92907174 100644 --- a/packages/app-builder/src/locales/en/scenarios.json +++ b/packages/app-builder/src/locales/en/scenarios.json @@ -350,6 +350,10 @@ "edit_is_multiple_of.examples.caption": "Examples", "edit_is_multiple_of.examples.value": "Value", "edit_is_multiple_of.examples.result": "Result", + "edit_string_template.title": "String template", + "edit_string_template.template_field.label": "Template", + "edit_string_template.template_field.placeholder": "Type a text containing a %variable% in it", + "edit_string_template.variables.label": "Variables", "scheduled_execution.number_of_created_decisions": "Decisions created", "scheduled_execution.number_of_evaluated_decisions": "Decisions evaluated", "scheduled_execution.number_of_planned_decisions": "Decisions to do", diff --git a/packages/app-builder/src/locales/en/workflows.json b/packages/app-builder/src/locales/en/workflows.json index 91516510..ef346bca 100644 --- a/packages/app-builder/src/locales/en/workflows.json +++ b/packages/app-builder/src/locales/en/workflows.json @@ -30,10 +30,10 @@ "detail_panel.inbox.label": "Inbox", "detail_panel.inbox.placeholder": "Select an inbox", "detail_panel.inbox.need_inbox_contact_admin": "Please contact your admin to create an inbox", - "detail_panel.create_case.default_name.helper": "The default name should follow this template:", + "detail_panel.create_case.case_name.label": "Case name", "detail_panel.add_to_case_if_possible.description": "In the selected inbox, if an open case matches the decision’s pivot value, add the decision to it. If no matching case is found, a new case will be created with a default name.", "detail_panel.add_to_case_if_possible.no_pivot": "You need to define a pivot value to add the decision to a case.", - "detail_panel.add_to_case_if_possible.default_name.helper": "If no matching case is found, the created case name should follow this template:", + "detail_panel.add_to_case_if_possible.case_name.label": "Case name if no matching case is found", "detail_panel.checklist.title": "Checklist", "detail_panel.checklist.description": "Make sure all issues are resolved before saving the workflow.", "detail_panel.checklist.no_issues": "No issues found", diff --git a/packages/app-builder/src/locales/fr/scenarios.json b/packages/app-builder/src/locales/fr/scenarios.json index ffd1a9d4..c77804bd 100644 --- a/packages/app-builder/src/locales/fr/scenarios.json +++ b/packages/app-builder/src/locales/fr/scenarios.json @@ -361,5 +361,9 @@ "testrun.filters.hit": "Déclenchée", "testrun.cancel.callout": "Etes-vous sûr de vouloir arrêter le test en cours ?", "testrun.cancel": "Arrêtez le test", - "testrun.description": "Testez et comparez une version de scénario avec une version en direct" + "testrun.description": "Testez et comparez une version de scénario avec une version en direct", + "edit_string_template.template_field.label": "Modèle", + "edit_string_template.template_field.placeholder": "Tapez un texte contenant une %variable%", + "edit_string_template.title": "Modèle de texte", + "edit_string_template.variables.label": "Variables" } diff --git a/packages/app-builder/src/locales/fr/workflows.json b/packages/app-builder/src/locales/fr/workflows.json index 2513ece4..5960f6a3 100644 --- a/packages/app-builder/src/locales/fr/workflows.json +++ b/packages/app-builder/src/locales/fr/workflows.json @@ -7,7 +7,6 @@ "action_node.create_case.empty_content": "Sélectionnez les informations manquantes pour terminer la configuration.", "action_node.create_case.entity": "investigation", "action_node.create_case.title": "Créer une nouvelle investigation", - "detail_panel.add_to_case_if_possible.default_name.helper": "Si aucune investigation correspondante n'est trouvée, le nom de l'investigation créée suivra ce modèle :", "detail_panel.add_to_case_if_possible.description": "Dans la boîte de réception sélectionnée, si une investigation ouverte correspond à la valeur pivot de la décision, ajoutez-y la décision. \nSi aucune investigation correspondante n'est trouvé, une nouvelle investigation sera créée avec un nom par défaut.", "detail_panel.add_to_case_if_possible.no_pivot": "Vous devez définir une valeur pivot pour ajouter la décision à une investigation.", "detail_panel.checklist.description": "Assurez-vous que tous les problèmes sont résolus avant d'enregistrer l'automatisation.", @@ -30,7 +29,6 @@ "detail_panel.confirm_delete_workflow.title": "Supprimer l'automatisation", "detail_panel.create_action_node.description": "Une action est une tâche exécutée par l'automatisation. \nChoisissez une action à ajouter à l'automatisation.", "detail_panel.create_action_node.title": "Choisissez une action", - "detail_panel.create_case.default_name.helper": "Le nom par défaut doit suivre ce modèle :", "detail_panel.create_case.description": "Créez une nouvelle investigation avec la décision créée. \nL'investigation sera créée dans la boîte de réception sélectionnée avec un nom par défaut.", "detail_panel.create_trigger_node.description": "Un déclencheur est un événement qui démarre l'automatisation. \nChoisissez un déclencheur pour démarrer l'automatisation.", "detail_panel.create_trigger_node.title": "Choisissez un déclencheur", @@ -53,5 +51,7 @@ "trigger_node.decision_created.content": "Sur {{scenarioName}} lorsque le résultat est :", "trigger_node.decision_created.empty_content": "Sélectionnez les informations manquantes pour terminer la configuration.", "trigger_node.decision_created.entity": "décision", - "trigger_node.decision_created.title": "Décision créée" + "trigger_node.decision_created.title": "Décision créée", + "detail_panel.add_to_case_if_possible.case_name.label": "Nom de l'investigation si aucune investigation correspondante n'est trouvée", + "detail_panel.create_case.case_name.label": "Nom de l'investigation" } diff --git a/packages/app-builder/src/models/ast-node.ts b/packages/app-builder/src/models/ast-node.ts index bea84128..95693225 100644 --- a/packages/app-builder/src/models/ast-node.ts +++ b/packages/app-builder/src/models/ast-node.ts @@ -191,6 +191,14 @@ export interface PayloadAstNode { namedChildren: Record; } +export function NewPayloadAstNode(field: string): PayloadAstNode { + return { + name: payloadAstNodeName, + children: [NewConstantAstNode({ constant: field })], + namedChildren: {}, + }; +} + export const customListAccessAstNodeName = 'CustomListAccess'; export interface CustomListAccessAstNode { name: typeof customListAccessAstNodeName; @@ -446,6 +454,26 @@ export function NewIsMultipleOfAstNode( }; } +export const stringTemplateAstNodeName = 'StringTemplate'; +export interface StringTemplateAstNode { + name: typeof stringTemplateAstNodeName; + constant?: undefined; + children: ConstantAstNode[]; + namedChildren: Record; +} + +export function NewStringTemplateAstNode( + template: string = '', + variables: Record = {}, +): StringTemplateAstNode { + return { + name: stringTemplateAstNodeName, + constant: undefined, + children: [NewConstantAstNode({ constant: template })], + namedChildren: variables, + }; +} + export function isDatabaseAccess(node: AstNode): node is DatabaseAccessAstNode { return node.name === databaseAccessAstNodeName; } @@ -508,11 +536,18 @@ export function isIsMultipleOf(node: AstNode): node is IsMultipleOfAstNode { return node.name === isMultipleOfAstNodeName; } +export function isStringTemplateAstNode( + node: AstNode, +): node is StringTemplateAstNode { + return node.name === stringTemplateAstNodeName; +} + export type EditableAstNode = | AggregationAstNode | TimeAddAstNode | FuzzyMatchComparatorAstNode - | IsMultipleOfAstNode; + | IsMultipleOfAstNode + | StringTemplateAstNode; /** * Check if the node is editable in a dedicated modal @@ -525,7 +560,8 @@ export function isEditableAstNode(node: AstNode): node is EditableAstNode { isTimeAdd(node) || isFuzzyMatchComparator(node) || isTimestampExtract(node) || - isIsMultipleOf(node) + isIsMultipleOf(node) || + isStringTemplateAstNode(node) ); } diff --git a/packages/app-builder/src/models/node-evaluation.ts b/packages/app-builder/src/models/node-evaluation.ts index 16ca645e..29a3b339 100644 --- a/packages/app-builder/src/models/node-evaluation.ts +++ b/packages/app-builder/src/models/node-evaluation.ts @@ -16,6 +16,8 @@ export type ReturnValue = isOmitted: true; }; +export type ReturnValueType = 'string' | 'int' | 'float' | 'bool'; + export type NonOmittedReturnValue = { value: ConstantType; isOmitted: false }; export function hasReturnValue( diff --git a/packages/app-builder/src/models/scenario.ts b/packages/app-builder/src/models/scenario.ts index 3a0b40b9..97724f0b 100644 --- a/packages/app-builder/src/models/scenario.ts +++ b/packages/app-builder/src/models/scenario.ts @@ -5,6 +5,12 @@ import { } from 'marble-api'; import * as z from 'zod'; +import { + adaptAstNode, + adaptNodeDto, + isStringTemplateAstNode, + type StringTemplateAstNode, +} from './ast-node'; import { type Outcome, outcomes } from './outcome'; type DecisionToCaseWorkflowType = @@ -18,6 +24,7 @@ export interface Scenario { decisionToCaseInboxId?: string; decisionToCaseOutcomes: Outcome[]; decisionToCaseWorkflowType: DecisionToCaseWorkflowType; + decisionToCaseNameTemplate: StringTemplateAstNode | null; description: string; liveVersionId?: string; name: string; @@ -26,12 +33,20 @@ export interface Scenario { } export function adaptScenario(dto: ScenarioDto): Scenario { + const caseNameAstNode = dto.decision_to_case_name_template + ? adaptAstNode(dto.decision_to_case_name_template) + : null; + return { id: dto.id, createdAt: dto.created_at, decisionToCaseInboxId: dto.decision_to_case_inbox_id, decisionToCaseOutcomes: dto.decision_to_case_outcomes, decisionToCaseWorkflowType: dto.decision_to_case_workflow_type, + decisionToCaseNameTemplate: + caseNameAstNode && isStringTemplateAstNode(caseNameAstNode) + ? caseNameAstNode + : null, description: dto.description, liveVersionId: dto.live_version_id, name: dto.name, @@ -63,11 +78,13 @@ export const scenarioUpdateWorkflowInputSchema = z.discriminatedUnion( decisionToCaseWorkflowType: z.literal('CREATE_CASE'), decisionToCaseInboxId: z.string(), decisionToCaseOutcomes: z.array(z.enum(outcomes)), + decisionToCaseNameTemplate: z.any(), }), z.object({ decisionToCaseWorkflowType: z.literal('ADD_TO_CASE_IF_POSSIBLE'), decisionToCaseInboxId: z.string(), decisionToCaseOutcomes: z.array(z.enum(outcomes)), + decisionToCaseNameTemplate: z.any(), }), z.object({ decisionToCaseWorkflowType: z.literal('DISABLED'), @@ -91,5 +108,8 @@ export function adaptScenarioUpdateInputDto( decision_to_case_inbox_id: input.decisionToCaseInboxId, decision_to_case_outcomes: input.decisionToCaseOutcomes, decision_to_case_workflow_type: input.decisionToCaseWorkflowType, + decision_to_case_name_template: adaptNodeDto( + input.decisionToCaseNameTemplate, + ), }; } diff --git a/packages/app-builder/src/repositories/ScenarioRepository.ts b/packages/app-builder/src/repositories/ScenarioRepository.ts index 00293bd5..ce8d6c15 100644 --- a/packages/app-builder/src/repositories/ScenarioRepository.ts +++ b/packages/app-builder/src/repositories/ScenarioRepository.ts @@ -8,6 +8,11 @@ import { isStatusConflictHttpError, type ScenarioValidation, } from '@app-builder/models'; +import { + adaptNodeEvaluation, + type NodeEvaluation, + type ReturnValueType, +} from '@app-builder/models/node-evaluation'; import { adaptSnoozesOfIteration, type SnoozesOfIteration, @@ -70,6 +75,10 @@ export interface ScenarioRepository { rule: AstNode; ruleId: string; }): Promise; + validateAst( + scenarioId: string, + payload: { node: AstNode; expectedReturnType?: ReturnValueType }, + ): Promise; commitScenarioIteration(args: { iterationId: string; }): Promise; @@ -166,6 +175,17 @@ export function makeGetScenarioRepository() { ruleId, ); }, + validateAst: async (scenarioId, { node, expectedReturnType }) => { + const { ast_validation } = await marbleCoreApiClient.validateAstNode( + scenarioId, + { + node: adaptNodeDto(node), + expected_return_type: expectedReturnType, + }, + ); + + return adaptNodeEvaluation(ast_validation); + }, commitScenarioIteration: async ({ iterationId }) => { const { iteration } = await marbleCoreApiClient.commitScenarioIteration(iterationId); diff --git a/packages/app-builder/src/routes/_builder+/scenarios+/$scenarioId+/_layout.tsx b/packages/app-builder/src/routes/_builder+/scenarios+/$scenarioId+/_layout.tsx index 86124edf..a2371eb0 100644 --- a/packages/app-builder/src/routes/_builder+/scenarios+/$scenarioId+/_layout.tsx +++ b/packages/app-builder/src/routes/_builder+/scenarios+/$scenarioId+/_layout.tsx @@ -3,11 +3,7 @@ import { adaptScenarioIterationWithType } from '@app-builder/models/scenario-ite import { serverServices } from '@app-builder/services/init.server'; import { getRoute, type RouteID } from '@app-builder/utils/routes'; import { fromParams } from '@app-builder/utils/short-uuid'; -import { - json, - type LoaderFunctionArgs, - type SerializeFrom, -} from '@remix-run/node'; +import { type LoaderFunctionArgs, type SerializeFrom } from '@remix-run/node'; import { Outlet, useRouteError, useRouteLoaderData } from '@remix-run/react'; import { captureRemixErrorBoundaryError } from '@sentry/remix'; import { type Namespace } from 'i18next'; @@ -31,12 +27,12 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }), ]); - return json({ + return { currentScenario, scenarioIterations: scenarioIterations.map((dto) => adaptScenarioIterationWithType(dto, currentScenario.liveVersionId), ), - }); + }; } export function useScenarioIterations() { diff --git a/packages/app-builder/src/routes/_builder+/scenarios+/$scenarioId+/workflow.tsx b/packages/app-builder/src/routes/_builder+/scenarios+/$scenarioId+/workflow.tsx index fa68e490..99ac91f6 100644 --- a/packages/app-builder/src/routes/_builder+/scenarios+/$scenarioId+/workflow.tsx +++ b/packages/app-builder/src/routes/_builder+/scenarios+/$scenarioId+/workflow.tsx @@ -17,11 +17,11 @@ import { type ScenarioUpdateWorkflowInput, scenarioUpdateWorkflowInputSchema, } from '@app-builder/models/scenario'; +import { OptionsProvider } from '@app-builder/services/editor/options'; import { serverServices } from '@app-builder/services/init.server'; import { getRoute } from '@app-builder/utils/routes'; import { fromParams, fromUUID } from '@app-builder/utils/short-uuid'; import { - json, type LinksFunction, type LoaderFunctionArgs, redirect, @@ -53,10 +53,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) { ); } - const { user, scenario, inbox, dataModelRepository } = - await authService.isAuthenticated(request, { - failureRedirect: getRoute('/sign-in'), - }); + const { + user, + scenario, + inbox, + dataModelRepository, + editor, + customListsRepository, + } = await authService.isAuthenticated(request, { + failureRedirect: getRoute('/sign-in'), + }); const [scenarios, inboxes, pivotValues] = await Promise.all([ scenario.listScenarios(), @@ -70,14 +76,32 @@ export async function loader({ request, params }: LoaderFunctionArgs) { (pivot) => pivot.baseTable === currentScenario?.triggerObjectType, ); - return json({ + const [operators, accessors, dataModel, customLists] = await Promise.all([ + editor.listOperators({ + scenarioId, + }), + editor.listAccessors({ + scenarioId, + }), + dataModelRepository.getDataModel(), + customListsRepository.listCustomLists(), + ]); + + return { scenarios, inboxes, hasPivotValue, workflowDataFeatureAccess: { isCreateInboxAvailable: featureAccessService.isCreateInboxAvailable(user), }, - }); + builderOptions: { + databaseAccessors: accessors.databaseAccessors, + payloadAccessors: accessors.payloadAccessors, + operators, + dataModel, + customLists, + }, + }; } export async function action({ request, params }: LoaderFunctionArgs) { @@ -104,9 +128,25 @@ export async function action({ request, params }: LoaderFunctionArgs) { const input = scenarioUpdateWorkflowInputSchema.parse(await request.json()); - await scenario.updateScenarioWorkflow(scenarioId, input); - const session = await getSession(request); + + try { + await scenario.updateScenarioWorkflow(scenarioId, input); + } catch { + setToastMessage(session, { + type: 'error', + message: 'Something went wrong', + }); + return redirect( + getRoute('/scenarios/:scenarioId/workflow', { + scenarioId: fromUUID(scenarioId), + }), + { + headers: { 'Set-Cookie': await commitSession(session) }, + }, + ); + } + const t = await getFixedT(request, ['workflows']); setToastMessage(session, { type: 'success', @@ -127,8 +167,13 @@ export async function action({ request, params }: LoaderFunctionArgs) { } export default function Workflow() { - const { scenarios, inboxes, hasPivotValue, workflowDataFeatureAccess } = - useLoaderData(); + const { + scenarios, + inboxes, + hasPivotValue, + workflowDataFeatureAccess, + builderOptions, + } = useLoaderData(); const currentScenario = useCurrentScenario(); const initialWorkflow = adaptValidWorkflow(currentScenario); @@ -177,7 +222,14 @@ export default function Workflow() { >
- + + +
diff --git a/packages/app-builder/src/routes/ressources+/scenarios+/$scenarioId+/$iterationId+/validate-with-given-trigger-or-rule.tsx b/packages/app-builder/src/routes/ressources+/scenarios+/$scenarioId+/$iterationId+/validate-with-given-trigger-or-rule.tsx index 6c63cb3b..5785fb58 100644 --- a/packages/app-builder/src/routes/ressources+/scenarios+/$scenarioId+/$iterationId+/validate-with-given-trigger-or-rule.tsx +++ b/packages/app-builder/src/routes/ressources+/scenarios+/$scenarioId+/$iterationId+/validate-with-given-trigger-or-rule.tsx @@ -2,7 +2,7 @@ import { type AstNode } from '@app-builder/models'; import { serverServices } from '@app-builder/services/init.server'; import { getRoute } from '@app-builder/utils/routes'; import { fromParams, fromUUID } from '@app-builder/utils/short-uuid'; -import { type ActionFunctionArgs, json } from '@remix-run/node'; +import { type ActionFunctionArgs } from '@remix-run/node'; import { useFetcher } from '@remix-run/react'; import { useCallback } from 'react'; @@ -21,12 +21,12 @@ export async function action({ request, params }: ActionFunctionArgs) { if ('rule' in body) { const validation = await scenario.validateRule({ iterationId, ...body }); - return json(validation.ruleEvaluation); + return validation.ruleEvaluation; } const validation = await scenario.validateTrigger({ iterationId, ...body }); - return json(validation.triggerEvaluation); + return validation.triggerEvaluation; } type TriggerValidationArgs = { diff --git a/packages/app-builder/src/routes/ressources+/scenarios+/$scenarioId+/validate-ast.tsx b/packages/app-builder/src/routes/ressources+/scenarios+/$scenarioId+/validate-ast.tsx new file mode 100644 index 00000000..28e0b5a8 --- /dev/null +++ b/packages/app-builder/src/routes/ressources+/scenarios+/$scenarioId+/validate-ast.tsx @@ -0,0 +1,58 @@ +import { type AstNode } from '@app-builder/models'; +import { type ReturnValueType } from '@app-builder/models/node-evaluation'; +import { serverServices } from '@app-builder/services/init.server'; +import { getRoute } from '@app-builder/utils/routes'; +import { fromParams, fromUUID } from '@app-builder/utils/short-uuid'; +import { type ActionFunctionArgs } from '@remix-run/node'; +import { useFetcher } from '@remix-run/react'; +import { useCallback } from 'react'; + +type AstValidationPayload = { + node: AstNode; + expectedReturnType?: ReturnValueType; +}; + +export async function action({ request, params }: ActionFunctionArgs) { + const { authService } = serverServices; + const { scenario } = await authService.isAuthenticated(request, { + failureRedirect: getRoute('/sign-in'), + }); + + const scenarioId = fromParams(params, 'scenarioId'); + const body = (await request.json()) as AstValidationPayload; + + const res = await scenario.validateAst(scenarioId, { + node: body.node, + expectedReturnType: body.expectedReturnType, + }); + + console.dir(res, { depth: null }); + + return res; +} + +export function useAstValidationFetcher(scenarioId: string) { + const { submit, data } = useFetcher(); + + const validate = useCallback( + (ast: AstNode, expectedReturnType?: ReturnValueType) => { + const args: AstValidationPayload = { + node: ast, + expectedReturnType, + }; + submit(args, { + method: 'POST', + encType: 'application/json', + action: getRoute('/ressources/scenarios/:scenarioId/validate-ast', { + scenarioId: fromUUID(scenarioId), + }), + }); + }, + [submit, scenarioId], + ); + + return { + validate, + validation: data ?? undefined, + }; +} diff --git a/packages/app-builder/src/services/ast-node/getAstNodeDataType.ts b/packages/app-builder/src/services/ast-node/getAstNodeDataType.ts index dd67cdd9..28ea16d4 100644 --- a/packages/app-builder/src/services/ast-node/getAstNodeDataType.ts +++ b/packages/app-builder/src/services/ast-node/getAstNodeDataType.ts @@ -6,6 +6,7 @@ import { isDataAccessorAstNode, isFuzzyMatchComparator, isIsMultipleOf, + isStringTemplateAstNode, isTimeAdd, isTimeNow, isTimestampExtract, @@ -57,6 +58,10 @@ export function getAstNodeDataType( return field.dataType; } + if (isStringTemplateAstNode(astNode)) { + return 'String'; + } + if (isFuzzyMatchComparator(astNode) || isIsMultipleOf(astNode)) { return 'Bool'; } diff --git a/packages/app-builder/src/services/ast-node/getAstNodeDisplayName.ts b/packages/app-builder/src/services/ast-node/getAstNodeDisplayName.ts index 4c1e5097..05a2eebb 100644 --- a/packages/app-builder/src/services/ast-node/getAstNodeDisplayName.ts +++ b/packages/app-builder/src/services/ast-node/getAstNodeDisplayName.ts @@ -10,10 +10,12 @@ import { isIsMultipleOf, type IsMultipleOfAstNode, isPayload, + isStringTemplateAstNode, isTimeAdd, isTimeNow, isTimestampExtract, isUndefinedAstNode, + type StringTemplateAstNode, type TimeAddAstNode, type TimestampExtractAstNode, } from '@app-builder/models'; @@ -84,6 +86,10 @@ export function getAstNodeDisplayName( return getIsMultipleOfDisplayName(astNode, context); } + if (isStringTemplateAstNode(astNode)) { + return getStringTemplateDisplayName(astNode, context); + } + if (isUndefinedAstNode(astNode)) { return ''; } @@ -257,3 +263,14 @@ function getIsMultipleOfDisplayName( }, }); } + +function getStringTemplateDisplayName( + astNode: StringTemplateAstNode, + context: AstNodeStringifierContext, +) { + const value = astNode.children[0]?.constant ?? ''; + if (!value) { + return context.t('scenarios:edit_string_template.title'); + } + return value; +} diff --git a/packages/app-builder/src/services/ast-node/getAstNodeOperandType.ts b/packages/app-builder/src/services/ast-node/getAstNodeOperandType.ts index 0b108ab3..98df5917 100644 --- a/packages/app-builder/src/services/ast-node/getAstNodeOperandType.ts +++ b/packages/app-builder/src/services/ast-node/getAstNodeOperandType.ts @@ -8,6 +8,7 @@ import { isDataAccessorAstNode, isFuzzyMatchComparator, isIsMultipleOf, + isStringTemplateAstNode, isTimeAdd, isTimeNow, isTimestampExtract, @@ -53,7 +54,8 @@ export function getAstNodeOperandType( isTimeNow(astNode) || isFuzzyMatchComparator(astNode) || isTimestampExtract(astNode) || - isIsMultipleOf(astNode) + isIsMultipleOf(astNode) || + isStringTemplateAstNode(astNode) ) { return 'Function'; } diff --git a/packages/app-builder/src/utils/routes/routes.ts b/packages/app-builder/src/utils/routes/routes.ts index fda3cb57..d39e96f1 100644 --- a/packages/app-builder/src/utils/routes/routes.ts +++ b/packages/app-builder/src/utils/routes/routes.ts @@ -480,6 +480,11 @@ export const routes = [ "path": "ressources/scenarios/:scenarioId/testrun/create", "file": "routes/ressources+/scenarios+/$scenarioId+/testrun+/create.tsx" }, + { + "id": "routes/ressources+/scenarios+/$scenarioId+/validate-ast", + "path": "ressources/scenarios/:scenarioId/validate-ast", + "file": "routes/ressources+/scenarios+/$scenarioId+/validate-ast.tsx" + }, { "id": "routes/ressources+/scenarios+/create", "path": "ressources/scenarios/create", diff --git a/packages/app-builder/src/utils/routes/types.ts b/packages/app-builder/src/utils/routes/types.ts index 46772011..0c1e962c 100644 --- a/packages/app-builder/src/utils/routes/types.ts +++ b/packages/app-builder/src/utils/routes/types.ts @@ -82,6 +82,7 @@ export type RoutePath = | '/ressources/scenarios/:scenarioId/:iterationId/validate-with-given-trigger-or-rule' | '/ressources/scenarios/:scenarioId/testrun/:testRunId/cancel' | '/ressources/scenarios/:scenarioId/testrun/create' + | '/ressources/scenarios/:scenarioId/validate-ast' | '/ressources/scenarios/create' | '/ressources/scenarios/update' | '/ressources/settings/api-keys/create' @@ -209,6 +210,7 @@ export type RouteID = | 'routes/ressources+/scenarios+/$scenarioId+/$iterationId+/validate-with-given-trigger-or-rule' | 'routes/ressources+/scenarios+/$scenarioId+/testrun+/$testRunId+/cancel' | 'routes/ressources+/scenarios+/$scenarioId+/testrun+/create' + | 'routes/ressources+/scenarios+/$scenarioId+/validate-ast' | 'routes/ressources+/scenarios+/create' | 'routes/ressources+/scenarios+/update' | 'routes/ressources+/settings+/api-keys+/create' diff --git a/packages/marble-api/openapis/marblecore-api.yaml b/packages/marble-api/openapis/marblecore-api.yaml index c2f5a6f0..84fe657f 100644 --- a/packages/marble-api/openapis/marblecore-api.yaml +++ b/packages/marble-api/openapis/marblecore-api.yaml @@ -1314,6 +1314,46 @@ paths: $ref: '#/components/responses/403' '404': $ref: '#/components/responses/404' + /scenarios/{scenarioId}/validate-ast: + post: + tags: + - Scenarios + summary: Validate an AST + operationId: validateAstNode + security: + - bearerAuth: [] + parameters: + - name: scenarioId + description: ID of the scenario for which you need to validate the AST + in: path + required: true + schema: + type: string + format: uuid + requestBody: + description: The AST to validate + content: + application/json: + schema: + $ref: '#/components/schemas/ScenarioAstValidateInputDto' + responses: + '200': + description: The evaluation corresponding to the specified node + content: + application/json: + schema: + type: object + required: + - ast_validation + properties: + ast_validation: + $ref: '#/components/schemas/NodeEvaluationDto' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' /scenario-iterations: get: tags: @@ -4752,6 +4792,11 @@ components: decision_to_case_workflow_type: type: string enum: ['DISABLED', 'CREATE_CASE', 'ADD_TO_CASE_IF_POSSIBLE'] + decision_to_case_name_template: + type: object + nullable: true + allOf: + - $ref: '#/components/schemas/NodeDto' description: type: string live_version_id: @@ -4786,6 +4831,16 @@ components: - description - name - trigger_object_type + ScenarioAstValidateInputDto: + type: object + properties: + node: + type: object + allOf: + - $ref: '#/components/schemas/NodeDto' + expected_return_type: + type: string + enum: ['string', 'int', 'float', 'bool'] ScenarioUpdateInputDto: type: object properties: @@ -4799,6 +4854,10 @@ components: decision_to_case_workflow_type: type: string enum: ['DISABLED', 'CREATE_CASE', 'ADD_TO_CASE_IF_POSSIBLE'] + decision_to_case_name_template: + type: object + allOf: + - $ref: '#/components/schemas/NodeDto' description: type: string name: diff --git a/packages/marble-api/src/generated/marblecore-api.ts b/packages/marble-api/src/generated/marblecore-api.ts index 333d218a..a37d0445 100644 --- a/packages/marble-api/src/generated/marblecore-api.ts +++ b/packages/marble-api/src/generated/marblecore-api.ts @@ -328,12 +328,21 @@ export type UpdateCustomListBodyDto = { export type CreateCustomListValueBody = { value: string; }; +export type NodeDto = { + name?: string; + constant?: ConstantDto; + children?: NodeDto[]; + named_children?: { + [key: string]: NodeDto; + }; +}; export type ScenarioDto = { id: string; created_at: string; decision_to_case_inbox_id?: string; decision_to_case_outcomes: OutcomeDto[]; decision_to_case_workflow_type: "DISABLED" | "CREATE_CASE" | "ADD_TO_CASE_IF_POSSIBLE"; + decision_to_case_name_template?: (NodeDto) | null; description: string; live_version_id?: string; name: string; @@ -349,9 +358,14 @@ export type ScenarioUpdateInputDto = { decision_to_case_inbox_id?: string; decision_to_case_outcomes?: OutcomeDto[]; decision_to_case_workflow_type?: "DISABLED" | "CREATE_CASE" | "ADD_TO_CASE_IF_POSSIBLE"; + decision_to_case_name_template?: NodeDto; description?: string; name?: string; }; +export type ScenarioAstValidateInputDto = { + node?: NodeDto; + expected_return_type?: "string" | "int" | "float" | "bool"; +}; export type ScenarioIterationDto = { id: string; scenario_id: string; @@ -359,14 +373,6 @@ export type ScenarioIterationDto = { created_at: string; updated_at: string; }; -export type NodeDto = { - name?: string; - constant?: ConstantDto; - children?: NodeDto[]; - named_children?: { - [key: string]: NodeDto; - }; -}; export type ScenarioIterationRuleDto = { id: string; scenario_iteration_id: string; @@ -1501,6 +1507,30 @@ export function updateScenario(scenarioId: string, scenarioUpdateInputDto: Scena body: scenarioUpdateInputDto }))); } +/** + * Validate an AST + */ +export function validateAstNode(scenarioId: string, scenarioAstValidateInputDto?: ScenarioAstValidateInputDto, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: { + ast_validation: NodeEvaluationDto; + }; + } | { + status: 401; + data: string; + } | { + status: 403; + data: string; + } | { + status: 404; + data: string; + }>(`/scenarios/${encodeURIComponent(scenarioId)}/validate-ast`, oazapfts.json({ + ...opts, + method: "POST", + body: scenarioAstValidateInputDto + }))); +} /** * List iterations */