From b21ea0f9445f3c18e9b87f948d3071a3ff7d6a38 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Thu, 13 Aug 2026 12:45:06 +0200 Subject: [PATCH 01/33] feat(sdk): variables by type and handle update with fixed imports --- apps/demo/src/app/data/nodes/action/action.ts | 3 +- .../src/app/data/nodes/trigger/trigger.ts | 3 +- .../project-selection/project-selection.tsx | 2 +- packages/sdk/src/features/i18n/locales/en.ts | 2 + packages/sdk/src/features/i18n/locales/pl.ts | 2 + .../dependencies/dependencies.tsx | 2 +- .../conditions-form-field.tsx | 13 +- .../conditions-form.tsx | 6 +- .../variable-text-area-control.tsx | 8 +- .../variable-text-control.tsx | 10 +- .../get-available-variables-by-node-id.ts | 100 ------------- .../actions/get-is-single-variable.ts | 24 --- .../actions/get-nodes-with-variable.ts | 31 ++-- ...et-single-variable-metadata-if-possible.ts | 78 ++++++++++ .../get-single-variable-type-if-possible.ts | 70 +++------ .../variables/actions/get-string-type.ts | 16 -- .../get-string-variable-type-if-possible.ts | 30 ++++ .../dynamic-typed-input/constants.ts | 20 ++- .../dynamic-typed-input.module.css | 47 +++++- .../dynamic-typed-input.tsx | 129 ++++++++++++---- ...dynamic-typed-variable-or-input.module.css | 11 ++ .../dynamic-typed-variable-or-input.tsx | 57 ++++--- .../schema-builder/schema-builder.module.css | 43 ++++++ .../schema-builder/schema-builder.tsx | 141 ++++++++++++++++++ .../variable-preview/variable-meta.module.css | 10 +- .../variable-preview/variable-meta.tsx | 10 +- .../variable-preview.module.css | 9 +- .../variable-preview/variable-preview.tsx | 16 +- .../wrappers/variable-preview-global.tsx | 18 +++ .../variable-select.module.css | 10 ++ .../variable-select/variable-select.tsx | 25 +++- .../variable-text/variable-text.module.css | 15 +- .../variable-text/variable-text.tsx | 55 ++++++- .../variable-text/variable-text.types.ts | 1 + .../sdk/src/features/variables/constants.ts | 61 +++----- .../hooks/use-available-variables.ts | 59 ++++---- .../variables/hooks/use-refresh-variables.ts | 71 +++++++++ .../variables/modals/control/README.md | 3 + ...-schema-builder-variable-config.module.css | 3 + .../modal-schema-builder-variable-config.tsx | 64 ++++++++ .../modal-schema-builder-variable-remove.tsx | 38 +++++ .../variables/modals/global/README.md | 3 + .../modals/{ => global}/constants.ts | 0 .../{ => global}/modal-settings.module.css | 1 - .../modals/{ => global}/modal-settings.tsx | 15 +- .../settings/settings-navigation.module.css | 8 +- .../settings/settings-navigation.tsx | 0 .../tab-general/tab-general.module.css | 0 .../{ => global}/tab-general/tab-general.tsx | 3 +- .../panes/pane-edit-variable-global.tsx | 64 ++++++++ .../panes/pane-remove-variable-global.tsx | 41 +++++ .../tab-global-variables.module.css | 0 .../tab-global-variables.tsx | 56 +++++++ .../modals/{ => global}/tab/tab-active.tsx | 7 +- .../modals/global/tab/tab-header.module.css | 34 +++++ .../modals/{ => global}/tab/tab-header.tsx | 22 ++- .../components}/constants.ts | 0 .../pane-add-variable/pane-add-variable.tsx | 49 ++++++ .../pane-edit-variable/pane-edit-variable.tsx | 49 ++++++ .../pane-list/pane-list.module.css | 0 .../components}/pane-list/pane-list.tsx | 8 +- .../pane-remove-variable.module.css | 8 +- .../pane-remove-variable.tsx | 61 ++++---- .../variable-form/variable-form.module.css | 0 .../variable-form/variable-form.tsx | 56 +++++-- .../pane-add-variable/pane-add-variable.tsx | 31 ---- .../pane-edit-variable/pane-edit-variable.tsx | 52 ------- .../tab-global-variables.tsx | 44 ------ .../modals/tab/tab-header.module.css | 35 ----- .../core/get-node-variables-suggestions.ts | 75 ++++++++++ .../get-suggestions-from-output-properties.ts | 30 ++++ .../get-suggestions-from-variables-index.ts | 49 ++++++ .../get-suggestions-node-data.ts | 123 +++++++++++++++ .../stores/core/refresh-suggestions.ts | 49 ++++++ .../set-suggestions-node-data.ts | 74 +++++++++ .../src/features/variables/stores/types.ts | 24 +++ .../stores/use-variable-suggestions-store.ts | 28 ++++ packages/sdk/src/features/variables/types.ts | 6 +- .../core/filter-suggestions-by-types.spec.ts | 108 ++++++++++++++ .../utils/core/filter-suggestions-by-types.ts | 24 +++ .../get-available-variables-by-node-id.ts | 76 ++++++++++ .../utils/diagram/get-node-ancestors.ts | 33 ++++ .../diagram/get-node-label-for-variable.ts | 11 ++ .../utils/filter-suggestion-groups-by-type.ts | 3 +- .../form-validation}/conditions.ts | 12 +- .../form-validation}/definitions.ts | 13 +- .../utils/get-boolean-if-possible.ts | 35 +++++ .../utils/get-global-variable-key.ts | 5 - .../variables/utils/get-is-date-type.ts | 5 +- .../get-is-wrong-type-but-acceptable.ts | 15 +- ...node-suggestions-from-output-properties.ts | 32 ---- .../keys/get-is-string-variable-reference.ts | 42 ++++++ .../get-variable-reference-if-possible.ts | 10 ++ ...e-reference-without-brackets-for-global.ts | 6 + ...ble-reference-without-brackets-for-node.ts | 14 ++ .../utils/keys/get-variable-references.ts | 41 +++++ ...ables-referencing-strategy.decision-log.md | 60 ++++++++ .../use-workflow-builder-actions.spec.tsx | 2 +- .../src/hooks/use-workflow-builder-actions.ts | 2 +- packages/sdk/src/node/node-output-schema.ts | 26 +++- .../src/store/slices/diagram-slice/actions.ts | 4 + packages/sdk/src/types/controls.ts | 4 + packages/sdk/src/utils/a11y.ts | 12 +- packages/sdk/src/utils/object.ts | 15 ++ packages/sdk/src/utils/text.ts | 51 ++++++- packages/sdk/src/utils/time.ts | 50 ++++++- 106 files changed, 2446 insertions(+), 686 deletions(-) delete mode 100644 packages/sdk/src/features/variables/actions/get-available-variables-by-node-id.ts delete mode 100644 packages/sdk/src/features/variables/actions/get-is-single-variable.ts create mode 100644 packages/sdk/src/features/variables/actions/get-single-variable-metadata-if-possible.ts delete mode 100644 packages/sdk/src/features/variables/actions/get-string-type.ts create mode 100644 packages/sdk/src/features/variables/actions/get-string-variable-type-if-possible.ts create mode 100644 packages/sdk/src/features/variables/components/schema-builder/schema-builder.module.css create mode 100644 packages/sdk/src/features/variables/components/schema-builder/schema-builder.tsx rename packages/sdk/src/features/variables/{modals/tab-global-variables => components}/variable-preview/variable-meta.module.css (68%) rename packages/sdk/src/features/variables/{modals/tab-global-variables => components}/variable-preview/variable-meta.tsx (50%) rename packages/sdk/src/features/variables/{modals/tab-global-variables => components}/variable-preview/variable-preview.module.css (87%) rename packages/sdk/src/features/variables/{modals/tab-global-variables => components}/variable-preview/variable-preview.tsx (77%) create mode 100644 packages/sdk/src/features/variables/components/variable-preview/wrappers/variable-preview-global.tsx create mode 100644 packages/sdk/src/features/variables/hooks/use-refresh-variables.ts create mode 100644 packages/sdk/src/features/variables/modals/control/README.md create mode 100644 packages/sdk/src/features/variables/modals/control/modal-schema-builder-variable-config.module.css create mode 100644 packages/sdk/src/features/variables/modals/control/modal-schema-builder-variable-config.tsx create mode 100644 packages/sdk/src/features/variables/modals/control/modal-schema-builder-variable-remove.tsx create mode 100644 packages/sdk/src/features/variables/modals/global/README.md rename packages/sdk/src/features/variables/modals/{ => global}/constants.ts (100%) rename packages/sdk/src/features/variables/modals/{ => global}/modal-settings.module.css (95%) rename packages/sdk/src/features/variables/modals/{ => global}/modal-settings.tsx (76%) rename packages/sdk/src/features/variables/modals/{ => global}/settings/settings-navigation.module.css (81%) rename packages/sdk/src/features/variables/modals/{ => global}/settings/settings-navigation.tsx (100%) rename packages/sdk/src/features/variables/modals/{ => global}/tab-general/tab-general.module.css (100%) rename packages/sdk/src/features/variables/modals/{ => global}/tab-general/tab-general.tsx (80%) create mode 100644 packages/sdk/src/features/variables/modals/global/tab-global-variables/panes/pane-edit-variable-global.tsx create mode 100644 packages/sdk/src/features/variables/modals/global/tab-global-variables/panes/pane-remove-variable-global.tsx rename packages/sdk/src/features/variables/modals/{ => global}/tab-global-variables/tab-global-variables.module.css (100%) create mode 100644 packages/sdk/src/features/variables/modals/global/tab-global-variables/tab-global-variables.tsx rename packages/sdk/src/features/variables/modals/{ => global}/tab/tab-active.tsx (63%) create mode 100644 packages/sdk/src/features/variables/modals/global/tab/tab-header.module.css rename packages/sdk/src/features/variables/modals/{ => global}/tab/tab-header.tsx (70%) rename packages/sdk/src/features/variables/modals/{tab-global-variables => shared/components}/constants.ts (100%) create mode 100644 packages/sdk/src/features/variables/modals/shared/components/pane-add-variable/pane-add-variable.tsx create mode 100644 packages/sdk/src/features/variables/modals/shared/components/pane-edit-variable/pane-edit-variable.tsx rename packages/sdk/src/features/variables/modals/{tab-global-variables => shared/components}/pane-list/pane-list.module.css (100%) rename packages/sdk/src/features/variables/modals/{tab-global-variables => shared/components}/pane-list/pane-list.tsx (86%) rename packages/sdk/src/features/variables/modals/{tab-global-variables => shared/components}/pane-remove-variable/pane-remove-variable.module.css (81%) rename packages/sdk/src/features/variables/modals/{tab-global-variables => shared/components}/pane-remove-variable/pane-remove-variable.tsx (50%) rename packages/sdk/src/features/variables/modals/{tab-global-variables => shared/components}/variable-form/variable-form.module.css (100%) rename packages/sdk/src/features/variables/modals/{tab-global-variables => shared/components}/variable-form/variable-form.tsx (65%) delete mode 100644 packages/sdk/src/features/variables/modals/tab-global-variables/pane-add-variable/pane-add-variable.tsx delete mode 100644 packages/sdk/src/features/variables/modals/tab-global-variables/pane-edit-variable/pane-edit-variable.tsx delete mode 100644 packages/sdk/src/features/variables/modals/tab-global-variables/tab-global-variables.tsx delete mode 100644 packages/sdk/src/features/variables/modals/tab/tab-header.module.css create mode 100644 packages/sdk/src/features/variables/stores/core/get-node-variables-suggestions.ts create mode 100644 packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-output-properties.ts create mode 100644 packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-variables-index.ts create mode 100644 packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts create mode 100644 packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts create mode 100644 packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts create mode 100644 packages/sdk/src/features/variables/stores/types.ts create mode 100644 packages/sdk/src/features/variables/stores/use-variable-suggestions-store.ts create mode 100644 packages/sdk/src/features/variables/utils/core/filter-suggestions-by-types.spec.ts create mode 100644 packages/sdk/src/features/variables/utils/core/filter-suggestions-by-types.ts create mode 100644 packages/sdk/src/features/variables/utils/core/get-available-variables-by-node-id.ts create mode 100644 packages/sdk/src/features/variables/utils/diagram/get-node-ancestors.ts create mode 100644 packages/sdk/src/features/variables/utils/diagram/get-node-label-for-variable.ts rename packages/sdk/src/features/variables/{actions => utils/form-validation}/conditions.ts (74%) rename packages/sdk/src/features/variables/{actions => utils/form-validation}/definitions.ts (63%) create mode 100644 packages/sdk/src/features/variables/utils/get-boolean-if-possible.ts delete mode 100644 packages/sdk/src/features/variables/utils/get-global-variable-key.ts rename packages/sdk/src/features/variables/{actions => utils}/get-is-wrong-type-but-acceptable.ts (63%) delete mode 100644 packages/sdk/src/features/variables/utils/get-node-suggestions-from-output-properties.ts create mode 100644 packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.ts create mode 100644 packages/sdk/src/features/variables/utils/keys/get-variable-reference-if-possible.ts create mode 100644 packages/sdk/src/features/variables/utils/keys/get-variable-reference-without-brackets-for-global.ts create mode 100644 packages/sdk/src/features/variables/utils/keys/get-variable-reference-without-brackets-for-node.ts create mode 100644 packages/sdk/src/features/variables/utils/keys/get-variable-references.ts create mode 100644 packages/sdk/src/features/variables/variables-referencing-strategy.decision-log.md create mode 100644 packages/sdk/src/utils/object.ts diff --git a/apps/demo/src/app/data/nodes/action/action.ts b/apps/demo/src/app/data/nodes/action/action.ts index 2ef830c8a..129327a67 100644 --- a/apps/demo/src/app/data/nodes/action/action.ts +++ b/apps/demo/src/app/data/nodes/action/action.ts @@ -16,7 +16,8 @@ export const action: PaletteItem = { type: 'default', properties: { status: { type: 'string', label: 'Status', description: 'Execution status: success, failure, or skipped' }, - result: { type: 'object', label: 'Result', description: 'The data returned by the action' }, + // TODO: Match JSON schema + // result: { type: 'object', label: 'Result', description: 'The data returned by the action' }, errorMessage: { type: 'string', label: 'Error Message', description: 'Error details if the action failed' }, }, }, diff --git a/apps/demo/src/app/data/nodes/trigger/trigger.ts b/apps/demo/src/app/data/nodes/trigger/trigger.ts index 62291e86c..8b5c26eb6 100644 --- a/apps/demo/src/app/data/nodes/trigger/trigger.ts +++ b/apps/demo/src/app/data/nodes/trigger/trigger.ts @@ -17,7 +17,8 @@ export const triggerNode: PaletteItem = { properties: { eventType: { type: 'string', label: 'Event Type', description: 'The type of event that started the workflow' }, timestamp: { type: 'string', label: 'Timestamp', description: 'ISO 8601 date-time when the trigger fired' }, - payload: { type: 'object', label: 'Payload', description: 'The raw event data received by the trigger' }, + // TODO: Match JSON schema + // payload: { type: 'object', label: 'Payload', description: 'The raw event data received by the trigger' }, }, }, }; diff --git a/packages/sdk/src/features/app-bar/components/project-selection/project-selection.tsx b/packages/sdk/src/features/app-bar/components/project-selection/project-selection.tsx index 6ca66b438..40948de01 100644 --- a/packages/sdk/src/features/app-bar/components/project-selection/project-selection.tsx +++ b/packages/sdk/src/features/app-bar/components/project-selection/project-selection.tsx @@ -7,9 +7,9 @@ import { Icon } from '@workflow-builder/icons'; import styles from '../../app-bar.module.css'; -import { openModalWorkflowSettings } from '../../../../features/variables/modals/modal-settings'; import { useStore } from '../../../../store/store'; import { withOptionalComponentPlugins } from '../../../plugins-core/adapters/adapter-components'; +import { openModalWorkflowSettings } from '../../../variables/modals/global/modal-settings'; /** * Props accepted by {@link ProjectSelection}. Use this when typing a diff --git a/packages/sdk/src/features/i18n/locales/en.ts b/packages/sdk/src/features/i18n/locales/en.ts index 56f399bc8..7cf4cf3e0 100644 --- a/packages/sdk/src/features/i18n/locales/en.ts +++ b/packages/sdk/src/features/i18n/locales/en.ts @@ -181,6 +181,8 @@ export const en = { wrongDiagramFormat: 'Wrong diagram format', contentCopied: 'Content copied to clipboard', variablesListIsEmpty: 'The list of available variables is empty.', + variableNameAlreadyExists: 'A variable with this name already exists.', + variableWasNotFound: 'This variable was not found.', cantEditReadOnlyMode: 'Editing is blocked in read-only mode.', }, workflowsSettings: { diff --git a/packages/sdk/src/features/i18n/locales/pl.ts b/packages/sdk/src/features/i18n/locales/pl.ts index 96a918c02..ea00cdaa0 100644 --- a/packages/sdk/src/features/i18n/locales/pl.ts +++ b/packages/sdk/src/features/i18n/locales/pl.ts @@ -145,6 +145,8 @@ export const pl = { wrongDiagramFormat: 'Nieprawidłowy format diagramu', contentCopied: 'Treść skopiowana do schowka', variablesListIsEmpty: 'Lista dostępnych zmiennych jest pusta.', + variableNameAlreadyExists: 'Zmienna o tej nazwie już istnieje.', + variableWasNotFound: 'Nie znaleziono tej zmiennej.', cantEditReadOnlyMode: 'Edycja jest zablokowana w trybie tylko do odczytu.', }, aiTools: { diff --git a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx index e9308c5ff..1f905ef7e 100644 --- a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx +++ b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx @@ -4,11 +4,11 @@ import styles from './dependencies.module.css'; import { FormControlWithLabel } from '../../../../../components/form/form-control-with-label/form-control-with-label'; import { useSingleSelectedElement } from '../../../../../features/properties-bar/use-single-selected-element'; -import { conditionsToDependencies } from '../../../../../features/variables/actions/conditions'; import { VariableText } from '../../../../../features/variables/components/variable-text/variable-text'; import { useAvailableVariables } from '../../../../../features/variables/hooks/use-available-variables'; import type { DynamicCondition } from '../../../../../types/controls'; import { noop } from '../../../../../utils/noop'; +import { conditionsToDependencies } from '../../../utils/conditional-transform'; type Props = { conditions: DynamicCondition[]; diff --git a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form-field/conditions-form-field.tsx b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form-field/conditions-form-field.tsx index 9b417761a..ff1b8fd2b 100644 --- a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form-field/conditions-form-field.tsx +++ b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form-field/conditions-form-field.tsx @@ -7,17 +7,18 @@ import { Icon } from '@workflow-builder/icons'; import styles from './conditions-form-field.module.css'; -import { type ConditionErrors, getConditionErrors } from '../../../../../features/variables/actions/conditions'; -import { getStringType } from '../../../../../features/variables/actions/get-string-type'; import { DynamicTypedVariableOrInput } from '../../../../../features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input'; import { VariableText } from '../../../../../features/variables/components/variable-text/variable-text'; import type { VariableSuggestionGroup } from '../../../../../features/variables/components/variable-text/variable-text.types'; import { type ComparisonOperator, + LOGICAL_OPERATOR, comparisonOperatorsByPrimitiveType, } from '../../../../../features/variables/constants'; import type { VariableTypePrimitive } from '../../../../../node/node-output-schema'; import type { DynamicCondition } from '../../../../../types/controls'; +import { getStringVariableTypeIfPossible } from '../../../../variables/actions/get-string-variable-type-if-possible'; +import { type ConditionErrors, getConditionErrors } from '../../../../variables/utils/form-validation/conditions'; type ConditionsFormFieldProps = { condition: Partial; @@ -34,7 +35,7 @@ const getTypeOptions = ( xType: VariableTypePrimitive; comparisonsOperators: ComparisonOperator[]; } => { - const xType = getStringType(value); + const xType = getStringVariableTypeIfPossible(value); const comparisonsOperators: ComparisonOperator[] = comparisonOperatorsByPrimitiveType[xType] || []; return { @@ -78,11 +79,11 @@ export function ConditionsFormField(props: ConditionsFormFieldProps) { handleChange('logicalOperator', value)} > - {t('conditions.compare.all')} - {t('conditions.compare.one')} + {t('conditions.compare.all')} + {t('conditions.compare.one')} )} diff --git a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form/conditions-form.tsx b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form/conditions-form.tsx index 71c3dc76d..ccfbddd58 100644 --- a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form/conditions-form.tsx +++ b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form/conditions-form.tsx @@ -8,9 +8,9 @@ import styles from './conditions-form.module.css'; import type { DynamicCondition } from '../../../../../features/json-form/types/controls'; import { closeModal } from '../../../../../features/modals/stores/use-modal-store'; import { useSingleSelectedElement } from '../../../../../features/properties-bar/use-single-selected-element'; -import { getConditionErrors } from '../../../../../features/variables/actions/conditions'; import { variablesTypesToExcludeNonPrimitive } from '../../../../../features/variables/constants'; import { useAvailableVariables } from '../../../../../features/variables/hooks/use-available-variables'; +import { getConditionErrors } from '../../../../variables/utils/form-validation/conditions'; import { ConditionsFormField } from '../dynamic-conditions-form-field/conditions-form-field'; type ConditionsFormProps = { @@ -37,7 +37,9 @@ export const ConditionsForm = forwardRef(null); diff --git a/packages/sdk/src/features/json-form/controls/variable-text-area-control/variable-text-area-control.tsx b/packages/sdk/src/features/json-form/controls/variable-text-area-control/variable-text-area-control.tsx index c8a5665e7..6ea54d8d7 100644 --- a/packages/sdk/src/features/json-form/controls/variable-text-area-control/variable-text-area-control.tsx +++ b/packages/sdk/src/features/json-form/controls/variable-text-area-control/variable-text-area-control.tsx @@ -10,10 +10,12 @@ import { ControlWrapper } from '../control-wrapper'; function VariableTextAreaControl(props: VariableTextAreaControlProps) { const { data, handleChange, path, errors, enabled, uischema } = props; - const { placeholder, disabled } = uischema; + const { placeholder, variablesTypes, disabled } = uischema; const selection = useSingleSelectedElement(); - // TODO: add param to pick what type of variables are available - const suggestionGroups = useAvailableVariables(selection?.node?.id, variablesTypesToExcludeInText); + const suggestionGroups = useAvailableVariables(selection?.node?.id, { + excludeTypes: variablesTypes ? [] : variablesTypesToExcludeInText, + includeTypes: variablesTypes, + }); const isDisabled = !enabled || disabled === true; diff --git a/packages/sdk/src/features/json-form/controls/variable-text-control/variable-text-control.tsx b/packages/sdk/src/features/json-form/controls/variable-text-control/variable-text-control.tsx index 0eee80849..0b69c1346 100644 --- a/packages/sdk/src/features/json-form/controls/variable-text-control/variable-text-control.tsx +++ b/packages/sdk/src/features/json-form/controls/variable-text-control/variable-text-control.tsx @@ -1,19 +1,21 @@ import { useCallback, useEffect, useState } from 'react'; import { VariableText } from '../../../../features/variables/components/variable-text/variable-text'; -import { variablesTypesToExcludeInText } from '../../../../features/variables/constants'; import { useAvailableVariables } from '../../../../features/variables/hooks/use-available-variables'; import { useSingleSelectedElement } from '../../../properties-bar/use-single-selected-element'; +import { variablesTypesToExcludeInText } from '../../../variables/constants'; import type { VariableTextControlProps } from '../../types/controls'; import { createControlRenderer } from '../../utils/rendering'; import { ControlWrapper } from '../control-wrapper'; function VariableTextControl(props: VariableTextControlProps) { const { data, handleChange, path, errors, enabled, uischema } = props; - const { placeholder, disabled } = uischema; + const { placeholder, variablesTypes, disabled } = uischema; const selection = useSingleSelectedElement(); - // TODO: add param to pick what type of variables are available - const suggestionGroups = useAvailableVariables(selection?.node?.id, variablesTypesToExcludeInText); + const suggestionGroups = useAvailableVariables(selection?.node?.id, { + excludeTypes: variablesTypes ? [] : variablesTypesToExcludeInText, + includeTypes: variablesTypes, + }); const isDisabled = !enabled || disabled === true; diff --git a/packages/sdk/src/features/variables/actions/get-available-variables-by-node-id.ts b/packages/sdk/src/features/variables/actions/get-available-variables-by-node-id.ts deleted file mode 100644 index da4a16ae0..000000000 --- a/packages/sdk/src/features/variables/actions/get-available-variables-by-node-id.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '../../../node/node-data'; -import { OUTPUT_SCHEMA_TYPE } from '../../../node/node-output-schema'; -import { useStore } from '../../../store/store'; -import { getNodesDefinitionsByType } from '../../../utils/validation/get-nodes-definitions-by-type'; -import type { VariableSuggestion, VariableSuggestionGroup } from '../components/variable-text/variable-text.types'; -import { getNodeSuggestionsFromOutputProperties } from '../utils/get-node-suggestions-from-output-properties'; - -type Params = { - nodeId: string | undefined; - nodes: WorkflowBuilderNode[]; - edges: WorkflowBuilderEdge[]; - excludeTypes?: string[]; -}; - -export function getAvailableVariablesByNodeId({ - nodeId, - nodes, - edges, - excludeTypes = [], -}: Params): VariableSuggestionGroup[] { - if (!nodeId) { - return []; - } - - // BFS backward through edges to find all ancestor nodes - const ancestors = new Set(); - const queue = [nodeId]; - - while (queue.length > 0) { - const nodeId = queue.shift()!; - for (const edge of edges) { - if (edge.target === nodeId && !ancestors.has(edge.source)) { - ancestors.add(edge.source); - queue.push(edge.source); - } - } - } - - const data = useStore.getState().data; - const definitionsByType = getNodesDefinitionsByType(data); - const groups: VariableSuggestionGroup[] = []; - - for (const ancestorId of ancestors) { - const node = nodes.find((n) => n.id === ancestorId); - if (!node) { - continue; - } - - const definition = definitionsByType[node.data.type]; - if (!definition?.outputSchema) { - continue; - } - - const nodeLabel = (node.data.properties as { label?: string }).label || definition.label || node.data.type; - - let suggestions: VariableSuggestion[] = []; - - if (definition.outputSchema.type === OUTPUT_SCHEMA_TYPE.DEFAULT) { - suggestions = getNodeSuggestionsFromOutputProperties({ - properties: definition.outputSchema.properties, - nodeLabel, - nodeId: ancestorId, - excludeTypes, - }); - } - - if (definition.outputSchema.type === OUTPUT_SCHEMA_TYPE.VARIANT) { - const variant = Object.values(definition.outputSchema.variants).find((variant) => { - if (!variant?.variantRule) { - return true; - } - - const { dataPropertyName, dataPropertyValue } = variant.variantRule; - - if (node.data.properties[dataPropertyName] === dataPropertyValue) { - return true; - } - - return false; - }); - - if (variant) { - suggestions = getNodeSuggestionsFromOutputProperties({ - properties: variant.properties, - nodeLabel, - nodeId: ancestorId, - excludeTypes, - }); - } - } - - groups.push({ - label: nodeLabel, - icon: node.data.icon, - suggestions, - }); - } - - return groups; -} diff --git a/packages/sdk/src/features/variables/actions/get-is-single-variable.ts b/packages/sdk/src/features/variables/actions/get-is-single-variable.ts deleted file mode 100644 index 9a7719d29..000000000 --- a/packages/sdk/src/features/variables/actions/get-is-single-variable.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { VARIABLE_BRACKETS_END, VARIABLE_BRACKETS_START } from '../constants'; - -export function getIsSingleVariable(value: string | undefined): boolean { - const valueTrimmed = value?.trim(); - if (!valueTrimmed) { - return false; - } - - const hasExpectedBrackets = - valueTrimmed.startsWith(VARIABLE_BRACKETS_START) && valueTrimmed.endsWith(VARIABLE_BRACKETS_END); - if (!hasExpectedBrackets) { - return false; - } - - const isOnlyOneVariable = - `${VARIABLE_BRACKETS_START}${valueTrimmed.replaceAll(VARIABLE_BRACKETS_START, '').replaceAll(VARIABLE_BRACKETS_END, '')}${VARIABLE_BRACKETS_END}` === - valueTrimmed; - - if (isOnlyOneVariable) { - return true; - } - - return false; -} diff --git a/packages/sdk/src/features/variables/actions/get-nodes-with-variable.ts b/packages/sdk/src/features/variables/actions/get-nodes-with-variable.ts index d02302695..8e7846278 100644 --- a/packages/sdk/src/features/variables/actions/get-nodes-with-variable.ts +++ b/packages/sdk/src/features/variables/actions/get-nodes-with-variable.ts @@ -1,22 +1,31 @@ import type { WBIcon } from '@workflow-builder/icons'; import { getStoreNodes } from '../../../store/slices/diagram-slice/actions'; -import { VARIABLE_BRACKETS_START, VARIABLE_GLOBAL_KEY, VARIABLE_NODES_KEY } from '../constants'; +import type { MaybeVariableReference } from '../types'; +import { getVariableReferences } from '../utils/keys/get-variable-references'; -type NodeWithVariable = { +export type NodeWithVariable = { id: string; icon: WBIcon; title?: string; }; -// This is very expensive operation call it only inside a callback that is trigger by user action -export function getNodesWithVariable(variableKey: string): NodeWithVariable[] { - const isSupportedVariable = [VARIABLE_GLOBAL_KEY, VARIABLE_NODES_KEY].some((key) => - variableKey.startsWith(`${VARIABLE_BRACKETS_START}${key}`), - ); - - if (!isSupportedVariable) { - console.error(`Unsupported variable for getNodesIdsWithVariable: ${variableKey}`); +/** + * Returns nodes whose properties reference the given variable. + * + * This is a very expensive operation (stringifies properties of every node), so call it only inside + * a callback triggered by a user action - when the variable edit or delete flow is opened. + * + * The result is used to: + * - block changing the type of a variable that is already used, since existing controls would keep a value + * that no longer matches the type (e.g. a number variable switched to string leaves a broken control value) + * - block deleting a variable that is still used, and show which nodes contain it + */ +export function getNodesWithVariable(maybeReference: MaybeVariableReference): NodeWithVariable[] { + const { reference } = getVariableReferences(maybeReference); + + if (!reference) { + console.error(`Unsupported variable for getNodesIdsWithVariable: ${maybeReference}`); return []; } @@ -25,7 +34,7 @@ export function getNodesWithVariable(variableKey: string): NodeWithVariable[] { const nodesWithVariables = nodes .filter((node) => { - return JSON.stringify(node.data.properties).includes(variableKey); + return JSON.stringify(node.data.properties).includes(reference); }) .map((node) => ({ id: node.id, diff --git a/packages/sdk/src/features/variables/actions/get-single-variable-metadata-if-possible.ts b/packages/sdk/src/features/variables/actions/get-single-variable-metadata-if-possible.ts new file mode 100644 index 000000000..500fb6b34 --- /dev/null +++ b/packages/sdk/src/features/variables/actions/get-single-variable-metadata-if-possible.ts @@ -0,0 +1,78 @@ +import type { VariableType } from '../../../node/node-output-schema'; +import { getNodeByIdAction } from '../../../store-get-actions/stores/use-store-get-actions'; +import { useStore } from '../../../store/store'; +import { VARIABLE_GLOBAL_KEY, VARIABLE_NODES_KEY } from '../constants'; +import { getVariableBySourceHandlesForNode } from '../stores/core/get-node-variables-suggestions'; +import type { MaybeVariableReference } from '../types'; +import { getVariableReferences } from '../utils/keys/get-variable-references'; + +type VariableMetadata = { + label: string; + type: VariableType; + // Example: {{nodes..propertyNameA.propertyNameB}} + reference: string; +}; + +/** + * Returns metadata (label, type, reference) for a value that is a single variable. + * + * Supports global variables (`{{global.}}`) and previous node variables + * (`{{nodes..propertyName}}`), with or without brackets. + * + * Returns `undefined` when the value isn't a single variable or the variable can't be resolved. + */ +export function getSingleVariableMetadataIfPossible( + maybeReference: MaybeVariableReference, +): VariableMetadata | undefined { + const { reference, referenceWithoutBrackets } = getVariableReferences(maybeReference); + + if (!reference || !referenceWithoutBrackets) { + return; + } + + const isGlobalVariable = referenceWithoutBrackets.startsWith(VARIABLE_GLOBAL_KEY); + if (isGlobalVariable) { + const [_key, globalVariableId] = referenceWithoutBrackets.split('.'); + const definition = useStore.getState().globalVariables[globalVariableId]; + + if (!definition) { + return; + } + + return { + label: definition.name, + type: definition.type, + reference, + }; + } + + const isPreviousNodeVariable = referenceWithoutBrackets.startsWith(VARIABLE_NODES_KEY); + if (isPreviousNodeVariable) { + const [_key, nodeId] = referenceWithoutBrackets.split('.'); + + const node = getNodeByIdAction(nodeId); + + if (!node) { + return; + } + + const variablesBySourceHandles = getVariableBySourceHandlesForNode({ nodeId: node.id }); + + if (!variablesBySourceHandles) { + return; + } + + const allSuggestions = Object.values(variablesBySourceHandles).flatMap((suggestions) => suggestions || []); + + const suggestion = allSuggestions.find((suggestion) => suggestion.id === referenceWithoutBrackets); + if (suggestion) { + return { + label: suggestion.label, + type: suggestion.type, + reference, + }; + } + } + + return; +} diff --git a/packages/sdk/src/features/variables/actions/get-single-variable-type-if-possible.ts b/packages/sdk/src/features/variables/actions/get-single-variable-type-if-possible.ts index 1e51efc42..e45eb14df 100644 --- a/packages/sdk/src/features/variables/actions/get-single-variable-type-if-possible.ts +++ b/packages/sdk/src/features/variables/actions/get-single-variable-type-if-possible.ts @@ -1,57 +1,21 @@ -import { - type NodeOutputSchemaDefault, - type VariableTypePrimitive, - getVariableTypeIfPrimitive, -} from '../../../node/node-output-schema'; -import { getNodeByIdAction } from '../../../store-get-actions/stores/use-store-get-actions'; -import { useStore } from '../../../store/store'; -import { getNodeDefinition } from '../../../utils/validation/get-node-definition'; -import { VARIABLE_BRACKETS_END, VARIABLE_BRACKETS_START, VARIABLE_GLOBAL_KEY, VARIABLE_NODES_KEY } from '../constants'; -import { getIsSingleVariable } from './get-is-single-variable'; - -export function getSingleVariableTypeIfPossible(value: string | undefined): VariableTypePrimitive | undefined { - const valueTrimmed = value?.trim() || ''; - if (getIsSingleVariable(valueTrimmed) === false) { +import type { VariableType } from '../../../node/node-output-schema'; +import type { MaybeVariableReference } from '../types'; +import { getSingleVariableMetadataIfPossible } from './get-single-variable-metadata-if-possible'; + +/** + * Returns the type for a value that is a single variable. + * + * Supports global variables (`{{global.}}`) and previous node variables + * (`{{nodes..propertyName}}`), with or without brackets. + * + * Returns `undefined` when the value isn't a single variable or the variable can't be resolved. + */ +export function getSingleVariableTypeIfPossible(maybeReference: MaybeVariableReference): VariableType | undefined { + const metadata = getSingleVariableMetadataIfPossible(maybeReference); + + if (!metadata) { return; } - const valueWithNoBrackets = valueTrimmed - .slice(VARIABLE_BRACKETS_START.length) - .slice(0, -1 * VARIABLE_BRACKETS_END.length); - - const isGlobalVariable = valueWithNoBrackets.startsWith(VARIABLE_GLOBAL_KEY); - if (isGlobalVariable) { - const [_key, globalVariableId] = valueWithNoBrackets.split('.'); - const definition = useStore.getState().globalVariables[globalVariableId]; - - if (!definition) { - return; - } - - return definition.type; - } - - const isPreviousNodeVariable = valueWithNoBrackets.startsWith(VARIABLE_NODES_KEY); - if (isPreviousNodeVariable) { - const [_key, nodeId, ...propertyNameParts] = valueWithNoBrackets.split('.'); - - const node = getNodeByIdAction(nodeId); - - if (!node) { - return; - } - - const definition = getNodeDefinition(node); - if (!definition?.outputSchema) { - return; - } - - const propertyName = propertyNameParts.join('.'); - - const type = (definition.outputSchema as NodeOutputSchemaDefault)?.properties?.[propertyName]?.type; - - return getVariableTypeIfPrimitive(type); - } - - return; + return metadata.type; } diff --git a/packages/sdk/src/features/variables/actions/get-string-type.ts b/packages/sdk/src/features/variables/actions/get-string-type.ts deleted file mode 100644 index a325881c2..000000000 --- a/packages/sdk/src/features/variables/actions/get-string-type.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { VariableTypePrimitive } from '../../../node/node-output-schema'; -import { getIsStringNumber } from '../../../utils/validation/get-is-string-number'; -import { getSingleVariableTypeIfPossible } from './get-single-variable-type-if-possible'; - -export function getStringType(value: string | undefined): VariableTypePrimitive { - if (getIsStringNumber(value)) { - return 'number'; - } - - const singleType = getSingleVariableTypeIfPossible(value); - if (singleType) { - return singleType; - } - - return 'string'; -} diff --git a/packages/sdk/src/features/variables/actions/get-string-variable-type-if-possible.ts b/packages/sdk/src/features/variables/actions/get-string-variable-type-if-possible.ts new file mode 100644 index 000000000..441bd2d8f --- /dev/null +++ b/packages/sdk/src/features/variables/actions/get-string-variable-type-if-possible.ts @@ -0,0 +1,30 @@ +import { type VariableTypePrimitive, getVariableTypeIfPrimitive } from '../../../node/node-output-schema'; +import { getIsStringNumber } from '../../../utils/validation/get-is-string-number'; +import { getSingleVariableTypeIfPossible } from './get-single-variable-type-if-possible'; + +/** + * Guesses the best matching type for a raw string value. + * + * The value can be a literal ('21' → 'number'), or a single variable reference, + * in which case the type comes from its definition. Anything else falls back to 'string'. + * + * Used e.g. in the condition builder to suggest type-relevant operators: + * typing 12 matches 'number' and suggests 'greater than', while a text value + * matches 'string' and suggests 'contains'. + */ +export function getStringVariableTypeIfPossible(value: string | undefined): VariableTypePrimitive { + if (getIsStringNumber(value)) { + return 'number'; + } + + const singleType = getSingleVariableTypeIfPossible(value); + if (singleType) { + // Currently strings can't be matched to complex types (objects, arrays) + const singleTypePrimitive = getVariableTypeIfPrimitive(singleType); + if (singleTypePrimitive) { + return singleTypePrimitive; + } + } + + return 'string'; +} diff --git a/packages/sdk/src/features/variables/components/dynamic-typed-input/constants.ts b/packages/sdk/src/features/variables/components/dynamic-typed-input/constants.ts index ab8ec806b..7e9e209fd 100644 --- a/packages/sdk/src/features/variables/components/dynamic-typed-input/constants.ts +++ b/packages/sdk/src/features/variables/components/dynamic-typed-input/constants.ts @@ -1,26 +1,32 @@ import type { SelectItem } from '@synergycodes/overflow-ui'; -import type { VariableTypePrimitive } from '../../../../node/node-output-schema'; +import type { VariableType } from '@workflow-builder/types/node-output-schema'; -export const typesForDate: VariableTypePrimitive[] = ['date', 'datetime']; +export const typesForDate: VariableType[] = ['date', 'datetime']; -export const typesForInput: VariableTypePrimitive[] = ['string', 'number']; +export const typesForInput: VariableType[] = ['string', 'number']; + +export const ITEMS_FOR_BOOLEAN_VALUES = { + TRUE: 'true', + FALSE: 'false', + EMPTY: '', +} as const; export const itemsForBoolean: SelectItem[] = [ { type: 'item', - label: 'Empty', - value: '', + label: ' ', // Empty space + value: ITEMS_FOR_BOOLEAN_VALUES.EMPTY, }, { type: 'item', label: 'True', - value: 'true', + value: ITEMS_FOR_BOOLEAN_VALUES.TRUE, }, { type: 'item', label: 'False', - value: 'false', + value: ITEMS_FOR_BOOLEAN_VALUES.FALSE, }, ]; diff --git a/packages/sdk/src/features/variables/components/dynamic-typed-input/dynamic-typed-input.module.css b/packages/sdk/src/features/variables/components/dynamic-typed-input/dynamic-typed-input.module.css index 5475d5dc4..61efe2d2c 100644 --- a/packages/sdk/src/features/variables/components/dynamic-typed-input/dynamic-typed-input.module.css +++ b/packages/sdk/src/features/variables/components/dynamic-typed-input/dynamic-typed-input.module.css @@ -1,8 +1,12 @@ +:root { + --wb-variable-control-height: 2.5625rem; +} + .row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.25rem; - height: 2.5rem; + height: var(--wb-variable-control-height); } .date-picker, @@ -10,15 +14,48 @@ border-radius: var(--ax-public-input-border-radius-medium); > div > button { - min-height: 2.5rem; + min-height: var(--wb-variable-control-height); text-align: left; } } +.date-picker--date { + > div > button { + height: var(--wb-variable-control-height); + } +} + +.date-picker--date-alone { + &:has([class*='container--error']) { + & ~ :global(.right-adornment) button { + color: var(--ax-public-input-root-color-error); + } + } +} + +.date-picker--time { + height: var(--wb-variable-control-height); + + &:global(.base--error) { + input:disabled { + color: var(--ax-public-input-root-color-error); + opacity: 0.5; + } + + :global(.right-adornment) button { + color: var(--ax-public-input-root-color-error); + } + } +} + .date-with-reset-container { position: relative; } +.select { + height: var(--wb-variable-control-height); +} + .cursor-pointer { cursor: pointer; } @@ -46,6 +83,12 @@ z-index: 1; } +.container--select:has([class*='container--error']) { + :global(.right-adornment) button { + color: var(--ax-public-input-root-color-error); + } +} + .adornment--date { position: absolute; top: 50%; diff --git a/packages/sdk/src/features/variables/components/dynamic-typed-input/dynamic-typed-input.tsx b/packages/sdk/src/features/variables/components/dynamic-typed-input/dynamic-typed-input.tsx index 0f4ea50d6..7876f798e 100644 --- a/packages/sdk/src/features/variables/components/dynamic-typed-input/dynamic-typed-input.tsx +++ b/packages/sdk/src/features/variables/components/dynamic-typed-input/dynamic-typed-input.tsx @@ -1,17 +1,19 @@ import { DatePicker, Input, Select } from '@synergycodes/overflow-ui'; import clsx from 'clsx'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import styles from './dynamic-typed-input.module.css'; import type { VariableTypePrimitive } from '../../../../node/node-output-schema'; -import { getDateIfValid, getTimeFromDateIfValid, setDateWithTimeFromTime } from '../../../../utils/time'; +import { getDateIfValid, getISODate, getTimeFromDateIfValid, setDateWithTimeFromTime } from '../../../../utils/time'; import { getIsStringNumber } from '../../../../utils/validation/get-is-string-number'; import { getIsValidDate, getIsValidTime } from '../../../../utils/validation/get-is-valid-date'; -import { VARIABLE_BRACKETS_START, variableTypeInfoByType } from '../../constants'; +import { variableTypeInfoByType } from '../../constants'; import { filterSuggestionGroupsByType } from '../../utils/filter-suggestion-groups-by-type'; +import { getBooleanStringIfPossible } from '../../utils/get-boolean-if-possible'; import { getIsDateType } from '../../utils/get-is-date-type'; +import { getIsStringVariableReferenceStart } from '../../utils/keys/get-is-string-variable-reference'; import { VariableText } from '../variable-text/variable-text'; import type { VariableSuggestionGroup } from '../variable-text/variable-text.types'; import { itemsForBoolean, typesForInput } from './constants'; @@ -19,6 +21,7 @@ import { itemsForBoolean, typesForInput } from './constants'; type DynamicTypedInputProps = { className?: string; onChange: (value: string) => void; + onBlur?: (value: string) => void; value?: string; type?: VariableTypePrimitive; placeholder?: string; @@ -32,6 +35,7 @@ type DynamicTypedInputProps = { export function DynamicTypedInput({ className, onChange, + onBlur, value, type, placeholder, @@ -45,6 +49,12 @@ export function DynamicTypedInput({ const variableTypeInfo = type ? variableTypeInfoByType[type] : undefined; const { t } = useTranslation(); + useEffect(() => { + if (getIsDateType(type)) { + setTime(getTimeFromDateIfValid(value)); + } + }, [type, value]); + const suggestionGroupsForString = useMemo(() => { if (!variableTypeInfo || typesForInput.includes(variableTypeInfo.type) === false) { return []; @@ -61,21 +71,15 @@ export function DynamicTypedInput({ return null; } - if (typesForInput.includes(variableTypeInfo.type)) { - const { baseType } = variableTypeInfo; - const isInvalidNumberValue = - baseType === 'number' && - !!value && - !getIsStringNumber(value) && - !value.startsWith(VARIABLE_BRACKETS_START.slice(0, 1)); - + if (variableTypeInfo.type === 'string') { if (suggestionGroupsForString.length > 0) { return ( onChange(event.target.value as string)} // Adornment here doesn't make sense since we show variable picker above // endAdornment={endAdornment} + onBlur={onBlur ? (event) => onBlur(event.target.value) : undefined} + error={isError} + placeholder={placeholder ?? t('variables.placeholderTypeString')} + disabled={disabled} + /> + ); + } + + if (variableTypeInfo.type === 'number') { + const isValidRegularNumber = getIsStringNumber(value); + const isValidVariableNumber = getIsStringVariableReferenceStart(value); + const isInvalidNumberValue = !(isValidRegularNumber || isValidVariableNumber); + + return ( + onChange(event.target.value as string)} + endAdornment={endAdornment} + onBlur={onBlur ? (event) => onBlur(event.target.value) : undefined} error={isError || isInvalidNumberValue} - placeholder={ - placeholder ?? - t(baseType === 'number' ? 'variables.placeholderTypeNumber' : 'variables.placeholderTypeString') - } + placeholder={placeholder ?? t('variables.placeholderTypeNumber')} disabled={disabled} /> ); } if (type === 'boolean') { + const booleanValue = getBooleanStringIfPossible(value); + return (
{ @@ -178,13 +227,35 @@ export function DynamicTypedInput({ setTime(value); if (date && getIsValidDate(date)) { - onChange(setDateWithTimeFromTime(date, value)?.toISOString()); + onChange(getISODate(setDateWithTimeFromTime(date, value))); + } + } else { + setTime(timeForRawDates); + + if (date && getIsValidDate(date)) { + onChange(getISODate(setDateWithTimeFromTime(date, timeForRawDates))); + } + } + } + }} + onBlur={(event) => { + if (!onBlur) { + return; + } + + const value = (event.target.value as string).slice(0, 5); + if (value.length === 5) { + if (getIsValidTime(value)) { + setTime(value); + + if (date && getIsValidDate(date)) { + onBlur(getISODate(setDateWithTimeFromTime(date, value))); } } else { setTime(timeForRawDates); if (date && getIsValidDate(date)) { - onChange(setDateWithTimeFromTime(date, timeForRawDates)?.toISOString()); + onBlur(getISODate(setDateWithTimeFromTime(date, timeForRawDates))); } } } diff --git a/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.module.css b/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.module.css index e0ff1d2ed..82353515f 100644 --- a/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.module.css +++ b/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.module.css @@ -1,4 +1,15 @@ .button-toggle { padding: 0; margin: 0; + + > div { + display: inline-flex; + } +} + +.container:global(.base--error), +.container:has([class*='control--error']) { + .button-toggle { + color: var(--ax-public-input-root-color-error); + } } diff --git a/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx b/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx index f453b6627..fb9a6b0c2 100644 --- a/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx +++ b/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx @@ -1,46 +1,59 @@ -import { NavButton } from '@synergycodes/overflow-ui'; +import { NavButton, Tooltip } from '@synergycodes/overflow-ui'; +import clsx from 'clsx'; import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '@workflow-builder/icons'; +import type { VariableTypePrimitive } from '@workflow-builder/types/node-output-schema'; import styles from './dynamic-typed-variable-or-input.module.css'; -import type { VariableTypePrimitive } from '../../../../node/node-output-schema'; -import { getIsSingleVariable } from '../../actions/get-is-single-variable'; +import { useTranslateIfPossible } from '../../../../hooks/use-translate-if-possible'; import { filterSuggestionGroupsByType } from '../../utils/filter-suggestion-groups-by-type'; +import { getIsStringVariableReference } from '../../utils/keys/get-is-string-variable-reference'; import { DynamicTypedInput } from '../dynamic-typed-input/dynamic-typed-input'; import { VariableSelect } from '../variable-select/variable-select'; import type { VariableSuggestionGroup } from '../variable-text/variable-text.types'; -type DynamicTypedVariableOrInput = { +type DynamicTypedVariableOrInputProps = { className?: string; onChange: (value: string) => void; + onBlur?: (value: string) => void; value?: string; type?: VariableTypePrimitive; + placeholder?: string; isError?: boolean; isDisabled?: boolean; suggestionGroups: VariableSuggestionGroup[]; }; +type DynamicControlType = 'manual' | 'variable'; + export function DynamicTypedVariableOrInput({ className, value = '', onChange, + onBlur, + placeholder, isError, type, isDisabled, suggestionGroups = [], -}: DynamicTypedVariableOrInput) { +}: DynamicTypedVariableOrInputProps) { const { t } = useTranslation(); - const [mode, setMode] = useState<'manual' | 'variable'>(getIsSingleVariable(value) ? 'variable' : 'manual'); + const translateIfPossible = useTranslateIfPossible(); + const [mode, setMode] = useState(getIsStringVariableReference(value) ? 'variable' : 'manual'); const handleToggleMode = useCallback(() => { + const newMode = mode === 'variable' ? 'manual' : 'variable'; + setMode(newMode); + onChange(''); - setMode((previous) => { - return previous === 'variable' ? 'manual' : 'variable'; - }); - }, [onChange]); + + if (onBlur) { + onBlur(''); + } + }, [mode, onBlur, onChange]); const suggestionGroupsForType = useMemo(() => { if (!type) { @@ -56,11 +69,16 @@ export function DynamicTypedVariableOrInput({ className={className} value={value} onChange={onChange} + onBlur={onBlur} variant="text" suggestionGroups={suggestionGroupsForType} hasError={isError} endAdornment={ - + } @@ -70,21 +88,24 @@ export function DynamicTypedVariableOrInput({ return ( 0 ? ( - - + + + + + + {t('variables.pickVariable')} + ) : undefined } diff --git a/packages/sdk/src/features/variables/components/schema-builder/schema-builder.module.css b/packages/sdk/src/features/variables/components/schema-builder/schema-builder.module.css new file mode 100644 index 000000000..5e8d19e0c --- /dev/null +++ b/packages/sdk/src/features/variables/components/schema-builder/schema-builder.module.css @@ -0,0 +1,43 @@ +.container { + display: flex; + flex-flow: column; + gap: 0.5rem; +} + +.button-empty { + display: flex; + padding: 1.375rem 1rem; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 0.5rem; + align-self: stretch; + border-radius: 0.5rem; + border: 1px solid var(--wb-ui-stroke-primary-default); + background: none; + + svg { + color: #e8833a; + } + + span { + color: var(--wb-colors-gray-900); + font-size: 0.8125rem; + font-weight: 600; + } +} + +.preview { + padding: var(--wb-token-spacing-spacing-4) var(--wb-token-spacing-spacing-8); + color: var(--wb-txt-tertiary-default); + + * { + font-size: 0.75rem; + gap: var(--wb-token-spacing-spacing-4); + } +} + +.button--add { + display: flex; + width: 100%; +} diff --git a/packages/sdk/src/features/variables/components/schema-builder/schema-builder.tsx b/packages/sdk/src/features/variables/components/schema-builder/schema-builder.tsx new file mode 100644 index 000000000..db0bb393c --- /dev/null +++ b/packages/sdk/src/features/variables/components/schema-builder/schema-builder.tsx @@ -0,0 +1,141 @@ +import { PlusCircle } from '@phosphor-icons/react'; +import { Button, SnackbarType } from '@synergycodes/overflow-ui'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Icon } from '@workflow-builder/icons'; + +import styles from './schema-builder.module.css'; + +import { filterEmpty } from '@/utils/array'; +import { showSnackbar } from '@/utils/show-snackbar'; + +import { getNodesWithVariable } from '../../actions/get-nodes-with-variable'; +import { VARIABLE_BRACKETS_END, VARIABLE_BRACKETS_START } from '../../constants'; +import { openModalSchemaBuilderVariableConfig } from '../../modals/control/modal-schema-builder-variable-config'; +import { openModalSchemaBuilderVariableRemoval } from '../../modals/control/modal-schema-builder-variable-remove'; +import type { VariablesIndex } from '../../types'; +import { getEmptyVariableDefinition } from '../../utils/get-empty-variable-definition'; +import { getVariableReferenceWithoutBracketsForNode } from '../../utils/keys/get-variable-reference-without-brackets-for-node'; +import { VariablePreview } from '../variable-preview/variable-preview'; + +type Props = { + isDisabled: boolean; + // If filled it will validate if it is used + nodeId: string | undefined; + value: VariablesIndex; + onChange: (value: VariablesIndex) => void; +}; + +export function SchemaBuilder({ isDisabled, value, onChange, nodeId }: Props) { + const { t } = useTranslation(); + + const handleAddVariable = useCallback(() => { + openModalSchemaBuilderVariableConfig({ + variant: 'add', + variable: getEmptyVariableDefinition(), + isReadOnly: isDisabled, + onSave: (variable) => + onChange({ + ...value, + [variable.id]: variable, + }), + variablesById: value, + }); + }, [isDisabled, onChange, value]); + + const handleEditVariable = useCallback( + (variableId: string) => { + if (!value[variableId]) { + showSnackbar({ + title: 'variableWasNotFound', + variant: SnackbarType.ERROR, + }); + + return; + } + + const variableReference = nodeId + ? `${VARIABLE_BRACKETS_START}${getVariableReferenceWithoutBracketsForNode({ nodeId, propertyName: variableId })}${VARIABLE_BRACKETS_END}` + : ''; + + const nodesWithVariable = variableReference ? getNodesWithVariable(variableReference) : []; + + openModalSchemaBuilderVariableConfig({ + variant: nodesWithVariable.length > 0 ? 'edit-limited-strict' : 'edit', + variable: value[variableId], + isReadOnly: isDisabled, + onSave: (variable) => { + const newValue = { ...value }; + // Remove the old variable + delete newValue[variableId]; + + return onChange({ + ...newValue, + [variable.id]: variable, + }); + }, + variablesById: value, + }); + }, + [isDisabled, nodeId, onChange, value], + ); + + const handleRemove = useCallback( + (variableId: string) => { + const variableReference = nodeId + ? `${VARIABLE_BRACKETS_START}${getVariableReferenceWithoutBracketsForNode({ nodeId, propertyName: variableId })}${VARIABLE_BRACKETS_END}` + : ''; + + const nodesWithVariable = variableReference ? getNodesWithVariable(variableReference) : []; + + openModalSchemaBuilderVariableRemoval({ + variable: value[variableId], + isReadOnly: isDisabled, + onRemove: () => { + const newValue = { ...value }; + // Remove the old variable + delete newValue[variableId]; + + onChange({ + ...newValue, + }); + }, + nodesWithVariable, + }); + }, + [isDisabled, nodeId, onChange, value], + ); + + const variables = Object.values(value).filter(filterEmpty); + + return ( +
+ {variables.length === 0 && ( + + )} + {variables.map((variable) => ( + handleEditVariable(variable.id)} + onRemove={() => handleRemove(variable.id)} + /> + ))} + +
+ ); +} diff --git a/packages/sdk/src/features/variables/modals/tab-global-variables/variable-preview/variable-meta.module.css b/packages/sdk/src/features/variables/components/variable-preview/variable-meta.module.css similarity index 68% rename from packages/sdk/src/features/variables/modals/tab-global-variables/variable-preview/variable-meta.module.css rename to packages/sdk/src/features/variables/components/variable-preview/variable-meta.module.css index 0ae6766f0..2782e4d8c 100644 --- a/packages/sdk/src/features/variables/modals/tab-global-variables/variable-preview/variable-meta.module.css +++ b/packages/sdk/src/features/variables/components/variable-preview/variable-meta.module.css @@ -1,8 +1,3 @@ -:root { - --wb-variable-bg: var(--wb-color-violet-400-20); - --wb-variable-txt: #6b3bff; -} - .container { display: flex; gap: 0.75rem; @@ -15,13 +10,14 @@ line-height: 1.3125rem; display: inline-flex; font-family: 'Courier New'; + color: #6b3bff; border-radius: 4px; padding: 4px 8px 3px 8px; - color: var(--ax-txt-primary-default); - background: var(--wb-variable-bg); + background: #f4f0ff; display: -webkit-box; -webkit-line-clamp: 1; line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; + word-break: break-all; } diff --git a/packages/sdk/src/features/variables/modals/tab-global-variables/variable-preview/variable-meta.tsx b/packages/sdk/src/features/variables/components/variable-preview/variable-meta.tsx similarity index 50% rename from packages/sdk/src/features/variables/modals/tab-global-variables/variable-preview/variable-meta.tsx rename to packages/sdk/src/features/variables/components/variable-preview/variable-meta.tsx index af0e29134..ab1130759 100644 --- a/packages/sdk/src/features/variables/modals/tab-global-variables/variable-preview/variable-meta.tsx +++ b/packages/sdk/src/features/variables/components/variable-preview/variable-meta.tsx @@ -2,19 +2,21 @@ import clsx from 'clsx'; import styles from './variable-meta.module.css'; -import { variableTypeInfoByType } from '../../../../../features/variables/constants'; -import type { VariableTypePrimitive } from '../../../../../node/node-output-schema'; +import type { VariableType } from '../../../../node/node-output-schema'; +import { variableTypeInfoByType } from '../../constants'; type Props = { className?: string; name: string; - type: VariableTypePrimitive; + type: VariableType; }; export function VariableMeta({ className = '', name, type }: Props) { + const typeLabel = variableTypeInfoByType[type]?.label || type; + return (
- {name}|{variableTypeInfoByType[type].label} + {name}|{typeLabel}
); } diff --git a/packages/sdk/src/features/variables/modals/tab-global-variables/variable-preview/variable-preview.module.css b/packages/sdk/src/features/variables/components/variable-preview/variable-preview.module.css similarity index 87% rename from packages/sdk/src/features/variables/modals/tab-global-variables/variable-preview/variable-preview.module.css rename to packages/sdk/src/features/variables/components/variable-preview/variable-preview.module.css index 20fdaa2e9..d7d24774e 100644 --- a/packages/sdk/src/features/variables/modals/tab-global-variables/variable-preview/variable-preview.module.css +++ b/packages/sdk/src/features/variables/components/variable-preview/variable-preview.module.css @@ -5,8 +5,8 @@ padding: var(--wb-token-spacing-spacing-12, 12px) var(--wb-token-spacing-spacing-16, 16px) var(--wb-token-spacing-spacing-12, 12px) 16px; border-radius: 8px; - border: 1px solid var(--ax-ui-bg-secondary-default); - background: var(--ax-ui-bg-secondary-default); + border: 1px solid #e5e5e5; + background: #f9f9f9; transition: all var(--wb-transition); &:hover { @@ -30,6 +30,10 @@ transition: all var(--wb-transition); } +.name { + word-break: break-all; +} + .description { margin: 0; color: var(--wb-txt-tertiary-default); @@ -38,6 +42,7 @@ line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; + word-break: break-all; &:empty { display: none; diff --git a/packages/sdk/src/features/variables/modals/tab-global-variables/variable-preview/variable-preview.tsx b/packages/sdk/src/features/variables/components/variable-preview/variable-preview.tsx similarity index 77% rename from packages/sdk/src/features/variables/modals/tab-global-variables/variable-preview/variable-preview.tsx rename to packages/sdk/src/features/variables/components/variable-preview/variable-preview.tsx index 607d1efac..79794f487 100644 --- a/packages/sdk/src/features/variables/modals/tab-global-variables/variable-preview/variable-preview.tsx +++ b/packages/sdk/src/features/variables/components/variable-preview/variable-preview.tsx @@ -6,25 +6,21 @@ import { Icon } from '@workflow-builder/icons'; import styles from './variable-preview.module.css'; -import { useStore } from '../../../../../store/store'; +import type { VariableDefinition } from '../../types'; import { VariableMeta } from './variable-meta'; -type Props = { - id: string; +export type VariablePreviewProps = { + className?: string; + variable: VariableDefinition; onEdit?: () => void; onRemove?: () => void; }; -export function VariablePreview({ id, onEdit, onRemove }: Props) { - const variable = useStore((store) => store.globalVariables[id]); +export function VariablePreview({ className = '', variable, onEdit, onRemove }: VariablePreviewProps) { const { t } = useTranslation(); - if (!variable) { - return null; - } - return ( -
+
diff --git a/packages/sdk/src/features/variables/components/variable-preview/wrappers/variable-preview-global.tsx b/packages/sdk/src/features/variables/components/variable-preview/wrappers/variable-preview-global.tsx new file mode 100644 index 000000000..7b7fcd09d --- /dev/null +++ b/packages/sdk/src/features/variables/components/variable-preview/wrappers/variable-preview-global.tsx @@ -0,0 +1,18 @@ +import { useStore } from '../../../../../store/store'; +import { VariablePreview, type VariablePreviewProps } from '../variable-preview'; + +type Props = Omit & { + id: string; + onEdit?: () => void; + onRemove?: () => void; +}; + +export function GlobalVariablePreview(props: Props) { + const variable = useStore((store) => store.globalVariables[props.id]); + + if (!variable) { + return null; + } + + return ; +} diff --git a/packages/sdk/src/features/variables/components/variable-select/variable-select.module.css b/packages/sdk/src/features/variables/components/variable-select/variable-select.module.css index b5f4e16e0..73fcf8d32 100644 --- a/packages/sdk/src/features/variables/components/variable-select/variable-select.module.css +++ b/packages/sdk/src/features/variables/components/variable-select/variable-select.module.css @@ -1,5 +1,11 @@ .container { position: relative; + + &:has([class*='control--error']) { + .adornment button { + color: var(--ax-public-input-root-color-error); + } + } } .adornment { @@ -9,3 +15,7 @@ transform: translateY(-50%); z-index: 1; } + +.control { + height: 2.5625rem; +} diff --git a/packages/sdk/src/features/variables/components/variable-select/variable-select.tsx b/packages/sdk/src/features/variables/components/variable-select/variable-select.tsx index 9735a9dbf..4e1bd5bc0 100644 --- a/packages/sdk/src/features/variables/components/variable-select/variable-select.tsx +++ b/packages/sdk/src/features/variables/components/variable-select/variable-select.tsx @@ -1,10 +1,11 @@ +import clsx from 'clsx'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import styles from './variable-select.module.css'; import { focusNextElement } from '../../../../utils/a11y'; -import { getIsSingleVariable } from '../../actions/get-is-single-variable'; +import { getIsStringVariableReference } from '../../utils/keys/get-is-string-variable-reference'; import { VariableText } from '../variable-text/variable-text'; import type { VariableTextProps } from '../variable-text/variable-text.types'; @@ -12,13 +13,13 @@ type Props = VariableTextProps & { endAdornment?: React.ReactNode; }; -export function VariableSelect({ onChange, endAdornment, ...props }: Props) { +export function VariableSelect({ onChange, onBlur, endAdornment, ...props }: Props) { const { t } = useTranslation(); const handleOnChange: VariableTextProps['onChange'] = useCallback( (value) => { const newValue = value ? `{{` + value.split('{{').at(-1) : ''; - const valueToPass = getIsSingleVariable(newValue) ? newValue : ''; + const valueToPass = getIsStringVariableReference(newValue) ? newValue : ''; onChange(valueToPass); focusNextElement(); @@ -26,16 +27,32 @@ export function VariableSelect({ onChange, endAdornment, ...props }: Props) { [onChange], ); + const handleOnBlur: VariableTextProps['onBlur'] = useCallback( + (value: string) => { + if (!onBlur) { + return; + } + + const newValue = value ? `{{` + value.split('{{').at(-1) : ''; + const valueToPass = getIsStringVariableReference(newValue) ? newValue : ''; + + onBlur(valueToPass); + }, + [onBlur], + ); + return (
- {endAdornment && {endAdornment}} + {endAdornment && {endAdornment}}
); } diff --git a/packages/sdk/src/features/variables/components/variable-text/variable-text.module.css b/packages/sdk/src/features/variables/components/variable-text/variable-text.module.css index 1db39b970..58d84c3cb 100644 --- a/packages/sdk/src/features/variables/components/variable-text/variable-text.module.css +++ b/packages/sdk/src/features/variables/components/variable-text/variable-text.module.css @@ -10,6 +10,17 @@ --wb-variable-backdrop: color-mix(in srgb, var(--ax-public-modal-backdrop-background), transparent 50%); } +.container:has(+ :global(.right-adornment)) { + .control { + padding-right: 2rem; + + > div { + /* Div inside with highlighted variables takes full width anyway and this hides it below adornment */ + mask: linear-gradient(to right, white, white calc(100% - 3rem), transparent calc(100% - 2rem), transparent); + } + } +} + .control { position: relative; border: var(--ax-public-input-root-border-size) solid var(--ax-public-input-root-border-color); @@ -145,7 +156,6 @@ body:has(.suggestionsContainer) { .suggestionsTitle { composes: ax-public-h10 from global; - color: var(--ax-txt-primary-default); } .suggestionsClose { @@ -172,9 +182,8 @@ body:has(.suggestionsContainer) { .groupHeader { composes: ax-public-h10 from global; - color: var(--ax-txt-primary-default); display: flex; - color: var(--ax-txt-primary-default); + display: flex; align-items: center; gap: var(--wb-token-spacing-spacing-4, 4px); flex: 1 0 0; diff --git a/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx b/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx index 2374ca35a..bcb27d029 100644 --- a/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx +++ b/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx @@ -1,5 +1,6 @@ import { NavButton, SnackbarType } from '@synergycodes/overflow-ui'; -import { type ReactElement, type ReactNode, cloneElement, useCallback, useMemo } from 'react'; +import clsx from 'clsx'; +import { type ReactElement, type ReactNode, cloneElement, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Mention, type MentionDataItem, MentionsInput } from 'react-mentions-ts'; @@ -8,8 +9,9 @@ import { Icon } from '@workflow-builder/icons'; import styles from './variable-text.module.css'; import type { VariableType } from '../../../../node/node-output-schema'; +import { getNodeByIdAction } from '../../../../store-get-actions/stores/use-store-get-actions'; import { showSnackbar } from '../../../../utils/show-snackbar'; -import { VARIABLE_BRACKETS_START, VARIABLE_NODES_KEY } from '../../constants'; +import { VARIABLE_BRACKETS_END, VARIABLE_BRACKETS_START, VARIABLE_NODES_KEY } from '../../constants'; import type { VariableSuggestion, VariableSuggestionGroup, VariableTextProps } from './variable-text.types'; const DEFAULT_TRIGGER = '{{'; @@ -47,7 +49,7 @@ function buildMentionData(groups: VariableSuggestionGroup[]): VariableMentionDat return groups.flatMap((group) => group.suggestions.map((suggestion) => ({ id: suggestion.id, - display: suggestion.display, + display: `{{ ${suggestion.display} }}`, groupLabel: group.label, label: suggestion.label, description: suggestion.description, @@ -167,6 +169,7 @@ export function VariableText({ classNameWrapper, value, onChange, + onBlur, variant = 'text', suggestionGroups, title = DEFAULT_TITLE, @@ -177,6 +180,7 @@ export function VariableText({ mentionProps, }: VariableTextProps) { const { t } = useTranslation(); + const refValueForBlur = useRef(value); const singleLine = variant === 'text'; const mentionData = useMemo(() => buildMentionData(suggestionGroups), [suggestionGroups]); @@ -188,11 +192,18 @@ export function VariableText({ const item = mentionData.find((m) => m.id === typedId); if (item) { - return item.display ? `{{ ${item.display} }}` : defaultLabel; + return item.display || defaultLabel; } if (typedId.startsWith(VARIABLE_NODES_KEY)) { const nodeId = typedId.replace(`${VARIABLE_NODES_KEY}.`, '').split('.').at(0) || ''; + + const node = getNodeByIdAction(nodeId); + + if (node) { + return `{{ ${node.data?.properties?.label} · ${t('plugins.validation.missingMentionNodeVariablePrefix')} · ${typedId.split('.').at(-1)} }}`; + } + return `{{ ${t('plugins.validation.missingMentionNodePrefix')} (${nodeId.slice(0, 4)}...) · ${typedId.split('.').at(-1)} }}`; } @@ -242,11 +253,38 @@ export function VariableText({ }); } + refValueForBlur.current = value; + onChange(value); }, [mentionData.length, onChange], ); + const handleFocus = useCallback( + (event: { target: { value: string } }) => { + let value = event.target.value; + + for (const variable of mentionData) { + if (variable.display) { + value = value.replaceAll( + variable.display, + `${VARIABLE_BRACKETS_START}${variable.id}${VARIABLE_BRACKETS_END}`, + ); + } + } + + refValueForBlur.current = value; + }, + [mentionData], + ); + + const handleBlur = useCallback(() => { + // We can't rely on the value from the onBlur event because the value is updated afterward. + if (onBlur) { + onBlur(refValueForBlur.current); + } + }, [onBlur]); + const { trigger = DEFAULT_TRIGGER, markup = DEFAULT_MARKUP, @@ -256,8 +294,8 @@ export function VariableText({ const classNames = useMemo(() => { const base = singleLine ? singleLineClassNames : multiLineClassNames; - let control = base.control; + let control = base.control; if (hasError) { control = control + ' ' + styles['control--error']; } @@ -269,14 +307,15 @@ export function VariableText({ ...base, control, }; - }, [hasError, singleLine, className]); + }, [className, hasError, singleLine]); return ( void; + onBlur?: (value: string) => void; variant?: 'text' | 'text-area'; suggestionGroups: VariableSuggestionGroup[]; diff --git a/packages/sdk/src/features/variables/constants.ts b/packages/sdk/src/features/variables/constants.ts index 1b1d3b1e8..7381c0929 100644 --- a/packages/sdk/src/features/variables/constants.ts +++ b/packages/sdk/src/features/variables/constants.ts @@ -1,14 +1,13 @@ -import type { VariableType, VariableTypePrimitive } from '../../node/node-output-schema'; +import type { VariableType, VariableTypePrimitive } from '@workflow-builder/types/node-output-schema'; -export type LogicalOperator = 'OR' | 'AND'; +export const NODE_ID_FOR_COMMON_NODE_DATA = ''; + +export const LOGICAL_OPERATOR = { + OR: 'OR', + AND: 'AND', +} as const; +export type LogicalOperator = (typeof LOGICAL_OPERATOR)[keyof typeof LOGICAL_OPERATOR]; -/** - * String literal union of comparison operators recognised by the - * dynamic-conditions / decision-branches controls (`'isEqual'`, - * `'isGreaterThan'`, `'isContaining'`, …). - * - * @category Forms - */ // eslint-disable-next-line @typescript-eslint/no-unused-vars const comparisonsOperators = [ 'isEqual', @@ -51,33 +50,16 @@ export const comparisonOperatorsByPrimitiveType: Record = { +export const variableTypeInfoByType: Record = { string: { type: 'string', baseType: 'string', @@ -103,17 +85,16 @@ export const variableTypeInfoByType: Record store.globalVariables); const nodes = useStore((store) => store.nodes); const edges = useStore((store) => store.edges); @@ -19,26 +22,29 @@ export function useAvailableVariables( const { t } = useTranslation(); const globalSuggestionsGroups = useMemo(() => { - const suggestions: VariableSuggestion[] = Object.values(globalVariables) - .filter(filterEmpty) - .map((definition) => { - return { - id: getGlobalVariableKey(definition.id), - display: truncate(definition.name, 25), - label: definition.name, - description: definition.description, - type: definition.type, - }; - }); - - const globalGroup: VariableSuggestionGroup = { - label: t('workflowsSettings.tab.globalVariables'), - icon: 'Gear', + const suggestions: VariableSuggestion[] = getSuggestionsFromVariableIndex({ + variablesIndex: globalVariables, + variant: 'global', + }); + + const filteredSuggestions = filterSuggestionsByTypes({ suggestions, - }; + excludeTypes, + includeTypes, + }); + + if (filteredSuggestions.length > 0) { + const globalGroup: VariableSuggestionGroup = { + label: t('workflowsSettings.tab.globalVariables'), + icon: 'Gear', + suggestions: filteredSuggestions, + }; + + return [globalGroup]; + } - return [globalGroup]; - }, [globalVariables, t]); + return []; + }, [excludeTypes, globalVariables, includeTypes, t]); const nodeSuggestionsGroups = useMemo(() => { return getAvailableVariablesByNodeId({ @@ -46,6 +52,7 @@ export function useAvailableVariables( nodes, edges, excludeTypes, + includeTypes, }); // .length is critical here for performance. diff --git a/packages/sdk/src/features/variables/hooks/use-refresh-variables.ts b/packages/sdk/src/features/variables/hooks/use-refresh-variables.ts new file mode 100644 index 000000000..2312c0b66 --- /dev/null +++ b/packages/sdk/src/features/variables/hooks/use-refresh-variables.ts @@ -0,0 +1,71 @@ +import { useEffect, useRef } from 'react'; + +import { useChangesTrackerStore } from '@/features/changes-tracker/stores/use-changes-tracker-store'; + +import { refreshAllSuggestions, refreshNodesIdsSuggestions } from '../stores/core/refresh-suggestions'; + +type Refresh = { + type: 'partial' | 'global'; + nodesIds: Set; +}; + +const REFRESH_ALL_VARIABLES_DELAY_MS = 100; +const REFRESH_PART_VARIABLES_DELAY_MS = 100; + +function useRefreshVariables() { + const timeoutRef = useRef | null>(null); + const refreshRef = useRef({ + type: 'partial', + nodesIds: new Set(), + }); + const lastChangeName = useChangesTrackerStore((store) => store.lastChangeName); + const lastChangeParams = useChangesTrackerStore((store) => store.lastChangeParams); + + useEffect(() => { + const wasNodeUpdated = ['dataUpdateNode', 'addNode'].includes(lastChangeName); + const wasDiagramReloaded = ['undo', 'redo', 'import'].includes(lastChangeName); + + const shouldRefreshVariables = wasNodeUpdated || wasDiagramReloaded; + if (!shouldRefreshVariables) { + return; + } + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + if (wasNodeUpdated) { + const nodeId = (lastChangeParams as unknown as { id?: string })?.id || ''; + if (nodeId) { + refreshRef.current.nodesIds.add(nodeId); + } + } + + if (wasDiagramReloaded) { + refreshRef.current.type = 'global'; + } + + if (refreshRef.current.type === 'global') { + timeoutRef.current = setTimeout(() => { + refreshAllSuggestions(); + refreshRef.current = { + type: 'partial', + nodesIds: new Set(), + }; + }, REFRESH_ALL_VARIABLES_DELAY_MS); + + return; + } + + timeoutRef.current = setTimeout(() => { + refreshNodesIdsSuggestions([...refreshRef.current.nodesIds]); + + refreshRef.current = { + type: 'partial', + nodesIds: new Set(), + }; + }, REFRESH_PART_VARIABLES_DELAY_MS); + }, [lastChangeName, lastChangeParams]); +} + +export default useRefreshVariables; diff --git a/packages/sdk/src/features/variables/modals/control/README.md b/packages/sdk/src/features/variables/modals/control/README.md new file mode 100644 index 000000000..86b3692b6 --- /dev/null +++ b/packages/sdk/src/features/variables/modals/control/README.md @@ -0,0 +1,3 @@ +# Global + +**The control settings** modal allows users to configure schema from properties sidebar. diff --git a/packages/sdk/src/features/variables/modals/control/modal-schema-builder-variable-config.module.css b/packages/sdk/src/features/variables/modals/control/modal-schema-builder-variable-config.module.css new file mode 100644 index 000000000..5bfbb7c6f --- /dev/null +++ b/packages/sdk/src/features/variables/modals/control/modal-schema-builder-variable-config.module.css @@ -0,0 +1,3 @@ +.container { + width: 100%; +} diff --git a/packages/sdk/src/features/variables/modals/control/modal-schema-builder-variable-config.tsx b/packages/sdk/src/features/variables/modals/control/modal-schema-builder-variable-config.tsx new file mode 100644 index 000000000..19cdce89d --- /dev/null +++ b/packages/sdk/src/features/variables/modals/control/modal-schema-builder-variable-config.tsx @@ -0,0 +1,64 @@ +import { SnackbarType } from '@synergycodes/overflow-ui'; +import i18n from 'i18next'; +import { useCallback } from 'react'; + +import { Icon } from '@workflow-builder/icons'; + +import styles from './modal-schema-builder-variable-config.module.css'; + +import { showSnackbar } from '../../../../utils/show-snackbar'; +import { labelToFloorCase } from '../../../../utils/text'; +import { closeModal, openModal } from '../../../modals/stores/use-modal-store'; +import type { VariableDefinition, VariablesIndex } from '../../types'; +import { + PaneEditVariable, + type PaneEditVariableProps, +} from '../shared/components/pane-edit-variable/pane-edit-variable'; +import { VARIABLE_FORM_VARIANT } from '../shared/components/variable-form/variable-form'; + +type Props = { + isReadOnly: boolean; + variablesById: VariablesIndex; +} & Pick; + +function ModalSchemaBuilderVariableConfig(props: Props) { + const handleSave: PaneEditVariableProps['onSave'] = useCallback( + (definition: VariableDefinition) => { + const floorIdForAPI = labelToFloorCase(definition.name); + + if (props.variant === VARIABLE_FORM_VARIANT.ADD && props.variablesById[floorIdForAPI]) { + showSnackbar({ + title: 'variableNameAlreadyExists', + variant: SnackbarType.ERROR, + }); + + throw 'variableNameAlreadyExists'; + } + + props.onSave({ + ...definition, + id: floorIdForAPI, + }); + + closeModal(); + }, + [props], + ); + + return ( +
+ +
+ ); +} + +export function openModalSchemaBuilderVariableConfig(props: Props) { + openModal({ + content: , + icon: , + title: + props.variant === VARIABLE_FORM_VARIANT.ADD + ? i18n.t('workflowsSettings.tab.addVariable') + : i18n.t('workflowsSettings.tab.editVariable'), + }); +} diff --git a/packages/sdk/src/features/variables/modals/control/modal-schema-builder-variable-remove.tsx b/packages/sdk/src/features/variables/modals/control/modal-schema-builder-variable-remove.tsx new file mode 100644 index 000000000..c883f5297 --- /dev/null +++ b/packages/sdk/src/features/variables/modals/control/modal-schema-builder-variable-remove.tsx @@ -0,0 +1,38 @@ +import i18n from 'i18next'; +import { useCallback } from 'react'; + +import { Icon } from '@workflow-builder/icons'; + +import styles from './modal-schema-builder-variable-config.module.css'; + +import { closeModal, openModal } from '../../../modals/stores/use-modal-store'; +import { + PaneRemoveVariable, + type PaneRemoveVariableProps, +} from '../shared/components/pane-remove-variable/pane-remove-variable'; + +type Props = { + isReadOnly: boolean; + onRemove: () => void; +} & Pick; + +function ModalSchemaBuilderVariableRemoval(props: Props) { + const handleRemove = useCallback(() => { + props.onRemove(); + closeModal(); + }, [props]); + + return ( +
+ +
+ ); +} + +export function openModalSchemaBuilderVariableRemoval(props: Props) { + openModal({ + content: , + icon: , + title: i18n.t('workflowsSettings.tab.removeVariable'), + }); +} diff --git a/packages/sdk/src/features/variables/modals/global/README.md b/packages/sdk/src/features/variables/modals/global/README.md new file mode 100644 index 000000000..d2db86b5b --- /dev/null +++ b/packages/sdk/src/features/variables/modals/global/README.md @@ -0,0 +1,3 @@ +# Global + +**The global settings** modal allows users to configure global variables. diff --git a/packages/sdk/src/features/variables/modals/constants.ts b/packages/sdk/src/features/variables/modals/global/constants.ts similarity index 100% rename from packages/sdk/src/features/variables/modals/constants.ts rename to packages/sdk/src/features/variables/modals/global/constants.ts diff --git a/packages/sdk/src/features/variables/modals/modal-settings.module.css b/packages/sdk/src/features/variables/modals/global/modal-settings.module.css similarity index 95% rename from packages/sdk/src/features/variables/modals/modal-settings.module.css rename to packages/sdk/src/features/variables/modals/global/modal-settings.module.css index 7e9208e82..c4b0ecb42 100644 --- a/packages/sdk/src/features/variables/modals/modal-settings.module.css +++ b/packages/sdk/src/features/variables/modals/global/modal-settings.module.css @@ -10,7 +10,6 @@ display: flex; flex-flow: column; gap: 2rem; - color: var(--ax-txt-secondary-default); @media (width > 800px) { flex-flow: row; diff --git a/packages/sdk/src/features/variables/modals/modal-settings.tsx b/packages/sdk/src/features/variables/modals/global/modal-settings.tsx similarity index 76% rename from packages/sdk/src/features/variables/modals/modal-settings.tsx rename to packages/sdk/src/features/variables/modals/global/modal-settings.tsx index 5efaf4de3..7903d5a49 100644 --- a/packages/sdk/src/features/variables/modals/modal-settings.tsx +++ b/packages/sdk/src/features/variables/modals/global/modal-settings.tsx @@ -5,12 +5,17 @@ import { Icon } from '@workflow-builder/icons'; import styles from './modal-settings.module.css'; -import { openModal } from '../../../features/modals/stores/use-modal-store'; +import { useStore } from '../../../../store/store'; +import { openModal } from '../../../modals/stores/use-modal-store'; import { SETTINGS_TABS, type SettingsTab } from './constants'; import { SettingsNavigation } from './settings/settings-navigation'; import { TabActive } from './tab/tab-active'; -function ModalWorkflowSettings() { +type Props = { + isReadOnly?: boolean; +}; + +function ModalWorkflowSettings({ isReadOnly }: Props) { const [{ activeTab, lastPickedTimestamp }, setActiveTab] = useState<{ activeTab: SettingsTab; lastPickedTimestamp: number; @@ -30,15 +35,17 @@ function ModalWorkflowSettings() {
- +
); } export function openModalWorkflowSettings() { + const isReadOnly = useStore.getState().isReadOnlyMode; + openModal({ - content: , + content: , icon: , title: i18n.t('workflowsSettings.modalTitle'), }); diff --git a/packages/sdk/src/features/variables/modals/settings/settings-navigation.module.css b/packages/sdk/src/features/variables/modals/global/settings/settings-navigation.module.css similarity index 81% rename from packages/sdk/src/features/variables/modals/settings/settings-navigation.module.css rename to packages/sdk/src/features/variables/modals/global/settings/settings-navigation.module.css index 6b3bdea6f..8c1b05922 100644 --- a/packages/sdk/src/features/variables/modals/settings/settings-navigation.module.css +++ b/packages/sdk/src/features/variables/modals/global/settings/settings-navigation.module.css @@ -1,7 +1,3 @@ -:root { - --wb-settings-navigation-background: var(--ax-public-modal-header-background); -} - .container { display: flex; flex-flow: column; @@ -10,7 +6,7 @@ text-align: left; gap: var(--wb-token-spacing-operator-wrap-gap, 4px); border-radius: var(--wb-token-radius-dropdown-wrap-s, 8px); - background: var(--wb-settings-navigation-background); + background: var(--wb-dropdown-bg-primary-default, #f9faf9); } .button { @@ -36,7 +32,7 @@ &:hover, &.button--active { - background: var(--ax-public-button-nav-background-color); + background: var(--wb-nav-button-bg-primary-hover, rgba(26, 26, 25, 0.02)); } &.button--active { diff --git a/packages/sdk/src/features/variables/modals/settings/settings-navigation.tsx b/packages/sdk/src/features/variables/modals/global/settings/settings-navigation.tsx similarity index 100% rename from packages/sdk/src/features/variables/modals/settings/settings-navigation.tsx rename to packages/sdk/src/features/variables/modals/global/settings/settings-navigation.tsx diff --git a/packages/sdk/src/features/variables/modals/tab-general/tab-general.module.css b/packages/sdk/src/features/variables/modals/global/tab-general/tab-general.module.css similarity index 100% rename from packages/sdk/src/features/variables/modals/tab-general/tab-general.module.css rename to packages/sdk/src/features/variables/modals/global/tab-general/tab-general.module.css diff --git a/packages/sdk/src/features/variables/modals/tab-general/tab-general.tsx b/packages/sdk/src/features/variables/modals/global/tab-general/tab-general.tsx similarity index 80% rename from packages/sdk/src/features/variables/modals/tab-general/tab-general.tsx rename to packages/sdk/src/features/variables/modals/global/tab-general/tab-general.tsx index 9c1b2cbf8..dc6f4fb82 100644 --- a/packages/sdk/src/features/variables/modals/tab-general/tab-general.tsx +++ b/packages/sdk/src/features/variables/modals/global/tab-general/tab-general.tsx @@ -2,11 +2,12 @@ import clsx from 'clsx'; import styles from './tab-general.module.css'; -import { ToggleDarkMode } from '../../../../features/app-bar/components/toggle-dark-mode/toggle-dark-mode'; +import { ToggleDarkMode } from '../../../../app-bar/components/toggle-dark-mode/toggle-dark-mode'; import { TabHeader } from '../tab/tab-header'; type Props = { className?: string; + isReadOnly?: boolean; }; export function TabGeneral({ className }: Props) { diff --git a/packages/sdk/src/features/variables/modals/global/tab-global-variables/panes/pane-edit-variable-global.tsx b/packages/sdk/src/features/variables/modals/global/tab-global-variables/panes/pane-edit-variable-global.tsx new file mode 100644 index 000000000..db2d6a313 --- /dev/null +++ b/packages/sdk/src/features/variables/modals/global/tab-global-variables/panes/pane-edit-variable-global.tsx @@ -0,0 +1,64 @@ +import { SnackbarType } from '@synergycodes/overflow-ui'; +import { useCallback, useMemo } from 'react'; + +import { getStoreVariables, saveVariableDefinition } from '../../../../../../store/slices/diagram-slice/actions'; +import { useStore } from '../../../../../../store/store'; +import { filterEmpty } from '../../../../../../utils/array'; +import { showSnackbar } from '../../../../../../utils/show-snackbar'; +import { getNodesWithVariable } from '../../../../actions/get-nodes-with-variable'; +import type { VariableDefinition } from '../../../../types'; +import { getVariableReferenceWithoutBracketsForGlobal } from '../../../../utils/keys/get-variable-reference-without-brackets-for-global'; +import { VARIABLE_PANE } from '../../../shared/components/constants'; +import { + PaneEditVariable, + type PaneEditVariableProps, +} from '../../../shared/components/pane-edit-variable/pane-edit-variable'; + +type Props = { + id: string; +} & Omit & + Required>; + +export function PaneEditVariableGlobal({ className, setActivePane, id, isReadOnly }: Props) { + const variable = useStore((store) => store.globalVariables[id]); + + const handleSave = useCallback( + (definition: VariableDefinition) => { + const variables = getStoreVariables(); + + const variableWithName = Object.values(variables) + .filter(filterEmpty) + .find(({ id, name }) => id !== definition.id && name.toLowerCase() === definition.name.toLowerCase()); + + if (variableWithName) { + showSnackbar({ + title: 'variableNameAlreadyExists', + variant: SnackbarType.ERROR, + }); + + throw 'variableNameAlreadyExists'; + } + + saveVariableDefinition(definition); + setActivePane(VARIABLE_PANE.LIST); + }, + [setActivePane], + ); + + const nodesWithVariable = useMemo(() => { + const variableKey = getVariableReferenceWithoutBracketsForGlobal(id); + + return getNodesWithVariable(variableKey); + }, [id]); + + return ( + 0 ? 'edit-limited' : 'edit'} + isReadOnly={isReadOnly} + variable={variable} + onSave={handleSave} + /> + ); +} diff --git a/packages/sdk/src/features/variables/modals/global/tab-global-variables/panes/pane-remove-variable-global.tsx b/packages/sdk/src/features/variables/modals/global/tab-global-variables/panes/pane-remove-variable-global.tsx new file mode 100644 index 000000000..92de901ac --- /dev/null +++ b/packages/sdk/src/features/variables/modals/global/tab-global-variables/panes/pane-remove-variable-global.tsx @@ -0,0 +1,41 @@ +import { useCallback, useMemo } from 'react'; + +import { removeVariableDefinition } from '../../../../../../store/slices/diagram-slice/actions'; +import { useStore } from '../../../../../../store/store'; +import { getNodesWithVariable } from '../../../../actions/get-nodes-with-variable'; +import { getVariableReferenceWithoutBracketsForGlobal } from '../../../../utils/keys/get-variable-reference-without-brackets-for-global'; +import { VARIABLE_PANE } from '../../../shared/components/constants'; +import { + PaneRemoveVariable, + type PaneRemoveVariableProps, +} from '../../../shared/components/pane-remove-variable/pane-remove-variable'; + +type Props = { + id: string; +} & Omit & + Required>; + +export function PaneRemoveVariableGlobal({ className, setActivePane, id }: Props) { + const variable = useStore((store) => store.globalVariables[id]); + + const handleRemove = useCallback(() => { + removeVariableDefinition(id); + setActivePane(VARIABLE_PANE.LIST); + }, [id, setActivePane]); + + const nodesWithVariable = useMemo(() => { + const variableKey = getVariableReferenceWithoutBracketsForGlobal(id); + + return getNodesWithVariable(variableKey); + }, [id]); + + return ( + + ); +} diff --git a/packages/sdk/src/features/variables/modals/tab-global-variables/tab-global-variables.module.css b/packages/sdk/src/features/variables/modals/global/tab-global-variables/tab-global-variables.module.css similarity index 100% rename from packages/sdk/src/features/variables/modals/tab-global-variables/tab-global-variables.module.css rename to packages/sdk/src/features/variables/modals/global/tab-global-variables/tab-global-variables.module.css diff --git a/packages/sdk/src/features/variables/modals/global/tab-global-variables/tab-global-variables.tsx b/packages/sdk/src/features/variables/modals/global/tab-global-variables/tab-global-variables.tsx new file mode 100644 index 000000000..341faa5e0 --- /dev/null +++ b/packages/sdk/src/features/variables/modals/global/tab-global-variables/tab-global-variables.tsx @@ -0,0 +1,56 @@ +import clsx from 'clsx'; +import { useCallback, useState } from 'react'; + +import styles from './tab-global-variables.module.css'; + +import { VARIABLE_PANE, type VariablePane } from '../../shared/components/constants'; +import { PaneAddVariable } from '../../shared/components/pane-add-variable/pane-add-variable'; +import { PaneList } from '../../shared/components/pane-list/pane-list'; +import { PaneEditVariableGlobal } from './panes/pane-edit-variable-global'; +import { PaneRemoveVariableGlobal } from './panes/pane-remove-variable-global'; + +type Props = { + className?: string; + isReadOnly?: boolean; +}; + +export function TabGlobalVariables({ className, isReadOnly }: Props) { + const [{ activePane, id }, setActivePaneOriginal] = useState<{ + activePane: VariablePane; + id?: string; + }>({ activePane: VARIABLE_PANE.LIST }); + + const setActivePane = useCallback((pane: VariablePane, id: string = '') => { + setActivePaneOriginal({ + activePane: pane, + id, + }); + }, []); + + if (activePane === VARIABLE_PANE.ADD && !isReadOnly) { + return ; + } + + if (activePane === VARIABLE_PANE.EDIT && id) { + return ( + + ); + } + + if (activePane === VARIABLE_PANE.REMOVE && id && !isReadOnly) { + return ( + + ); + } + + return ; +} diff --git a/packages/sdk/src/features/variables/modals/tab/tab-active.tsx b/packages/sdk/src/features/variables/modals/global/tab/tab-active.tsx similarity index 63% rename from packages/sdk/src/features/variables/modals/tab/tab-active.tsx rename to packages/sdk/src/features/variables/modals/global/tab/tab-active.tsx index fb57248b7..5cc739dea 100644 --- a/packages/sdk/src/features/variables/modals/tab/tab-active.tsx +++ b/packages/sdk/src/features/variables/modals/global/tab/tab-active.tsx @@ -4,15 +4,16 @@ import { TabGlobalVariables } from '../tab-global-variables/tab-global-variables type Props = { activeTab: SettingsTab; + isReadOnly?: boolean; }; -const contentByTab: Record = { +const contentByTab: Record> = { [SETTINGS_TABS.GENERAL]: TabGeneral, [SETTINGS_TABS.GLOBAL_VARIABLES]: TabGlobalVariables, }; -export function TabActive({ activeTab }: Props) { +export function TabActive({ activeTab, isReadOnly }: Props) { const Content = contentByTab[activeTab]; - return ; + return ; } diff --git a/packages/sdk/src/features/variables/modals/global/tab/tab-header.module.css b/packages/sdk/src/features/variables/modals/global/tab/tab-header.module.css new file mode 100644 index 000000000..4ef487275 --- /dev/null +++ b/packages/sdk/src/features/variables/modals/global/tab/tab-header.module.css @@ -0,0 +1,34 @@ +.container { + display: flex; + width: 100%; + height: 3.5rem; + gap: 1rem; + padding-bottom: 1rem; + + &:not(.container--no-border) { + border-bottom: 1px solid var(--wb-ui-stroke-primary-default); + + + * { + padding-top: var(--wb-token-spacing-modal-l-content-gap-2, 16px); + } + } +} + +.content { + display: flex; + flex-flow: column; + justify-content: center; +} + +.title, +.description { + margin: 0; +} + +.description { + color: var(--wb-txt-tertiary-default); +} + +.children { + margin-left: auto; +} diff --git a/packages/sdk/src/features/variables/modals/tab/tab-header.tsx b/packages/sdk/src/features/variables/modals/global/tab/tab-header.tsx similarity index 70% rename from packages/sdk/src/features/variables/modals/tab/tab-header.tsx rename to packages/sdk/src/features/variables/modals/global/tab/tab-header.tsx index 34b9f82ba..cb669fd42 100644 --- a/packages/sdk/src/features/variables/modals/tab/tab-header.tsx +++ b/packages/sdk/src/features/variables/modals/global/tab/tab-header.tsx @@ -7,21 +7,37 @@ import { Icon } from '@workflow-builder/icons'; import styles from './tab-header.module.css'; -import { useTranslateIfPossible } from '../../../../hooks/use-translate-if-possible'; +import { useTranslateIfPossible } from '../../../../../hooks/use-translate-if-possible'; type Props = { title?: string; description?: string; onGoBack?: () => void; className?: string; + shouldShowBorder?: boolean; }; -export function TabHeader({ title, description, onGoBack, children, className = '' }: PropsWithChildren) { +export function TabHeader({ + title, + description, + onGoBack, + children, + className = '', + shouldShowBorder = true, +}: PropsWithChildren) { const translateIfPossible = useTranslateIfPossible(); const { t } = useTranslation(); return ( -
+
{onGoBack && ( diff --git a/packages/sdk/src/features/variables/modals/tab-global-variables/constants.ts b/packages/sdk/src/features/variables/modals/shared/components/constants.ts similarity index 100% rename from packages/sdk/src/features/variables/modals/tab-global-variables/constants.ts rename to packages/sdk/src/features/variables/modals/shared/components/constants.ts diff --git a/packages/sdk/src/features/variables/modals/shared/components/pane-add-variable/pane-add-variable.tsx b/packages/sdk/src/features/variables/modals/shared/components/pane-add-variable/pane-add-variable.tsx new file mode 100644 index 000000000..b493eacb2 --- /dev/null +++ b/packages/sdk/src/features/variables/modals/shared/components/pane-add-variable/pane-add-variable.tsx @@ -0,0 +1,49 @@ +import { SnackbarType } from '@synergycodes/overflow-ui'; +import clsx from 'clsx'; +import { useCallback } from 'react'; + +import { getStoreVariables, saveVariableDefinition } from '../../../../../../store/slices/diagram-slice/actions'; +import { filterEmpty } from '../../../../../../utils/array'; +import { showSnackbar } from '../../../../../../utils/show-snackbar'; +import type { VariableDefinition } from '../../../../types'; +import { getEmptyVariableDefinition } from '../../../../utils/get-empty-variable-definition'; +import { TabHeader } from '../../../global/tab/tab-header'; +import { VARIABLE_PANE, type VariablePane } from '../constants'; +import { VariableForm } from '../variable-form/variable-form'; + +type Props = { + className?: string; + setActivePane: (pane: VariablePane, id?: string) => void; +}; + +export function PaneAddVariable({ className, setActivePane }: Props) { + const handleSave = useCallback( + (definition: VariableDefinition) => { + const variables = getStoreVariables(); + + const variableWithName = Object.values(variables) + .filter(filterEmpty) + .find(({ id, name }) => id !== definition.id && name.toLowerCase() === definition.name.toLowerCase()); + + if (variableWithName) { + showSnackbar({ + title: 'variableNameAlreadyExists', + variant: SnackbarType.ERROR, + }); + + throw 'variableNameAlreadyExists'; + } + + saveVariableDefinition(definition); + setActivePane(VARIABLE_PANE.LIST); + }, + [setActivePane], + ); + + return ( +
+ setActivePane(VARIABLE_PANE.LIST)} /> + +
+ ); +} diff --git a/packages/sdk/src/features/variables/modals/shared/components/pane-edit-variable/pane-edit-variable.tsx b/packages/sdk/src/features/variables/modals/shared/components/pane-edit-variable/pane-edit-variable.tsx new file mode 100644 index 000000000..5604323df --- /dev/null +++ b/packages/sdk/src/features/variables/modals/shared/components/pane-edit-variable/pane-edit-variable.tsx @@ -0,0 +1,49 @@ +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; + +import type { VariableDefinition } from '../../../../types'; +import { TabHeader } from '../../../global/tab/tab-header'; +import { VARIABLE_PANE, type VariablePane } from '../constants'; +import { VariableForm, type VariableFormVariant } from '../variable-form/variable-form'; + +export type PaneEditVariableProps = { + className?: string; + title?: string; + setActivePane?: (pane: VariablePane) => void; + isReadOnly?: boolean; + variant: VariableFormVariant; + variable: VariableDefinition | undefined; + onCancel?: () => void; + onSave: (definition: VariableDefinition) => void; +}; + +export function PaneEditVariable({ + className, + title = 'workflowsSettings.tab.editVariable', + variant, + setActivePane, + variable, + onCancel, + onSave, + isReadOnly, +}: PaneEditVariableProps) { + const { t } = useTranslation(); + + return ( +
+ {(title || setActivePane) && ( + setActivePane(VARIABLE_PANE.LIST) : undefined} /> + )} + {!variable &&

{t('variables.variableNotFound')}

} + {variable && ( + + )} +
+ ); +} diff --git a/packages/sdk/src/features/variables/modals/tab-global-variables/pane-list/pane-list.module.css b/packages/sdk/src/features/variables/modals/shared/components/pane-list/pane-list.module.css similarity index 100% rename from packages/sdk/src/features/variables/modals/tab-global-variables/pane-list/pane-list.module.css rename to packages/sdk/src/features/variables/modals/shared/components/pane-list/pane-list.module.css diff --git a/packages/sdk/src/features/variables/modals/tab-global-variables/pane-list/pane-list.tsx b/packages/sdk/src/features/variables/modals/shared/components/pane-list/pane-list.tsx similarity index 86% rename from packages/sdk/src/features/variables/modals/tab-global-variables/pane-list/pane-list.tsx rename to packages/sdk/src/features/variables/modals/shared/components/pane-list/pane-list.tsx index 1327f7c8e..48815c1c0 100644 --- a/packages/sdk/src/features/variables/modals/tab-global-variables/pane-list/pane-list.tsx +++ b/packages/sdk/src/features/variables/modals/shared/components/pane-list/pane-list.tsx @@ -7,10 +7,10 @@ import { Icon } from '@workflow-builder/icons'; import styles from './pane-list.module.css'; -import { useStore } from '../../../../../store/store'; -import { TabHeader } from '../../tab/tab-header'; +import { useStore } from '../../../../../../store/store'; +import { GlobalVariablePreview } from '../../../../components/variable-preview/wrappers/variable-preview-global'; +import { TabHeader } from '../../../global/tab/tab-header'; import { VARIABLE_PANE, type VariablePane } from '../constants'; -import { VariablePreview } from '../variable-preview/variable-preview'; type Props = { className?: string; @@ -41,7 +41,7 @@ export function PaneList({ className, setActivePane }: Props) { )}
{variablesIds.map((id) => ( - setActivePane(VARIABLE_PANE.EDIT, id)} diff --git a/packages/sdk/src/features/variables/modals/tab-global-variables/pane-remove-variable/pane-remove-variable.module.css b/packages/sdk/src/features/variables/modals/shared/components/pane-remove-variable/pane-remove-variable.module.css similarity index 81% rename from packages/sdk/src/features/variables/modals/tab-global-variables/pane-remove-variable/pane-remove-variable.module.css rename to packages/sdk/src/features/variables/modals/shared/components/pane-remove-variable/pane-remove-variable.module.css index 26d48e326..a86bf6119 100644 --- a/packages/sdk/src/features/variables/modals/tab-global-variables/pane-remove-variable/pane-remove-variable.module.css +++ b/packages/sdk/src/features/variables/modals/shared/components/pane-remove-variable/pane-remove-variable.module.css @@ -1,7 +1,3 @@ -:root { - --wb-settings-remove-bg: var(--ax-public-modal-header-background); -} - .description { margin-top: var(--wb-token-spacing-spacing-16); margin-bottom: var(--wb-token-spacing-spacing-20, 20px); @@ -12,8 +8,8 @@ flex-flow: column; gap: 8px; border-radius: 8px; - border: 1px solid var(--wb-settings-remove-bg); - background: var(--wb-settings-remove-bg); + border: 1px solid #e5e5e5; + background: #f9f9f9; color: var(--wb-txt-secondary-default); margin: 0; padding: var(--wb-token-spacing-spacing-12, 12px) var(--wb-token-spacing-spacing-16, 16px) diff --git a/packages/sdk/src/features/variables/modals/tab-global-variables/pane-remove-variable/pane-remove-variable.tsx b/packages/sdk/src/features/variables/modals/shared/components/pane-remove-variable/pane-remove-variable.tsx similarity index 50% rename from packages/sdk/src/features/variables/modals/tab-global-variables/pane-remove-variable/pane-remove-variable.tsx rename to packages/sdk/src/features/variables/modals/shared/components/pane-remove-variable/pane-remove-variable.tsx index 6e354f13c..93b05b383 100644 --- a/packages/sdk/src/features/variables/modals/tab-global-variables/pane-remove-variable/pane-remove-variable.tsx +++ b/packages/sdk/src/features/variables/modals/shared/components/pane-remove-variable/pane-remove-variable.tsx @@ -1,45 +1,54 @@ import clsx from 'clsx'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import styles from './pane-remove-variable.module.css'; -import { ButtonSubmit } from '../../../../../components/button-submit/button-submit'; -import { getNodesWithVariable } from '../../../../../features/variables/actions/get-nodes-with-variable'; -import { getGlobalVariableKey } from '../../../../../features/variables/utils/get-global-variable-key'; -import { removeVariableDefinition } from '../../../../../store/slices/diagram-slice/actions'; -import { useStore } from '../../../../../store/store'; -import { TabHeader } from '../../tab/tab-header'; +import { ButtonSubmit } from '../../../../../../components/button-submit/button-submit'; +import type { NodeWithVariable } from '../../../../actions/get-nodes-with-variable'; +import { VariableMeta } from '../../../../components/variable-preview/variable-meta'; +import type { VariableDefinition } from '../../../../types'; +import { TabHeader } from '../../../global/tab/tab-header'; import { VARIABLE_PANE, type VariablePane } from '../constants'; -import { VariableMeta } from '../variable-preview/variable-meta'; -type Props = { +export type PaneRemoveVariableProps = { className?: string; - setActivePane: (pane: VariablePane) => void; - id: string; + title?: string; + setActivePane?: (pane: VariablePane) => void; + onRemove: () => void; + nodesWithVariable: NodeWithVariable[]; + variable: VariableDefinition | undefined; + isReadOnly?: boolean; }; -export function PaneRemoveVariable({ className, setActivePane, id }: Props) { - const variable = useStore((store) => store.globalVariables[id]); - +export function PaneRemoveVariable({ + className, + title = 'workflowsSettings.tab.removeVariable', + setActivePane, + nodesWithVariable, + onRemove, + variable, + isReadOnly = false, +}: PaneRemoveVariableProps) { const { t } = useTranslation(); const handleRemove = useCallback(() => { - removeVariableDefinition(id); - setActivePane(VARIABLE_PANE.LIST); - }, [id, setActivePane]); - - const nodesWithVariable = useMemo(() => { - const variableKey = getGlobalVariableKey(id); - - return getNodesWithVariable(variableKey); - }, [id]); + onRemove(); + if (setActivePane) { + setActivePane(VARIABLE_PANE.LIST); + } + }, [onRemove, setActivePane]); return (
- setActivePane(VARIABLE_PANE.LIST)} /> + {(title || setActivePane) && ( + setActivePane(VARIABLE_PANE.LIST) : undefined} + /> + )}
- {!variable && t('variables.variableNotFound')} + {!variable &&

{t('variables.variableNotFound')}

} {variable && } {nodesWithVariable.length === 0 ? (

{t('variables.removeVariableWarning')}

@@ -62,7 +71,7 @@ export function PaneRemoveVariable({ className, setActivePane, id }: Props) { onClick={handleRemove} variant="error" isPending={false} - disabled={nodesWithVariable.length > 0} + disabled={isReadOnly || nodesWithVariable.length > 0} > {t('workflowsSettings.tab.removeVariable')} diff --git a/packages/sdk/src/features/variables/modals/tab-global-variables/variable-form/variable-form.module.css b/packages/sdk/src/features/variables/modals/shared/components/variable-form/variable-form.module.css similarity index 100% rename from packages/sdk/src/features/variables/modals/tab-global-variables/variable-form/variable-form.module.css rename to packages/sdk/src/features/variables/modals/shared/components/variable-form/variable-form.module.css diff --git a/packages/sdk/src/features/variables/modals/tab-global-variables/variable-form/variable-form.tsx b/packages/sdk/src/features/variables/modals/shared/components/variable-form/variable-form.tsx similarity index 65% rename from packages/sdk/src/features/variables/modals/tab-global-variables/variable-form/variable-form.tsx rename to packages/sdk/src/features/variables/modals/shared/components/variable-form/variable-form.tsx index 6947adc5a..62653472a 100644 --- a/packages/sdk/src/features/variables/modals/tab-global-variables/variable-form/variable-form.tsx +++ b/packages/sdk/src/features/variables/modals/shared/components/variable-form/variable-form.tsx @@ -1,16 +1,16 @@ -import { Input, Select, type SelectItem, TextArea } from '@synergycodes/overflow-ui'; +import { Button, Input, Select, type SelectItem, TextArea } from '@synergycodes/overflow-ui'; import clsx from 'clsx'; import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import styles from './variable-form.module.css'; -import { ButtonSubmit } from '../../../../../components/button-submit/button-submit'; -import { FormControlWithLabel } from '../../../../../components/form/form-control-with-label/form-control-with-label'; -import { getDefinitionErrors } from '../../../../../features/variables/actions/definitions'; -import { DynamicTypedInput } from '../../../../../features/variables/components/dynamic-typed-input/dynamic-typed-input'; -import { variableTypesOptions } from '../../../../../features/variables/constants'; -import type { VariableDefinition } from '../../../../../features/variables/types'; +import { ButtonSubmit } from '../../../../../../components/button-submit/button-submit'; +import { FormControlWithLabel } from '../../../../../../components/form/form-control-with-label/form-control-with-label'; +import { DynamicTypedInput } from '../../../../components/dynamic-typed-input/dynamic-typed-input'; +import { variableTypesOptions } from '../../../../constants'; +import type { VariableDefinition } from '../../../../types'; +import { getDefinitionErrors } from '../../../../utils/form-validation/definitions'; const optionsType: SelectItem[] = variableTypesOptions.map(({ type, label }) => ({ type: 'item', @@ -22,10 +22,23 @@ type FormData = VariableDefinition & { fieldsWithErrors: Set; }; +export const VARIABLE_FORM_VARIANT = { + ADD: 'add', + EDIT: 'edit', + // Global can't change type, but can change name + EDIT_LIMITED: 'edit-limited', + // Node can't change type and name + EDIT_LIMITED_STRICT: 'edit-limited-strict', +} as const; + +export type VariableFormVariant = (typeof VARIABLE_FORM_VARIANT)[keyof typeof VARIABLE_FORM_VARIANT]; + type Props = { initData: VariableDefinition; + onCancel?: () => void; onSave: (definition: VariableDefinition) => void; - variant: 'add' | 'edit' | 'edit-limited'; + variant: VariableFormVariant; + isReadOnly?: boolean; }; type HandleFieldUpdate = { @@ -40,7 +53,9 @@ export function VariableForm(props: Props) { fieldsWithErrors: new Set(), }); const { t } = useTranslation(); - const isEditionLimited = props.variant === 'edit-limited'; + const isEditionLimited = ( + [VARIABLE_FORM_VARIANT.EDIT_LIMITED, VARIABLE_FORM_VARIANT.EDIT_LIMITED_STRICT] as VariableFormVariant[] + ).includes(props.variant); const handleInputUpdate: HandleFieldUpdate = useCallback((name, value) => { setFormData((state) => ({ @@ -73,9 +88,13 @@ export function VariableForm(props: Props) { } try { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { fieldsWithErrors, ...definition } = formData; - props.onSave(definition); + props.onSave({ + id: formData.id, + name: formData.name.trim(), + description: formData.description.trim(), + type: formData.type, + defaultValue: formData.defaultValue, + }); } catch { // } @@ -91,6 +110,7 @@ export function VariableForm(props: Props) { error={formData.fieldsWithErrors.has('name')} placeholder={t('common.namePlaceholder')} onChange={(event) => handleInputUpdate('name', event.target.value)} + disabled={VARIABLE_FORM_VARIANT.EDIT_LIMITED_STRICT === props.variant || props.isReadOnly} /> @@ -98,7 +118,7 @@ export function VariableForm(props: Props) { value={formData.type} items={optionsType} onChange={(_, value) => handleInputUpdate('type', value as VariableDefinition['type'])} - disabled={isEditionLimited} + disabled={isEditionLimited || props.isReadOnly} error={formData.fieldsWithErrors.has('type')} /> @@ -109,6 +129,7 @@ export function VariableForm(props: Props) { onChange={(value) => handleInputUpdate('defaultValue', value)} suggestionGroups={[]} isError={formData.fieldsWithErrors.has('defaultValue')} + disabled={props.isReadOnly} /> @@ -123,8 +144,13 @@ export function VariableForm(props: Props) { />
- - {t(props.variant === 'add' ? 'workflowsSettings.tab.addVariable' : 'common.save')} + {props.onCancel && ( + + )} + + {t('common.save')}
diff --git a/packages/sdk/src/features/variables/modals/tab-global-variables/pane-add-variable/pane-add-variable.tsx b/packages/sdk/src/features/variables/modals/tab-global-variables/pane-add-variable/pane-add-variable.tsx deleted file mode 100644 index 5fb14287b..000000000 --- a/packages/sdk/src/features/variables/modals/tab-global-variables/pane-add-variable/pane-add-variable.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import clsx from 'clsx'; -import { useCallback } from 'react'; - -import type { VariableDefinition } from '../../../../../features/variables/types'; -import { saveVariableDefinition } from '../../../../../store/slices/diagram-slice/actions'; -import { getEmptyVariableDefinition } from '../../../utils/get-empty-variable-definition'; -import { TabHeader } from '../../tab/tab-header'; -import { VARIABLE_PANE, type VariablePane } from '../constants'; -import { VariableForm } from '../variable-form/variable-form'; - -type Props = { - className?: string; - setActivePane: (pane: VariablePane, id?: string) => void; -}; - -export function PaneAddVariable({ className, setActivePane }: Props) { - const handleSave = useCallback( - (definition: VariableDefinition) => { - saveVariableDefinition(definition); - setActivePane(VARIABLE_PANE.LIST); - }, - [setActivePane], - ); - - return ( -
- setActivePane(VARIABLE_PANE.LIST)} /> - -
- ); -} diff --git a/packages/sdk/src/features/variables/modals/tab-global-variables/pane-edit-variable/pane-edit-variable.tsx b/packages/sdk/src/features/variables/modals/tab-global-variables/pane-edit-variable/pane-edit-variable.tsx deleted file mode 100644 index cff9f2804..000000000 --- a/packages/sdk/src/features/variables/modals/tab-global-variables/pane-edit-variable/pane-edit-variable.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import clsx from 'clsx'; -import { useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { getNodesWithVariable } from '../../../../../features/variables/actions/get-nodes-with-variable'; -import type { VariableDefinition } from '../../../../../features/variables/types'; -import { getGlobalVariableKey } from '../../../../../features/variables/utils/get-global-variable-key'; -import { saveVariableDefinition } from '../../../../../store/slices/diagram-slice/actions'; -import { useStore } from '../../../../../store/store'; -import { TabHeader } from '../../tab/tab-header'; -import { VARIABLE_PANE, type VariablePane } from '../constants'; -import { VariableForm } from '../variable-form/variable-form'; - -type Props = { - className?: string; - setActivePane: (pane: VariablePane) => void; - id: string; -}; - -export function PaneEditVariable({ className, setActivePane, id }: Props) { - const variable = useStore((store) => store.globalVariables[id]); - - const { t } = useTranslation(); - - const handleSave = useCallback( - (definition: VariableDefinition) => { - saveVariableDefinition(definition); - setActivePane(VARIABLE_PANE.LIST); - }, - [setActivePane], - ); - - const nodesWithVariable = useMemo(() => { - const variableKey = getGlobalVariableKey(id); - - return getNodesWithVariable(variableKey); - }, [id]); - - return ( -
- setActivePane(VARIABLE_PANE.LIST)} /> - {!variable && t('variables.variableNotFound')} - {variable && ( - 0 ? 'edit-limited' : 'edit'} - initData={variable} - onSave={handleSave} - /> - )} -
- ); -} diff --git a/packages/sdk/src/features/variables/modals/tab-global-variables/tab-global-variables.tsx b/packages/sdk/src/features/variables/modals/tab-global-variables/tab-global-variables.tsx deleted file mode 100644 index 412da3eda..000000000 --- a/packages/sdk/src/features/variables/modals/tab-global-variables/tab-global-variables.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import clsx from 'clsx'; -import { useCallback, useState } from 'react'; - -import styles from './tab-global-variables.module.css'; - -import { VARIABLE_PANE, type VariablePane } from './constants'; -import { PaneAddVariable } from './pane-add-variable/pane-add-variable'; -import { PaneEditVariable } from './pane-edit-variable/pane-edit-variable'; -import { PaneList } from './pane-list/pane-list'; -import { PaneRemoveVariable } from './pane-remove-variable/pane-remove-variable'; - -type Props = { - className?: string; -}; - -export function TabGlobalVariables({ className }: Props) { - const [{ activePane, id }, setActivePaneOriginal] = useState<{ - activePane: VariablePane; - id?: string; - }>({ activePane: VARIABLE_PANE.LIST }); - - const setActivePane = useCallback((pane: VariablePane, id: string = '') => { - setActivePaneOriginal({ - activePane: pane, - id, - }); - }, []); - - if (activePane === VARIABLE_PANE.ADD) { - return ; - } - - if (activePane === VARIABLE_PANE.EDIT && id) { - return ; - } - - if (activePane === VARIABLE_PANE.REMOVE && id) { - return ( - - ); - } - - return ; -} diff --git a/packages/sdk/src/features/variables/modals/tab/tab-header.module.css b/packages/sdk/src/features/variables/modals/tab/tab-header.module.css deleted file mode 100644 index 63d758035..000000000 --- a/packages/sdk/src/features/variables/modals/tab/tab-header.module.css +++ /dev/null @@ -1,35 +0,0 @@ -:root { - --settings-tab-header-border: var(--ax-public-node-border-size) solid var(--ax-public-node-border-color); -} - -.container { - display: flex; - width: 100%; - height: 3.5rem; - gap: 1rem; - padding-bottom: 1rem; - border-bottom: var(--settings-tab-header-border); - - + * { - padding-top: var(--wb-token-spacing-modal-l-content-gap-2, 16px); - } -} - -.content { - display: flex; - flex-flow: column; - justify-content: center; -} - -.title, -.description { - margin: 0; -} - -.title { - color: var(--ax-txt-primary-default); -} - -.children { - margin-left: auto; -} diff --git a/packages/sdk/src/features/variables/stores/core/get-node-variables-suggestions.ts b/packages/sdk/src/features/variables/stores/core/get-node-variables-suggestions.ts new file mode 100644 index 000000000..46edf36fb --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/get-node-variables-suggestions.ts @@ -0,0 +1,75 @@ +import type { VariableSuggestion } from '../../components/variable-text/variable-text.types'; +import { NODE_ID_FOR_COMMON_NODE_DATA } from '../../constants'; +import { SUGGESTION_NODE_TYPE, type SuggestionsBySourceHandle } from '../types'; +import { type VariablesSuggestionsStore, useVariablesSuggestionsStore } from '../use-variable-suggestions-store'; + +type UndefinedNotIndexed = undefined; + +// General getter for all variables from a node, divided by source handles +export const getVariableBySourceHandlesForNode = (params: { + nodeId: string; + cachedStore?: VariablesSuggestionsStore; +}): SuggestionsBySourceHandle | UndefinedNotIndexed => { + // Pass the store if you want to call this function multiple times + const store = params.cachedStore ?? useVariablesSuggestionsStore.getState(); + + const nodeData = store.byNodeId[params.nodeId]; + + // Not indexed + if (!nodeData) { + return undefined; + } + + let bySourceHandle: SuggestionsBySourceHandle | undefined; + + if (nodeData.type === SUGGESTION_NODE_TYPE.CUSTOM) { + bySourceHandle = nodeData.bySourceHandle; + } else if (nodeData.type === SUGGESTION_NODE_TYPE.COMMON) { + bySourceHandle = store.commonByType[nodeData.nodeType]; + } + + if (bySourceHandle) { + return bySourceHandle; + } + + // Not indexed + return undefined; +}; + +// Getter for variables of picked node available from picked sourceHandle +export const getNodeVariablesSuggestions = (params: { + nodeId: string; + sourceHandle: string | undefined; + // Pass one store if you want to call it multiple times + cachedStore?: VariablesSuggestionsStore; +}): VariableSuggestion[] | UndefinedNotIndexed => { + const bySourceHandle = getVariableBySourceHandlesForNode(params); + + // Not indexed + if (!bySourceHandle) { + return undefined; + } + + let suggestions: VariableSuggestion[] | UndefinedNotIndexed = undefined; + + if (params.sourceHandle && Array.isArray(bySourceHandle[params.sourceHandle])) { + suggestions = bySourceHandle[params.sourceHandle]; + } + + if (Array.isArray(bySourceHandle.default)) { + suggestions = [...(suggestions || []), ...bySourceHandle.default]; + } + + // Some nodes (with the same type) have suggestions stored in shared place and need adjustment + const hasPlaceholderIdToSwap = suggestions?.some((suggestion) => + suggestion.id.includes(NODE_ID_FOR_COMMON_NODE_DATA), + ); + if (Array.isArray(suggestions) && hasPlaceholderIdToSwap) { + return suggestions.map((suggestion) => ({ + ...suggestion, + id: suggestion.id.replace(NODE_ID_FOR_COMMON_NODE_DATA, params.nodeId), + })); + } + + return suggestions; +}; diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-output-properties.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-output-properties.ts new file mode 100644 index 000000000..3a2e3fa2f --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-output-properties.ts @@ -0,0 +1,30 @@ +import type { OutputPropertiesIndex } from '../../../../../node/node-output-schema'; +import { filterEmpty } from '../../../../../utils/array'; +import { keyToLabel, truncate } from '../../../../../utils/text'; +import type { VariableSuggestion } from '../../../components/variable-text/variable-text.types'; +import { getVariableReferenceWithoutBracketsForNode } from '../../../utils/keys/get-variable-reference-without-brackets-for-node'; + +type Params = { + properties: OutputPropertiesIndex; + nodeId: string; + nodeLabel: string; +}; + +/** + * Produces a list of suggestions generated from `outputPropertiesIndex` (used by node definition variants). + */ +export function getSuggestionsFromOutputProperties({ nodeId, nodeLabel, properties }: Params): VariableSuggestion[] { + return Object.entries(properties) + .map(([propertyKey, property]) => + property + ? { + id: getVariableReferenceWithoutBracketsForNode({ nodeId, propertyName: propertyKey }), + display: `${truncate(nodeLabel, 15)} · ${truncate(property.label || keyToLabel(propertyKey), 15)}`, + label: property.label || truncate(property.label || keyToLabel(propertyKey), 25), + description: property.description, + type: property.type, + } + : undefined, + ) + .filter(filterEmpty); +} diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-variables-index.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-variables-index.ts new file mode 100644 index 000000000..ac682b079 --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-variables-index.ts @@ -0,0 +1,49 @@ +import { filterEmpty } from '../../../../../utils/array'; +import { truncate } from '../../../../../utils/text'; +import type { VariableSuggestion } from '../../../components/variable-text/variable-text.types'; +import type { VariablesIndex } from '../../../types'; +import { getVariableReferenceWithoutBracketsForGlobal } from '../../../utils/keys/get-variable-reference-without-brackets-for-global'; +import { getVariableReferenceWithoutBracketsForNode } from '../../../utils/keys/get-variable-reference-without-brackets-for-node'; + +type ParamsShared = { + variablesIndex: VariablesIndex; +}; + +type ParamsForGlobal = { + variant: 'global'; +} & ParamsShared; + +type ParamsForNode = { + variant: 'nodes'; + nodeId: string; + nodeLabel: string; +} & ParamsShared; + +type Params = ParamsForGlobal | ParamsForNode; + +/** + * Produces a list of suggestions generated from `variablesIndex` (used by global variables and the build schema control). + */ +export const getSuggestionsFromVariableIndex = ({ variablesIndex, ...props }: Params): VariableSuggestion[] => { + const suggestions: VariableSuggestion[] = Object.values(variablesIndex) + .filter(filterEmpty) + .map((definition) => { + return props.variant === 'global' + ? { + id: getVariableReferenceWithoutBracketsForGlobal(definition.id), + display: truncate(definition.name, 25), + label: definition.name, + description: definition.description, + type: definition.type, + } + : { + id: getVariableReferenceWithoutBracketsForNode({ nodeId: props.nodeId, propertyName: definition.id }), + display: `${truncate(props.nodeLabel, 15)} · ${truncate(definition.name, 15)}`, + label: definition.name, + description: definition.description, + type: definition.type, + }; + }); + + return suggestions; +}; diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts new file mode 100644 index 000000000..09b624500 --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts @@ -0,0 +1,123 @@ +import type { PaletteItem } from '../../../../../node/common'; +import type { WorkflowBuilderNode } from '../../../../../node/node-data'; +import { OUTPUT_SCHEMA_TYPE } from '../../../../../node/node-output-schema'; +import { filterEmpty } from '../../../../../utils/array'; +import { getByPath } from '../../../../../utils/object'; +import type { VariablesIndex } from '../../../types'; +import { getNodeLabelForVariable } from '../../../utils/diagram/get-node-label-for-variable'; +import { SUGGESTION_NODE_TYPE, type SuggestionNodeType, type SuggestionsBySourceHandle } from '../../types'; +import { getSuggestionsFromOutputProperties } from './get-suggestions-from-output-properties'; +import { getSuggestionsFromVariableIndex } from './get-suggestions-from-variables-index'; + +type Params = { + definition: PaletteItem; + node: WorkflowBuilderNode; +}; + +type Response = { + type: SuggestionNodeType; + bySourceHandle: SuggestionsBySourceHandle; +}; + +const EMPTY_NODE_SUGGESTIONS: Response = { + type: SUGGESTION_NODE_TYPE.COMMON, + bySourceHandle: { + default: [], + }, +}; + +export function getSuggestionsNodeData({ definition, node }: Params): Response { + const nodeLabel = getNodeLabelForVariable({ node, definition }); + + if (!definition?.outputSchema?.type) { + return EMPTY_NODE_SUGGESTIONS; + } + + // Node that always returns the same variables + if (definition.outputSchema.type === OUTPUT_SCHEMA_TYPE.DEFAULT) { + return { + type: SUGGESTION_NODE_TYPE.COMMON, + bySourceHandle: { + default: getSuggestionsFromOutputProperties({ + nodeId: node.id, + nodeLabel, + properties: definition.outputSchema.properties, + }), + }, + }; + } + + // From variants (they have rules based on data inside the node and edges) + if (definition.outputSchema.type === OUTPUT_SCHEMA_TYPE.VARIANT) { + const variantsMatchingDataPropertyValue = Object.values(definition.outputSchema.variants) + .filter((variant) => { + if (!variant?.variantRule) { + return true; + } + + // We assume true if rule isn't set - the rule can be set for sourceHandle only + const isValidPropertyValue = + 'dataPropertyName' in variant.variantRule && variant.variantRule.dataPropertyName + ? node.data.properties[variant.variantRule.dataPropertyName] === variant.variantRule.dataPropertyValue + : true; + + return isValidPropertyValue; + }) + .filter(filterEmpty); + + const bySourceHandle: SuggestionsBySourceHandle = { + default: [], + }; + + for (const variant of variantsMatchingDataPropertyValue) { + const sourceHandle = variant.variantRule?.sourceHandleId || 'default'; + bySourceHandle[sourceHandle] = [ + ...(bySourceHandle[sourceHandle] || []), + ...getSuggestionsFromOutputProperties({ + nodeId: node.id, + nodeLabel, + properties: variant.properties, + }), + ]; + } + + return { + type: SUGGESTION_NODE_TYPE.CUSTOM, + bySourceHandle, + }; + } + + // Build with schema builder control + if (definition?.outputSchema.type === OUTPUT_SCHEMA_TYPE.PROPERTY_VALUE) { + // TODO: Add better guard + const variablesIndex = getByPath(node.data.properties, definition.outputSchema.propertyPath) as unknown as + | VariablesIndex + | undefined; + + if (!variablesIndex) { + return { + ...EMPTY_NODE_SUGGESTIONS, + /* + It's custom because it has built-in controls, and even if it's empty, that doesn't mean the others are empty too. + */ + type: SUGGESTION_NODE_TYPE.CUSTOM, + }; + } + + const suggestions = getSuggestionsFromVariableIndex({ + variablesIndex, + nodeId: node.id, + nodeLabel, + variant: 'nodes', + }); + + return { + type: SUGGESTION_NODE_TYPE.CUSTOM, + bySourceHandle: { + default: suggestions, + }, + }; + } + + return EMPTY_NODE_SUGGESTIONS; +} diff --git a/packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts b/packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts new file mode 100644 index 000000000..7b0ff5ed8 --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts @@ -0,0 +1,49 @@ +import { getNodeDefinition } from '@/utils/validation/get-node-definition'; + +import type { WorkflowBuilderNode } from '../../../../node/node-data'; +import { useStore } from '../../../../store/store'; +import { + type VariablesSuggestionsStore, + emptyVariablesSuggestionsStore, + useVariablesSuggestionsStore, +} from '../use-variable-suggestions-store'; +import { getSuggestionsNodeData } from './get-suggestions-node-data/get-suggestions-node-data'; +import { setVariablesSuggestionsNodeData } from './set-suggestions-node-data/set-suggestions-node-data'; + +function refreshNodesSuggestions(nodes: WorkflowBuilderNode[], initialStore: VariablesSuggestionsStore) { + let currentStore = initialStore; + + for (const node of nodes) { + const definition = getNodeDefinition(node); + + if (definition) { + const { type, bySourceHandle } = getSuggestionsNodeData({ node, definition }); + + currentStore = setVariablesSuggestionsNodeData({ + type, + nodeId: node.id, + nodeType: node.data.type, + bySourceHandle, + cachedStore: currentStore, + shouldOnlyPassedStore: true, + }); + } + } + + useVariablesSuggestionsStore.setState(currentStore); +} + +export function refreshAllSuggestions() { + const { nodes } = useStore.getState(); + + refreshNodesSuggestions(nodes, { ...emptyVariablesSuggestionsStore }); +} + +export function refreshNodesIdsSuggestions(nodesIds: string[]) { + const currentStore = useVariablesSuggestionsStore.getState(); + const { nodes } = useStore.getState(); + + const nodesToRefresh = nodes.filter((node) => nodesIds.includes(node.id)); + + refreshNodesSuggestions(nodesToRefresh, currentStore); +} diff --git a/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts b/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts new file mode 100644 index 000000000..7f76bc381 --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts @@ -0,0 +1,74 @@ +import { NODE_ID_FOR_COMMON_NODE_DATA } from '@/features/variables/constants'; + +import type { SuggestionNodeType, SuggestionsBySourceHandle } from '../../types'; +import { SUGGESTION_NODE_TYPE } from '../../types'; +import type { VariablesSuggestionsStore } from '../../use-variable-suggestions-store'; +import { useVariablesSuggestionsStore } from '../../use-variable-suggestions-store'; + +type ParamsShared = { + type: SuggestionNodeType; + nodeId: string; + nodeType: string; + bySourceHandle: SuggestionsBySourceHandle; +}; + +// If all variables are refreshed is worth batching them all and then setting the mutated store +type ParamsMutation = { + cachedStore: VariablesSuggestionsStore; + shouldOnlyPassedStore: true; +} & ParamsShared; + +type ParamsUpdate = { + cachedStore: undefined; + shouldOnlyPassedStore?: false; +} & ParamsShared; + +type Params = ParamsMutation | ParamsUpdate; + +export function setVariablesSuggestionsNodeData(params: Params): VariablesSuggestionsStore { + // Pass the store if you want to call this function multiple times + let storeToMutate = + params.shouldOnlyPassedStore === true ? params.cachedStore : useVariablesSuggestionsStore.getState(); + + if (params.type === SUGGESTION_NODE_TYPE.COMMON) { + storeToMutate = { + ...storeToMutate, + commonByType: { + ...storeToMutate.commonByType, + [params.nodeType]: Object.fromEntries( + Object.entries(params.bySourceHandle).map(([sourceHandle, suggestions]) => [ + sourceHandle, + suggestions?.map((suggestion) => ({ + ...suggestion, + id: suggestion.id.replace(params.nodeId, NODE_ID_FOR_COMMON_NODE_DATA), + })), + ]), + ) as SuggestionsBySourceHandle, + }, + byNodeId: { + ...storeToMutate.byNodeId, + [params.nodeId]: { + type: SUGGESTION_NODE_TYPE.COMMON, + nodeType: params.nodeType, + }, + }, + }; + } else if (params.type === SUGGESTION_NODE_TYPE.CUSTOM) { + storeToMutate = { + ...storeToMutate, + byNodeId: { + ...storeToMutate.byNodeId, + [params.nodeId]: { + type: SUGGESTION_NODE_TYPE.CUSTOM, + bySourceHandle: params.bySourceHandle, + }, + }, + }; + } + + if (params.shouldOnlyPassedStore === false) { + useVariablesSuggestionsStore.setState(storeToMutate); + } + + return storeToMutate; +} diff --git a/packages/sdk/src/features/variables/stores/types.ts b/packages/sdk/src/features/variables/stores/types.ts new file mode 100644 index 000000000..f81f855f7 --- /dev/null +++ b/packages/sdk/src/features/variables/stores/types.ts @@ -0,0 +1,24 @@ +import type { VariableSuggestion } from '../components/variable-text/variable-text.types'; + +export type SuggestionsBySourceHandle = { + [sourceHandle: string]: VariableSuggestion[] | undefined; + default: VariableSuggestion[]; +}; + +export const SUGGESTION_NODE_TYPE = { + COMMON: 'common', + CUSTOM: 'custom', +} as const; +export type SuggestionNodeType = (typeof SUGGESTION_NODE_TYPE)[keyof typeof SUGGESTION_NODE_TYPE]; + +export type SuggestionsNodeData = + | { + // References array kept in commonByType (we don't need to store the same array for each node) + type: typeof SUGGESTION_NODE_TYPE.COMMON; + nodeType: string; + } + | { + // Custom setup for nodes that require configuration based on data in node + type: typeof SUGGESTION_NODE_TYPE.CUSTOM; + bySourceHandle: SuggestionsBySourceHandle; + }; diff --git a/packages/sdk/src/features/variables/stores/use-variable-suggestions-store.ts b/packages/sdk/src/features/variables/stores/use-variable-suggestions-store.ts new file mode 100644 index 000000000..6fa9bd7ea --- /dev/null +++ b/packages/sdk/src/features/variables/stores/use-variable-suggestions-store.ts @@ -0,0 +1,28 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +import type { SuggestionsBySourceHandle, SuggestionsNodeData } from './types'; + +export type VariablesSuggestionsStore = { + commonByType: { + [nodeType: string]: SuggestionsBySourceHandle | undefined; + }; + byNodeId: { + [nodeId: string]: SuggestionsNodeData | undefined; + }; +}; + +export const emptyVariablesSuggestionsStore: VariablesSuggestionsStore = { + commonByType: {}, + byNodeId: {}, +}; + +export const useVariablesSuggestionsStore = create()( + devtools( + () => + ({ + ...emptyVariablesSuggestionsStore, + }) satisfies VariablesSuggestionsStore, + { name: 'variablesSuggestionsStore' }, + ), +); diff --git a/packages/sdk/src/features/variables/types.ts b/packages/sdk/src/features/variables/types.ts index 05537c1f5..cd295fa3e 100644 --- a/packages/sdk/src/features/variables/types.ts +++ b/packages/sdk/src/features/variables/types.ts @@ -1,11 +1,13 @@ import type { VariableTypePrimitive } from '../../node/node-output-schema'; -type VariableType = VariableTypePrimitive; +export type VariableReference = `{{${string}}}`; + +export type MaybeVariableReference = VariableReference | (string & {}) | undefined; export type VariableDefinition = { id: string; name: string; - type: VariableType; + type: VariableTypePrimitive; defaultValue: string; description: string; }; diff --git a/packages/sdk/src/features/variables/utils/core/filter-suggestions-by-types.spec.ts b/packages/sdk/src/features/variables/utils/core/filter-suggestions-by-types.spec.ts new file mode 100644 index 000000000..4b5c94501 --- /dev/null +++ b/packages/sdk/src/features/variables/utils/core/filter-suggestions-by-types.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import type { VariableType } from '@workflow-builder/types/node-output-schema'; + +import type { VariableSuggestion } from '../../components/variable-text/variable-text.types'; +import { filterSuggestionsByTypes } from './filter-suggestions-by-types'; + +function createSuggestion(id: string, type: VariableType): VariableSuggestion { + return { + id, + display: id, + label: id, + type, + }; +} + +const stringSuggestion = createSuggestion('string-1', 'string'); +const numberSuggestion = createSuggestion('number-1', 'number'); +const booleanSuggestion = createSuggestion('boolean-1', 'boolean'); +const objectSuggestion = createSuggestion('object-1', 'object'); + +const suggestions = [stringSuggestion, numberSuggestion, booleanSuggestion, objectSuggestion]; + +describe('filterSuggestionsByTypes', () => { + it('should return all suggestions when no types are excluded and includeTypes is empty', () => { + const result = filterSuggestionsByTypes({ suggestions, excludeTypes: [], includeTypes: [] }); + + expect(result).toEqual(suggestions); + }); + + it('should return all suggestions when includeTypes is undefined', () => { + const result = filterSuggestionsByTypes({ suggestions, excludeTypes: [], includeTypes: undefined }); + + expect(result).toEqual(suggestions); + }); + + it('should remove excluded types', () => { + const result = filterSuggestionsByTypes({ + suggestions, + excludeTypes: ['object', 'boolean'], + includeTypes: [], + }); + + expect(result).toEqual([stringSuggestion, numberSuggestion]); + }); + + it('should keep only included types', () => { + const result = filterSuggestionsByTypes({ + suggestions, + excludeTypes: [], + includeTypes: ['number'], + }); + + expect(result).toEqual([numberSuggestion]); + }); + + it('should apply both excludeTypes and includeTypes', () => { + const result = filterSuggestionsByTypes({ + suggestions, + excludeTypes: ['number'], + includeTypes: ['string', 'number'], + }); + + expect(result).toEqual([stringSuggestion]); + }); + + it('should prioritize excludeTypes over includeTypes for the same type', () => { + const result = filterSuggestionsByTypes({ + suggestions, + excludeTypes: ['string'], + includeTypes: ['string'], + }); + + expect(result).toEqual([]); + }); + + it('should return an empty array when includeTypes matches nothing', () => { + const result = filterSuggestionsByTypes({ + suggestions, + excludeTypes: [], + includeTypes: ['array'], + }); + + expect(result).toEqual([]); + }); + + it('should keep every suggestion of a matching type', () => { + const anotherStringSuggestion = createSuggestion('string-2', 'string'); + + const result = filterSuggestionsByTypes({ + suggestions: [...suggestions, anotherStringSuggestion], + excludeTypes: [], + includeTypes: ['string'], + }); + + expect(result).toEqual([stringSuggestion, anotherStringSuggestion]); + }); + + it('should handle an empty suggestions list', () => { + const result = filterSuggestionsByTypes({ + suggestions: [], + excludeTypes: ['string'], + includeTypes: ['number'], + }); + + expect(result).toEqual([]); + }); +}); diff --git a/packages/sdk/src/features/variables/utils/core/filter-suggestions-by-types.ts b/packages/sdk/src/features/variables/utils/core/filter-suggestions-by-types.ts new file mode 100644 index 000000000..f9319102a --- /dev/null +++ b/packages/sdk/src/features/variables/utils/core/filter-suggestions-by-types.ts @@ -0,0 +1,24 @@ +import type { VariableType } from '@workflow-builder/types/node-output-schema'; + +import type { VariableSuggestion } from '../../components/variable-text/variable-text.types'; + +type Params = { + suggestions: VariableSuggestion[]; + excludeTypes: VariableType[]; + // If empty accept all + includeTypes: VariableType[] | undefined; +}; + +export function filterSuggestionsByTypes({ suggestions, excludeTypes, includeTypes = [] }: Params) { + return suggestions.filter(({ type }) => { + if (excludeTypes.includes(type) === true) { + return false; + } + + if (includeTypes.length > 0 && includeTypes.includes(type) === false) { + return false; + } + + return true; + }); +} diff --git a/packages/sdk/src/features/variables/utils/core/get-available-variables-by-node-id.ts b/packages/sdk/src/features/variables/utils/core/get-available-variables-by-node-id.ts new file mode 100644 index 000000000..ea15a17b1 --- /dev/null +++ b/packages/sdk/src/features/variables/utils/core/get-available-variables-by-node-id.ts @@ -0,0 +1,76 @@ +import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '../../../../node/node-data'; +import type { VariableType } from '../../../../node/node-output-schema'; +import { getNodeDefinition } from '../../../../utils/validation/get-node-definition'; +import type { VariableSuggestionGroup } from '../../components/variable-text/variable-text.types'; +import { getNodeVariablesSuggestions } from '../../stores/core/get-node-variables-suggestions'; +import { useVariablesSuggestionsStore } from '../../stores/use-variable-suggestions-store'; +import { getNodeAncestors } from '../diagram/get-node-ancestors'; +import { getNodeLabelForVariable } from '../diagram/get-node-label-for-variable'; +import { filterSuggestionsByTypes } from './filter-suggestions-by-types'; + +type Params = { + nodeId: string | undefined; + nodes: WorkflowBuilderNode[]; + edges: WorkflowBuilderEdge[]; + excludeTypes: VariableType[]; + includeTypes: VariableType[]; +}; + +// Returns variables available for nodes as a result of edges connected to their target nodes +export function getAvailableVariablesByNodeId({ + nodeId, + nodes, + edges, + excludeTypes, + includeTypes, +}: Params): VariableSuggestionGroup[] { + if (!nodeId) { + return []; + } + + const variableSuggestionsStore = useVariablesSuggestionsStore.getState(); + + // BFS backward through edges to find all ancestor nodes + const ancestors = getNodeAncestors(nodeId, edges); + + const groups: VariableSuggestionGroup[] = []; + + for (const ancestor of ancestors) { + // Source handle is important because source handle from Source named error and success should returns different variables + const { source: nodeId, sourceHandle } = ancestor; + const node = nodes.find((n) => n.id === nodeId); + if (!node) { + continue; + } + + const definition = getNodeDefinition(node); + if (!definition?.outputSchema) { + continue; + } + + const nodeLabel = getNodeLabelForVariable({ node, definition }); + + const suggestions = + getNodeVariablesSuggestions({ + nodeId, + sourceHandle, + cachedStore: variableSuggestionsStore, + }) || []; + + const filteredSuggestions = filterSuggestionsByTypes({ + suggestions, + excludeTypes, + includeTypes, + }); + + if (filteredSuggestions.length > 0) { + groups.push({ + label: nodeLabel, + icon: node.data.icon, + suggestions: filteredSuggestions, + }); + } + } + + return groups; +} diff --git a/packages/sdk/src/features/variables/utils/diagram/get-node-ancestors.ts b/packages/sdk/src/features/variables/utils/diagram/get-node-ancestors.ts new file mode 100644 index 000000000..64c829a7f --- /dev/null +++ b/packages/sdk/src/features/variables/utils/diagram/get-node-ancestors.ts @@ -0,0 +1,33 @@ +import type { WorkflowBuilderEdge } from '../../../../node/node-data'; + +type AncestorConnection = { + source: string; + sourceHandle?: string | undefined; +}; + +export function getNodeAncestors(nodeId: string, edges: WorkflowBuilderEdge[]): AncestorConnection[] { + const ancestors = new Map(); + + const queue = [nodeId]; + + while (queue.length > 0) { + const currentNodeId = queue.shift()!; + + for (const edge of edges) { + if (edge.target === currentNodeId) { + const key = `${edge.source}:${edge.sourceHandle ?? ''}`; + + if (!ancestors.has(key)) { + ancestors.set(key, { + source: edge.source, + sourceHandle: edge.sourceHandle ?? undefined, + }); + + queue.push(edge.source); + } + } + } + } + + return [...ancestors.values()]; +} diff --git a/packages/sdk/src/features/variables/utils/diagram/get-node-label-for-variable.ts b/packages/sdk/src/features/variables/utils/diagram/get-node-label-for-variable.ts new file mode 100644 index 000000000..dd0515083 --- /dev/null +++ b/packages/sdk/src/features/variables/utils/diagram/get-node-label-for-variable.ts @@ -0,0 +1,11 @@ +import type { PaletteItem } from '../../../../node/common'; +import type { WorkflowBuilderNode } from '../../../../node/node-data'; + +type Params = { + definition: PaletteItem; + node: WorkflowBuilderNode; +}; + +export function getNodeLabelForVariable({ node, definition }: Params): string { + return (node.data.properties as { label?: string }).label || definition.label || node.data.type; +} diff --git a/packages/sdk/src/features/variables/utils/filter-suggestion-groups-by-type.ts b/packages/sdk/src/features/variables/utils/filter-suggestion-groups-by-type.ts index cc9f4bbdf..e6bae8e08 100644 --- a/packages/sdk/src/features/variables/utils/filter-suggestion-groups-by-type.ts +++ b/packages/sdk/src/features/variables/utils/filter-suggestion-groups-by-type.ts @@ -1,4 +1,5 @@ -import type { VariableType } from '../../../node/node-output-schema'; +import type { VariableType } from '@workflow-builder/types/node-output-schema'; + import { typesForDate } from '../components/dynamic-typed-input/constants'; import type { VariableSuggestionGroup } from '../components/variable-text/variable-text.types'; import { getIsDateType } from './get-is-date-type'; diff --git a/packages/sdk/src/features/variables/actions/conditions.ts b/packages/sdk/src/features/variables/utils/form-validation/conditions.ts similarity index 74% rename from packages/sdk/src/features/variables/actions/conditions.ts rename to packages/sdk/src/features/variables/utils/form-validation/conditions.ts index e4c18502d..97698cf74 100644 --- a/packages/sdk/src/features/variables/actions/conditions.ts +++ b/packages/sdk/src/features/variables/utils/form-validation/conditions.ts @@ -1,7 +1,7 @@ -import type { DynamicCondition } from '../../../types/controls'; -import { numberComparisonsOperators } from '../constants'; -import { getIsWrongTypeButAcceptable } from './get-is-wrong-type-but-acceptable'; -import { getStringType } from './get-string-type'; +import type { DynamicCondition } from '../../../../types/controls'; +import { getStringVariableTypeIfPossible } from '../../actions/get-string-variable-type-if-possible'; +import { numberComparisonsOperators } from '../../constants'; +import { getIsWrongTypeButAcceptable } from '../get-is-wrong-type-but-acceptable'; export function conditionsToDependencies(conditions: DynamicCondition[]): string[] { return conditions.reduce((stack: string[], condition) => { @@ -30,8 +30,8 @@ export function getConditionErrors(condition: Partial): Condit }; // The type of x defines the type of the entire condition. - const xType = getStringType(condition.x); - const yType = getStringType(condition.y); + const xType = getStringVariableTypeIfPossible(condition.x); + const yType = getStringVariableTypeIfPossible(condition.y); if (xType !== yType) { const isWrongTypeButAcceptable = getIsWrongTypeButAcceptable({ diff --git a/packages/sdk/src/features/variables/actions/definitions.ts b/packages/sdk/src/features/variables/utils/form-validation/definitions.ts similarity index 63% rename from packages/sdk/src/features/variables/actions/definitions.ts rename to packages/sdk/src/features/variables/utils/form-validation/definitions.ts index 415a02cfb..41ceb2f25 100644 --- a/packages/sdk/src/features/variables/actions/definitions.ts +++ b/packages/sdk/src/features/variables/utils/form-validation/definitions.ts @@ -1,6 +1,6 @@ -import type { VariableDefinition } from '../types'; -import { getIsWrongTypeButAcceptable } from './get-is-wrong-type-but-acceptable'; -import { getStringType } from './get-string-type'; +import { getStringVariableTypeIfPossible } from '../../actions/get-string-variable-type-if-possible'; +import type { VariableDefinition } from '../../types'; +import { getIsWrongTypeButAcceptable } from '../get-is-wrong-type-but-acceptable'; type DefinitionErrors = { [K in keyof VariableDefinition]: boolean; @@ -24,7 +24,7 @@ export function getDefinitionErrors(definition: Partial): De } const selectedType = definition.type; - const defaultValueType = getStringType(definition.defaultValue); + const defaultValueType = getStringVariableTypeIfPossible(definition.defaultValue); if (selectedType !== defaultValueType) { const isWrongTypeButAcceptable = getIsWrongTypeButAcceptable({ @@ -37,5 +37,10 @@ export function getDefinitionErrors(definition: Partial): De } } + // Uncomment if required + // if (!definition.description) { + // validity.description = true; + // } + return validity; } diff --git a/packages/sdk/src/features/variables/utils/get-boolean-if-possible.ts b/packages/sdk/src/features/variables/utils/get-boolean-if-possible.ts new file mode 100644 index 000000000..11215a24d --- /dev/null +++ b/packages/sdk/src/features/variables/utils/get-boolean-if-possible.ts @@ -0,0 +1,35 @@ +import { ITEMS_FOR_BOOLEAN_VALUES, itemsForBoolean } from '../components/dynamic-typed-input/constants'; + +export function getBooleanIfPossible(value: string | boolean | undefined): boolean | undefined { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string' && itemsForBoolean.some((option) => option.value === value)) { + if (value === ITEMS_FOR_BOOLEAN_VALUES.TRUE) { + return true; + } + + if (value === ITEMS_FOR_BOOLEAN_VALUES.FALSE) { + return false; + } + } + + return undefined; +} + +export function getBooleanStringIfPossible(value: string | boolean | undefined): string | undefined { + if (typeof value === 'string' && itemsForBoolean.some((option) => option.value === value)) { + return value; + } + + if (value === true) { + return ITEMS_FOR_BOOLEAN_VALUES.TRUE; + } + + if (value === false) { + return ITEMS_FOR_BOOLEAN_VALUES.FALSE; + } + + return ITEMS_FOR_BOOLEAN_VALUES.EMPTY; +} diff --git a/packages/sdk/src/features/variables/utils/get-global-variable-key.ts b/packages/sdk/src/features/variables/utils/get-global-variable-key.ts deleted file mode 100644 index 2c519bb03..000000000 --- a/packages/sdk/src/features/variables/utils/get-global-variable-key.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { VARIABLE_GLOBAL_KEY } from '../constants'; - -export function getGlobalVariableKey(variableId: string) { - return `${VARIABLE_GLOBAL_KEY}.${variableId}`; -} diff --git a/packages/sdk/src/features/variables/utils/get-is-date-type.ts b/packages/sdk/src/features/variables/utils/get-is-date-type.ts index 12a046134..42f53c42b 100644 --- a/packages/sdk/src/features/variables/utils/get-is-date-type.ts +++ b/packages/sdk/src/features/variables/utils/get-is-date-type.ts @@ -1,6 +1,7 @@ -import type { VariableTypePrimitive } from '../../../node/node-output-schema'; +import type { VariableType, VariableTypePrimitive } from '@workflow-builder/types/node-output-schema'; + import { typesForDate } from '../components/dynamic-typed-input/constants'; -export function getIsDateType(type: VariableTypePrimitive | string | undefined) { +export function getIsDateType(type: VariableType | string | undefined) { return typesForDate.includes((type || '') as VariableTypePrimitive); } diff --git a/packages/sdk/src/features/variables/actions/get-is-wrong-type-but-acceptable.ts b/packages/sdk/src/features/variables/utils/get-is-wrong-type-but-acceptable.ts similarity index 63% rename from packages/sdk/src/features/variables/actions/get-is-wrong-type-but-acceptable.ts rename to packages/sdk/src/features/variables/utils/get-is-wrong-type-but-acceptable.ts index f66e48d8d..4244c0b38 100644 --- a/packages/sdk/src/features/variables/actions/get-is-wrong-type-but-acceptable.ts +++ b/packages/sdk/src/features/variables/utils/get-is-wrong-type-but-acceptable.ts @@ -1,15 +1,26 @@ import type { VariableTypePrimitive } from '../../../node/node-output-schema'; import { getIsValidDate } from '../../../utils/validation/get-is-valid-date'; +import { getStringVariableTypeIfPossible } from '../actions/get-string-variable-type-if-possible'; import { acceptedBooleanValues, typesForDate } from '../components/dynamic-typed-input/constants'; -import { getStringType } from './get-string-type'; type Params = { expectedType?: VariableTypePrimitive; value: string | undefined; }; +/** + * Tells whether a value's inferred type doesn't match the expected type, + * but is still usable (a "soft" mismatch we can tolerate instead of erroring). + * + * Returns false when types match, and false when the mismatch is unacceptable. + * Returns true only for these tolerated mismatches: + * - number value where a string is expected (e.g. '12' compared as string) + * - boolean expected with 'true' / 'false' / '' string value + * - date ↔ datetime mix + * - date/datetime expected with a string that parses as a valid date + */ export function getIsWrongTypeButAcceptable({ expectedType = 'string', value }: Params) { - const valueType = getStringType(value); + const valueType = getStringVariableTypeIfPossible(value); if (expectedType !== valueType) { // We can use string variable and compare it to the string '12' diff --git a/packages/sdk/src/features/variables/utils/get-node-suggestions-from-output-properties.ts b/packages/sdk/src/features/variables/utils/get-node-suggestions-from-output-properties.ts deleted file mode 100644 index 62754f13e..000000000 --- a/packages/sdk/src/features/variables/utils/get-node-suggestions-from-output-properties.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { OutputProperty } from '../../../node/node-output-schema'; -import { truncate } from '../../../utils/text'; -import type { VariableSuggestion } from '../components/variable-text/variable-text.types'; -import { VARIABLE_NODES_KEY } from '../constants'; - -type Params = { - properties: Record; - nodeId: string; - nodeLabel: string; - excludeTypes?: string[]; -}; - -export const getNodeSuggestionsFromOutputProperties = ({ - properties, - nodeId, - nodeLabel, - excludeTypes = [], -}: Params): VariableSuggestion[] => { - const suggestions = Object.entries(properties).map(([propertyKey, property]) => ({ - id: `${VARIABLE_NODES_KEY}.${nodeId}.${propertyKey}`, - display: `${truncate(nodeLabel, 15)} · ${truncate(property.label, 15)}`, - label: property.label, - description: property.description, - type: property.type, - })); - - if (excludeTypes.length === 0) { - return suggestions; - } - - return suggestions.filter(({ type }) => excludeTypes.includes(type) === false); -}; diff --git a/packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.ts b/packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.ts new file mode 100644 index 000000000..bc2f95275 --- /dev/null +++ b/packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.ts @@ -0,0 +1,42 @@ +import { VARIABLE_BRACKETS_END, VARIABLE_BRACKETS_START } from '../../constants'; +import type { MaybeVariableReference } from '../../types'; + +export function getIsStringVariableReference(value: MaybeVariableReference): boolean { + const valueTrimmed = typeof value === 'string' ? value?.trim() : ''; + if (!valueTrimmed) { + return false; + } + + const hasExpectedBrackets = + valueTrimmed.startsWith(VARIABLE_BRACKETS_START) && valueTrimmed.endsWith(VARIABLE_BRACKETS_END); + if (!hasExpectedBrackets) { + return false; + } + + const isOnlyOneVariable = + `${VARIABLE_BRACKETS_START}${valueTrimmed.replaceAll(VARIABLE_BRACKETS_START, '').replaceAll(VARIABLE_BRACKETS_END, '')}${VARIABLE_BRACKETS_END}` === + valueTrimmed; + + if (isOnlyOneVariable) { + return true; + } + + return false; +} + +export function getIsStringVariableReferenceStart(value: MaybeVariableReference): boolean { + const valueTrimmed = typeof value === 'string' ? value?.trim() : ''; + if (!valueTrimmed) { + return false; + } + + if (valueTrimmed.startsWith(VARIABLE_BRACKETS_START.slice(0, 1)) && valueTrimmed.length === 1) { + return true; + } + + if (valueTrimmed.startsWith(VARIABLE_BRACKETS_START.slice(0, 2))) { + return true; + } + + return false; +} diff --git a/packages/sdk/src/features/variables/utils/keys/get-variable-reference-if-possible.ts b/packages/sdk/src/features/variables/utils/keys/get-variable-reference-if-possible.ts new file mode 100644 index 000000000..954d14aca --- /dev/null +++ b/packages/sdk/src/features/variables/utils/keys/get-variable-reference-if-possible.ts @@ -0,0 +1,10 @@ +import type { MaybeVariableReference, VariableReference } from '../../types'; +import { getIsStringVariableReference } from './get-is-string-variable-reference'; + +export function getVariableReferenceIfPossible(value: MaybeVariableReference): VariableReference | undefined { + const isValid = getIsStringVariableReference(value?.trim()); + + if (value && isValid) { + return value.trim() as VariableReference; + } +} diff --git a/packages/sdk/src/features/variables/utils/keys/get-variable-reference-without-brackets-for-global.ts b/packages/sdk/src/features/variables/utils/keys/get-variable-reference-without-brackets-for-global.ts new file mode 100644 index 000000000..e2f1c5db5 --- /dev/null +++ b/packages/sdk/src/features/variables/utils/keys/get-variable-reference-without-brackets-for-global.ts @@ -0,0 +1,6 @@ +import { VARIABLE_GLOBAL_KEY } from '../../constants'; +import type { MaybeVariableReference } from '../../types'; + +export function getVariableReferenceWithoutBracketsForGlobal(variableId: string): NonNullable { + return `${VARIABLE_GLOBAL_KEY}.${variableId}`; +} diff --git a/packages/sdk/src/features/variables/utils/keys/get-variable-reference-without-brackets-for-node.ts b/packages/sdk/src/features/variables/utils/keys/get-variable-reference-without-brackets-for-node.ts new file mode 100644 index 000000000..339e82957 --- /dev/null +++ b/packages/sdk/src/features/variables/utils/keys/get-variable-reference-without-brackets-for-node.ts @@ -0,0 +1,14 @@ +import { VARIABLE_NODES_KEY } from '../../constants'; +import type { MaybeVariableReference } from '../../types'; + +type Params = { + nodeId: string; + propertyName: string; +}; + +export function getVariableReferenceWithoutBracketsForNode({ + nodeId, + propertyName, +}: Params): NonNullable { + return `${VARIABLE_NODES_KEY}.${nodeId}.${propertyName}`; +} diff --git a/packages/sdk/src/features/variables/utils/keys/get-variable-references.ts b/packages/sdk/src/features/variables/utils/keys/get-variable-references.ts new file mode 100644 index 000000000..9164d6832 --- /dev/null +++ b/packages/sdk/src/features/variables/utils/keys/get-variable-references.ts @@ -0,0 +1,41 @@ +import { VARIABLE_BRACKETS_END, VARIABLE_BRACKETS_START } from '../../constants'; +import type { MaybeVariableReference, VariableReference } from '../../types'; +import { getIsStringVariableReferenceStart } from './get-is-string-variable-reference'; +import { getVariableReferenceIfPossible } from './get-variable-reference-if-possible'; + +type Response = { + reference: VariableReference | undefined; + referenceWithoutBrackets: string | undefined; +}; + +const INVALID_RESPONSE: Response = { + reference: undefined, + referenceWithoutBrackets: undefined, +}; + +export function getVariableReferences(keyOrReference: MaybeVariableReference): Response { + const stringToParse = keyOrReference?.trim() || ''; + if (!stringToParse) { + return INVALID_RESPONSE; + } + + const isMaybeReference = getIsStringVariableReferenceStart(stringToParse); + const maybeReference = isMaybeReference + ? stringToParse + : `${VARIABLE_BRACKETS_START}${stringToParse}${VARIABLE_BRACKETS_END}`; + + const reference = getVariableReferenceIfPossible(maybeReference); + + if (!reference) { + return INVALID_RESPONSE; + } + + const referenceWithoutBrackets = isMaybeReference + ? stringToParse.slice(VARIABLE_BRACKETS_START.length).slice(0, -1 * VARIABLE_BRACKETS_END.length) + : stringToParse; + + return { + reference, + referenceWithoutBrackets, + }; +} diff --git a/packages/sdk/src/features/variables/variables-referencing-strategy.decision-log.md b/packages/sdk/src/features/variables/variables-referencing-strategy.decision-log.md new file mode 100644 index 000000000..2a12fe481 --- /dev/null +++ b/packages/sdk/src/features/variables/variables-referencing-strategy.decision-log.md @@ -0,0 +1,60 @@ +### Title: Variable Referencing Strategy + +### Proposed by: Szymon Tondowski + +### Date: 13.08.2026 + +## Context + +Workflow Builder supports passing variables by typing '{{' in dedicated controls. To provide relevant variable suggestions, the application needs to recognize which variables are available within a given node and determine which types of variables (e.g. number, string, or date) are accepted by each control. Different controls may accept different variable types. + +Variable suggestions for a given control depend on the global variables and the variables produced by previous nodes connected to it. + +We need a robust mechanism for storing accessing variable suggestions so that the picker can efficiently provide the relevant options whenever the user starts typing '{{'. + +## Decisions + +### 1. Precaching suggestions in dedicated store + +Using an additional Zustand store to keep available variables by nodeId and sourceHandleId, allowing them to be collected using those parameters when the list of available variables is shown. + +#### Consequences + +##### Pros + +- Improved Performance: Variables available further in the flow inherit values from previous nodes. This is an expensive operation that still needs to be calculated to collect the available suggestions, but the values themselves do not need to be recalculated (we take them from the store) +- Separation of Concerns: The complexity of determining which variables are available as outputs of a node and which should be shown for a control in another node is separated. +- Centralized State: We can preview the available suggestions without triggering a control search to build them. They can be inspected directly in Redux Toolkit DevTools or through a dedicated plugin that displays data for picked node +- Reusability: Different controls can consume the same suggestion data (we don't need to recalculate them) +- Scalability: The approach provides a foundation for supporting more complex variable availability and type rules in the future. + +##### Cons + +- Additional State Management: Introducing a dedicated store adds another layer of application state. +- Cache Invalidation: The store needs to ensure cached suggestions are updated when the workflow or available variables change. + +##### Alternative Options Considered + +1. **Calculating suggestions per control on focus** + - **Pros:** No memory used for centralized state + - **Cons:** Harder to debug, as it entangles the collection of variables from previous nodes with the dynamic process of building them + +### 2. Target handles don't influence variable availability + +The nature of the most common diagrams in Workflow Builder can result in different variables being provided by different source outputs of a node. For example, a condition node can provide different variables for the true and false branches. However, a potential implementation of a node with multiple incoming handles shouldn't affect the available variables in the sidebar, as they are all defined per field in the sidebar. + +#### Consequences + +##### Pros + +- Simpler Implementation: The approach aligns with the current workflow design, where variables are defined and passed through fields in the sidebar rather than being determined dynamically by the graph structure. +- Separation of Concerns: Target handles are treated as connections that affect the node's execution flow, rather than as a mechanism for determining which variables are available to its inputs. Introducing this behavior would require additional complex logic in Workflow Builder or additional parsing logic in the engine. +- Built-in Type Checking and Validation: The current implementation requires users to provide compatible variables to sidebar inputs, where type checking and validation can ensure that the provided variables are valid. (We don't need to block edge creation because the value provided to the target handle has the wrong type) + +##### Cons + +- Limited Support for Database-Like Diagrams: This approach does not support diagrams where variables are passed between nodes through edges, similar to how data flows between nodes in database-like systems. In such cases, variables would need to be explicitly propagated through the graph rather than being defined per input field. + +## Status + +Accepted diff --git a/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx b/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx index aebe968c4..815d8ad3b 100644 --- a/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx +++ b/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { openExportModal } from '../features/integration/components/import-export/export-modal/open-export-modal'; import { openImportModal } from '../features/integration/components/import-export/import-modal/open-import-modal'; import { IntegrationContext } from '../features/integration/components/integration-variants/context/integration-context-wrapper'; -import { openModalWorkflowSettings } from '../features/variables/modals/modal-settings'; +import { openModalWorkflowSettings } from '../features/variables/modals/global/modal-settings'; import { useStore } from '../store/store'; import { getTheme } from './theme'; import { type WorkflowBuilderActions, useWorkflowBuilderActions } from './use-workflow-builder-actions'; diff --git a/packages/sdk/src/hooks/use-workflow-builder-actions.ts b/packages/sdk/src/hooks/use-workflow-builder-actions.ts index df4897dff..924958d40 100644 --- a/packages/sdk/src/hooks/use-workflow-builder-actions.ts +++ b/packages/sdk/src/hooks/use-workflow-builder-actions.ts @@ -3,7 +3,7 @@ import { useContext, useMemo } from 'react'; import { openExportModal } from '../features/integration/components/import-export/export-modal/open-export-modal'; import { openImportModal } from '../features/integration/components/import-export/import-modal/open-import-modal'; import { IntegrationContext } from '../features/integration/components/integration-variants/context/integration-context-wrapper'; -import { openModalWorkflowSettings } from '../features/variables/modals/modal-settings'; +import { openModalWorkflowSettings } from '../features/variables/modals/global/modal-settings'; import type { LayoutDirection } from '../node/common'; import { getStoreNodes, setStoreNodes } from '../store/slices/diagram-slice/actions'; import { useStore } from '../store/store'; diff --git a/packages/sdk/src/node/node-output-schema.ts b/packages/sdk/src/node/node-output-schema.ts index dcfeda6d9..198bc2adc 100644 --- a/packages/sdk/src/node/node-output-schema.ts +++ b/packages/sdk/src/node/node-output-schema.ts @@ -11,24 +11,26 @@ export function getVariableTypeIfPrimitive(type: VariableType): VariableTypePrim } export type OutputProperty = { - type: VariableType; - label: string; + type: VariableTypePrimitive; + label?: string; description?: string; }; export const OUTPUT_SCHEMA_TYPE = { DEFAULT: 'default', VARIANT: 'variant', + PROPERTY_VALUE: 'property-value', } as const; -export type OutputPropertiesIndex = Record; +export type OutputPropertiesIndex = Record; export type OutputVariant = { variantRule: | undefined | { - dataPropertyName: string; - dataPropertyValue: string; + sourceHandleId?: string; + dataPropertyName?: string; + dataPropertyValue?: string; }; properties: OutputPropertiesIndex; }; @@ -40,12 +42,20 @@ export type NodeOutputSchemaDefault = { export type NodeOutputSchemaVariant = { /* - Variants may be set dynamically by the node configuration. - */ + Predefined variant depending on the value of a property. + */ type: 'variant'; variants: { [variantName: string]: OutputVariant | undefined; }; }; -export type NodeOutputSchema = NodeOutputSchemaDefault | NodeOutputSchemaVariant; +export type NodeOutputSchemaPropertyValue = { + /* + Value is built dynamically in the node property. + */ + type: 'property-value'; + propertyPath: string; +}; + +export type NodeOutputSchema = NodeOutputSchemaDefault | NodeOutputSchemaVariant | NodeOutputSchemaPropertyValue; diff --git a/packages/sdk/src/store/slices/diagram-slice/actions.ts b/packages/sdk/src/store/slices/diagram-slice/actions.ts index 282e3b000..169abe06f 100644 --- a/packages/sdk/src/store/slices/diagram-slice/actions.ts +++ b/packages/sdk/src/store/slices/diagram-slice/actions.ts @@ -145,6 +145,10 @@ export function refreshNodesErrorsIfNeeded() { }); } +export function getStoreVariables() { + return useStore.getState().globalVariables; +} + export function saveVariableDefinition(definition: VariableDefinition) { useStore.setState((state) => ({ globalVariables: { diff --git a/packages/sdk/src/types/controls.ts b/packages/sdk/src/types/controls.ts index 6259b277c..904b862b8 100644 --- a/packages/sdk/src/types/controls.ts +++ b/packages/sdk/src/types/controls.ts @@ -1,6 +1,8 @@ import type { ControlElement, ControlProps as JsonFormsControlProps } from '@jsonforms/core'; import type { InputProps, TextAreaProps } from '@synergycodes/overflow-ui'; +import type { VariableType } from '@workflow-builder/types/node-output-schema'; + import type { ComparisonOperator, LogicalOperator } from '../features/variables/constants'; import type { FieldSchema } from '../node/node-schema'; import type { UISchemaRule } from './rules'; @@ -117,6 +119,7 @@ export type VariableTextControlElement = Override< BaseControlElement, { type: 'VariableText'; + variablesTypes?: VariableType[]; } & Pick >; export type VariableTextControlProps = ControlProps; @@ -125,6 +128,7 @@ export type VariableTextAreaControlElement = Override< BaseControlElement, { type: 'VariableTextArea'; + variablesTypes?: VariableType[]; } & Pick >; export type VariableTextAreaControlProps = ControlProps; diff --git a/packages/sdk/src/utils/a11y.ts b/packages/sdk/src/utils/a11y.ts index 9c2150859..d94a4b2cb 100644 --- a/packages/sdk/src/utils/a11y.ts +++ b/packages/sdk/src/utils/a11y.ts @@ -1,20 +1,20 @@ // https://stackoverflow.com/a/40686327/6743808 export function focusNextElement() { - const focussableElements = + const focusableElements = 'a:not([disabled]), button:not([disabled]), input[type=text]:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])'; if (document.activeElement) { - const focussable = Array.prototype.filter.call( - document.activeElement.querySelectorAll(focussableElements), + const focusable = Array.prototype.filter.call( + document.activeElement.querySelectorAll(focusableElements), function (element) { return element.offsetWidth > 0 || element.offsetHeight > 0 || element === document.activeElement; }, ); - const index = focussable.indexOf(document.activeElement); + const index = focusable.indexOf(document.activeElement); - const targetElement = focussable[index + 1]; + const targetElement = focusable[index + 1]; if (targetElement) { - focussable[index + 1].focus(); + focusable[index + 1].focus(); } else { console.warn('Not focusable element found'); (document.activeElement as HTMLElement)?.blur(); diff --git a/packages/sdk/src/utils/object.ts b/packages/sdk/src/utils/object.ts new file mode 100644 index 000000000..ac3ca0231 --- /dev/null +++ b/packages/sdk/src/utils/object.ts @@ -0,0 +1,15 @@ +type AnyRecord = Record; + +export function getByPath(object: AnyRecord | null | undefined, path: string): T | undefined { + if (!object) return undefined; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let result: any = object; + + for (const key of path.split('.')) { + if (result == null) return undefined; + result = result[key]; + } + + return result; +} diff --git a/packages/sdk/src/utils/text.ts b/packages/sdk/src/utils/text.ts index 8a3f86558..55bb42115 100644 --- a/packages/sdk/src/utils/text.ts +++ b/packages/sdk/src/utils/text.ts @@ -1,4 +1,53 @@ -export const capitalize = (text: string | undefined = '') => (text ? text[0].toUpperCase() + text.slice(1) : text); +export const capitalizeFirstLetter = (text: string | undefined = '') => + text ? text[0].toUpperCase() + text.slice(1) : text; export const truncate = (text: string, maxLength: number) => text.length > maxLength ? `${text.slice(0, maxLength)}...` : text; + +const floorCaseToPascalCase = (text: string): string => { + const textWithoutSpaces = text.replaceAll(' ', '_'); + if (textWithoutSpaces.includes('_') === false) { + return text; + } + + return textWithoutSpaces + .split('_') + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(''); +}; + +export const keyToLabel = (key: string): string => { + if (!key) { + return ''; + } + const pascalCaseKey = floorCaseToPascalCase(key); + + const words = pascalCaseKey.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z]+/g) ?? []; + return words + .map((word, index) => { + if (/^[A-Z]+$/.test(word)) { + // Preserve acronyms like ID, HTML, API + return word; + } + + if (['id', 'api', 'html'].includes(word.toLowerCase())) { + return word.toUpperCase(); + } + + const lower = word.toLowerCase(); + + return index === 0 ? lower.charAt(0).toUpperCase() + lower.slice(1) : lower; + }) + .join(' '); +}; + +export const labelToFloorCase = (label: string) => { + // Normalization guessing the best strategy + const labelToUse = label.replaceAll(' ', '_'); + const pascalCaseLabel = floorCaseToPascalCase(labelToUse); + + const words = pascalCaseLabel.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+/g) ?? []; + + return words.map((word) => word.toLowerCase()).join('_'); +}; diff --git a/packages/sdk/src/utils/time.ts b/packages/sdk/src/utils/time.ts index ab2b0d25b..af7aa1635 100644 --- a/packages/sdk/src/utils/time.ts +++ b/packages/sdk/src/utils/time.ts @@ -30,7 +30,55 @@ export function getTimeFromDateIfValid(dateString?: string): undefined | string return format(date, 'HH:mm'); } -export function setDateWithTimeFromTime(date: Date, timeStamp: string) { +type DateLike = Date | number | string; + +export function getISODate(dateLike: DateLike | null): string { + if (!dateLike) { + console.warn(`DateString expected but missing`); + + return ''; + } + + if (typeof (dateLike as Date)?.toISOString === 'function') { + return (dateLike as Date)?.toISOString(); + } + + if (typeof dateLike === 'number') { + const date = new Date(dateLike); + + const isValidDate = !Number.isNaN(date.getTime()); + if (!isValidDate) { + console.warn(`DateString doesn't support number`, dateLike); + return ''; + } + + const year = date.getFullYear(); + const dateISO = date.toISOString(); + if (year < 1980) { + console.warn(`DateString doesn't is number but may be wrong`, dateLike, dateISO); + } + + return dateISO; + } + + if (typeof dateLike === 'string') { + const date = new Date(dateLike); + + if (!Number.isNaN(date.getTime())) { + return date.toISOString(); + } + + console.warn(`DateString doesn't support string`, dateLike); + + return dateLike; + } + + console.warn(`DateString doesn't support ISO`, dateLike); + + return dateLike ? dateLike.toString() : ''; +} + +export function setDateWithTimeFromTime(date: string | Date, timeStamp: string) { if (!date || !timeStamp) { return date; } From 00cf36ab01299d1b3b2b391f2265824f531751a9 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Thu, 13 Aug 2026 12:56:25 +0200 Subject: [PATCH 02/33] chore(sdk): better typing for getVariableReferences --- .../get-single-variable-metadata-if-possible.ts | 2 +- .../variables/utils/keys/get-variable-references.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/features/variables/actions/get-single-variable-metadata-if-possible.ts b/packages/sdk/src/features/variables/actions/get-single-variable-metadata-if-possible.ts index 500fb6b34..0e08fd6e7 100644 --- a/packages/sdk/src/features/variables/actions/get-single-variable-metadata-if-possible.ts +++ b/packages/sdk/src/features/variables/actions/get-single-variable-metadata-if-possible.ts @@ -26,7 +26,7 @@ export function getSingleVariableMetadataIfPossible( ): VariableMetadata | undefined { const { reference, referenceWithoutBrackets } = getVariableReferences(maybeReference); - if (!reference || !referenceWithoutBrackets) { + if (!reference) { return; } diff --git a/packages/sdk/src/features/variables/utils/keys/get-variable-references.ts b/packages/sdk/src/features/variables/utils/keys/get-variable-references.ts index 9164d6832..2f6a39914 100644 --- a/packages/sdk/src/features/variables/utils/keys/get-variable-references.ts +++ b/packages/sdk/src/features/variables/utils/keys/get-variable-references.ts @@ -3,10 +3,15 @@ import type { MaybeVariableReference, VariableReference } from '../../types'; import { getIsStringVariableReferenceStart } from './get-is-string-variable-reference'; import { getVariableReferenceIfPossible } from './get-variable-reference-if-possible'; -type Response = { - reference: VariableReference | undefined; - referenceWithoutBrackets: string | undefined; -}; +type Response = + | { + reference: VariableReference; + referenceWithoutBrackets: string; + } + | { + reference: undefined; + referenceWithoutBrackets: undefined; + }; const INVALID_RESPONSE: Response = { reference: undefined, From 53acf52f346d1eae22fd8d0c98cc28c6f8e46698 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Thu, 13 Aug 2026 14:09:09 +0200 Subject: [PATCH 03/33] chore(sdk): naming, linting, restoring css variables --- packages/sdk/src/features/i18n/locales/en.ts | 1 + packages/sdk/src/features/i18n/locales/pl.ts | 1 + .../edge-properties/edge-properties.tsx | 2 ++ .../node-properties/node-properties.tsx | 2 +- .../dynamic-typed-input.tsx | 1 - .../dynamic-typed-variable-or-input.tsx | 4 +-- .../schema-builder/schema-builder.tsx | 23 +++++++--------- .../variable-preview/variable-meta.module.css | 9 +++++-- .../variable-select/variable-select.tsx | 5 ++-- .../variable-text/variable-text.module.css | 2 ++ .../sdk/src/features/variables/constants.ts | 26 ++++++++++++++++++- .../modals/global/modal-settings.module.css | 1 + .../settings/settings-navigation.module.css | 8 ++++-- .../pane-remove-variable.module.css | 11 +++++--- .../core/get-node-variables-suggestions.ts | 11 +++++--- .../utils/core/filter-suggestions-by-types.ts | 2 +- .../keys/get-is-string-variable-reference.ts | 6 ++++- packages/sdk/src/hooks/use-palette-drop.ts | 2 +- 18 files changed, 83 insertions(+), 34 deletions(-) diff --git a/packages/sdk/src/features/i18n/locales/en.ts b/packages/sdk/src/features/i18n/locales/en.ts index 7cf4cf3e0..44c672d0c 100644 --- a/packages/sdk/src/features/i18n/locales/en.ts +++ b/packages/sdk/src/features/i18n/locales/en.ts @@ -138,6 +138,7 @@ export const en = { variableNotFound: 'Variable not found.', removeVariableWarning: 'Deleting this variable will permanently remove its configuration.', removeVariableIsBlocked: 'The variable is used in the following nodes and cannot be deleted.', + addVariableToContinue: 'Add a variable to continue', }, loader: { text: 'Loading...', diff --git a/packages/sdk/src/features/i18n/locales/pl.ts b/packages/sdk/src/features/i18n/locales/pl.ts index ea00cdaa0..d556a6203 100644 --- a/packages/sdk/src/features/i18n/locales/pl.ts +++ b/packages/sdk/src/features/i18n/locales/pl.ts @@ -102,6 +102,7 @@ export const pl = { variableNotFound: 'Nie znaleziono zmiennej.', removeVariableWarning: 'Usunięcie tej zmiennej trwale usunie jej konfigurację.', removeVariableIsBlocked: 'Ta zmienna jest używana w następujących węzłach i nie może zostać usunięta.', + addVariableToContinue: 'Dodaj zmienną aby kontynuować', }, loader: { text: 'Ładowanie...', diff --git a/packages/sdk/src/features/properties-bar/components/edge-properties/edge-properties.tsx b/packages/sdk/src/features/properties-bar/components/edge-properties/edge-properties.tsx index e3edad031..5f871ef6d 100644 --- a/packages/sdk/src/features/properties-bar/components/edge-properties/edge-properties.tsx +++ b/packages/sdk/src/features/properties-bar/components/edge-properties/edge-properties.tsx @@ -6,6 +6,7 @@ import styles from './edge-properties.module.css'; import { FormControlWithLabel } from '../../../../components/form/form-control-with-label/form-control-with-label'; import type { WorkflowBuilderEdge } from '../../../../node/node-data'; import { useStore } from '../../../../store/store'; +import { trackFutureChange } from '../../../changes-tracker/stores/use-changes-tracker-store'; import { OptionalEdgeProperties } from '../../../plugins-core/components/app/optional-edge-properties'; type Props = { @@ -28,6 +29,7 @@ export function EdgeProperties({ edge }: Props) { const onChange: React.ChangeEventHandler = (event) => { const { value } = event.target; setInput(value); + trackFutureChange('dataUpdateEdge', { id }); setEdgeData(id, { label: value }); }; diff --git a/packages/sdk/src/features/properties-bar/components/node-properties/node-properties.tsx b/packages/sdk/src/features/properties-bar/components/node-properties/node-properties.tsx index 21b2d72a7..5288b06ae 100644 --- a/packages/sdk/src/features/properties-bar/components/node-properties/node-properties.tsx +++ b/packages/sdk/src/features/properties-bar/components/node-properties/node-properties.tsx @@ -88,7 +88,7 @@ export const NodeProperties = memo(({ node }: Props) => { } const flattenErrors = flatErrors(errors); - trackFutureChange('dataUpdate'); + trackFutureChange('dataUpdateNode', { id }); setNodeProperties(id, { ...data, errors: flattenErrors }); removeEdgesForDeletedHandles(id, properties, data); }; diff --git a/packages/sdk/src/features/variables/components/dynamic-typed-input/dynamic-typed-input.tsx b/packages/sdk/src/features/variables/components/dynamic-typed-input/dynamic-typed-input.tsx index 7876f798e..08e225811 100644 --- a/packages/sdk/src/features/variables/components/dynamic-typed-input/dynamic-typed-input.tsx +++ b/packages/sdk/src/features/variables/components/dynamic-typed-input/dynamic-typed-input.tsx @@ -195,7 +195,6 @@ export function DynamicTypedInput({ const newValue = getISODate(date); onChange(newValue); - // setTime(getTimeFromDateIfValid(newValue)); // It's the most reliable method to call if (onBlur) { diff --git a/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx b/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx index fb9a6b0c2..823682946 100644 --- a/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx +++ b/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx @@ -15,7 +15,7 @@ import { DynamicTypedInput } from '../dynamic-typed-input/dynamic-typed-input'; import { VariableSelect } from '../variable-select/variable-select'; import type { VariableSuggestionGroup } from '../variable-text/variable-text.types'; -type DynamicTypedVariableOrInputProps = { +type Props = { className?: string; onChange: (value: string) => void; onBlur?: (value: string) => void; @@ -39,7 +39,7 @@ export function DynamicTypedVariableOrInput({ type, isDisabled, suggestionGroups = [], -}: DynamicTypedVariableOrInputProps) { +}: Props) { const { t } = useTranslation(); const translateIfPossible = useTranslateIfPossible(); const [mode, setMode] = useState(getIsStringVariableReference(value) ? 'variable' : 'manual'); diff --git a/packages/sdk/src/features/variables/components/schema-builder/schema-builder.tsx b/packages/sdk/src/features/variables/components/schema-builder/schema-builder.tsx index db0bb393c..27ac92c3e 100644 --- a/packages/sdk/src/features/variables/components/schema-builder/schema-builder.tsx +++ b/packages/sdk/src/features/variables/components/schema-builder/schema-builder.tsx @@ -7,11 +7,9 @@ import { Icon } from '@workflow-builder/icons'; import styles from './schema-builder.module.css'; -import { filterEmpty } from '@/utils/array'; -import { showSnackbar } from '@/utils/show-snackbar'; - +import { filterEmpty } from '../../../../utils/array'; +import { showSnackbar } from '../../../../utils/show-snackbar'; import { getNodesWithVariable } from '../../actions/get-nodes-with-variable'; -import { VARIABLE_BRACKETS_END, VARIABLE_BRACKETS_START } from '../../constants'; import { openModalSchemaBuilderVariableConfig } from '../../modals/control/modal-schema-builder-variable-config'; import { openModalSchemaBuilderVariableRemoval } from '../../modals/control/modal-schema-builder-variable-remove'; import type { VariablesIndex } from '../../types'; @@ -55,11 +53,11 @@ export function SchemaBuilder({ isDisabled, value, onChange, nodeId }: Props) { return; } - const variableReference = nodeId - ? `${VARIABLE_BRACKETS_START}${getVariableReferenceWithoutBracketsForNode({ nodeId, propertyName: variableId })}${VARIABLE_BRACKETS_END}` + const referenceWithoutBrackets = nodeId + ? getVariableReferenceWithoutBracketsForNode({ nodeId, propertyName: variableId }) : ''; - const nodesWithVariable = variableReference ? getNodesWithVariable(variableReference) : []; + const nodesWithVariable = referenceWithoutBrackets ? getNodesWithVariable(referenceWithoutBrackets) : []; openModalSchemaBuilderVariableConfig({ variant: nodesWithVariable.length > 0 ? 'edit-limited-strict' : 'edit', @@ -67,7 +65,7 @@ export function SchemaBuilder({ isDisabled, value, onChange, nodeId }: Props) { isReadOnly: isDisabled, onSave: (variable) => { const newValue = { ...value }; - // Remove the old variable + // Edited variable may have a different ID (if it is generated from the name) delete newValue[variableId]; return onChange({ @@ -83,18 +81,17 @@ export function SchemaBuilder({ isDisabled, value, onChange, nodeId }: Props) { const handleRemove = useCallback( (variableId: string) => { - const variableReference = nodeId - ? `${VARIABLE_BRACKETS_START}${getVariableReferenceWithoutBracketsForNode({ nodeId, propertyName: variableId })}${VARIABLE_BRACKETS_END}` + const referenceWithoutBrackets = nodeId + ? getVariableReferenceWithoutBracketsForNode({ nodeId, propertyName: variableId }) : ''; - const nodesWithVariable = variableReference ? getNodesWithVariable(variableReference) : []; + const nodesWithVariable = referenceWithoutBrackets ? getNodesWithVariable(referenceWithoutBrackets) : []; openModalSchemaBuilderVariableRemoval({ variable: value[variableId], isReadOnly: isDisabled, onRemove: () => { const newValue = { ...value }; - // Remove the old variable delete newValue[variableId]; onChange({ @@ -114,7 +111,7 @@ export function SchemaBuilder({ isDisabled, value, onChange, nodeId }: Props) { {variables.length === 0 && ( )} {variables.map((variable) => ( diff --git a/packages/sdk/src/features/variables/components/variable-preview/variable-meta.module.css b/packages/sdk/src/features/variables/components/variable-preview/variable-meta.module.css index 2782e4d8c..4d7d1e3f2 100644 --- a/packages/sdk/src/features/variables/components/variable-preview/variable-meta.module.css +++ b/packages/sdk/src/features/variables/components/variable-preview/variable-meta.module.css @@ -1,3 +1,8 @@ +:root { + --wb-variable-bg: var(--wb-color-violet-400-20); + --wb-variable-txt: #6b3bff; +} + .container { display: flex; gap: 0.75rem; @@ -10,10 +15,10 @@ line-height: 1.3125rem; display: inline-flex; font-family: 'Courier New'; - color: #6b3bff; + color: var(--ax-txt-primary-default); + background: var(--wb-variable-bg); border-radius: 4px; padding: 4px 8px 3px 8px; - background: #f4f0ff; display: -webkit-box; -webkit-line-clamp: 1; line-clamp: 1; diff --git a/packages/sdk/src/features/variables/components/variable-select/variable-select.tsx b/packages/sdk/src/features/variables/components/variable-select/variable-select.tsx index 4e1bd5bc0..7022609e0 100644 --- a/packages/sdk/src/features/variables/components/variable-select/variable-select.tsx +++ b/packages/sdk/src/features/variables/components/variable-select/variable-select.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import styles from './variable-select.module.css'; import { focusNextElement } from '../../../../utils/a11y'; +import { VARIABLE_BRACKETS_START } from '../../constants'; import { getIsStringVariableReference } from '../../utils/keys/get-is-string-variable-reference'; import { VariableText } from '../variable-text/variable-text'; import type { VariableTextProps } from '../variable-text/variable-text.types'; @@ -18,7 +19,7 @@ export function VariableSelect({ onChange, onBlur, endAdornment, ...props }: Pro const handleOnChange: VariableTextProps['onChange'] = useCallback( (value) => { - const newValue = value ? `{{` + value.split('{{').at(-1) : ''; + const newValue = value ? VARIABLE_BRACKETS_START + value.split(VARIABLE_BRACKETS_START).at(-1) : ''; const valueToPass = getIsStringVariableReference(newValue) ? newValue : ''; onChange(valueToPass); @@ -33,7 +34,7 @@ export function VariableSelect({ onChange, onBlur, endAdornment, ...props }: Pro return; } - const newValue = value ? `{{` + value.split('{{').at(-1) : ''; + const newValue = value ? VARIABLE_BRACKETS_START + value.split(VARIABLE_BRACKETS_START).at(-1) : ''; const valueToPass = getIsStringVariableReference(newValue) ? newValue : ''; onBlur(valueToPass); diff --git a/packages/sdk/src/features/variables/components/variable-text/variable-text.module.css b/packages/sdk/src/features/variables/components/variable-text/variable-text.module.css index 58d84c3cb..b2a74d4d4 100644 --- a/packages/sdk/src/features/variables/components/variable-text/variable-text.module.css +++ b/packages/sdk/src/features/variables/components/variable-text/variable-text.module.css @@ -156,6 +156,8 @@ body:has(.suggestionsContainer) { .suggestionsTitle { composes: ax-public-h10 from global; + + color: var(--ax-txt-primary-default); } .suggestionsClose { diff --git a/packages/sdk/src/features/variables/constants.ts b/packages/sdk/src/features/variables/constants.ts index 7381c0929..6d9f8873c 100644 --- a/packages/sdk/src/features/variables/constants.ts +++ b/packages/sdk/src/features/variables/constants.ts @@ -8,6 +8,13 @@ export const LOGICAL_OPERATOR = { } as const; export type LogicalOperator = (typeof LOGICAL_OPERATOR)[keyof typeof LOGICAL_OPERATOR]; +/** + * String literal union of comparison operators recognised by the + * dynamic-conditions / decision-branches controls (`'isEqual'`, + * `'isGreaterThan'`, `'isContaining'`, …). + * + * @category Forms + */ // eslint-disable-next-line @typescript-eslint/no-unused-vars const comparisonsOperators = [ 'isEqual', @@ -50,7 +57,24 @@ export const comparisonOperatorsByPrimitiveType: Record = }, }; -// Remove filter when other formats will be supported +// TODO: Remove filter when other formats will be supported export const variableTypesOptions: VariableTypeOption[] = Object.values(variableTypeInfoByType).filter( ({ type, baseType }) => type === baseType, ); diff --git a/packages/sdk/src/features/variables/modals/global/modal-settings.module.css b/packages/sdk/src/features/variables/modals/global/modal-settings.module.css index c4b0ecb42..7e9208e82 100644 --- a/packages/sdk/src/features/variables/modals/global/modal-settings.module.css +++ b/packages/sdk/src/features/variables/modals/global/modal-settings.module.css @@ -10,6 +10,7 @@ display: flex; flex-flow: column; gap: 2rem; + color: var(--ax-txt-secondary-default); @media (width > 800px) { flex-flow: row; diff --git a/packages/sdk/src/features/variables/modals/global/settings/settings-navigation.module.css b/packages/sdk/src/features/variables/modals/global/settings/settings-navigation.module.css index 8c1b05922..6b3bdea6f 100644 --- a/packages/sdk/src/features/variables/modals/global/settings/settings-navigation.module.css +++ b/packages/sdk/src/features/variables/modals/global/settings/settings-navigation.module.css @@ -1,3 +1,7 @@ +:root { + --wb-settings-navigation-background: var(--ax-public-modal-header-background); +} + .container { display: flex; flex-flow: column; @@ -6,7 +10,7 @@ text-align: left; gap: var(--wb-token-spacing-operator-wrap-gap, 4px); border-radius: var(--wb-token-radius-dropdown-wrap-s, 8px); - background: var(--wb-dropdown-bg-primary-default, #f9faf9); + background: var(--wb-settings-navigation-background); } .button { @@ -32,7 +36,7 @@ &:hover, &.button--active { - background: var(--wb-nav-button-bg-primary-hover, rgba(26, 26, 25, 0.02)); + background: var(--ax-public-button-nav-background-color); } &.button--active { diff --git a/packages/sdk/src/features/variables/modals/shared/components/pane-remove-variable/pane-remove-variable.module.css b/packages/sdk/src/features/variables/modals/shared/components/pane-remove-variable/pane-remove-variable.module.css index a86bf6119..0b30d3eb0 100644 --- a/packages/sdk/src/features/variables/modals/shared/components/pane-remove-variable/pane-remove-variable.module.css +++ b/packages/sdk/src/features/variables/modals/shared/components/pane-remove-variable/pane-remove-variable.module.css @@ -1,3 +1,7 @@ +:root { + --wb-settings-remove-bg: var(--ax-public-modal-header-background); +} + .description { margin-top: var(--wb-token-spacing-spacing-16); margin-bottom: var(--wb-token-spacing-spacing-20, 20px); @@ -8,14 +12,13 @@ flex-flow: column; gap: 8px; border-radius: 8px; - border: 1px solid #e5e5e5; - background: #f9f9f9; + border: 1px solid var(--wb-settings-remove-bg); + background: var(--wb-settings-remove-bg); color: var(--wb-txt-secondary-default); margin: 0; padding: var(--wb-token-spacing-spacing-12, 12px) var(--wb-token-spacing-spacing-16, 16px) var(--wb-token-spacing-spacing-12, 12px) 16px; - - li { + ą li { margin: 0; padding: 0; margin-left: var(--wb-token-spacing-spacing-16, 16px); diff --git a/packages/sdk/src/features/variables/stores/core/get-node-variables-suggestions.ts b/packages/sdk/src/features/variables/stores/core/get-node-variables-suggestions.ts index 46edf36fb..32732a8f0 100644 --- a/packages/sdk/src/features/variables/stores/core/get-node-variables-suggestions.ts +++ b/packages/sdk/src/features/variables/stores/core/get-node-variables-suggestions.ts @@ -5,7 +5,9 @@ import { type VariablesSuggestionsStore, useVariablesSuggestionsStore } from '.. type UndefinedNotIndexed = undefined; -// General getter for all variables from a node, divided by source handles +/** + * General getter for all variables from a node, grouped by source handles. + */ export const getVariableBySourceHandlesForNode = (params: { nodeId: string; cachedStore?: VariablesSuggestionsStore; @@ -36,11 +38,14 @@ export const getVariableBySourceHandlesForNode = (params: { return undefined; }; -// Getter for variables of picked node available from picked sourceHandle +/** + * Getter for variables of the selected node available from the selected source handle. + * + * Pass a store if you want to call this function multiple times. + */ export const getNodeVariablesSuggestions = (params: { nodeId: string; sourceHandle: string | undefined; - // Pass one store if you want to call it multiple times cachedStore?: VariablesSuggestionsStore; }): VariableSuggestion[] | UndefinedNotIndexed => { const bySourceHandle = getVariableBySourceHandlesForNode(params); diff --git a/packages/sdk/src/features/variables/utils/core/filter-suggestions-by-types.ts b/packages/sdk/src/features/variables/utils/core/filter-suggestions-by-types.ts index f9319102a..b646e6da9 100644 --- a/packages/sdk/src/features/variables/utils/core/filter-suggestions-by-types.ts +++ b/packages/sdk/src/features/variables/utils/core/filter-suggestions-by-types.ts @@ -5,7 +5,7 @@ import type { VariableSuggestion } from '../../components/variable-text/variable type Params = { suggestions: VariableSuggestion[]; excludeTypes: VariableType[]; - // If empty accept all + // It accepts all if the array is empty or undefined. includeTypes: VariableType[] | undefined; }; diff --git a/packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.ts b/packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.ts index bc2f95275..8bfa4b581 100644 --- a/packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.ts +++ b/packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.ts @@ -13,10 +13,14 @@ export function getIsStringVariableReference(value: MaybeVariableReference): boo return false; } + const hasInvalidCharacters = valueTrimmed.includes(' '); + if (hasInvalidCharacters) { + return true; + } + const isOnlyOneVariable = `${VARIABLE_BRACKETS_START}${valueTrimmed.replaceAll(VARIABLE_BRACKETS_START, '').replaceAll(VARIABLE_BRACKETS_END, '')}${VARIABLE_BRACKETS_END}` === valueTrimmed; - if (isOnlyOneVariable) { return true; } diff --git a/packages/sdk/src/hooks/use-palette-drop.ts b/packages/sdk/src/hooks/use-palette-drop.ts index 27d1ffd34..2548284fb 100644 --- a/packages/sdk/src/hooks/use-palette-drop.ts +++ b/packages/sdk/src/hooks/use-palette-drop.ts @@ -48,7 +48,7 @@ export function usePaletteDrop() { const reactFlowNodeType = resolveReactFlowNodeType(type, templateType, getCustomNodeTemplates()); const newNodeId = crypto.randomUUID(); - trackFutureChange('addNode', { nodeType: type }); + trackFutureChange('addNode', { id: newNodeId, nodeType: type }); resetSelectedElements(); onNodesChange(getNodeAddChange(reactFlowNodeType, position, data, newNodeId)); }, From 32260343585d14950f5fbd96ba01e90f2dc5b802 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Fri, 14 Aug 2026 13:44:04 +0200 Subject: [PATCH 04/33] feat(sdk): better variable logic by sourceHandle --- apps/demo/src/app/app.tsx | 17 +++++- apps/demo/src/app/data/nodes/action/action.ts | 14 +++-- apps/demo/src/app/data/nodes/action/schema.ts | 4 +- .../src/app/data/nodes/action/uischema.ts | 8 +-- .../src/app/data/nodes/ai-agent/ai-agent.ts | 10 +-- .../app/data/nodes/conditional/conditional.ts | 14 +++-- .../src/app/data/nodes/decision/decision.ts | 8 ++- apps/demo/src/app/data/nodes/delay/delay.ts | 8 ++- .../data/nodes/notification/notification.ts | 14 +++-- .../src/app/data/nodes/notification/schema.ts | 4 +- .../src/app/data/nodes/trigger/trigger.ts | 12 ++-- packages/sdk/src/features/diagram/diagram.tsx | 3 + packages/sdk/src/features/i18n/locales/en.ts | 3 + packages/sdk/src/features/i18n/locales/pl.ts | 3 + .../variable-text/variable-text.tsx | 6 +- .../sdk/src/features/variables/constants.ts | 23 +++++++ .../variables/hooks/use-refresh-variables.ts | 33 ++++++---- .../core/get-node-variables-suggestions.ts | 52 ++++++++-------- .../get-suggestions-node-data.ts | 61 +++++++++++-------- .../stores/core/refresh-suggestions.ts | 3 +- .../set-suggestions-node-data.ts | 3 +- .../src/features/variables/stores/types.ts | 5 +- .../core/filter-suggestions-duplicates.ts | 13 ++++ .../get-available-variables-by-node-id.ts | 23 ++++--- packages/sdk/src/node/node-output-schema.ts | 18 +++--- 25 files changed, 235 insertions(+), 127 deletions(-) create mode 100644 packages/sdk/src/features/variables/utils/core/filter-suggestions-duplicates.ts diff --git a/apps/demo/src/app/app.tsx b/apps/demo/src/app/app.tsx index 79d479f09..6f7358769 100644 --- a/apps/demo/src/app/app.tsx +++ b/apps/demo/src/app/app.tsx @@ -1,3 +1,4 @@ +import { SnackbarType } from '@synergycodes/overflow-ui'; import { WorkflowBuilder } from '@workflowbuilder/sdk'; import type { WorkflowBuilderEdgeTemplates, @@ -8,6 +9,7 @@ import type { import '@workflowbuilder/sdk/style.css'; +import { showSnackbar } from '../../../../packages/sdk/src/utils/show-snackbar'; import { DashedEdge } from './components/dashed-edge/dashed-edge'; import { MultiPortNodeTemplate } from './components/multi-port-node/multi-port-node-template'; import { demoPaletteItems } from './data/palette'; @@ -35,8 +37,19 @@ const edgeTemplates = { dashed: DashedEdge, } satisfies WorkflowBuilderEdgeTemplates; -// A trigger is a workflow entry point, so it can never be a connection target. -const isValidConnection: WorkflowBuilderIsValidConnection = ({ targetNode }) => targetNode.data.type !== 'trigger'; +const isValidConnection: WorkflowBuilderIsValidConnection = ({ targetNode }) => { + // A trigger is a workflow entry point, so it can never be a connection target. + if (targetNode.data.type === 'trigger') { + showSnackbar({ + title: 'notValidConnection', + variant: SnackbarType.WARNING, + }); + + return false; + } + + return true; +}; // Advanced escape hatch: forward extra ReactFlow props (SDK-owned props can't be set here). const reactFlowProps = { diff --git a/apps/demo/src/app/data/nodes/action/action.ts b/apps/demo/src/app/data/nodes/action/action.ts index 129327a67..eaef507d2 100644 --- a/apps/demo/src/app/data/nodes/action/action.ts +++ b/apps/demo/src/app/data/nodes/action/action.ts @@ -14,11 +14,15 @@ export const action: PaletteItem = { uischema, outputSchema: { type: 'default', - properties: { - status: { type: 'string', label: 'Status', description: 'Execution status: success, failure, or skipped' }, - // TODO: Match JSON schema - // result: { type: 'object', label: 'Result', description: 'The data returned by the action' }, - errorMessage: { type: 'string', label: 'Error Message', description: 'Error details if the action failed' }, + bySourceHandle: { + success: { + status: { type: 'string', label: 'Status', description: 'Execution status: success, failure, or skipped' }, + // TODO: Match JSON schema + // result: { type: 'object', label: 'Result', description: 'The data returned by the action' }, + }, + error: { + errorMessage: { type: 'string', label: 'Error Message', description: 'Error details if the action failed' }, + }, }, }, }; diff --git a/apps/demo/src/app/data/nodes/action/schema.ts b/apps/demo/src/app/data/nodes/action/schema.ts index 413b53fcd..4836d2593 100644 --- a/apps/demo/src/app/data/nodes/action/schema.ts +++ b/apps/demo/src/app/data/nodes/action/schema.ts @@ -91,8 +91,8 @@ export const schema = { sendEmail: { type: 'object', properties: { - address: { type: 'string' }, - copy: { type: 'string' }, + address: { type: 'string', format: 'email' }, + copy: { type: 'string', format: 'email' }, subject: { type: 'string' }, body: { type: 'string' }, priority: { type: 'string', options: Object.values(priorityOptions) }, diff --git a/apps/demo/src/app/data/nodes/action/uischema.ts b/apps/demo/src/app/data/nodes/action/uischema.ts index 8cf308dc7..1a6fe2e3d 100644 --- a/apps/demo/src/app/data/nodes/action/uischema.ts +++ b/apps/demo/src/app/data/nodes/action/uischema.ts @@ -45,16 +45,16 @@ const sendEmailProperties: ActionNodeUISchema = { placeholder: 'manager@example.com', }, { - type: 'Text', + type: 'VariableText', scope: scope('properties.sendEmail.properties.subject'), label: 'Subject', - placeholder: 'Type your subject here...', + placeholder: 'Type your subject here... Use {{ to insert variables', }, { - type: 'TextArea', + type: 'VariableTextArea', scope: scope('properties.sendEmail.properties.body'), label: 'Email Body', - placeholder: 'Type your message here...', + placeholder: 'Type your message here... Use {{ to insert variables', minRows: 5, }, { diff --git a/apps/demo/src/app/data/nodes/ai-agent/ai-agent.ts b/apps/demo/src/app/data/nodes/ai-agent/ai-agent.ts index b8823667a..3897ab36d 100644 --- a/apps/demo/src/app/data/nodes/ai-agent/ai-agent.ts +++ b/apps/demo/src/app/data/nodes/ai-agent/ai-agent.ts @@ -16,10 +16,12 @@ export const aiAgent: PaletteItem = { uischema, outputSchema: { type: 'default', - properties: { - response: { type: 'string', label: 'Response', description: 'The text generated by the AI model' }, - tokensUsed: { type: 'number', label: 'Tokens Used', description: 'Total number of tokens consumed' }, - model: { type: 'string', label: 'Model', description: 'The AI model that was used' }, + bySourceHandle: { + success: { + response: { type: 'string', label: 'Response', description: 'The text generated by the AI model' }, + tokensUsed: { type: 'number', label: 'Tokens Used', description: 'Total number of tokens consumed' }, + model: { type: 'string', label: 'Model', description: 'The AI model that was used' }, + }, }, }, }; diff --git a/apps/demo/src/app/data/nodes/conditional/conditional.ts b/apps/demo/src/app/data/nodes/conditional/conditional.ts index 8e880aae9..9146848bd 100644 --- a/apps/demo/src/app/data/nodes/conditional/conditional.ts +++ b/apps/demo/src/app/data/nodes/conditional/conditional.ts @@ -14,12 +14,14 @@ export const conditional: PaletteItem = { uischema, outputSchema: { type: 'default', - properties: { - result: { type: 'boolean', label: 'Result', description: 'Whether the condition evaluated to true or false' }, - matchedCondition: { - type: 'string', - label: 'Matched Condition', - description: 'The condition expression that matched', + bySourceHandle: { + success: { + result: { type: 'boolean', label: 'Result', description: 'Whether the condition evaluated to true or false' }, + matchedCondition: { + type: 'string', + label: 'Matched Condition', + description: 'The condition expression that matched', + }, }, }, }, diff --git a/apps/demo/src/app/data/nodes/decision/decision.ts b/apps/demo/src/app/data/nodes/decision/decision.ts index 1214a62e9..0b4f7ed1c 100644 --- a/apps/demo/src/app/data/nodes/decision/decision.ts +++ b/apps/demo/src/app/data/nodes/decision/decision.ts @@ -16,9 +16,11 @@ export const decision: PaletteItem = { uischema, outputSchema: { type: 'default', - properties: { - selectedBranch: { type: 'string', label: 'Selected Branch', description: 'Label of the branch that was taken' }, - branchIndex: { type: 'number', label: 'Branch Index', description: 'Zero-based index of the selected branch' }, + bySourceHandle: { + every: { + selectedBranch: { type: 'string', label: 'Selected Branch', description: 'Label of the branch that was taken' }, + branchIndex: { type: 'number', label: 'Branch Index', description: 'Zero-based index of the selected branch' }, + }, }, }, }; diff --git a/apps/demo/src/app/data/nodes/delay/delay.ts b/apps/demo/src/app/data/nodes/delay/delay.ts index ede30284d..d9582debe 100644 --- a/apps/demo/src/app/data/nodes/delay/delay.ts +++ b/apps/demo/src/app/data/nodes/delay/delay.ts @@ -14,9 +14,11 @@ export const delay: PaletteItem = { uischema, outputSchema: { type: 'default', - properties: { - resumedAt: { type: 'string', label: 'Resumed At', description: 'ISO 8601 date-time when the delay ended' }, - delayDuration: { type: 'number', label: 'Delay Duration', description: 'Actual wait time in milliseconds' }, + bySourceHandle: { + success: { + resumedAt: { type: 'string', label: 'Resumed At', description: 'ISO 8601 date-time when the delay ended' }, + delayDuration: { type: 'number', label: 'Delay Duration', description: 'Actual wait time in milliseconds' }, + }, }, }, }; diff --git a/apps/demo/src/app/data/nodes/notification/notification.ts b/apps/demo/src/app/data/nodes/notification/notification.ts index 3fce5204a..6a9270c18 100644 --- a/apps/demo/src/app/data/nodes/notification/notification.ts +++ b/apps/demo/src/app/data/nodes/notification/notification.ts @@ -14,10 +14,16 @@ export const notification: PaletteItem = { uischema, outputSchema: { type: 'default', - properties: { - sent: { type: 'boolean', label: 'Sent', description: 'Whether the notification was sent successfully' }, - sentAt: { type: 'string', label: 'Sent At', description: 'ISO 8601 date-time when the notification was sent' }, - recipient: { type: 'string', label: 'Recipient', description: 'The email address the notification was sent to' }, + bySourceHandle: { + success: { + sent: { type: 'boolean', label: 'Sent', description: 'Whether the notification was sent successfully' }, + sentAt: { type: 'string', label: 'Sent At', description: 'ISO 8601 date-time when the notification was sent' }, + recipient: { + type: 'string', + label: 'Recipient', + description: 'The email address the notification was sent to', + }, + }, }, }, }; diff --git a/apps/demo/src/app/data/nodes/notification/schema.ts b/apps/demo/src/app/data/nodes/notification/schema.ts index 70826f2cb..522fb920b 100644 --- a/apps/demo/src/app/data/nodes/notification/schema.ts +++ b/apps/demo/src/app/data/nodes/notification/schema.ts @@ -38,8 +38,8 @@ export const schema = { sendEmail: { type: 'object', properties: { - address: { type: 'string' }, - copy: { type: 'string' }, + address: { type: 'string', format: 'email' }, + copy: { type: 'string', format: 'email' }, subject: { type: 'string' }, body: { type: 'string' }, priority: { type: 'string', options: Object.values(priorityOptions) }, diff --git a/apps/demo/src/app/data/nodes/trigger/trigger.ts b/apps/demo/src/app/data/nodes/trigger/trigger.ts index 8b5c26eb6..fb5d2bbd7 100644 --- a/apps/demo/src/app/data/nodes/trigger/trigger.ts +++ b/apps/demo/src/app/data/nodes/trigger/trigger.ts @@ -14,11 +14,13 @@ export const triggerNode: PaletteItem = { uischema, outputSchema: { type: 'default', - properties: { - eventType: { type: 'string', label: 'Event Type', description: 'The type of event that started the workflow' }, - timestamp: { type: 'string', label: 'Timestamp', description: 'ISO 8601 date-time when the trigger fired' }, - // TODO: Match JSON schema - // payload: { type: 'object', label: 'Payload', description: 'The raw event data received by the trigger' }, + bySourceHandle: { + source: { + eventType: { type: 'string', label: 'Event Type', description: 'The type of event that started the workflow' }, + timestamp: { type: 'string', label: 'Timestamp', description: 'ISO 8601 date-time when the trigger fired' }, + // TODO: Match JSON schema + // payload: { type: 'object', label: 'Payload', description: 'The raw event data received by the trigger' }, + }, }, }, }; diff --git a/packages/sdk/src/features/diagram/diagram.tsx b/packages/sdk/src/features/diagram/diagram.tsx index 8e38e557c..804b2e844 100644 --- a/packages/sdk/src/features/diagram/diagram.tsx +++ b/packages/sdk/src/features/diagram/diagram.tsx @@ -25,6 +25,7 @@ import type { WorkflowBuilderReactFlowProps } from '../../workflow-builder-root/ import { trackFutureChange } from '../changes-tracker/stores/use-changes-tracker-store'; import { useDeleteConfirmation } from '../modals/delete-confirmation/use-delete-confirmation'; import { withOptionalComponentPlugins } from '../plugins-core/adapters/adapter-components'; +import useRefreshVariables from '../variables/hooks/use-refresh-variables'; import { deleteKeyCode } from './const'; import { SNAP_GRID, SNAP_IS_ACTIVE } from './diagram.const'; import { TemporaryEdge } from './edges/temporary-edge/temporary-edge'; @@ -127,6 +128,8 @@ function DiagramContainerComponent({ edgeTypes = {} }: DiagramContainerProps) { [onDropFromPalette], ); + useRefreshVariables(); + const { onConnect, onConnectStart, onConnectEnd } = useConnect(); const onNodeDragStop = useCallback(() => { diff --git a/packages/sdk/src/features/i18n/locales/en.ts b/packages/sdk/src/features/i18n/locales/en.ts index 44c672d0c..88eb35c13 100644 --- a/packages/sdk/src/features/i18n/locales/en.ts +++ b/packages/sdk/src/features/i18n/locales/en.ts @@ -139,6 +139,8 @@ export const en = { removeVariableWarning: 'Deleting this variable will permanently remove its configuration.', removeVariableIsBlocked: 'The variable is used in the following nodes and cannot be deleted.', addVariableToContinue: 'Add a variable to continue', + missingMentionNodePrefix: 'Missing node', + missingMentionNodeVariablePrefix: 'Missing variable', }, loader: { text: 'Loading...', @@ -185,6 +187,7 @@ export const en = { variableNameAlreadyExists: 'A variable with this name already exists.', variableWasNotFound: 'This variable was not found.', cantEditReadOnlyMode: 'Editing is blocked in read-only mode.', + notValidConnection: 'That connection is blocked.', }, workflowsSettings: { modalTitle: 'Settings', diff --git a/packages/sdk/src/features/i18n/locales/pl.ts b/packages/sdk/src/features/i18n/locales/pl.ts index d556a6203..4a3448e5a 100644 --- a/packages/sdk/src/features/i18n/locales/pl.ts +++ b/packages/sdk/src/features/i18n/locales/pl.ts @@ -103,6 +103,8 @@ export const pl = { removeVariableWarning: 'Usunięcie tej zmiennej trwale usunie jej konfigurację.', removeVariableIsBlocked: 'Ta zmienna jest używana w następujących węzłach i nie może zostać usunięta.', addVariableToContinue: 'Dodaj zmienną aby kontynuować', + missingMentionNodePrefix: 'Brak węzła', + missingMentionNodeVariablePrefix: 'Brak zmiennej', }, loader: { text: 'Ładowanie...', @@ -149,6 +151,7 @@ export const pl = { variableNameAlreadyExists: 'Zmienna o tej nazwie już istnieje.', variableWasNotFound: 'Nie znaleziono tej zmiennej.', cantEditReadOnlyMode: 'Edycja jest zablokowana w trybie tylko do odczytu.', + notValidConnection: 'To połączenie jest zablokowane.', }, aiTools: { title: 'Narzędzia agenta AI', diff --git a/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx b/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx index bcb27d029..91c0eaff1 100644 --- a/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx +++ b/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx @@ -201,10 +201,12 @@ export function VariableText({ const node = getNodeByIdAction(nodeId); if (node) { - return `{{ ${node.data?.properties?.label} · ${t('plugins.validation.missingMentionNodeVariablePrefix')} · ${typedId.split('.').at(-1)} }}`; + const nodeLabel = node.data?.properties?.label; + + return `{{ ${nodeLabel ? `${nodeLabel} · ` : ''}${t('variables.missingMentionNodeVariablePrefix')} · ${typedId.split('.').at(-1)} }}`; } - return `{{ ${t('plugins.validation.missingMentionNodePrefix')} (${nodeId.slice(0, 4)}...) · ${typedId.split('.').at(-1)} }}`; + return `{{ ${t('variables.missingMentionNodePrefix')} (${nodeId.slice(0, 4)}...) · ${typedId.split('.').at(-1)} }}`; } return defaultLabel; diff --git a/packages/sdk/src/features/variables/constants.ts b/packages/sdk/src/features/variables/constants.ts index 6d9f8873c..7dc3e9930 100644 --- a/packages/sdk/src/features/variables/constants.ts +++ b/packages/sdk/src/features/variables/constants.ts @@ -129,3 +129,26 @@ export const variableTypesOptions: VariableTypeOption[] = Object.values(variable export const variablesTypesToExcludeNonPrimitive: VariableType[] = ['object', 'array']; export const variablesTypesToExcludeInText: VariableType[] = [...variablesTypesToExcludeNonPrimitive, 'boolean']; + +/** + * Special keywords used to determine source-handle behaviour. + * + * Source handles may have arbitrary names, but handles containing one of these + * keywords are treated specially when processing `bySourceHandle`. + * + * - `EVERY` (`every`): Values assigned to this handle are additionally attached + * to every branch. `every` values are always forwarded. + * - `SUCCESS` (`success`): A branch is considered successful when its source + * handle does not contain the `ERROR` keyword. Successful branches receive + * the values assigned to this handle in addition to their own values. + * - `ERROR` (`error`): Values assigned to this handle are additionally attached + * to every branch whose source handle contains the `ERROR` keyword. + * + * The keywords are matched against the source-handle name, so source handles + * can have custom names while still triggering the corresponding behaviour. + */ +export const SPECIAL_SOURCE_HANDLE_KEYWORDS = { + EVERY: 'every', + SUCCESS: 'success', + ERROR: 'error', +} as const; diff --git a/packages/sdk/src/features/variables/hooks/use-refresh-variables.ts b/packages/sdk/src/features/variables/hooks/use-refresh-variables.ts index 2312c0b66..dce861509 100644 --- a/packages/sdk/src/features/variables/hooks/use-refresh-variables.ts +++ b/packages/sdk/src/features/variables/hooks/use-refresh-variables.ts @@ -1,7 +1,6 @@ -import { useEffect, useRef } from 'react'; - -import { useChangesTrackerStore } from '@/features/changes-tracker/stores/use-changes-tracker-store'; +import { useCallback, useEffect, useRef } from 'react'; +import { useChangesTrackerStore } from '../../changes-tracker/stores/use-changes-tracker-store'; import { refreshAllSuggestions, refreshNodesIdsSuggestions } from '../stores/core/refresh-suggestions'; type Refresh = { @@ -21,6 +20,20 @@ function useRefreshVariables() { const lastChangeName = useChangesTrackerStore((store) => store.lastChangeName); const lastChangeParams = useChangesTrackerStore((store) => store.lastChangeParams); + const refreshAll = useCallback(() => { + timeoutRef.current = setTimeout(() => { + refreshAllSuggestions(); + refreshRef.current = { + type: 'partial', + nodesIds: new Set(), + }; + }, REFRESH_ALL_VARIABLES_DELAY_MS); + }, []); + + useEffect(() => { + refreshAll(); + }, [refreshAll]); + useEffect(() => { const wasNodeUpdated = ['dataUpdateNode', 'addNode'].includes(lastChangeName); const wasDiagramReloaded = ['undo', 'redo', 'import'].includes(lastChangeName); @@ -38,6 +51,10 @@ function useRefreshVariables() { const nodeId = (lastChangeParams as unknown as { id?: string })?.id || ''; if (nodeId) { refreshRef.current.nodesIds.add(nodeId); + } else { + console.warn('Expected nodeId from the event to refresh variables, but it was not received.'); + // Force global refresh + refreshRef.current.type = 'global'; } } @@ -46,13 +63,7 @@ function useRefreshVariables() { } if (refreshRef.current.type === 'global') { - timeoutRef.current = setTimeout(() => { - refreshAllSuggestions(); - refreshRef.current = { - type: 'partial', - nodesIds: new Set(), - }; - }, REFRESH_ALL_VARIABLES_DELAY_MS); + timeoutRef.current = setTimeout(refreshAll, REFRESH_ALL_VARIABLES_DELAY_MS); return; } @@ -65,7 +76,7 @@ function useRefreshVariables() { nodesIds: new Set(), }; }, REFRESH_PART_VARIABLES_DELAY_MS); - }, [lastChangeName, lastChangeParams]); + }, [lastChangeName, lastChangeParams, refreshAll]); } export default useRefreshVariables; diff --git a/packages/sdk/src/features/variables/stores/core/get-node-variables-suggestions.ts b/packages/sdk/src/features/variables/stores/core/get-node-variables-suggestions.ts index 32732a8f0..85e2a71e8 100644 --- a/packages/sdk/src/features/variables/stores/core/get-node-variables-suggestions.ts +++ b/packages/sdk/src/features/variables/stores/core/get-node-variables-suggestions.ts @@ -1,13 +1,11 @@ import type { VariableSuggestion } from '../../components/variable-text/variable-text.types'; -import { NODE_ID_FOR_COMMON_NODE_DATA } from '../../constants'; +import { NODE_ID_FOR_COMMON_NODE_DATA, SPECIAL_SOURCE_HANDLE_KEYWORDS } from '../../constants'; import { SUGGESTION_NODE_TYPE, type SuggestionsBySourceHandle } from '../types'; import { type VariablesSuggestionsStore, useVariablesSuggestionsStore } from '../use-variable-suggestions-store'; type UndefinedNotIndexed = undefined; -/** - * General getter for all variables from a node, grouped by source handles. - */ +// General getter for all variables from a node, divided by source handles export const getVariableBySourceHandlesForNode = (params: { nodeId: string; cachedStore?: VariablesSuggestionsStore; @@ -27,7 +25,18 @@ export const getVariableBySourceHandlesForNode = (params: { if (nodeData.type === SUGGESTION_NODE_TYPE.CUSTOM) { bySourceHandle = nodeData.bySourceHandle; } else if (nodeData.type === SUGGESTION_NODE_TYPE.COMMON) { - bySourceHandle = store.commonByType[nodeData.nodeType]; + // Some nodes (with the same type) have suggestions stored in shared place and need adjustment + bySourceHandle = Object.entries(store.commonByType[nodeData.nodeType] || {}).reduce( + (stack: SuggestionsBySourceHandle, [sourceHandle, suggestions = []]) => { + stack[sourceHandle] = suggestions.map((suggestion) => ({ + ...suggestion, + id: suggestion.id.replace(NODE_ID_FOR_COMMON_NODE_DATA, params.nodeId), + })); + + return stack; + }, + {}, + ); } if (bySourceHandle) { @@ -38,14 +47,11 @@ export const getVariableBySourceHandlesForNode = (params: { return undefined; }; -/** - * Getter for variables of the selected node available from the selected source handle. - * - * Pass a store if you want to call this function multiple times. - */ +// Getter for variables of picked node available from picked sourceHandle export const getNodeVariablesSuggestions = (params: { nodeId: string; sourceHandle: string | undefined; + // Pass one store if you want to call it multiple times cachedStore?: VariablesSuggestionsStore; }): VariableSuggestion[] | UndefinedNotIndexed => { const bySourceHandle = getVariableBySourceHandlesForNode(params); @@ -57,24 +63,20 @@ export const getNodeVariablesSuggestions = (params: { let suggestions: VariableSuggestion[] | UndefinedNotIndexed = undefined; - if (params.sourceHandle && Array.isArray(bySourceHandle[params.sourceHandle])) { - suggestions = bySourceHandle[params.sourceHandle]; - } + const sourceHandle = params.sourceHandle || ''; - if (Array.isArray(bySourceHandle.default)) { - suggestions = [...(suggestions || []), ...bySourceHandle.default]; + if (Array.isArray(bySourceHandle[sourceHandle])) { + suggestions = bySourceHandle[sourceHandle]; } - // Some nodes (with the same type) have suggestions stored in shared place and need adjustment - const hasPlaceholderIdToSwap = suggestions?.some((suggestion) => - suggestion.id.includes(NODE_ID_FOR_COMMON_NODE_DATA), - ); - if (Array.isArray(suggestions) && hasPlaceholderIdToSwap) { - return suggestions.map((suggestion) => ({ - ...suggestion, - id: suggestion.id.replace(NODE_ID_FOR_COMMON_NODE_DATA, params.nodeId), - })); - } + const isErrorBranch = sourceHandle.includes(SPECIAL_SOURCE_HANDLE_KEYWORDS.ERROR); + + suggestions = [ + ...(suggestions || []), + ...(bySourceHandle[SPECIAL_SOURCE_HANDLE_KEYWORDS.EVERY] || []), + ...(bySourceHandle[isErrorBranch ? SPECIAL_SOURCE_HANDLE_KEYWORDS.ERROR : SPECIAL_SOURCE_HANDLE_KEYWORDS.SUCCESS] || + []), + ]; return suggestions; }; diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts index 09b624500..60cd3b14e 100644 --- a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts @@ -22,7 +22,7 @@ type Response = { const EMPTY_NODE_SUGGESTIONS: Response = { type: SUGGESTION_NODE_TYPE.COMMON, bySourceHandle: { - default: [], + every: [], }, }; @@ -33,21 +33,32 @@ export function getSuggestionsNodeData({ definition, node }: Params): Response { return EMPTY_NODE_SUGGESTIONS; } + const bySourceHandle: SuggestionsBySourceHandle = { + every: [], + }; + // Node that always returns the same variables if (definition.outputSchema.type === OUTPUT_SCHEMA_TYPE.DEFAULT) { + for (const [sourceHandle, properties] of Object.entries(definition.outputSchema.bySourceHandle)) { + if (properties) { + bySourceHandle[sourceHandle] = [ + ...(bySourceHandle[sourceHandle] || []), + ...getSuggestionsFromOutputProperties({ + nodeId: node.id, + nodeLabel, + properties: properties, + }), + ]; + } + } + return { type: SUGGESTION_NODE_TYPE.COMMON, - bySourceHandle: { - default: getSuggestionsFromOutputProperties({ - nodeId: node.id, - nodeLabel, - properties: definition.outputSchema.properties, - }), - }, + bySourceHandle, }; } - // From variants (they have rules based on data inside the node and edges) + // From variants (they have rules based on data inside the node) if (definition.outputSchema.type === OUTPUT_SCHEMA_TYPE.VARIANT) { const variantsMatchingDataPropertyValue = Object.values(definition.outputSchema.variants) .filter((variant) => { @@ -55,30 +66,26 @@ export function getSuggestionsNodeData({ definition, node }: Params): Response { return true; } - // We assume true if rule isn't set - the rule can be set for sourceHandle only const isValidPropertyValue = - 'dataPropertyName' in variant.variantRule && variant.variantRule.dataPropertyName - ? node.data.properties[variant.variantRule.dataPropertyName] === variant.variantRule.dataPropertyValue - : true; + node.data.properties[variant.variantRule.dataPropertyName] === variant.variantRule.dataPropertyValue; return isValidPropertyValue; }) .filter(filterEmpty); - const bySourceHandle: SuggestionsBySourceHandle = { - default: [], - }; - for (const variant of variantsMatchingDataPropertyValue) { - const sourceHandle = variant.variantRule?.sourceHandleId || 'default'; - bySourceHandle[sourceHandle] = [ - ...(bySourceHandle[sourceHandle] || []), - ...getSuggestionsFromOutputProperties({ - nodeId: node.id, - nodeLabel, - properties: variant.properties, - }), - ]; + for (const [sourceHandle, properties] of Object.entries(variant.bySourceHandle)) { + if (properties) { + bySourceHandle[sourceHandle] = [ + ...(bySourceHandle[sourceHandle] || []), + ...getSuggestionsFromOutputProperties({ + nodeId: node.id, + nodeLabel, + properties: properties, + }), + ]; + } + } } return { @@ -114,7 +121,7 @@ export function getSuggestionsNodeData({ definition, node }: Params): Response { return { type: SUGGESTION_NODE_TYPE.CUSTOM, bySourceHandle: { - default: suggestions, + success: suggestions, }, }; } diff --git a/packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts b/packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts index 7b0ff5ed8..c54f833c7 100644 --- a/packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts +++ b/packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts @@ -1,7 +1,6 @@ -import { getNodeDefinition } from '@/utils/validation/get-node-definition'; - import type { WorkflowBuilderNode } from '../../../../node/node-data'; import { useStore } from '../../../../store/store'; +import { getNodeDefinition } from '../../../../utils/validation/get-node-definition'; import { type VariablesSuggestionsStore, emptyVariablesSuggestionsStore, diff --git a/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts b/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts index 7f76bc381..3bc175948 100644 --- a/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts +++ b/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts @@ -1,5 +1,4 @@ -import { NODE_ID_FOR_COMMON_NODE_DATA } from '@/features/variables/constants'; - +import { NODE_ID_FOR_COMMON_NODE_DATA } from '../../../constants'; import type { SuggestionNodeType, SuggestionsBySourceHandle } from '../../types'; import { SUGGESTION_NODE_TYPE } from '../../types'; import type { VariablesSuggestionsStore } from '../../use-variable-suggestions-store'; diff --git a/packages/sdk/src/features/variables/stores/types.ts b/packages/sdk/src/features/variables/stores/types.ts index f81f855f7..b34fb6fa8 100644 --- a/packages/sdk/src/features/variables/stores/types.ts +++ b/packages/sdk/src/features/variables/stores/types.ts @@ -1,8 +1,11 @@ import type { VariableSuggestion } from '../components/variable-text/variable-text.types'; +import type { SPECIAL_SOURCE_HANDLE_KEYWORDS } from '../constants'; export type SuggestionsBySourceHandle = { [sourceHandle: string]: VariableSuggestion[] | undefined; - default: VariableSuggestion[]; + [SPECIAL_SOURCE_HANDLE_KEYWORDS.EVERY]?: VariableSuggestion[]; + [SPECIAL_SOURCE_HANDLE_KEYWORDS.SUCCESS]?: VariableSuggestion[]; + [SPECIAL_SOURCE_HANDLE_KEYWORDS.ERROR]?: VariableSuggestion[]; }; export const SUGGESTION_NODE_TYPE = { diff --git a/packages/sdk/src/features/variables/utils/core/filter-suggestions-duplicates.ts b/packages/sdk/src/features/variables/utils/core/filter-suggestions-duplicates.ts new file mode 100644 index 000000000..87d05154d --- /dev/null +++ b/packages/sdk/src/features/variables/utils/core/filter-suggestions-duplicates.ts @@ -0,0 +1,13 @@ +import type { VariableSuggestion } from '../../components/variable-text/variable-text.types'; + +export function filterSuggestionsDuplicates(suggestions: VariableSuggestion[]): VariableSuggestion[] { + const suggestionsById = suggestions.reduce((stack: { [suggestionId: string]: VariableSuggestion }, suggestion) => { + if (!stack[suggestion.id]) { + stack[suggestion.id] = suggestion; + } + + return stack; + }, {}); + + return Object.values(suggestionsById); +} diff --git a/packages/sdk/src/features/variables/utils/core/get-available-variables-by-node-id.ts b/packages/sdk/src/features/variables/utils/core/get-available-variables-by-node-id.ts index ea15a17b1..378d7fdf4 100644 --- a/packages/sdk/src/features/variables/utils/core/get-available-variables-by-node-id.ts +++ b/packages/sdk/src/features/variables/utils/core/get-available-variables-by-node-id.ts @@ -7,6 +7,7 @@ import { useVariablesSuggestionsStore } from '../../stores/use-variable-suggesti import { getNodeAncestors } from '../diagram/get-node-ancestors'; import { getNodeLabelForVariable } from '../diagram/get-node-label-for-variable'; import { filterSuggestionsByTypes } from './filter-suggestions-by-types'; +import { filterSuggestionsDuplicates } from './filter-suggestions-duplicates'; type Params = { nodeId: string | undefined; @@ -33,7 +34,9 @@ export function getAvailableVariablesByNodeId({ // BFS backward through edges to find all ancestor nodes const ancestors = getNodeAncestors(nodeId, edges); - const groups: VariableSuggestionGroup[] = []; + const groupsByLabel: { + [label: string]: VariableSuggestionGroup; + } = {}; for (const ancestor of ancestors) { // Source handle is important because source handle from Source named error and success should returns different variables @@ -63,14 +66,16 @@ export function getAvailableVariablesByNodeId({ includeTypes, }); - if (filteredSuggestions.length > 0) { - groups.push({ - label: nodeLabel, - icon: node.data.icon, - suggestions: filteredSuggestions, - }); - } + const uniqueSuggestions = groupsByLabel[nodeLabel]?.suggestions + ? filterSuggestionsDuplicates([...groupsByLabel[nodeLabel].suggestions, ...filteredSuggestions]) + : filteredSuggestions; + + groupsByLabel[nodeLabel] = { + label: nodeLabel, + icon: node.data.icon, + suggestions: uniqueSuggestions, + }; } - return groups; + return Object.values(groupsByLabel); } diff --git a/packages/sdk/src/node/node-output-schema.ts b/packages/sdk/src/node/node-output-schema.ts index 198bc2adc..c05202e37 100644 --- a/packages/sdk/src/node/node-output-schema.ts +++ b/packages/sdk/src/node/node-output-schema.ts @@ -24,20 +24,24 @@ export const OUTPUT_SCHEMA_TYPE = { export type OutputPropertiesIndex = Record; +export type PropertiesBySourceHandle = { + [sourceHandle: string]: OutputPropertiesIndex | undefined; + every?: OutputPropertiesIndex; +}; + export type OutputVariant = { variantRule: | undefined | { - sourceHandleId?: string; - dataPropertyName?: string; - dataPropertyValue?: string; + dataPropertyName: string; + dataPropertyValue: string; }; - properties: OutputPropertiesIndex; + bySourceHandle: PropertiesBySourceHandle; }; export type NodeOutputSchemaDefault = { type: 'default'; - properties: OutputPropertiesIndex; + bySourceHandle: PropertiesBySourceHandle; }; export type NodeOutputSchemaVariant = { @@ -45,9 +49,7 @@ export type NodeOutputSchemaVariant = { Predefined variant depending on the value of a property. */ type: 'variant'; - variants: { - [variantName: string]: OutputVariant | undefined; - }; + variants: OutputVariant[]; }; export type NodeOutputSchemaPropertyValue = { From f716ffab658f6aba98baed2c75398856386b1634 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Fri, 14 Aug 2026 15:19:29 +0200 Subject: [PATCH 05/33] test(sdk): mock updated --- .../variables/components/schema-builder/schema-builder.tsx | 3 +-- packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx | 2 +- packages/sdk/src/hooks/use-workflow-builder-actions.ts | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/features/variables/components/schema-builder/schema-builder.tsx b/packages/sdk/src/features/variables/components/schema-builder/schema-builder.tsx index e40d4e70f..8745ea3e7 100644 --- a/packages/sdk/src/features/variables/components/schema-builder/schema-builder.tsx +++ b/packages/sdk/src/features/variables/components/schema-builder/schema-builder.tsx @@ -1,5 +1,5 @@ import { PlusCircle } from '@phosphor-icons/react'; - +import { Button, SnackbarType } from '@workflowbuilder/ui'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -16,7 +16,6 @@ import type { VariablesIndex } from '../../types'; import { getEmptyVariableDefinition } from '../../utils/get-empty-variable-definition'; import { getVariableReferenceWithoutBracketsForNode } from '../../utils/keys/get-variable-reference-without-brackets-for-node'; import { VariablePreview } from '../variable-preview/variable-preview'; -import { Button, SnackbarType } from '@workflowbuilder/ui'; type Props = { isDisabled: boolean; diff --git a/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx b/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx index 751651883..36ea8f598 100644 --- a/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx +++ b/packages/sdk/src/hooks/use-workflow-builder-actions.spec.tsx @@ -18,7 +18,7 @@ vi.mock('../features/integration/components/import-export/import-modal/open-impo openImportModal: vi.fn(), })); -vi.mock('../features/variables/modals/modal-settings', () => ({ +vi.mock('../features/variables/modals/global/modal-settings', () => ({ openModalWorkflowSettings: vi.fn(), })); diff --git a/packages/sdk/src/hooks/use-workflow-builder-actions.ts b/packages/sdk/src/hooks/use-workflow-builder-actions.ts index 924958d40..f3b9e3438 100644 --- a/packages/sdk/src/hooks/use-workflow-builder-actions.ts +++ b/packages/sdk/src/hooks/use-workflow-builder-actions.ts @@ -106,7 +106,7 @@ export function useWorkflowBuilderActions(): WorkflowBuilderActions { () => ({ save: () => onSave({ isAutoSave: false }), - openSettings: openModalWorkflowSettings, + openSettings: () => openModalWorkflowSettings(), openImport: openImportModal, openExport: openExportModal, From 06c9a901e9f425569066bf2cb05d53a7b52046f7 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Fri, 14 Aug 2026 16:14:17 +0200 Subject: [PATCH 06/33] fix(sdk): refreshing variables on init & controls after edge creation --- apps/demo/src/app/data/nodes/action/action.ts | 4 ++-- .../src/app/data/nodes/trigger/trigger.ts | 4 ++-- .../dependencies/dependencies.tsx | 3 ++- .../conditions-form.tsx | 4 ++-- .../variable-text-area-control.tsx | 10 ++++++---- .../variable-text-control.tsx | 10 ++++++---- .../sdk/src/features/variables/constants.ts | 2 ++ .../hooks/use-available-variables.ts | 19 +++++++++++++++++-- .../variables/hooks/use-refresh-variables.ts | 4 ---- packages/sdk/src/node/node-output-schema.ts | 2 +- .../src/store/slices/diagram-slice/actions.ts | 3 +++ 11 files changed, 43 insertions(+), 22 deletions(-) diff --git a/apps/demo/src/app/data/nodes/action/action.ts b/apps/demo/src/app/data/nodes/action/action.ts index eaef507d2..1bb78ed9c 100644 --- a/apps/demo/src/app/data/nodes/action/action.ts +++ b/apps/demo/src/app/data/nodes/action/action.ts @@ -17,8 +17,8 @@ export const action: PaletteItem = { bySourceHandle: { success: { status: { type: 'string', label: 'Status', description: 'Execution status: success, failure, or skipped' }, - // TODO: Match JSON schema - // result: { type: 'object', label: 'Result', description: 'The data returned by the action' }, + // TODO: outputSchema and schema properties should support the full JsonSchema7 type imported from @jsonforms/core to build suggestions not only for objects but also for their variables. + result: { type: 'object', label: 'Result', description: 'The data returned by the action' }, }, error: { errorMessage: { type: 'string', label: 'Error Message', description: 'Error details if the action failed' }, diff --git a/apps/demo/src/app/data/nodes/trigger/trigger.ts b/apps/demo/src/app/data/nodes/trigger/trigger.ts index fb5d2bbd7..a90d2b159 100644 --- a/apps/demo/src/app/data/nodes/trigger/trigger.ts +++ b/apps/demo/src/app/data/nodes/trigger/trigger.ts @@ -18,8 +18,8 @@ export const triggerNode: PaletteItem = { source: { eventType: { type: 'string', label: 'Event Type', description: 'The type of event that started the workflow' }, timestamp: { type: 'string', label: 'Timestamp', description: 'ISO 8601 date-time when the trigger fired' }, - // TODO: Match JSON schema - // payload: { type: 'object', label: 'Payload', description: 'The raw event data received by the trigger' }, + // TODO: outputSchema and schema properties should support the full JsonSchema7 type imported from @jsonforms/core to build suggestions not only for objects but also for their variables. + payload: { type: 'object', label: 'Payload', description: 'The raw event data received by the trigger' }, }, }, }, diff --git a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx index 1f905ef7e..187f487b7 100644 --- a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx +++ b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx @@ -23,12 +23,13 @@ export function Dependencies({ conditions, onClick, disabled = false, hasError } }, [conditions]); const selection = useSingleSelectedElement(); - const suggestionGroups = useAvailableVariables(selection?.node?.id); + const { suggestionGroups, totalVariables } = useAvailableVariables(selection?.node?.id); return ( -
+
{conditions.map((condition, index) => ( store.globalVariables); const nodes = useStore((store) => store.nodes); @@ -60,6 +65,16 @@ export function useAvailableVariables(nodeId: string | undefined, options?: Opti }, [nodeId, edges.length, nodes.length]); return useMemo(() => { - return [...globalSuggestionsGroups, ...nodeSuggestionsGroups]; + const suggestionGroups = [...globalSuggestionsGroups, ...nodeSuggestionsGroups]; + const totalVariables = suggestionGroups.reduce((stack: number, group) => { + stack += group.suggestions.length; + + return stack; + }, 0); + + return { + suggestionGroups, + totalVariables, + }; }, [globalSuggestionsGroups, nodeSuggestionsGroups]); } diff --git a/packages/sdk/src/features/variables/hooks/use-refresh-variables.ts b/packages/sdk/src/features/variables/hooks/use-refresh-variables.ts index dce861509..fd204be0a 100644 --- a/packages/sdk/src/features/variables/hooks/use-refresh-variables.ts +++ b/packages/sdk/src/features/variables/hooks/use-refresh-variables.ts @@ -30,10 +30,6 @@ function useRefreshVariables() { }, REFRESH_ALL_VARIABLES_DELAY_MS); }, []); - useEffect(() => { - refreshAll(); - }, [refreshAll]); - useEffect(() => { const wasNodeUpdated = ['dataUpdateNode', 'addNode'].includes(lastChangeName); const wasDiagramReloaded = ['undo', 'redo', 'import'].includes(lastChangeName); diff --git a/packages/sdk/src/node/node-output-schema.ts b/packages/sdk/src/node/node-output-schema.ts index c05202e37..ca4e1c1de 100644 --- a/packages/sdk/src/node/node-output-schema.ts +++ b/packages/sdk/src/node/node-output-schema.ts @@ -11,7 +11,7 @@ export function getVariableTypeIfPrimitive(type: VariableType): VariableTypePrim } export type OutputProperty = { - type: VariableTypePrimitive; + type: VariableType; label?: string; description?: string; }; diff --git a/packages/sdk/src/store/slices/diagram-slice/actions.ts b/packages/sdk/src/store/slices/diagram-slice/actions.ts index 169abe06f..ab9e8751f 100644 --- a/packages/sdk/src/store/slices/diagram-slice/actions.ts +++ b/packages/sdk/src/store/slices/diagram-slice/actions.ts @@ -6,6 +6,7 @@ import { migrateLegacyHandleIdsOnNodes, } from '../../../features/diagram/handles/migrate-legacy-handle-id'; import { selectSingleSelectedElement } from '../../../features/properties-bar/use-single-selected-element'; +import { refreshAllSuggestions } from '../../../features/variables/stores/core/refresh-suggestions'; import type { VariableDefinition } from '../../../features/variables/types'; import type { LayoutDirection } from '../../../node/common'; import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '../../../node/node-data'; @@ -116,6 +117,8 @@ export function setStoreDataFromIntegration(loadData: Partial Date: Fri, 14 Aug 2026 16:22:28 +0200 Subject: [PATCH 07/33] fix(sdk): dark-mode in variable preview --- .../variable-preview/variable-preview.module.css | 4 ++-- .../variables/modals/global/tab/tab-header.module.css | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/sdk/src/features/variables/components/variable-preview/variable-preview.module.css b/packages/sdk/src/features/variables/components/variable-preview/variable-preview.module.css index d7d24774e..e5b3ae6bd 100644 --- a/packages/sdk/src/features/variables/components/variable-preview/variable-preview.module.css +++ b/packages/sdk/src/features/variables/components/variable-preview/variable-preview.module.css @@ -5,8 +5,8 @@ padding: var(--wb-token-spacing-spacing-12, 12px) var(--wb-token-spacing-spacing-16, 16px) var(--wb-token-spacing-spacing-12, 12px) 16px; border-radius: 8px; - border: 1px solid #e5e5e5; - background: #f9f9f9; + border: 1px solid var(--ax-ui-bg-secondary-default); + background: var(--ax-ui-bg-secondary-default); transition: all var(--wb-transition); &:hover { diff --git a/packages/sdk/src/features/variables/modals/global/tab/tab-header.module.css b/packages/sdk/src/features/variables/modals/global/tab/tab-header.module.css index 4ef487275..6d976ea62 100644 --- a/packages/sdk/src/features/variables/modals/global/tab/tab-header.module.css +++ b/packages/sdk/src/features/variables/modals/global/tab/tab-header.module.css @@ -1,3 +1,7 @@ +:root { + --settings-tab-header-border: var(--ax-public-node-border-size) solid var(--ax-public-node-border-color); +} + .container { display: flex; width: 100%; @@ -6,7 +10,7 @@ padding-bottom: 1rem; &:not(.container--no-border) { - border-bottom: 1px solid var(--wb-ui-stroke-primary-default); + border-bottom: var(--settings-tab-header-border); + * { padding-top: var(--wb-token-spacing-modal-l-content-gap-2, 16px); @@ -25,6 +29,10 @@ margin: 0; } +.title { + color: var(--ax-txt-primary-default); +} + .description { color: var(--wb-txt-tertiary-default); } From f3585f6c4d2888c2d6fc55b6226081111439e93b Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Fri, 14 Aug 2026 16:49:43 +0200 Subject: [PATCH 08/33] feat(sdk): output depending on value --- .../src/app/data/nodes/trigger/trigger.ts | 65 ++++++++++++++++--- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/apps/demo/src/app/data/nodes/trigger/trigger.ts b/apps/demo/src/app/data/nodes/trigger/trigger.ts index a90d2b159..78f707eb6 100644 --- a/apps/demo/src/app/data/nodes/trigger/trigger.ts +++ b/apps/demo/src/app/data/nodes/trigger/trigger.ts @@ -13,14 +13,63 @@ export const triggerNode: PaletteItem = { schema, uischema, outputSchema: { - type: 'default', - bySourceHandle: { - source: { - eventType: { type: 'string', label: 'Event Type', description: 'The type of event that started the workflow' }, - timestamp: { type: 'string', label: 'Timestamp', description: 'ISO 8601 date-time when the trigger fired' }, - // TODO: outputSchema and schema properties should support the full JsonSchema7 type imported from @jsonforms/core to build suggestions not only for objects but also for their variables. - payload: { type: 'object', label: 'Payload', description: 'The raw event data received by the trigger' }, + type: 'variant', + variants: [ + { + variantRule: undefined, + bySourceHandle: { + every: { + eventType: { + type: 'string', + label: 'Event Type', + description: 'The type of event that started the workflow', + }, + timestamp: { type: 'string', label: 'Timestamp', description: 'ISO 8601 date-time when the trigger fired' }, + }, + }, }, - }, + { + variantRule: { + dataPropertyName: 'type', + dataPropertyValue: 'timeBasedTrigger', + }, + bySourceHandle: { + success: { + allDay: { + type: 'boolean', + label: 'All day event', + description: 'The type of event that started the workflow', + }, + startDate: { + type: 'date', + label: 'Start date', + description: 'The date when the event was scheduled to start', + }, + endDate: { + type: 'date', + label: 'End date', + description: 'The date when the event was scheduled to end', + }, + }, + }, + }, + { + variantRule: { + dataPropertyName: 'type', + dataPropertyValue: 'eventBasedTrigger', + }, + bySourceHandle: { + success: { + typeOfEventType: { + type: 'string', + label: 'Type of event type', + description: 'For example: form submission, user action etc.', + }, + }, + }, + }, + ], }, }; + +// payload: { type: 'object', label: 'Payload', description: 'The raw event data received by the trigger' }, From efcee6383ffe0aca14d9897852f2c8919c373813 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Fri, 14 Aug 2026 17:25:00 +0200 Subject: [PATCH 09/33] chore(sdk): better type for Timestamp --- apps/demo/src/app/data/nodes/trigger/trigger.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/demo/src/app/data/nodes/trigger/trigger.ts b/apps/demo/src/app/data/nodes/trigger/trigger.ts index 78f707eb6..333c7ebbc 100644 --- a/apps/demo/src/app/data/nodes/trigger/trigger.ts +++ b/apps/demo/src/app/data/nodes/trigger/trigger.ts @@ -24,7 +24,11 @@ export const triggerNode: PaletteItem = { label: 'Event Type', description: 'The type of event that started the workflow', }, - timestamp: { type: 'string', label: 'Timestamp', description: 'ISO 8601 date-time when the trigger fired' }, + timestamp: { + type: 'datetime', + label: 'Timestamp', + description: 'ISO 8601 date-time when the trigger fired', + }, }, }, }, @@ -41,12 +45,12 @@ export const triggerNode: PaletteItem = { description: 'The type of event that started the workflow', }, startDate: { - type: 'date', + type: 'datetime', label: 'Start date', description: 'The date when the event was scheduled to start', }, endDate: { - type: 'date', + type: 'datetime', label: 'End date', description: 'The date when the event was scheduled to end', }, From 8024a46df24f051680913dab8e5f987deccd0388 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Fri, 14 Aug 2026 17:48:59 +0200 Subject: [PATCH 10/33] feat(sdk): dynamic select --- .../src/app/data/nodes/action/uischema.ts | 3 +- .../nodes/delay/default-properties-data.ts | 1 + apps/demo/src/app/data/nodes/delay/schema.ts | 3 + .../demo/src/app/data/nodes/delay/uischema.ts | 21 ++++ .../app/data/nodes/notification/uischema.ts | 3 +- .../variable-dynamic-control/README.md | 9 ++ .../controls/variable-dynamic-control/type.ts | 13 +++ .../variable-dynamic-control.tsx | 97 +++++++++++++++++++ .../sdk/src/features/json-form/json-form.tsx | 2 + packages/sdk/src/types/controls.ts | 24 ++--- packages/sdk/src/types/uischema.ts | 2 + 11 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 packages/sdk/src/features/json-form/controls/variable-dynamic-control/README.md create mode 100644 packages/sdk/src/features/json-form/controls/variable-dynamic-control/type.ts create mode 100644 packages/sdk/src/features/json-form/controls/variable-dynamic-control/variable-dynamic-control.tsx diff --git a/apps/demo/src/app/data/nodes/action/uischema.ts b/apps/demo/src/app/data/nodes/action/uischema.ts index 1a6fe2e3d..ef76b3ff0 100644 --- a/apps/demo/src/app/data/nodes/action/uischema.ts +++ b/apps/demo/src/app/data/nodes/action/uischema.ts @@ -81,7 +81,8 @@ const sendEmailProperties: ActionNodeUISchema = { elements: [ { type: 'Label', text: 'Number of retries' }, { - type: 'Text', + type: 'VariableDynamic', + variableType: 'number', scope: scope('properties.sendEmail.properties.retries'), rule: { effect: 'DISABLE', diff --git a/apps/demo/src/app/data/nodes/delay/default-properties-data.ts b/apps/demo/src/app/data/nodes/delay/default-properties-data.ts index 7c8844ca4..0b6239931 100644 --- a/apps/demo/src/app/data/nodes/delay/default-properties-data.ts +++ b/apps/demo/src/app/data/nodes/delay/default-properties-data.ts @@ -14,4 +14,5 @@ export const defaultPropertiesData: Required expression: 'order.processing_time * 2', }, type: delayTypeOptions.fixed.value, + untilDate: '', }; diff --git a/apps/demo/src/app/data/nodes/delay/schema.ts b/apps/demo/src/app/data/nodes/delay/schema.ts index 10f53ac74..d913b8d9f 100644 --- a/apps/demo/src/app/data/nodes/delay/schema.ts +++ b/apps/demo/src/app/data/nodes/delay/schema.ts @@ -37,6 +37,9 @@ export const schema = { }, }, }, + untilDate: { + type: 'string', + }, }, ...conditionalValidation, } satisfies NodeSchema; diff --git a/apps/demo/src/app/data/nodes/delay/uischema.ts b/apps/demo/src/app/data/nodes/delay/uischema.ts index 61cca1091..f67cd65c4 100644 --- a/apps/demo/src/app/data/nodes/delay/uischema.ts +++ b/apps/demo/src/app/data/nodes/delay/uischema.ts @@ -90,6 +90,26 @@ const dynamicDelayProperties: PaletteItem['uischema'] = { ], }; +const untilSpecificProperties: PaletteItem['uischema'] = { + rule: { + effect: 'SHOW', + condition: { + scope: scope('properties.type'), + schema: { const: delayTypeOptions.untilSpecific.value }, + }, + }, + type: 'Accordion', + label: 'Date/Time', + elements: [ + { + type: 'VariableDynamic', + scope: scope('properties.untilDate'), + label: 'Wait until', + variableType: 'date', + }, + ], +}; + export const uischema: UISchema = { type: 'VerticalLayout', elements: [ @@ -102,5 +122,6 @@ export const uischema: UISchema = { ...(generalInformation ? [generalInformation] : []), fixedDelayProperties, dynamicDelayProperties, + untilSpecificProperties, ], }; diff --git a/apps/demo/src/app/data/nodes/notification/uischema.ts b/apps/demo/src/app/data/nodes/notification/uischema.ts index eaf511318..3ddf6788f 100644 --- a/apps/demo/src/app/data/nodes/notification/uischema.ts +++ b/apps/demo/src/app/data/nodes/notification/uischema.ts @@ -65,7 +65,8 @@ const sendEmailProperties: PaletteItem['uischema'] = { elements: [ { type: 'Label', text: 'Number of retries' }, { - type: 'Text', + type: 'VariableDynamic', + variableType: 'number', scope: scope('properties.sendEmail.properties.retries'), rule: { effect: 'DISABLE', diff --git a/packages/sdk/src/features/json-form/controls/variable-dynamic-control/README.md b/packages/sdk/src/features/json-form/controls/variable-dynamic-control/README.md new file mode 100644 index 000000000..e2d2afe2a --- /dev/null +++ b/packages/sdk/src/features/json-form/controls/variable-dynamic-control/README.md @@ -0,0 +1,9 @@ +# Variable Dynamic Control + +This control accepts an expected type and displays the appropriate input control for that type. It also provides an additional `{}` button that lets users select variables whose type matches the expected type. + +## Backend + +### Expectations + +This control is designed to always return either the correct type or a single variable reference matching that type (a string). For example, a boolean value can be either boolean `true` or string `{{nodes..wasChecked}}`, which is dynamic and can be `true` or `false`. diff --git a/packages/sdk/src/features/json-form/controls/variable-dynamic-control/type.ts b/packages/sdk/src/features/json-form/controls/variable-dynamic-control/type.ts new file mode 100644 index 000000000..1a3661b90 --- /dev/null +++ b/packages/sdk/src/features/json-form/controls/variable-dynamic-control/type.ts @@ -0,0 +1,13 @@ +import type { InputProps } from '@workflowbuilder/ui'; + +import type { VariableTypePrimitive } from '../../../../node/node-output-schema'; +import type { BaseControlElement } from '../../../../types/controls'; +import type { Override } from '../../../../types/utils'; + +export type VariableDynamicControlElement = Override< + BaseControlElement, + { + type: 'VariableDynamic'; + variableType: VariableTypePrimitive; + } & Pick +>; diff --git a/packages/sdk/src/features/json-form/controls/variable-dynamic-control/variable-dynamic-control.tsx b/packages/sdk/src/features/json-form/controls/variable-dynamic-control/variable-dynamic-control.tsx new file mode 100644 index 000000000..1e5cda333 --- /dev/null +++ b/packages/sdk/src/features/json-form/controls/variable-dynamic-control/variable-dynamic-control.tsx @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useState } from 'react'; + +import type { VariableType, VariableTypePrimitive } from '../../../../node/node-output-schema'; +import type { WBControlProps } from '../../../../types/controls'; +import { getIsStringNumber } from '../../../../utils/validation/get-is-string-number'; +import { useSingleSelectedElement } from '../../../properties-bar/use-single-selected-element'; +import { DynamicTypedVariableOrInput } from '../../../variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input'; +import { useAvailableVariables } from '../../../variables/hooks/use-available-variables'; +import { getBooleanIfPossible } from '../../../variables/utils/get-boolean-if-possible'; +import { getIsStringVariableReference } from '../../../variables/utils/keys/get-is-string-variable-reference'; +import { createControlRenderer } from '../../utils/rendering'; +import { ControlWrapper } from '../control-wrapper'; +import type { VariableDynamicControlElement } from './type'; + +type VariableDynamicControlProps = WBControlProps; + +const variableTypesForSuggestions: Record = { + boolean: ['boolean'], + date: ['date', 'datetime'], + datetime: ['date', 'datetime'], + number: ['number'], + string: ['string', 'number', 'date', 'datetime'], +}; + +function VariableDynamicControl(props: VariableDynamicControlProps) { + const { data, handleChange, path, errors, enabled, uischema } = props; + const { placeholder, variableType } = uischema; + const selection = useSingleSelectedElement(); + const { suggestionGroups, totalVariables } = useAvailableVariables(selection?.node?.id, { + excludeTypes: [], + includeTypes: variableTypesForSuggestions[variableType], + }); + + const [inputValue, setInputValue] = useState(data); + + useEffect(() => { + if (data == null) { + setInputValue(''); + } else { + setInputValue(String(data)); + } + }, [data]); + + const onChange = useCallback((value: string) => { + setInputValue(value); + }, []); + + const onBlur = useCallback( + (value: string) => { + setInputValue(value); + const trimmedValue = value.trim(); + const isSingleVariable = getIsStringVariableReference(trimmedValue); + + if (isSingleVariable) { + handleChange(path, trimmedValue); + + return; + } + + if (variableType === 'number') { + const isNumber = getIsStringNumber(trimmedValue); + handleChange(path, isNumber ? Number(trimmedValue) : undefined); + + return; + } + + if (variableType === 'boolean') { + const valueToSave = getBooleanIfPossible(trimmedValue); + + handleChange(path, valueToSave); + + return; + } + + handleChange(path, trimmedValue || undefined); + }, + [handleChange, path, variableType], + ); + + return ( + + 0} + isDisabled={!enabled} + placeholder={placeholder} + /> + + ); +} + +export const variableDynamicControlRenderer = createControlRenderer('VariableDynamic', VariableDynamicControl); diff --git a/packages/sdk/src/features/json-form/json-form.tsx b/packages/sdk/src/features/json-form/json-form.tsx index f2efa162a..232d1c950 100644 --- a/packages/sdk/src/features/json-form/json-form.tsx +++ b/packages/sdk/src/features/json-form/json-form.tsx @@ -16,6 +16,7 @@ import { selectControlRenderer } from './controls/select-control/select-control' import { switchControlRenderer } from './controls/switch-control/switch-control'; import { textAreaControlRenderer } from './controls/text-area-control/text-area-control'; import { textControlRenderer } from './controls/text-control/text-control'; +import { variableDynamicControlRenderer } from './controls/variable-dynamic-control/variable-dynamic-control'; import { variableTextAreaControlRenderer } from './controls/variable-text-area-control/variable-text-area-control'; import { variableTextControlRenderer } from './controls/variable-text-control/variable-text-control'; import { getCustomCells, getCustomRenderers } from './extension-registry'; @@ -68,6 +69,7 @@ const builtinRenderers: JsonFormsRendererRegistryEntry[] = [ dynamicConditionsControlRenderer, aiToolsControlRenderer, decisionBranchesControlRenderer, + variableDynamicControlRenderer, variableTextControlRenderer, variableTextAreaControlRenderer, messageOnErrorControlRenderer, diff --git a/packages/sdk/src/types/controls.ts b/packages/sdk/src/types/controls.ts index a95b5453b..f7592d965 100644 --- a/packages/sdk/src/types/controls.ts +++ b/packages/sdk/src/types/controls.ts @@ -9,7 +9,7 @@ import type { UISchemaRule } from './rules'; import type { UISchemaControlElement } from './uischema'; import type { Override } from './utils'; -type ControlProps = Override< +export type WBControlProps = Override< BaseControlProps, { data: D; @@ -25,7 +25,7 @@ export type TextControlElement = Override< inputType?: string; } & Pick >; -export type TextControlProps = ControlProps; +export type TextControlProps = WBControlProps; export type SwitchControlElement = Override< BaseControlElement, @@ -33,7 +33,7 @@ export type SwitchControlElement = Override< type: 'Switch'; } >; -export type SwitchControlProps = ControlProps; +export type SwitchControlProps = WBControlProps; export type TextAreaControlElement = Override< BaseControlElement, @@ -41,7 +41,7 @@ export type TextAreaControlElement = Override< type: 'TextArea'; } & Pick >; -export type TextAreaControlProps = ControlProps; +export type TextAreaControlProps = WBControlProps; /** * One row in a dynamic-conditions control — two operands (`x`, `y`), a @@ -74,7 +74,7 @@ export type DynamicConditionsControlElement = Override< } >; -export type DynamicConditionsControlProps = ControlProps; +export type DynamicConditionsControlProps = WBControlProps; export type DecisionBranchesControlElement = Override< BaseControlElement, @@ -83,7 +83,7 @@ export type DecisionBranchesControlElement = Override< } >; -export type DecisionBranchesControlProps = ControlProps; +export type DecisionBranchesControlProps = WBControlProps; export type SelectControlElement = Override< BaseControlElement, @@ -91,7 +91,7 @@ export type SelectControlElement = Override< type: 'Select'; } >; -export type SelectControlProps = ControlProps; +export type SelectControlProps = WBControlProps; export type DatePickerControlElement = Override< BaseControlElement, @@ -99,7 +99,7 @@ export type DatePickerControlElement = Override< type: 'DatePicker'; } >; -export type DatePickerControlProps = ControlProps; +export type DatePickerControlProps = WBControlProps; export type BaseControlProps = Override< JsonFormsControlProps, @@ -122,7 +122,7 @@ export type VariableTextControlElement = Override< variablesTypes?: VariableType[]; } & Pick >; -export type VariableTextControlProps = ControlProps; +export type VariableTextControlProps = WBControlProps; export type VariableTextAreaControlElement = Override< BaseControlElement, @@ -131,7 +131,7 @@ export type VariableTextAreaControlElement = Override< variablesTypes?: VariableType[]; } & Pick >; -export type VariableTextAreaControlProps = ControlProps; +export type VariableTextAreaControlProps = WBControlProps; export type MessageOnErrorControlElement = Override< BaseControlElement, @@ -142,9 +142,9 @@ export type MessageOnErrorControlElement = Override< variant?: 'info' | 'warning' | 'error'; } >; -export type MessageOnErrorProps = ControlProps; +export type MessageOnErrorProps = WBControlProps; -type BaseControlElement = Override; +export type BaseControlElement = Override; // Re-exported for use in NodeDataProperties-based types diff --git a/packages/sdk/src/types/uischema.ts b/packages/sdk/src/types/uischema.ts index 98e313a09..376373cac 100644 --- a/packages/sdk/src/types/uischema.ts +++ b/packages/sdk/src/types/uischema.ts @@ -1,3 +1,4 @@ +import type { VariableDynamicControlElement } from '../features/json-form/controls/variable-dynamic-control/type'; import { type AiToolsControlElement, type DatePickerControlElement, @@ -28,6 +29,7 @@ export type UISchemaControlElement = ( | DynamicConditionsControlElement | AiToolsControlElement | DecisionBranchesControlElement + | VariableDynamicControlElement | VariableTextControlElement | VariableTextAreaControlElement | MessageOnErrorControlElement From 9f22a8c8057383ad5dc2c253fde9c542d030e5c2 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Thu, 20 Aug 2026 15:53:31 +0200 Subject: [PATCH 11/33] feat(sdk): exposing variables api --- .../src/app/data/nodes/trigger/trigger.ts | 2 - .../variable-text/core/build-mention-data.ts | 14 ++ .../variable-text/variable-text.tsx | 185 ++++++++---------- .../variable-text/variable-text.types.ts | 9 +- .../utils/form-validation/definitions.ts | 5 - packages/sdk/src/index.ts | 25 ++- packages/sdk/src/utils/time.ts | 2 +- 7 files changed, 134 insertions(+), 108 deletions(-) create mode 100644 packages/sdk/src/features/variables/components/variable-text/core/build-mention-data.ts diff --git a/apps/demo/src/app/data/nodes/trigger/trigger.ts b/apps/demo/src/app/data/nodes/trigger/trigger.ts index 333c7ebbc..8e995a11f 100644 --- a/apps/demo/src/app/data/nodes/trigger/trigger.ts +++ b/apps/demo/src/app/data/nodes/trigger/trigger.ts @@ -75,5 +75,3 @@ export const triggerNode: PaletteItem = { ], }, }; - -// payload: { type: 'object', label: 'Payload', description: 'The raw event data received by the trigger' }, diff --git a/packages/sdk/src/features/variables/components/variable-text/core/build-mention-data.ts b/packages/sdk/src/features/variables/components/variable-text/core/build-mention-data.ts new file mode 100644 index 000000000..cbafdb49a --- /dev/null +++ b/packages/sdk/src/features/variables/components/variable-text/core/build-mention-data.ts @@ -0,0 +1,14 @@ +import type { VariableMentionData, VariableSuggestionGroup } from '../variable-text.types'; + +export function buildMentionData(groups: VariableSuggestionGroup[]): VariableMentionData[] { + return groups.flatMap((group) => + group.suggestions.map((suggestion) => ({ + id: suggestion.id, + display: `{{ ${suggestion.display} }}`, + groupLabel: group.label, + label: suggestion.label, + description: suggestion.description, + type: suggestion.type, + })), + ); +} diff --git a/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx b/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx index a1c8dfba6..0386bd9e5 100644 --- a/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx +++ b/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx @@ -1,6 +1,6 @@ import { NavButton, SnackbarType } from '@workflowbuilder/ui'; import clsx from 'clsx'; -import { type ReactElement, type ReactNode, cloneElement, useCallback, useMemo, useRef } from 'react'; +import { type ReactElement, type ReactNode, cloneElement, useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Mention, type MentionDataItem, MentionsInput } from 'react-mentions-ts'; @@ -8,11 +8,16 @@ import { Icon } from '@workflow-builder/icons'; import styles from './variable-text.module.css'; -import type { VariableType } from '../../../../node/node-output-schema'; import { getNodeByIdAction } from '../../../../store-get-actions/stores/use-store-get-actions'; import { showSnackbar } from '../../../../utils/show-snackbar'; import { VARIABLE_BRACKETS_END, VARIABLE_BRACKETS_START, VARIABLE_NODES_KEY } from '../../constants'; -import type { VariableSuggestion, VariableSuggestionGroup, VariableTextProps } from './variable-text.types'; +import { buildMentionData } from './core/build-mention-data'; +import type { + VariableMentionData, + VariableSuggestion, + VariableSuggestionGroup, + VariableTextProps, +} from './variable-text.types'; const DEFAULT_TRIGGER = '{{'; const DEFAULT_MARKUP = '{{__id__}}'; @@ -38,26 +43,6 @@ const multiLineClassNames = { control: `${styles['control']} ${styles['multiLine']}`, }; -type VariableMentionData = MentionDataItem & { - groupLabel?: string; - label: string; - description?: string; - type: VariableType; -}; - -function buildMentionData(groups: VariableSuggestionGroup[]): VariableMentionData[] { - return groups.flatMap((group) => - group.suggestions.map((suggestion) => ({ - id: suggestion.id, - display: `{{ ${suggestion.display} }}`, - groupLabel: group.label, - label: suggestion.label, - description: suggestion.description, - type: suggestion.type, - })), - ); -} - function defaultRenderGroupItem(suggestion: VariableSuggestion, _focused: boolean): ReactNode { return (
@@ -90,80 +75,6 @@ function stopLibraryMouseDown(event: React.MouseEvent) { event.stopPropagation(); } -function handleClose() { - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } -} - -function SuggestionsContainer({ - groups, - title, - renderGroupHeader, - children, -}: { - groups: VariableSuggestionGroup[]; - title: string; - renderGroupHeader: (group: VariableSuggestionGroup) => ReactNode; - children: ReactElement; -}) { - const ul = children as ReactElement<{ children?: ReactElement[]; className?: string }>; - const items = ul.props.children; - - if (!items || !Array.isArray(items) || items.length === 0) { - return children; - } - - // Build a lookup from suggestion id → group for efficient header injection - const groupByItemId = new Map(); - for (const group of groups) { - for (const s of group.suggestions) { - groupByItemId.set(s.id, group); - } - } - - const grouped: ReactNode[] = []; - let previousLabel = ''; - - for (const item of items) { - // Library keys are formatted as "childIndex-suggestionId", e.g. "0-nodeId.propKey" - const suggestionId = String(item.key ?? '').replace(/^\d+-/, ''); - const group = groupByItemId.get(suggestionId); - const label = group?.label ?? ''; - - if (label !== previousLabel) { - const headerGroup = group ?? groups.find((group) => group.label === label); - if (headerGroup && (headerGroup.label || headerGroup.icon)) { - grouped.push( -
  • - {renderGroupHeader(headerGroup)} -
  • , - ); - } - previousLabel = label; - } - - grouped.push(item); - } - - return ( -
    -
    - {title} - { - event.stopPropagation(); - handleClose(); - }} - > - - -
    - {cloneElement(ul, {}, grouped)} -
    - ); -} - export function VariableText({ className, classNameWrapper, @@ -180,11 +91,87 @@ export function VariableText({ mentionProps, }: VariableTextProps) { const { t } = useTranslation(); + const [key, setKey] = useState(crypto.randomUUID()); const refValueForBlur = useRef(value); const singleLine = variant === 'text'; const mentionData = useMemo(() => buildMentionData(suggestionGroups), [suggestionGroups]); + const handleClose = useCallback(() => { + setKey(crypto.randomUUID()); + }, []); + + const SuggestionsContainer = useCallback( + ({ + groups, + title, + renderGroupHeader, + children, + }: { + groups: VariableSuggestionGroup[]; + title: string; + renderGroupHeader: (group: VariableSuggestionGroup) => ReactNode; + children: ReactElement; + }) => { + const ul = children as ReactElement<{ children?: ReactElement[]; className?: string }>; + const items = ul.props.children; + + if (!items || !Array.isArray(items) || items.length === 0) { + return children; + } + + // Build a lookup from suggestion id → group for efficient header injection + const groupByItemId = new Map(); + for (const group of groups) { + for (const s of group.suggestions) { + groupByItemId.set(s.id, group); + } + } + + const grouped: ReactNode[] = []; + let previousLabel = ''; + + for (const item of items) { + // Library keys are formatted as "childIndex-suggestionId", e.g. "0-nodeId.propKey" + const suggestionId = String(item.key ?? '').replace(/^\d+-/, ''); + const group = groupByItemId.get(suggestionId); + const label = group?.label ?? ''; + + if (label !== previousLabel) { + const headerGroup = group ?? groups.find((group) => group.label === label); + if (headerGroup && (headerGroup.label || headerGroup.icon)) { + grouped.push( +
  • + {renderGroupHeader(headerGroup)} +
  • , + ); + } + previousLabel = label; + } + + grouped.push(item); + } + + return ( +
    +
    + {title} + { + event.stopPropagation(); + handleClose(); + }} + > + + +
    + {cloneElement(ul, {}, grouped)} +
    + ); + }, + [handleClose], + ); + const displayTransform = useCallback( (id: string | number) => { const typedId = String(id); @@ -242,7 +229,7 @@ export function VariableText({ {children} ), - [suggestionGroups, title, renderGroupHeader], + [SuggestionsContainer, suggestionGroups, title, renderGroupHeader], ); const onMentionsChange = useCallback( @@ -313,11 +300,13 @@ export function VariableText({ return ( ; hasError?: boolean; }; + +export type VariableMentionData = MentionDataItem & { + groupLabel?: string; + label: string; + description?: string; + type: VariableType; +}; diff --git a/packages/sdk/src/features/variables/utils/form-validation/definitions.ts b/packages/sdk/src/features/variables/utils/form-validation/definitions.ts index 41ceb2f25..4ed4fd292 100644 --- a/packages/sdk/src/features/variables/utils/form-validation/definitions.ts +++ b/packages/sdk/src/features/variables/utils/form-validation/definitions.ts @@ -37,10 +37,5 @@ export function getDefinitionErrors(definition: Partial): De } } - // Uncomment if required - // if (!definition.description) { - // validity.description = true; - // } - return validity; } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index e8a970338..cbeface8e 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -112,6 +112,7 @@ export type { IconType, LayoutDirection, PaletteItem, + PaletteGroup, PaletteItemOrGroup, TemplateModel, } from './node/common'; @@ -202,6 +203,7 @@ export { } from './features/diagram/listeners/node-drag-start-listeners'; export { getHandleId } from './features/diagram/handles/get-handle-id'; +export { getNodeAncestors } from './features/variables/utils/diagram/get-node-ancestors'; // ============================================================================= // JsonForms helpers (plugin schema authoring) @@ -228,7 +230,6 @@ export { EDGE_OFFSET, SELF_CONNECTING_EDGE_LABEL_OFFSET, } from './features/diagram/edges/edge.consts'; -export { VARIABLE_NODES_KEY } from './features/variables/constants'; // ============================================================================= // i18n @@ -260,3 +261,25 @@ export { Icon } from '@workflow-builder/icons'; * @category Icons */ export type { WBIcon } from '@workflow-builder/icons'; + +// ============================================================================= +// Elements +// ============================================================================= +// Individual UI schema element types, for consumers building parts of a +// `UISchema` piecemeal (e.g. a helper returning a single layout element). + +export type { UISchemaElement } from './types/uischema'; + +// ============================================================================= +// Variables +// ============================================================================= +// Delimiters wrapping a variable reference in a string value +// (`{{nodeId.output}}`), so consumers build and parse references without +// hardcoding the brackets. + +export { VARIABLE_BRACKETS_START, VARIABLE_BRACKETS_END } from './features/variables/constants'; +export { VARIABLE_NODES_KEY } from './features/variables/constants'; + +export { useVariablesSuggestionsStore } from './features/variables/stores/use-variable-suggestions-store'; +export type { VariablesSuggestionsStore } from './features/variables/stores/use-variable-suggestions-store'; +export { getNodeVariablesSuggestions } from './features/variables/stores/core/get-node-variables-suggestions'; diff --git a/packages/sdk/src/utils/time.ts b/packages/sdk/src/utils/time.ts index af7aa1635..31484e3a0 100644 --- a/packages/sdk/src/utils/time.ts +++ b/packages/sdk/src/utils/time.ts @@ -55,7 +55,7 @@ export function getISODate(dateLike: DateLike | null): string { const year = date.getFullYear(); const dateISO = date.toISOString(); if (year < 1980) { - console.warn(`DateString doesn't is number but may be wrong`, dateLike, dateISO); + console.warn(`DateString is a number but may be wrong`, dateLike, dateISO); } return dateISO; From fc6c3df479ae2334aaa28c05ea7334295ca5ace3 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Wed, 26 Aug 2026 09:09:27 +0200 Subject: [PATCH 12/33] fix(sdk): setDiagramModel refreshes variable suggestions B1 --- .../conditions-form-field.tsx | 3 +++ .../dynamic-typed-variable-or-input.tsx | 7 ++++++- packages/sdk/src/store/slices/diagram-slice.ts | 3 +++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form-field/conditions-form-field.tsx b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form-field/conditions-form-field.tsx index 7db37f3d5..329b8d3b2 100644 --- a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form-field/conditions-form-field.tsx +++ b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form-field/conditions-form-field.tsx @@ -16,6 +16,7 @@ import { comparisonOperatorsByPrimitiveType, } from '../../../../../features/variables/constants'; import type { VariableTypePrimitive } from '../../../../../node/node-output-schema'; +import { useStore } from '../../../../../store/store'; import type { DynamicCondition } from '../../../../../types/controls'; import { getStringVariableTypeIfPossible } from '../../../../variables/actions/get-string-variable-type-if-possible'; import { type ConditionErrors, getConditionErrors } from '../../../../variables/utils/form-validation/conditions'; @@ -45,6 +46,7 @@ const getTypeOptions = ( }; export function ConditionsFormField(props: ConditionsFormFieldProps) { + const isReadOnlyMode = useStore((store) => store.isReadOnlyMode); const { condition, onChange, onRemove, shouldShowOperator = false, shouldShowValidation, suggestionGroups } = props; const [{ xType, comparisonsOperators }, setTypeOptions] = useState(getTypeOptions(condition.x)); @@ -123,6 +125,7 @@ export function ConditionsFormField(props: ConditionsFormFieldProps) { isError={shouldShowValidation && errors.y} type={xType} suggestionGroups={suggestionGroups} + isDisabled={isReadOnlyMode} />
    diff --git a/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx b/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx index 78c627b78..79d1a7fb4 100644 --- a/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx +++ b/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx @@ -78,6 +78,7 @@ export function DynamicTypedVariableOrInput({ className={clsx(styles['button-toggle'], 'right-adornment')} tooltip={t('variables.typeValue')} onClick={handleToggleMode} + disabled={isDisabled} > @@ -99,7 +100,11 @@ export function DynamicTypedVariableOrInput({ suggestionGroups={suggestionGroupsForType} endAdornment={ suggestionGroupsForType.length > 0 ? ( - + diff --git a/packages/sdk/src/store/slices/diagram-slice.ts b/packages/sdk/src/store/slices/diagram-slice.ts index aa53eb34c..c6b99e4ff 100644 --- a/packages/sdk/src/store/slices/diagram-slice.ts +++ b/packages/sdk/src/store/slices/diagram-slice.ts @@ -6,6 +6,7 @@ import { migrateLegacyHandleIdsOnEdges, migrateLegacyHandleIdsOnNodes, } from '../../features/diagram/handles/migrate-legacy-handle-id'; +import { refreshAllSuggestions } from '../../features/variables/stores/core/refresh-suggestions'; import type { VariablesIndex } from '../../features/variables/types'; import { type ConnectionBeingDragged, @@ -89,6 +90,8 @@ export function useDiagramSlice(set: SetDiagramState, get: GetDiagramState) { layoutDirection, documentName, }); + + refreshAllSuggestions(); }, setDocumentName: (name: string) => { set({ From eac7baf5c17c7026aedbb387a01956fd2f4c1445 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Wed, 26 Aug 2026 11:36:24 +0200 Subject: [PATCH 13/33] feat(sdk): variables using JSONSchema7 --- apps/ai-studio/src/nodes/ai-agent/index.ts | 9 ++- apps/demo/src/app/data/nodes/action/action.ts | 25 +++++-- .../src/app/data/nodes/ai-agent/ai-agent.ts | 21 +++++- .../app/data/nodes/conditional/conditional.ts | 17 +++-- .../src/app/data/nodes/decision/decision.ts | 15 ++++- apps/demo/src/app/data/nodes/delay/delay.ts | 16 ++++- .../data/nodes/notification/notification.ts | 25 +++++-- .../src/app/data/nodes/trigger/trigger.ts | 66 +++++++++++-------- packages/sdk/src/node/node-output-schema.ts | 5 +- 9 files changed, 148 insertions(+), 51 deletions(-) diff --git a/apps/ai-studio/src/nodes/ai-agent/index.ts b/apps/ai-studio/src/nodes/ai-agent/index.ts index eac71dfc7..9c3cfa23c 100644 --- a/apps/ai-studio/src/nodes/ai-agent/index.ts +++ b/apps/ai-studio/src/nodes/ai-agent/index.ts @@ -18,7 +18,14 @@ export const aiAgentPaletteItem: PaletteItem = { type: 'default', bySourceHandle: { success: { - response: { type: 'string', label: 'Response', description: 'The text generated by the AI model' }, + type: 'object', + properties: { + response: { + type: 'string', + title: 'Response', + description: 'The text generated by the AI model', + }, + }, }, }, }, diff --git a/apps/demo/src/app/data/nodes/action/action.ts b/apps/demo/src/app/data/nodes/action/action.ts index 1bb78ed9c..cea593dfc 100644 --- a/apps/demo/src/app/data/nodes/action/action.ts +++ b/apps/demo/src/app/data/nodes/action/action.ts @@ -16,12 +16,29 @@ export const action: PaletteItem = { type: 'default', bySourceHandle: { success: { - status: { type: 'string', label: 'Status', description: 'Execution status: success, failure, or skipped' }, - // TODO: outputSchema and schema properties should support the full JsonSchema7 type imported from @jsonforms/core to build suggestions not only for objects but also for their variables. - result: { type: 'object', label: 'Result', description: 'The data returned by the action' }, + type: 'object', + properties: { + status: { + type: 'string', + title: 'Status', + description: 'Execution status: success, failure, or skipped', + }, + result: { + type: 'object', + title: 'Result', + description: 'The data returned by the action', + }, + }, }, error: { - errorMessage: { type: 'string', label: 'Error Message', description: 'Error details if the action failed' }, + type: 'object', + properties: { + errorMessage: { + type: 'string', + title: 'Error Message', + description: 'Error details if the action failed', + }, + }, }, }, }, diff --git a/apps/demo/src/app/data/nodes/ai-agent/ai-agent.ts b/apps/demo/src/app/data/nodes/ai-agent/ai-agent.ts index 3897ab36d..fe65e7454 100644 --- a/apps/demo/src/app/data/nodes/ai-agent/ai-agent.ts +++ b/apps/demo/src/app/data/nodes/ai-agent/ai-agent.ts @@ -18,9 +18,24 @@ export const aiAgent: PaletteItem = { type: 'default', bySourceHandle: { success: { - response: { type: 'string', label: 'Response', description: 'The text generated by the AI model' }, - tokensUsed: { type: 'number', label: 'Tokens Used', description: 'Total number of tokens consumed' }, - model: { type: 'string', label: 'Model', description: 'The AI model that was used' }, + type: 'object', + properties: { + response: { + type: 'string', + title: 'Response', + description: 'The text generated by the AI model', + }, + tokensUsed: { + type: 'number', + title: 'Tokens Used', + description: 'Total number of tokens consumed', + }, + model: { + type: 'string', + title: 'Model', + description: 'The AI model that was used', + }, + }, }, }, }, diff --git a/apps/demo/src/app/data/nodes/conditional/conditional.ts b/apps/demo/src/app/data/nodes/conditional/conditional.ts index 9146848bd..042f12ab9 100644 --- a/apps/demo/src/app/data/nodes/conditional/conditional.ts +++ b/apps/demo/src/app/data/nodes/conditional/conditional.ts @@ -16,11 +16,18 @@ export const conditional: PaletteItem = { type: 'default', bySourceHandle: { success: { - result: { type: 'boolean', label: 'Result', description: 'Whether the condition evaluated to true or false' }, - matchedCondition: { - type: 'string', - label: 'Matched Condition', - description: 'The condition expression that matched', + type: 'object', + properties: { + result: { + type: 'boolean', + title: 'Result', + description: 'Whether the condition evaluated to true or false', + }, + matchedCondition: { + type: 'string', + title: 'Matched Condition', + description: 'The condition expression that matched', + }, }, }, }, diff --git a/apps/demo/src/app/data/nodes/decision/decision.ts b/apps/demo/src/app/data/nodes/decision/decision.ts index 0b4f7ed1c..d85d3e622 100644 --- a/apps/demo/src/app/data/nodes/decision/decision.ts +++ b/apps/demo/src/app/data/nodes/decision/decision.ts @@ -18,8 +18,19 @@ export const decision: PaletteItem = { type: 'default', bySourceHandle: { every: { - selectedBranch: { type: 'string', label: 'Selected Branch', description: 'Label of the branch that was taken' }, - branchIndex: { type: 'number', label: 'Branch Index', description: 'Zero-based index of the selected branch' }, + type: 'object', + properties: { + selectedBranch: { + type: 'string', + title: 'Selected Branch', + description: 'Label of the branch that was taken', + }, + branchIndex: { + type: 'number', + title: 'Branch Index', + description: 'Zero-based index of the selected branch', + }, + }, }, }, }, diff --git a/apps/demo/src/app/data/nodes/delay/delay.ts b/apps/demo/src/app/data/nodes/delay/delay.ts index d9582debe..941bc4843 100644 --- a/apps/demo/src/app/data/nodes/delay/delay.ts +++ b/apps/demo/src/app/data/nodes/delay/delay.ts @@ -16,8 +16,20 @@ export const delay: PaletteItem = { type: 'default', bySourceHandle: { success: { - resumedAt: { type: 'string', label: 'Resumed At', description: 'ISO 8601 date-time when the delay ended' }, - delayDuration: { type: 'number', label: 'Delay Duration', description: 'Actual wait time in milliseconds' }, + type: 'object', + properties: { + resumedAt: { + type: 'string', + format: 'date-time', + title: 'Resumed At', + description: 'ISO 8601 date-time when the delay ended', + }, + delayDuration: { + type: 'number', + title: 'Delay Duration', + description: 'Actual wait time in milliseconds', + }, + }, }, }, }, diff --git a/apps/demo/src/app/data/nodes/notification/notification.ts b/apps/demo/src/app/data/nodes/notification/notification.ts index 6a9270c18..06665c14b 100644 --- a/apps/demo/src/app/data/nodes/notification/notification.ts +++ b/apps/demo/src/app/data/nodes/notification/notification.ts @@ -16,12 +16,25 @@ export const notification: PaletteItem = { type: 'default', bySourceHandle: { success: { - sent: { type: 'boolean', label: 'Sent', description: 'Whether the notification was sent successfully' }, - sentAt: { type: 'string', label: 'Sent At', description: 'ISO 8601 date-time when the notification was sent' }, - recipient: { - type: 'string', - label: 'Recipient', - description: 'The email address the notification was sent to', + type: 'object', + properties: { + sent: { + type: 'boolean', + title: 'Sent', + description: 'Whether the notification was sent successfully', + }, + sentAt: { + type: 'string', + format: 'date-time', + title: 'Sent At', + description: 'ISO 8601 date-time when the notification was sent', + }, + recipient: { + type: 'string', + format: 'email', + title: 'Recipient', + description: 'The email address the notification was sent to', + }, }, }, }, diff --git a/apps/demo/src/app/data/nodes/trigger/trigger.ts b/apps/demo/src/app/data/nodes/trigger/trigger.ts index 8e995a11f..a1a4e6d4b 100644 --- a/apps/demo/src/app/data/nodes/trigger/trigger.ts +++ b/apps/demo/src/app/data/nodes/trigger/trigger.ts @@ -19,15 +19,19 @@ export const triggerNode: PaletteItem = { variantRule: undefined, bySourceHandle: { every: { - eventType: { - type: 'string', - label: 'Event Type', - description: 'The type of event that started the workflow', - }, - timestamp: { - type: 'datetime', - label: 'Timestamp', - description: 'ISO 8601 date-time when the trigger fired', + type: 'object', + properties: { + eventType: { + type: 'string', + title: 'Event Type', + description: 'The type of event that started the workflow', + }, + timestamp: { + type: 'string', + format: 'date-time', + title: 'Timestamp', + description: 'ISO 8601 date-time when the trigger fired', + }, }, }, }, @@ -39,20 +43,25 @@ export const triggerNode: PaletteItem = { }, bySourceHandle: { success: { - allDay: { - type: 'boolean', - label: 'All day event', - description: 'The type of event that started the workflow', - }, - startDate: { - type: 'datetime', - label: 'Start date', - description: 'The date when the event was scheduled to start', - }, - endDate: { - type: 'datetime', - label: 'End date', - description: 'The date when the event was scheduled to end', + type: 'object', + properties: { + allDay: { + type: 'boolean', + title: 'All day event', + description: 'The type of event that started the workflow', + }, + startDate: { + type: 'string', + format: 'date-time', + title: 'Start date', + description: 'The date when the event was scheduled to start', + }, + endDate: { + type: 'string', + format: 'date-time', + title: 'End date', + description: 'The date when the event was scheduled to end', + }, }, }, }, @@ -64,10 +73,13 @@ export const triggerNode: PaletteItem = { }, bySourceHandle: { success: { - typeOfEventType: { - type: 'string', - label: 'Type of event type', - description: 'For example: form submission, user action etc.', + type: 'object', + properties: { + typeOfEventType: { + type: 'string', + title: 'Type of event type', + description: 'For example: form submission, user action etc.', + }, }, }, }, diff --git a/packages/sdk/src/node/node-output-schema.ts b/packages/sdk/src/node/node-output-schema.ts index ca4e1c1de..c503023e2 100644 --- a/packages/sdk/src/node/node-output-schema.ts +++ b/packages/sdk/src/node/node-output-schema.ts @@ -1,3 +1,5 @@ +import type { JsonSchema7 } from '@jsonforms/core'; + export type VariableTypePrimitive = 'string' | 'number' | 'boolean' | 'datetime' | 'date'; export type VariableType = VariableTypePrimitive | 'object' | 'array'; @@ -22,7 +24,8 @@ export const OUTPUT_SCHEMA_TYPE = { PROPERTY_VALUE: 'property-value', } as const; -export type OutputPropertiesIndex = Record; +// export type OutputPropertiesIndex = Record; +export type OutputPropertiesIndex = JsonSchema7; export type PropertiesBySourceHandle = { [sourceHandle: string]: OutputPropertiesIndex | undefined; From 481db80fe99fba1e9d24d93bcd2a1fd75c9215a0 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Wed, 26 Aug 2026 15:07:32 +0200 Subject: [PATCH 14/33] feat(sdk): generating suggestions from JsonSchema7 --- apps/demo/src/app/data/nodes/action/action.ts | 12 ++-- .../sdk/src/features/json-form/json-form.tsx | 1 + ... => get-suggestions-from-output-schema.ts} | 12 ++-- .../get-suggestions-node-data.ts | 10 +-- ...flattened-properties-from-json-schema-7.ts | 70 +++++++++++++++++++ .../get-is-supported-variable-type.spec.ts | 50 +++++++++++++ .../get-is-supported-variable-type.ts | 18 +++++ packages/sdk/src/node/node-output-schema.ts | 7 +- 8 files changed, 162 insertions(+), 18 deletions(-) rename packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/{get-suggestions-from-output-properties.ts => get-suggestions-from-output-schema.ts} (69%) create mode 100644 packages/sdk/src/features/variables/utils/json-schema/get-flattened-properties-from-json-schema-7.ts create mode 100644 packages/sdk/src/features/variables/utils/json-schema/get-is-supported-variable-type.spec.ts create mode 100644 packages/sdk/src/features/variables/utils/json-schema/get-is-supported-variable-type.ts diff --git a/apps/demo/src/app/data/nodes/action/action.ts b/apps/demo/src/app/data/nodes/action/action.ts index cea593dfc..a52e6d6d1 100644 --- a/apps/demo/src/app/data/nodes/action/action.ts +++ b/apps/demo/src/app/data/nodes/action/action.ts @@ -18,15 +18,17 @@ export const action: PaletteItem = { success: { type: 'object', properties: { - status: { - type: 'string', - title: 'Status', - description: 'Execution status: success, failure, or skipped', - }, result: { type: 'object', title: 'Result', description: 'The data returned by the action', + properties: { + status: { + type: 'string', + title: 'Status', + description: 'Execution status: success, failure, or skipped', + }, + }, }, }, }, diff --git a/packages/sdk/src/features/json-form/json-form.tsx b/packages/sdk/src/features/json-form/json-form.tsx index 232d1c950..63cd0bd7e 100644 --- a/packages/sdk/src/features/json-form/json-form.tsx +++ b/packages/sdk/src/features/json-form/json-form.tsx @@ -48,6 +48,7 @@ export function JSONForm(props: Props) { ajv={workflowBuilderValidator} {...rest} config={{ readonly }} + readonly={readonly} />
    ); diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-output-properties.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-output-schema.ts similarity index 69% rename from packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-output-properties.ts rename to packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-output-schema.ts index 3a2e3fa2f..5837bbdfe 100644 --- a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-output-properties.ts +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-output-schema.ts @@ -1,11 +1,13 @@ -import type { OutputPropertiesIndex } from '../../../../../node/node-output-schema'; +import type { JsonSchema7 } from '@jsonforms/core'; + import { filterEmpty } from '../../../../../utils/array'; import { keyToLabel, truncate } from '../../../../../utils/text'; import type { VariableSuggestion } from '../../../components/variable-text/variable-text.types'; +import { getFlattenedPropertiesFromJsonSchema7 } from '../../../utils/json-schema/get-flattened-properties-from-json-schema-7'; import { getVariableReferenceWithoutBracketsForNode } from '../../../utils/keys/get-variable-reference-without-brackets-for-node'; type Params = { - properties: OutputPropertiesIndex; + properties: JsonSchema7; nodeId: string; nodeLabel: string; }; @@ -13,8 +15,10 @@ type Params = { /** * Produces a list of suggestions generated from `outputPropertiesIndex` (used by node definition variants). */ -export function getSuggestionsFromOutputProperties({ nodeId, nodeLabel, properties }: Params): VariableSuggestion[] { - return Object.entries(properties) +export function getSuggestionsFromOutputSchema({ nodeId, nodeLabel, properties }: Params): VariableSuggestion[] { + const flattenedProperties = getFlattenedPropertiesFromJsonSchema7(properties); + + return Object.entries(flattenedProperties) .map(([propertyKey, property]) => property ? { diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts index 60cd3b14e..30d070249 100644 --- a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts @@ -6,7 +6,7 @@ import { getByPath } from '../../../../../utils/object'; import type { VariablesIndex } from '../../../types'; import { getNodeLabelForVariable } from '../../../utils/diagram/get-node-label-for-variable'; import { SUGGESTION_NODE_TYPE, type SuggestionNodeType, type SuggestionsBySourceHandle } from '../../types'; -import { getSuggestionsFromOutputProperties } from './get-suggestions-from-output-properties'; +import { getSuggestionsFromOutputSchema } from './get-suggestions-from-output-schema'; import { getSuggestionsFromVariableIndex } from './get-suggestions-from-variables-index'; type Params = { @@ -43,10 +43,10 @@ export function getSuggestionsNodeData({ definition, node }: Params): Response { if (properties) { bySourceHandle[sourceHandle] = [ ...(bySourceHandle[sourceHandle] || []), - ...getSuggestionsFromOutputProperties({ + ...getSuggestionsFromOutputSchema({ nodeId: node.id, nodeLabel, - properties: properties, + properties, }), ]; } @@ -78,10 +78,10 @@ export function getSuggestionsNodeData({ definition, node }: Params): Response { if (properties) { bySourceHandle[sourceHandle] = [ ...(bySourceHandle[sourceHandle] || []), - ...getSuggestionsFromOutputProperties({ + ...getSuggestionsFromOutputSchema({ nodeId: node.id, nodeLabel, - properties: properties, + properties, }), ]; } diff --git a/packages/sdk/src/features/variables/utils/json-schema/get-flattened-properties-from-json-schema-7.ts b/packages/sdk/src/features/variables/utils/json-schema/get-flattened-properties-from-json-schema-7.ts new file mode 100644 index 000000000..fa785579a --- /dev/null +++ b/packages/sdk/src/features/variables/utils/json-schema/get-flattened-properties-from-json-schema-7.ts @@ -0,0 +1,70 @@ +import type { JsonSchema7 } from '@jsonforms/core'; + +import type { FlattenedPropertiesIndex } from '../../../../node/node-output-schema'; +import { getIsSupportedVariableType } from './get-is-supported-variable-type'; + +type Params = { + properties: JsonSchema7['properties']; + namePrefix: string; + rootSchema: JsonSchema7; +}; + +function getFlattenedPropertiesFromJsonSchema7Properties({ + properties, + namePrefix, + rootSchema, +}: Params): FlattenedPropertiesIndex { + if (!properties) { + return {}; + } + + const fields = Object.entries(properties).reduce((stack: FlattenedPropertiesIndex, [fieldName, field]) => { + const propertyName = [namePrefix, fieldName].filter(Boolean).join('.'); + + if (getIsSupportedVariableType(field.type)) { + const isDate = field.type === 'string' && field.format === 'date-time'; + stack[propertyName] = { + type: isDate ? 'datetime' : field.type, + label: field.title || '', + description: field.description || '', + }; + } + + if (field.type === 'object') { + const objectFields = getFlattenedPropertiesFromJsonSchema7Properties({ + properties: field.properties, + namePrefix: propertyName, + rootSchema, + }); + + stack = { + ...stack, + ...objectFields, + }; + } + + return stack; + }, {}); + + return fields; +} + +export function getFlattenedPropertiesFromJsonSchema7(schema: JsonSchema7): FlattenedPropertiesIndex { + if (schema.type === 'object') { + const fields = getFlattenedPropertiesFromJsonSchema7Properties({ + properties: schema.properties, + namePrefix: '', + rootSchema: schema, + }); + + return { + response: { + type: 'object', + label: 'Response', + }, + ...fields, + }; + } + + return {}; +} diff --git a/packages/sdk/src/features/variables/utils/json-schema/get-is-supported-variable-type.spec.ts b/packages/sdk/src/features/variables/utils/json-schema/get-is-supported-variable-type.spec.ts new file mode 100644 index 000000000..ff8e26996 --- /dev/null +++ b/packages/sdk/src/features/variables/utils/json-schema/get-is-supported-variable-type.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import type { VariableType } from '../../../../node/node-output-schema'; +import { getIsSupportedVariableType } from './get-is-supported-variable-type'; + +const supportedTypes: VariableType[] = ['string', 'number', 'boolean', 'datetime', 'date', 'object', 'array']; + +describe('getIsSupportedVariableType', () => { + it.each(supportedTypes)('should accept %s', (type) => { + expect(getIsSupportedVariableType(type)).toBe(true); + }); + + it('should reject json schema types outside VariableType', () => { + expect(getIsSupportedVariableType('integer')).toBe(false); + expect(getIsSupportedVariableType('null')).toBe(false); + }); + + it('should reject unknown strings', () => { + expect(getIsSupportedVariableType('')).toBe(false); + expect(getIsSupportedVariableType('String')).toBe(false); + expect(getIsSupportedVariableType('str')).toBe(false); + }); + + it('should reject Object.prototype keys', () => { + expect(getIsSupportedVariableType('toString')).toBe(false); + expect(getIsSupportedVariableType('constructor')).toBe(false); + expect(getIsSupportedVariableType('hasOwnProperty')).toBe(false); + }); + + it('should reject non-string values', () => { + // eslint-disable-next-line unicorn/no-useless-undefined + expect(getIsSupportedVariableType(undefined)).toBe(false); + expect(getIsSupportedVariableType(null)).toBe(false); + expect(getIsSupportedVariableType(['string'])).toBe(false); + expect(getIsSupportedVariableType(1)).toBe(false); + expect(getIsSupportedVariableType({ type: 'string' })).toBe(false); + }); + + it('should narrow the type', () => { + const type: unknown = 'string'; + + if (getIsSupportedVariableType(type)) { + const narrowed: VariableType = type; + + expect(narrowed).toBe('string'); + } else { + expect.unreachable(); + } + }); +}); diff --git a/packages/sdk/src/features/variables/utils/json-schema/get-is-supported-variable-type.ts b/packages/sdk/src/features/variables/utils/json-schema/get-is-supported-variable-type.ts new file mode 100644 index 000000000..9c63fc6c2 --- /dev/null +++ b/packages/sdk/src/features/variables/utils/json-schema/get-is-supported-variable-type.ts @@ -0,0 +1,18 @@ +import type { VariableType } from '../../../../node/node-output-schema'; + +/* + Keys must cover VariableType - `satisfies` breaks the build when a new type is added. +*/ +const SUPPORTED_VARIABLE_TYPES = { + string: true, + number: true, + boolean: true, + datetime: true, + date: true, + object: true, + array: true, +} as const satisfies Record; + +export function getIsSupportedVariableType(type: unknown): type is VariableType { + return typeof type === 'string' && Object.hasOwn(SUPPORTED_VARIABLE_TYPES, type); +} diff --git a/packages/sdk/src/node/node-output-schema.ts b/packages/sdk/src/node/node-output-schema.ts index c503023e2..f02e20b54 100644 --- a/packages/sdk/src/node/node-output-schema.ts +++ b/packages/sdk/src/node/node-output-schema.ts @@ -24,12 +24,11 @@ export const OUTPUT_SCHEMA_TYPE = { PROPERTY_VALUE: 'property-value', } as const; -// export type OutputPropertiesIndex = Record; -export type OutputPropertiesIndex = JsonSchema7; +export type FlattenedPropertiesIndex = Record; export type PropertiesBySourceHandle = { - [sourceHandle: string]: OutputPropertiesIndex | undefined; - every?: OutputPropertiesIndex; + [sourceHandle: string]: JsonSchema7 | undefined; + every?: JsonSchema7; }; export type OutputVariant = { From 4d5c51ef6120af241084d146bd086b9093531f38 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Wed, 26 Aug 2026 16:20:32 +0200 Subject: [PATCH 15/33] feat(sdk): better from builder variant --- .../src/app/data/nodes/trigger/trigger.ts | 6 +- .../get-suggestions-node-data.ts | 97 ++++++++++--------- packages/sdk/src/node/node-output-schema.ts | 40 ++++---- 3 files changed, 74 insertions(+), 69 deletions(-) diff --git a/apps/demo/src/app/data/nodes/trigger/trigger.ts b/apps/demo/src/app/data/nodes/trigger/trigger.ts index a1a4e6d4b..d5e3cd709 100644 --- a/apps/demo/src/app/data/nodes/trigger/trigger.ts +++ b/apps/demo/src/app/data/nodes/trigger/trigger.ts @@ -38,8 +38,7 @@ export const triggerNode: PaletteItem = { }, { variantRule: { - dataPropertyName: 'type', - dataPropertyValue: 'timeBasedTrigger', + onlyIfPropertyNameEquals: { path: 'type', value: 'timeBasedTrigger' }, }, bySourceHandle: { success: { @@ -68,8 +67,7 @@ export const triggerNode: PaletteItem = { }, { variantRule: { - dataPropertyName: 'type', - dataPropertyValue: 'eventBasedTrigger', + onlyIfPropertyNameEquals: { path: 'type', value: 'eventBasedTrigger' }, }, bySourceHandle: { success: { diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts index 30d070249..a229acf86 100644 --- a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts @@ -62,67 +62,70 @@ export function getSuggestionsNodeData({ definition, node }: Params): Response { if (definition.outputSchema.type === OUTPUT_SCHEMA_TYPE.VARIANT) { const variantsMatchingDataPropertyValue = Object.values(definition.outputSchema.variants) .filter((variant) => { - if (!variant?.variantRule) { - return true; - } + if (variant.variantRule && 'onlyIfPropertyNameEquals' in variant.variantRule) { + const { path, value } = variant.variantRule.onlyIfPropertyNameEquals; + const isValid = getByPath(node.data.properties, path) === value; - const isValidPropertyValue = - node.data.properties[variant.variantRule.dataPropertyName] === variant.variantRule.dataPropertyValue; + if (!isValid) { + return false; + } + } - return isValidPropertyValue; + // No rule is always a match + return true; }) .filter(filterEmpty); for (const variant of variantsMatchingDataPropertyValue) { - for (const [sourceHandle, properties] of Object.entries(variant.bySourceHandle)) { - if (properties) { - bySourceHandle[sourceHandle] = [ - ...(bySourceHandle[sourceHandle] || []), - ...getSuggestionsFromOutputSchema({ - nodeId: node.id, - nodeLabel, - properties, - }), - ]; + // Default variables by sourceHandle + if ('bySourceHandle' in variant) { + for (const [sourceHandle, properties] of Object.entries(variant.bySourceHandle)) { + if (properties) { + bySourceHandle[sourceHandle] = [ + ...(bySourceHandle[sourceHandle] || []), + ...getSuggestionsFromOutputSchema({ + nodeId: node.id, + nodeLabel, + properties, + }), + ]; + } } } - } - return { - type: SUGGESTION_NODE_TYPE.CUSTOM, - bySourceHandle, - }; - } + if ( + variant.variantRule && + 'fromValueOfPropertyPath' in variant.variantRule && + variant.variantRule.fromValueOfPropertyPath + ) { + const variablesIndex = getByPath( + node.data.properties, + variant.variantRule.fromValueOfPropertyPath, + ) as unknown as VariablesIndex | undefined; + + const sourceHandlesToAdd = variant.variantRule.toSourceHandles; + + // TODO: Add better guard + // It's an output of schema-builder control + if (variablesIndex) { + const suggestions = getSuggestionsFromVariableIndex({ + variablesIndex, + nodeId: node.id, + nodeLabel, + variant: 'nodes', + }); - // Build with schema builder control - if (definition?.outputSchema.type === OUTPUT_SCHEMA_TYPE.PROPERTY_VALUE) { - // TODO: Add better guard - const variablesIndex = getByPath(node.data.properties, definition.outputSchema.propertyPath) as unknown as - | VariablesIndex - | undefined; - - if (!variablesIndex) { - return { - ...EMPTY_NODE_SUGGESTIONS, - /* - It's custom because it has built-in controls, and even if it's empty, that doesn't mean the others are empty too. - */ - type: SUGGESTION_NODE_TYPE.CUSTOM, - }; + for (const sourceHandle of sourceHandlesToAdd) { + console.log(sourceHandle); + bySourceHandle[sourceHandle] = [...(bySourceHandle[sourceHandle] || []), ...suggestions]; + } + } + } } - const suggestions = getSuggestionsFromVariableIndex({ - variablesIndex, - nodeId: node.id, - nodeLabel, - variant: 'nodes', - }); - return { type: SUGGESTION_NODE_TYPE.CUSTOM, - bySourceHandle: { - success: suggestions, - }, + bySourceHandle, }; } diff --git a/packages/sdk/src/node/node-output-schema.ts b/packages/sdk/src/node/node-output-schema.ts index f02e20b54..496eade79 100644 --- a/packages/sdk/src/node/node-output-schema.ts +++ b/packages/sdk/src/node/node-output-schema.ts @@ -21,7 +21,6 @@ export type OutputProperty = { export const OUTPUT_SCHEMA_TYPE = { DEFAULT: 'default', VARIANT: 'variant', - PROPERTY_VALUE: 'property-value', } as const; export type FlattenedPropertiesIndex = Record; @@ -31,15 +30,28 @@ export type PropertiesBySourceHandle = { every?: JsonSchema7; }; -export type OutputVariant = { - variantRule: - | undefined - | { - dataPropertyName: string; - dataPropertyValue: string; +export type OutputVariant = + | { + variantRule: + | undefined + | { + onlyIfPropertyNameEquals: { path: string; value: string | number }; + }; + bySourceHandle: PropertiesBySourceHandle; + } + | { + variantRule: { + onlyIfPropertyNameEquals: { path: string; value: string | number }; + fromValueOfPropertyPath: string; + toSourceHandles: string[]; }; - bySourceHandle: PropertiesBySourceHandle; -}; + } + | { + variantRule: { + fromValueOfPropertyPath: string; + toSourceHandles: string[]; + }; + }; export type NodeOutputSchemaDefault = { type: 'default'; @@ -54,12 +66,4 @@ export type NodeOutputSchemaVariant = { variants: OutputVariant[]; }; -export type NodeOutputSchemaPropertyValue = { - /* - Value is built dynamically in the node property. - */ - type: 'property-value'; - propertyPath: string; -}; - -export type NodeOutputSchema = NodeOutputSchemaDefault | NodeOutputSchemaVariant | NodeOutputSchemaPropertyValue; +export type NodeOutputSchema = NodeOutputSchemaDefault | NodeOutputSchemaVariant; From 6e30e06f23fad3bbca8941def7d6c075462541b0 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Wed, 26 Aug 2026 22:20:04 +0200 Subject: [PATCH 16/33] chore(sdk): ouput variables in additional files --- apps/ai-studio/src/nodes/ai-agent/index.ts | 13 +--- apps/demo/CLAUDE.md | 2 +- apps/demo/src/app/app.tsx | 2 +- apps/demo/src/app/data/nodes/action/action.ts | 34 +-------- .../app/data/nodes/action/schema-output.ts | 34 +++++++++ .../src/app/data/nodes/ai-agent/ai-agent.ts | 27 +------ .../app/data/nodes/ai-agent/schema-output.ts | 27 +++++++ .../app/data/nodes/conditional/conditional.ts | 22 +----- .../data/nodes/conditional/schema-output.ts | 22 ++++++ .../src/app/data/nodes/decision/decision.ts | 22 +----- .../app/data/nodes/decision/schema-output.ts | 22 ++++++ apps/demo/src/app/data/nodes/delay/delay.ts | 23 +----- .../src/app/data/nodes/delay/schema-output.ts | 23 ++++++ .../data/nodes/notification/notification.ts | 29 +------- .../data/nodes/notification/schema-output.ts | 29 ++++++++ .../app/data/nodes/trigger/schema-output.ts | 74 +++++++++++++++++++ .../src/app/data/nodes/trigger/trigger.ts | 74 +------------------ .../docs/guides/use-variable-picker.mdx | 10 +-- .../generic-execution-core.decision-log.md | 2 +- .../get-suggestions-node-data.ts | 10 +-- .../get-available-variables-by-node-id.ts | 2 +- packages/sdk/src/index.ts | 9 +++ .../deprecated-node-output-schema.ts | 28 +++++++ packages/sdk/src/node/node-data.ts | 13 +++- packages/sdk/src/node/node-output-schema.ts | 6 +- packages/types/src/node-output-schema.ts | 2 +- 26 files changed, 313 insertions(+), 248 deletions(-) create mode 100644 apps/demo/src/app/data/nodes/action/schema-output.ts create mode 100644 apps/demo/src/app/data/nodes/ai-agent/schema-output.ts create mode 100644 apps/demo/src/app/data/nodes/conditional/schema-output.ts create mode 100644 apps/demo/src/app/data/nodes/decision/schema-output.ts create mode 100644 apps/demo/src/app/data/nodes/delay/schema-output.ts create mode 100644 apps/demo/src/app/data/nodes/notification/schema-output.ts create mode 100644 apps/demo/src/app/data/nodes/trigger/schema-output.ts create mode 100644 packages/sdk/src/node/deprecated/deprecated-node-output-schema.ts diff --git a/apps/ai-studio/src/nodes/ai-agent/index.ts b/apps/ai-studio/src/nodes/ai-agent/index.ts index 9c3cfa23c..d74e375c6 100644 --- a/apps/ai-studio/src/nodes/ai-agent/index.ts +++ b/apps/ai-studio/src/nodes/ai-agent/index.ts @@ -16,17 +16,8 @@ export const aiAgentPaletteItem: PaletteItem = { // Lets `{{ nodes..response }}` references resolve to a real mention instead of a "missing mention" pill. outputSchema: { type: 'default', - bySourceHandle: { - success: { - type: 'object', - properties: { - response: { - type: 'string', - title: 'Response', - description: 'The text generated by the AI model', - }, - }, - }, + properties: { + response: { type: 'string', label: 'Response', description: 'The text generated by the AI model' }, }, }, }; diff --git a/apps/demo/CLAUDE.md b/apps/demo/CLAUDE.md index 60cdddbf9..2198b5c4a 100644 --- a/apps/demo/CLAUDE.md +++ b/apps/demo/CLAUDE.md @@ -12,7 +12,7 @@ Each node type lives in `src/app/data/nodes//`. Canonical pattern is | File | Purpose | | ---------------------------- | ------------------------------------------------------------------------------------- | -| `.ts` | `PaletteItem` — label, type, icon, defaults, schemas, optional `outputSchema` | +| `.ts` | `PaletteItem` — label, type, icon, defaults, schemas, optional `schemaOutput` | | `schema.ts` | JSON Schema with `satisfies NodeSchema`; spreads `sharedProperties` from the SDK | | `uischema.ts` | UI Schema using `getScope` for type-safe scopes | | `default-properties-data.ts` | Defaults aligned with the schema | diff --git a/apps/demo/src/app/app.tsx b/apps/demo/src/app/app.tsx index 12d598417..8739821eb 100644 --- a/apps/demo/src/app/app.tsx +++ b/apps/demo/src/app/app.tsx @@ -5,11 +5,11 @@ import type { WorkflowBuilderNodeTemplates, WorkflowBuilderReactFlowProps, } from '@workflowbuilder/sdk'; +import { showSnackbar } from '@workflowbuilder/sdk'; import { SnackbarType } from '@workflowbuilder/ui'; import '@workflowbuilder/sdk/style.css'; -import { showSnackbar } from '../../../../packages/sdk/src/utils/show-snackbar'; import { DashedEdge } from './components/dashed-edge/dashed-edge'; import { MultiPortNodeTemplate } from './components/multi-port-node/multi-port-node-template'; import { demoPaletteItems } from './data/palette'; diff --git a/apps/demo/src/app/data/nodes/action/action.ts b/apps/demo/src/app/data/nodes/action/action.ts index a52e6d6d1..54470593e 100644 --- a/apps/demo/src/app/data/nodes/action/action.ts +++ b/apps/demo/src/app/data/nodes/action/action.ts @@ -2,6 +2,7 @@ import type { PaletteItem } from '@workflowbuilder/sdk'; import { defaultPropertiesData } from './default-properties-data'; import { type ActionNodeSchema, schema } from './schema'; +import { schemaOutput } from './schema-output'; import { uischema } from './uischema'; export const action: PaletteItem = { @@ -11,37 +12,6 @@ export const action: PaletteItem = { description: 'node.action.description', defaultPropertiesData, schema, + schemaOutput, uischema, - outputSchema: { - type: 'default', - bySourceHandle: { - success: { - type: 'object', - properties: { - result: { - type: 'object', - title: 'Result', - description: 'The data returned by the action', - properties: { - status: { - type: 'string', - title: 'Status', - description: 'Execution status: success, failure, or skipped', - }, - }, - }, - }, - }, - error: { - type: 'object', - properties: { - errorMessage: { - type: 'string', - title: 'Error Message', - description: 'Error details if the action failed', - }, - }, - }, - }, - }, }; diff --git a/apps/demo/src/app/data/nodes/action/schema-output.ts b/apps/demo/src/app/data/nodes/action/schema-output.ts new file mode 100644 index 000000000..f36a9bd73 --- /dev/null +++ b/apps/demo/src/app/data/nodes/action/schema-output.ts @@ -0,0 +1,34 @@ +import type { NodeSchemaOutput } from '@workflowbuilder/sdk'; + +export const schemaOutput: NodeSchemaOutput = { + type: 'default', + bySourceHandle: { + success: { + type: 'object', + properties: { + result: { + type: 'object', + title: 'Result', + description: 'The data returned by the action', + properties: { + status: { + type: 'string', + title: 'Status', + description: 'Execution status: success, failure, or skipped', + }, + }, + }, + }, + }, + error: { + type: 'object', + properties: { + errorMessage: { + type: 'string', + title: 'Error Message', + description: 'Error details if the action failed', + }, + }, + }, + }, +}; diff --git a/apps/demo/src/app/data/nodes/ai-agent/ai-agent.ts b/apps/demo/src/app/data/nodes/ai-agent/ai-agent.ts index fe65e7454..a37524e20 100644 --- a/apps/demo/src/app/data/nodes/ai-agent/ai-agent.ts +++ b/apps/demo/src/app/data/nodes/ai-agent/ai-agent.ts @@ -3,6 +3,7 @@ import type { PaletteItem } from '@workflowbuilder/sdk'; import { defaultPropertiesData } from './default-properties-data'; import { schema } from './schema'; +import { schemaOutput } from './schema-output'; import { uischema } from './uischema'; export const aiAgent: PaletteItem = { @@ -13,30 +14,6 @@ export const aiAgent: PaletteItem = { templateType: NodeType.AiNode, defaultPropertiesData, schema, + schemaOutput, uischema, - outputSchema: { - type: 'default', - bySourceHandle: { - success: { - type: 'object', - properties: { - response: { - type: 'string', - title: 'Response', - description: 'The text generated by the AI model', - }, - tokensUsed: { - type: 'number', - title: 'Tokens Used', - description: 'Total number of tokens consumed', - }, - model: { - type: 'string', - title: 'Model', - description: 'The AI model that was used', - }, - }, - }, - }, - }, }; diff --git a/apps/demo/src/app/data/nodes/ai-agent/schema-output.ts b/apps/demo/src/app/data/nodes/ai-agent/schema-output.ts new file mode 100644 index 000000000..1a687710b --- /dev/null +++ b/apps/demo/src/app/data/nodes/ai-agent/schema-output.ts @@ -0,0 +1,27 @@ +import type { NodeSchemaOutput } from '@workflowbuilder/sdk'; + +export const schemaOutput: NodeSchemaOutput = { + type: 'default', + bySourceHandle: { + success: { + type: 'object', + properties: { + response: { + type: 'string', + title: 'Response', + description: 'The text generated by the AI model', + }, + tokensUsed: { + type: 'number', + title: 'Tokens Used', + description: 'Total number of tokens consumed', + }, + model: { + type: 'string', + title: 'Model', + description: 'The AI model that was used', + }, + }, + }, + }, +}; diff --git a/apps/demo/src/app/data/nodes/conditional/conditional.ts b/apps/demo/src/app/data/nodes/conditional/conditional.ts index 042f12ab9..26ad99f6e 100644 --- a/apps/demo/src/app/data/nodes/conditional/conditional.ts +++ b/apps/demo/src/app/data/nodes/conditional/conditional.ts @@ -2,6 +2,7 @@ import type { PaletteItem } from '@workflowbuilder/sdk'; import { defaultPropertiesData } from './default-properties-data'; import { type ConditionalNodeSchema, schema } from './schema'; +import { schemaOutput } from './schema-output'; import { uischema } from './uischema'; export const conditional: PaletteItem = { @@ -11,25 +12,6 @@ export const conditional: PaletteItem = { icon: 'ListChecks', defaultPropertiesData, schema, + schemaOutput, uischema, - outputSchema: { - type: 'default', - bySourceHandle: { - success: { - type: 'object', - properties: { - result: { - type: 'boolean', - title: 'Result', - description: 'Whether the condition evaluated to true or false', - }, - matchedCondition: { - type: 'string', - title: 'Matched Condition', - description: 'The condition expression that matched', - }, - }, - }, - }, - }, }; diff --git a/apps/demo/src/app/data/nodes/conditional/schema-output.ts b/apps/demo/src/app/data/nodes/conditional/schema-output.ts new file mode 100644 index 000000000..614f01638 --- /dev/null +++ b/apps/demo/src/app/data/nodes/conditional/schema-output.ts @@ -0,0 +1,22 @@ +import type { NodeSchemaOutput } from '@workflowbuilder/sdk'; + +export const schemaOutput: NodeSchemaOutput = { + type: 'default', + bySourceHandle: { + success: { + type: 'object', + properties: { + result: { + type: 'boolean', + title: 'Result', + description: 'Whether the condition evaluated to true or false', + }, + matchedCondition: { + type: 'string', + title: 'Matched Condition', + description: 'The condition expression that matched', + }, + }, + }, + }, +}; diff --git a/apps/demo/src/app/data/nodes/decision/decision.ts b/apps/demo/src/app/data/nodes/decision/decision.ts index d85d3e622..1194bfacb 100644 --- a/apps/demo/src/app/data/nodes/decision/decision.ts +++ b/apps/demo/src/app/data/nodes/decision/decision.ts @@ -3,6 +3,7 @@ import type { PaletteItem } from '@workflowbuilder/sdk'; import { defaultPropertiesData } from './default-properties-data'; import { type DecisionNodeSchema, schema } from './schema'; +import { schemaOutput } from './schema-output'; import { uischema } from './uischema'; export const decision: PaletteItem = { @@ -13,25 +14,6 @@ export const decision: PaletteItem = { templateType: NodeType.DecisionNode, defaultPropertiesData, schema, + schemaOutput, uischema, - outputSchema: { - type: 'default', - bySourceHandle: { - every: { - type: 'object', - properties: { - selectedBranch: { - type: 'string', - title: 'Selected Branch', - description: 'Label of the branch that was taken', - }, - branchIndex: { - type: 'number', - title: 'Branch Index', - description: 'Zero-based index of the selected branch', - }, - }, - }, - }, - }, }; diff --git a/apps/demo/src/app/data/nodes/decision/schema-output.ts b/apps/demo/src/app/data/nodes/decision/schema-output.ts new file mode 100644 index 000000000..e887de016 --- /dev/null +++ b/apps/demo/src/app/data/nodes/decision/schema-output.ts @@ -0,0 +1,22 @@ +import type { NodeSchemaOutput } from '@workflowbuilder/sdk'; + +export const schemaOutput: NodeSchemaOutput = { + type: 'default', + bySourceHandle: { + every: { + type: 'object', + properties: { + selectedBranch: { + type: 'string', + title: 'Selected Branch', + description: 'Label of the branch that was taken', + }, + branchIndex: { + type: 'number', + title: 'Branch Index', + description: 'Zero-based index of the selected branch', + }, + }, + }, + }, +}; diff --git a/apps/demo/src/app/data/nodes/delay/delay.ts b/apps/demo/src/app/data/nodes/delay/delay.ts index 941bc4843..2b088d9c2 100644 --- a/apps/demo/src/app/data/nodes/delay/delay.ts +++ b/apps/demo/src/app/data/nodes/delay/delay.ts @@ -2,6 +2,7 @@ import type { PaletteItem } from '@workflowbuilder/sdk'; import { defaultPropertiesData } from './default-properties-data'; import { type DelayNodeSchema, schema } from './schema'; +import { schemaOutput } from './schema-output'; import { uischema } from './uischema'; export const delay: PaletteItem = { @@ -11,26 +12,6 @@ export const delay: PaletteItem = { icon: 'Timer', defaultPropertiesData, schema, + schemaOutput, uischema, - outputSchema: { - type: 'default', - bySourceHandle: { - success: { - type: 'object', - properties: { - resumedAt: { - type: 'string', - format: 'date-time', - title: 'Resumed At', - description: 'ISO 8601 date-time when the delay ended', - }, - delayDuration: { - type: 'number', - title: 'Delay Duration', - description: 'Actual wait time in milliseconds', - }, - }, - }, - }, - }, }; diff --git a/apps/demo/src/app/data/nodes/delay/schema-output.ts b/apps/demo/src/app/data/nodes/delay/schema-output.ts new file mode 100644 index 000000000..ef89bbed0 --- /dev/null +++ b/apps/demo/src/app/data/nodes/delay/schema-output.ts @@ -0,0 +1,23 @@ +import type { NodeSchemaOutput } from '@workflowbuilder/sdk'; + +export const schemaOutput: NodeSchemaOutput = { + type: 'default', + bySourceHandle: { + success: { + type: 'object', + properties: { + resumedAt: { + type: 'string', + format: 'date-time', + title: 'Resumed At', + description: 'ISO 8601 date-time when the delay ended', + }, + delayDuration: { + type: 'number', + title: 'Delay Duration', + description: 'Actual wait time in milliseconds', + }, + }, + }, + }, +}; diff --git a/apps/demo/src/app/data/nodes/notification/notification.ts b/apps/demo/src/app/data/nodes/notification/notification.ts index 06665c14b..35be57d98 100644 --- a/apps/demo/src/app/data/nodes/notification/notification.ts +++ b/apps/demo/src/app/data/nodes/notification/notification.ts @@ -2,6 +2,7 @@ import type { PaletteItem } from '@workflowbuilder/sdk'; import { defaultPropertiesData } from './default-properties-data'; import { type NotificationNodeSchema, schema } from './schema'; +import { schemaOutput } from './schema-output'; import { uischema } from './uischema'; export const notification: PaletteItem = { @@ -11,32 +12,6 @@ export const notification: PaletteItem = { icon: 'PaperPlaneRight', defaultPropertiesData, schema, + schemaOutput, uischema, - outputSchema: { - type: 'default', - bySourceHandle: { - success: { - type: 'object', - properties: { - sent: { - type: 'boolean', - title: 'Sent', - description: 'Whether the notification was sent successfully', - }, - sentAt: { - type: 'string', - format: 'date-time', - title: 'Sent At', - description: 'ISO 8601 date-time when the notification was sent', - }, - recipient: { - type: 'string', - format: 'email', - title: 'Recipient', - description: 'The email address the notification was sent to', - }, - }, - }, - }, - }, }; diff --git a/apps/demo/src/app/data/nodes/notification/schema-output.ts b/apps/demo/src/app/data/nodes/notification/schema-output.ts new file mode 100644 index 000000000..bc6edc738 --- /dev/null +++ b/apps/demo/src/app/data/nodes/notification/schema-output.ts @@ -0,0 +1,29 @@ +import type { NodeSchemaOutput } from '@workflowbuilder/sdk'; + +export const schemaOutput: NodeSchemaOutput = { + type: 'default', + bySourceHandle: { + success: { + type: 'object', + properties: { + sent: { + type: 'boolean', + title: 'Sent', + description: 'Whether the notification was sent successfully', + }, + sentAt: { + type: 'string', + format: 'date-time', + title: 'Sent At', + description: 'ISO 8601 date-time when the notification was sent', + }, + recipient: { + type: 'string', + format: 'email', + title: 'Recipient', + description: 'The email address the notification was sent to', + }, + }, + }, + }, +}; diff --git a/apps/demo/src/app/data/nodes/trigger/schema-output.ts b/apps/demo/src/app/data/nodes/trigger/schema-output.ts new file mode 100644 index 000000000..86a94b592 --- /dev/null +++ b/apps/demo/src/app/data/nodes/trigger/schema-output.ts @@ -0,0 +1,74 @@ +import type { NodeSchemaOutput } from '@workflowbuilder/sdk'; + +export const schemaOutput: NodeSchemaOutput = { + type: 'variant', + variants: [ + { + variantRule: undefined, + bySourceHandle: { + every: { + type: 'object', + properties: { + eventType: { + type: 'string', + title: 'Event Type', + description: 'The type of event that started the workflow', + }, + timestamp: { + type: 'string', + format: 'date-time', + title: 'Timestamp', + description: 'ISO 8601 date-time when the trigger fired', + }, + }, + }, + }, + }, + { + variantRule: { + onlyIfPropertyNameEquals: { path: 'type', value: 'timeBasedTrigger' }, + }, + bySourceHandle: { + success: { + type: 'object', + properties: { + allDay: { + type: 'boolean', + title: 'All day event', + description: 'The type of event that started the workflow', + }, + startDate: { + type: 'string', + format: 'date-time', + title: 'Start date', + description: 'The date when the event was scheduled to start', + }, + endDate: { + type: 'string', + format: 'date-time', + title: 'End date', + description: 'The date when the event was scheduled to end', + }, + }, + }, + }, + }, + { + variantRule: { + onlyIfPropertyNameEquals: { path: 'type', value: 'eventBasedTrigger' }, + }, + bySourceHandle: { + success: { + type: 'object', + properties: { + typeOfEventType: { + type: 'string', + title: 'Type of event type', + description: 'For example: form submission, user action etc.', + }, + }, + }, + }, + }, + ], +}; diff --git a/apps/demo/src/app/data/nodes/trigger/trigger.ts b/apps/demo/src/app/data/nodes/trigger/trigger.ts index d5e3cd709..a0b6f1550 100644 --- a/apps/demo/src/app/data/nodes/trigger/trigger.ts +++ b/apps/demo/src/app/data/nodes/trigger/trigger.ts @@ -2,6 +2,7 @@ import type { PaletteItem } from '@workflowbuilder/sdk'; import { defaultPropertiesData } from './default-properties-data'; import { type TriggerNodeSchema, schema } from './schema'; +import { schemaOutput } from './schema-output'; import { uischema } from './uischema'; export const triggerNode: PaletteItem = { @@ -11,77 +12,6 @@ export const triggerNode: PaletteItem = { icon: 'Lightning', defaultPropertiesData, schema, + schemaOutput, uischema, - outputSchema: { - type: 'variant', - variants: [ - { - variantRule: undefined, - bySourceHandle: { - every: { - type: 'object', - properties: { - eventType: { - type: 'string', - title: 'Event Type', - description: 'The type of event that started the workflow', - }, - timestamp: { - type: 'string', - format: 'date-time', - title: 'Timestamp', - description: 'ISO 8601 date-time when the trigger fired', - }, - }, - }, - }, - }, - { - variantRule: { - onlyIfPropertyNameEquals: { path: 'type', value: 'timeBasedTrigger' }, - }, - bySourceHandle: { - success: { - type: 'object', - properties: { - allDay: { - type: 'boolean', - title: 'All day event', - description: 'The type of event that started the workflow', - }, - startDate: { - type: 'string', - format: 'date-time', - title: 'Start date', - description: 'The date when the event was scheduled to start', - }, - endDate: { - type: 'string', - format: 'date-time', - title: 'End date', - description: 'The date when the event was scheduled to end', - }, - }, - }, - }, - }, - { - variantRule: { - onlyIfPropertyNameEquals: { path: 'type', value: 'eventBasedTrigger' }, - }, - bySourceHandle: { - success: { - type: 'object', - properties: { - typeOfEventType: { - type: 'string', - title: 'Type of event type', - description: 'For example: form submission, user action etc.', - }, - }, - }, - }, - }, - ], - }, }; diff --git a/apps/docs/src/content/docs/guides/use-variable-picker.mdx b/apps/docs/src/content/docs/guides/use-variable-picker.mdx index 5118bf50e..d00fe73b3 100644 --- a/apps/docs/src/content/docs/guides/use-variable-picker.mdx +++ b/apps/docs/src/content/docs/guides/use-variable-picker.mdx @@ -24,7 +24,7 @@ The picker is wired up on these built-in fields: The chip is the editor view; the diagram stores the raw form `{{nodes..}}`. -Suggestions come only from **ancestor** nodes - nodes reachable by following edges backward from the selected node. Each suggestion is built from the ancestor's `outputSchema`, so a node only appears in the picker if it declares one. +Suggestions come only from **ancestor** nodes - nodes reachable by following edges backward from the selected node. Each suggestion is built from the ancestor's `schemaOutput`, so a node only appears in the picker if it declares one. ## Syntax @@ -57,7 +57,7 @@ The SDK only stores these references as text on the diagram. Actual values are f ### `nodes..` -Output of an ancestor node, keyed by the node's `id`. **Surfaced in the picker.** The available properties for each ancestor come from that node's `outputSchema`. +Output of an ancestor node, keyed by the node's `id`. **Surfaced in the picker.** The available properties for each ancestor come from that node's `schemaOutput`. For example, an AI Agent node emits `response`, `tokensUsed`, and `model`, so a downstream field can reference any of: @@ -162,14 +162,14 @@ The control behaves like the matching plain control, except `{{` opens the sugge ### Expose the node's outputs to downstream pickers -Add an `outputSchema` to the node's `PaletteItem`. Each entry describes one output property the node will emit: +Add an `schemaOutput` to the node's `PaletteItem`. Each entry describes one output property the node will emit: ```typescript import type { PaletteItem } from '@workflowbuilder/sdk'; export const myNode: PaletteItem = { // ...label, type, schema, defaults, uischema - outputSchema: { + schemaOutput: { properties: { response: { type: 'string', label: 'Response', description: 'The text returned by the model' }, tokensUsed: { type: 'number', label: 'Tokens Used' }, @@ -178,7 +178,7 @@ export const myNode: PaletteItem = { }; ``` -Without an `outputSchema`, the node never appears as a group in any downstream picker. +Without an `schemaOutput`, the node never appears as a group in any downstream picker. ## See also diff --git a/packages/execution-core/generic-execution-core.decision-log.md b/packages/execution-core/generic-execution-core.decision-log.md index df4e180f8..76bab43fb 100644 --- a/packages/execution-core/generic-execution-core.decision-log.md +++ b/packages/execution-core/generic-execution-core.decision-log.md @@ -14,7 +14,7 @@ Net effect: the engine adapter was swappable per `WorkflowEnginePort`, but the node vocabulary was not. Anyone wanting to ship a different workflow product on this codebase had to fork the core, fork the types package, and rewrite backend mappers. The package's stated purpose ("generic graph-runner mechanism") and its actual surface area diverged. -A separate dead-code observation: `packages/types/src/workflow-execution/node-output-schemas.ts` exported an `executionNodeOutputSchemas` map keyed by `'ai-studio/*'` with zero readers in the repo. It was infrastructure for a feature nobody was using. +A separate dead-code observation: `packages/types/src/workflow-execution/node-output-schemas.ts` exported an `executionNodeSchemaOutputs` map keyed by `'ai-studio/*'` with zero readers in the repo. It was infrastructure for a feature nobody was using. ## Decision diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts index a229acf86..32f9d6d94 100644 --- a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts @@ -29,7 +29,7 @@ const EMPTY_NODE_SUGGESTIONS: Response = { export function getSuggestionsNodeData({ definition, node }: Params): Response { const nodeLabel = getNodeLabelForVariable({ node, definition }); - if (!definition?.outputSchema?.type) { + if (!definition?.schemaOutput?.type) { return EMPTY_NODE_SUGGESTIONS; } @@ -38,8 +38,8 @@ export function getSuggestionsNodeData({ definition, node }: Params): Response { }; // Node that always returns the same variables - if (definition.outputSchema.type === OUTPUT_SCHEMA_TYPE.DEFAULT) { - for (const [sourceHandle, properties] of Object.entries(definition.outputSchema.bySourceHandle)) { + if (definition.schemaOutput.type === OUTPUT_SCHEMA_TYPE.DEFAULT) { + for (const [sourceHandle, properties] of Object.entries(definition.schemaOutput.bySourceHandle)) { if (properties) { bySourceHandle[sourceHandle] = [ ...(bySourceHandle[sourceHandle] || []), @@ -59,8 +59,8 @@ export function getSuggestionsNodeData({ definition, node }: Params): Response { } // From variants (they have rules based on data inside the node) - if (definition.outputSchema.type === OUTPUT_SCHEMA_TYPE.VARIANT) { - const variantsMatchingDataPropertyValue = Object.values(definition.outputSchema.variants) + if (definition.schemaOutput.type === OUTPUT_SCHEMA_TYPE.VARIANT) { + const variantsMatchingDataPropertyValue = Object.values(definition.schemaOutput.variants) .filter((variant) => { if (variant.variantRule && 'onlyIfPropertyNameEquals' in variant.variantRule) { const { path, value } = variant.variantRule.onlyIfPropertyNameEquals; diff --git a/packages/sdk/src/features/variables/utils/core/get-available-variables-by-node-id.ts b/packages/sdk/src/features/variables/utils/core/get-available-variables-by-node-id.ts index 378d7fdf4..5ef6ff185 100644 --- a/packages/sdk/src/features/variables/utils/core/get-available-variables-by-node-id.ts +++ b/packages/sdk/src/features/variables/utils/core/get-available-variables-by-node-id.ts @@ -47,7 +47,7 @@ export function getAvailableVariablesByNodeId({ } const definition = getNodeDefinition(node); - if (!definition?.outputSchema) { + if (!definition?.schemaOutput) { continue; } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index cbeface8e..0181244b4 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -270,6 +270,13 @@ export type { WBIcon } from '@workflow-builder/icons'; export type { UISchemaElement } from './types/uischema'; +/** + * Displays a snackbar notification to the user. + * + * @param message - The message to display in the snackbar. + */ +export { showSnackbar } from './utils/show-snackbar'; + // ============================================================================= // Variables // ============================================================================= @@ -277,6 +284,8 @@ export type { UISchemaElement } from './types/uischema'; // (`{{nodeId.output}}`), so consumers build and parse references without // hardcoding the brackets. +export type { NodeSchemaOutput } from './node/node-output-schema'; + export { VARIABLE_BRACKETS_START, VARIABLE_BRACKETS_END } from './features/variables/constants'; export { VARIABLE_NODES_KEY } from './features/variables/constants'; diff --git a/packages/sdk/src/node/deprecated/deprecated-node-output-schema.ts b/packages/sdk/src/node/deprecated/deprecated-node-output-schema.ts new file mode 100644 index 000000000..85f70e1f2 --- /dev/null +++ b/packages/sdk/src/node/deprecated/deprecated-node-output-schema.ts @@ -0,0 +1,28 @@ +import type { FlattenedPropertiesIndex } from '../node-output-schema'; + +export type DeprecatedOutputVariant = { + variantRule: + | undefined + | { + dataPropertyName: string; + dataPropertyValue: string; + }; + properties: FlattenedPropertiesIndex; +}; + +export type DeprecatedNodeOutputSchemaDefault = { + type: 'default'; + properties: FlattenedPropertiesIndex; +}; + +export type DeprecatedNodeOutputSchemaVariant = { + /* + Variants may be set dynamically by the node configuration. + */ + type: 'variant'; + variants: { + [variantName: string]: FlattenedPropertiesIndex | undefined; + }; +}; + +export type DeprecatedNodeOutputSchema = DeprecatedNodeOutputSchemaDefault | DeprecatedNodeOutputSchemaVariant; diff --git a/packages/sdk/src/node/node-data.ts b/packages/sdk/src/node/node-data.ts index 04d53ac17..3efd3665a 100644 --- a/packages/sdk/src/node/node-data.ts +++ b/packages/sdk/src/node/node-data.ts @@ -3,7 +3,8 @@ import type { Edge, Node } from '@xyflow/react'; import type { NodeDataProperties } from '../types/default-properties'; import type { UISchema } from '../types/uischema'; import type { IconType } from './common'; -import type { NodeOutputSchema } from './node-output-schema'; +import type { DeprecatedNodeOutputSchema } from './deprecated/deprecated-node-output-schema'; +import type { NodeSchemaOutput } from './node-output-schema'; import type { BaseNodeProperties, NodeSchema } from './node-schema'; import type { NodeType } from './node-types'; @@ -30,7 +31,15 @@ export type NodeDefinition = { /** describes how the form looks like and to which fields data properties should be mapped */ uischema?: UISchema; /** describes the output properties this node produces, used by the variable picker */ - outputSchema?: NodeOutputSchema; + schemaOutput?: NodeSchemaOutput; + /** + * @deprecated outputSchema is deprecated. Switch to schemaOutput instead. + * The newer version uses a schema similar to the Node schema, but also supports handling responses + * by source handle (the error port does not receive successful variables). + * + * outputSchema will be removed in the next major release (3.0). + */ + outputSchema?: DeprecatedNodeOutputSchema; } & Required> & Pick; diff --git a/packages/sdk/src/node/node-output-schema.ts b/packages/sdk/src/node/node-output-schema.ts index 496eade79..75bdb2988 100644 --- a/packages/sdk/src/node/node-output-schema.ts +++ b/packages/sdk/src/node/node-output-schema.ts @@ -53,12 +53,12 @@ export type OutputVariant = }; }; -export type NodeOutputSchemaDefault = { +export type NodeSchemaOutputDefault = { type: 'default'; bySourceHandle: PropertiesBySourceHandle; }; -export type NodeOutputSchemaVariant = { +export type NodeSchemaOutputVariant = { /* Predefined variant depending on the value of a property. */ @@ -66,4 +66,4 @@ export type NodeOutputSchemaVariant = { variants: OutputVariant[]; }; -export type NodeOutputSchema = NodeOutputSchemaDefault | NodeOutputSchemaVariant; +export type NodeSchemaOutput = NodeSchemaOutputDefault | NodeSchemaOutputVariant; diff --git a/packages/types/src/node-output-schema.ts b/packages/types/src/node-output-schema.ts index 11827237d..0b68c82a6 100644 --- a/packages/types/src/node-output-schema.ts +++ b/packages/types/src/node-output-schema.ts @@ -16,6 +16,6 @@ export type OutputProperty = { description?: string; }; -export type NodeOutputSchema = { +export type NodeSchemaOutput = { properties: Record; }; From 9e0e941d7c632d0404761b0d5bd0b8c3e815dcf8 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Thu, 27 Aug 2026 11:41:55 +0200 Subject: [PATCH 17/33] chore(sdk): support for deprecated --- apps/demo/src/app/data/nodes/action/action.ts | 13 +++- ...cated-suggestions-from-output-schema.ts.ts | 40 +++++++++++ ...s => get-suggestions-from-schema-ouput.ts} | 4 +- .../get-suggestions-node-data.ts | 70 +++++++++++++++++-- .../get-available-variables-by-node-id.ts | 2 +- .../deprecated-node-output-schema.ts | 2 +- 6 files changed, 118 insertions(+), 13 deletions(-) create mode 100644 packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-deprecated-suggestions-from-output-schema.ts.ts rename packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/{get-suggestions-from-output-schema.ts => get-suggestions-from-schema-ouput.ts} (88%) diff --git a/apps/demo/src/app/data/nodes/action/action.ts b/apps/demo/src/app/data/nodes/action/action.ts index 54470593e..af68491e7 100644 --- a/apps/demo/src/app/data/nodes/action/action.ts +++ b/apps/demo/src/app/data/nodes/action/action.ts @@ -2,7 +2,7 @@ import type { PaletteItem } from '@workflowbuilder/sdk'; import { defaultPropertiesData } from './default-properties-data'; import { type ActionNodeSchema, schema } from './schema'; -import { schemaOutput } from './schema-output'; +// import { schemaOutput } from './schema-output'; import { uischema } from './uischema'; export const action: PaletteItem = { @@ -12,6 +12,15 @@ export const action: PaletteItem = { description: 'node.action.description', defaultPropertiesData, schema, - schemaOutput, + // schemaOutput, + // Example of a deprecated response. Use schemaOutput instead. + outputSchema: { + type: 'default', + properties: { + status: { type: 'string', label: 'Status', description: 'Execution status: success, failure, or skipped' }, + result: { type: 'object', label: 'Result', description: 'The data returned by the action' }, + errorMessage: { type: 'string', label: 'Error Message', description: 'Error details if the action failed' }, + }, + }, uischema, }; diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-deprecated-suggestions-from-output-schema.ts.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-deprecated-suggestions-from-output-schema.ts.ts new file mode 100644 index 000000000..cad83ae1e --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-deprecated-suggestions-from-output-schema.ts.ts @@ -0,0 +1,40 @@ +import type { FlattenedPropertiesIndex } from '../../../../../node/node-output-schema'; +import { filterEmpty } from '../../../../../utils/array'; +import { keyToLabel, truncate } from '../../../../../utils/text'; +import type { VariableSuggestion } from '../../../components/variable-text/variable-text.types'; +import { getVariableReferenceWithoutBracketsForNode } from '../../../utils/keys/get-variable-reference-without-brackets-for-node'; + +type Params = { + properties: FlattenedPropertiesIndex; + nodeId: string; + nodeLabel: string; +}; + +/** + * Produces a list of suggestions generated from `FlattenedPropertiesIndex` (used by node outputProperties). + * + * @deprecated `outputSchema` is deprecated. Switch to `schemaOutput` instead. + * The newer version uses a schema similar to the Node schema, but also supports handling responses + * by source handle (the error port does not receive successful variables). + * + * `outputSchema` will be removed in the next major release (3.0). + */ +export function getDeprecatedSuggestionsFromOutputSchema({ + nodeId, + nodeLabel, + properties, +}: Params): VariableSuggestion[] { + return Object.entries(properties) + .map(([propertyKey, property]) => + property + ? { + id: getVariableReferenceWithoutBracketsForNode({ nodeId, propertyName: propertyKey }), + display: `${truncate(nodeLabel, 15)} · ${truncate(property.label || keyToLabel(propertyKey), 15)}`, + label: property.label || truncate(property.label || keyToLabel(propertyKey), 25), + description: property.description, + type: property.type, + } + : undefined, + ) + .filter(filterEmpty); +} diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-output-schema.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-schema-ouput.ts similarity index 88% rename from packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-output-schema.ts rename to packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-schema-ouput.ts index 5837bbdfe..00eb00ac7 100644 --- a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-output-schema.ts +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-schema-ouput.ts @@ -13,9 +13,9 @@ type Params = { }; /** - * Produces a list of suggestions generated from `outputPropertiesIndex` (used by node definition variants). + * Produces a list of suggestions generated from JsonSchema7 (used by node definition variants). */ -export function getSuggestionsFromOutputSchema({ nodeId, nodeLabel, properties }: Params): VariableSuggestion[] { +export function getSuggestionsFromSchemaOutput({ nodeId, nodeLabel, properties }: Params): VariableSuggestion[] { const flattenedProperties = getFlattenedPropertiesFromJsonSchema7(properties); return Object.entries(flattenedProperties) diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts index 32f9d6d94..fda6b0cc8 100644 --- a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts @@ -6,7 +6,8 @@ import { getByPath } from '../../../../../utils/object'; import type { VariablesIndex } from '../../../types'; import { getNodeLabelForVariable } from '../../../utils/diagram/get-node-label-for-variable'; import { SUGGESTION_NODE_TYPE, type SuggestionNodeType, type SuggestionsBySourceHandle } from '../../types'; -import { getSuggestionsFromOutputSchema } from './get-suggestions-from-output-schema'; +import { getDeprecatedSuggestionsFromOutputSchema } from './get-deprecated-suggestions-from-output-schema.ts'; +import { getSuggestionsFromSchemaOutput } from './get-suggestions-from-schema-ouput'; import { getSuggestionsFromVariableIndex } from './get-suggestions-from-variables-index'; type Params = { @@ -28,22 +29,77 @@ const EMPTY_NODE_SUGGESTIONS: Response = { export function getSuggestionsNodeData({ definition, node }: Params): Response { const nodeLabel = getNodeLabelForVariable({ node, definition }); + const bySourceHandle: SuggestionsBySourceHandle = { + every: [], + }; if (!definition?.schemaOutput?.type) { + // outputSchema is deprecated and will be removed in future versions + if (definition?.outputSchema?.type === OUTPUT_SCHEMA_TYPE.DEFAULT) { + // The old format doesn't return the same value for all handles + bySourceHandle['every'] = [ + ...(bySourceHandle['every'] || []), + ...getDeprecatedSuggestionsFromOutputSchema({ + nodeId: node.id, + nodeLabel, + properties: definition.outputSchema.properties, + }), + ]; + + return { + type: SUGGESTION_NODE_TYPE.COMMON, + bySourceHandle, + }; + } + + if (definition?.outputSchema?.type === OUTPUT_SCHEMA_TYPE.VARIANT) { + // The old format accepted only one rule at the time, newer merges multiple rules + const variant = Object.values(definition.outputSchema.variants).find((variant) => { + if (!variant?.variantRule) { + return true; + } + + const { dataPropertyName, dataPropertyValue } = variant.variantRule; + + if (node.data.properties[dataPropertyName] === dataPropertyValue) { + return true; + } + + return false; + }); + + if (variant) { + bySourceHandle['every'] = [ + ...(bySourceHandle['every'] || []), + ...getDeprecatedSuggestionsFromOutputSchema({ + nodeId: node.id, + nodeLabel, + properties: variant.properties, + }), + ]; + + return { + type: SUGGESTION_NODE_TYPE.CUSTOM, + bySourceHandle, + }; + } + + return { + type: SUGGESTION_NODE_TYPE.CUSTOM, + bySourceHandle: {}, + }; + } + return EMPTY_NODE_SUGGESTIONS; } - const bySourceHandle: SuggestionsBySourceHandle = { - every: [], - }; - // Node that always returns the same variables if (definition.schemaOutput.type === OUTPUT_SCHEMA_TYPE.DEFAULT) { for (const [sourceHandle, properties] of Object.entries(definition.schemaOutput.bySourceHandle)) { if (properties) { bySourceHandle[sourceHandle] = [ ...(bySourceHandle[sourceHandle] || []), - ...getSuggestionsFromOutputSchema({ + ...getSuggestionsFromSchemaOutput({ nodeId: node.id, nodeLabel, properties, @@ -83,7 +139,7 @@ export function getSuggestionsNodeData({ definition, node }: Params): Response { if (properties) { bySourceHandle[sourceHandle] = [ ...(bySourceHandle[sourceHandle] || []), - ...getSuggestionsFromOutputSchema({ + ...getSuggestionsFromSchemaOutput({ nodeId: node.id, nodeLabel, properties, diff --git a/packages/sdk/src/features/variables/utils/core/get-available-variables-by-node-id.ts b/packages/sdk/src/features/variables/utils/core/get-available-variables-by-node-id.ts index 5ef6ff185..db244c3f7 100644 --- a/packages/sdk/src/features/variables/utils/core/get-available-variables-by-node-id.ts +++ b/packages/sdk/src/features/variables/utils/core/get-available-variables-by-node-id.ts @@ -47,7 +47,7 @@ export function getAvailableVariablesByNodeId({ } const definition = getNodeDefinition(node); - if (!definition?.schemaOutput) { + if (!definition?.schemaOutput && !definition?.outputSchema) { continue; } diff --git a/packages/sdk/src/node/deprecated/deprecated-node-output-schema.ts b/packages/sdk/src/node/deprecated/deprecated-node-output-schema.ts index 85f70e1f2..e0a2e53b4 100644 --- a/packages/sdk/src/node/deprecated/deprecated-node-output-schema.ts +++ b/packages/sdk/src/node/deprecated/deprecated-node-output-schema.ts @@ -21,7 +21,7 @@ export type DeprecatedNodeOutputSchemaVariant = { */ type: 'variant'; variants: { - [variantName: string]: FlattenedPropertiesIndex | undefined; + [variantName: string]: DeprecatedOutputVariant | undefined; }; }; From bb6316bd1369c0dcbfdcef41e1680c04a03da325 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Thu, 27 Aug 2026 14:06:19 +0200 Subject: [PATCH 18/33] chore(sdk): less exports --- .../dependencies/dependencies.tsx | 4 ++-- .../conditions-form.tsx | 4 ++-- .../variable-dynamic-control.tsx | 4 ++-- .../variable-text-area-control.tsx | 4 ++-- .../variable-text-control.tsx | 4 ++-- ...ble-variables.ts => use-node-variables.ts} | 3 ++- packages/sdk/src/index.ts | 22 +++++++++++++------ packages/sdk/src/utils/general-information.ts | 7 +++--- 8 files changed, 30 insertions(+), 22 deletions(-) rename packages/sdk/src/features/variables/hooks/{use-available-variables.ts => use-node-variables.ts} (91%) diff --git a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx index 187f487b7..e95ff2a50 100644 --- a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx +++ b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx @@ -5,9 +5,9 @@ import styles from './dependencies.module.css'; import { FormControlWithLabel } from '../../../../../components/form/form-control-with-label/form-control-with-label'; import { useSingleSelectedElement } from '../../../../../features/properties-bar/use-single-selected-element'; import { VariableText } from '../../../../../features/variables/components/variable-text/variable-text'; -import { useAvailableVariables } from '../../../../../features/variables/hooks/use-available-variables'; import type { DynamicCondition } from '../../../../../types/controls'; import { noop } from '../../../../../utils/noop'; +import { useNodeVariables } from '../../../../variables/hooks/use-node-variables'; import { conditionsToDependencies } from '../../../utils/conditional-transform'; type Props = { @@ -23,7 +23,7 @@ export function Dependencies({ conditions, onClick, disabled = false, hasError } }, [conditions]); const selection = useSingleSelectedElement(); - const { suggestionGroups, totalVariables } = useAvailableVariables(selection?.node?.id); + const { suggestionGroups, totalVariables } = useNodeVariables(selection?.node?.id); return ( diff --git a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form/conditions-form.tsx b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form/conditions-form.tsx index 5e6ded683..4bf930101 100644 --- a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form/conditions-form.tsx +++ b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form/conditions-form.tsx @@ -9,7 +9,7 @@ import type { DynamicCondition } from '../../../../../features/json-form/types/c import { closeModal } from '../../../../../features/modals/stores/use-modal-store'; import { useSingleSelectedElement } from '../../../../../features/properties-bar/use-single-selected-element'; import { variablesTypesToExcludeNonPrimitive } from '../../../../../features/variables/constants'; -import { useAvailableVariables } from '../../../../../features/variables/hooks/use-available-variables'; +import { useNodeVariables } from '../../../../variables/hooks/use-node-variables'; import { getConditionErrors } from '../../../../variables/utils/form-validation/conditions'; import { ConditionsFormField } from '../dynamic-conditions-form-field/conditions-form-field'; @@ -37,7 +37,7 @@ export const ConditionsForm = forwardRef store.globalVariables); const nodes = useStore((store) => store.nodes); @@ -60,6 +60,7 @@ export function useAvailableVariables(nodeId: string | undefined, options?: Opti includeTypes, }); + // Variables can’t change while the modal containing them is in use, so we only need to refresh them when they change. // .length is critical here for performance. // eslint-disable-next-line react-hooks/exhaustive-deps }, [nodeId, edges.length, nodes.length]); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 0181244b4..f1bcc678b 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -280,15 +280,23 @@ export { showSnackbar } from './utils/show-snackbar'; // ============================================================================= // Variables // ============================================================================= -// Delimiters wrapping a variable reference in a string value -// (`{{nodeId.output}}`), so consumers build and parse references without -// hardcoding the brackets. +// The output contract of a node — the shape downstream nodes can reference as +// variables. Attach it via `NodeData.schemaOutput`. Two forms: +// `{ type: 'default', bySourceHandle }` maps each source handle to a JSON +// Schema (`every` covers all handles at once, e.g. `success` / `error` split); +// `{ type: 'variant', variants }` picks the shape at runtime from a property +// value, for nodes whose outputs depend on how they are configured. export type { NodeSchemaOutput } from './node/node-output-schema'; -export { VARIABLE_BRACKETS_START, VARIABLE_BRACKETS_END } from './features/variables/constants'; -export { VARIABLE_NODES_KEY } from './features/variables/constants'; +// Every variable a node can reference — global variables plus the outputs of +// its ancestors — grouped for a picker UI. Optionally narrowed by variable type +// (`includeTypes` / `excludeTypes`). Recomputes only when node/edge counts +// change, so it is cheap to call from a modal or properties panel. +export { useNodeVariables } from './features/variables/hooks/use-node-variables'; -export { useVariablesSuggestionsStore } from './features/variables/stores/use-variable-suggestions-store'; -export type { VariablesSuggestionsStore } from './features/variables/stores/use-variable-suggestions-store'; +// Imperative, non-React counterpart: the variables a single node exposes on one +// of its source handles (branch-aware — an error handle gets the error-branch +// outputs, others the success ones). Returns `undefined` when the node has not +// been indexed yet. export { getNodeVariablesSuggestions } from './features/variables/stores/core/get-node-variables-suggestions'; diff --git a/packages/sdk/src/utils/general-information.ts b/packages/sdk/src/utils/general-information.ts index 8dfbabfc2..b7ad2bb99 100644 --- a/packages/sdk/src/utils/general-information.ts +++ b/packages/sdk/src/utils/general-information.ts @@ -15,7 +15,7 @@ export const statusOptions = { /** * UISchema fragments rendered on every node's properties tab regardless - * of the node type. Today contains the missing-previous-variable error + * of the node type. Today contains the custom-error error * message; compose it into a node's UISchema with spread/merge. * * @category Utilities @@ -27,7 +27,7 @@ export const globalControls: UISchemaElement[] = [ data.properties.customErrors = [ { - instancePath: '/missingPreviousVariable', + instancePath: '/customError', message: i18n.t('your.custom.message'), schemaPath: '', keyword: '', @@ -37,8 +37,7 @@ export const globalControls: UISchemaElement[] = [ */ { type: 'MessageOnError', - scope: '#/properties/missingPreviousVariable', - text: 'plugins.validation.missingDependency', + scope: '#/properties/customError', }, ]; From 8e8cb99b8432bcfc5bfa162c4e0d9b7a32dc1059 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Thu, 27 Aug 2026 15:03:19 +0200 Subject: [PATCH 19/33] fix(sdk): wrong match onBlur in text-variable --- .../variable-text-area-control.tsx | 12 +++-- .../variable-text-control.tsx | 12 +++-- .../variable-text/variable-text.tsx | 44 ++++++------------- .../get-is-string-variable-reference.spec.ts | 32 ++++++++++++++ .../keys/get-is-string-variable-reference.ts | 2 +- 5 files changed, 63 insertions(+), 39 deletions(-) create mode 100644 packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.spec.ts diff --git a/packages/sdk/src/features/json-form/controls/variable-text-area-control/variable-text-area-control.tsx b/packages/sdk/src/features/json-form/controls/variable-text-area-control/variable-text-area-control.tsx index ac4aa4991..4bbc96f1f 100644 --- a/packages/sdk/src/features/json-form/controls/variable-text-area-control/variable-text-area-control.tsx +++ b/packages/sdk/src/features/json-form/controls/variable-text-area-control/variable-text-area-control.tsx @@ -26,9 +26,12 @@ function VariableTextAreaControl(props: VariableTextAreaControlProps) { setInputValue(data ?? ''); }, [data]); - const onBlur = useCallback(() => { - handleChange(path, inputValue || undefined); - }, [handleChange, path, inputValue]); + const onBlur = useCallback( + (value: string) => { + handleChange(path, value || undefined); + }, + [handleChange, path], + ); return ( @@ -36,10 +39,11 @@ function VariableTextAreaControl(props: VariableTextAreaControlProps) { key={totalVariables} value={inputValue} onChange={setInputValue} + onBlur={onBlur} variant="text-area" suggestionGroups={suggestionGroups} hasError={errors.length > 0} - mentionsInputProps={{ disabled: isDisabled, placeholder, onBlur }} + mentionsInputProps={{ disabled: isDisabled, placeholder }} /> ); diff --git a/packages/sdk/src/features/json-form/controls/variable-text-control/variable-text-control.tsx b/packages/sdk/src/features/json-form/controls/variable-text-control/variable-text-control.tsx index eb2342507..4dcaa863a 100644 --- a/packages/sdk/src/features/json-form/controls/variable-text-control/variable-text-control.tsx +++ b/packages/sdk/src/features/json-form/controls/variable-text-control/variable-text-control.tsx @@ -26,9 +26,12 @@ function VariableTextControl(props: VariableTextControlProps) { setInputValue(data ?? ''); }, [data]); - const onBlur = useCallback(() => { - handleChange(path, inputValue || undefined); - }, [handleChange, path, inputValue]); + const onBlur = useCallback( + (value: string) => { + handleChange(path, value || undefined); + }, + [handleChange, path], + ); return ( @@ -36,10 +39,11 @@ function VariableTextControl(props: VariableTextControlProps) { key={totalVariables} value={inputValue} onChange={setInputValue} + onBlur={onBlur} variant="text" suggestionGroups={suggestionGroups} hasError={errors.length > 0} - mentionsInputProps={{ disabled: isDisabled, placeholder, onBlur }} + mentionsInputProps={{ disabled: isDisabled, placeholder }} /> ); diff --git a/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx b/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx index 0386bd9e5..2ac2d08bb 100644 --- a/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx +++ b/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx @@ -1,8 +1,8 @@ import { NavButton, SnackbarType } from '@workflowbuilder/ui'; import clsx from 'clsx'; -import { type ReactElement, type ReactNode, cloneElement, useCallback, useMemo, useRef, useState } from 'react'; +import { type ReactElement, type ReactNode, cloneElement, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Mention, type MentionDataItem, MentionsInput } from 'react-mentions-ts'; +import { Mention, type MentionDataItem, MentionsInput, type MentionsInputProps } from 'react-mentions-ts'; import { Icon } from '@workflow-builder/icons'; @@ -10,7 +10,7 @@ import styles from './variable-text.module.css'; import { getNodeByIdAction } from '../../../../store-get-actions/stores/use-store-get-actions'; import { showSnackbar } from '../../../../utils/show-snackbar'; -import { VARIABLE_BRACKETS_END, VARIABLE_BRACKETS_START, VARIABLE_NODES_KEY } from '../../constants'; +import { VARIABLE_BRACKETS_START, VARIABLE_NODES_KEY } from '../../constants'; import { buildMentionData } from './core/build-mention-data'; import type { VariableMentionData, @@ -23,6 +23,8 @@ const DEFAULT_TRIGGER = '{{'; const DEFAULT_MARKUP = '{{__id__}}'; const DEFAULT_TITLE = 'Variables'; +type MentionsInputBlurHandler = NonNullable; + const baseClassNames = { control: styles['control'], input: styles['input'], @@ -92,7 +94,6 @@ export function VariableText({ }: VariableTextProps) { const { t } = useTranslation(); const [key, setKey] = useState(crypto.randomUUID()); - const refValueForBlur = useRef(value); const singleLine = variant === 'text'; const mentionData = useMemo(() => buildMentionData(suggestionGroups), [suggestionGroups]); @@ -242,38 +243,23 @@ export function VariableText({ }); } - refValueForBlur.current = value; - onChange(value); }, [mentionData.length, onChange], ); - const handleFocus = useCallback( - (event: { target: { value: string } }) => { - let value = event.target.value; + const { onBlur: onMentionsInputBlur, ...restMentionsInputProps } = mentionsInputProps ?? {}; - for (const variable of mentionData) { - if (variable.display) { - value = value.replaceAll( - variable.display, - `${VARIABLE_BRACKETS_START}${variable.id}${VARIABLE_BRACKETS_END}`, - ); - } - } - - refValueForBlur.current = value; + // `event.target.value` holds the display text (labels, "Missing node" placeholders), + // not the `{{id}}` markup — the controlled `value` prop is the only source of truth. + const handleBlur = useCallback( + (event) => { + onMentionsInputBlur?.(event); + onBlur?.(value); }, - [mentionData], + [onBlur, onMentionsInputBlur, value], ); - const handleBlur = useCallback(() => { - // We can't rely on the value from the onBlur event because the value is updated afterward. - if (onBlur) { - onBlur(refValueForBlur.current); - } - }, [onBlur]); - const { trigger = DEFAULT_TRIGGER, markup = DEFAULT_MARKUP, @@ -300,17 +286,15 @@ export function VariableText({ return ( { + it.each(['{{nodes.abc.output}}', '{{global.total}}', ' {{nodes.abc.output}} ', '{{a}}'])( + 'accepts a single reference: %j', + (value) => { + expect(getIsStringVariableReference(value)).toBe(true); + }, + ); + + it.each([ + undefined, + '', + ' ', + 'plain text', + '{{a}} {{b}}', + '{{a}}{{b}}', + '{{a}} text', + 'text {{a}}', + '{{a}}x', + '{{a b}}', + '{{ Missing node (abcd...) · output }}', + '{{ Label · output }}', + '{{a', + 'a}}', + '{a}', + ])('rejects non-single references: %j', (value) => { + expect(getIsStringVariableReference(value)).toBe(false); + }); +}); diff --git a/packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.ts b/packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.ts index 8bfa4b581..7fbda4021 100644 --- a/packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.ts +++ b/packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.ts @@ -15,7 +15,7 @@ export function getIsStringVariableReference(value: MaybeVariableReference): boo const hasInvalidCharacters = valueTrimmed.includes(' '); if (hasInvalidCharacters) { - return true; + return false; } const isOnlyOneVariable = From 3e2efe88b71689fea7d9bb8992b8698c98f49d10 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Fri, 28 Aug 2026 08:39:59 +0200 Subject: [PATCH 20/33] docs(sdk): chagngset added --- .changeset/sdk-variable-dynamic-control.md | 5 +++++ .changeset/sdk-variables-json-schema.md | 5 +++++ .../get-suggestions-node-data/get-suggestions-node-data.ts | 6 ++++-- .../variables-referencing-strategy.decision-log.md | 4 ++-- 4 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 .changeset/sdk-variable-dynamic-control.md create mode 100644 .changeset/sdk-variables-json-schema.md diff --git a/.changeset/sdk-variable-dynamic-control.md b/.changeset/sdk-variable-dynamic-control.md new file mode 100644 index 000000000..dd31061de --- /dev/null +++ b/.changeset/sdk-variable-dynamic-control.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': minor +--- + +Added a new 'VariableDynamic' control that dynamically builds a control matching the variable's type. It toggles between a regular input and a variable picker limited to variables of the matching type; only a single variable can be set. diff --git a/.changeset/sdk-variables-json-schema.md b/.changeset/sdk-variables-json-schema.md new file mode 100644 index 000000000..99a6c3225 --- /dev/null +++ b/.changeset/sdk-variables-json-schema.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': patch +--- + +Variables now support returning different values for different source handles. Variables are now defined using a JSON schema rather than a flattened list, providing a foundation for future validation. diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts index fda6b0cc8..b9f8e5586 100644 --- a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts @@ -47,7 +47,8 @@ export function getSuggestionsNodeData({ definition, node }: Params): Response { ]; return { - type: SUGGESTION_NODE_TYPE.COMMON, + // type: SUGGESTION_NODE_TYPE.COMMON, + type: SUGGESTION_NODE_TYPE.CUSTOM, bySourceHandle, }; } @@ -109,7 +110,8 @@ export function getSuggestionsNodeData({ definition, node }: Params): Response { } return { - type: SUGGESTION_NODE_TYPE.COMMON, + // type: SUGGESTION_NODE_TYPE.COMMON, + type: SUGGESTION_NODE_TYPE.CUSTOM, bySourceHandle, }; } diff --git a/packages/sdk/src/features/variables/variables-referencing-strategy.decision-log.md b/packages/sdk/src/features/variables/variables-referencing-strategy.decision-log.md index 2a12fe481..8f237acbe 100644 --- a/packages/sdk/src/features/variables/variables-referencing-strategy.decision-log.md +++ b/packages/sdk/src/features/variables/variables-referencing-strategy.decision-log.md @@ -2,7 +2,7 @@ ### Proposed by: Szymon Tondowski -### Date: 13.08.2026 +### Date: 28.08.2026 ## Context @@ -10,7 +10,7 @@ Workflow Builder supports passing variables by typing '{{' in dedicated controls Variable suggestions for a given control depend on the global variables and the variables produced by previous nodes connected to it. -We need a robust mechanism for storing accessing variable suggestions so that the picker can efficiently provide the relevant options whenever the user starts typing '{{'. +We need a robust mechanism for storing and accessing variable suggestions so that the picker can efficiently provide the relevant options whenever the user starts typing '{{'. ## Decisions From 4a630c56572a88ce33095b8f2c8a87bdd8c6f07a Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Fri, 28 Aug 2026 08:47:25 +0200 Subject: [PATCH 21/33] docs(sdk): use-variable-picker dock updated --- .../docs/guides/use-variable-picker.mdx | 80 +++++++++++++++++-- 1 file changed, 72 insertions(+), 8 deletions(-) diff --git a/apps/docs/src/content/docs/guides/use-variable-picker.mdx b/apps/docs/src/content/docs/guides/use-variable-picker.mdx index d00fe73b3..416b6ece6 100644 --- a/apps/docs/src/content/docs/guides/use-variable-picker.mdx +++ b/apps/docs/src/content/docs/guides/use-variable-picker.mdx @@ -162,23 +162,87 @@ The control behaves like the matching plain control, except `{{` opens the sugge ### Expose the node's outputs to downstream pickers -Add an `schemaOutput` to the node's `PaletteItem`. Each entry describes one output property the node will emit: +Add a `schemaOutput` (type `NodeSchemaOutput`) to the node's `PaletteItem`. It describes what the node emits on each of its **source handles** as a JSON Schema, so the picker can offer the right properties depending on which port the downstream node is connected to (an `error` port does not receive the success-branch outputs). ```typescript -import type { PaletteItem } from '@workflowbuilder/sdk'; +import type { NodeSchemaOutput, PaletteItem } from '@workflowbuilder/sdk'; + +const schemaOutput: NodeSchemaOutput = { + type: 'default', + bySourceHandle: { + success: { + type: 'object', + properties: { + response: { type: 'string', title: 'Response', description: 'The text returned by the model' }, + tokensUsed: { type: 'number', title: 'Tokens Used' }, + finishedAt: { type: 'string', format: 'date-time', title: 'Finished At' }, + }, + }, + error: { + type: 'object', + properties: { + errorMessage: { type: 'string', title: 'Error Message' }, + }, + }, + }, +}; export const myNode: PaletteItem = { // ...label, type, schema, defaults, uischema - schemaOutput: { - properties: { - response: { type: 'string', label: 'Response', description: 'The text returned by the model' }, - tokensUsed: { type: 'number', label: 'Tokens Used' }, + schemaOutput, +}; +``` + +How the schema is read: + +- Keys of `bySourceHandle` are source handle ids (`success`, `error`, ...). Use the special key `every` for properties emitted on all handles. +- Each value is a JSON Schema object (`type: 'object'` with `properties`). `title` becomes the suggestion label, `description` its help text. +- Supported property types: `string`, `number`, `boolean`, `object`, `array`. A `string` with `format: 'date-time'` is surfaced as a date-time variable. +- Nested `object` properties are flattened to dot paths - `result.status` in the schema becomes `{{nodes..result.status}}` in the picker. + +Without a `schemaOutput`, the node never appears as a group in any downstream picker. + +#### Outputs that depend on configuration + +If the node's outputs change with how it is configured (e.g. a Trigger node whose fields differ per trigger type), use the `variant` form. Every variant whose rule matches the node's current `properties` is merged into the suggestions; a variant with `variantRule: undefined` always applies: + +```typescript +const schemaOutput: NodeSchemaOutput = { + type: 'variant', + variants: [ + { + variantRule: undefined, + bySourceHandle: { + every: { + type: 'object', + properties: { + eventType: { type: 'string', title: 'Event Type' }, + }, + }, + }, }, - }, + { + variantRule: { onlyIfPropertyNameEquals: { path: 'type', value: 'timeBasedTrigger' } }, + bySourceHandle: { + success: { + type: 'object', + properties: { + startDate: { type: 'string', format: 'date-time', title: 'Start date' }, + }, + }, + }, + }, + ], }; ``` -Without an `schemaOutput`, the node never appears as a group in any downstream picker. +`onlyIfPropertyNameEquals.path` is a dot path into the node's `properties`; the variant applies when the value at that path strictly equals `value`. + +A variant can also take its properties from user-defined variables stored on the node instead of a static schema: `variantRule: { fromValueOfPropertyPath: 'outputs', toSourceHandles: ['success'] }` reads the variable definitions saved at `properties.outputs` (the value produced by the SDK's schema-builder control) and exposes them on the listed handles. + +:::note +`outputSchema` (the previous flat `{ properties }` shape) is deprecated and still accepted, but will be removed in the next major release. Migrate to `schemaOutput`. +::: ## See also From 0f4cbeaad7b8058a20c3f6968b3240ff52429b42 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Fri, 28 Aug 2026 09:42:04 +0200 Subject: [PATCH 22/33] fix(sdk): correct node label for common suggestions --- .../variables/components/variable-text/variable-text.tsx | 9 ++++++--- packages/sdk/src/features/variables/constants.ts | 2 ++ .../stores/core/get-node-variables-suggestions.ts | 8 +++++++- .../get-deprecated-suggestions-from-output-schema.ts.ts | 5 ++++- .../get-suggestions-from-schema-ouput.ts | 5 ++++- .../get-suggestions-from-variables-index.ts | 5 ++++- .../get-suggestions-node-data.ts | 8 +++----- .../variables/stores/core/refresh-suggestions.ts | 3 +++ .../set-suggestions-node-data.ts | 8 +++++++- packages/sdk/src/features/variables/stores/types.ts | 1 + 10 files changed, 41 insertions(+), 13 deletions(-) diff --git a/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx b/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx index 2ac2d08bb..83615660c 100644 --- a/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx +++ b/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx @@ -10,7 +10,7 @@ import styles from './variable-text.module.css'; import { getNodeByIdAction } from '../../../../store-get-actions/stores/use-store-get-actions'; import { showSnackbar } from '../../../../utils/show-snackbar'; -import { VARIABLE_BRACKETS_START, VARIABLE_NODES_KEY } from '../../constants'; +import { VARIABLE_BRACKETS_START, VARIABLE_NODES_KEY, variableTypeInfoByType } from '../../constants'; import { buildMentionData } from './core/build-mention-data'; import type { VariableMentionData, @@ -47,7 +47,10 @@ const multiLineClassNames = { function defaultRenderGroupItem(suggestion: VariableSuggestion, _focused: boolean): ReactNode { return ( -
    +
    {suggestion.label} {suggestion.description && {suggestion.description}}
    @@ -191,7 +194,7 @@ export function VariableText({ if (node) { const nodeLabel = node.data?.properties?.label; - return `{{ ${nodeLabel ? `${nodeLabel} · ` : ''}${t('variables.missingMentionNodeVariablePrefix')} · ${typedId.split('.').at(-1)} }}`; + return `{{ ${nodeLabel ? `${nodeLabel}` : ''}${t('variables.missingMentionNodeVariablePrefix')} · ${typedId.split('.').at(-1)} }}`; } return `{{ ${t('variables.missingMentionNodePrefix')} (${nodeId.slice(0, 4)}...) · ${typedId.split('.').at(-1)} }}`; diff --git a/packages/sdk/src/features/variables/constants.ts b/packages/sdk/src/features/variables/constants.ts index a7dd84527..67b17454b 100644 --- a/packages/sdk/src/features/variables/constants.ts +++ b/packages/sdk/src/features/variables/constants.ts @@ -1,6 +1,7 @@ import type { VariableType, VariableTypePrimitive } from '@workflow-builder/types/node-output-schema'; export const NODE_ID_FOR_COMMON_NODE_DATA = ''; +export const NODE_LABEL_FOR_COMMON_NODE_DATA = ''; export const LOGICAL_OPERATOR = { OR: 'OR', @@ -56,6 +57,7 @@ export const comparisonOperatorsByPrimitiveType: Record { stack[sourceHandle] = suggestions.map((suggestion) => ({ ...suggestion, + display: suggestion.display.replace(NODE_LABEL_FOR_COMMON_NODE_DATA, truncate(nodeData.nodeLabel, 15)), id: suggestion.id.replace(NODE_ID_FOR_COMMON_NODE_DATA, params.nodeId), })); diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-deprecated-suggestions-from-output-schema.ts.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-deprecated-suggestions-from-output-schema.ts.ts index cad83ae1e..17078ea64 100644 --- a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-deprecated-suggestions-from-output-schema.ts.ts +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-deprecated-suggestions-from-output-schema.ts.ts @@ -2,6 +2,7 @@ import type { FlattenedPropertiesIndex } from '../../../../../node/node-output-s import { filterEmpty } from '../../../../../utils/array'; import { keyToLabel, truncate } from '../../../../../utils/text'; import type { VariableSuggestion } from '../../../components/variable-text/variable-text.types'; +import { VARIABLE_DELIMITER } from '../../../constants'; import { getVariableReferenceWithoutBracketsForNode } from '../../../utils/keys/get-variable-reference-without-brackets-for-node'; type Params = { @@ -29,7 +30,9 @@ export function getDeprecatedSuggestionsFromOutputSchema({ property ? { id: getVariableReferenceWithoutBracketsForNode({ nodeId, propertyName: propertyKey }), - display: `${truncate(nodeLabel, 15)} · ${truncate(property.label || keyToLabel(propertyKey), 15)}`, + display: [truncate(nodeLabel, 15), truncate(property.label || keyToLabel(propertyKey), 15)] + .filter(Boolean) + .join(VARIABLE_DELIMITER), label: property.label || truncate(property.label || keyToLabel(propertyKey), 25), description: property.description, type: property.type, diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-schema-ouput.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-schema-ouput.ts index 00eb00ac7..75de8c316 100644 --- a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-schema-ouput.ts +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-schema-ouput.ts @@ -3,6 +3,7 @@ import type { JsonSchema7 } from '@jsonforms/core'; import { filterEmpty } from '../../../../../utils/array'; import { keyToLabel, truncate } from '../../../../../utils/text'; import type { VariableSuggestion } from '../../../components/variable-text/variable-text.types'; +import { VARIABLE_DELIMITER } from '../../../constants'; import { getFlattenedPropertiesFromJsonSchema7 } from '../../../utils/json-schema/get-flattened-properties-from-json-schema-7'; import { getVariableReferenceWithoutBracketsForNode } from '../../../utils/keys/get-variable-reference-without-brackets-for-node'; @@ -23,7 +24,9 @@ export function getSuggestionsFromSchemaOutput({ nodeId, nodeLabel, properties } property ? { id: getVariableReferenceWithoutBracketsForNode({ nodeId, propertyName: propertyKey }), - display: `${truncate(nodeLabel, 15)} · ${truncate(property.label || keyToLabel(propertyKey), 15)}`, + display: [truncate(nodeLabel, 15), truncate(property.label || keyToLabel(propertyKey), 15)] + .filter(Boolean) + .join(VARIABLE_DELIMITER), label: property.label || truncate(property.label || keyToLabel(propertyKey), 25), description: property.description, type: property.type, diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-variables-index.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-variables-index.ts index ac682b079..1a9d5d878 100644 --- a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-variables-index.ts +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-variables-index.ts @@ -1,6 +1,7 @@ import { filterEmpty } from '../../../../../utils/array'; import { truncate } from '../../../../../utils/text'; import type { VariableSuggestion } from '../../../components/variable-text/variable-text.types'; +import { VARIABLE_DELIMITER } from '../../../constants'; import type { VariablesIndex } from '../../../types'; import { getVariableReferenceWithoutBracketsForGlobal } from '../../../utils/keys/get-variable-reference-without-brackets-for-global'; import { getVariableReferenceWithoutBracketsForNode } from '../../../utils/keys/get-variable-reference-without-brackets-for-node'; @@ -38,7 +39,9 @@ export const getSuggestionsFromVariableIndex = ({ variablesIndex, ...props }: Pa } : { id: getVariableReferenceWithoutBracketsForNode({ nodeId: props.nodeId, propertyName: definition.id }), - display: `${truncate(props.nodeLabel, 15)} · ${truncate(definition.name, 15)}`, + display: [truncate(props.nodeLabel, 15), truncate(definition.name, 15)] + .filter(Boolean) + .join(VARIABLE_DELIMITER), label: definition.name, description: definition.description, type: definition.type, diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts index b9f8e5586..ad193a327 100644 --- a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts @@ -21,7 +21,7 @@ type Response = { }; const EMPTY_NODE_SUGGESTIONS: Response = { - type: SUGGESTION_NODE_TYPE.COMMON, + type: SUGGESTION_NODE_TYPE.CUSTOM, bySourceHandle: { every: [], }, @@ -47,8 +47,7 @@ export function getSuggestionsNodeData({ definition, node }: Params): Response { ]; return { - // type: SUGGESTION_NODE_TYPE.COMMON, - type: SUGGESTION_NODE_TYPE.CUSTOM, + type: SUGGESTION_NODE_TYPE.COMMON, bySourceHandle, }; } @@ -110,8 +109,7 @@ export function getSuggestionsNodeData({ definition, node }: Params): Response { } return { - // type: SUGGESTION_NODE_TYPE.COMMON, - type: SUGGESTION_NODE_TYPE.CUSTOM, + type: SUGGESTION_NODE_TYPE.COMMON, bySourceHandle, }; } diff --git a/packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts b/packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts index c54f833c7..4320628d6 100644 --- a/packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts +++ b/packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts @@ -1,6 +1,7 @@ import type { WorkflowBuilderNode } from '../../../../node/node-data'; import { useStore } from '../../../../store/store'; import { getNodeDefinition } from '../../../../utils/validation/get-node-definition'; +import { getNodeLabelForVariable } from '../../utils/diagram/get-node-label-for-variable'; import { type VariablesSuggestionsStore, emptyVariablesSuggestionsStore, @@ -16,12 +17,14 @@ function refreshNodesSuggestions(nodes: WorkflowBuilderNode[], initialStore: Var const definition = getNodeDefinition(node); if (definition) { + const nodeLabel = getNodeLabelForVariable({ node, definition }); const { type, bySourceHandle } = getSuggestionsNodeData({ node, definition }); currentStore = setVariablesSuggestionsNodeData({ type, nodeId: node.id, nodeType: node.data.type, + nodeLabel, bySourceHandle, cachedStore: currentStore, shouldOnlyPassedStore: true, diff --git a/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts b/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts index 3bc175948..a62f2415e 100644 --- a/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts +++ b/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts @@ -1,4 +1,4 @@ -import { NODE_ID_FOR_COMMON_NODE_DATA } from '../../../constants'; +import { NODE_ID_FOR_COMMON_NODE_DATA, NODE_LABEL_FOR_COMMON_NODE_DATA, VARIABLE_DELIMITER } from '../../../constants'; import type { SuggestionNodeType, SuggestionsBySourceHandle } from '../../types'; import { SUGGESTION_NODE_TYPE } from '../../types'; import type { VariablesSuggestionsStore } from '../../use-variable-suggestions-store'; @@ -8,6 +8,7 @@ type ParamsShared = { type: SuggestionNodeType; nodeId: string; nodeType: string; + nodeLabel: string; bySourceHandle: SuggestionsBySourceHandle; }; @@ -39,6 +40,10 @@ export function setVariablesSuggestionsNodeData(params: Params): VariablesSugges sourceHandle, suggestions?.map((suggestion) => ({ ...suggestion, + display: suggestion.display + .split(VARIABLE_DELIMITER) + .map((chunk, index) => (index === 0 ? NODE_LABEL_FOR_COMMON_NODE_DATA : chunk)) + .join(VARIABLE_DELIMITER), id: suggestion.id.replace(params.nodeId, NODE_ID_FOR_COMMON_NODE_DATA), })), ]), @@ -49,6 +54,7 @@ export function setVariablesSuggestionsNodeData(params: Params): VariablesSugges [params.nodeId]: { type: SUGGESTION_NODE_TYPE.COMMON, nodeType: params.nodeType, + nodeLabel: params.nodeLabel, }, }, }; diff --git a/packages/sdk/src/features/variables/stores/types.ts b/packages/sdk/src/features/variables/stores/types.ts index b34fb6fa8..3484c802c 100644 --- a/packages/sdk/src/features/variables/stores/types.ts +++ b/packages/sdk/src/features/variables/stores/types.ts @@ -19,6 +19,7 @@ export type SuggestionsNodeData = // References array kept in commonByType (we don't need to store the same array for each node) type: typeof SUGGESTION_NODE_TYPE.COMMON; nodeType: string; + nodeLabel: string; } | { // Custom setup for nodes that require configuration based on data in node From b92bf143ab2d84c7e6db5e7d59ab5b4a46b7d8c1 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Fri, 28 Aug 2026 10:19:11 +0200 Subject: [PATCH 23/33] docs(sdk): migration startegy for deprecated --- .../docs/guides/use-variable-picker.mdx | 2 +- packages/sdk/src/node/deprecated/README.md | 105 ++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/src/node/deprecated/README.md diff --git a/apps/docs/src/content/docs/guides/use-variable-picker.mdx b/apps/docs/src/content/docs/guides/use-variable-picker.mdx index 416b6ece6..a91cb2790 100644 --- a/apps/docs/src/content/docs/guides/use-variable-picker.mdx +++ b/apps/docs/src/content/docs/guides/use-variable-picker.mdx @@ -238,7 +238,7 @@ const schemaOutput: NodeSchemaOutput = { `onlyIfPropertyNameEquals.path` is a dot path into the node's `properties`; the variant applies when the value at that path strictly equals `value`. -A variant can also take its properties from user-defined variables stored on the node instead of a static schema: `variantRule: { fromValueOfPropertyPath: 'outputs', toSourceHandles: ['success'] }` reads the variable definitions saved at `properties.outputs` (the value produced by the SDK's schema-builder control) and exposes them on the listed handles. +A variant can also take its properties from user-defined variables stored on the node instead of a static schema: `variantRule: { fromValueOfPropertyPath: 'outputs', toSourceHandles: ['success'] }` reads the variable definitions saved at `properties.outputs`. :::note `outputSchema` (the previous flat `{ properties }` shape) is deprecated and still accepted, but will be removed in the next major release. Migrate to `schemaOutput`. diff --git a/packages/sdk/src/node/deprecated/README.md b/packages/sdk/src/node/deprecated/README.md new file mode 100644 index 000000000..64d77b004 --- /dev/null +++ b/packages/sdk/src/node/deprecated/README.md @@ -0,0 +1,105 @@ +# Migrating `outputSchema` → `schemaOutput` + +`PaletteItem.outputSchema` (`DeprecatedNodeOutputSchema`) is deprecated and will be removed in **3.0**. Replace it with `PaletteItem.schemaOutput` (`NodeSchemaOutput`). + +Why: the new format is a JSON Schema (same dialect as `NodeSchema`), supports nested objects, and scopes variables **per source handle** — the `error` port no longer advertises `success` variables. + +Both fields may coexist during migration; `schemaOutput` wins when present, `outputSchema` is only read when `schemaOutput` is missing. + +## Strategy + +1. Add `schemaOutput` next to the existing `outputSchema` (see mapping below). +2. Verify the variable picker on a downstream node shows the same variables. +3. Delete `outputSchema`. +4. Repeat per node. Ship in any order — no big-bang needed. + +## Mapping + +| Old (`outputSchema`) | New (`schemaOutput`) | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `properties: { key: { type, label, description } }` | `bySourceHandle.: { type: 'object', properties: { key: { type, title, description } } }` | +| flat, dot-notation keys (`'result.status'`) | nested `properties` (`result: { type: 'object', properties: { status } }`) | +| `label` | `title` | +| `type: 'datetime'` | `type: 'string', format: 'date-time'` | +| `type: 'date'` | `type: 'string'` (no dedicated mapping yet) | +| same variables on every handle | `bySourceHandle.every` | +| `variants: { name: { variantRule, properties } }` (record) | `variants: [{ variantRule, bySourceHandle }]` (array) | +| `variantRule: { dataPropertyName, dataPropertyValue }` | `variantRule: { onlyIfPropertyNameEquals: { path, value } }` (`path` supports dot notation) | +| first matching variant only | **all** matching variants are merged | +| — | `variantRule: { fromValueOfPropertyPath, toSourceHandles }` for user-defined output shapes | + +## Example: default + +```ts +// before +outputSchema: { + type: 'default', + properties: { + status: { type: 'string', label: 'Status' }, + errorMessage: { type: 'string', label: 'Error Message' }, + }, +} + +// after — success/error split +schemaOutput: { + type: 'default', + bySourceHandle: { + success: { + type: 'object', + properties: { status: { type: 'string', title: 'Status' } }, + }, + error: { + type: 'object', + properties: { errorMessage: { type: 'string', title: 'Error Message' } }, + }, + }, +} +``` + +Use `bySourceHandle.every` instead of named handles when the node has a single output or all handles share the shape. + +## Example: variant + +```ts +// before +outputSchema: { + type: 'variant', + variants: { + text: { + variantRule: { dataPropertyName: 'mode', dataPropertyValue: 'text' }, + properties: { text: { type: 'string', label: 'Text' } }, + }, + json: { + variantRule: { dataPropertyName: 'mode', dataPropertyValue: 'json' }, + properties: { data: { type: 'object', label: 'Data' } }, + }, + }, +} + +// after +schemaOutput: { + type: 'variant', + variants: [ + { + variantRule: { onlyIfPropertyNameEquals: { path: 'mode', value: 'text' } }, + bySourceHandle: { + every: { type: 'object', properties: { text: { type: 'string', title: 'Text' } } }, + }, + }, + { + variantRule: { onlyIfPropertyNameEquals: { path: 'mode', value: 'json' } }, + bySourceHandle: { + every: { type: 'object', properties: { data: { type: 'object', title: 'Data' } } }, + }, + }, + ], +} +``` + +A variant with `variantRule: undefined` always matches — use it for variables shared across all modes (old format allowed only one variant to apply; new merges them). + +## Gotchas + +- Variable references are unchanged (`{{nodeId.result.status}}`), so existing diagrams keep working. +- If a node previously exposed error fields on every handle, moving them to `bySourceHandle.error` is a behaviour change for downstream nodes on the success path — intended, but check templates. +- Reference: `apps/demo/src/app/data/nodes/*/schema-output.ts`; `action/action.ts` still carries the old format as a side-by-side example. From 47e9a62a7167e2f0fbf7ec8ba4fd9bf0208a6755 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Fri, 28 Aug 2026 11:25:30 +0200 Subject: [PATCH 24/33] fix(sdk): broken string for missing --- .../variables/components/variable-text/variable-text.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx b/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx index 83615660c..14e0e35ae 100644 --- a/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx +++ b/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx @@ -194,7 +194,7 @@ export function VariableText({ if (node) { const nodeLabel = node.data?.properties?.label; - return `{{ ${nodeLabel ? `${nodeLabel}` : ''}${t('variables.missingMentionNodeVariablePrefix')} · ${typedId.split('.').at(-1)} }}`; + return `{{ ${nodeLabel ? `${nodeLabel} · ` : ''}${t('variables.missingMentionNodeVariablePrefix')} · ${typedId.split('.').at(-1)} }}`; } return `{{ ${t('variables.missingMentionNodePrefix')} (${nodeId.slice(0, 4)}...) · ${typedId.split('.').at(-1)} }}`; From 748119a57fdff722a53763d6fa0a2a987b00953e Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Fri, 28 Aug 2026 12:08:13 +0200 Subject: [PATCH 25/33] chore(sdk): typos fixed --- apps/docs/src/content/docs/node-schemas/form-controls.md | 6 +++--- packages/sdk/src/features/variables/constants.ts | 1 - .../control/modal-schema-builder-variable-config.tsx | 4 ++-- .../pane-remove-variable/pane-remove-variable.module.css | 3 ++- ...s => get-deprecated-suggestions-from-output-schema.ts} | 0 ...ema-ouput.ts => get-suggestions-from-schema-output.ts} | 0 .../get-suggestions-node-data.ts | 5 ++--- packages/sdk/src/utils/text.ts | 8 ++++---- 8 files changed, 13 insertions(+), 14 deletions(-) rename packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/{get-deprecated-suggestions-from-output-schema.ts.ts => get-deprecated-suggestions-from-output-schema.ts} (100%) rename packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/{get-suggestions-from-schema-ouput.ts => get-suggestions-from-schema-output.ts} (100%) diff --git a/apps/docs/src/content/docs/node-schemas/form-controls.md b/apps/docs/src/content/docs/node-schemas/form-controls.md index b04c0c5d4..c68895185 100644 --- a/apps/docs/src/content/docs/node-schemas/form-controls.md +++ b/apps/docs/src/content/docs/node-schemas/form-controls.md @@ -154,7 +154,7 @@ Multi-line variant of `VariableText`. Same `{{...}}` placeholder behaviour. ## `MessageOnError` -Inline message that surfaces when a node-level validation error matches the `scope`. Renders nothing if there's no error on that property — useful for context-specific guidance ("This input requires an upstream variable") next to the affected field. +Inline message that surfaces when a node-level validation error matches the `scope`. Renders nothing if there's no error on that property — useful for context-specific guidance ("A custom error message") next to the affected field. | Prop | Type | Required | Notes | | ------ | ------ | -------- | ----------------------------------------------------------------------------------------------------- | @@ -163,8 +163,8 @@ Inline message that surfaces when a node-level validation error matches the `sco ```ts { type: 'MessageOnError', - scope: '#/properties/missingPreviousVariable', - text: 'This field needs a variable from an upstream node.', + scope: '#/properties/customError', + text: 'A custom error message.', } ``` diff --git a/packages/sdk/src/features/variables/constants.ts b/packages/sdk/src/features/variables/constants.ts index 67b17454b..0b04b2e5d 100644 --- a/packages/sdk/src/features/variables/constants.ts +++ b/packages/sdk/src/features/variables/constants.ts @@ -123,7 +123,6 @@ export const variableTypeInfoByType: Record = }, }; -// TODO: Remove filter when other formats will be supported export const variableTypesOptions: VariableTypeOption[] = Object.values(variableTypeInfoByType).filter( ({ type, baseType }) => type === baseType, ); diff --git a/packages/sdk/src/features/variables/modals/control/modal-schema-builder-variable-config.tsx b/packages/sdk/src/features/variables/modals/control/modal-schema-builder-variable-config.tsx index 2560ff352..50f6408fb 100644 --- a/packages/sdk/src/features/variables/modals/control/modal-schema-builder-variable-config.tsx +++ b/packages/sdk/src/features/variables/modals/control/modal-schema-builder-variable-config.tsx @@ -7,7 +7,7 @@ import { Icon } from '@workflow-builder/icons'; import styles from './modal-schema-builder-variable-config.module.css'; import { showSnackbar } from '../../../../utils/show-snackbar'; -import { labelToFloorCase } from '../../../../utils/text'; +import { labelToSnakeCase } from '../../../../utils/text'; import { closeModal, openModal } from '../../../modals/stores/use-modal-store'; import type { VariableDefinition, VariablesIndex } from '../../types'; import { @@ -24,7 +24,7 @@ type Props = { function ModalSchemaBuilderVariableConfig(props: Props) { const handleSave: PaneEditVariableProps['onSave'] = useCallback( (definition: VariableDefinition) => { - const floorIdForAPI = labelToFloorCase(definition.name); + const floorIdForAPI = labelToSnakeCase(definition.name); if (props.variant === VARIABLE_FORM_VARIANT.ADD && props.variablesById[floorIdForAPI]) { showSnackbar({ diff --git a/packages/sdk/src/features/variables/modals/shared/components/pane-remove-variable/pane-remove-variable.module.css b/packages/sdk/src/features/variables/modals/shared/components/pane-remove-variable/pane-remove-variable.module.css index 0b30d3eb0..26d48e326 100644 --- a/packages/sdk/src/features/variables/modals/shared/components/pane-remove-variable/pane-remove-variable.module.css +++ b/packages/sdk/src/features/variables/modals/shared/components/pane-remove-variable/pane-remove-variable.module.css @@ -18,7 +18,8 @@ margin: 0; padding: var(--wb-token-spacing-spacing-12, 12px) var(--wb-token-spacing-spacing-16, 16px) var(--wb-token-spacing-spacing-12, 12px) 16px; - ą li { + + li { margin: 0; padding: 0; margin-left: var(--wb-token-spacing-spacing-16, 16px); diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-deprecated-suggestions-from-output-schema.ts.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-deprecated-suggestions-from-output-schema.ts similarity index 100% rename from packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-deprecated-suggestions-from-output-schema.ts.ts rename to packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-deprecated-suggestions-from-output-schema.ts diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-schema-ouput.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-schema-output.ts similarity index 100% rename from packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-schema-ouput.ts rename to packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-schema-output.ts diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts index ad193a327..dc65b5e0f 100644 --- a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts @@ -6,8 +6,8 @@ import { getByPath } from '../../../../../utils/object'; import type { VariablesIndex } from '../../../types'; import { getNodeLabelForVariable } from '../../../utils/diagram/get-node-label-for-variable'; import { SUGGESTION_NODE_TYPE, type SuggestionNodeType, type SuggestionsBySourceHandle } from '../../types'; -import { getDeprecatedSuggestionsFromOutputSchema } from './get-deprecated-suggestions-from-output-schema.ts'; -import { getSuggestionsFromSchemaOutput } from './get-suggestions-from-schema-ouput'; +import { getDeprecatedSuggestionsFromOutputSchema } from './get-deprecated-suggestions-from-output-schema'; +import { getSuggestionsFromSchemaOutput } from './get-suggestions-from-schema-output'; import { getSuggestionsFromVariableIndex } from './get-suggestions-from-variables-index'; type Params = { @@ -172,7 +172,6 @@ export function getSuggestionsNodeData({ definition, node }: Params): Response { }); for (const sourceHandle of sourceHandlesToAdd) { - console.log(sourceHandle); bySourceHandle[sourceHandle] = [...(bySourceHandle[sourceHandle] || []), ...suggestions]; } } diff --git a/packages/sdk/src/utils/text.ts b/packages/sdk/src/utils/text.ts index 55bb42115..0cced8d35 100644 --- a/packages/sdk/src/utils/text.ts +++ b/packages/sdk/src/utils/text.ts @@ -4,7 +4,7 @@ export const capitalizeFirstLetter = (text: string | undefined = '') => export const truncate = (text: string, maxLength: number) => text.length > maxLength ? `${text.slice(0, maxLength)}...` : text; -const floorCaseToPascalCase = (text: string): string => { +const snakeCaseToPascalCase = (text: string): string => { const textWithoutSpaces = text.replaceAll(' ', '_'); if (textWithoutSpaces.includes('_') === false) { return text; @@ -21,7 +21,7 @@ export const keyToLabel = (key: string): string => { if (!key) { return ''; } - const pascalCaseKey = floorCaseToPascalCase(key); + const pascalCaseKey = snakeCaseToPascalCase(key); const words = pascalCaseKey.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z]+/g) ?? []; return words @@ -42,10 +42,10 @@ export const keyToLabel = (key: string): string => { .join(' '); }; -export const labelToFloorCase = (label: string) => { +export const labelToSnakeCase = (label: string) => { // Normalization guessing the best strategy const labelToUse = label.replaceAll(' ', '_'); - const pascalCaseLabel = floorCaseToPascalCase(labelToUse); + const pascalCaseLabel = snakeCaseToPascalCase(labelToUse); const words = pascalCaseLabel.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+/g) ?? []; From db512e68dedeee02717f133397803d34b6c0f17f Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Fri, 28 Aug 2026 13:21:25 +0200 Subject: [PATCH 26/33] fix(sdk): variable-select blur action fix --- .../variable-select/variable-select.tsx | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/sdk/src/features/variables/components/variable-select/variable-select.tsx b/packages/sdk/src/features/variables/components/variable-select/variable-select.tsx index 7022609e0..fad99abb7 100644 --- a/packages/sdk/src/features/variables/components/variable-select/variable-select.tsx +++ b/packages/sdk/src/features/variables/components/variable-select/variable-select.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import styles from './variable-select.module.css'; @@ -17,30 +17,36 @@ type Props = VariableTextProps & { export function VariableSelect({ onChange, onBlur, endAdornment, ...props }: Props) { const { t } = useTranslation(); + // We use ref because state mutates the onBlur which prop drill isn't updated in time + const inputValueRef = useRef(props.value == null ? '' : String(props.value)); + + useEffect(() => { + inputValueRef.current = props.value == null ? '' : String(props.value); + }, [props.value]); + const handleOnChange: VariableTextProps['onChange'] = useCallback( (value) => { const newValue = value ? VARIABLE_BRACKETS_START + value.split(VARIABLE_BRACKETS_START).at(-1) : ''; const valueToPass = getIsStringVariableReference(newValue) ? newValue : ''; + inputValueRef.current = valueToPass; onChange(valueToPass); focusNextElement(); }, [onChange], ); - const handleOnBlur: VariableTextProps['onBlur'] = useCallback( - (value: string) => { - if (!onBlur) { - return; - } + const handleOnBlur: VariableTextProps['onBlur'] = useCallback(() => { + if (!onBlur) { + return; + } - const newValue = value ? VARIABLE_BRACKETS_START + value.split(VARIABLE_BRACKETS_START).at(-1) : ''; - const valueToPass = getIsStringVariableReference(newValue) ? newValue : ''; + const inputValue = inputValueRef.current; + const newValue = inputValue ? VARIABLE_BRACKETS_START + inputValue.split(VARIABLE_BRACKETS_START).at(-1) : ''; + const valueToPass = getIsStringVariableReference(newValue) ? newValue : ''; - onBlur(valueToPass); - }, - [onBlur], - ); + onBlur(valueToPass); + }, [onBlur]); return (
    From 72ffde4907f61974bd564c5df9dcb1d2db609fd5 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Sat, 5 Sep 2026 21:34:50 +0200 Subject: [PATCH 27/33] docs(sdk): control documentation & restoring export --- .changeset/sdk-variables-json-schema.md | 2 +- .../docs/node-schemas/form-controls.md | 32 +++++++++++++++++++ ...flattened-properties-from-json-schema-7.ts | 4 --- packages/sdk/src/index.ts | 1 + 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/.changeset/sdk-variables-json-schema.md b/.changeset/sdk-variables-json-schema.md index 99a6c3225..17979251e 100644 --- a/.changeset/sdk-variables-json-schema.md +++ b/.changeset/sdk-variables-json-schema.md @@ -1,5 +1,5 @@ --- -'@workflowbuilder/sdk': patch +'@workflowbuilder/sdk': minor --- Variables now support returning different values for different source handles. Variables are now defined using a JSON schema rather than a flattened list, providing a foundation for future validation. diff --git a/apps/docs/src/content/docs/node-schemas/form-controls.md b/apps/docs/src/content/docs/node-schemas/form-controls.md index c68895185..88e3e2aaf 100644 --- a/apps/docs/src/content/docs/node-schemas/form-controls.md +++ b/apps/docs/src/content/docs/node-schemas/form-controls.md @@ -152,6 +152,38 @@ Multi-line variant of `VariableText`. Same `{{...}}` placeholder behaviour. { type: 'VariableTextArea', scope: '#/properties/messageBody', minRows: 4 } ``` +## `VariableDynamic` + +Typed input that lets the user **either type a literal value or pick a single upstream variable**. A toggle button in the field switches between the two modes: the variable mode shows a select listing only upstream outputs whose type matches `variableType`, the manual mode shows a widget picked from `variableType`: + +| `variableType` | Manual widget | Saved value | +| ---------------------- | ----------------------------------- | ------------------------------------------------ | +| `'string'` | Text input with inline `{{` picker | trimmed `string`, or `undefined` when empty | +| `'number'` | Text input with numeric validation | `number`, or `undefined` when not a valid number | +| `'boolean'` | True / False select | `boolean`, or `undefined` when unset | +| `'date'`, `'datetime'` | Date picker (+ time for `datetime`) | ISO 8601 `string` | + +Unlike `VariableText`, the stored value is either a typed literal or exactly one variable reference — `{{nodes..}}` for an upstream node output or `{{global.}}` for a global variable. Mixed text is not possible. The reference is always stored as a `string`, even when `variableType` is `'number'` or `'boolean'`. The variable toggle is hidden when no upstream output of a compatible type exists. + +| Prop | Type | Required | Notes | +| -------------- | ----------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `variableType` | `'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime'` | yes | Drives the manual widget, the value coercion on blur, and which upstream outputs are suggested (`string` also accepts `number` / `date` / `datetime`). | +| `placeholder` | string | no | Empty-state hint. Applies to the `string` / `number` widgets only. | + +```ts +// schema.ts +{ + retries: { type: 'number' }, + untilDate: { type: 'string' }, +} +``` + +```ts +// uischema.ts +{ type: 'VariableDynamic', scope: '#/properties/retries', variableType: 'number' } +{ type: 'VariableDynamic', scope: '#/properties/untilDate', variableType: 'date' } +``` + ## `MessageOnError` Inline message that surfaces when a node-level validation error matches the `scope`. Renders nothing if there's no error on that property — useful for context-specific guidance ("A custom error message") next to the affected field. diff --git a/packages/sdk/src/features/variables/utils/json-schema/get-flattened-properties-from-json-schema-7.ts b/packages/sdk/src/features/variables/utils/json-schema/get-flattened-properties-from-json-schema-7.ts index fa785579a..8bc585470 100644 --- a/packages/sdk/src/features/variables/utils/json-schema/get-flattened-properties-from-json-schema-7.ts +++ b/packages/sdk/src/features/variables/utils/json-schema/get-flattened-properties-from-json-schema-7.ts @@ -58,10 +58,6 @@ export function getFlattenedPropertiesFromJsonSchema7(schema: JsonSchema7): Flat }); return { - response: { - type: 'object', - label: 'Response', - }, ...fields, }; } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f1bcc678b..1f18bfb39 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -230,6 +230,7 @@ export { EDGE_OFFSET, SELF_CONNECTING_EDGE_LABEL_OFFSET, } from './features/diagram/edges/edge.consts'; +export { VARIABLE_NODES_KEY } from './features/variables/constants'; // ============================================================================= // i18n From bbdad7fa81226f619c53ed42beb041e4dc7e4559 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Sat, 5 Sep 2026 22:39:35 +0200 Subject: [PATCH 28/33] fix(sdk): refreshing variables for input focused before suggestions recalc --- .../sdk/src/features/variables/hooks/use-node-variables.ts | 5 +++-- .../set-suggestions-node-data/set-suggestions-node-data.ts | 2 ++ .../variables/stores/use-variable-suggestions-store.ts | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/features/variables/hooks/use-node-variables.ts b/packages/sdk/src/features/variables/hooks/use-node-variables.ts index 3e524cfff..7ba051622 100644 --- a/packages/sdk/src/features/variables/hooks/use-node-variables.ts +++ b/packages/sdk/src/features/variables/hooks/use-node-variables.ts @@ -5,6 +5,7 @@ import type { VariableType } from '../../../node/node-output-schema'; import { useStore } from '../../../store/store'; import type { VariableSuggestion, VariableSuggestionGroup } from '../components/variable-text/variable-text.types'; import { getSuggestionsFromVariableIndex } from '../stores/core/get-suggestions-node-data/get-suggestions-from-variables-index'; +import { useVariablesSuggestionsStore } from '../stores/use-variable-suggestions-store'; import { filterSuggestionsByTypes } from '../utils/core/filter-suggestions-by-types'; import { getAvailableVariablesByNodeId } from '../utils/core/get-available-variables-by-node-id'; @@ -23,7 +24,7 @@ export function useNodeVariables(nodeId: string | undefined, options?: Options): const globalVariables = useStore((store) => store.globalVariables); const nodes = useStore((store) => store.nodes); const edges = useStore((store) => store.edges); - + const lastUpdateTimestamp = useVariablesSuggestionsStore((store) => store.lastUpdateTimestamp); const { t } = useTranslation(); const globalSuggestionsGroups = useMemo(() => { @@ -63,7 +64,7 @@ export function useNodeVariables(nodeId: string | undefined, options?: Options): // Variables can’t change while the modal containing them is in use, so we only need to refresh them when they change. // .length is critical here for performance. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [nodeId, edges.length, nodes.length]); + }, [lastUpdateTimestamp, nodeId, lastUpdateTimestamp, excludeTypes, includeTypes, edges.length, nodes.length]); return useMemo(() => { const suggestionGroups = [...globalSuggestionsGroups, ...nodeSuggestionsGroups]; diff --git a/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts b/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts index a62f2415e..a76da8a26 100644 --- a/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts +++ b/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts @@ -33,6 +33,7 @@ export function setVariablesSuggestionsNodeData(params: Params): VariablesSugges if (params.type === SUGGESTION_NODE_TYPE.COMMON) { storeToMutate = { ...storeToMutate, + lastUpdateTimestamp: Date.now(), commonByType: { ...storeToMutate.commonByType, [params.nodeType]: Object.fromEntries( @@ -61,6 +62,7 @@ export function setVariablesSuggestionsNodeData(params: Params): VariablesSugges } else if (params.type === SUGGESTION_NODE_TYPE.CUSTOM) { storeToMutate = { ...storeToMutate, + lastUpdateTimestamp: Date.now(), byNodeId: { ...storeToMutate.byNodeId, [params.nodeId]: { diff --git a/packages/sdk/src/features/variables/stores/use-variable-suggestions-store.ts b/packages/sdk/src/features/variables/stores/use-variable-suggestions-store.ts index 6fa9bd7ea..f70ecd381 100644 --- a/packages/sdk/src/features/variables/stores/use-variable-suggestions-store.ts +++ b/packages/sdk/src/features/variables/stores/use-variable-suggestions-store.ts @@ -4,6 +4,7 @@ import { devtools } from 'zustand/middleware'; import type { SuggestionsBySourceHandle, SuggestionsNodeData } from './types'; export type VariablesSuggestionsStore = { + lastUpdateTimestamp: number; commonByType: { [nodeType: string]: SuggestionsBySourceHandle | undefined; }; @@ -13,6 +14,7 @@ export type VariablesSuggestionsStore = { }; export const emptyVariablesSuggestionsStore: VariablesSuggestionsStore = { + lastUpdateTimestamp: 0, commonByType: {}, byNodeId: {}, }; From 6985785c928f6780b0366fc68abde585160158e7 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Sat, 5 Sep 2026 22:42:28 +0200 Subject: [PATCH 29/33] fix(sdk): refreshing variables for input focused before suggestions recalc --- packages/sdk/src/features/variables/hooks/use-node-variables.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/features/variables/hooks/use-node-variables.ts b/packages/sdk/src/features/variables/hooks/use-node-variables.ts index 7ba051622..a2ede0676 100644 --- a/packages/sdk/src/features/variables/hooks/use-node-variables.ts +++ b/packages/sdk/src/features/variables/hooks/use-node-variables.ts @@ -64,7 +64,7 @@ export function useNodeVariables(nodeId: string | undefined, options?: Options): // Variables can’t change while the modal containing them is in use, so we only need to refresh them when they change. // .length is critical here for performance. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lastUpdateTimestamp, nodeId, lastUpdateTimestamp, excludeTypes, includeTypes, edges.length, nodes.length]); + }, [lastUpdateTimestamp, nodeId, excludeTypes, includeTypes, edges.length, nodes.length]); return useMemo(() => { const suggestionGroups = [...globalSuggestionsGroups, ...nodeSuggestionsGroups]; From 1606dead927d86f652c62ec8dded2e65f58e6c83 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Mon, 7 Sep 2026 09:48:37 +0200 Subject: [PATCH 30/33] docs(sdk): comment in package --- packages/sdk/src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 1f18bfb39..193fb1fda 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -230,7 +230,6 @@ export { EDGE_OFFSET, SELF_CONNECTING_EDGE_LABEL_OFFSET, } from './features/diagram/edges/edge.consts'; -export { VARIABLE_NODES_KEY } from './features/variables/constants'; // ============================================================================= // i18n @@ -282,6 +281,9 @@ export { showSnackbar } from './utils/show-snackbar'; // Variables // ============================================================================= +// Prefix of a node-output reference (`{{nodes..}}`). +export { VARIABLE_NODES_KEY } from './features/variables/constants'; + // The output contract of a node — the shape downstream nodes can reference as // variables. Attach it via `NodeData.schemaOutput`. Two forms: // `{ type: 'default', bySourceHandle }` maps each source handle to a JSON From 8338c97f45930d1ef3d5f15bce849915fdd00eeb Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Wed, 9 Sep 2026 16:57:27 +0200 Subject: [PATCH 31/33] fix(sdk): store recalc counter + docs --- .../demo/src/app/data/nodes/delay/uischema.ts | 2 +- .../dependencies/dependencies.tsx | 4 +-- .../conditions-form.tsx | 4 +-- .../variable-dynamic-control.tsx | 4 +-- .../variable-text-area-control.tsx | 4 +-- .../variable-text-control.tsx | 4 +-- .../variables/hooks/use-node-variables.ts | 27 ++++++++++++++----- .../get-suggestions-from-schema-output.ts | 6 ++--- .../stores/core/refresh-suggestions.ts | 6 ++++- .../set-suggestions-node-data.ts | 4 +-- .../stores/use-variable-suggestions-store.ts | 4 +-- packages/sdk/src/index.ts | 3 +-- packages/sdk/src/utils/text.ts | 15 +++++++++++ 13 files changed, 59 insertions(+), 28 deletions(-) diff --git a/apps/demo/src/app/data/nodes/delay/uischema.ts b/apps/demo/src/app/data/nodes/delay/uischema.ts index f67cd65c4..730f08e4a 100644 --- a/apps/demo/src/app/data/nodes/delay/uischema.ts +++ b/apps/demo/src/app/data/nodes/delay/uischema.ts @@ -105,7 +105,7 @@ const untilSpecificProperties: PaletteItem['uischema'] = { type: 'VariableDynamic', scope: scope('properties.untilDate'), label: 'Wait until', - variableType: 'date', + variableType: 'datetime', }, ], }; diff --git a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx index e95ff2a50..7d8f68cf7 100644 --- a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx +++ b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx @@ -23,13 +23,13 @@ export function Dependencies({ conditions, onClick, disabled = false, hasError } }, [conditions]); const selection = useSingleSelectedElement(); - const { suggestionGroups, totalVariables } = useNodeVariables(selection?.node?.id); + const { suggestionGroups, variablesKey } = useNodeVariables(selection?.node?.id); return ( -
    +
    {conditions.map((condition, index) => ( store.globalVariables); const nodes = useStore((store) => store.nodes); const edges = useStore((store) => store.edges); - const lastUpdateTimestamp = useVariablesSuggestionsStore((store) => store.lastUpdateTimestamp); + const lastUpdateIndex = useVariablesSuggestionsStore((store) => store.lastUpdateIndex); const { t } = useTranslation(); const globalSuggestionsGroups = useMemo(() => { @@ -64,19 +67,29 @@ export function useNodeVariables(nodeId: string | undefined, options?: Options): // Variables can’t change while the modal containing them is in use, so we only need to refresh them when they change. // .length is critical here for performance. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lastUpdateTimestamp, nodeId, excludeTypes, includeTypes, edges.length, nodes.length]); + }, [lastUpdateIndex, nodeId, excludeTypes, includeTypes, edges.length, nodes.length]); return useMemo(() => { const suggestionGroups = [...globalSuggestionsGroups, ...nodeSuggestionsGroups]; - const totalVariables = suggestionGroups.reduce((stack: number, group) => { - stack += group.suggestions.length; + const { totalVariables, variablesKey } = suggestionGroups.reduce( + (stack: Omit, group) => { + stack.totalVariables += group.suggestions.length; + for (const suggestion of group.suggestions) { + stack.variablesKey += `-${suggestion.id}`; + } - return stack; - }, 0); + return stack; + }, + { + totalVariables: 0, + variablesKey: 'vars', + }, + ); return { suggestionGroups, totalVariables, + variablesKey, }; }, [globalSuggestionsGroups, nodeSuggestionsGroups]); } diff --git a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-schema-output.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-schema-output.ts index 75de8c316..1b9a2d302 100644 --- a/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-schema-output.ts +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-schema-output.ts @@ -1,7 +1,7 @@ import type { JsonSchema7 } from '@jsonforms/core'; import { filterEmpty } from '../../../../../utils/array'; -import { keyToLabel, truncate } from '../../../../../utils/text'; +import { keyToLabel, pathToLabel, truncate } from '../../../../../utils/text'; import type { VariableSuggestion } from '../../../components/variable-text/variable-text.types'; import { VARIABLE_DELIMITER } from '../../../constants'; import { getFlattenedPropertiesFromJsonSchema7 } from '../../../utils/json-schema/get-flattened-properties-from-json-schema-7'; @@ -24,11 +24,11 @@ export function getSuggestionsFromSchemaOutput({ nodeId, nodeLabel, properties } property ? { id: getVariableReferenceWithoutBracketsForNode({ nodeId, propertyName: propertyKey }), - display: [truncate(nodeLabel, 15), truncate(property.label || keyToLabel(propertyKey), 15)] + display: [truncate(nodeLabel, 15), truncate(property.label || pathToLabel(propertyKey), 15)] .filter(Boolean) .join(VARIABLE_DELIMITER), label: property.label || truncate(property.label || keyToLabel(propertyKey), 25), - description: property.description, + description: property.description || keyToLabel(propertyKey), type: property.type, } : undefined, diff --git a/packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts b/packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts index 4320628d6..e555041b6 100644 --- a/packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts +++ b/packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts @@ -36,9 +36,13 @@ function refreshNodesSuggestions(nodes: WorkflowBuilderNode[], initialStore: Var } export function refreshAllSuggestions() { + const currentStore = useVariablesSuggestionsStore.getState(); const { nodes } = useStore.getState(); - refreshNodesSuggestions(nodes, { ...emptyVariablesSuggestionsStore }); + refreshNodesSuggestions(nodes, { + ...emptyVariablesSuggestionsStore, + lastUpdateIndex: currentStore.lastUpdateIndex + 1, + }); } export function refreshNodesIdsSuggestions(nodesIds: string[]) { diff --git a/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts b/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts index a76da8a26..8c94c3e5f 100644 --- a/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts +++ b/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts @@ -33,7 +33,7 @@ export function setVariablesSuggestionsNodeData(params: Params): VariablesSugges if (params.type === SUGGESTION_NODE_TYPE.COMMON) { storeToMutate = { ...storeToMutate, - lastUpdateTimestamp: Date.now(), + lastUpdateIndex: 1, commonByType: { ...storeToMutate.commonByType, [params.nodeType]: Object.fromEntries( @@ -62,7 +62,7 @@ export function setVariablesSuggestionsNodeData(params: Params): VariablesSugges } else if (params.type === SUGGESTION_NODE_TYPE.CUSTOM) { storeToMutate = { ...storeToMutate, - lastUpdateTimestamp: Date.now(), + lastUpdateIndex: Date.now(), byNodeId: { ...storeToMutate.byNodeId, [params.nodeId]: { diff --git a/packages/sdk/src/features/variables/stores/use-variable-suggestions-store.ts b/packages/sdk/src/features/variables/stores/use-variable-suggestions-store.ts index f70ecd381..0fce5b8af 100644 --- a/packages/sdk/src/features/variables/stores/use-variable-suggestions-store.ts +++ b/packages/sdk/src/features/variables/stores/use-variable-suggestions-store.ts @@ -4,7 +4,7 @@ import { devtools } from 'zustand/middleware'; import type { SuggestionsBySourceHandle, SuggestionsNodeData } from './types'; export type VariablesSuggestionsStore = { - lastUpdateTimestamp: number; + lastUpdateIndex: number; commonByType: { [nodeType: string]: SuggestionsBySourceHandle | undefined; }; @@ -14,7 +14,7 @@ export type VariablesSuggestionsStore = { }; export const emptyVariablesSuggestionsStore: VariablesSuggestionsStore = { - lastUpdateTimestamp: 0, + lastUpdateIndex: 0, commonByType: {}, byNodeId: {}, }; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 193fb1fda..2c241b7e3 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -294,8 +294,7 @@ export type { NodeSchemaOutput } from './node/node-output-schema'; // Every variable a node can reference — global variables plus the outputs of // its ancestors — grouped for a picker UI. Optionally narrowed by variable type -// (`includeTypes` / `excludeTypes`). Recomputes only when node/edge counts -// change, so it is cheap to call from a modal or properties panel. +// (`includeTypes` / `excludeTypes`). export { useNodeVariables } from './features/variables/hooks/use-node-variables'; // Imperative, non-React counterpart: the variables a single node exposes on one diff --git a/packages/sdk/src/utils/text.ts b/packages/sdk/src/utils/text.ts index 0cced8d35..d22088cff 100644 --- a/packages/sdk/src/utils/text.ts +++ b/packages/sdk/src/utils/text.ts @@ -42,6 +42,21 @@ export const keyToLabel = (key: string): string => { .join(' '); }; +export const pathToLabel = (path: string): string => { + if (!path) { + return ''; + } + + if (path.includes('.')) { + const lastKey = path.split('.').at(-1); + if (lastKey) { + return keyToLabel(lastKey); + } + } + + return keyToLabel(path); +}; + export const labelToSnakeCase = (label: string) => { // Normalization guessing the best strategy const labelToUse = label.replaceAll(' ', '_'); From e38f72508a45020415b88f2b901c71428e541f46 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Wed, 9 Sep 2026 21:03:30 +0200 Subject: [PATCH 32/33] fix(sdk): performence improvments --- .../dynamic-conditions-form/conditions-form.tsx | 4 ++-- .../variable-dynamic-control.tsx | 3 ++- .../variable-text-area-control.tsx | 10 +++++++--- .../variable-text-control/variable-text-control.tsx | 10 +++++++--- packages/sdk/src/features/variables/constants.ts | 8 +++++--- .../src/features/variables/hooks/use-node-variables.ts | 8 +++----- .../set-suggestions-node-data.ts | 4 ++-- .../variables/stores/use-variable-suggestions-store.ts | 2 +- 8 files changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form/conditions-form.tsx b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form/conditions-form.tsx index 35e9fa01a..e50624195 100644 --- a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form/conditions-form.tsx +++ b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form/conditions-form.tsx @@ -8,7 +8,7 @@ import styles from './conditions-form.module.css'; import type { DynamicCondition } from '../../../../../features/json-form/types/controls'; import { closeModal } from '../../../../../features/modals/stores/use-modal-store'; import { useSingleSelectedElement } from '../../../../../features/properties-bar/use-single-selected-element'; -import { variablesTypesToExcludeNonPrimitive } from '../../../../../features/variables/constants'; +import { VARIABLES_TYPES_NOT_PRIMITIVE } from '../../../../../features/variables/constants'; import { useNodeVariables } from '../../../../variables/hooks/use-node-variables'; import { getConditionErrors } from '../../../../variables/utils/form-validation/conditions'; import { ConditionsFormField } from '../dynamic-conditions-form-field/conditions-form-field'; @@ -38,7 +38,7 @@ export const ConditionsForm = forwardRef(null); diff --git a/packages/sdk/src/features/json-form/controls/variable-dynamic-control/variable-dynamic-control.tsx b/packages/sdk/src/features/json-form/controls/variable-dynamic-control/variable-dynamic-control.tsx index 81aef8c5e..39b7d5109 100644 --- a/packages/sdk/src/features/json-form/controls/variable-dynamic-control/variable-dynamic-control.tsx +++ b/packages/sdk/src/features/json-form/controls/variable-dynamic-control/variable-dynamic-control.tsx @@ -5,6 +5,7 @@ import type { WBControlProps } from '../../../../types/controls'; import { getIsStringNumber } from '../../../../utils/validation/get-is-string-number'; import { useSingleSelectedElement } from '../../../properties-bar/use-single-selected-element'; import { DynamicTypedVariableOrInput } from '../../../variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input'; +import { VARIABLES_TYPES_EMPTY } from '../../../variables/constants'; import { useNodeVariables } from '../../../variables/hooks/use-node-variables'; import { getBooleanIfPossible } from '../../../variables/utils/get-boolean-if-possible'; import { getIsStringVariableReference } from '../../../variables/utils/keys/get-is-string-variable-reference'; @@ -27,7 +28,7 @@ function VariableDynamicControl(props: VariableDynamicControlProps) { const { placeholder, variableType } = uischema; const selection = useSingleSelectedElement(); const { suggestionGroups, variablesKey } = useNodeVariables(selection?.node?.id, { - excludeTypes: [], + excludeTypes: VARIABLES_TYPES_EMPTY, includeTypes: variableTypesForSuggestions[variableType], }); diff --git a/packages/sdk/src/features/json-form/controls/variable-text-area-control/variable-text-area-control.tsx b/packages/sdk/src/features/json-form/controls/variable-text-area-control/variable-text-area-control.tsx index e0ee7b815..05361f987 100644 --- a/packages/sdk/src/features/json-form/controls/variable-text-area-control/variable-text-area-control.tsx +++ b/packages/sdk/src/features/json-form/controls/variable-text-area-control/variable-text-area-control.tsx @@ -2,7 +2,11 @@ import { useCallback, useEffect, useState } from 'react'; import { useSingleSelectedElement } from '../../../../features/properties-bar/use-single-selected-element'; import { VariableText } from '../../../../features/variables/components/variable-text/variable-text'; -import { variablesTypesNumeric, variablesTypesToExcludeInText } from '../../../../features/variables/constants'; +import { + VARIABLES_TYPES_EMPTY, + VARIABLES_TYPES_NUMERIC, + VARIABLES_TYPES_TO_EXCLUDE_IN_TEXT, +} from '../../../../features/variables/constants'; import { useNodeVariables } from '../../../variables/hooks/use-node-variables'; import type { VariableTextAreaControlProps } from '../../types/controls'; import { createControlRenderer } from '../../utils/rendering'; @@ -14,8 +18,8 @@ function VariableTextAreaControl(props: VariableTextAreaControlProps) { const { type } = schema; const selection = useSingleSelectedElement(); const { suggestionGroups, variablesKey } = useNodeVariables(selection?.node?.id, { - excludeTypes: variablesTypes ? [] : variablesTypesToExcludeInText, - includeTypes: variablesTypes || (type === 'number' ? variablesTypesNumeric : undefined), + excludeTypes: variablesTypes ? VARIABLES_TYPES_EMPTY : VARIABLES_TYPES_TO_EXCLUDE_IN_TEXT, + includeTypes: variablesTypes || (type === 'number' ? VARIABLES_TYPES_NUMERIC : VARIABLES_TYPES_EMPTY), }); const isDisabled = !enabled || disabled === true; diff --git a/packages/sdk/src/features/json-form/controls/variable-text-control/variable-text-control.tsx b/packages/sdk/src/features/json-form/controls/variable-text-control/variable-text-control.tsx index abc4611d8..38a312966 100644 --- a/packages/sdk/src/features/json-form/controls/variable-text-control/variable-text-control.tsx +++ b/packages/sdk/src/features/json-form/controls/variable-text-control/variable-text-control.tsx @@ -2,7 +2,11 @@ import { useCallback, useEffect, useState } from 'react'; import { VariableText } from '../../../../features/variables/components/variable-text/variable-text'; import { useSingleSelectedElement } from '../../../properties-bar/use-single-selected-element'; -import { variablesTypesNumeric, variablesTypesToExcludeInText } from '../../../variables/constants'; +import { + VARIABLES_TYPES_EMPTY, + VARIABLES_TYPES_NUMERIC, + VARIABLES_TYPES_TO_EXCLUDE_IN_TEXT, +} from '../../../variables/constants'; import { useNodeVariables } from '../../../variables/hooks/use-node-variables'; import type { VariableTextControlProps } from '../../types/controls'; import { createControlRenderer } from '../../utils/rendering'; @@ -14,8 +18,8 @@ function VariableTextControl(props: VariableTextControlProps) { const { type } = schema; const selection = useSingleSelectedElement(); const { suggestionGroups, variablesKey } = useNodeVariables(selection?.node?.id, { - excludeTypes: variablesTypes ? [] : variablesTypesToExcludeInText, - includeTypes: variablesTypes || (type === 'number' ? variablesTypesNumeric : undefined), + excludeTypes: variablesTypes ? VARIABLES_TYPES_EMPTY : VARIABLES_TYPES_TO_EXCLUDE_IN_TEXT, + includeTypes: variablesTypes || (type === 'number' ? VARIABLES_TYPES_NUMERIC : VARIABLES_TYPES_EMPTY), }); const isDisabled = !enabled || disabled === true; diff --git a/packages/sdk/src/features/variables/constants.ts b/packages/sdk/src/features/variables/constants.ts index 0b04b2e5d..279a0158b 100644 --- a/packages/sdk/src/features/variables/constants.ts +++ b/packages/sdk/src/features/variables/constants.ts @@ -127,11 +127,13 @@ export const variableTypesOptions: VariableTypeOption[] = Object.values(variable ({ type, baseType }) => type === baseType, ); -export const variablesTypesToExcludeNonPrimitive: VariableType[] = ['object', 'array']; +export const VARIABLES_TYPES_NOT_PRIMITIVE: VariableType[] = ['object', 'array']; -export const variablesTypesToExcludeInText: VariableType[] = [...variablesTypesToExcludeNonPrimitive, 'boolean']; +export const VARIABLES_TYPES_TO_EXCLUDE_IN_TEXT: VariableType[] = [...VARIABLES_TYPES_NOT_PRIMITIVE, 'boolean']; -export const variablesTypesNumeric: VariableType[] = ['number']; +export const VARIABLES_TYPES_NUMERIC: VariableType[] = ['number']; + +export const VARIABLES_TYPES_EMPTY: VariableType[] = []; // module scope /** * Special keywords used to determine source-handle behaviour. diff --git a/packages/sdk/src/features/variables/hooks/use-node-variables.ts b/packages/sdk/src/features/variables/hooks/use-node-variables.ts index 5de50160a..56fbf85a0 100644 --- a/packages/sdk/src/features/variables/hooks/use-node-variables.ts +++ b/packages/sdk/src/features/variables/hooks/use-node-variables.ts @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import type { VariableType } from '../../../node/node-output-schema'; import { useStore } from '../../../store/store'; import type { VariableSuggestion, VariableSuggestionGroup } from '../components/variable-text/variable-text.types'; +import { VARIABLES_TYPES_EMPTY } from '../constants'; import { getSuggestionsFromVariableIndex } from '../stores/core/get-suggestions-node-data/get-suggestions-from-variables-index'; import { useVariablesSuggestionsStore } from '../stores/use-variable-suggestions-store'; import { filterSuggestionsByTypes } from '../utils/core/filter-suggestions-by-types'; @@ -20,10 +21,8 @@ type Response = { variablesKey: string; }; -const NO_TYPES: VariableType[] = []; // module scope - export function useNodeVariables(nodeId: string | undefined, options?: Options): Response { - const { excludeTypes = NO_TYPES, includeTypes = NO_TYPES } = options || {}; + const { excludeTypes = VARIABLES_TYPES_EMPTY, includeTypes = VARIABLES_TYPES_EMPTY } = options || {}; const globalVariables = useStore((store) => store.globalVariables); const nodes = useStore((store) => store.nodes); const edges = useStore((store) => store.edges); @@ -64,10 +63,9 @@ export function useNodeVariables(nodeId: string | undefined, options?: Options): includeTypes, }); - // Variables can’t change while the modal containing them is in use, so we only need to refresh them when they change. // .length is critical here for performance. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lastUpdateIndex, nodeId, excludeTypes, includeTypes, edges.length, nodes.length]); + }, [lastUpdateIndex, nodeId, edges.length, nodes.length]); return useMemo(() => { const suggestionGroups = [...globalSuggestionsGroups, ...nodeSuggestionsGroups]; diff --git a/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts b/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts index 8c94c3e5f..54da60c2d 100644 --- a/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts +++ b/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts @@ -33,7 +33,7 @@ export function setVariablesSuggestionsNodeData(params: Params): VariablesSugges if (params.type === SUGGESTION_NODE_TYPE.COMMON) { storeToMutate = { ...storeToMutate, - lastUpdateIndex: 1, + lastUpdateIndex: storeToMutate.lastUpdateIndex + 1, commonByType: { ...storeToMutate.commonByType, [params.nodeType]: Object.fromEntries( @@ -62,7 +62,7 @@ export function setVariablesSuggestionsNodeData(params: Params): VariablesSugges } else if (params.type === SUGGESTION_NODE_TYPE.CUSTOM) { storeToMutate = { ...storeToMutate, - lastUpdateIndex: Date.now(), + lastUpdateIndex: storeToMutate.lastUpdateIndex + 1, byNodeId: { ...storeToMutate.byNodeId, [params.nodeId]: { diff --git a/packages/sdk/src/features/variables/stores/use-variable-suggestions-store.ts b/packages/sdk/src/features/variables/stores/use-variable-suggestions-store.ts index 0fce5b8af..933f05141 100644 --- a/packages/sdk/src/features/variables/stores/use-variable-suggestions-store.ts +++ b/packages/sdk/src/features/variables/stores/use-variable-suggestions-store.ts @@ -14,7 +14,7 @@ export type VariablesSuggestionsStore = { }; export const emptyVariablesSuggestionsStore: VariablesSuggestionsStore = { - lastUpdateIndex: 0, + lastUpdateIndex: 1, commonByType: {}, byNodeId: {}, }; From 6053089ff206eb8b6da0fe536528a3868a1d8b1e Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Wed, 9 Sep 2026 21:27:04 +0200 Subject: [PATCH 33/33] fix(sdk): missing refresh triggers added --- .../sdk/src/features/variables/hooks/use-refresh-variables.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/src/features/variables/hooks/use-refresh-variables.ts b/packages/sdk/src/features/variables/hooks/use-refresh-variables.ts index fd204be0a..73a5b4ef1 100644 --- a/packages/sdk/src/features/variables/hooks/use-refresh-variables.ts +++ b/packages/sdk/src/features/variables/hooks/use-refresh-variables.ts @@ -32,7 +32,7 @@ function useRefreshVariables() { useEffect(() => { const wasNodeUpdated = ['dataUpdateNode', 'addNode'].includes(lastChangeName); - const wasDiagramReloaded = ['undo', 'redo', 'import'].includes(lastChangeName); + const wasDiagramReloaded = ['undo', 'redo', 'paste', 'cut', 'import'].includes(lastChangeName); const shouldRefreshVariables = wasNodeUpdated || wasDiagramReloaded; if (!shouldRefreshVariables) {