diff --git a/.changeset/sdk-variable-dynamic-control.md b/.changeset/sdk-variable-dynamic-control.md new file mode 100644 index 000000000..dd31061de --- /dev/null +++ b/.changeset/sdk-variable-dynamic-control.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': minor +--- + +Added a new 'VariableDynamic' control that dynamically builds a control matching the variable's type. It toggles between a regular input and a variable picker limited to variables of the matching type; only a single variable can be set. diff --git a/.changeset/sdk-variables-json-schema.md b/.changeset/sdk-variables-json-schema.md new file mode 100644 index 000000000..17979251e --- /dev/null +++ b/.changeset/sdk-variables-json-schema.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': minor +--- + +Variables now support returning different values for different source handles. Variables are now defined using a JSON schema rather than a flattened list, providing a foundation for future validation. diff --git a/apps/demo/CLAUDE.md b/apps/demo/CLAUDE.md index 60cdddbf9..2198b5c4a 100644 --- a/apps/demo/CLAUDE.md +++ b/apps/demo/CLAUDE.md @@ -12,7 +12,7 @@ Each node type lives in `src/app/data/nodes//`. Canonical pattern is | File | Purpose | | ---------------------------- | ------------------------------------------------------------------------------------- | -| `.ts` | `PaletteItem` — label, type, icon, defaults, schemas, optional `outputSchema` | +| `.ts` | `PaletteItem` — label, type, icon, defaults, schemas, optional `schemaOutput` | | `schema.ts` | JSON Schema with `satisfies NodeSchema`; spreads `sharedProperties` from the SDK | | `uischema.ts` | UI Schema using `getScope` for type-safe scopes | | `default-properties-data.ts` | Defaults aligned with the schema | diff --git a/apps/demo/src/app/app.tsx b/apps/demo/src/app/app.tsx index d79b0a96f..61f78bcff 100644 --- a/apps/demo/src/app/app.tsx +++ b/apps/demo/src/app/app.tsx @@ -5,6 +5,8 @@ import type { WorkflowBuilderNodeTemplates, WorkflowBuilderReactFlowProps, } from '@workflowbuilder/sdk'; +import { showSnackbar } from '@workflowbuilder/sdk'; +import { SnackbarType } from '@workflowbuilder/ui'; import '@workflowbuilder/sdk/style.css'; @@ -36,7 +38,18 @@ const edgeTemplates = { } satisfies WorkflowBuilderEdgeTemplates; // A start node is where the run begins, so it can never be a connection target. -const isValidConnection: WorkflowBuilderIsValidConnection = ({ targetNode }) => !targetNode.data.isStartNode; +const isValidConnection: WorkflowBuilderIsValidConnection = ({ targetNode }) => { + if (targetNode.data.isStartNode) { + 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..af68491e7 100644 --- a/apps/demo/src/app/data/nodes/action/action.ts +++ b/apps/demo/src/app/data/nodes/action/action.ts @@ -2,6 +2,7 @@ import type { PaletteItem } from '@workflowbuilder/sdk'; import { defaultPropertiesData } from './default-properties-data'; import { type ActionNodeSchema, schema } from './schema'; +// import { schemaOutput } from './schema-output'; import { uischema } from './uischema'; export const action: PaletteItem = { @@ -11,7 +12,8 @@ export const action: PaletteItem = { description: 'node.action.description', defaultPropertiesData, schema, - uischema, + // schemaOutput, + // Example of a deprecated response. Use schemaOutput instead. outputSchema: { type: 'default', properties: { @@ -20,4 +22,5 @@ export const action: PaletteItem = { errorMessage: { type: 'string', label: 'Error Message', description: 'Error details if the action failed' }, }, }, + uischema, }; diff --git a/apps/demo/src/app/data/nodes/action/schema-output.ts b/apps/demo/src/app/data/nodes/action/schema-output.ts new file mode 100644 index 000000000..f36a9bd73 --- /dev/null +++ b/apps/demo/src/app/data/nodes/action/schema-output.ts @@ -0,0 +1,34 @@ +import type { NodeSchemaOutput } from '@workflowbuilder/sdk'; + +export const schemaOutput: NodeSchemaOutput = { + type: 'default', + bySourceHandle: { + success: { + type: 'object', + properties: { + result: { + type: 'object', + title: 'Result', + description: 'The data returned by the action', + properties: { + status: { + type: 'string', + title: 'Status', + description: 'Execution status: success, failure, or skipped', + }, + }, + }, + }, + }, + error: { + type: 'object', + properties: { + errorMessage: { + type: 'string', + title: 'Error Message', + description: 'Error details if the action failed', + }, + }, + }, + }, +}; diff --git a/apps/demo/src/app/data/nodes/action/uischema.ts b/apps/demo/src/app/data/nodes/action/uischema.ts index 8cf308dc7..ef76b3ff0 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, }, { @@ -81,7 +81,8 @@ const sendEmailProperties: ActionNodeUISchema = { elements: [ { type: 'Label', text: 'Number of retries' }, { - type: 'Text', + type: 'VariableDynamic', + variableType: 'number', scope: scope('properties.sendEmail.properties.retries'), rule: { effect: 'DISABLE', diff --git a/apps/demo/src/app/data/nodes/ai-agent/ai-agent.ts b/apps/demo/src/app/data/nodes/ai-agent/ai-agent.ts index b8823667a..a37524e20 100644 --- a/apps/demo/src/app/data/nodes/ai-agent/ai-agent.ts +++ b/apps/demo/src/app/data/nodes/ai-agent/ai-agent.ts @@ -3,6 +3,7 @@ import type { PaletteItem } from '@workflowbuilder/sdk'; import { defaultPropertiesData } from './default-properties-data'; import { schema } from './schema'; +import { schemaOutput } from './schema-output'; import { uischema } from './uischema'; export const aiAgent: PaletteItem = { @@ -13,13 +14,6 @@ export const aiAgent: PaletteItem = { templateType: NodeType.AiNode, defaultPropertiesData, schema, + schemaOutput, 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' }, - }, - }, }; diff --git a/apps/demo/src/app/data/nodes/ai-agent/schema-output.ts b/apps/demo/src/app/data/nodes/ai-agent/schema-output.ts new file mode 100644 index 000000000..1a687710b --- /dev/null +++ b/apps/demo/src/app/data/nodes/ai-agent/schema-output.ts @@ -0,0 +1,27 @@ +import type { NodeSchemaOutput } from '@workflowbuilder/sdk'; + +export const schemaOutput: NodeSchemaOutput = { + type: 'default', + bySourceHandle: { + success: { + type: 'object', + properties: { + response: { + type: 'string', + title: 'Response', + description: 'The text generated by the AI model', + }, + tokensUsed: { + type: 'number', + title: 'Tokens Used', + description: 'Total number of tokens consumed', + }, + model: { + type: 'string', + title: 'Model', + description: 'The AI model that was used', + }, + }, + }, + }, +}; diff --git a/apps/demo/src/app/data/nodes/conditional/conditional.ts b/apps/demo/src/app/data/nodes/conditional/conditional.ts index 8e880aae9..26ad99f6e 100644 --- a/apps/demo/src/app/data/nodes/conditional/conditional.ts +++ b/apps/demo/src/app/data/nodes/conditional/conditional.ts @@ -2,6 +2,7 @@ import type { PaletteItem } from '@workflowbuilder/sdk'; import { defaultPropertiesData } from './default-properties-data'; import { type ConditionalNodeSchema, schema } from './schema'; +import { schemaOutput } from './schema-output'; import { uischema } from './uischema'; export const conditional: PaletteItem = { @@ -11,16 +12,6 @@ export const conditional: PaletteItem = { icon: 'ListChecks', defaultPropertiesData, schema, + schemaOutput, 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', - }, - }, - }, }; diff --git a/apps/demo/src/app/data/nodes/conditional/schema-output.ts b/apps/demo/src/app/data/nodes/conditional/schema-output.ts new file mode 100644 index 000000000..614f01638 --- /dev/null +++ b/apps/demo/src/app/data/nodes/conditional/schema-output.ts @@ -0,0 +1,22 @@ +import type { NodeSchemaOutput } from '@workflowbuilder/sdk'; + +export const schemaOutput: NodeSchemaOutput = { + type: 'default', + bySourceHandle: { + success: { + type: 'object', + properties: { + result: { + type: 'boolean', + title: 'Result', + description: 'Whether the condition evaluated to true or false', + }, + matchedCondition: { + type: 'string', + title: 'Matched Condition', + description: 'The condition expression that matched', + }, + }, + }, + }, +}; diff --git a/apps/demo/src/app/data/nodes/decision/decision.ts b/apps/demo/src/app/data/nodes/decision/decision.ts index 1214a62e9..1194bfacb 100644 --- a/apps/demo/src/app/data/nodes/decision/decision.ts +++ b/apps/demo/src/app/data/nodes/decision/decision.ts @@ -3,6 +3,7 @@ import type { PaletteItem } from '@workflowbuilder/sdk'; import { defaultPropertiesData } from './default-properties-data'; import { type DecisionNodeSchema, schema } from './schema'; +import { schemaOutput } from './schema-output'; import { uischema } from './uischema'; export const decision: PaletteItem = { @@ -13,12 +14,6 @@ export const decision: PaletteItem = { templateType: NodeType.DecisionNode, defaultPropertiesData, schema, + schemaOutput, 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' }, - }, - }, }; diff --git a/apps/demo/src/app/data/nodes/decision/schema-output.ts b/apps/demo/src/app/data/nodes/decision/schema-output.ts new file mode 100644 index 000000000..e887de016 --- /dev/null +++ b/apps/demo/src/app/data/nodes/decision/schema-output.ts @@ -0,0 +1,22 @@ +import type { NodeSchemaOutput } from '@workflowbuilder/sdk'; + +export const schemaOutput: NodeSchemaOutput = { + type: 'default', + bySourceHandle: { + every: { + type: 'object', + properties: { + selectedBranch: { + type: 'string', + title: 'Selected Branch', + description: 'Label of the branch that was taken', + }, + branchIndex: { + type: 'number', + title: 'Branch Index', + description: 'Zero-based index of the selected branch', + }, + }, + }, + }, +}; diff --git a/apps/demo/src/app/data/nodes/delay/default-properties-data.ts b/apps/demo/src/app/data/nodes/delay/default-properties-data.ts index 7c8844ca4..0b6239931 100644 --- a/apps/demo/src/app/data/nodes/delay/default-properties-data.ts +++ b/apps/demo/src/app/data/nodes/delay/default-properties-data.ts @@ -14,4 +14,5 @@ export const defaultPropertiesData: Required expression: 'order.processing_time * 2', }, type: delayTypeOptions.fixed.value, + untilDate: '', }; diff --git a/apps/demo/src/app/data/nodes/delay/delay.ts b/apps/demo/src/app/data/nodes/delay/delay.ts index ede30284d..2b088d9c2 100644 --- a/apps/demo/src/app/data/nodes/delay/delay.ts +++ b/apps/demo/src/app/data/nodes/delay/delay.ts @@ -2,6 +2,7 @@ import type { PaletteItem } from '@workflowbuilder/sdk'; import { defaultPropertiesData } from './default-properties-data'; import { type DelayNodeSchema, schema } from './schema'; +import { schemaOutput } from './schema-output'; import { uischema } from './uischema'; export const delay: PaletteItem = { @@ -11,12 +12,6 @@ export const delay: PaletteItem = { icon: 'Timer', defaultPropertiesData, schema, + schemaOutput, 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' }, - }, - }, }; diff --git a/apps/demo/src/app/data/nodes/delay/schema-output.ts b/apps/demo/src/app/data/nodes/delay/schema-output.ts new file mode 100644 index 000000000..ef89bbed0 --- /dev/null +++ b/apps/demo/src/app/data/nodes/delay/schema-output.ts @@ -0,0 +1,23 @@ +import type { NodeSchemaOutput } from '@workflowbuilder/sdk'; + +export const schemaOutput: NodeSchemaOutput = { + type: 'default', + bySourceHandle: { + success: { + type: 'object', + properties: { + resumedAt: { + type: 'string', + format: 'date-time', + title: 'Resumed At', + description: 'ISO 8601 date-time when the delay ended', + }, + delayDuration: { + type: 'number', + title: 'Delay Duration', + description: 'Actual wait time in milliseconds', + }, + }, + }, + }, +}; diff --git a/apps/demo/src/app/data/nodes/delay/schema.ts b/apps/demo/src/app/data/nodes/delay/schema.ts index 10f53ac74..d913b8d9f 100644 --- a/apps/demo/src/app/data/nodes/delay/schema.ts +++ b/apps/demo/src/app/data/nodes/delay/schema.ts @@ -37,6 +37,9 @@ export const schema = { }, }, }, + untilDate: { + type: 'string', + }, }, ...conditionalValidation, } satisfies NodeSchema; diff --git a/apps/demo/src/app/data/nodes/delay/uischema.ts b/apps/demo/src/app/data/nodes/delay/uischema.ts index 61cca1091..730f08e4a 100644 --- a/apps/demo/src/app/data/nodes/delay/uischema.ts +++ b/apps/demo/src/app/data/nodes/delay/uischema.ts @@ -90,6 +90,26 @@ const dynamicDelayProperties: PaletteItem['uischema'] = { ], }; +const untilSpecificProperties: PaletteItem['uischema'] = { + rule: { + effect: 'SHOW', + condition: { + scope: scope('properties.type'), + schema: { const: delayTypeOptions.untilSpecific.value }, + }, + }, + type: 'Accordion', + label: 'Date/Time', + elements: [ + { + type: 'VariableDynamic', + scope: scope('properties.untilDate'), + label: 'Wait until', + variableType: 'datetime', + }, + ], +}; + export const uischema: UISchema = { type: 'VerticalLayout', elements: [ @@ -102,5 +122,6 @@ export const uischema: UISchema = { ...(generalInformation ? [generalInformation] : []), fixedDelayProperties, dynamicDelayProperties, + untilSpecificProperties, ], }; diff --git a/apps/demo/src/app/data/nodes/notification/notification.ts b/apps/demo/src/app/data/nodes/notification/notification.ts index 3fce5204a..35be57d98 100644 --- a/apps/demo/src/app/data/nodes/notification/notification.ts +++ b/apps/demo/src/app/data/nodes/notification/notification.ts @@ -2,6 +2,7 @@ import type { PaletteItem } from '@workflowbuilder/sdk'; import { defaultPropertiesData } from './default-properties-data'; import { type NotificationNodeSchema, schema } from './schema'; +import { schemaOutput } from './schema-output'; import { uischema } from './uischema'; export const notification: PaletteItem = { @@ -11,13 +12,6 @@ export const notification: PaletteItem = { icon: 'PaperPlaneRight', defaultPropertiesData, schema, + schemaOutput, 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' }, - }, - }, }; diff --git a/apps/demo/src/app/data/nodes/notification/schema-output.ts b/apps/demo/src/app/data/nodes/notification/schema-output.ts new file mode 100644 index 000000000..bc6edc738 --- /dev/null +++ b/apps/demo/src/app/data/nodes/notification/schema-output.ts @@ -0,0 +1,29 @@ +import type { NodeSchemaOutput } from '@workflowbuilder/sdk'; + +export const schemaOutput: NodeSchemaOutput = { + type: 'default', + bySourceHandle: { + success: { + type: 'object', + properties: { + sent: { + type: 'boolean', + title: 'Sent', + description: 'Whether the notification was sent successfully', + }, + sentAt: { + type: 'string', + format: 'date-time', + title: 'Sent At', + description: 'ISO 8601 date-time when the notification was sent', + }, + recipient: { + type: 'string', + format: 'email', + title: 'Recipient', + description: 'The email address the notification was sent to', + }, + }, + }, + }, +}; diff --git a/apps/demo/src/app/data/nodes/notification/uischema.ts b/apps/demo/src/app/data/nodes/notification/uischema.ts index eaf511318..3ddf6788f 100644 --- a/apps/demo/src/app/data/nodes/notification/uischema.ts +++ b/apps/demo/src/app/data/nodes/notification/uischema.ts @@ -65,7 +65,8 @@ const sendEmailProperties: PaletteItem['uischema'] = { elements: [ { type: 'Label', text: 'Number of retries' }, { - type: 'Text', + type: 'VariableDynamic', + variableType: 'number', scope: scope('properties.sendEmail.properties.retries'), rule: { effect: 'DISABLE', diff --git a/apps/demo/src/app/data/nodes/trigger/schema-output.ts b/apps/demo/src/app/data/nodes/trigger/schema-output.ts new file mode 100644 index 000000000..86a94b592 --- /dev/null +++ b/apps/demo/src/app/data/nodes/trigger/schema-output.ts @@ -0,0 +1,74 @@ +import type { NodeSchemaOutput } from '@workflowbuilder/sdk'; + +export const schemaOutput: NodeSchemaOutput = { + type: 'variant', + variants: [ + { + variantRule: undefined, + bySourceHandle: { + every: { + type: 'object', + properties: { + eventType: { + type: 'string', + title: 'Event Type', + description: 'The type of event that started the workflow', + }, + timestamp: { + type: 'string', + format: 'date-time', + title: 'Timestamp', + description: 'ISO 8601 date-time when the trigger fired', + }, + }, + }, + }, + }, + { + variantRule: { + onlyIfPropertyNameEquals: { path: 'type', value: 'timeBasedTrigger' }, + }, + bySourceHandle: { + success: { + type: 'object', + properties: { + allDay: { + type: 'boolean', + title: 'All day event', + description: 'The type of event that started the workflow', + }, + startDate: { + type: 'string', + format: 'date-time', + title: 'Start date', + description: 'The date when the event was scheduled to start', + }, + endDate: { + type: 'string', + format: 'date-time', + title: 'End date', + description: 'The date when the event was scheduled to end', + }, + }, + }, + }, + }, + { + variantRule: { + onlyIfPropertyNameEquals: { path: 'type', value: 'eventBasedTrigger' }, + }, + bySourceHandle: { + success: { + type: 'object', + properties: { + typeOfEventType: { + type: 'string', + title: 'Type of event type', + description: 'For example: form submission, user action etc.', + }, + }, + }, + }, + }, + ], +}; diff --git a/apps/demo/src/app/data/nodes/trigger/trigger.ts b/apps/demo/src/app/data/nodes/trigger/trigger.ts index dc2dcaacd..3028f89bc 100644 --- a/apps/demo/src/app/data/nodes/trigger/trigger.ts +++ b/apps/demo/src/app/data/nodes/trigger/trigger.ts @@ -2,6 +2,7 @@ import type { PaletteItem } from '@workflowbuilder/sdk'; import { defaultPropertiesData } from './default-properties-data'; import { type TriggerNodeSchema, schema } from './schema'; +import { schemaOutput } from './schema-output'; import { uischema } from './uischema'; export const triggerNode: PaletteItem = { @@ -12,13 +13,6 @@ export const triggerNode: PaletteItem = { isStartNode: true, defaultPropertiesData, schema, + schemaOutput, 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' }, - }, - }, }; diff --git a/apps/docs/src/content/docs/guides/use-variable-picker.mdx b/apps/docs/src/content/docs/guides/use-variable-picker.mdx index 5118bf50e..a91cb2790 100644 --- a/apps/docs/src/content/docs/guides/use-variable-picker.mdx +++ b/apps/docs/src/content/docs/guides/use-variable-picker.mdx @@ -24,7 +24,7 @@ The picker is wired up on these built-in fields: The chip is the editor view; the diagram stores the raw form `{{nodes..}}`. -Suggestions come only from **ancestor** nodes - nodes reachable by following edges backward from the selected node. Each suggestion is built from the ancestor's `outputSchema`, so a node only appears in the picker if it declares one. +Suggestions come only from **ancestor** nodes - nodes reachable by following edges backward from the selected node. Each suggestion is built from the ancestor's `schemaOutput`, so a node only appears in the picker if it declares one. ## Syntax @@ -57,7 +57,7 @@ The SDK only stores these references as text on the diagram. Actual values are f ### `nodes..` -Output of an ancestor node, keyed by the node's `id`. **Surfaced in the picker.** The available properties for each ancestor come from that node's `outputSchema`. +Output of an ancestor node, keyed by the node's `id`. **Surfaced in the picker.** The available properties for each ancestor come from that node's `schemaOutput`. For example, an AI Agent node emits `response`, `tokensUsed`, and `model`, so a downstream field can reference any of: @@ -162,23 +162,87 @@ The control behaves like the matching plain control, except `{{` opens the sugge ### Expose the node's outputs to downstream pickers -Add an `outputSchema` to the node's `PaletteItem`. Each entry describes one output property the node will emit: +Add a `schemaOutput` (type `NodeSchemaOutput`) to the node's `PaletteItem`. It describes what the node emits on each of its **source handles** as a JSON Schema, so the picker can offer the right properties depending on which port the downstream node is connected to (an `error` port does not receive the success-branch outputs). ```typescript -import type { PaletteItem } from '@workflowbuilder/sdk'; +import type { NodeSchemaOutput, PaletteItem } from '@workflowbuilder/sdk'; + +const schemaOutput: NodeSchemaOutput = { + type: 'default', + bySourceHandle: { + success: { + type: 'object', + properties: { + response: { type: 'string', title: 'Response', description: 'The text returned by the model' }, + tokensUsed: { type: 'number', title: 'Tokens Used' }, + finishedAt: { type: 'string', format: 'date-time', title: 'Finished At' }, + }, + }, + error: { + type: 'object', + properties: { + errorMessage: { type: 'string', title: 'Error Message' }, + }, + }, + }, +}; export const myNode: PaletteItem = { // ...label, type, schema, defaults, uischema - outputSchema: { - properties: { - response: { type: 'string', label: 'Response', description: 'The text returned by the model' }, - tokensUsed: { type: 'number', label: 'Tokens Used' }, + schemaOutput, +}; +``` + +How the schema is read: + +- Keys of `bySourceHandle` are source handle ids (`success`, `error`, ...). Use the special key `every` for properties emitted on all handles. +- Each value is a JSON Schema object (`type: 'object'` with `properties`). `title` becomes the suggestion label, `description` its help text. +- Supported property types: `string`, `number`, `boolean`, `object`, `array`. A `string` with `format: 'date-time'` is surfaced as a date-time variable. +- Nested `object` properties are flattened to dot paths - `result.status` in the schema becomes `{{nodes..result.status}}` in the picker. + +Without a `schemaOutput`, the node never appears as a group in any downstream picker. + +#### Outputs that depend on configuration + +If the node's outputs change with how it is configured (e.g. a Trigger node whose fields differ per trigger type), use the `variant` form. Every variant whose rule matches the node's current `properties` is merged into the suggestions; a variant with `variantRule: undefined` always applies: + +```typescript +const schemaOutput: NodeSchemaOutput = { + type: 'variant', + variants: [ + { + variantRule: undefined, + bySourceHandle: { + every: { + type: 'object', + properties: { + eventType: { type: 'string', title: 'Event Type' }, + }, + }, + }, }, - }, + { + variantRule: { onlyIfPropertyNameEquals: { path: 'type', value: 'timeBasedTrigger' } }, + bySourceHandle: { + success: { + type: 'object', + properties: { + startDate: { type: 'string', format: 'date-time', title: 'Start date' }, + }, + }, + }, + }, + ], }; ``` -Without an `outputSchema`, the node never appears as a group in any downstream picker. +`onlyIfPropertyNameEquals.path` is a dot path into the node's `properties`; the variant applies when the value at that path strictly equals `value`. + +A variant can also take its properties from user-defined variables stored on the node instead of a static schema: `variantRule: { fromValueOfPropertyPath: 'outputs', toSourceHandles: ['success'] }` reads the variable definitions saved at `properties.outputs`. + +:::note +`outputSchema` (the previous flat `{ properties }` shape) is deprecated and still accepted, but will be removed in the next major release. Migrate to `schemaOutput`. +::: ## See also diff --git a/apps/docs/src/content/docs/node-schemas/form-controls.md b/apps/docs/src/content/docs/node-schemas/form-controls.md index b04c0c5d4..88e3e2aaf 100644 --- a/apps/docs/src/content/docs/node-schemas/form-controls.md +++ b/apps/docs/src/content/docs/node-schemas/form-controls.md @@ -152,9 +152,41 @@ Multi-line variant of `VariableText`. Same `{{...}}` placeholder behaviour. { type: 'VariableTextArea', scope: '#/properties/messageBody', minRows: 4 } ``` +## `VariableDynamic` + +Typed input that lets the user **either type a literal value or pick a single upstream variable**. A toggle button in the field switches between the two modes: the variable mode shows a select listing only upstream outputs whose type matches `variableType`, the manual mode shows a widget picked from `variableType`: + +| `variableType` | Manual widget | Saved value | +| ---------------------- | ----------------------------------- | ------------------------------------------------ | +| `'string'` | Text input with inline `{{` picker | trimmed `string`, or `undefined` when empty | +| `'number'` | Text input with numeric validation | `number`, or `undefined` when not a valid number | +| `'boolean'` | True / False select | `boolean`, or `undefined` when unset | +| `'date'`, `'datetime'` | Date picker (+ time for `datetime`) | ISO 8601 `string` | + +Unlike `VariableText`, the stored value is either a typed literal or exactly one variable reference — `{{nodes..}}` for an upstream node output or `{{global.}}` for a global variable. Mixed text is not possible. The reference is always stored as a `string`, even when `variableType` is `'number'` or `'boolean'`. The variable toggle is hidden when no upstream output of a compatible type exists. + +| Prop | Type | Required | Notes | +| -------------- | ----------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `variableType` | `'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime'` | yes | Drives the manual widget, the value coercion on blur, and which upstream outputs are suggested (`string` also accepts `number` / `date` / `datetime`). | +| `placeholder` | string | no | Empty-state hint. Applies to the `string` / `number` widgets only. | + +```ts +// schema.ts +{ + retries: { type: 'number' }, + untilDate: { type: 'string' }, +} +``` + +```ts +// uischema.ts +{ type: 'VariableDynamic', scope: '#/properties/retries', variableType: 'number' } +{ type: 'VariableDynamic', scope: '#/properties/untilDate', variableType: 'date' } +``` + ## `MessageOnError` -Inline message that surfaces when a node-level validation error matches the `scope`. Renders nothing if there's no error on that property — useful for context-specific guidance ("This input requires an upstream variable") next to the affected field. +Inline message that surfaces when a node-level validation error matches the `scope`. Renders nothing if there's no error on that property — useful for context-specific guidance ("A custom error message") next to the affected field. | Prop | Type | Required | Notes | | ------ | ------ | -------- | ----------------------------------------------------------------------------------------------------- | @@ -163,8 +195,8 @@ Inline message that surfaces when a node-level validation error matches the `sco ```ts { type: 'MessageOnError', - scope: '#/properties/missingPreviousVariable', - text: 'This field needs a variable from an upstream node.', + scope: '#/properties/customError', + text: 'A custom error message.', } ``` diff --git a/packages/execution-core/generic-execution-core.decision-log.md b/packages/execution-core/generic-execution-core.decision-log.md index df4e180f8..76bab43fb 100644 --- a/packages/execution-core/generic-execution-core.decision-log.md +++ b/packages/execution-core/generic-execution-core.decision-log.md @@ -14,7 +14,7 @@ Net effect: the engine adapter was swappable per `WorkflowEnginePort`, but the node vocabulary was not. Anyone wanting to ship a different workflow product on this codebase had to fork the core, fork the types package, and rewrite backend mappers. The package's stated purpose ("generic graph-runner mechanism") and its actual surface area diverged. -A separate dead-code observation: `packages/types/src/workflow-execution/node-output-schemas.ts` exported an `executionNodeOutputSchemas` map keyed by `'ai-studio/*'` with zero readers in the repo. It was infrastructure for a feature nobody was using. +A separate dead-code observation: `packages/types/src/workflow-execution/node-output-schemas.ts` exported an `executionNodeSchemaOutputs` map keyed by `'ai-studio/*'` with zero readers in the repo. It was infrastructure for a feature nobody was using. ## Decision diff --git a/packages/sdk/src/features/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..7d8f68cf7 100644 --- a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx +++ b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dependencies/dependencies.tsx @@ -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 { useNodeVariables } from '../../../../variables/hooks/use-node-variables'; +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, variablesKey } = useNodeVariables(selection?.node?.id); return ( ; @@ -34,7 +36,7 @@ const getTypeOptions = ( xType: VariableTypePrimitive; comparisonsOperators: ComparisonOperator[]; } => { - const xType = getStringType(value); + const xType = getStringVariableTypeIfPossible(value); const comparisonsOperators: ComparisonOperator[] = comparisonOperatorsByPrimitiveType[xType] || []; return { @@ -44,6 +46,7 @@ const getTypeOptions = ( }; export function ConditionsFormField(props: ConditionsFormFieldProps) { + const isReadOnlyMode = useStore((store) => store.isReadOnlyMode); const { condition, onChange, onRemove, shouldShowOperator = false, shouldShowValidation, suggestionGroups } = props; const [{ xType, comparisonsOperators }, setTypeOptions] = useState(getTypeOptions(condition.x)); @@ -78,11 +81,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')} )} @@ -122,6 +125,7 @@ export function ConditionsFormField(props: ConditionsFormFieldProps) { isError={shouldShowValidation && errors.y} type={xType} suggestionGroups={suggestionGroups} + isDisabled={isReadOnlyMode} /> diff --git a/packages/sdk/src/features/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..e50624195 100644 --- a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form/conditions-form.tsx +++ b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form/conditions-form.tsx @@ -8,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 { VARIABLES_TYPES_NOT_PRIMITIVE } from '../../../../../features/variables/constants'; +import { useNodeVariables } from '../../../../variables/hooks/use-node-variables'; +import { getConditionErrors } from '../../../../variables/utils/form-validation/conditions'; import { ConditionsFormField } from '../dynamic-conditions-form-field/conditions-form-field'; type ConditionsFormProps = { @@ -37,7 +37,9 @@ export const ConditionsForm = forwardRef(null); @@ -86,7 +88,7 @@ export const ConditionsForm = forwardRef -
+
{conditions.map((condition, index) => ( .wasChecked}}`, which is dynamic and can be `true` or `false`. diff --git a/packages/sdk/src/features/json-form/controls/variable-dynamic-control/type.ts b/packages/sdk/src/features/json-form/controls/variable-dynamic-control/type.ts new file mode 100644 index 000000000..1a3661b90 --- /dev/null +++ b/packages/sdk/src/features/json-form/controls/variable-dynamic-control/type.ts @@ -0,0 +1,13 @@ +import type { InputProps } from '@workflowbuilder/ui'; + +import type { VariableTypePrimitive } from '../../../../node/node-output-schema'; +import type { BaseControlElement } from '../../../../types/controls'; +import type { Override } from '../../../../types/utils'; + +export type VariableDynamicControlElement = Override< + BaseControlElement, + { + type: 'VariableDynamic'; + variableType: VariableTypePrimitive; + } & Pick +>; diff --git a/packages/sdk/src/features/json-form/controls/variable-dynamic-control/variable-dynamic-control.tsx b/packages/sdk/src/features/json-form/controls/variable-dynamic-control/variable-dynamic-control.tsx new file mode 100644 index 000000000..39b7d5109 --- /dev/null +++ b/packages/sdk/src/features/json-form/controls/variable-dynamic-control/variable-dynamic-control.tsx @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useState } from 'react'; + +import type { VariableType, VariableTypePrimitive } from '../../../../node/node-output-schema'; +import type { WBControlProps } from '../../../../types/controls'; +import { getIsStringNumber } from '../../../../utils/validation/get-is-string-number'; +import { useSingleSelectedElement } from '../../../properties-bar/use-single-selected-element'; +import { DynamicTypedVariableOrInput } from '../../../variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input'; +import { VARIABLES_TYPES_EMPTY } from '../../../variables/constants'; +import { useNodeVariables } from '../../../variables/hooks/use-node-variables'; +import { getBooleanIfPossible } from '../../../variables/utils/get-boolean-if-possible'; +import { getIsStringVariableReference } from '../../../variables/utils/keys/get-is-string-variable-reference'; +import { createControlRenderer } from '../../utils/rendering'; +import { ControlWrapper } from '../control-wrapper'; +import type { VariableDynamicControlElement } from './type'; + +type VariableDynamicControlProps = WBControlProps; + +const variableTypesForSuggestions: Record = { + boolean: ['boolean'], + date: ['date', 'datetime'], + datetime: ['date', 'datetime'], + number: ['number'], + string: ['string', 'number', 'date', 'datetime'], +}; + +function VariableDynamicControl(props: VariableDynamicControlProps) { + const { data, handleChange, path, errors, enabled, uischema } = props; + const { placeholder, variableType } = uischema; + const selection = useSingleSelectedElement(); + const { suggestionGroups, variablesKey } = useNodeVariables(selection?.node?.id, { + excludeTypes: VARIABLES_TYPES_EMPTY, + includeTypes: variableTypesForSuggestions[variableType], + }); + + const [inputValue, setInputValue] = useState(data); + + useEffect(() => { + if (data == null) { + setInputValue(''); + } else { + setInputValue(String(data)); + } + }, [data]); + + const onChange = useCallback((value: string) => { + setInputValue(value); + }, []); + + const onBlur = useCallback( + (value: string) => { + setInputValue(value); + const trimmedValue = value.trim(); + const isSingleVariable = getIsStringVariableReference(trimmedValue); + + if (isSingleVariable) { + handleChange(path, trimmedValue); + + return; + } + + if (variableType === 'number') { + const isNumber = getIsStringNumber(trimmedValue); + handleChange(path, isNumber ? Number(trimmedValue) : undefined); + + return; + } + + if (variableType === 'boolean') { + const valueToSave = getBooleanIfPossible(trimmedValue); + + handleChange(path, valueToSave); + + return; + } + + handleChange(path, trimmedValue || undefined); + }, + [handleChange, path, variableType], + ); + + return ( + + 0} + isDisabled={!enabled} + placeholder={placeholder} + /> + + ); +} + +export const variableDynamicControlRenderer = createControlRenderer('VariableDynamic', VariableDynamicControl); diff --git a/packages/sdk/src/features/json-form/controls/variable-text-area-control/variable-text-area-control.tsx b/packages/sdk/src/features/json-form/controls/variable-text-area-control/variable-text-area-control.tsx index c8a5665e7..05361f987 100644 --- a/packages/sdk/src/features/json-form/controls/variable-text-area-control/variable-text-area-control.tsx +++ b/packages/sdk/src/features/json-form/controls/variable-text-area-control/variable-text-area-control.tsx @@ -2,18 +2,25 @@ import { useCallback, useEffect, useState } from 'react'; import { useSingleSelectedElement } from '../../../../features/properties-bar/use-single-selected-element'; import { VariableText } from '../../../../features/variables/components/variable-text/variable-text'; -import { variablesTypesToExcludeInText } from '../../../../features/variables/constants'; -import { useAvailableVariables } from '../../../../features/variables/hooks/use-available-variables'; +import { + VARIABLES_TYPES_EMPTY, + VARIABLES_TYPES_NUMERIC, + VARIABLES_TYPES_TO_EXCLUDE_IN_TEXT, +} from '../../../../features/variables/constants'; +import { useNodeVariables } from '../../../variables/hooks/use-node-variables'; import type { VariableTextAreaControlProps } from '../../types/controls'; import { createControlRenderer } from '../../utils/rendering'; import { ControlWrapper } from '../control-wrapper'; function VariableTextAreaControl(props: VariableTextAreaControlProps) { - const { data, handleChange, path, errors, enabled, uischema } = props; - const { placeholder, disabled } = uischema; + const { data, handleChange, path, errors, enabled, uischema, schema } = props; + const { placeholder, variablesTypes, disabled } = uischema; + const { type } = schema; const selection = useSingleSelectedElement(); - // TODO: add param to pick what type of variables are available - const suggestionGroups = useAvailableVariables(selection?.node?.id, variablesTypesToExcludeInText); + const { suggestionGroups, variablesKey } = useNodeVariables(selection?.node?.id, { + excludeTypes: variablesTypes ? VARIABLES_TYPES_EMPTY : VARIABLES_TYPES_TO_EXCLUDE_IN_TEXT, + includeTypes: variablesTypes || (type === 'number' ? VARIABLES_TYPES_NUMERIC : VARIABLES_TYPES_EMPTY), + }); const isDisabled = !enabled || disabled === true; @@ -23,19 +30,24 @@ function VariableTextAreaControl(props: VariableTextAreaControlProps) { setInputValue(data ?? ''); }, [data]); - const onBlur = useCallback(() => { - handleChange(path, inputValue || undefined); - }, [handleChange, path, inputValue]); + const onBlur = useCallback( + (value: string) => { + handleChange(path, value || undefined); + }, + [handleChange, path], + ); return ( 0} - mentionsInputProps={{ disabled: isDisabled, placeholder, onBlur }} + mentionsInputProps={{ disabled: isDisabled, placeholder }} /> ); diff --git a/packages/sdk/src/features/json-form/controls/variable-text-control/variable-text-control.tsx b/packages/sdk/src/features/json-form/controls/variable-text-control/variable-text-control.tsx index 0eee80849..38a312966 100644 --- a/packages/sdk/src/features/json-form/controls/variable-text-control/variable-text-control.tsx +++ b/packages/sdk/src/features/json-form/controls/variable-text-control/variable-text-control.tsx @@ -1,19 +1,26 @@ import { useCallback, useEffect, useState } from 'react'; import { VariableText } from '../../../../features/variables/components/variable-text/variable-text'; -import { variablesTypesToExcludeInText } from '../../../../features/variables/constants'; -import { useAvailableVariables } from '../../../../features/variables/hooks/use-available-variables'; import { useSingleSelectedElement } from '../../../properties-bar/use-single-selected-element'; +import { + VARIABLES_TYPES_EMPTY, + VARIABLES_TYPES_NUMERIC, + VARIABLES_TYPES_TO_EXCLUDE_IN_TEXT, +} from '../../../variables/constants'; +import { useNodeVariables } from '../../../variables/hooks/use-node-variables'; import type { VariableTextControlProps } from '../../types/controls'; import { createControlRenderer } from '../../utils/rendering'; import { ControlWrapper } from '../control-wrapper'; function VariableTextControl(props: VariableTextControlProps) { - const { data, handleChange, path, errors, enabled, uischema } = props; - const { placeholder, disabled } = uischema; + const { data, handleChange, path, errors, enabled, uischema, schema } = props; + const { placeholder, variablesTypes, disabled } = uischema; + const { type } = schema; const selection = useSingleSelectedElement(); - // TODO: add param to pick what type of variables are available - const suggestionGroups = useAvailableVariables(selection?.node?.id, variablesTypesToExcludeInText); + const { suggestionGroups, variablesKey } = useNodeVariables(selection?.node?.id, { + excludeTypes: variablesTypes ? VARIABLES_TYPES_EMPTY : VARIABLES_TYPES_TO_EXCLUDE_IN_TEXT, + includeTypes: variablesTypes || (type === 'number' ? VARIABLES_TYPES_NUMERIC : VARIABLES_TYPES_EMPTY), + }); const isDisabled = !enabled || disabled === true; @@ -23,19 +30,24 @@ function VariableTextControl(props: VariableTextControlProps) { setInputValue(data ?? ''); }, [data]); - const onBlur = useCallback(() => { - handleChange(path, inputValue || undefined); - }, [handleChange, path, inputValue]); + const onBlur = useCallback( + (value: string) => { + handleChange(path, value || undefined); + }, + [handleChange, path], + ); return ( 0} - mentionsInputProps={{ disabled: isDisabled, placeholder, onBlur }} + mentionsInputProps={{ disabled: isDisabled, placeholder }} /> ); diff --git a/packages/sdk/src/features/json-form/json-form.tsx b/packages/sdk/src/features/json-form/json-form.tsx index f2efa162a..63cd0bd7e 100644 --- a/packages/sdk/src/features/json-form/json-form.tsx +++ b/packages/sdk/src/features/json-form/json-form.tsx @@ -16,6 +16,7 @@ import { selectControlRenderer } from './controls/select-control/select-control' import { switchControlRenderer } from './controls/switch-control/switch-control'; import { textAreaControlRenderer } from './controls/text-area-control/text-area-control'; import { textControlRenderer } from './controls/text-control/text-control'; +import { variableDynamicControlRenderer } from './controls/variable-dynamic-control/variable-dynamic-control'; import { variableTextAreaControlRenderer } from './controls/variable-text-area-control/variable-text-area-control'; import { variableTextControlRenderer } from './controls/variable-text-control/variable-text-control'; import { getCustomCells, getCustomRenderers } from './extension-registry'; @@ -47,6 +48,7 @@ export function JSONForm(props: Props) { ajv={workflowBuilderValidator} {...rest} config={{ readonly }} + readonly={readonly} />
); @@ -68,6 +70,7 @@ const builtinRenderers: JsonFormsRendererRegistryEntry[] = [ dynamicConditionsControlRenderer, aiToolsControlRenderer, decisionBranchesControlRenderer, + variableDynamicControlRenderer, variableTextControlRenderer, variableTextAreaControlRenderer, messageOnErrorControlRenderer, diff --git a/packages/sdk/src/features/properties-bar/components/edge-properties/edge-properties.tsx b/packages/sdk/src/features/properties-bar/components/edge-properties/edge-properties.tsx index ed6f59404..7f1d86961 100644 --- a/packages/sdk/src/features/properties-bar/components/edge-properties/edge-properties.tsx +++ b/packages/sdk/src/features/properties-bar/components/edge-properties/edge-properties.tsx @@ -6,6 +6,7 @@ import styles from './edge-properties.module.css'; import { FormControlWithLabel } from '../../../../components/form/form-control-with-label/form-control-with-label'; import type { WorkflowBuilderEdge } from '../../../../node/node-data'; import { useStore } from '../../../../store/store'; +import { trackFutureChange } from '../../../changes-tracker/stores/use-changes-tracker-store'; import { OptionalEdgeProperties } from '../../../plugins-core/components/app/optional-edge-properties'; type Props = { @@ -28,6 +29,7 @@ export function EdgeProperties({ edge }: Props) { const onChange: React.ChangeEventHandler = (event) => { const { value } = event.target; setInput(value); + trackFutureChange('dataUpdateEdge', { id }); setEdgeData(id, { label: value }); }; diff --git a/packages/sdk/src/features/properties-bar/components/node-properties/node-properties.tsx b/packages/sdk/src/features/properties-bar/components/node-properties/node-properties.tsx index 21b2d72a7..5288b06ae 100644 --- a/packages/sdk/src/features/properties-bar/components/node-properties/node-properties.tsx +++ b/packages/sdk/src/features/properties-bar/components/node-properties/node-properties.tsx @@ -88,7 +88,7 @@ export const NodeProperties = memo(({ node }: Props) => { } const flattenErrors = flatErrors(errors); - trackFutureChange('dataUpdate'); + trackFutureChange('dataUpdateNode', { id }); setNodeProperties(id, { ...data, errors: flattenErrors }); removeEdgesForDeletedHandles(id, properties, data); }; diff --git a/packages/sdk/src/features/variables/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..79d1a7fb4 100644 --- a/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx +++ b/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx @@ -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,17 @@ export function DynamicTypedVariableOrInput({ className={className} value={value} onChange={onChange} + onBlur={onBlur} variant="text" suggestionGroups={suggestionGroupsForType} hasError={isError} endAdornment={ - + } @@ -70,9 +89,11 @@ 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..fad99abb7 100644 --- a/packages/sdk/src/features/variables/components/variable-select/variable-select.tsx +++ b/packages/sdk/src/features/variables/components/variable-select/variable-select.tsx @@ -1,10 +1,12 @@ -import { useCallback } from 'react'; +import clsx from 'clsx'; +import { useCallback, useEffect, useRef } 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,30 +14,52 @@ type Props = VariableTextProps & { endAdornment?: React.ReactNode; }; -export function VariableSelect({ onChange, endAdornment, ...props }: Props) { +export function VariableSelect({ onChange, onBlur, endAdornment, ...props }: Props) { const { t } = useTranslation(); + // We use ref because state mutates the onBlur which prop drill isn't updated in time + const inputValueRef = useRef(props.value == null ? '' : String(props.value)); + + useEffect(() => { + inputValueRef.current = props.value == null ? '' : String(props.value); + }, [props.value]); + const handleOnChange: VariableTextProps['onChange'] = useCallback( (value) => { - const newValue = value ? `{{` + 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 : ''; + inputValueRef.current = valueToPass; onChange(valueToPass); focusNextElement(); }, [onChange], ); + const handleOnBlur: VariableTextProps['onBlur'] = useCallback(() => { + if (!onBlur) { + return; + } + + const inputValue = inputValueRef.current; + const newValue = inputValue ? VARIABLE_BRACKETS_START + inputValue.split(VARIABLE_BRACKETS_START).at(-1) : ''; + const valueToPass = getIsStringVariableReference(newValue) ? newValue : ''; + + onBlur(valueToPass); + }, [onBlur]); + return (
- {endAdornment && {endAdornment}} + {endAdornment && {endAdornment}}
); } diff --git a/packages/sdk/src/features/variables/components/variable-text/core/build-mention-data.ts b/packages/sdk/src/features/variables/components/variable-text/core/build-mention-data.ts new file mode 100644 index 000000000..cbafdb49a --- /dev/null +++ b/packages/sdk/src/features/variables/components/variable-text/core/build-mention-data.ts @@ -0,0 +1,14 @@ +import type { VariableMentionData, VariableSuggestionGroup } from '../variable-text.types'; + +export function buildMentionData(groups: VariableSuggestionGroup[]): VariableMentionData[] { + return groups.flatMap((group) => + group.suggestions.map((suggestion) => ({ + id: suggestion.id, + display: `{{ ${suggestion.display} }}`, + groupLabel: group.label, + label: suggestion.label, + description: suggestion.description, + type: suggestion.type, + })), + ); +} diff --git a/packages/sdk/src/features/variables/components/variable-text/variable-text.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..14e0e35ae 100644 --- a/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx +++ b/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx @@ -1,21 +1,30 @@ 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, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Mention, type MentionDataItem, MentionsInput } from 'react-mentions-ts'; +import { Mention, type MentionDataItem, MentionsInput, type MentionsInputProps } from 'react-mentions-ts'; import { Icon } from '@workflow-builder/icons'; 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 type { VariableSuggestion, VariableSuggestionGroup, VariableTextProps } from './variable-text.types'; +import { VARIABLE_BRACKETS_START, VARIABLE_NODES_KEY, variableTypeInfoByType } from '../../constants'; +import { buildMentionData } from './core/build-mention-data'; +import type { + VariableMentionData, + VariableSuggestion, + VariableSuggestionGroup, + VariableTextProps, +} from './variable-text.types'; const DEFAULT_TRIGGER = '{{'; const DEFAULT_MARKUP = '{{__id__}}'; const DEFAULT_TITLE = 'Variables'; +type MentionsInputBlurHandler = NonNullable; + const baseClassNames = { control: styles['control'], input: styles['input'], @@ -36,29 +45,12 @@ const multiLineClassNames = { control: `${styles['control']} ${styles['multiLine']}`, }; -type VariableMentionData = MentionDataItem & { - groupLabel?: string; - label: string; - description?: string; - type: VariableType; -}; - -function buildMentionData(groups: VariableSuggestionGroup[]): VariableMentionData[] { - return groups.flatMap((group) => - group.suggestions.map((suggestion) => ({ - id: suggestion.id, - display: suggestion.display, - groupLabel: group.label, - label: suggestion.label, - description: suggestion.description, - type: suggestion.type, - })), - ); -} - function defaultRenderGroupItem(suggestion: VariableSuggestion, _focused: boolean): ReactNode { return ( -
+
{suggestion.label} {suggestion.description && {suggestion.description}}
@@ -88,85 +80,12 @@ function stopLibraryMouseDown(event: React.MouseEvent) { event.stopPropagation(); } -function handleClose() { - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } -} - -function SuggestionsContainer({ - groups, - title, - renderGroupHeader, - children, -}: { - groups: VariableSuggestionGroup[]; - title: string; - renderGroupHeader: (group: VariableSuggestionGroup) => ReactNode; - children: ReactElement; -}) { - const ul = children as ReactElement<{ children?: ReactElement[]; className?: string }>; - const items = ul.props.children; - - if (!items || !Array.isArray(items) || items.length === 0) { - return children; - } - - // Build a lookup from suggestion id → group for efficient header injection - const groupByItemId = new Map(); - for (const group of groups) { - for (const s of group.suggestions) { - groupByItemId.set(s.id, group); - } - } - - const grouped: ReactNode[] = []; - let previousLabel = ''; - - for (const item of items) { - // Library keys are formatted as "childIndex-suggestionId", e.g. "0-nodeId.propKey" - const suggestionId = String(item.key ?? '').replace(/^\d+-/, ''); - const group = groupByItemId.get(suggestionId); - const label = group?.label ?? ''; - - if (label !== previousLabel) { - const headerGroup = group ?? groups.find((group) => group.label === label); - if (headerGroup && (headerGroup.label || headerGroup.icon)) { - grouped.push( -
  • - {renderGroupHeader(headerGroup)} -
  • , - ); - } - previousLabel = label; - } - - grouped.push(item); - } - - return ( -
    -
    - {title} - { - event.stopPropagation(); - handleClose(); - }} - > - - -
    - {cloneElement(ul, {}, grouped)} -
    - ); -} - export function VariableText({ className, classNameWrapper, value, onChange, + onBlur, variant = 'text', suggestionGroups, title = DEFAULT_TITLE, @@ -177,10 +96,86 @@ export function VariableText({ mentionProps, }: VariableTextProps) { const { t } = useTranslation(); + const [key, setKey] = useState(crypto.randomUUID()); const singleLine = variant === 'text'; const mentionData = useMemo(() => buildMentionData(suggestionGroups), [suggestionGroups]); + const handleClose = useCallback(() => { + setKey(crypto.randomUUID()); + }, []); + + const SuggestionsContainer = useCallback( + ({ + groups, + title, + renderGroupHeader, + children, + }: { + groups: VariableSuggestionGroup[]; + title: string; + renderGroupHeader: (group: VariableSuggestionGroup) => ReactNode; + children: ReactElement; + }) => { + const ul = children as ReactElement<{ children?: ReactElement[]; className?: string }>; + const items = ul.props.children; + + if (!items || !Array.isArray(items) || items.length === 0) { + return children; + } + + // Build a lookup from suggestion id → group for efficient header injection + const groupByItemId = new Map(); + for (const group of groups) { + for (const s of group.suggestions) { + groupByItemId.set(s.id, group); + } + } + + const grouped: ReactNode[] = []; + let previousLabel = ''; + + for (const item of items) { + // Library keys are formatted as "childIndex-suggestionId", e.g. "0-nodeId.propKey" + const suggestionId = String(item.key ?? '').replace(/^\d+-/, ''); + const group = groupByItemId.get(suggestionId); + const label = group?.label ?? ''; + + if (label !== previousLabel) { + const headerGroup = group ?? groups.find((group) => group.label === label); + if (headerGroup && (headerGroup.label || headerGroup.icon)) { + grouped.push( +
  • + {renderGroupHeader(headerGroup)} +
  • , + ); + } + previousLabel = label; + } + + grouped.push(item); + } + + return ( +
    +
    + {title} + { + event.stopPropagation(); + handleClose(); + }} + > + + +
    + {cloneElement(ul, {}, grouped)} +
    + ); + }, + [handleClose], + ); + const displayTransform = useCallback( (id: string | number) => { const typedId = String(id); @@ -188,12 +183,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; @@ -229,7 +233,7 @@ export function VariableText({ {children} ), - [suggestionGroups, title, renderGroupHeader], + [SuggestionsContainer, suggestionGroups, title, renderGroupHeader], ); const onMentionsChange = useCallback( @@ -247,6 +251,18 @@ export function VariableText({ [mentionData.length, onChange], ); + const { onBlur: onMentionsInputBlur, ...restMentionsInputProps } = mentionsInputProps ?? {}; + + // `event.target.value` holds the display text (labels, "Missing node" placeholders), + // not the `{{id}}` markup — the controlled `value` prop is the only source of truth. + const handleBlur = useCallback( + (event) => { + onMentionsInputBlur?.(event); + onBlur?.(value); + }, + [onBlur, onMentionsInputBlur, value], + ); + const { trigger = DEFAULT_TRIGGER, markup = DEFAULT_MARKUP, @@ -256,8 +272,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,18 +285,19 @@ export function VariableText({ ...base, control, }; - }, [hasError, singleLine, className]); + }, [className, hasError, singleLine]); return ( void; + onBlur?: (value: string) => void; variant?: 'text' | 'text-area'; suggestionGroups: VariableSuggestionGroup[]; @@ -38,3 +39,10 @@ export type VariableTextProps = { mentionProps?: Omit; hasError?: boolean; }; + +export type VariableMentionData = MentionDataItem & { + groupLabel?: string; + label: string; + description?: string; + type: VariableType; +}; diff --git a/packages/sdk/src/features/variables/constants.ts b/packages/sdk/src/features/variables/constants.ts index 1b1d3b1e8..279a0158b 100644 --- a/packages/sdk/src/features/variables/constants.ts +++ b/packages/sdk/src/features/variables/constants.ts @@ -1,6 +1,13 @@ -import type { VariableType, VariableTypePrimitive } from '../../node/node-output-schema'; +import type { VariableType, VariableTypePrimitive } from '@workflow-builder/types/node-output-schema'; -export type LogicalOperator = 'OR' | 'AND'; +export const NODE_ID_FOR_COMMON_NODE_DATA = ''; +export const NODE_LABEL_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 @@ -50,6 +57,7 @@ export const comparisonOperatorsByPrimitiveType: Record = { +export const variableTypeInfoByType: Record = { string: { type: 'string', baseType: 'string', @@ -103,24 +111,49 @@ export const variableTypeInfoByType: Record type === baseType, ); -export const variablesTypesToExcludeNonPrimitive: VariableType[] = ['object', 'array']; +export const VARIABLES_TYPES_NOT_PRIMITIVE: VariableType[] = ['object', 'array']; + +export const VARIABLES_TYPES_TO_EXCLUDE_IN_TEXT: VariableType[] = [...VARIABLES_TYPES_NOT_PRIMITIVE, 'boolean']; + +export const VARIABLES_TYPES_NUMERIC: VariableType[] = ['number']; + +export const VARIABLES_TYPES_EMPTY: VariableType[] = []; // module scope -export const variablesTypesToExcludeInText: VariableType[] = [...variablesTypesToExcludeNonPrimitive, 'boolean']; +/** + * Special keywords used to determine source-handle behaviour. + * + * Source handles may have arbitrary names, but handles containing one of these + * keywords are treated specially when processing `bySourceHandle`. + * + * - `EVERY` (`every`): Values assigned to this handle are additionally attached + * to every branch. `every` values are always forwarded. + * - `SUCCESS` (`success`): A branch is considered successful when its source + * handle does not contain the `ERROR` keyword. Successful branches receive + * the values assigned to this handle in addition to their own values. + * - `ERROR` (`error`): Values assigned to this handle are additionally attached + * to every branch whose source handle contains the `ERROR` keyword. + * + * The keywords are matched against the source-handle name, so source handles + * can have custom names while still triggering the corresponding behaviour. + */ +export const SPECIAL_SOURCE_HANDLE_KEYWORDS = { + EVERY: 'every', + SUCCESS: 'success', + ERROR: 'error', +} as const; diff --git a/packages/sdk/src/features/variables/hooks/use-available-variables.ts b/packages/sdk/src/features/variables/hooks/use-available-variables.ts deleted file mode 100644 index 057a89b7a..000000000 --- a/packages/sdk/src/features/variables/hooks/use-available-variables.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -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'; - -export function useAvailableVariables( - nodeId: string | undefined, - excludeTypes: string[] = [], -): VariableSuggestionGroup[] { - const globalVariables = useStore((store) => store.globalVariables); - const nodes = useStore((store) => store.nodes); - const edges = useStore((store) => store.edges); - - 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', - suggestions, - }; - - return [globalGroup]; - }, [globalVariables, t]); - - const nodeSuggestionsGroups = useMemo(() => { - return getAvailableVariablesByNodeId({ - nodeId, - nodes, - edges, - excludeTypes, - }); - - // .length is critical here for performance. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [nodeId, edges.length, nodes.length]); - - return useMemo(() => { - return [...globalSuggestionsGroups, ...nodeSuggestionsGroups]; - }, [globalSuggestionsGroups, nodeSuggestionsGroups]); -} diff --git a/packages/sdk/src/features/variables/hooks/use-node-variables.ts b/packages/sdk/src/features/variables/hooks/use-node-variables.ts new file mode 100644 index 000000000..56fbf85a0 --- /dev/null +++ b/packages/sdk/src/features/variables/hooks/use-node-variables.ts @@ -0,0 +1,93 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { VariableType } from '../../../node/node-output-schema'; +import { useStore } from '../../../store/store'; +import type { VariableSuggestion, VariableSuggestionGroup } from '../components/variable-text/variable-text.types'; +import { VARIABLES_TYPES_EMPTY } from '../constants'; +import { getSuggestionsFromVariableIndex } from '../stores/core/get-suggestions-node-data/get-suggestions-from-variables-index'; +import { useVariablesSuggestionsStore } from '../stores/use-variable-suggestions-store'; +import { filterSuggestionsByTypes } from '../utils/core/filter-suggestions-by-types'; +import { getAvailableVariablesByNodeId } from '../utils/core/get-available-variables-by-node-id'; + +type Options = { + excludeTypes?: VariableType[]; + includeTypes?: VariableType[]; +}; + +type Response = { + suggestionGroups: VariableSuggestionGroup[]; + totalVariables: number; + variablesKey: string; +}; + +export function useNodeVariables(nodeId: string | undefined, options?: Options): Response { + const { excludeTypes = VARIABLES_TYPES_EMPTY, includeTypes = VARIABLES_TYPES_EMPTY } = options || {}; + const globalVariables = useStore((store) => store.globalVariables); + const nodes = useStore((store) => store.nodes); + const edges = useStore((store) => store.edges); + const lastUpdateIndex = useVariablesSuggestionsStore((store) => store.lastUpdateIndex); + const { t } = useTranslation(); + + const globalSuggestionsGroups = useMemo(() => { + 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 []; + }, [excludeTypes, globalVariables, includeTypes, t]); + + const nodeSuggestionsGroups = useMemo(() => { + return getAvailableVariablesByNodeId({ + nodeId, + nodes, + edges, + excludeTypes, + includeTypes, + }); + + // .length is critical here for performance. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastUpdateIndex, nodeId, edges.length, nodes.length]); + + return useMemo(() => { + const suggestionGroups = [...globalSuggestionsGroups, ...nodeSuggestionsGroups]; + const { totalVariables, variablesKey } = suggestionGroups.reduce( + (stack: Omit, group) => { + stack.totalVariables += group.suggestions.length; + for (const suggestion of group.suggestions) { + stack.variablesKey += `-${suggestion.id}`; + } + + return stack; + }, + { + totalVariables: 0, + variablesKey: 'vars', + }, + ); + + return { + suggestionGroups, + totalVariables, + variablesKey, + }; + }, [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..73a5b4ef1 --- /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', 'paste', 'cut', '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..50f6408fb --- /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 { labelToSnakeCase } 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 = labelToSnakeCase(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 100% 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 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..1af6a9605 --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/get-node-variables-suggestions.ts @@ -0,0 +1,88 @@ +import { truncate } from '../../../../utils/text'; +import type { VariableSuggestion } from '../../components/variable-text/variable-text.types'; +import { + NODE_ID_FOR_COMMON_NODE_DATA, + NODE_LABEL_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, + display: suggestion.display.replace(NODE_LABEL_FOR_COMMON_NODE_DATA, truncate(nodeData.nodeLabel, 15)), + id: suggestion.id.replace(NODE_ID_FOR_COMMON_NODE_DATA, params.nodeId), + })); + + 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-deprecated-suggestions-from-output-schema.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-deprecated-suggestions-from-output-schema.ts new file mode 100644 index 000000000..17078ea64 --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-deprecated-suggestions-from-output-schema.ts @@ -0,0 +1,43 @@ +import type { FlattenedPropertiesIndex } from '../../../../../node/node-output-schema'; +import { filterEmpty } from '../../../../../utils/array'; +import { keyToLabel, truncate } from '../../../../../utils/text'; +import type { VariableSuggestion } from '../../../components/variable-text/variable-text.types'; +import { VARIABLE_DELIMITER } from '../../../constants'; +import { getVariableReferenceWithoutBracketsForNode } from '../../../utils/keys/get-variable-reference-without-brackets-for-node'; + +type Params = { + properties: FlattenedPropertiesIndex; + nodeId: string; + nodeLabel: string; +}; + +/** + * Produces a list of suggestions generated from `FlattenedPropertiesIndex` (used by node outputProperties). + * + * @deprecated `outputSchema` is deprecated. Switch to `schemaOutput` instead. + * The newer version uses a schema similar to the Node schema, but also supports handling responses + * by source handle (the error port does not receive successful variables). + * + * `outputSchema` will be removed in the next major release (3.0). + */ +export function getDeprecatedSuggestionsFromOutputSchema({ + nodeId, + nodeLabel, + properties, +}: Params): VariableSuggestion[] { + return Object.entries(properties) + .map(([propertyKey, property]) => + property + ? { + id: getVariableReferenceWithoutBracketsForNode({ nodeId, propertyName: propertyKey }), + display: [truncate(nodeLabel, 15), truncate(property.label || keyToLabel(propertyKey), 15)] + .filter(Boolean) + .join(VARIABLE_DELIMITER), + 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-schema-output.ts b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-schema-output.ts new file mode 100644 index 000000000..1b9a2d302 --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-schema-output.ts @@ -0,0 +1,37 @@ +import type { JsonSchema7 } from '@jsonforms/core'; + +import { filterEmpty } from '../../../../../utils/array'; +import { keyToLabel, pathToLabel, truncate } from '../../../../../utils/text'; +import type { VariableSuggestion } from '../../../components/variable-text/variable-text.types'; +import { VARIABLE_DELIMITER } from '../../../constants'; +import { getFlattenedPropertiesFromJsonSchema7 } from '../../../utils/json-schema/get-flattened-properties-from-json-schema-7'; +import { getVariableReferenceWithoutBracketsForNode } from '../../../utils/keys/get-variable-reference-without-brackets-for-node'; + +type Params = { + properties: JsonSchema7; + nodeId: string; + nodeLabel: string; +}; + +/** + * Produces a list of suggestions generated from JsonSchema7 (used by node definition variants). + */ +export function getSuggestionsFromSchemaOutput({ nodeId, nodeLabel, properties }: Params): VariableSuggestion[] { + const flattenedProperties = getFlattenedPropertiesFromJsonSchema7(properties); + + return Object.entries(flattenedProperties) + .map(([propertyKey, property]) => + property + ? { + id: getVariableReferenceWithoutBracketsForNode({ nodeId, propertyName: propertyKey }), + display: [truncate(nodeLabel, 15), truncate(property.label || pathToLabel(propertyKey), 15)] + .filter(Boolean) + .join(VARIABLE_DELIMITER), + label: property.label || truncate(property.label || keyToLabel(propertyKey), 25), + description: property.description || keyToLabel(propertyKey), + 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..1a9d5d878 --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-from-variables-index.ts @@ -0,0 +1,52 @@ +import { filterEmpty } from '../../../../../utils/array'; +import { truncate } from '../../../../../utils/text'; +import type { VariableSuggestion } from '../../../components/variable-text/variable-text.types'; +import { VARIABLE_DELIMITER } from '../../../constants'; +import type { VariablesIndex } from '../../../types'; +import { getVariableReferenceWithoutBracketsForGlobal } from '../../../utils/keys/get-variable-reference-without-brackets-for-global'; +import { getVariableReferenceWithoutBracketsForNode } from '../../../utils/keys/get-variable-reference-without-brackets-for-node'; + +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)] + .filter(Boolean) + .join(VARIABLE_DELIMITER), + 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..dc65b5e0f --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/get-suggestions-node-data/get-suggestions-node-data.ts @@ -0,0 +1,188 @@ +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 { getDeprecatedSuggestionsFromOutputSchema } from './get-deprecated-suggestions-from-output-schema'; +import { getSuggestionsFromSchemaOutput } from './get-suggestions-from-schema-output'; +import { getSuggestionsFromVariableIndex } from './get-suggestions-from-variables-index'; + +type Params = { + definition: PaletteItem; + node: WorkflowBuilderNode; +}; + +type Response = { + type: SuggestionNodeType; + bySourceHandle: SuggestionsBySourceHandle; +}; + +const EMPTY_NODE_SUGGESTIONS: Response = { + type: SUGGESTION_NODE_TYPE.CUSTOM, + bySourceHandle: { + every: [], + }, +}; + +export function getSuggestionsNodeData({ definition, node }: Params): Response { + const nodeLabel = getNodeLabelForVariable({ node, definition }); + const bySourceHandle: SuggestionsBySourceHandle = { + every: [], + }; + + if (!definition?.schemaOutput?.type) { + // outputSchema is deprecated and will be removed in future versions + if (definition?.outputSchema?.type === OUTPUT_SCHEMA_TYPE.DEFAULT) { + // The old format doesn't return the same value for all handles + bySourceHandle['every'] = [ + ...(bySourceHandle['every'] || []), + ...getDeprecatedSuggestionsFromOutputSchema({ + nodeId: node.id, + nodeLabel, + properties: definition.outputSchema.properties, + }), + ]; + + return { + type: SUGGESTION_NODE_TYPE.COMMON, + bySourceHandle, + }; + } + + if (definition?.outputSchema?.type === OUTPUT_SCHEMA_TYPE.VARIANT) { + // The old format accepted only one rule at the time, newer merges multiple rules + const variant = Object.values(definition.outputSchema.variants).find((variant) => { + if (!variant?.variantRule) { + return true; + } + + const { dataPropertyName, dataPropertyValue } = variant.variantRule; + + if (node.data.properties[dataPropertyName] === dataPropertyValue) { + return true; + } + + return false; + }); + + if (variant) { + bySourceHandle['every'] = [ + ...(bySourceHandle['every'] || []), + ...getDeprecatedSuggestionsFromOutputSchema({ + nodeId: node.id, + nodeLabel, + properties: variant.properties, + }), + ]; + + return { + type: SUGGESTION_NODE_TYPE.CUSTOM, + bySourceHandle, + }; + } + + return { + type: SUGGESTION_NODE_TYPE.CUSTOM, + bySourceHandle: {}, + }; + } + + return EMPTY_NODE_SUGGESTIONS; + } + + // Node that always returns the same variables + if (definition.schemaOutput.type === OUTPUT_SCHEMA_TYPE.DEFAULT) { + for (const [sourceHandle, properties] of Object.entries(definition.schemaOutput.bySourceHandle)) { + if (properties) { + bySourceHandle[sourceHandle] = [ + ...(bySourceHandle[sourceHandle] || []), + ...getSuggestionsFromSchemaOutput({ + nodeId: node.id, + nodeLabel, + properties, + }), + ]; + } + } + + return { + type: SUGGESTION_NODE_TYPE.COMMON, + bySourceHandle, + }; + } + + // From variants (they have rules based on data inside the node) + if (definition.schemaOutput.type === OUTPUT_SCHEMA_TYPE.VARIANT) { + const variantsMatchingDataPropertyValue = Object.values(definition.schemaOutput.variants) + .filter((variant) => { + if (variant.variantRule && 'onlyIfPropertyNameEquals' in variant.variantRule) { + const { path, value } = variant.variantRule.onlyIfPropertyNameEquals; + const isValid = getByPath(node.data.properties, path) === value; + + if (!isValid) { + return false; + } + } + + // No rule is always a match + return true; + }) + .filter(filterEmpty); + + for (const variant of variantsMatchingDataPropertyValue) { + // Default variables by sourceHandle + if ('bySourceHandle' in variant) { + for (const [sourceHandle, properties] of Object.entries(variant.bySourceHandle)) { + if (properties) { + bySourceHandle[sourceHandle] = [ + ...(bySourceHandle[sourceHandle] || []), + ...getSuggestionsFromSchemaOutput({ + nodeId: node.id, + nodeLabel, + properties, + }), + ]; + } + } + } + + if ( + variant.variantRule && + 'fromValueOfPropertyPath' in variant.variantRule && + variant.variantRule.fromValueOfPropertyPath + ) { + const variablesIndex = getByPath( + node.data.properties, + variant.variantRule.fromValueOfPropertyPath, + ) as unknown as VariablesIndex | undefined; + + const sourceHandlesToAdd = variant.variantRule.toSourceHandles; + + // TODO: Add better guard + // It's an output of schema-builder control + if (variablesIndex) { + const suggestions = getSuggestionsFromVariableIndex({ + variablesIndex, + nodeId: node.id, + nodeLabel, + variant: 'nodes', + }); + + for (const sourceHandle of sourceHandlesToAdd) { + bySourceHandle[sourceHandle] = [...(bySourceHandle[sourceHandle] || []), ...suggestions]; + } + } + } + } + + return { + type: SUGGESTION_NODE_TYPE.CUSTOM, + bySourceHandle, + }; + } + + 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..e555041b6 --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/refresh-suggestions.ts @@ -0,0 +1,55 @@ +import type { WorkflowBuilderNode } from '../../../../node/node-data'; +import { useStore } from '../../../../store/store'; +import { getNodeDefinition } from '../../../../utils/validation/get-node-definition'; +import { getNodeLabelForVariable } from '../../utils/diagram/get-node-label-for-variable'; +import { + type VariablesSuggestionsStore, + emptyVariablesSuggestionsStore, + 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 nodeLabel = getNodeLabelForVariable({ node, definition }); + const { type, bySourceHandle } = getSuggestionsNodeData({ node, definition }); + + currentStore = setVariablesSuggestionsNodeData({ + type, + nodeId: node.id, + nodeType: node.data.type, + nodeLabel, + bySourceHandle, + cachedStore: currentStore, + shouldOnlyPassedStore: true, + }); + } + } + + useVariablesSuggestionsStore.setState(currentStore); +} + +export function refreshAllSuggestions() { + const currentStore = useVariablesSuggestionsStore.getState(); + const { nodes } = useStore.getState(); + + refreshNodesSuggestions(nodes, { + ...emptyVariablesSuggestionsStore, + lastUpdateIndex: currentStore.lastUpdateIndex + 1, + }); +} + +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..54da60c2d --- /dev/null +++ b/packages/sdk/src/features/variables/stores/core/set-suggestions-node-data/set-suggestions-node-data.ts @@ -0,0 +1,81 @@ +import { NODE_ID_FOR_COMMON_NODE_DATA, NODE_LABEL_FOR_COMMON_NODE_DATA, VARIABLE_DELIMITER } from '../../../constants'; +import type { SuggestionNodeType, SuggestionsBySourceHandle } from '../../types'; +import { SUGGESTION_NODE_TYPE } from '../../types'; +import type { VariablesSuggestionsStore } from '../../use-variable-suggestions-store'; +import { useVariablesSuggestionsStore } from '../../use-variable-suggestions-store'; + +type ParamsShared = { + type: SuggestionNodeType; + nodeId: string; + nodeType: string; + nodeLabel: 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, + lastUpdateIndex: storeToMutate.lastUpdateIndex + 1, + commonByType: { + ...storeToMutate.commonByType, + [params.nodeType]: Object.fromEntries( + Object.entries(params.bySourceHandle).map(([sourceHandle, suggestions]) => [ + sourceHandle, + suggestions?.map((suggestion) => ({ + ...suggestion, + display: suggestion.display + .split(VARIABLE_DELIMITER) + .map((chunk, index) => (index === 0 ? NODE_LABEL_FOR_COMMON_NODE_DATA : chunk)) + .join(VARIABLE_DELIMITER), + id: suggestion.id.replace(params.nodeId, NODE_ID_FOR_COMMON_NODE_DATA), + })), + ]), + ) as SuggestionsBySourceHandle, + }, + byNodeId: { + ...storeToMutate.byNodeId, + [params.nodeId]: { + type: SUGGESTION_NODE_TYPE.COMMON, + nodeType: params.nodeType, + nodeLabel: params.nodeLabel, + }, + }, + }; + } else if (params.type === SUGGESTION_NODE_TYPE.CUSTOM) { + storeToMutate = { + ...storeToMutate, + lastUpdateIndex: storeToMutate.lastUpdateIndex + 1, + 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..3484c802c --- /dev/null +++ b/packages/sdk/src/features/variables/stores/types.ts @@ -0,0 +1,28 @@ +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; + nodeLabel: 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..933f05141 --- /dev/null +++ b/packages/sdk/src/features/variables/stores/use-variable-suggestions-store.ts @@ -0,0 +1,30 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +import type { SuggestionsBySourceHandle, SuggestionsNodeData } from './types'; + +export type VariablesSuggestionsStore = { + lastUpdateIndex: number; + commonByType: { + [nodeType: string]: SuggestionsBySourceHandle | undefined; + }; + byNodeId: { + [nodeId: string]: SuggestionsNodeData | undefined; + }; +}; + +export const emptyVariablesSuggestionsStore: VariablesSuggestionsStore = { + lastUpdateIndex: 1, + 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..db244c3f7 --- /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?.schemaOutput && !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 70% rename from packages/sdk/src/features/variables/actions/definitions.ts rename to packages/sdk/src/features/variables/utils/form-validation/definitions.ts index 415a02cfb..4ed4fd292 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({ 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/json-schema/get-flattened-properties-from-json-schema-7.ts b/packages/sdk/src/features/variables/utils/json-schema/get-flattened-properties-from-json-schema-7.ts new file mode 100644 index 000000000..8bc585470 --- /dev/null +++ b/packages/sdk/src/features/variables/utils/json-schema/get-flattened-properties-from-json-schema-7.ts @@ -0,0 +1,66 @@ +import type { JsonSchema7 } from '@jsonforms/core'; + +import type { FlattenedPropertiesIndex } from '../../../../node/node-output-schema'; +import { getIsSupportedVariableType } from './get-is-supported-variable-type'; + +type Params = { + properties: JsonSchema7['properties']; + namePrefix: string; + rootSchema: JsonSchema7; +}; + +function getFlattenedPropertiesFromJsonSchema7Properties({ + properties, + namePrefix, + rootSchema, +}: Params): FlattenedPropertiesIndex { + if (!properties) { + return {}; + } + + const fields = Object.entries(properties).reduce((stack: FlattenedPropertiesIndex, [fieldName, field]) => { + const propertyName = [namePrefix, fieldName].filter(Boolean).join('.'); + + if (getIsSupportedVariableType(field.type)) { + const isDate = field.type === 'string' && field.format === 'date-time'; + stack[propertyName] = { + type: isDate ? 'datetime' : field.type, + label: field.title || '', + description: field.description || '', + }; + } + + if (field.type === 'object') { + const objectFields = getFlattenedPropertiesFromJsonSchema7Properties({ + properties: field.properties, + namePrefix: propertyName, + rootSchema, + }); + + stack = { + ...stack, + ...objectFields, + }; + } + + return stack; + }, {}); + + return fields; +} + +export function getFlattenedPropertiesFromJsonSchema7(schema: JsonSchema7): FlattenedPropertiesIndex { + if (schema.type === 'object') { + const fields = getFlattenedPropertiesFromJsonSchema7Properties({ + properties: schema.properties, + namePrefix: '', + rootSchema: schema, + }); + + return { + ...fields, + }; + } + + return {}; +} diff --git a/packages/sdk/src/features/variables/utils/json-schema/get-is-supported-variable-type.spec.ts b/packages/sdk/src/features/variables/utils/json-schema/get-is-supported-variable-type.spec.ts new file mode 100644 index 000000000..ff8e26996 --- /dev/null +++ b/packages/sdk/src/features/variables/utils/json-schema/get-is-supported-variable-type.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import type { VariableType } from '../../../../node/node-output-schema'; +import { getIsSupportedVariableType } from './get-is-supported-variable-type'; + +const supportedTypes: VariableType[] = ['string', 'number', 'boolean', 'datetime', 'date', 'object', 'array']; + +describe('getIsSupportedVariableType', () => { + it.each(supportedTypes)('should accept %s', (type) => { + expect(getIsSupportedVariableType(type)).toBe(true); + }); + + it('should reject json schema types outside VariableType', () => { + expect(getIsSupportedVariableType('integer')).toBe(false); + expect(getIsSupportedVariableType('null')).toBe(false); + }); + + it('should reject unknown strings', () => { + expect(getIsSupportedVariableType('')).toBe(false); + expect(getIsSupportedVariableType('String')).toBe(false); + expect(getIsSupportedVariableType('str')).toBe(false); + }); + + it('should reject Object.prototype keys', () => { + expect(getIsSupportedVariableType('toString')).toBe(false); + expect(getIsSupportedVariableType('constructor')).toBe(false); + expect(getIsSupportedVariableType('hasOwnProperty')).toBe(false); + }); + + it('should reject non-string values', () => { + // eslint-disable-next-line unicorn/no-useless-undefined + expect(getIsSupportedVariableType(undefined)).toBe(false); + expect(getIsSupportedVariableType(null)).toBe(false); + expect(getIsSupportedVariableType(['string'])).toBe(false); + expect(getIsSupportedVariableType(1)).toBe(false); + expect(getIsSupportedVariableType({ type: 'string' })).toBe(false); + }); + + it('should narrow the type', () => { + const type: unknown = 'string'; + + if (getIsSupportedVariableType(type)) { + const narrowed: VariableType = type; + + expect(narrowed).toBe('string'); + } else { + expect.unreachable(); + } + }); +}); diff --git a/packages/sdk/src/features/variables/utils/json-schema/get-is-supported-variable-type.ts b/packages/sdk/src/features/variables/utils/json-schema/get-is-supported-variable-type.ts new file mode 100644 index 000000000..9c63fc6c2 --- /dev/null +++ b/packages/sdk/src/features/variables/utils/json-schema/get-is-supported-variable-type.ts @@ -0,0 +1,18 @@ +import type { VariableType } from '../../../../node/node-output-schema'; + +/* + Keys must cover VariableType - `satisfies` breaks the build when a new type is added. +*/ +const SUPPORTED_VARIABLE_TYPES = { + string: true, + number: true, + boolean: true, + datetime: true, + date: true, + object: true, + array: true, +} as const satisfies Record; + +export function getIsSupportedVariableType(type: unknown): type is VariableType { + return typeof type === 'string' && Object.hasOwn(SUPPORTED_VARIABLE_TYPES, type); +} diff --git a/packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.spec.ts b/packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.spec.ts new file mode 100644 index 000000000..6588bbedb --- /dev/null +++ b/packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { getIsStringVariableReference } from './get-is-string-variable-reference'; + +describe('getIsStringVariableReference', () => { + it.each(['{{nodes.abc.output}}', '{{global.total}}', ' {{nodes.abc.output}} ', '{{a}}'])( + 'accepts a single reference: %j', + (value) => { + expect(getIsStringVariableReference(value)).toBe(true); + }, + ); + + it.each([ + undefined, + '', + ' ', + 'plain text', + '{{a}} {{b}}', + '{{a}}{{b}}', + '{{a}} text', + 'text {{a}}', + '{{a}}x', + '{{a b}}', + '{{ Missing node (abcd...) · output }}', + '{{ Label · output }}', + '{{a', + 'a}}', + '{a}', + ])('rejects non-single references: %j', (value) => { + expect(getIsStringVariableReference(value)).toBe(false); + }); +}); diff --git a/packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.ts b/packages/sdk/src/features/variables/utils/keys/get-is-string-variable-reference.ts new file mode 100644 index 000000000..7fbda4021 --- /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 false; + } + + const isOnlyOneVariable = + `${VARIABLE_BRACKETS_START}${valueTrimmed.replaceAll(VARIABLE_BRACKETS_START, '').replaceAll(VARIABLE_BRACKETS_END, '')}${VARIABLE_BRACKETS_END}` === + valueTrimmed; + if (isOnlyOneVariable) { + return true; + } + + return false; +} + +export function getIsStringVariableReferenceStart(value: MaybeVariableReference): boolean { + const valueTrimmed = typeof value === 'string' ? value?.trim() : ''; + if (!valueTrimmed) { + return false; + } + + if (valueTrimmed.startsWith(VARIABLE_BRACKETS_START.slice(0, 1)) && valueTrimmed.length === 1) { + return true; + } + + if (valueTrimmed.startsWith(VARIABLE_BRACKETS_START.slice(0, 2))) { + return true; + } + + return false; +} diff --git a/packages/sdk/src/features/variables/utils/keys/get-variable-reference-if-possible.ts b/packages/sdk/src/features/variables/utils/keys/get-variable-reference-if-possible.ts new file mode 100644 index 000000000..954d14aca --- /dev/null +++ b/packages/sdk/src/features/variables/utils/keys/get-variable-reference-if-possible.ts @@ -0,0 +1,10 @@ +import type { MaybeVariableReference, VariableReference } from '../../types'; +import { getIsStringVariableReference } from './get-is-string-variable-reference'; + +export function getVariableReferenceIfPossible(value: MaybeVariableReference): VariableReference | undefined { + const isValid = getIsStringVariableReference(value?.trim()); + + if (value && isValid) { + return value.trim() as VariableReference; + } +} diff --git a/packages/sdk/src/features/variables/utils/keys/get-variable-reference-without-brackets-for-global.ts b/packages/sdk/src/features/variables/utils/keys/get-variable-reference-without-brackets-for-global.ts new file mode 100644 index 000000000..e2f1c5db5 --- /dev/null +++ b/packages/sdk/src/features/variables/utils/keys/get-variable-reference-without-brackets-for-global.ts @@ -0,0 +1,6 @@ +import { VARIABLE_GLOBAL_KEY } from '../../constants'; +import type { MaybeVariableReference } from '../../types'; + +export function getVariableReferenceWithoutBracketsForGlobal(variableId: string): NonNullable { + return `${VARIABLE_GLOBAL_KEY}.${variableId}`; +} diff --git a/packages/sdk/src/features/variables/utils/keys/get-variable-reference-without-brackets-for-node.ts b/packages/sdk/src/features/variables/utils/keys/get-variable-reference-without-brackets-for-node.ts new file mode 100644 index 000000000..339e82957 --- /dev/null +++ b/packages/sdk/src/features/variables/utils/keys/get-variable-reference-without-brackets-for-node.ts @@ -0,0 +1,14 @@ +import { VARIABLE_NODES_KEY } from '../../constants'; +import type { MaybeVariableReference } from '../../types'; + +type Params = { + nodeId: string; + propertyName: string; +}; + +export function getVariableReferenceWithoutBracketsForNode({ + nodeId, + propertyName, +}: Params): NonNullable { + return `${VARIABLE_NODES_KEY}.${nodeId}.${propertyName}`; +} diff --git a/packages/sdk/src/features/variables/utils/keys/get-variable-references.ts b/packages/sdk/src/features/variables/utils/keys/get-variable-references.ts new file mode 100644 index 000000000..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..8f237acbe --- /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: 28.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 and 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 f3ea54d7c..815f6ab95 100644 --- a/packages/sdk/src/hooks/use-palette-drop.ts +++ b/packages/sdk/src/hooks/use-palette-drop.ts @@ -49,7 +49,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/index.ts b/packages/sdk/src/index.ts index e8a970338..2c241b7e3 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -112,6 +112,7 @@ export type { IconType, LayoutDirection, PaletteItem, + PaletteGroup, PaletteItemOrGroup, TemplateModel, } from './node/common'; @@ -202,6 +203,7 @@ export { } from './features/diagram/listeners/node-drag-start-listeners'; export { getHandleId } from './features/diagram/handles/get-handle-id'; +export { getNodeAncestors } from './features/variables/utils/diagram/get-node-ancestors'; // ============================================================================= // JsonForms helpers (plugin schema authoring) @@ -228,7 +230,6 @@ export { EDGE_OFFSET, SELF_CONNECTING_EDGE_LABEL_OFFSET, } from './features/diagram/edges/edge.consts'; -export { VARIABLE_NODES_KEY } from './features/variables/constants'; // ============================================================================= // i18n @@ -260,3 +261,44 @@ export { Icon } from '@workflow-builder/icons'; * @category Icons */ export type { WBIcon } from '@workflow-builder/icons'; + +// ============================================================================= +// Elements +// ============================================================================= +// Individual UI schema element types, for consumers building parts of a +// `UISchema` piecemeal (e.g. a helper returning a single layout element). + +export type { UISchemaElement } from './types/uischema'; + +/** + * Displays a snackbar notification to the user. + * + * @param message - The message to display in the snackbar. + */ +export { showSnackbar } from './utils/show-snackbar'; + +// ============================================================================= +// Variables +// ============================================================================= + +// Prefix of a node-output reference (`{{nodes..}}`). +export { VARIABLE_NODES_KEY } from './features/variables/constants'; + +// The output contract of a node — the shape downstream nodes can reference as +// variables. Attach it via `NodeData.schemaOutput`. Two forms: +// `{ type: 'default', bySourceHandle }` maps each source handle to a JSON +// Schema (`every` covers all handles at once, e.g. `success` / `error` split); +// `{ type: 'variant', variants }` picks the shape at runtime from a property +// value, for nodes whose outputs depend on how they are configured. +export type { NodeSchemaOutput } from './node/node-output-schema'; + +// Every variable a node can reference — global variables plus the outputs of +// its ancestors — grouped for a picker UI. Optionally narrowed by variable type +// (`includeTypes` / `excludeTypes`). +export { useNodeVariables } from './features/variables/hooks/use-node-variables'; + +// Imperative, non-React counterpart: the variables a single node exposes on one +// of its source handles (branch-aware — an error handle gets the error-branch +// outputs, others the success ones). Returns `undefined` when the node has not +// been indexed yet. +export { getNodeVariablesSuggestions } from './features/variables/stores/core/get-node-variables-suggestions'; diff --git a/packages/sdk/src/node/deprecated/README.md b/packages/sdk/src/node/deprecated/README.md new file mode 100644 index 000000000..64d77b004 --- /dev/null +++ b/packages/sdk/src/node/deprecated/README.md @@ -0,0 +1,105 @@ +# Migrating `outputSchema` → `schemaOutput` + +`PaletteItem.outputSchema` (`DeprecatedNodeOutputSchema`) is deprecated and will be removed in **3.0**. Replace it with `PaletteItem.schemaOutput` (`NodeSchemaOutput`). + +Why: the new format is a JSON Schema (same dialect as `NodeSchema`), supports nested objects, and scopes variables **per source handle** — the `error` port no longer advertises `success` variables. + +Both fields may coexist during migration; `schemaOutput` wins when present, `outputSchema` is only read when `schemaOutput` is missing. + +## Strategy + +1. Add `schemaOutput` next to the existing `outputSchema` (see mapping below). +2. Verify the variable picker on a downstream node shows the same variables. +3. Delete `outputSchema`. +4. Repeat per node. Ship in any order — no big-bang needed. + +## Mapping + +| Old (`outputSchema`) | New (`schemaOutput`) | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `properties: { key: { type, label, description } }` | `bySourceHandle.: { type: 'object', properties: { key: { type, title, description } } }` | +| flat, dot-notation keys (`'result.status'`) | nested `properties` (`result: { type: 'object', properties: { status } }`) | +| `label` | `title` | +| `type: 'datetime'` | `type: 'string', format: 'date-time'` | +| `type: 'date'` | `type: 'string'` (no dedicated mapping yet) | +| same variables on every handle | `bySourceHandle.every` | +| `variants: { name: { variantRule, properties } }` (record) | `variants: [{ variantRule, bySourceHandle }]` (array) | +| `variantRule: { dataPropertyName, dataPropertyValue }` | `variantRule: { onlyIfPropertyNameEquals: { path, value } }` (`path` supports dot notation) | +| first matching variant only | **all** matching variants are merged | +| — | `variantRule: { fromValueOfPropertyPath, toSourceHandles }` for user-defined output shapes | + +## Example: default + +```ts +// before +outputSchema: { + type: 'default', + properties: { + status: { type: 'string', label: 'Status' }, + errorMessage: { type: 'string', label: 'Error Message' }, + }, +} + +// after — success/error split +schemaOutput: { + type: 'default', + bySourceHandle: { + success: { + type: 'object', + properties: { status: { type: 'string', title: 'Status' } }, + }, + error: { + type: 'object', + properties: { errorMessage: { type: 'string', title: 'Error Message' } }, + }, + }, +} +``` + +Use `bySourceHandle.every` instead of named handles when the node has a single output or all handles share the shape. + +## Example: variant + +```ts +// before +outputSchema: { + type: 'variant', + variants: { + text: { + variantRule: { dataPropertyName: 'mode', dataPropertyValue: 'text' }, + properties: { text: { type: 'string', label: 'Text' } }, + }, + json: { + variantRule: { dataPropertyName: 'mode', dataPropertyValue: 'json' }, + properties: { data: { type: 'object', label: 'Data' } }, + }, + }, +} + +// after +schemaOutput: { + type: 'variant', + variants: [ + { + variantRule: { onlyIfPropertyNameEquals: { path: 'mode', value: 'text' } }, + bySourceHandle: { + every: { type: 'object', properties: { text: { type: 'string', title: 'Text' } } }, + }, + }, + { + variantRule: { onlyIfPropertyNameEquals: { path: 'mode', value: 'json' } }, + bySourceHandle: { + every: { type: 'object', properties: { data: { type: 'object', title: 'Data' } } }, + }, + }, + ], +} +``` + +A variant with `variantRule: undefined` always matches — use it for variables shared across all modes (old format allowed only one variant to apply; new merges them). + +## Gotchas + +- Variable references are unchanged (`{{nodeId.result.status}}`), so existing diagrams keep working. +- If a node previously exposed error fields on every handle, moving them to `bySourceHandle.error` is a behaviour change for downstream nodes on the success path — intended, but check templates. +- Reference: `apps/demo/src/app/data/nodes/*/schema-output.ts`; `action/action.ts` still carries the old format as a side-by-side example. diff --git a/packages/sdk/src/node/deprecated/deprecated-node-output-schema.ts b/packages/sdk/src/node/deprecated/deprecated-node-output-schema.ts new file mode 100644 index 000000000..e0a2e53b4 --- /dev/null +++ b/packages/sdk/src/node/deprecated/deprecated-node-output-schema.ts @@ -0,0 +1,28 @@ +import type { FlattenedPropertiesIndex } from '../node-output-schema'; + +export type DeprecatedOutputVariant = { + variantRule: + | undefined + | { + dataPropertyName: string; + dataPropertyValue: string; + }; + properties: FlattenedPropertiesIndex; +}; + +export type DeprecatedNodeOutputSchemaDefault = { + type: 'default'; + properties: FlattenedPropertiesIndex; +}; + +export type DeprecatedNodeOutputSchemaVariant = { + /* + Variants may be set dynamically by the node configuration. + */ + type: 'variant'; + variants: { + [variantName: string]: DeprecatedOutputVariant | undefined; + }; +}; + +export type DeprecatedNodeOutputSchema = DeprecatedNodeOutputSchemaDefault | DeprecatedNodeOutputSchemaVariant; diff --git a/packages/sdk/src/node/node-data.ts b/packages/sdk/src/node/node-data.ts index 4470d45ad..29c213570 100644 --- a/packages/sdk/src/node/node-data.ts +++ b/packages/sdk/src/node/node-data.ts @@ -3,7 +3,8 @@ import type { Edge, Node } from '@xyflow/react'; import type { NodeDataProperties } from '../types/default-properties'; import type { UISchema } from '../types/uischema'; import type { IconType } from './common'; -import type { NodeOutputSchema } from './node-output-schema'; +import type { DeprecatedNodeOutputSchema } from './deprecated/deprecated-node-output-schema'; +import type { NodeSchemaOutput } from './node-output-schema'; import type { BaseNodeProperties, NodeSchema } from './node-schema'; import type { NodeType } from './node-types'; @@ -30,7 +31,15 @@ export type NodeDefinition = { /** describes how the form looks like and to which fields data properties should be mapped */ uischema?: UISchema; /** describes the output properties this node produces, used by the variable picker */ - outputSchema?: NodeOutputSchema; + schemaOutput?: NodeSchemaOutput; + /** + * @deprecated outputSchema is deprecated. Switch to schemaOutput instead. + * The newer version uses a schema similar to the Node schema, but also supports handling responses + * by source handle (the error port does not receive successful variables). + * + * outputSchema will be removed in the next major release (3.0). + */ + outputSchema?: DeprecatedNodeOutputSchema; } & Required> & Pick; diff --git a/packages/sdk/src/node/node-output-schema.ts b/packages/sdk/src/node/node-output-schema.ts index dcfeda6d9..75bdb2988 100644 --- a/packages/sdk/src/node/node-output-schema.ts +++ b/packages/sdk/src/node/node-output-schema.ts @@ -1,3 +1,5 @@ +import type { JsonSchema7 } from '@jsonforms/core'; + export type VariableTypePrimitive = 'string' | 'number' | 'boolean' | 'datetime' | 'date'; export type VariableType = VariableTypePrimitive | 'object' | 'array'; @@ -12,7 +14,7 @@ export function getVariableTypeIfPrimitive(type: VariableType): VariableTypePrim export type OutputProperty = { type: VariableType; - label: string; + label?: string; description?: string; }; @@ -21,31 +23,47 @@ export const OUTPUT_SCHEMA_TYPE = { VARIANT: 'variant', } as const; -export type OutputPropertiesIndex = Record; +export type FlattenedPropertiesIndex = Record; -export type OutputVariant = { - variantRule: - | undefined - | { - dataPropertyName: string; - dataPropertyValue: string; - }; - properties: OutputPropertiesIndex; +export type PropertiesBySourceHandle = { + [sourceHandle: string]: JsonSchema7 | undefined; + every?: JsonSchema7; }; -export type NodeOutputSchemaDefault = { +export type OutputVariant = + | { + variantRule: + | undefined + | { + onlyIfPropertyNameEquals: { path: string; value: string | number }; + }; + bySourceHandle: PropertiesBySourceHandle; + } + | { + variantRule: { + onlyIfPropertyNameEquals: { path: string; value: string | number }; + fromValueOfPropertyPath: string; + toSourceHandles: string[]; + }; + } + | { + variantRule: { + fromValueOfPropertyPath: string; + toSourceHandles: string[]; + }; + }; + +export type NodeSchemaOutputDefault = { type: 'default'; - properties: OutputPropertiesIndex; + bySourceHandle: PropertiesBySourceHandle; }; -export type NodeOutputSchemaVariant = { +export type NodeSchemaOutputVariant = { /* - 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 NodeOutputSchema = NodeOutputSchemaDefault | NodeOutputSchemaVariant; +export type NodeSchemaOutput = NodeSchemaOutputDefault | NodeSchemaOutputVariant; diff --git a/packages/sdk/src/store/slices/diagram-slice.ts b/packages/sdk/src/store/slices/diagram-slice.ts index aa53eb34c..c6b99e4ff 100644 --- a/packages/sdk/src/store/slices/diagram-slice.ts +++ b/packages/sdk/src/store/slices/diagram-slice.ts @@ -6,6 +6,7 @@ import { migrateLegacyHandleIdsOnEdges, migrateLegacyHandleIdsOnNodes, } from '../../features/diagram/handles/migrate-legacy-handle-id'; +import { refreshAllSuggestions } from '../../features/variables/stores/core/refresh-suggestions'; import type { VariablesIndex } from '../../features/variables/types'; import { type ConnectionBeingDragged, @@ -89,6 +90,8 @@ export function useDiagramSlice(set: SetDiagramState, get: GetDiagramState) { layoutDirection, documentName, }); + + refreshAllSuggestions(); }, setDocumentName: (name: string) => { set({ 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..f7592d965 100644 --- a/packages/sdk/src/types/controls.ts +++ b/packages/sdk/src/types/controls.ts @@ -1,13 +1,15 @@ 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'; import type { UISchemaControlElement } from './uischema'; import type { Override } from './utils'; -type ControlProps = Override< +export type WBControlProps = Override< BaseControlProps, { data: D; @@ -23,7 +25,7 @@ export type TextControlElement = Override< inputType?: string; } & Pick >; -export type TextControlProps = ControlProps; +export type TextControlProps = WBControlProps; export type SwitchControlElement = Override< BaseControlElement, @@ -31,7 +33,7 @@ export type SwitchControlElement = Override< type: 'Switch'; } >; -export type SwitchControlProps = ControlProps; +export type SwitchControlProps = WBControlProps; export type TextAreaControlElement = Override< BaseControlElement, @@ -39,7 +41,7 @@ export type TextAreaControlElement = Override< type: 'TextArea'; } & Pick >; -export type TextAreaControlProps = ControlProps; +export type TextAreaControlProps = WBControlProps; /** * One row in a dynamic-conditions control — two operands (`x`, `y`), a @@ -72,7 +74,7 @@ export type DynamicConditionsControlElement = Override< } >; -export type DynamicConditionsControlProps = ControlProps; +export type DynamicConditionsControlProps = WBControlProps; export type DecisionBranchesControlElement = Override< BaseControlElement, @@ -81,7 +83,7 @@ export type DecisionBranchesControlElement = Override< } >; -export type DecisionBranchesControlProps = ControlProps; +export type DecisionBranchesControlProps = WBControlProps; export type SelectControlElement = Override< BaseControlElement, @@ -89,7 +91,7 @@ export type SelectControlElement = Override< type: 'Select'; } >; -export type SelectControlProps = ControlProps; +export type SelectControlProps = WBControlProps; export type DatePickerControlElement = Override< BaseControlElement, @@ -97,7 +99,7 @@ export type DatePickerControlElement = Override< type: 'DatePicker'; } >; -export type DatePickerControlProps = ControlProps; +export type DatePickerControlProps = WBControlProps; export type BaseControlProps = Override< JsonFormsControlProps, @@ -117,17 +119,19 @@ export type VariableTextControlElement = Override< BaseControlElement, { type: 'VariableText'; + variablesTypes?: VariableType[]; } & Pick >; -export type VariableTextControlProps = ControlProps; +export type VariableTextControlProps = WBControlProps; export type VariableTextAreaControlElement = Override< BaseControlElement, { type: 'VariableTextArea'; + variablesTypes?: VariableType[]; } & Pick >; -export type VariableTextAreaControlProps = ControlProps; +export type VariableTextAreaControlProps = WBControlProps; export type MessageOnErrorControlElement = Override< BaseControlElement, @@ -138,9 +142,9 @@ export type MessageOnErrorControlElement = Override< variant?: 'info' | 'warning' | 'error'; } >; -export type MessageOnErrorProps = ControlProps; +export type MessageOnErrorProps = WBControlProps; -type BaseControlElement = Override; +export type BaseControlElement = Override; // Re-exported for use in NodeDataProperties-based types diff --git a/packages/sdk/src/types/uischema.ts b/packages/sdk/src/types/uischema.ts index 98e313a09..376373cac 100644 --- a/packages/sdk/src/types/uischema.ts +++ b/packages/sdk/src/types/uischema.ts @@ -1,3 +1,4 @@ +import type { VariableDynamicControlElement } from '../features/json-form/controls/variable-dynamic-control/type'; import { type AiToolsControlElement, type DatePickerControlElement, @@ -28,6 +29,7 @@ export type UISchemaControlElement = ( | DynamicConditionsControlElement | AiToolsControlElement | DecisionBranchesControlElement + | VariableDynamicControlElement | VariableTextControlElement | VariableTextAreaControlElement | MessageOnErrorControlElement 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/general-information.ts b/packages/sdk/src/utils/general-information.ts index 8dfbabfc2..b7ad2bb99 100644 --- a/packages/sdk/src/utils/general-information.ts +++ b/packages/sdk/src/utils/general-information.ts @@ -15,7 +15,7 @@ export const statusOptions = { /** * UISchema fragments rendered on every node's properties tab regardless - * of the node type. Today contains the missing-previous-variable error + * of the node type. Today contains the custom-error error * message; compose it into a node's UISchema with spread/merge. * * @category Utilities @@ -27,7 +27,7 @@ export const globalControls: UISchemaElement[] = [ data.properties.customErrors = [ { - instancePath: '/missingPreviousVariable', + instancePath: '/customError', message: i18n.t('your.custom.message'), schemaPath: '', keyword: '', @@ -37,8 +37,7 @@ export const globalControls: UISchemaElement[] = [ */ { type: 'MessageOnError', - scope: '#/properties/missingPreviousVariable', - text: 'plugins.validation.missingDependency', + scope: '#/properties/customError', }, ]; 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..d22088cff 100644 --- a/packages/sdk/src/utils/text.ts +++ b/packages/sdk/src/utils/text.ts @@ -1,4 +1,68 @@ -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 snakeCaseToPascalCase = (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 = snakeCaseToPascalCase(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 pathToLabel = (path: string): string => { + if (!path) { + return ''; + } + + if (path.includes('.')) { + const lastKey = path.split('.').at(-1); + if (lastKey) { + return keyToLabel(lastKey); + } + } + + return keyToLabel(path); +}; + +export const labelToSnakeCase = (label: string) => { + // Normalization guessing the best strategy + const labelToUse = label.replaceAll(' ', '_'); + const pascalCaseLabel = snakeCaseToPascalCase(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..31484e3a0 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 is a 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; } diff --git a/packages/types/src/node-output-schema.ts b/packages/types/src/node-output-schema.ts index 11827237d..0b68c82a6 100644 --- a/packages/types/src/node-output-schema.ts +++ b/packages/types/src/node-output-schema.ts @@ -16,6 +16,6 @@ export type OutputProperty = { description?: string; }; -export type NodeOutputSchema = { +export type NodeSchemaOutput = { properties: Record; };