From 2f66fe59e9b4f8c06bedba2bcce3eed4615f70bf Mon Sep 17 00:00:00 2001 From: nicosammito Date: Tue, 18 Aug 2026 17:36:51 +0200 Subject: [PATCH 1/4] feat: adding list-select input schema --- src/util/schema.util.ts | 74 +++++++++++++++- test/schema/schema.test.ts | 172 +++++++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 1 deletion(-) diff --git a/src/util/schema.util.ts b/src/util/schema.util.ts index 64842bb..f6d158d 100644 --- a/src/util/schema.util.ts +++ b/src/util/schema.util.ts @@ -132,6 +132,24 @@ export interface ListFileInput extends Input { mimetype?: string; } +/** + * Represents a list of select inputs. + * Emitted for any array/list whose element is a select type (a primitive + * literal union or a single string/number literal — e.g. `LIST`, + * `('GET' | 'POST')[]`) so the UI can render a dedicated multi-select instead + * of the generic list of individual select inputs its underlying type would + * otherwise produce. + */ +export interface ListSelectInput extends Input { + input?: "list-select"; + /** + * The literal values the array's elements may take, in declaration order + * (e.g. `LIST` → `["GET", "POST", "PUT", ...]`). Mirrors the + * options a single {@link PrimitiveInput} select would offer for the element. + */ + items?: (string | number | boolean)[]; +} + /** * Represents a data object input type with structured properties. * Includes property definitions and required field tracking. @@ -176,6 +194,7 @@ export type Schema = | ColorInput | FileInput | ListFileInput + | ListSelectInput | DataInput | ListInput | TypeInput @@ -335,7 +354,8 @@ export const getSchema = ( // Check primitive literal union first (e.g., "a" | "b" | "c") or a single // string/number literal (e.g., "GET"). A bare literal has only one allowed // value, so it should still surface as a select rather than a free-form text/number input. - if (isPrimitiveLiteralUnion(parameterType) || isStringOrNumberLiteral(parameterType)) { + // (Boolean has already been handled above; see isSelectType.) + if (isSelectType(parameterType)) { return {input: "select", type, ...combinedSuggestions}; } if (isNumber(parameterType)) { @@ -364,6 +384,15 @@ export const getSchema = ( return {input: "list-file", type, mimetype, ...combinedSuggestions}; } + // A list of a select type (LIST, ('GET' | 'POST')[], ...) + // surfaces a dedicated multi-select carrying the element's allowed + // literal values in `items`, instead of a generic list of individual + // select inputs. The suggestions stay the ones computed for the array. + if (itemTypes.length === 1 && isSelectType(itemTypes[0])) { + const items = getSelectItems(itemTypes[0]); + return {input: "list-select", type, items, ...combinedSuggestions}; + } + const itemSchemas = itemTypes.flatMap(itemType => { const itemTypes = itemType.isUnion() ? itemType.types : [itemType]; return itemTypes.map((itemType) => @@ -821,6 +850,49 @@ function isStringOrNumberLiteral(type: ts.Type): boolean { ); } +/** + * Checks whether a type surfaces as a select input. + * + * A select is a primitive literal union (e.g. `"a" | "b" | "c"`) or a single + * string/number literal (e.g. `"GET"`). Boolean is excluded so that `true` / + * `boolean` continue to render as a boolean input — mirroring the ordering in + * {@link getSchema}, where the boolean check runs first. Used both for the + * plain select input and to detect a {@link ListSelectInput} element. + * + * @param type - The type to check + * @returns True if the type surfaces as a select + */ +function isSelectType(type: ts.Type): boolean { + if (isBoolean(type)) return false; + return isPrimitiveLiteralUnion(type) || isStringOrNumberLiteral(type); +} + +/** + * Extracts the literal values a select type may take, in declaration order. + * + * Reads the constituent literals directly off the type (a union yields each of + * its members; a bare literal yields itself). Only reached for types that + * {@link isSelectType} already accepted, so every relevant member is a + * string/number/boolean literal. + * + * @param type - The select type to read the values from + * @returns The literal values (e.g. `["GET", "POST", ...]`) + */ +function getSelectItems(type: ts.Type): (string | number | boolean)[] { + const members = type.isUnion() ? type.types : [type]; + const items: (string | number | boolean)[] = []; + for (const member of members) { + if (member.isStringLiteral() || member.isNumberLiteral()) { + items.push(member.value); + } else if ((member as { intrinsicName?: string }).intrinsicName === "true") { + items.push(true); + } else if ((member as { intrinsicName?: string }).intrinsicName === "false") { + items.push(false); + } + } + return items; +} + /** * Checks if a type is a union of primitive types only. * diff --git a/test/schema/schema.test.ts b/test/schema/schema.test.ts index 4d84d6b..43c4db1 100644 --- a/test/schema/schema.test.ts +++ b/test/schema/schema.test.ts @@ -1616,6 +1616,178 @@ describe("Schema", () => { }); }); + describe("list-select input", () => { + // A plain select stays a primitive input; only wrapping it in an array + // promotes it to a dedicated list-select carrying the element's allowed + // literal values in `items`. + it("leaves a single select as a primitive select input", () => { + expect(getTypeSchema("HTTP_METHOD", DATA_TYPES)).toEqual({ + input: "select", + type: '"GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD"', + }); + }); + + it("resolves a list of a select data type to a list-select carrying its items", () => { + expect(getTypeSchema("LIST", DATA_TYPES)).toEqual({ + input: "list-select", + type: "HTTP_METHOD[]", + items: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"], + }); + }); + + it("resolves a list of an inline literal union to a list-select", () => { + expect(getTypeSchema("LIST<'GET' | 'POST'>", DATA_TYPES)).toEqual({ + input: "list-select", + type: '("GET" | "POST")[]', + items: ["GET", "POST"], + }); + }); + + it("keeps number literals as numbers in items", () => { + expect(getTypeSchema("LIST<1 | 2 | 3>", DATA_TYPES)).toEqual({ + input: "list-select", + type: "(1 | 2 | 3)[]", + items: [1, 2, 3], + }); + }); + + it("resolves a list of a single literal to a list-select", () => { + expect(getTypeSchema("LIST<'GET'>", DATA_TYPES)).toEqual({ + input: "list-select", + type: '"GET"[]', + items: ["GET"], + }); + }); + + it("resolves a list-select when nested in an object", () => { + const object = getTypeSchema( + "{ methods: LIST }", + DATA_TYPES + ) as any; + expect(object.input).toBe("data"); + expect(object.properties.methods).toEqual({ + input: "list-select", + type: "HTTP_METHOD[]", + items: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"], + }); + }); + + it("does not promote a list of booleans to a list-select", () => { + const list = getTypeSchema("LIST", DATA_TYPES) as any; + expect(list.input).toBe("list"); + expect(list.items).toEqual([ + {input: "boolean", type: "false"}, + {input: "boolean", type: "true"}, + ]); + }); + + // A synthetic function whose parameters/return are a concrete + // LIST — the stock signatures only expose generic LIST, + // which would never resolve to a concrete list-select. + const pickMethods: FunctionDefinition = { + __typename: "FunctionDefinition", + id: "gid://sagittarius/FunctionDefinition/900", + identifier: "test::methods::pick", + signature: + "(primary: LIST, fallback: LIST): LIST", + } as FunctionDefinition; + const functions = [...FUNCTION_SIGNATURES, pickMethods]; + + it("surfaces list-select in a signature schema with only valid reference suggestions", () => { + // node1 returns LIST; node2's first parameter references it. + const flow: Flow = { + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "test::methods::pick"}, + nextNodeId: "gid://sagittarius/NodeFunction/2", + parameters: {nodes: [{value: null}, {value: null}]}, + }, + { + id: "gid://sagittarius/NodeFunction/2", + functionDefinition: {identifier: "test::methods::pick"}, + parameters: { + nodes: [ + { + value: { + __typename: "ReferenceValue", + nodeFunctionId: "gid://sagittarius/NodeFunction/1", + }, + }, + {value: null}, + ], + }, + }, + ], + }, + } as unknown as Flow; + + const {parameters: [first]} = getSignatureSchema( + flow, + DATA_TYPES, + functions, + "gid://sagittarius/NodeFunction/2", + ); + + expect(first.schema.input).toBe("list-select"); + expect((first.schema as any).items).toEqual([ + "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", + ]); + + // The only suggestion is the in-scope reference to node1, whose + // return type (LIST) matches the parameter. No stray + // literal / single-method / cross-type suggestions leak in — the + // allowed literals live in `items`, not in `suggestions`. + expect(first.schema.suggestions).toEqual([ + { + __typename: "ReferenceValue", + nodeFunctionId: "gid://sagittarius/NodeFunction/1", + }, + ]); + }); + + it("keeps the full items and stays a list-select when a value is provided", () => { + // A provided literal array must not collapse the options to just the + // supplied values — the function-declared type stays the source of truth. + const flow: Flow = { + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "test::methods::pick"}, + parameters: { + nodes: [ + {value: {__typename: "LiteralValue", value: ["GET", "POST"]}}, + {value: null}, + ], + }, + }, + ], + }, + } as unknown as Flow; + + const {parameters: [first]} = getSignatureSchema( + flow, + DATA_TYPES, + functions, + "gid://sagittarius/NodeFunction/1", + ); + + expect(first.schema.input).toBe("list-select"); + expect((first.schema as any).items).toEqual([ + "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", + ]); + expect(first.schema.suggestions).toBeUndefined(); + }); + }); + describe("union-typed property (string | nested object) reference suggestions", () => { // A custom datatype that is an object. One of its keys, `flexible`, is a // union of a plain string (TEXT) or a nested object. The nested object in From 1663301052511ed01be552f712973689791f3ad3 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Tue, 18 Aug 2026 17:52:13 +0200 Subject: [PATCH 2/4] feat: adding list- input schema --- src/util/schema.util.ts | 50 +++++++++++++++++++++++++ test/schema/schema.test.ts | 76 ++++++++++++++++++++++++++++++++++---- 2 files changed, 119 insertions(+), 7 deletions(-) diff --git a/src/util/schema.util.ts b/src/util/schema.util.ts index f6d158d..0ed6ad2 100644 --- a/src/util/schema.util.ts +++ b/src/util/schema.util.ts @@ -150,6 +150,39 @@ export interface ListSelectInput extends Input { items?: (string | number | boolean)[]; } +/** + * Represents a list of boolean inputs. + * Emitted for any array/list of plain booleans (e.g. `LIST`, + * `boolean[]`) so the UI can render a dedicated multi-boolean input instead of + * the generic list of individual boolean inputs its underlying type would + * otherwise produce. Carries no additional properties. + */ +export interface ListBooleanInput extends Input { + input?: "list-boolean"; +} + +/** + * Represents a list of number inputs. + * Emitted for any array/list of plain numbers (e.g. `LIST`, `number[]`) + * so the UI can render a dedicated multi-number input instead of the generic + * list of individual number inputs its underlying type would otherwise produce. + * Carries no additional properties. + */ +export interface ListNumberInput extends Input { + input?: "list-number"; +} + +/** + * Represents a list of text inputs. + * Emitted for any array/list of plain strings (e.g. `LIST`, `string[]`) + * so the UI can render a dedicated multi-text input instead of the generic list + * of individual text inputs its underlying type would otherwise produce. Carries + * no additional properties. + */ +export interface ListTextInput extends Input { + input?: "list-text"; +} + /** * Represents a data object input type with structured properties. * Includes property definitions and required field tracking. @@ -195,6 +228,9 @@ export type Schema = | FileInput | ListFileInput | ListSelectInput + | ListBooleanInput + | ListNumberInput + | ListTextInput | DataInput | ListInput | TypeInput @@ -393,6 +429,20 @@ export const getSchema = ( return {input: "list-select", type, items, ...combinedSuggestions}; } + // A homogeneous list of a plain primitive surfaces a dedicated + // multi- input instead of a generic list of individual + // primitive inputs. Ordering mirrors the top-level primitive checks: + // boolean first, then number, then string — and select literals have + // already been handled above. Custom-input elements (DATE → date, + // COLOR → color, FILE → file) are structurally intersection/object types + // that fail these checks, so they keep their per-item schema below. + if (itemTypes.length === 1) { + const element = itemTypes[0]; + if (isBoolean(element)) return {input: "list-boolean", type, ...combinedSuggestions}; + if (isNumber(element)) return {input: "list-number", type, ...combinedSuggestions}; + if (isString(element)) return {input: "list-text", type, ...combinedSuggestions}; + } + const itemSchemas = itemTypes.flatMap(itemType => { const itemTypes = itemType.isUnion() ? itemType.types : [itemType]; return itemTypes.map((itemType) => diff --git a/test/schema/schema.test.ts b/test/schema/schema.test.ts index 43c4db1..137047c 100644 --- a/test/schema/schema.test.ts +++ b/test/schema/schema.test.ts @@ -1672,13 +1672,11 @@ describe("Schema", () => { }); }); - it("does not promote a list of booleans to a list-select", () => { - const list = getTypeSchema("LIST", DATA_TYPES) as any; - expect(list.input).toBe("list"); - expect(list.items).toEqual([ - {input: "boolean", type: "false"}, - {input: "boolean", type: "true"}, - ]); + it("promotes a list of booleans to list-boolean, not list-select", () => { + expect(getTypeSchema("LIST", DATA_TYPES)).toEqual({ + input: "list-boolean", + type: "boolean[]", + }); }); // A synthetic function whose parameters/return are a concrete @@ -1788,6 +1786,70 @@ describe("Schema", () => { }); }); + describe("list-boolean / list-number / list-text inputs", () => { + // A homogeneous list of a plain primitive surfaces a dedicated + // multi- input. Unlike list-select these carry no `items` and + // no extra properties — just input + type (+ suggestions when any). + it("resolves LIST to list-boolean", () => { + expect(getTypeSchema("LIST", DATA_TYPES)).toEqual({ + input: "list-boolean", + type: "boolean[]", + }); + }); + + it("resolves LIST to list-number", () => { + expect(getTypeSchema("LIST", DATA_TYPES)).toEqual({ + input: "list-number", + type: "number[]", + }); + }); + + it("resolves the number[] array form to list-number", () => { + expect(getTypeSchema("number[]", DATA_TYPES)).toEqual({ + input: "list-number", + type: "number[]", + }); + }); + + it("resolves LIST to list-text", () => { + expect(getTypeSchema("LIST", DATA_TYPES)).toEqual({ + input: "list-text", + type: "string[]", + }); + }); + + it("resolves the primitive list inputs when nested in an object", () => { + const object = getTypeSchema( + "{ tags: LIST, counts: LIST, flags: LIST }", + DATA_TYPES, + ) as any; + expect(object.input).toBe("data"); + expect(object.properties).toEqual({ + tags: {input: "list-text", type: "string[]"}, + counts: {input: "list-number", type: "number[]"}, + flags: {input: "list-boolean", type: "boolean[]"}, + }); + }); + + it("only promotes the inner list of LIST>, outer stays a generic list", () => { + const outer = getTypeSchema("LIST>", DATA_TYPES) as any; + expect(outer.input).toBe("list"); + expect(outer.items).toEqual([{input: "list-text", type: "string[]"}]); + }); + + // Custom-input elements must not be swallowed by the primitive promotion: + // DATE (number underneath) and COLOR (object) keep their per-item schema. + it("does not promote LIST or LIST to a primitive list input", () => { + const dateList = getTypeSchema("LIST", DATA_TYPES) as any; + expect(dateList.input).toBe("list"); + expect(dateList.items[0].input).toBe("date"); + + const colorList = getTypeSchema("LIST", DATA_TYPES) as any; + expect(colorList.input).toBe("list"); + expect(colorList.items[0].input).toBe("color"); + }); + }); + describe("union-typed property (string | nested object) reference suggestions", () => { // A custom datatype that is an object. One of its keys, `flexible`, is a // union of a plain string (TEXT) or a nested object. The nested object in From ac94ff82410d1a05f457bd7d84cd1ef454e6c17c Mon Sep 17 00:00:00 2001 From: nicosammito Date: Tue, 18 Aug 2026 19:14:27 +0200 Subject: [PATCH 3/4] feat: extending new list primitive inputs from ListInput --- src/util/schema.util.ts | 99 ++++++++++++++------------------------ test/schema/schema.test.ts | 74 +++++++++++++++++++--------- 2 files changed, 87 insertions(+), 86 deletions(-) diff --git a/src/util/schema.util.ts b/src/util/schema.util.ts index 0ed6ad2..1d74a08 100644 --- a/src/util/schema.util.ts +++ b/src/util/schema.util.ts @@ -137,17 +137,10 @@ export interface ListFileInput extends Input { * Emitted for any array/list whose element is a select type (a primitive * literal union or a single string/number literal — e.g. `LIST`, * `('GET' | 'POST')[]`) so the UI can render a dedicated multi-select instead - * of the generic list of individual select inputs its underlying type would - * otherwise produce. + * of the generic list input its underlying type would otherwise produce. */ -export interface ListSelectInput extends Input { +export interface ListSelectInput extends Omit { input?: "list-select"; - /** - * The literal values the array's elements may take, in declaration order - * (e.g. `LIST` → `["GET", "POST", "PUT", ...]`). Mirrors the - * options a single {@link PrimitiveInput} select would offer for the element. - */ - items?: (string | number | boolean)[]; } /** @@ -155,9 +148,10 @@ export interface ListSelectInput extends Input { * Emitted for any array/list of plain booleans (e.g. `LIST`, * `boolean[]`) so the UI can render a dedicated multi-boolean input instead of * the generic list of individual boolean inputs its underlying type would - * otherwise produce. Carries no additional properties. + * otherwise produce. Carries the per-item schemas in `items`, like a generic + * {@link ListInput}. */ -export interface ListBooleanInput extends Input { +export interface ListBooleanInput extends Omit { input?: "list-boolean"; } @@ -166,9 +160,9 @@ export interface ListBooleanInput extends Input { * Emitted for any array/list of plain numbers (e.g. `LIST`, `number[]`) * so the UI can render a dedicated multi-number input instead of the generic * list of individual number inputs its underlying type would otherwise produce. - * Carries no additional properties. + * Carries the per-item schemas in `items`, like a generic {@link ListInput}. */ -export interface ListNumberInput extends Input { +export interface ListNumberInput extends Omit { input?: "list-number"; } @@ -177,9 +171,9 @@ export interface ListNumberInput extends Input { * Emitted for any array/list of plain strings (e.g. `LIST`, `string[]`) * so the UI can render a dedicated multi-text input instead of the generic list * of individual text inputs its underlying type would otherwise produce. Carries - * no additional properties. + * the per-item schemas in `items`, like a generic {@link ListInput}. */ -export interface ListTextInput extends Input { +export interface ListTextInput extends Omit { input?: "list-text"; } @@ -420,37 +414,42 @@ export const getSchema = ( return {input: "list-file", type, mimetype, ...combinedSuggestions}; } + // Per-item schemas, computed the same way for a generic list and a + // list-select (whose `items` mirror a normal list's). A union element is + // split into one schema per member; a single element yields one schema. + const itemSchemas = itemTypes.flatMap(itemType => { + const memberTypes = itemType.isUnion() ? itemType.types : [itemType]; + return memberTypes.map((memberType) => + getSchema(checker, node, memberType, functionDeclarations, functions, suggestions, undefined, visited, recursionCache) + ) + }) + // A list of a select type (LIST, ('GET' | 'POST')[], ...) - // surfaces a dedicated multi-select carrying the element's allowed - // literal values in `items`, instead of a generic list of individual - // select inputs. The suggestions stay the ones computed for the array. + // surfaces a dedicated multi-select. Its `items` are the element schemas, + // exactly like a generic list — only the input kind differs so the UI can + // render a combined multi-select. Checked before the plain-primitive + // cases below because a single literal (e.g. LIST<1>) is a select, not a + // plain number. if (itemTypes.length === 1 && isSelectType(itemTypes[0])) { - const items = getSelectItems(itemTypes[0]); - return {input: "list-select", type, items, ...combinedSuggestions}; + return {input: "list-select", type, items: itemSchemas, ...combinedSuggestions}; } // A homogeneous list of a plain primitive surfaces a dedicated - // multi- input instead of a generic list of individual - // primitive inputs. Ordering mirrors the top-level primitive checks: - // boolean first, then number, then string — and select literals have - // already been handled above. Custom-input elements (DATE → date, - // COLOR → color, FILE → file) are structurally intersection/object types - // that fail these checks, so they keep their per-item schema below. + // multi- input. Its `items` are the element schemas, exactly + // like a generic list — only the input kind differs so the UI can render + // a combined multi- input. Ordering mirrors the top-level + // primitive checks: boolean first, then number, then string — select + // literals have already been handled above. Custom-input elements + // (DATE → date, COLOR → color, FILE → file) are structurally + // intersection/object types that fail these checks, so they keep the + // per-item schema of the generic list below. if (itemTypes.length === 1) { const element = itemTypes[0]; - if (isBoolean(element)) return {input: "list-boolean", type, ...combinedSuggestions}; - if (isNumber(element)) return {input: "list-number", type, ...combinedSuggestions}; - if (isString(element)) return {input: "list-text", type, ...combinedSuggestions}; + if (isBoolean(element)) return {input: "list-boolean", type, items: itemSchemas, ...combinedSuggestions}; + if (isNumber(element)) return {input: "list-number", type, items: itemSchemas, ...combinedSuggestions}; + if (isString(element)) return {input: "list-text", type, items: itemSchemas, ...combinedSuggestions}; } - const itemSchemas = itemTypes.flatMap(itemType => { - const itemTypes = itemType.isUnion() ? itemType.types : [itemType]; - return itemTypes.map((itemType) => - getSchema(checker, node, itemType, functionDeclarations, functions, suggestions, undefined, visited, recursionCache) - ) - }) - - return { input: "list", type, @@ -917,32 +916,6 @@ function isSelectType(type: ts.Type): boolean { return isPrimitiveLiteralUnion(type) || isStringOrNumberLiteral(type); } -/** - * Extracts the literal values a select type may take, in declaration order. - * - * Reads the constituent literals directly off the type (a union yields each of - * its members; a bare literal yields itself). Only reached for types that - * {@link isSelectType} already accepted, so every relevant member is a - * string/number/boolean literal. - * - * @param type - The select type to read the values from - * @returns The literal values (e.g. `["GET", "POST", ...]`) - */ -function getSelectItems(type: ts.Type): (string | number | boolean)[] { - const members = type.isUnion() ? type.types : [type]; - const items: (string | number | boolean)[] = []; - for (const member of members) { - if (member.isStringLiteral() || member.isNumberLiteral()) { - items.push(member.value); - } else if ((member as { intrinsicName?: string }).intrinsicName === "true") { - items.push(true); - } else if ((member as { intrinsicName?: string }).intrinsicName === "false") { - items.push(false); - } - } - return items; -} - /** * Checks if a type is a union of primitive types only. * diff --git a/test/schema/schema.test.ts b/test/schema/schema.test.ts index 137047c..99cf39a 100644 --- a/test/schema/schema.test.ts +++ b/test/schema/schema.test.ts @@ -1618,8 +1618,17 @@ describe("Schema", () => { describe("list-select input", () => { // A plain select stays a primitive input; only wrapping it in an array - // promotes it to a dedicated list-select carrying the element's allowed - // literal values in `items`. + // promotes it to a dedicated list-select. Its `items` are the element + // schemas, exactly like a generic list — one select schema per literal. + const methodItems = [ + {input: "select", type: '"GET"'}, + {input: "select", type: '"POST"'}, + {input: "select", type: '"PUT"'}, + {input: "select", type: '"DELETE"'}, + {input: "select", type: '"PATCH"'}, + {input: "select", type: '"HEAD"'}, + ]; + it("leaves a single select as a primitive select input", () => { expect(getTypeSchema("HTTP_METHOD", DATA_TYPES)).toEqual({ input: "select", @@ -1627,11 +1636,11 @@ describe("Schema", () => { }); }); - it("resolves a list of a select data type to a list-select carrying its items", () => { + it("resolves a list of a select data type to a list-select carrying its item schemas", () => { expect(getTypeSchema("LIST", DATA_TYPES)).toEqual({ input: "list-select", type: "HTTP_METHOD[]", - items: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"], + items: methodItems, }); }); @@ -1639,15 +1648,22 @@ describe("Schema", () => { expect(getTypeSchema("LIST<'GET' | 'POST'>", DATA_TYPES)).toEqual({ input: "list-select", type: '("GET" | "POST")[]', - items: ["GET", "POST"], + items: [ + {input: "select", type: '"GET"'}, + {input: "select", type: '"POST"'}, + ], }); }); - it("keeps number literals as numbers in items", () => { + it("resolves a list of a number literal union to a list-select", () => { expect(getTypeSchema("LIST<1 | 2 | 3>", DATA_TYPES)).toEqual({ input: "list-select", type: "(1 | 2 | 3)[]", - items: [1, 2, 3], + items: [ + {input: "select", type: "1"}, + {input: "select", type: "2"}, + {input: "select", type: "3"}, + ], }); }); @@ -1655,7 +1671,7 @@ describe("Schema", () => { expect(getTypeSchema("LIST<'GET'>", DATA_TYPES)).toEqual({ input: "list-select", type: '"GET"[]', - items: ["GET"], + items: [{input: "select", type: '"GET"'}], }); }); @@ -1668,7 +1684,7 @@ describe("Schema", () => { expect(object.properties.methods).toEqual({ input: "list-select", type: "HTTP_METHOD[]", - items: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"], + items: methodItems, }); }); @@ -1676,6 +1692,10 @@ describe("Schema", () => { expect(getTypeSchema("LIST", DATA_TYPES)).toEqual({ input: "list-boolean", type: "boolean[]", + items: [ + {input: "boolean", type: "false"}, + {input: "boolean", type: "true"}, + ], }); }); @@ -1732,14 +1752,11 @@ describe("Schema", () => { ); expect(first.schema.input).toBe("list-select"); - expect((first.schema as any).items).toEqual([ - "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", - ]); + expect((first.schema as any).items).toEqual(methodItems); // The only suggestion is the in-scope reference to node1, whose // return type (LIST) matches the parameter. No stray - // literal / single-method / cross-type suggestions leak in — the - // allowed literals live in `items`, not in `suggestions`. + // single-method / cross-type suggestions leak in. expect(first.schema.suggestions).toEqual([ { __typename: "ReferenceValue", @@ -1779,21 +1796,29 @@ describe("Schema", () => { ); expect(first.schema.input).toBe("list-select"); - expect((first.schema as any).items).toEqual([ - "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", - ]); + expect((first.schema as any).items).toEqual(methodItems); expect(first.schema.suggestions).toBeUndefined(); }); }); describe("list-boolean / list-number / list-text inputs", () => { // A homogeneous list of a plain primitive surfaces a dedicated - // multi- input. Unlike list-select these carry no `items` and - // no extra properties — just input + type (+ suggestions when any). + // multi- input. Like list-select (and a generic list) these + // carry the element schemas in `items` — one primitive schema per element. + // A boolean element normalizes to the `false | true` union, which — like + // any union element — splits into one schema per member. + const booleanItems = [ + {input: "boolean", type: "false"}, + {input: "boolean", type: "true"}, + ]; + const numberItems = [{input: "number", type: "number"}]; + const textItems = [{input: "text", type: "string"}]; + it("resolves LIST to list-boolean", () => { expect(getTypeSchema("LIST", DATA_TYPES)).toEqual({ input: "list-boolean", type: "boolean[]", + items: booleanItems, }); }); @@ -1801,6 +1826,7 @@ describe("Schema", () => { expect(getTypeSchema("LIST", DATA_TYPES)).toEqual({ input: "list-number", type: "number[]", + items: numberItems, }); }); @@ -1808,6 +1834,7 @@ describe("Schema", () => { expect(getTypeSchema("number[]", DATA_TYPES)).toEqual({ input: "list-number", type: "number[]", + items: numberItems, }); }); @@ -1815,6 +1842,7 @@ describe("Schema", () => { expect(getTypeSchema("LIST", DATA_TYPES)).toEqual({ input: "list-text", type: "string[]", + items: textItems, }); }); @@ -1825,16 +1853,16 @@ describe("Schema", () => { ) as any; expect(object.input).toBe("data"); expect(object.properties).toEqual({ - tags: {input: "list-text", type: "string[]"}, - counts: {input: "list-number", type: "number[]"}, - flags: {input: "list-boolean", type: "boolean[]"}, + tags: {input: "list-text", type: "string[]", items: textItems}, + counts: {input: "list-number", type: "number[]", items: numberItems}, + flags: {input: "list-boolean", type: "boolean[]", items: booleanItems}, }); }); it("only promotes the inner list of LIST>, outer stays a generic list", () => { const outer = getTypeSchema("LIST>", DATA_TYPES) as any; expect(outer.input).toBe("list"); - expect(outer.items).toEqual([{input: "list-text", type: "string[]"}]); + expect(outer.items).toEqual([{input: "list-text", type: "string[]", items: textItems}]); }); // Custom-input elements must not be swallowed by the primitive promotion: From 493d7f4a0081af3965dcbe0d576fe2a1e3c9da97 Mon Sep 17 00:00:00 2001 From: nicosammito Date: Tue, 18 Aug 2026 20:35:39 +0200 Subject: [PATCH 4/4] feat: adding new list-sub-flow input --- src/util/schema.util.ts | 46 +++++++++++++++++++-- test/schema/schema.test.ts | 84 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 7 deletions(-) diff --git a/src/util/schema.util.ts b/src/util/schema.util.ts index 1d74a08..894724d 100644 --- a/src/util/schema.util.ts +++ b/src/util/schema.util.ts @@ -177,6 +177,18 @@ export interface ListTextInput extends Omit { input?: "list-text"; } +/** + * Represents a list of sub-flow inputs. + * Emitted for any array/list whose element is a callable/sub-flow type (e.g. + * `LIST`, `(() => void)[]`) so the UI can render a dedicated multi-sub-flow + * input instead of the generic list of individual sub-flow inputs its underlying + * type would otherwise produce. Carries the per-item schemas in `items`, like a + * generic {@link ListInput}. + */ +export interface ListSubFlowInput extends Omit { + input?: "list-sub-flow"; +} + /** * Represents a data object input type with structured properties. * Includes property definitions and required field tracking. @@ -225,6 +237,7 @@ export type Schema = | ListBooleanInput | ListNumberInput | ListTextInput + | ListSubFlowInput | DataInput | ListInput | TypeInput @@ -448,6 +461,10 @@ export const getSchema = ( if (isBoolean(element)) return {input: "list-boolean", type, items: itemSchemas, ...combinedSuggestions}; if (isNumber(element)) return {input: "list-number", type, items: itemSchemas, ...combinedSuggestions}; if (isString(element)) return {input: "list-text", type, items: itemSchemas, ...combinedSuggestions}; + // A homogeneous list of a callable/sub-flow element surfaces a + // dedicated multi-sub-flow input. Checked after the primitives (none + // of which are callable) and before the generic list fallback. + if (isSubFlow(element)) return {input: "list-sub-flow", type, items: itemSchemas, ...combinedSuggestions}; } return { @@ -562,6 +579,19 @@ export const getSchema = ( * @param nodeSchema - The schema derived from the node's concrete (narrowed) parameter type * @returns A single merged schema */ +// Every list input kind whose schema carries per-element `items` — the generic +// list and all specialized list-* variants that mirror it. mergeSchemas recurses +// into these so element-level suggestions are preserved. list-file is excluded: +// it carries a `mimetype`, not `items`. +const LIST_INPUTS = new Set([ + "list", + "list-select", + "list-boolean", + "list-number", + "list-text", + "list-sub-flow", +]); + export const mergeSchemas = ( functionSchema: Schema | undefined, nodeSchema: Schema, @@ -598,10 +628,20 @@ export const mergeSchemas = ( }; } - if (functionSchema.input === "list") { - const fItems = functionSchema.items ?? []; + // The generic list and every specialized list-* variant (list-select, + // list-boolean/number/text, list-sub-flow) carry their element schemas in + // `items`. Merge those pairwise so element-level suggestions — e.g. the + // per-literal values on a list-select or the sub-flow function suggestions on + // a LIST> element — survive the merge instead of being dropped in + // favour of the suggestion-less function schema. Suggestions must never be + // lost, whatever the list kind. Only merges when both sides are the same list + // kind. (list-file carries a `mimetype`, not `items`, so it is not listed.) + if (LIST_INPUTS.has(functionSchema.input as string)) { + const fItems = (functionSchema as ListInput).items ?? []; const nItems = - nodeSchema.input === "list" ? (nodeSchema.items ?? []) : []; + nodeSchema.input === functionSchema.input + ? ((nodeSchema as ListInput).items ?? []) + : []; const items = fItems.length === nItems.length && fItems.length > 0 ? fItems.map((f, i) => mergeSchemas(f, nItems[i])) diff --git a/test/schema/schema.test.ts b/test/schema/schema.test.ts index 99cf39a..0cc1d94 100644 --- a/test/schema/schema.test.ts +++ b/test/schema/schema.test.ts @@ -1,6 +1,6 @@ import {describe, expect, it} from "vitest"; import {DataType, Flow, FunctionDefinition} from "@code0-tech/sagittarius-graphql-types"; -import {getSignatureSchema, getTypeSchema} from "../../src"; +import {getSignatureSchema, getTypeSchema, ListSubFlowInput, SubFlowInput} from "../../src"; import {DATA_TYPES, FUNCTION_SIGNATURES} from "../data"; describe("Schema", () => { @@ -68,7 +68,7 @@ describe("Schema", () => { signature: "(test: HTTP_METHOD): void" }; - const result = getSignatureSchema(flow, DATA_TYPES, FUNCTION_SIGNATURES); + const result = getSignatureSchema(flow, DATA_TYPES, FUNCTION_SIGNATURES, "gid://sagittarius/NodeFunction/2"); //console.dir(result, {depth: null}) }); @@ -1629,6 +1629,15 @@ describe("Schema", () => { {input: "select", type: '"HEAD"'}, ]; + // The same items as they surface through getSignatureSchema: the merge + // preserves each select element's own literal value as a suggestion + // (suggestions must never be lost for any list input). The plain + // getTypeSchema path (suggestions off) still yields the bare methodItems. + const methodItemsWithSuggestions = methodItems.map((item) => ({ + ...item, + suggestions: [{__typename: "LiteralValue", value: JSON.parse(item.type)}], + })); + it("leaves a single select as a primitive select input", () => { expect(getTypeSchema("HTTP_METHOD", DATA_TYPES)).toEqual({ input: "select", @@ -1752,7 +1761,7 @@ describe("Schema", () => { ); expect(first.schema.input).toBe("list-select"); - expect((first.schema as any).items).toEqual(methodItems); + expect((first.schema as any).items).toEqual(methodItemsWithSuggestions); // The only suggestion is the in-scope reference to node1, whose // return type (LIST) matches the parameter. No stray @@ -1796,7 +1805,7 @@ describe("Schema", () => { ); expect(first.schema.input).toBe("list-select"); - expect((first.schema as any).items).toEqual(methodItems); + expect((first.schema as any).items).toEqual(methodItemsWithSuggestions); expect(first.schema.suggestions).toBeUndefined(); }); }); @@ -1878,6 +1887,73 @@ describe("Schema", () => { }); }); + describe("list-sub-flow input", () => { + // A single callable stays a sub-flow input; only wrapping it in an array + // promotes it to a dedicated list-sub-flow. Its `items` are the element + // schemas, exactly like a generic list — one sub-flow schema per element. + it("leaves a single callable as a sub-flow input", () => { + const single = getTypeSchema("CONSUMER", DATA_TYPES) as any; + expect(single.input).toBe("sub-flow"); + }); + + it("resolves a list of a callable data type to a list-sub-flow", () => { + const list = getTypeSchema("LIST>", DATA_TYPES) as any; + expect(list.input).toBe("list-sub-flow"); + expect(list.items).toEqual([{input: "sub-flow", type: "(item: number) => void"}]); + }); + + it("resolves the array form of a callable to a list-sub-flow", () => { + const list = getTypeSchema("PREDICATE[]", DATA_TYPES) as any; + expect(list.input).toBe("list-sub-flow"); + expect(list.items).toEqual([{input: "sub-flow", type: "(item: string) => boolean"}]); + }); + + // A function under test whose parameter is a LIST> — a + // list whose element is a `(item: NUMBER) => void` sub-flow. + const runAll: FunctionDefinition = { + __typename: "FunctionDefinition", + id: "gid://sagittarius/FunctionDefinition/950", + identifier: "test::flows::run_all", + signature: "(handlers: LIST<(...args: any): any>): void", + } as FunctionDefinition; + + const numberConsumer: FunctionDefinition = { + __typename: "FunctionDefinition", + id: "gid://sagittarius/FunctionDefinition/951", + identifier: "test::consume::number", + signature: "(item: NUMBER): void", + } as FunctionDefinition; + + const functions = [...FUNCTION_SIGNATURES, runAll, numberConsumer]; + + it("resolves a list-of-sub-flows function parameter to a list-sub-flow schema", () => { + const flow: Flow = { + id: "gid://sagittarius/Flow/1", + startingNodeId: "gid://sagittarius/NodeFunction/1", + signature: "(): void", + nodes: { + nodes: [ + { + id: "gid://sagittarius/NodeFunction/1", + functionDefinition: {identifier: "test::flows::run_all"}, + parameters: {nodes: [{value: null}]}, + }, + ], + }, + } as unknown as Flow; + + const result = getSignatureSchema( + flow, + DATA_TYPES, + functions, + "gid://sagittarius/NodeFunction/1", + ); + + expect((result.parameters[0].schema as ListSubFlowInput)?.items?.[0]?.suggestions?.length).toBe(114) + + }); + }); + describe("union-typed property (string | nested object) reference suggestions", () => { // A custom datatype that is an object. One of its keys, `flexible`, is a // union of a plain string (TEXT) or a nested object. The nested object in