From b21ea0f9445f3c18e9b87f948d3071a3ff7d6a38 Mon Sep 17 00:00:00 2001 From: Szymon Tondowski Date: Thu, 13 Aug 2026 12:45:06 +0200 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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' },