Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/util/schema.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,17 @@ export interface DateInput extends Input {
input?: "date";
}

/**
* Represents a color input type.
* Emitted for the COLOR data type so the UI can render a dedicated color picker
* instead of expanding the `{ hue, saturation, lightness, alpha? }` object its
* underlying type would otherwise produce. Like {@link DateInput}, it carries no
* additional properties.
*/
export interface ColorInput extends Input {
input?: "color";
}

/**
* Represents a file input type.
* Emitted for the FILE data type so the UI can render a dedicated file picker
Expand Down Expand Up @@ -162,6 +173,7 @@ export interface TypeInput extends Input {
export type Schema =
| PrimitiveInput
| DateInput
| ColorInput
| FileInput
| ListFileInput
| DataInput
Expand Down Expand Up @@ -305,6 +317,14 @@ export const getSchema = (
return {input: "file", type, mimetype, ...combinedSuggestions};
}

// The COLOR data type is structurally an object ({ hue, saturation,
// lightness, alpha? }), but the UI should render a dedicated color picker
// rather than expanding those internals. Detected here so it short-circuits
// the object handling below. Like DATE, it carries no additional properties.
if (isColorType(checker, parameterType)) {
return {input: "color", type, ...combinedSuggestions};
}

// Boolean is internally represented by TypeScript as the union `true | false`,
// so it must be detected before the primitive-literal-union check below; otherwise
// `boolean` (and `true | false`) would incorrectly surface as a select.
Expand Down Expand Up @@ -649,6 +669,38 @@ function isFileType(checker: ts.TypeChecker, type: ts.Type): boolean {
return valueTypeType.isStringLiteral() && valueTypeType.value === "base64";
}

/**
* Checks whether a type is the COLOR data type.
*
* A type only counts as COLOR when it is *both* named `COLOR` and shaped like
* COLOR (`{ hue: number; saturation: number; lightness: number; alpha?: number }`) —
* the name alone could be an unrelated alias, and the shape alone could be a
* coincidental object literal. The three numeric channel properties
* (`hue`, `saturation`, `lightness`) are the distinguishing part of the
* structure; `alpha` is optional.
*
* @param checker - The type checker
* @param type - The type to check
* @returns True if the type is the COLOR data type
*/
function isColorType(checker: ts.TypeChecker, type: ts.Type): boolean {
if (type.aliasSymbol?.getName() !== "COLOR") return false;

if ((type.flags & ts.TypeFlags.Object) === 0) return false;

const byName = new Map(
checker.getPropertiesOfType(type).map((p) => [p.name, p])
);

return ["hue", "saturation", "lightness"].every((name) => {
const property = byName.get(name);
if (!property) return false;
const declaration = property.valueDeclaration ?? property.declarations?.[0];
if (!declaration) return false;
return isNumber(checker.getTypeOfSymbolAtLocation(property, declaration));
});
}


/**
* Extracts the MIME type a FILE is constrained to from its `contentType`
Expand Down
50 changes: 50 additions & 0 deletions test/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1588,6 +1588,56 @@ export const DATA_TYPES: DataType[] = [
}
}
},
{
"__typename": "DataType",
"id": "gid://sagittarius/DataType/30",
"createdAt": "2026-06-19T15:33:16Z",
"updatedAt": "2026-06-19T15:34:59Z",
"identifier": "COLOR",
"genericKeys": [],
"type": "{ hue: number; saturation: number; lightness: number; alpha?: number }",
"definitionSource": "",
"version": "0.0.35",
"name": [
{
"__typename": "Translation",
"code": "en-US",
"content": "Color"
}
],
"aliases": [
{
"__typename": "Translation",
"code": "en-US",
"content": "color;colour;grb;hsv;hsl;hex"
}
],
"displayMessages": [
{
"__typename": "Translation",
"code": "en-US",
"content": "Color"
}
],
"runtime": {
"id": "gid://sagittarius/Runtime/1",
"__typename": "Runtime"
},
"runtimeModule": {
"__typename": "RuntimeModule",
"id": "gid://sagittarius/RuntimeModule/11"
},
"rules": {
"__typename": "DataTypeRuleConnection",
"count": 0,
"nodes": [],
"pageInfo": {
"endCursor": null,
"hasNextPage": false,
"__typename": "PageInfo"
}
}
},
{
"__typename": "DataType",
"id": "gid://sagittarius/DataType/10",
Expand Down
39 changes: 39 additions & 0 deletions test/schema/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1577,6 +1577,45 @@ describe("Schema", () => {
});
});

describe("COLOR data type", () => {
// COLOR is declared as
// `{ hue: number; saturation: number; lightness: number; alpha?: number }`,
// but the schema layer must surface a dedicated color input instead of
// expanding those internal channels into a `data` input. Like DATE it
// carries no additional properties.
const COLOR_TYPE =
"{ hue: number; saturation: number; lightness: number; alpha?: number | undefined; }";

it("resolves to a color input", () => {
expect(getTypeSchema("COLOR", DATA_TYPES)).toEqual({
input: "color",
type: COLOR_TYPE,
});
});

it("resolves to a color input when nested in a list and object", () => {
const list = getTypeSchema("LIST<COLOR>", DATA_TYPES) as any;
expect(list.input).toBe("list");
expect(list.items[0]).toEqual({input: "color", type: COLOR_TYPE});

const object = getTypeSchema("{ background: COLOR }", DATA_TYPES) as any;
expect(object.input).toBe("data");
expect(object.properties.background).toEqual({
input: "color",
type: COLOR_TYPE,
});
});

it("still expands a coincidental color-shaped object without the COLOR alias", () => {
const object = getTypeSchema(
"{ hue: number; saturation: number; lightness: number }",
DATA_TYPES
) as any;
expect(object.input).toBe("data");
expect(object.properties.hue).toEqual({input: "number", type: "number"});
});
});

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
Expand Down