diff --git a/packages/nocodb-sdk/src/lib/workflow/interface.ts b/packages/nocodb-sdk/src/lib/workflow/interface.ts new file mode 100644 index 0000000000..e12aba1620 --- /dev/null +++ b/packages/nocodb-sdk/src/lib/workflow/interface.ts @@ -0,0 +1,139 @@ +enum VariableType { + String = 'string', + Number = 'number', + Integer = 'integer', + Boolean = 'boolean', + Object = 'object', + Array = 'array', + Date = 'date', + DateTime = 'datetime', +} + +enum VariableGroupKey { + Meta = 'meta', // System fields (id, createdAt, etc.) + Fields = 'fields', // User data fields +} + +interface VariableDefinition { + // Expression reference (e.g., "fields.Title" for use in {{ $("Node").fields.Title }}) + key: string; + + // Human-readable name for UI (e.g., "Title") + name: string; + + // Variable type + type: VariableType; + + // UI grouping + groupKey?: VariableGroupKey; + + // Is this variable an array? + isArray?: boolean; + + // Additional metadata for UI + extra?: { + // Entity ID for dependency tracking + entity_id?: string; + + // Entity type for dependency tracking ('column' | 'table' | 'view') + entity?: 'column' | 'table' | 'view'; + + // icon for the variable + icon?: string; + + // UIType for fields + uiType?: string; + + // Table/View names for display + tableName?: string; + viewName?: string; + + // Description for tooltips + description?: string; + + // Value for dynamically generated variables (used in data display) + value?: any; + + // Item schema for array variables - defines the structure of each array item + // Used to generate "Record 1", "Record 2", etc. when displaying actual data + itemSchema?: VariableDefinition[]; + }; + + // Nested variables for objects/arrays + children?: VariableDefinition[]; +} + +interface NodeExecutionResult { + nodeId: string; + nodeTitle: string; + status: 'pending' | 'running' | 'success' | 'error' | 'skipped'; + output?: any; + input?: any; + error?: string; + startTime: number; + endTime?: number; + nextNode?: string; // Next node to execute (for conditional branching) + logs?: Array<{ + level: 'info' | 'warn' | 'error'; + message: string; + ts?: number; + data?: any; + }>; + metrics?: Record; + isStale?: boolean; + inputVariables?: VariableDefinition[]; + outputVariables?: VariableDefinition[]; +} + +interface WorkflowExecutionState { + id: string; + workflowId: string; + status: 'running' | 'completed' | 'error' | 'cancelled' | 'skipped'; + startTime: number; + endTime?: number; + nodeResults: NodeExecutionResult[]; + currentNodeId?: string; + triggerData?: any; + triggerNodeTitle?: string; // Optional: which trigger node to start from +} + +interface NodeConfig { + [key: string]: any; +} + +interface WorkflowGeneralNode { + id: string; + type: string; + position: { + x: number; + y: number; + }; + data: { + config: NodeConfig; + title: string; + description?: string; + testResult?: NodeExecutionResult; + inputVariables?: VariableDefinition[]; + outputVariables?: VariableDefinition[]; + }; + targetPosition: 'top' | 'bottom' | 'left' | 'right'; + sourcePosition: 'top' | 'bottom' | 'left' | 'right'; +} + +interface WorkflowGeneralEdge { + id: string; + source: string; // Source node ID + target: string; // Target node ID + animated: boolean; + label?: string; // Optional label like "True" or "False" +} + +export { + VariableType, + VariableGroupKey, + VariableDefinition, + WorkflowGeneralEdge, + WorkflowGeneralNode, + NodeExecutionResult, + WorkflowExecutionState, +}; diff --git a/packages/nocodb-sdk/src/lib/workflow/variable-generator.ts b/packages/nocodb-sdk/src/lib/workflow/variable-generator.ts new file mode 100644 index 0000000000..e89dfb55c5 --- /dev/null +++ b/packages/nocodb-sdk/src/lib/workflow/variable-generator.ts @@ -0,0 +1,918 @@ +import UITypes from '../UITypes'; +import { isSystemColumn, parseProp } from '../helperFunctions'; +import { + VariableDefinition, + VariableGroupKey, + VariableType, +} from './interface'; +import { RelationTypes } from '../globals'; +import { ColumnType } from '~/lib'; +import { LinkToAnotherRecordType } from '~/lib/Api'; + +/** + * Map UIType to icon name (matching NocoDB's iconMap) + */ +export function uiTypeToIcon(column: ColumnType): string { + switch (column.uidt) { + case UITypes.ID: + case UITypes.ForeignKey: + return 'cellSystemKey'; + case UITypes.SingleLineText: + return 'cellText'; + case UITypes.LongText: + return 'cellLongText'; + case UITypes.Email: + return 'cellEmail'; + case UITypes.URL: + return 'cellUrl'; + case UITypes.PhoneNumber: + return 'cellPhone'; + case UITypes.Number: + return 'cellNumber'; + case UITypes.Decimal: + return 'cellDecimal'; + case UITypes.Currency: + return 'cellCurrency'; + case UITypes.Percent: + return 'cellPercent'; + case UITypes.Duration: + return 'cellDuration'; + case UITypes.Rating: + return 'cellRating'; + case UITypes.Checkbox: + return 'cellCheckbox'; + case UITypes.Date: + return 'cellDate'; + case UITypes.DateTime: + return 'cellDatetime'; + case UITypes.Time: + return 'cellTime'; + case UITypes.Year: + return 'cellYear'; + case UITypes.Attachment: + return 'cellAttachment'; + case UITypes.SingleSelect: + return 'cellSingleSelect'; + case UITypes.MultiSelect: + return 'cellMultiSelect'; + case UITypes.Collaborator: + return 'cellUser'; + case UITypes.CreatedBy: + case UITypes.LastModifiedBy: + return 'cellSystemUser'; + case UITypes.CreatedTime: + case UITypes.LastModifiedTime: + return 'cellSystemDate'; + case UITypes.LinkToAnotherRecord: + case UITypes.Links: { + const relationType = (column.colOptions as LinkToAnotherRecordType).type; + if (relationType === RelationTypes.HAS_MANY) { + return 'hm_solid'; + } else if (relationType === RelationTypes.MANY_TO_MANY) { + return 'mm_solid'; + } else if (relationType === RelationTypes.BELONGS_TO) { + return 'bt_solid'; + } else if (relationType === RelationTypes.ONE_TO_ONE) { + return 'oneToOneSolid'; + } + return 'mmSolid'; + } + case UITypes.Lookup: + return 'cellLookup'; + case UITypes.Rollup: + return 'cellRollup'; + case UITypes.Formula: + return 'cellFormula'; + case UITypes.Count: + case UITypes.AutoNumber: + return 'cellNumber'; + case UITypes.Barcode: + return 'cellBarcode'; + case UITypes.QrCode: + return 'cellQrCode'; + case UITypes.GeoData: + return 'ncMapPin'; + case UITypes.Geometry: + return 'cellGeometry'; + case UITypes.JSON: + return 'cellJson'; + case UITypes.Button: + return 'cellButton'; + case UITypes.SpecificDBType: + return 'cellDb'; + default: + return 'cellSystemText'; + } +} + +/** + * Convert NocoDB UIType to VariableType + */ +export function uiTypeToVariableType( + uiType: UITypes, + columnMeta?: any +): { type: VariableType; isArray: boolean } { + switch (uiType) { + // Number types + case UITypes.Number: + case UITypes.Decimal: + case UITypes.Currency: + case UITypes.Percent: + case UITypes.Duration: + case UITypes.Rating: + case UITypes.Rollup: + case UITypes.AutoNumber: + case UITypes.Count: + return { type: VariableType.Number, isArray: false }; + + // Integer type + case UITypes.Links: + return { type: VariableType.Integer, isArray: false }; + + // Boolean types + case UITypes.Checkbox: + return { type: VariableType.Boolean, isArray: false }; + + // DateTime types + case UITypes.Date: + case UITypes.DateTime: + case UITypes.Time: + case UITypes.CreatedTime: + case UITypes.LastModifiedTime: + return { type: VariableType.DateTime, isArray: false }; + + // Array types + case UITypes.Attachment: + return { type: VariableType.Object, isArray: true }; + + case UITypes.MultiSelect: + return { type: VariableType.String, isArray: true }; + + // Object types + case UITypes.LinkToAnotherRecord: { + // Check relation type from columnMeta + const isMultiple = + columnMeta?.type === RelationTypes.HAS_MANY || + columnMeta?.type === RelationTypes.MANY_TO_MANY; + return { type: VariableType.Object, isArray: isMultiple }; + } + + case UITypes.Collaborator: { + // Check if multi-user + const isMultiUser = parseProp(columnMeta?.meta)?.is_multi; + return { type: VariableType.Object, isArray: isMultiUser }; + } + + case UITypes.LastModifiedBy: + case UITypes.CreatedBy: + return { type: VariableType.Object, isArray: false }; + + case UITypes.JSON: + return { type: VariableType.Object, isArray: false }; + + // String types (default) + case UITypes.SingleLineText: + case UITypes.LongText: + case UITypes.Email: + case UITypes.URL: + case UITypes.PhoneNumber: + case UITypes.SingleSelect: + case UITypes.Year: + case UITypes.GeoData: + case UITypes.Geometry: + case UITypes.Barcode: + case UITypes.QrCode: + case UITypes.Button: + case UITypes.SpecificDBType: + case UITypes.ID: + case UITypes.ForeignKey: + default: + return { type: VariableType.String, isArray: false }; + } +} + +/** + * Helper to safely build a property accessor (dot notation or bracket notation) + * Uses bracket notation if the property name contains spaces or special characters + */ +function buildPropertyKey(prefix: string, propertyName: string): string { + // Check if property name needs bracket notation + // Use bracket notation if it contains spaces, starts with a number, or has special chars + const needsBrackets = + /[^a-zA-Z0-9_$]/.test(propertyName) || /^\d/.test(propertyName); + + if (needsBrackets) { + return `${prefix}['${propertyName}']`; + } else { + return `${prefix}.${propertyName}`; + } +} + +/** + * Generate variable definition from NocoDB column + */ +export function getFieldVariable( + column: ColumnType, + prefix: string = 'fields' +): VariableDefinition { + const { type, isArray } = uiTypeToVariableType( + column.uidt as UITypes, + column.colOptions + ); + + const variable: VariableDefinition = { + key: buildPropertyKey(prefix, column.title), + name: column.title, + type, + groupKey: VariableGroupKey.Fields, + isArray, + extra: { + entity_id: column.id, + entity: 'column', + uiType: column.uidt, + icon: uiTypeToIcon(column), + }, + }; + + if (column.uidt === UITypes.Attachment && isArray) { + // Define the structure of each attachment item + variable.extra = { + ...variable.extra, + itemSchema: [ + { + key: 'url', + name: 'url', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + icon: 'cellUrl', + description: 'URL of the attachment', + }, + }, + { + key: 'signedUrl', + name: 'signedUrl', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + icon: 'cellUrl', + description: 'Signed URL of the attachment', + }, + }, + { + key: 'title', + name: 'title', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + icon: 'cellText', + description: 'Title of the attachment', + }, + }, + { + key: 'mimetype', + name: 'mimetype', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + icon: 'cellText', + description: 'MIME type of the attachment', + }, + }, + { + key: 'size', + name: 'size', + type: VariableType.Number, + groupKey: VariableGroupKey.Fields, + extra: { + icon: 'cellNumber', + description: 'Size of the attachment in bytes', + }, + }, + ], + }; + + // Array-level properties + variable.children = [ + { + key: `${variable.key}.length`, + name: 'length', + type: VariableType.Number, + groupKey: VariableGroupKey.Meta, + extra: { + description: 'Number of attachments', + icon: 'cellNumber', + }, + }, + { + key: `${variable.key}.map(item => item.url).join(', ')`, + name: 'url', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + icon: 'cellUrl', + description: 'Comma-separated URLs of all attachments', + }, + }, + { + key: `${variable.key}.map(item => item.signedUrl).join(', ')`, + name: 'signedUrl', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + icon: 'cellUrl', + description: 'Comma-separated signed URLs of all attachments', + }, + }, + { + key: `${variable.key}.map(item => item.title).join(', ')`, + name: 'title', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + icon: 'cellText', + description: 'Comma-separated titles of all attachments', + }, + }, + { + key: `${variable.key}.map(item => item.mimetype).join(', ')`, + name: 'mimetype', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + icon: 'cellText', + description: 'Comma-separated mimetypes of all attachments', + }, + }, + { + key: `${variable.key}.map(item => item.size).join(', ')`, + name: 'size', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + icon: 'cellNumber', + description: 'Comma-separated sizes of all attachments', + }, + }, + ]; + } else if (column.uidt === UITypes.Collaborator) { + const isMulti = parseProp(column.meta)?.is_multi; + + variable.extra = isMulti + ? { + ...variable.extra, + itemSchema: [ + { + key: 'id', + name: 'id', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + icon: 'cellSystemKey', + description: 'User ID', + }, + }, + { + key: 'email', + name: 'email', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + icon: 'cellEmail', + description: 'User email', + }, + }, + { + key: 'display_name', + name: 'display_name', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + icon: 'cellText', + description: 'User display name', + }, + }, + ], + } + : {}; + + // Array-level properties + variable.children = [ + { + key: isMulti + ? `${variable.key}.map(item => item.id).join(', ')` + : `${variable.key}.id`, + name: 'id', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + icon: 'cellSystemKey', + description: isMulti ? 'Comma-separated user IDs' : 'User ID', + }, + }, + { + key: isMulti + ? `${variable.key}.map(item => item.email).join(', ')` + : `${variable.key}.email`, + name: 'email', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + icon: 'cellEmail', + description: isMulti ? 'Comma-separated emails' : 'User email', + }, + }, + { + key: isMulti + ? `${variable.key}.map(item => item.display_name || '').join(', ')` + : `${variable.key}.display_name`, + name: 'display_name', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + icon: 'cellText', + description: isMulti + ? 'Comma-separated display names' + : 'User display name', + }, + }, + { + key: `${variable.key}.length`, + name: 'length', + type: VariableType.Number, + groupKey: VariableGroupKey.Meta, + extra: { + description: 'Number of users', + icon: 'cellNumber', + }, + }, + ]; + } else if (column.uidt === UITypes.LinkToAnotherRecord && isArray) { + variable.children = [ + { + key: `${variable.key}.length`, + name: 'length', + type: VariableType.Number, + groupKey: VariableGroupKey.Meta, + extra: { + description: 'Number of linked records', + icon: 'cellNumber', + }, + }, + ]; + } else if (column.uidt === UITypes.MultiSelect && isArray) { + variable.extra = { + ...variable.extra, + itemSchema: [ + { + key: '', // Empty key means the item itself (it's a primitive string) + name: 'option', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + icon: 'cellText', + description: 'Selected option', + }, + }, + ], + }; + + // Array-level properties + variable.children = [ + { + key: `${variable.key}.length`, + name: 'length', + type: VariableType.Number, + groupKey: VariableGroupKey.Meta, + extra: { + description: 'Number of selected options', + icon: 'cellNumber', + }, + }, + ]; + } + + return variable; +} + +/** + * Generate system/meta variables for records + */ +export function getMetaVariables(prefix: string = ''): VariableDefinition[] { + const baseKey = prefix ? `${prefix}.` : ''; + + return [ + { + key: `${baseKey}id`, + name: 'id', + type: VariableType.String, + groupKey: VariableGroupKey.Meta, + extra: { + description: 'Record ID', + }, + }, + ]; +} + +/** + * Generate complete record variable structure + * This is the main function for generating variables from NocoDB table columns + * + * @param columns - Array of column definitions + * @param isArray - Whether this represents an array of records + * @param outputKey - The key under which the record(s) are stored in the output (e.g., 'record', 'deleted', 'records') + */ +export function genRecordVariables( + columns: Array, + isArray: boolean = false, + outputKey?: string +): VariableDefinition[] { + const filteredColumns = columns.filter((col) => !isSystemColumn(col)); + const recordKey = outputKey || (isArray ? 'records' : 'record'); + const recordName = recordKey.charAt(0).toUpperCase() + recordKey.slice(1); + + if (isArray) { + // Generate field variables (without array prefix for itemSchema) + const fieldVariables = filteredColumns.map((col) => { + return getFieldVariable(col, 'fields'); + }); + + return [ + { + key: recordKey, + name: recordName, + type: VariableType.Array, + groupKey: VariableGroupKey.Fields, + isArray: true, + extra: { + description: `List of ${recordName.toLowerCase()}`, + icon: 'cellJson', + // Define the structure of each record item + itemSchema: [ + { + key: 'id', + name: 'ID', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + description: 'Record ID', + icon: 'cellSystemKey', + }, + }, + { + key: 'fields', + name: 'Fields', + type: VariableType.Object, + groupKey: VariableGroupKey.Fields, + extra: { + description: 'Record fields', + icon: 'cellJson', + }, + children: fieldVariables, + }, + ], + }, + children: [ + { + key: `${recordKey}.length`, + name: 'Count', + type: VariableType.Number, + groupKey: VariableGroupKey.Meta, + extra: { + description: 'Number of records', + icon: 'cellNumber', + }, + }, + ], + }, + ]; + } else { + // Generate field variables with record prefix + const fieldVariables = filteredColumns.map((col) => { + const fieldVar = getFieldVariable(col, 'fields'); + return prefixVariableKeys(fieldVar, recordKey); + }); + + return [ + { + key: recordKey, + name: recordName, + type: VariableType.Object, + groupKey: VariableGroupKey.Fields, + extra: { + description: `The ${recordName.toLowerCase()}`, + icon: 'cellJson', + }, + children: [ + { + key: `${recordKey}.id`, + name: 'ID', + type: VariableType.String, + groupKey: VariableGroupKey.Fields, + extra: { + description: 'Record ID', + icon: 'cellSystemKey', + }, + }, + { + key: `${recordKey}.fields`, + name: 'Fields', + type: VariableType.Object, + groupKey: VariableGroupKey.Fields, + extra: { + description: 'Record fields', + icon: 'cellJson', + }, + children: fieldVariables, + }, + ], + }, + ]; + } +} + +/** + * Helper to prefix variable keys recursively + */ +export function prefixVariableKeys( + variable: VariableDefinition, + prefix: string +): VariableDefinition { + return { + ...variable, + key: `${prefix}.${variable.key}`, + children: (variable.children ?? []).map((child) => + prefixVariableKeys(child, prefix) + ), + }; +} + +/** + * Generate variables from actual output data (fallback for unknown structures) + * This is used when we don't have column definitions but have actual output data + */ +export function genGeneralVariables( + output: any, + prefix: string = '' +): VariableDefinition[] { + if (output == null) return []; + + const variables: VariableDefinition[] = []; + + // Primitive value + if (typeof output !== 'object') { + return [ + { + key: prefix || 'value', + name: prefix || 'value', + type: typeof output as VariableType, + groupKey: VariableGroupKey.Fields, + }, + ]; + } + + // Array - analyze first item + if (Array.isArray(output)) { + if (output.length === 0) return []; + + const firstItem = output[0]; + const arrayName = prefix.split('.').pop() || 'items'; + + // If array items are primitives (string, number, boolean) + if (typeof firstItem !== 'object' || firstItem === null) { + return [ + { + key: prefix, + name: arrayName, + type: VariableType.Array, + groupKey: VariableGroupKey.Fields, + isArray: true, + extra: { + description: `Array of ${typeof firstItem}s`, + itemSchema: [ + { + key: '', + name: 'item', + type: typeof firstItem as VariableType, + groupKey: VariableGroupKey.Fields, + extra: { + description: `Individual ${typeof firstItem}`, + }, + }, + ], + }, + children: [ + { + key: `${prefix}.length`, + name: 'length', + type: VariableType.Number, + groupKey: VariableGroupKey.Meta, + extra: { + description: 'Number of items', + }, + }, + ], + }, + ]; + } + + // If array items are objects, analyze the structure + const itemSchema: VariableDefinition[] = []; + + // Analyze first item's properties + for (const [itemKey, itemValue] of Object.entries(firstItem)) { + const isNestedArray = Array.isArray(itemValue); + const valueType = typeof itemValue; + + const itemDef: VariableDefinition = { + key: itemKey, + name: itemKey, + type: isNestedArray + ? VariableType.Array + : valueType === 'object' && itemValue !== null + ? VariableType.Object + : (valueType as VariableType), + groupKey: VariableGroupKey.Fields, + isArray: isNestedArray, + extra: { + description: `${itemKey} property of array item`, + }, + }; + + // For nested objects/arrays, recursively build schema + if (typeof itemValue === 'object' && itemValue !== null) { + if (Array.isArray(itemValue) && itemValue.length > 0) { + // Nested array - recursively generate its itemSchema + const nestedFirstItem = itemValue[0]; + if (typeof nestedFirstItem === 'object' && nestedFirstItem !== null) { + const nestedItemSchema: VariableDefinition[] = []; + for (const [nestedKey, nestedValue] of Object.entries( + nestedFirstItem + )) { + nestedItemSchema.push({ + key: nestedKey, + name: nestedKey, + type: typeof nestedValue as VariableType, + groupKey: VariableGroupKey.Fields, + extra: { + description: `${nestedKey} property`, + }, + }); + } + itemDef.extra = { + ...itemDef.extra, + itemSchema: nestedItemSchema, + }; + } else { + // Array of primitives + itemDef.extra = { + ...itemDef.extra, + itemSchema: [ + { + key: '', + name: 'item', + type: typeof nestedFirstItem as VariableType, + groupKey: VariableGroupKey.Fields, + }, + ], + }; + } + itemDef.children = [ + { + key: `${itemKey}.length`, + name: 'length', + type: VariableType.Number, + groupKey: VariableGroupKey.Meta, + extra: { + description: 'Number of items', + }, + }, + ]; + } else if (!Array.isArray(itemValue)) { + // Nested object - build children + const nestedChildren: VariableDefinition[] = []; + for (const [nestedKey, nestedValue] of Object.entries(itemValue)) { + nestedChildren.push({ + key: `${itemKey}.${nestedKey}`, + name: nestedKey, + type: typeof nestedValue as VariableType, + groupKey: VariableGroupKey.Fields, + extra: { + description: `${nestedKey} property`, + }, + }); + } + itemDef.children = nestedChildren; + } + } + + itemSchema.push(itemDef); + } + + return [ + { + key: prefix, + name: arrayName, + type: VariableType.Array, + groupKey: VariableGroupKey.Fields, + isArray: true, + extra: { + description: `Array of ${arrayName}`, + itemSchema: itemSchema, + }, + children: [ + { + key: `${prefix}.length`, + name: 'length', + type: VariableType.Number, + groupKey: VariableGroupKey.Meta, + extra: { + description: 'Number of items', + }, + }, + ], + }, + ]; + } + + // Object - recurse into properties + for (const [key, value] of Object.entries(output)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + const isArray = Array.isArray(value); + const valueType = typeof value; + + const varDef: VariableDefinition = { + key: fullKey, + name: key, + type: isArray + ? VariableType.Array + : valueType === 'object' + ? VariableType.Object + : (valueType as VariableType), + groupKey: VariableGroupKey.Fields, + isArray, + }; + + // Handle arrays with itemSchema + if (isArray && value.length > 0) { + const firstItem = value[0]; + + // Array of primitives + if (typeof firstItem !== 'object' || firstItem === null) { + varDef.extra = { + itemSchema: [ + { + key: '', + name: 'item', + type: typeof firstItem as VariableType, + groupKey: VariableGroupKey.Fields, + extra: { + description: `Individual ${typeof firstItem}`, + }, + }, + ], + }; + } else { + // Array of objects - build itemSchema + const itemSchema: VariableDefinition[] = []; + for (const [itemKey, itemValue] of Object.entries(firstItem)) { + itemSchema.push({ + key: itemKey, + name: itemKey, + type: Array.isArray(itemValue) + ? VariableType.Array + : typeof itemValue === 'object' && itemValue !== null + ? VariableType.Object + : (typeof itemValue as VariableType), + groupKey: VariableGroupKey.Fields, + isArray: Array.isArray(itemValue), + extra: { + description: `${itemKey} property`, + }, + }); + } + varDef.extra = { itemSchema }; + } + + // Add length property + varDef.children = [ + { + key: `${fullKey}.length`, + name: 'length', + type: VariableType.Number, + groupKey: VariableGroupKey.Meta, + extra: { + description: 'Number of items', + }, + }, + ]; + } else if (!isArray && valueType === 'object' && value !== null) { + // Regular object - recurse into children + varDef.children = genGeneralVariables(value, fullKey); + } + + variables.push(varDef); + } + + return variables; +}