Skip to content

Commit bbeed51

Browse files
authored
feat(validator): generate field states (#19)
Adds option to generate UI component states during forman validation.
1 parent d6d8b47 commit bbeed51

File tree

10 files changed

+544
-32
lines changed

10 files changed

+544
-32
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@makehq/forman-schema",
3-
"version": "1.4.1",
3+
"version": "1.5.0",
44
"description": "Forman Schema Tools",
55
"license": "MIT",
66
"author": "Make",

src/forman.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
FormanSchemaOption,
88
FormanSchemaOptionGroup,
99
} from './types';
10-
import { noEmpty, isObject, isOptionGroup, normalizeFormanFieldType, FORMAN_VISUAL_TYPES } from './utils';
10+
import { noEmpty, isObject, isOptionGroup, normalizeFormanFieldType, isVisualType } from './utils';
1111

1212
/**
1313
* Context for schema conversion operations
@@ -217,7 +217,7 @@ function handleCollectionType(field: FormanSchemaField, result: JSONSchema7, con
217217
});
218218

219219
function addField(subField: FormanSchemaField, tail?: string[]) {
220-
if (FORMAN_VISUAL_TYPES.includes(subField.type)) {
220+
if (isVisualType(subField.type)) {
221221
return;
222222
}
223223

src/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ export type FormanSchemaFieldType =
55
| 'aiagent'
66
| 'account'
77
| 'hook'
8+
| 'device'
89
| 'keychain'
910
| 'datastore'
1011
| 'udt'
12+
| 'scenario'
1113
| 'array'
1214
| 'collection'
1315
| 'text'
@@ -211,11 +213,23 @@ export type FormanValidationResult = {
211213
/** Error message */
212214
message: string;
213215
}[];
216+
/** States of fields grouped by domain */
217+
states?: Record<string, FormanSchemaFieldState>;
218+
};
219+
220+
export type FormanSchemaFieldState = {
221+
mode?: 'chose' | 'edit';
222+
label?: string;
223+
data?: Record<string, unknown>;
224+
nested?: Record<string, FormanSchemaFieldState>;
225+
items?: Record<string, FormanSchemaFieldState>[];
214226
};
215227

216228
export type FormanValidationOptions = {
217229
/** Unknown fields are not allowed when strict is true */
218230
strict?: boolean;
231+
/** Whether to generate states for fields */
232+
states?: boolean;
219233
/** Remote resource resolver */
220234
resolveRemote?(path: string, data: Record<string, unknown>): Promise<unknown>;
221235
};

src/utils.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
FormanSchemaExtendedOptions,
33
FormanSchemaField,
4+
FormanSchemaFieldState,
45
FormanSchemaFieldType,
56
FormanSchemaOption,
67
FormanSchemaOptionGroup,
@@ -9,7 +10,39 @@ import {
910
/**
1011
* Visual types are not a real input fields, they are used to display information in the UI.
1112
*/
12-
export const FORMAN_VISUAL_TYPES = ['banner', 'markdown', 'html', 'separator'];
13+
export const FORMAN_VISUAL_TYPES = ['banner', 'markdown', 'html', 'separator'] as const;
14+
15+
/**
16+
* Type guard to check if a field type is a visual type.
17+
* @param type The field type to check
18+
* @returns true if the type is a visual type
19+
*/
20+
export function isVisualType(type: FormanSchemaFieldType): type is (typeof FORMAN_VISUAL_TYPES)[number] {
21+
return (FORMAN_VISUAL_TYPES as readonly string[]).includes(type);
22+
}
23+
24+
/**
25+
* Reference types are types of type select that reference external resources.
26+
*/
27+
export const FORMAN_REFERENCE_TYPES = [
28+
'account',
29+
'hook',
30+
'device',
31+
'keychain',
32+
'datastore',
33+
'aiagent',
34+
'udt',
35+
'scenario',
36+
] as const;
37+
38+
/**
39+
* Type guard to check if a field type is a reference type.
40+
* @param type The field type to check
41+
* @returns true if the type is a reference type
42+
*/
43+
export function isReferenceType(type: FormanSchemaFieldType): type is (typeof FORMAN_REFERENCE_TYPES)[number] {
44+
return (FORMAN_REFERENCE_TYPES as readonly string[]).includes(type);
45+
}
1346

1447
/**
1548
* Utility function to handle empty strings by converting them to undefined.
@@ -64,8 +97,10 @@ export const API_ENDPOINTS = {
6497
aiagent: 'api://ai-agents/v1/agents',
6598
datastore: 'api://data-stores',
6699
hook: 'api://hooks/{{kind}}',
100+
device: 'api://devices',
67101
keychain: 'api://keys/{{kind}}',
68102
udt: 'api://data-structures',
103+
scenario: 'api://scenario-list',
69104
} as const;
70105

71106
/**
@@ -94,3 +129,100 @@ export function normalizeFormanFieldType(field: FormanSchemaField): FormanSchema
94129
},
95130
};
96131
}
132+
133+
/**
134+
* Transforms a flat array of domain/path/state items into a nested object structure.
135+
* Intermediate path levels are placed in a 'nested' property.
136+
* @param items Array of items with domain, path, and state properties
137+
* @returns Nested object structure organized by domain
138+
*/
139+
export function buildRestoreStructure(
140+
items: Array<{
141+
domain: string;
142+
path: (string | number)[];
143+
state: Omit<FormanSchemaFieldState, 'nested' | 'items'>;
144+
}>,
145+
): Record<string, FormanSchemaFieldState> {
146+
const result: Record<string, Record<string, FormanSchemaFieldState>> = {};
147+
148+
for (const item of items) {
149+
const { domain, path, state } = item;
150+
151+
// Ensure domain exists
152+
if (!result[domain]) {
153+
result[domain] = {};
154+
}
155+
156+
let current: Record<string, FormanSchemaFieldState> | Record<string, FormanSchemaFieldState>[] = result[domain];
157+
158+
// Navigate through the path
159+
for (const [index, key] of path.entries()) {
160+
const nextKey = path[index + 1];
161+
const isLastElement = index === path.length - 1;
162+
const nextIsNumber = typeof nextKey === 'number';
163+
164+
if (Array.isArray(current)) {
165+
// We're in an array context
166+
if (typeof key !== 'number') {
167+
throw new Error('Invalid path');
168+
}
169+
// Ensure the array item exists
170+
if (!current[key]) {
171+
current[key] = {};
172+
}
173+
174+
if (nextIsNumber) {
175+
// Next level is also an array - create nested array structure
176+
if (!current[key].value) {
177+
current[key].value = {
178+
mode: 'chose',
179+
items: [],
180+
};
181+
}
182+
if (!current[key].value.items) {
183+
current[key].value.items = [];
184+
}
185+
current = current[key].value.items;
186+
} else if (isLastElement) {
187+
// Last element - merge the state
188+
Object.assign(current[key], state);
189+
} else {
190+
// Move to the object at this array index
191+
current = current[key];
192+
}
193+
} else {
194+
// We're in an object context
195+
if (typeof key !== 'string') {
196+
throw new Error('Invalid path');
197+
}
198+
// Ensure the object item exists
199+
if (!current[key]) {
200+
current[key] = {};
201+
}
202+
203+
if (isLastElement) {
204+
// Last element - merge the state
205+
Object.assign(current[key], state);
206+
} else {
207+
// Intermediate element - ensure it exists and navigate
208+
if (nextIsNumber) {
209+
// Next level is an array - create array structure
210+
if (!current[key].items) {
211+
current[key].items = [];
212+
current[key].mode = 'chose';
213+
}
214+
current = current[key].items;
215+
} else {
216+
// Next level is an object - navigate to nested
217+
if (!current[key].nested) {
218+
current[key].nested = {};
219+
}
220+
current = current[key].nested;
221+
}
222+
}
223+
}
224+
}
225+
}
226+
227+
return result;
228+
}

0 commit comments

Comments
 (0)