diff --git a/apps/ai-studio/src/nodes/ai-agent/index.ts b/apps/ai-studio/src/nodes/ai-agent/index.ts index d74e375c6..eac71dfc7 100644 --- a/apps/ai-studio/src/nodes/ai-agent/index.ts +++ b/apps/ai-studio/src/nodes/ai-agent/index.ts @@ -16,8 +16,10 @@ export const aiAgentPaletteItem: PaletteItem = { // Lets `{{ nodes..response }}` references resolve to a real mention instead of a "missing mention" pill. outputSchema: { type: 'default', - properties: { - response: { type: 'string', label: 'Response', description: 'The text generated by the AI model' }, + bySourceHandle: { + success: { + response: { type: 'string', label: 'Response', description: 'The text generated by the AI model' }, + }, }, }, }; diff --git a/apps/demo/src/app/app.tsx b/apps/demo/src/app/app.tsx index 79d479f09..12d598417 100644 --- a/apps/demo/src/app/app.tsx +++ b/apps/demo/src/app/app.tsx @@ -5,9 +5,11 @@ import type { WorkflowBuilderNodeTemplates, WorkflowBuilderReactFlowProps, } from '@workflowbuilder/sdk'; +import { SnackbarType } from '@workflowbuilder/ui'; import '@workflowbuilder/sdk/style.css'; +import { showSnackbar } from '../../../../packages/sdk/src/utils/show-snackbar'; import { DashedEdge } from './components/dashed-edge/dashed-edge'; import { MultiPortNodeTemplate } from './components/multi-port-node/multi-port-node-template'; import { demoPaletteItems } from './data/palette'; @@ -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 2ef830c8a..1bb78ed9c 100644 --- a/apps/demo/src/app/data/nodes/action/action.ts +++ b/apps/demo/src/app/data/nodes/action/action.ts @@ -14,10 +14,15 @@ export const action: PaletteItem = { uischema, outputSchema: { type: 'default', - properties: { - status: { type: 'string', label: 'Status', description: 'Execution status: success, failure, or skipped' }, - result: { type: 'object', label: 'Result', description: 'The data returned by the action' }, - errorMessage: { type: 'string', label: 'Error Message', description: 'Error details if the action failed' }, + bySourceHandle: { + success: { + status: { type: 'string', label: 'Status', description: 'Execution status: success, failure, or skipped' }, + // TODO: outputSchema and schema properties should support the full JsonSchema7 type imported from @jsonforms/core to build suggestions not only for objects but also for their variables. + result: { type: 'object', label: 'Result', description: 'The data returned by the action' }, + }, + error: { + errorMessage: { type: 'string', label: 'Error Message', description: 'Error details if the action failed' }, + }, }, }, }; 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/trigger/trigger.ts b/apps/demo/src/app/data/nodes/trigger/trigger.ts index 62291e86c..78f707eb6 100644 --- a/apps/demo/src/app/data/nodes/trigger/trigger.ts +++ b/apps/demo/src/app/data/nodes/trigger/trigger.ts @@ -13,11 +13,63 @@ export const triggerNode: PaletteItem = { schema, 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' }, - 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' }, 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 e25a62748..75b4a9729 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/diagram/diagram.tsx b/packages/sdk/src/features/diagram/diagram.tsx index 466cd50bd..53625012f 100644 --- a/packages/sdk/src/features/diagram/diagram.tsx +++ b/packages/sdk/src/features/diagram/diagram.tsx @@ -24,6 +24,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'; @@ -126,6 +127,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 56f399bc8..88eb35c13 100644 --- a/packages/sdk/src/features/i18n/locales/en.ts +++ b/packages/sdk/src/features/i18n/locales/en.ts @@ -138,6 +138,9 @@ 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', + missingMentionNodePrefix: 'Missing node', + missingMentionNodeVariablePrefix: 'Missing variable', }, loader: { text: 'Loading...', @@ -181,7 +184,10 @@ 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.', + 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 96a918c02..4a3448e5a 100644 --- a/packages/sdk/src/features/i18n/locales/pl.ts +++ b/packages/sdk/src/features/i18n/locales/pl.ts @@ -102,6 +102,9 @@ 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ć', + missingMentionNodePrefix: 'Brak węzła', + missingMentionNodeVariablePrefix: 'Brak zmiennej', }, loader: { text: 'Ładowanie...', @@ -145,7 +148,10 @@ 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.', + notValidConnection: 'To połączenie jest zablokowane.', }, aiTools: { title: 'Narzędzia agenta AI', 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..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 @@ -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[]; @@ -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 ( ; @@ -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 ccce534ff..5e6ded683 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); @@ -86,7 +88,7 @@ export const ConditionsForm = forwardRef -
+
{conditions.map((condition, index) => ( = (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/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..0e08fd6e7 --- /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) { + 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 4140f434f..39f7a3cbc 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 '@workflowbuilder/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 d1aadbc3e..38ec459aa 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); } .container--select { @@ -10,21 +14,67 @@ 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 { - border-radius: var(--ax-public-input-border-radius-medium); - min-height: 2.5rem; - 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; +} + +.reset-button { + position: absolute; + top: var(--ax-token-spacing-input-m-v-pad); + right: var(--ax-token-spacing-input-m-h-pad); + cursor: pointer; +} + +.container--select { + position: relative; + + > select { + min-height: 41px; + } +} + .adornment--select { position: absolute; top: 50%; @@ -33,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 36fd873ff..2af4fc8bd 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 '@workflowbuilder/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 +226,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 d079c1ccb..78c627b78 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 '@workflowbuilder/ui'; +import { NavButton, Tooltip } from '@workflowbuilder/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 Props = { 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) { +}: Props) { 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..8745ea3e7 --- /dev/null +++ b/packages/sdk/src/features/variables/components/schema-builder/schema-builder.tsx @@ -0,0 +1,138 @@ +import { PlusCircle } from '@phosphor-icons/react'; +import { Button, SnackbarType } from '@workflowbuilder/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 { 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 referenceWithoutBrackets = nodeId + ? getVariableReferenceWithoutBracketsForNode({ nodeId, propertyName: variableId }) + : ''; + + const nodesWithVariable = referenceWithoutBrackets ? getNodesWithVariable(referenceWithoutBrackets) : []; + + openModalSchemaBuilderVariableConfig({ + variant: nodesWithVariable.length > 0 ? 'edit-limited-strict' : 'edit', + variable: value[variableId], + isReadOnly: isDisabled, + onSave: (variable) => { + const newValue = { ...value }; + // Edited variable may have a different ID (if it is generated from the name) + delete newValue[variableId]; + + return onChange({ + ...newValue, + [variable.id]: variable, + }); + }, + variablesById: value, + }); + }, + [isDisabled, nodeId, onChange, value], + ); + + const handleRemove = useCallback( + (variableId: string) => { + const referenceWithoutBrackets = nodeId + ? getVariableReferenceWithoutBracketsForNode({ nodeId, propertyName: variableId }) + : ''; + + const nodesWithVariable = referenceWithoutBrackets ? getNodesWithVariable(referenceWithoutBrackets) : []; + + openModalSchemaBuilderVariableRemoval({ + variable: value[variableId], + isReadOnly: isDisabled, + onRemove: () => { + const newValue = { ...value }; + 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 95% 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..4d7d1e3f2 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 @@ -15,13 +15,14 @@ line-height: 1.3125rem; display: inline-flex; font-family: 'Courier New'; - border-radius: 4px; - padding: 4px 8px 3px 8px; color: var(--ax-txt-primary-default); background: var(--wb-variable-bg); + border-radius: 4px; + padding: 4px 8px 3px 8px; 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 93% 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..e5b3ae6bd 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 @@ -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 a872a1f5c..100d5852d 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..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 @@ -1,10 +1,12 @@ +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 { 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'; @@ -12,13 +14,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 newValue = value ? VARIABLE_BRACKETS_START + value.split(VARIABLE_BRACKETS_START).at(-1) : ''; + const valueToPass = getIsStringVariableReference(newValue) ? newValue : ''; onChange(valueToPass); focusNextElement(); @@ -26,16 +28,32 @@ export function VariableSelect({ onChange, endAdornment, ...props }: Props) { [onChange], ); + const handleOnBlur: VariableTextProps['onBlur'] = useCallback( + (value: string) => { + if (!onBlur) { + return; + } + + const newValue = value ? VARIABLE_BRACKETS_START + value.split(VARIABLE_BRACKETS_START).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 4794b78fc..7d21a2e84 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); @@ -146,6 +157,7 @@ body:has(.suggestionsContainer) { .suggestionsTitle { composes: ax-public-h10 from global; + color: var(--ax-txt-primary-default); } @@ -173,9 +185,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 8acc7b417..a1c8dfba6 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 '@workflowbuilder/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,12 +192,21 @@ 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) || ''; - return `{{ ${t('plugins.validation.missingMentionNodePrefix')} (${nodeId.slice(0, 4)}...) · ${typedId.split('.').at(-1)} }}`; + + const node = getNodeByIdAction(nodeId); + + if (node) { + const nodeLabel = node.data?.properties?.label; + + return `{{ ${nodeLabel ? `${nodeLabel} · ` : ''}${t('variables.missingMentionNodeVariablePrefix')} · ${typedId.split('.').at(-1)} }}`; + } + + return `{{ ${t('variables.missingMentionNodePrefix')} (${nodeId.slice(0, 4)}...) · ${typedId.split('.').at(-1)} }}`; } return defaultLabel; @@ -242,11 +255,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 +296,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 +309,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..a7dd84527 100644 --- a/packages/sdk/src/features/variables/constants.ts +++ b/packages/sdk/src/features/variables/constants.ts @@ -1,6 +1,12 @@ -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 @@ -72,12 +78,12 @@ export const VARIABLE_GLOBAL_KEY = 'global'; export const VARIABLE_NODES_KEY = 'nodes'; type VariableTypeOption = { - type: VariableTypePrimitive; - baseType: VariableTypePrimitive; + type: VariableType; + baseType: VariableType; label: string; }; -export const variableTypeInfoByType: Record = { +export const variableTypeInfoByType: Record = { string: { type: 'string', baseType: 'string', @@ -103,20 +109,19 @@ export const variableTypeInfoByType: Record type === baseType, ); @@ -124,3 +129,28 @@ export const variableTypesOptions: VariableTypeOption[] = Object.values(variable export const variablesTypesToExcludeNonPrimitive: VariableType[] = ['object', 'array']; export const variablesTypesToExcludeInText: VariableType[] = [...variablesTypesToExcludeNonPrimitive, 'boolean']; + +export const variablesTypesNumeric: VariableType[] = ['number']; + +/** + * 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-available-variables.ts b/packages/sdk/src/features/variables/hooks/use-available-variables.ts index 057a89b7a..fb27e1323 100644 --- a/packages/sdk/src/features/variables/hooks/use-available-variables.ts +++ b/packages/sdk/src/features/variables/hooks/use-available-variables.ts @@ -1,17 +1,25 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import type { VariableType } from '../../../node/node-output-schema'; import { useStore } from '../../../store/store'; -import { filterEmpty } from '../../../utils/array'; -import { truncate } from '../../../utils/text'; -import { getAvailableVariablesByNodeId } from '../actions/get-available-variables-by-node-id'; import type { VariableSuggestion, VariableSuggestionGroup } from '../components/variable-text/variable-text.types'; -import { getGlobalVariableKey } from '../utils/get-global-variable-key'; +import { getSuggestionsFromVariableIndex } from '../stores/core/get-suggestions-node-data/get-suggestions-from-variables-index'; +import { filterSuggestionsByTypes } from '../utils/core/filter-suggestions-by-types'; +import { getAvailableVariablesByNodeId } from '../utils/core/get-available-variables-by-node-id'; -export function useAvailableVariables( - nodeId: string | undefined, - excludeTypes: string[] = [], -): VariableSuggestionGroup[] { +type Options = { + excludeTypes?: VariableType[]; + includeTypes?: VariableType[]; +}; + +type Response = { + suggestionGroups: VariableSuggestionGroup[]; + totalVariables: number; +}; + +export function useAvailableVariables(nodeId: string | undefined, options?: Options): Response { + const { excludeTypes = [], includeTypes = [] } = options || {}; const globalVariables = useStore((store) => store.globalVariables); const nodes = useStore((store) => store.nodes); const edges = useStore((store) => store.edges); @@ -19,26 +27,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 +57,7 @@ export function useAvailableVariables( nodes, edges, excludeTypes, + includeTypes, }); // .length is critical here for performance. @@ -53,6 +65,16 @@ export function useAvailableVariables( }, [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 new file mode 100644 index 000000000..fd204be0a --- /dev/null +++ b/packages/sdk/src/features/variables/hooks/use-refresh-variables.ts @@ -0,0 +1,78 @@ +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 = { + 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); + + const refreshAll = useCallback(() => { + timeoutRef.current = setTimeout(() => { + refreshAllSuggestions(); + refreshRef.current = { + type: 'partial', + nodesIds: new Set(), + }; + }, REFRESH_ALL_VARIABLES_DELAY_MS); + }, []); + + 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); + } else { + console.warn('Expected nodeId from the event to refresh variables, but it was not received.'); + // Force global refresh + refreshRef.current.type = 'global'; + } + } + + if (wasDiagramReloaded) { + refreshRef.current.type = 'global'; + } + + if (refreshRef.current.type === 'global') { + timeoutRef.current = setTimeout(refreshAll, 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, refreshAll]); +} + +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..2560ff352 --- /dev/null +++ b/packages/sdk/src/features/variables/modals/control/modal-schema-builder-variable-config.tsx @@ -0,0 +1,64 @@ +import { SnackbarType } from '@workflowbuilder/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 100% 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 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 100% 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 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..03a4d28be --- /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 '@workflowbuilder/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/tab/tab-header.module.css b/packages/sdk/src/features/variables/modals/global/tab/tab-header.module.css similarity index 64% rename from packages/sdk/src/features/variables/modals/tab/tab-header.module.css rename to packages/sdk/src/features/variables/modals/global/tab/tab-header.module.css index 63d758035..6d976ea62 100644 --- a/packages/sdk/src/features/variables/modals/tab/tab-header.module.css +++ b/packages/sdk/src/features/variables/modals/global/tab/tab-header.module.css @@ -8,10 +8,13 @@ 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); + &:not(.container--no-border) { + border-bottom: var(--settings-tab-header-border); + + + * { + padding-top: var(--wb-token-spacing-modal-l-content-gap-2, 16px); + } } } @@ -30,6 +33,10 @@ color: var(--ax-txt-primary-default); } +.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 2d417d231..b805d2352 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..0a286900d --- /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 '@workflowbuilder/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 85% 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 ab691e0c6..e7bd9d101 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 98% 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..0b30d3eb0 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 @@ -18,8 +18,7 @@ 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/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 98a75d12d..268828283 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 '@workflowbuilder/ui'; +import { Button, Input, Select, type SelectItem, TextArea } from '@workflowbuilder/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/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..85e2a71e8 --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/get-node-variables-suggestions.ts @@ -0,0 +1,82 @@ +import type { VariableSuggestion } from '../../components/variable-text/variable-text.types'; +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, 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) { + // 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) { + 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; + + const sourceHandle = params.sourceHandle || ''; + + if (Array.isArray(bySourceHandle[sourceHandle])) { + suggestions = bySourceHandle[sourceHandle]; + } + + 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-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..60cd3b14e --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts @@ -0,0 +1,130 @@ +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: { + every: [], + }, +}; + +export function getSuggestionsNodeData({ definition, node }: Params): Response { + const nodeLabel = getNodeLabelForVariable({ node, definition }); + + if (!definition?.outputSchema?.type) { + 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, + }; + } + + // 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) => { + if (!variant?.variantRule) { + return true; + } + + const isValidPropertyValue = + node.data.properties[variant.variantRule.dataPropertyName] === variant.variantRule.dataPropertyValue; + + return isValidPropertyValue; + }) + .filter(filterEmpty); + + for (const variant of variantsMatchingDataPropertyValue) { + for (const [sourceHandle, properties] of Object.entries(variant.bySourceHandle)) { + if (properties) { + bySourceHandle[sourceHandle] = [ + ...(bySourceHandle[sourceHandle] || []), + ...getSuggestionsFromOutputProperties({ + nodeId: node.id, + nodeLabel, + properties: 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: { + success: 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..c54f833c7 --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts @@ -0,0 +1,48 @@ +import type { WorkflowBuilderNode } from '../../../../node/node-data'; +import { useStore } from '../../../../store/store'; +import { getNodeDefinition } from '../../../../utils/validation/get-node-definition'; +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..3bc175948 --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts @@ -0,0 +1,73 @@ +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'; +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..b34fb6fa8 --- /dev/null +++ b/packages/sdk/src/features/variables/stores/types.ts @@ -0,0 +1,27 @@ +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; + [SPECIAL_SOURCE_HANDLE_KEYWORDS.EVERY]?: VariableSuggestion[]; + [SPECIAL_SOURCE_HANDLE_KEYWORDS.SUCCESS]?: VariableSuggestion[]; + [SPECIAL_SOURCE_HANDLE_KEYWORDS.ERROR]?: 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..b646e6da9 --- /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[]; + // It accepts all if the array is empty or undefined. + 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/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 new file mode 100644 index 000000000..378d7fdf4 --- /dev/null +++ b/packages/sdk/src/features/variables/utils/core/get-available-variables-by-node-id.ts @@ -0,0 +1,81 @@ +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'; +import { filterSuggestionsDuplicates } from './filter-suggestions-duplicates'; + +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 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 + 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, + }); + + const uniqueSuggestions = groupsByLabel[nodeLabel]?.suggestions + ? filterSuggestionsDuplicates([...groupsByLabel[nodeLabel].suggestions, ...filteredSuggestions]) + : filteredSuggestions; + + groupsByLabel[nodeLabel] = { + label: nodeLabel, + icon: node.data.icon, + suggestions: uniqueSuggestions, + }; + } + + return Object.values(groupsByLabel); +} 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..8bfa4b581 --- /dev/null +++ b/packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.ts @@ -0,0 +1,46 @@ +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 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; + } + + 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..2f6a39914 --- /dev/null +++ b/packages/sdk/src/features/variables/utils/keys/get-variable-references.ts @@ -0,0 +1,46 @@ +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; + referenceWithoutBrackets: string; + } + | { + reference: undefined; + referenceWithoutBrackets: 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-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)); }, 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 69e4028a1..36ea8f598 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'; @@ -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 df4897dff..f3b9e3438 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'; @@ -106,7 +106,7 @@ export function useWorkflowBuilderActions(): WorkflowBuilderActions { () => ({ save: () => onSave({ isAutoSave: false }), - openSettings: openModalWorkflowSettings, + openSettings: () => openModalWorkflowSettings(), openImport: openImportModal, openExport: openExportModal, diff --git a/packages/sdk/src/node/node-output-schema.ts b/packages/sdk/src/node/node-output-schema.ts index dcfeda6d9..ca4e1c1de 100644 --- a/packages/sdk/src/node/node-output-schema.ts +++ b/packages/sdk/src/node/node-output-schema.ts @@ -12,16 +12,22 @@ export function getVariableTypeIfPrimitive(type: VariableType): VariableTypePrim export type OutputProperty = { type: VariableType; - label: string; + 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 PropertiesBySourceHandle = { + [sourceHandle: string]: OutputPropertiesIndex | undefined; + every?: OutputPropertiesIndex; +}; export type OutputVariant = { variantRule: @@ -30,22 +36,28 @@ export type OutputVariant = { dataPropertyName: string; dataPropertyValue: string; }; - properties: OutputPropertiesIndex; + bySourceHandle: PropertiesBySourceHandle; }; export type NodeOutputSchemaDefault = { type: 'default'; - properties: OutputPropertiesIndex; + bySourceHandle: PropertiesBySourceHandle; }; 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; - }; + variants: OutputVariant[]; +}; + +export type NodeOutputSchemaPropertyValue = { + /* + Value is built dynamically in the node property. + */ + type: 'property-value'; + propertyPath: string; }; -export type NodeOutputSchema = NodeOutputSchemaDefault | NodeOutputSchemaVariant; +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..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 ({ globalVariables: { diff --git a/packages/sdk/src/types/controls.ts b/packages/sdk/src/types/controls.ts index ac609a739..a95b5453b 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 '@workflowbuilder/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; }