diff --git a/AGENTS.md b/AGENTS.md index 17c9013..6cde4eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,11 +6,12 @@ TypeScript library for converting and validating **Forman Schema** (Make's inter ## Project map -- `src/index.ts` — public API: re-exports `toJSONSchema`, `toFormanSchema`, `validateForman`, `validateFormanWithDomains`, and all types +- `src/index.ts` — public API: re-exports `toJSONSchema`, `toFormanSchema`, `validateForman`, `validateFormanWithDomains`, `fieldEdges`, `activeFieldEdges`, and all types - `src/types.ts` — all type definitions - `src/forman.ts` — `toJSONSchema` conversion - `src/json.ts` — `toFormanSchema` conversion - `src/validator.ts` — validation engine +- `src/edges.ts` — `fieldEdges` / `activeFieldEdges`, the normalized reading of how a field reveals children - `src/utils.ts` — shared helpers - `src/composites/` — composite field type handlers (`udtspec.ts`, `udttype.ts`) used by forman, json, and validator - `test/` — tests (`*.spec.ts`, `directives/*.spec.ts`, `composites/*.spec.ts`); fixtures in `test/mocks/` @@ -30,6 +31,8 @@ TypeScript library for converting and validating **Forman Schema** (Make's inter **Forman Schema** is Make's proprietary form field format. A schema is an array of `FormanSchemaField` objects. Each field has a `type` (one of ~40 types), optional `spec` (sub-fields for `collection`/`array`), `options` (select options or `rpc://` URL), `nested` (fields revealed by the value of a select or boolean), and `validate`. +**Field edges** (`fieldEdges` / `activeFieldEdges` in `src/edges.ts`) are the normalized view of how a field reveals children, following the validator's reading: per-option `nested` (plain array, `store`, or inside an option group) matched on the key named by `options.value`, `options.placeholder.nested` (gated on `''`, non-required `select` only), the field's own `nested` or — only when absent — `options.nested`, the boolean `{ true, false }` form, and the `{ store, domain }` wrapper around any of them. Boolean-ness goes through `resolveFormanFieldType`, so aliases and casing resolve the same way as in the converter. `fieldEdges` is structural (every reachable edge); `activeFieldEdges` applies the validator's selection rules — a matching gated edge replaces the unconditional ones, an out-of-options value falls back to them, an empty value reveals only the placeholder edge, a boolean holding an IML value reveals its single-branch `nested` and nothing of the two-branch form. The converter and validator still read `options`/`nested` through their own helpers (`extractNestedAndDomain`, `extractNestedFromField`), and they disagree when a field declares both `options.nested` and `nested` (converter reads the former, validator the latter). `test/field-edges.spec.ts` pins `activeFieldEdges` to the validator's `resolvedSchemas` per selection rule and over the google-sheets mock; a new selection rule in the validator needs a row in that table. + **Domains** are named scopes (e.g. `default`, `additional`) used in multi-domain validation. Fields in one domain can reveal nested fields in another domain via `field.nested.domain`. The `x-domain-root` property on a `collection` field registers it as the anchor for cross-domain routing. @@ -37,7 +40,7 @@ TypeScript library for converting and validating **Forman Schema** (Make's inter **Public signatures:** - `toJSONSchema(field, options?)` returns a bare `JSONSchema7` (backward-compatible). Delegates to `toJSONSchemaAdvanced` and returns `.schema`. -- `toJSONSchemaAdvanced(field, options?)` returns `{ schema: JSONSchema7, skippedPaths?: { advanced?: string[] } }`. `skippedPaths` is omitted entirely when nothing was skipped. +- `toJSONSchemaAdvanced(field, options?)` returns `{ schema: JSONSchema7, skippedPaths?: { advanced?: string[]; unconvertible?: string[]; remoteFragments?: string[] } }`. `skippedPaths` is omitted entirely when nothing was skipped. **Forman types → JSON Schema types** mapping is in `src/forman.ts` at `FORMAN_TYPE_MAP`. Notable: `collection→object`, `array→array`, `filter→array`, `checkbox→boolean`, `hidden/any→undefined`. @@ -47,7 +50,11 @@ TypeScript library for converting and validating **Forman Schema** (Make's inter **Unresolvable types degrade, they do not throw** (default). `degradeUnconvertibleField` returns the permissive `any` shape and records the path on `context.skippedPaths.unconvertible`; `{ strictFieldTypes: true }` restores throwing. The old behaviour threw at the top of _every_ recursive call, so one bad leaf field aborted the entire schema — in production this made Maia conclude the user's module was broken and replace it. Note the validator has always been tolerant of unknown types (`if (expectedType && ...)` skips the check), so this aligns the two paths rather than loosening one. A field with **no** type is still a structured `validateForman` error — it used to crash with a raw `TypeError` from `normalizeFormanFieldType`. -Entry: `toJSONSchemaInternal(field, context)`. Dispatches by type to `handleCollectionType`, `handleArrayType`, `handleSelectOrPathType`, `handleFilterType`, `handlePrimitiveType`. `ConversionContext` carries `domain`, `path`, `tail`, `roots`, `addConditionalFields` callback (for select-with-nested → `allOf[if/then]` generation on parent collection), `excludeAdvancedFields` (default `false`), `strictFieldTypes` (default `false`), and `skippedPaths` (mutable accumulator, keyed by skip reason — `advanced` and `unconvertible` — shared across recursion via context spread). `SchemaConversionError` is also defined here. +Entry: `toJSONSchemaInternal(field, context)`. Dispatches by type to `handleCollectionType`, `handleArrayType`, `handleSelectOrPathType`, `handleFilterType`, `handlePrimitiveType`. `ConversionContext` carries `domain`, `path`, `tail`, `roots`, `addConditionalFields` callback (for select-with-nested → `allOf[if/then]` generation on parent collection), `excludeAdvancedFields` (default `false`), `strictFieldTypes` (default `false`), `excludeRemoteFragments` (default `false`), and `skippedPaths` (mutable accumulator, keyed by skip reason — `advanced`, `unconvertible` and `remoteFragments` — shared across recursion via context spread). `SchemaConversionError` is also defined here. + +**Remote fragment exclusion:** `excludeRemoteFragments` is applied by `withoutRemoteFragments(list, path, context)` at the three places a field _list_ is read — `handleCollectionType.addField` (a bare string in `spec`), `extractNestedAndDomain` (the field-level `nested` / `options.nested`, once, before the per-option fallback and `processNestedDirective` reuse it), and an option's own `nested` in `handleSelectOrPathType`. Paths come from `fieldPath` (the `collectionPath` rule: the field's name, or the enclosing path for a nameless field), so a fragment is reported at the field or collection declaring it — a cross-domain `nested` at its origin field, not the receiving root. A list left empty emits no `x-nested` marker or `then` branch. A whole-list remote (`nested: "rpc://…"`, string not array) is untouched: it becomes `x-nested: { $ref }` / `then: { $ref }`, a marker on the field rather than a fragment in a list. + +**Editor/multiline markers:** `handlePrimitiveType` stamps `x-editor: true` and `x-language` for `type: 'editor'` (the converter's `FORMAN_TYPE_MAP` has `editor: 'string'`; the validator's does not, so it skips the type check on editor values) and `x-multiline: true` for `multiline: true` on string-typed results only, since `toFormanSchema` reads both back in its string branch. **Advanced field tracking:** filter point is `handleCollectionType.addField`. Fields with `advanced: true` are **included by default** and stamped with `x-advanced: true` (enumerable, configurable, writable). When `excludeAdvancedFields: true` is passed, they're omitted from the schema and their paths accumulate in `context.skippedPaths.advanced`. Path segments are built via the `collectionPath` helper, which is `[...context.path, field.name]` when `field.name` is set, else `context.path` (this handles synthetic anonymous collection wrappers — array items, nested-by-option, RPC params, composite expansions — cleanly so `[]` array paths don't get a literal `"undefined"` segment). Composite types (`udtspec`, `udttype`) memoize via `context.definitions[type]`; advanced fields inside a composite are recorded once per `toJSONSchemaAdvanced` call, not per usage. @@ -68,7 +75,7 @@ Entry: `toJSONSchemaInternal(field, context)`. Dispatches by type to `handleColl -**`x-*` round-trip markers** — Forman-specific metadata that JSON Schema doesn't have a native slot for is attached via `Object.defineProperty` on JSON Schema output objects: `x-filter`, `x-path`, `x-fetch`, `x-nested`, `x-search`, `x-advanced`, `x-composite`, `x-filestorage`, `x-json`. Most are declared `enumerable: true` (so they DO appear in `JSON.stringify` output and are part of the serialized schema); `x-filestorage` is the exception at `enumerable: false`. `defineProperty` is used (rather than plain assignment) to keep these out of the structural TypeScript shape of `JSONSchema7` and to keep them isolated from spec-compliant property handling. `toFormanSchema` reads them back via `Object.getOwnPropertyDescriptor`. For `x-advanced`, recovery happens in the top-level `toFormanSchema` wrapper (after delegating to `toFormanSchemaInternal`) so all branches — including composite short-circuits — inherit it uniformly. +**`x-*` round-trip markers** — Forman-specific metadata that JSON Schema doesn't have a native slot for is attached via `Object.defineProperty` on JSON Schema output objects: `x-filter`, `x-path`, `x-fetch`, `x-nested`, `x-search`, `x-advanced`, `x-composite`, `x-filestorage`, `x-json`, `x-editor`, `x-language`, `x-multiline`. Most are declared `enumerable: true` (so they DO appear in `JSON.stringify` output and are part of the serialized schema); `x-filestorage` is the exception at `enumerable: false`. `defineProperty` is used (rather than plain assignment) to keep these out of the structural TypeScript shape of `JSONSchema7` and to keep them isolated from spec-compliant property handling. `toFormanSchema` reads them back via `Object.getOwnPropertyDescriptor`. For `x-advanced`, recovery happens in the top-level `toFormanSchema` wrapper (after delegating to `toFormanSchemaInternal`) so all branches — including composite short-circuits — inherit it uniformly. **`json` type** — a `json` field carrying an explicit `schema` (a `JSONSchema7`) echoes that schema verbatim (`handleJsonType` in `forman.ts`), letting complex schema parts be authored directly in JSON Schema and mixed with primitive Forman fields. The schema is shallow-cloned (caller input never mutated); `label`/`help` fill `title`/`description` only when the echoed schema omits them. An enumerable `x-json: true` marker (stripped from the recovered `schema`) lets `toFormanSchemaInternal` short-circuit back to `{ type: 'json', schema }`, and survives JSON serialization. A `json` field **without** a `schema` returns the pre-built `result` as-is — `{ type: 'object' }` (the `FORMAN_TYPE_MAP` entry) plus any `title`/`description` — so it round-trips to `dynamicCollection` (empty object → dynamic collection). diff --git a/README.md b/README.md index 8602f98..39de9a6 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,21 @@ Conversion and validation utilities for Forman Schema. +## v2.1.0 — field edges, editor markers, remote fragment exclusion + +Non-breaking minor release: new exports, new markers and a new opt-in conversion option; nothing +existing is renamed or changes behaviour. + +- New `fieldEdges(field)` and `activeFieldEdges(field, value)` expose every way a field reveals child + fields as one normalized list of edges — see [Reading child fields](#reading-child-fields). +- `editor` fields are stamped with `x-editor: true` (plus `x-language`), multiline text with + `x-multiline: true`; both round-trip through `toFormanSchema`. +- New conversion option `excludeRemoteFragments` drops remote form fragments (bare strings in field + lists) and reports them on `skippedPaths.remoteFragments` — see + [Remote form fragments](#remote-form-fragments). +- `FormanSchemaExtendedOptions.store` is optional, matching schemas whose `options` wrapper carries + only `nested`, and accepts a partially grouped store. + ## v2.0.1 (patch): inactive branches stay out of `schemas` The fields nested under a boolean toggle that is `false` (or absent and filled to `false` by @@ -76,6 +91,73 @@ const { schema, skippedPaths } = toJSONSchemaAdvanced(formanField, { excludeAdva The filter applies to **sub-fields of a collection** — including nested-by-option fields, array-of-collection items, composite expansions (`udtspec`, `udttype`), and cross-domain buffered fields. It does **not** apply to: the top-level field passed in (always converted), or the item type of an array whose `spec` is a single primitive field. To hide an entire array or any other top-level structure, mark the _parent_ field as `advanced: true`. +### Remote form fragments + +A field list may hold a bare string next to its fields — a form fragment fetched live, such as a banner +or a record schema behind `rpc://…`, or a platform form behind `api://…`. By default it converts to an +`allOf: [{ $ref: "rpc://…" }]` entry on the enclosing object. Pass `{ excludeRemoteFragments: true }` to +drop every such string instead; `toJSONSchemaAdvanced` reports each dropped fragment once, as the dot +path of the field or collection declaring the list plus the reference: + +```typescript +const { schema, skippedPaths } = toJSONSchemaAdvanced( + { name: 'wrapper', type: 'collection', spec: ['rpc://banner', { name: 'a', type: 'text' }] }, + { excludeRemoteFragments: true }, +); +// schema.allOf → undefined +// skippedPaths → { remoteFragments: ['wrapper (rpc://banner)'] } +``` + +A list left empty by the exclusion emits no `x-nested` marker and no `allOf` branch. A field whose +_whole_ child list is remote (`nested: "rpc://…"`) is unaffected — that stays an `x-nested: { $ref }` +marker on the field, since it is not a fragment inside a list. + +### Editor and multiline markers + +`type: 'editor'` converts to a string schema stamped with `x-editor: true` and, when the field declares +a `language`, `x-language: ''`. A string-typed field (`text`, `editor`, …) with +`multiline: true` is stamped with `x-multiline: true`. Both are enumerable, so they survive +serialization, and `toFormanSchema` reads them back into `type: 'editor'`/`language` and +`multiline: true`. + +### Reading child fields + +A Forman field can reveal children in several spellings: per-option `nested` (in a plain `options` +array, an `options.store`, or an option group), `options.placeholder.nested`, `options.nested`, the +field's own `nested`, the boolean `{ true, false }` form, and the `{ store, domain }` wrapper around any +of them. `fieldEdges(field)` normalizes all of these into one list of edges, so a consumer walking a +form never reads `options`/`nested` directly: + +```typescript +import { fieldEdges, activeFieldEdges } from '@makehq/forman-schema'; + +fieldEdges({ + name: 'mode', + type: 'select', + options: { + store: [{ value: 'a', nested: [{ name: 'onA', type: 'text' }] }, { value: 'b' }], + nested: { domain: 'expect', store: [{ name: 'always', type: 'text' }] }, + }, +}); +// [ +// { gate: { name: 'mode', value: 'a' }, children: [{ name: 'onA', type: 'text' }] }, +// { domain: 'expect', children: [{ name: 'always', type: 'text' }] }, +// ] +``` + +An edge carries `gate` when the children depend on the parent's value, `domain` when they belong to +another domain, and either `children` (a static list, bare `rpc://` strings kept verbatim) or `remote` +(the whole list is fetched live). The list follows the validator's reading of the schema: an option is +matched on the key named by `options.value` (default `value`); the field's own `nested` shadows +`options.nested` when both are declared; `placeholder.nested` counts only on a non-required `select`; a +boolean's `nested` is an edge gated on `true`, or on `false` under `reversedNested`. + +`activeFieldEdges(field, value)` returns the edges a given value reveals, with the validator's rules: a +matching gated edge replaces the unconditional ones, a value outside the static options falls back to +them, and an empty value (`undefined`, `null`, `''`) reveals only the placeholder edge. A boolean +holding an IML expression reveals its single-branch `nested` whatever the toggle, and nothing of the +`{ true, false }` form. + ### Converting from JSON Schema to Forman Schema ```typescript diff --git a/package-lock.json b/package-lock.json index 51327a7..357c938 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@makehq/forman-schema", - "version": "2.0.1", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@makehq/forman-schema", - "version": "2.0.1", + "version": "2.1.0", "license": "MIT", "devDependencies": { "@jest/globals": "^29.7.0", diff --git a/package.json b/package.json index 9fa2cdc..ecebe2e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@makehq/forman-schema", - "version": "2.0.1", + "version": "2.1.0", "description": "Forman Schema Tools", "license": "MIT", "author": "Make", diff --git a/src/edges.ts b/src/edges.ts new file mode 100644 index 0000000..27c0a4e --- /dev/null +++ b/src/edges.ts @@ -0,0 +1,120 @@ +import type { + FormanFieldEdge, + FormanSchemaBooleanNested, + FormanSchemaExtendedNested, + FormanSchemaExtendedOptions, + FormanSchemaField, + FormanSchemaNested, + FormanSchemaOption, + FormanSchemaSelectOptionsStore, + FormanSchemaValue, +} from './types'; +import { resolveFormanFieldType } from './forman'; +import { isBooleanBranchNested, isObject, isOptionGroup, valuesMatch } from './utils'; + +function isBooleanField(field: FormanSchemaField): boolean { + const type = resolveFormanFieldType(field.type); + return type === 'boolean' || type === 'checkbox'; +} + +/** The nested definition the validator reads: the field's own `nested`, else `options.nested`. */ +function fieldNested( + field: FormanSchemaField, +): FormanSchemaNested | FormanSchemaExtendedNested | FormanSchemaBooleanNested | undefined { + if (field.nested != null) return field.nested; + return isObject(field.options) ? field.options.nested : undefined; +} + +function optionValue(field: FormanSchemaField, option: FormanSchemaOption): FormanSchemaValue { + const key = + isObject(field.options) && field.options.value ? field.options.value : 'value'; + return (option as Record)[key] as FormanSchemaValue; +} + +/** One `nested` definition as edge parts; `undefined` when absent or in the boolean `{ true, false }` form, which the caller splits per branch. */ +function nestedEdge( + nested: FormanSchemaNested | FormanSchemaBooleanNested | undefined, +): Pick | undefined { + if (nested == null || isBooleanBranchNested(nested)) return undefined; + + const store = isObject(nested) ? nested.store : nested; + const domain = isObject(nested) ? nested.domain : undefined; + const edge = typeof store === 'string' ? { remote: store } : Array.isArray(store) ? { children: store } : undefined; + + return edge && domain ? { ...edge, domain } : edge; +} + +/** + * Every way `field` can reveal child fields, as {@link FormanFieldEdge}s in declaration order: per-option + * `nested`, `options.placeholder.nested` (gated on `''`, non-required selects only), then the field's own + * `nested` or, failing that, `options.nested` (gated on the toggle value for booleans). Structural, so an + * unconditional edge is listed alongside conditional ones; {@link activeFieldEdges} decides which a value + * reveals. A nameless field cannot gate, so its option children are listed unconditionally. + */ +export function fieldEdges(field: FormanSchemaField): FormanFieldEdge[] { + const edges: FormanFieldEdge[] = []; + const gateOn = (value: FormanSchemaValue): Pick => + field.name ? { gate: { name: field.name, value } } : {}; + + const options = field.options; + const store = isObject(options) ? options.store : options; + if (Array.isArray(store)) { + for (const entry of store as FormanSchemaSelectOptionsStore) { + for (const option of isOptionGroup(entry) ? entry.options : [entry]) { + const edge = nestedEdge(option.nested); + if (edge) edges.push({ ...gateOn(optionValue(field, option)), ...edge }); + } + } + } + + if (field.type === 'select' && !field.required && isObject(options)) { + const placeholder = options.placeholder; + const placeholderEdge = isObject<{ nested?: FormanSchemaNested }>(placeholder) + ? nestedEdge(placeholder.nested) + : undefined; + if (placeholderEdge) edges.push({ ...gateOn(''), ...placeholderEdge }); + } + + const nested = fieldNested(field); + if (nested == null) return edges; + + if (isBooleanBranchNested(nested)) { + for (const branch of [true, false] as const) { + const edge = nestedEdge(nested[`${branch}`]); + if (edge) edges.push({ ...gateOn(branch), ...edge }); + } + return edges; + } + + const ownEdge = nestedEdge(nested); + if (!ownEdge) return edges; + + if (isBooleanField(field)) { + edges.push({ ...gateOn(field.reversedNested !== true), ...ownEdge }); + } else { + edges.push(ownEdge); + } + + return edges; +} + +/** + * The edges `value` reveals, by the validator's rules: a matching gated edge replaces the unconditional + * ones, an out-of-options value (custom, IML) falls back to them, and an empty value reveals only the + * placeholder edge. A boolean holding a non-boolean value (IML) reveals its single-branch nested + * regardless of the toggle, and nothing of the two-branch form. + */ +export function activeFieldEdges(field: FormanSchemaField, value: unknown): FormanFieldEdge[] { + const edges = fieldEdges(field); + + if (value == null || value === '') return edges.filter(edge => edge.gate?.value === ''); + + const gated = edges.filter(edge => edge.gate && valuesMatch(edge.gate.value, value)); + if (gated.length > 0) return gated; + + if (isBooleanField(field) && typeof value !== 'boolean') { + return isBooleanBranchNested(fieldNested(field)) ? [] : edges.filter(edge => edge.gate); + } + + return edges.filter(edge => !edge.gate); +} diff --git a/src/forman.ts b/src/forman.ts index ebc16c1..2249bfd 100644 --- a/src/forman.ts +++ b/src/forman.ts @@ -49,8 +49,10 @@ export interface ConversionContext { definitions?: Record; /** Whether to exclude fields with `advanced: true` from the rendered schema. */ excludeAdvancedFields: boolean; + /** Drop remote form fragments (bare strings in field lists) instead of emitting `$ref` entries. */ + excludeRemoteFragments: boolean; /** Accumulator for paths of skipped fields, keyed by skip reason. Shared (mutated) across recursion. */ - skippedPaths: { advanced?: string[]; unconvertible?: string[] }; + skippedPaths: { advanced?: string[]; unconvertible?: string[]; remoteFragments?: string[] }; /** Throw on unresolvable field types instead of degrading them to a permissive schema. */ strictFieldTypes: boolean; } @@ -239,6 +241,7 @@ export function createDefaultContext(options?: FormanJsonSchemaOptions): Convers roots: {}, definitions: {}, excludeAdvancedFields: options?.excludeAdvancedFields ?? false, + excludeRemoteFragments: options?.excludeRemoteFragments ?? false, strictFieldTypes: options?.strictFieldTypes ?? false, skippedPaths: {}, addConditionalFields: () => { @@ -396,10 +399,12 @@ function handleCollectionType(field: FormanSchemaField, result: JSONSchema7, con // Synthetic anonymous collections (array items, nested-by-option wrappers, RPC param wrappers) // have no name; in that case the collection contributes no path segment. - const collectionPath = field.name ? [...context.path, field.name] : context.path; + const collectionPath = fieldPath(field, context); function addField(subField: FormanSchemaField | string, tail?: string[]) { if (typeof subField === 'string') { + if (withoutRemoteFragments([subField], collectionPath, context).length === 0) return; + const value = { $ref: appendQueryString(subField, context.domain, tail || context.tail) }; result.allOf ||= []; @@ -675,7 +680,7 @@ function handleSelectOrPathType( }); } - const { nested, domain } = extractNestedAndDomain(field); + const { nested, domain } = extractNestedAndDomain(field, context); if (typeof optionsOrGroups === 'string') { Object.defineProperty(result, 'x-fetch', { @@ -746,9 +751,12 @@ function handleSelectOrPathType( }) ) { result.oneOf = (options || []).map(option => { - const localNested = - (isObject(option.nested) ? option.nested.store : option.nested) || - nested; + const ownNested = isObject(option.nested) + ? option.nested.store + : option.nested; + const localNested = Array.isArray(ownNested) + ? withoutRemoteFragments(ownNested, fieldPath(field, context), context) + : ownNested || nested; const localNestedContainsStrings = Array.isArray(localNested) && localNested.some(item => typeof item === 'string'); @@ -758,7 +766,7 @@ function handleSelectOrPathType( ? option.nested.domain : domain) || context.domain; - if (localNested) { + if (localNested && localNested.length > 0) { context.addConditionalFields( field.name!, option.value, @@ -879,7 +887,7 @@ function handleJsonType(field: FormanSchemaField, result: JSONSchema7): JSONSche * @returns A permissive JSON Schema preserving the field's label and help */ function degradeUnconvertibleField(field: FormanSchemaField, context: ConversionContext): JSONSchema7 { - const path = field.name ? [...context.path, field.name].join('.') : context.path.join('.'); + const path = fieldPath(field, context).join('.'); const reason = field.type ? `unknown type: ${field.type}` : 'missing type'; (context.skippedPaths.unconvertible ||= []).push(`${path} (${reason})`); @@ -922,14 +930,60 @@ function handlePrimitiveType(field: FormanSchemaField, result: JSONSchema7, cont if (field.rpc) result = processRpcDirective(field, result, context); + if (field.type === 'editor') { + Object.defineProperty(result, 'x-editor', { + configurable: true, + enumerable: true, + writable: true, + value: true, + }); + if (typeof field.language === 'string' && field.language) { + Object.defineProperty(result, 'x-language', { + configurable: true, + enumerable: true, + writable: true, + value: field.language, + }); + } + } + + if (field.multiline === true && result.type === 'string') { + Object.defineProperty(result, 'x-multiline', { + configurable: true, + enumerable: true, + writable: true, + value: true, + }); + } + if (field.nested) { - const { nested, domain } = extractNestedAndDomain(field); + const { nested, domain } = extractNestedAndDomain(field, context); result = handleNestedWithDomain(field, nested, domain, result, context); } return result; } +/** The dot path of `field`, or of its enclosing collection when the field is nameless (an array item). */ +function fieldPath(field: FormanSchemaField, context: ConversionContext): string[] { + return field.name ? [...context.path, field.name] : context.path; +} + +/** Under `excludeRemoteFragments`, removes the string entries of a field list and records each on `skippedPaths.remoteFragments`. */ +function withoutRemoteFragments( + list: (FormanSchemaField | string)[], + path: string[], + context: ConversionContext, +): (FormanSchemaField | string)[] { + if (!context.excludeRemoteFragments) return list; + + return list.filter(item => { + if (typeof item !== 'string') return true; + (context.skippedPaths.remoteFragments ||= []).push(`${path.join('.')} (${item})`); + return false; + }); +} + /** * Processes the RPC directive for a field * @param field The field with the RPC directive @@ -958,15 +1012,20 @@ function processRpcDirective(field: FormanSchemaField, result: JSONSchema7, cont } /** - * Extracts nested fields and domain from a field, unwrapping extended nested format + * Extracts nested fields and domain from a field, unwrapping extended nested format and applying + * `excludeRemoteFragments` to the list * @param field The field to extract from + * @param context The context for the conversion * @returns Object containing nested fields and optional domain */ -function extractNestedAndDomain(field: FormanSchemaField): { +function extractNestedAndDomain( + field: FormanSchemaField, + context: ConversionContext, +): { nested: (FormanSchemaField | string)[] | string | undefined; domain: string | undefined; } { - const nested = isObject(field.options) + const declared = isObject(field.options) ? isObject(field.options.nested) ? field.options.nested.store : field.options.nested @@ -984,6 +1043,10 @@ function extractNestedAndDomain(field: FormanSchemaField): { ? field.nested.domain : undefined; + const nested = Array.isArray(declared) + ? withoutRemoteFragments(declared, fieldPath(field, context), context) + : declared; + return { nested, domain }; } @@ -1044,7 +1107,7 @@ function processNestedDirective( result: JSONSchema7, context: ConversionContext, ): JSONSchema7 { - if (!nested) return result; + if (!nested || nested.length === 0) return result; const nestedContainsStrings = Array.isArray(nested) && nested.some(item => typeof item === 'string'); diff --git a/src/index.ts b/src/index.ts index 05b1814..a07eb2c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import type { import { validateFormanWithDomainsInternal } from './validator'; export type { + FormanFieldEdge, FormanSchemaFieldType, FormanSchemaField, FormanSchemaValue, @@ -31,6 +32,7 @@ export type { } from './types'; export { toFormanSchema } from './json'; export { SchemaConversionError, resolveFormanFieldType } from './forman'; +export { fieldEdges, activeFieldEdges } from './edges'; /** * Converts a Forman Schema field to its JSON Schema equivalent and reports the paths of any @@ -85,6 +87,9 @@ export function toJSONSchemaAdvanced( if (context.skippedPaths.unconvertible?.length) { skippedPaths.unconvertible = context.skippedPaths.unconvertible; } + if (context.skippedPaths.remoteFragments?.length) { + skippedPaths.remoteFragments = context.skippedPaths.remoteFragments; + } return { schema, diff --git a/src/json.ts b/src/json.ts index d99483c..a5b0c59 100644 --- a/src/json.ts +++ b/src/json.ts @@ -173,6 +173,15 @@ function toFormanSchemaInternal(field: JSONSchema7): FormanSchemaField { default: field.default as FormanSchemaValue, }; + if (Object.getOwnPropertyDescriptor(field, 'x-editor')?.value === true) { + textField.type = 'editor'; + const language = Object.getOwnPropertyDescriptor(field, 'x-language')?.value; + if (typeof language === 'string') textField.language = language; + } + if (Object.getOwnPropertyDescriptor(field, 'x-multiline')?.value === true) { + textField.multiline = true; + } + // Add validation if present if (field.pattern || field.enum) { textField.validate = textField.validate || {}; diff --git a/src/types.ts b/src/types.ts index c4395ce..3b79b00 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,6 +43,7 @@ export type FormanSchemaFieldType = | 'timezone' | 'url' | 'uuid' + | 'editor' | `account:${string}` | `hook:${string}` | `keychain:${string}` @@ -112,6 +113,8 @@ export type FormanSchemaField = { editable?: boolean; /** Whether the user will be able to insert new lines in GUI (a textarea will be displayed instead of the text field) */ multiline?: boolean; + /** Syntax highlighting language of the code editor (`editor` only), e.g. `javascript`, `python` */ + language?: string; /** Whether the select field allows multiple values */ multiple?: boolean; /** Specifies how to treat HTML tags in the field (text only) */ @@ -218,8 +221,8 @@ export type FormanSchemaSelectOptionsStore = (FormanSchemaOption | FormanSchemaO * Extended options for a select field */ export type FormanSchemaExtendedOptions = { - /** Store for the options */ - store: FormanSchemaOption[] | FormanSchemaOptionGroup[] | string; + /** Store for the options; absent when the wrapper only carries `nested` */ + store?: FormanSchemaSelectOptionsStore | string; /** Nested fields for every option */ nested?: FormanSchemaNested; /** Name of the property as the label of an option */ @@ -257,6 +260,18 @@ export type FormanSchemaBooleanNested = { false?: (FormanSchemaField | string)[] | string; }; +/** One way a field reveals child fields, whichever spelling the schema used — see {@link fieldEdges}. */ +export type FormanFieldEdge = { + /** The parent's value that reveals these children; absent when they apply for any value. */ + gate?: { name: string; value: FormanSchemaValue }; + /** Domain the children belong to when it differs from the parent's (`nested.domain`). */ + domain?: string; + /** Static children, remote fragment strings kept verbatim. */ + children?: (FormanSchemaField | string)[]; + /** The whole child list is fetched remotely. */ + remote?: string; +}; + /** * Validation result */ @@ -343,6 +358,14 @@ export type FormanJsonSchemaOptions = { * `toJSONSchemaAdvanced`'s `skippedPaths.advanced` so the caller can re-request them. */ excludeAdvancedFields?: boolean; + /** + * Drop remote form fragments — bare strings in a field list (`"rpc://…"`, `"api://…"`), fetched at + * render time — instead of emitting an unresolvable `allOf: [{ $ref }]`. Defaults to `false`. + * Dropped fragments are reported on `skippedPaths.remoteFragments`, and a list left empty emits no + * `x-nested` marker or branch. A whole-list remote (`nested: "rpc://…"`) stays an + * `x-nested: { $ref }` marker on its field. + */ + excludeRemoteFragments?: boolean; /** * Throw a `SchemaConversionError` when a field's type cannot be resolved, instead of degrading * it to a permissive typeless schema. Defaults to `false` — by default, unresolvable fields are @@ -369,6 +392,12 @@ export type FormanJsonSchemaResult = { * `(unknown type: X)` or `(missing type)`. Present only when at least one field was degraded. */ unconvertible?: string[]; + /** + * Remote form fragments dropped under `excludeRemoteFragments`, as the dot-notation path of the + * field or collection declaring the list, suffixed with the reference — `wrapper (rpc://banner)`. + * Present only when at least one fragment was dropped. + */ + remoteFragments?: string[]; }; }; diff --git a/src/utils.ts b/src/utils.ts index f08c1d8..e8653cf 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -9,6 +9,7 @@ import { FormanSchemaOption, FormanSchemaOptionGroup, FormanSchemaSelectOptionsStore, + FormanSchemaValue, } from './types'; /** @@ -156,7 +157,7 @@ export function normalizeFormanFieldType(field: FormanSchemaField): FormanSchema }; } -function valuesMatch(a: unknown, b: unknown): boolean { +export function valuesMatch(a: unknown, b: unknown): boolean { if (a === b) return true; // If this ever turns out insufficient, move to object-hash, but given the fact Forman sets the Form Value based on the RPC, which is the data source for the BE validation as well, the stringification should be sufficient. if (isObject(a) && isObject(b)) return JSON.stringify(a) === JSON.stringify(b); diff --git a/test/composites/__snapshots__/udtspec.spec.ts.snap b/test/composites/__snapshots__/udtspec.spec.ts.snap index e97ab6d..36ea652 100644 --- a/test/composites/__snapshots__/udtspec.spec.ts.snap +++ b/test/composites/__snapshots__/udtspec.spec.ts.snap @@ -249,6 +249,7 @@ exports[`udtspec composite Validation should convert schema with multiple udtspe "description": undefined, "title": "Description", "type": "string", + "x-multiline": true, }, "label": { "description": "Display name for better readability.", diff --git a/test/composites/__snapshots__/udttype.spec.ts.snap b/test/composites/__snapshots__/udttype.spec.ts.snap index 67777c3..b3b5bf9 100644 --- a/test/composites/__snapshots__/udttype.spec.ts.snap +++ b/test/composites/__snapshots__/udttype.spec.ts.snap @@ -249,6 +249,7 @@ exports[`udttype composite Forman -> JSON Schema should convert schema with udtt "description": undefined, "title": "Description", "type": "string", + "x-multiline": true, }, "label": { "description": "Display name for better readability.", diff --git a/test/editor-markers.spec.ts b/test/editor-markers.spec.ts new file mode 100644 index 0000000..11df27a --- /dev/null +++ b/test/editor-markers.spec.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from '@jest/globals'; +import type { JSONSchema7 } from 'json-schema'; +import type { FormanSchemaField } from '../src/index.js'; +import { toFormanSchema, toJSONSchema } from '../src/index.js'; + +describe('editor and multiline markers', () => { + it('stamps x-editor and x-language on an editor field', () => { + const schema = toJSONSchema({ name: 'code', type: 'editor', language: 'javascript', label: 'Code' }); + + expect(schema).toMatchObject({ type: 'string', title: 'Code' }); + expect(Object.getOwnPropertyDescriptor(schema, 'x-editor')?.value).toBe(true); + expect(Object.getOwnPropertyDescriptor(schema, 'x-language')?.value).toBe('javascript'); + }); + + it('stamps x-editor without x-language when the editor declares no language', () => { + const schema = toJSONSchema({ name: 'code', type: 'editor' }); + + expect(Object.getOwnPropertyDescriptor(schema, 'x-editor')?.value).toBe(true); + expect(Object.getOwnPropertyDescriptor(schema, 'x-language')).toBeUndefined(); + }); + + it('stamps x-multiline on a multiline string-typed field and on nothing else', () => { + const multiline = toJSONSchema({ name: 'body', type: 'text', multiline: true }); + const single = toJSONSchema({ name: 'subject', type: 'text' }); + const number = toJSONSchema({ name: 'n', type: 'number', multiline: true }); + + expect(Object.getOwnPropertyDescriptor(multiline, 'x-multiline')?.value).toBe(true); + expect(Object.getOwnPropertyDescriptor(single, 'x-multiline')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(single, 'x-editor')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(number, 'x-multiline')).toBeUndefined(); + expect(toFormanSchema(number)).toEqual({ type: 'number' }); + }); + + it('serializes the markers, like every other x-* marker', () => { + const schema = toJSONSchema({ name: 'code', type: 'editor', language: 'python', multiline: true }); + + expect(JSON.parse(JSON.stringify(schema))).toEqual({ + type: 'string', + 'x-editor': true, + 'x-language': 'python', + 'x-multiline': true, + }); + }); + + it('round-trips through toFormanSchema', () => { + const editor = toFormanSchema(toJSONSchema({ name: 'code', type: 'editor', language: 'javascript' })); + const multiline = toFormanSchema(toJSONSchema({ name: 'body', type: 'text', multiline: true })); + const plain = toFormanSchema(toJSONSchema({ name: 'subject', type: 'text' })); + + expect(editor).toMatchObject({ type: 'editor', language: 'javascript' }); + expect(multiline).toMatchObject({ type: 'text', multiline: true }); + expect(plain.type).toBe('text'); + expect(plain).not.toHaveProperty('multiline'); + expect(plain).not.toHaveProperty('language'); + }); + + it('lands the markers on branch-nested fields, under the if/then the branch produces', () => { + // The shape of Make's `code:ExecuteCode` module: the editor and the plain string variant sit two + // branches deep, under `language` and then `inputFormat`. + const inputFormat = (suffix: string): FormanSchemaField => ({ + name: 'inputFormat', + type: 'select', + required: true, + options: { + store: [ + { + value: 'editor', + nested: [{ name: `codeEditor${suffix}`, type: 'editor', language: suffix.toLowerCase() }], + }, + { value: 'string', nested: [{ name: `codeString${suffix}`, type: 'text', multiline: true }] }, + ], + }, + }); + const schema = toJSONSchema({ + type: 'collection', + spec: [ + { + name: 'language', + type: 'select', + required: true, + options: { + store: [ + { value: 'javascript', nested: [inputFormat('Javascript')] }, + { value: 'python', nested: [inputFormat('Python')] }, + ], + }, + }, + ], + }); + + const located = new Map(); + (function walk(node: JSONSchema7) { + for (const [name, property] of Object.entries(node.properties ?? {})) { + if (typeof property === 'object') located.set(name, property); + } + for (const entry of node.allOf ?? []) { + if (typeof entry === 'object' && typeof entry.then === 'object') walk(entry.then); + } + })(schema); + + expect(schema.properties).toEqual({ language: expect.anything() }); + expect(JSON.parse(JSON.stringify(located.get('codeEditorJavascript')))).toEqual({ + type: 'string', + 'x-editor': true, + 'x-language': 'javascript', + }); + expect(JSON.parse(JSON.stringify(located.get('codeEditorPython')))).toEqual({ + type: 'string', + 'x-editor': true, + 'x-language': 'python', + }); + expect(JSON.parse(JSON.stringify(located.get('codeStringJavascript')))).toEqual({ + type: 'string', + 'x-multiline': true, + }); + expect(JSON.parse(JSON.stringify(located.get('codeStringPython')))).toEqual({ + type: 'string', + 'x-multiline': true, + }); + }); +}); diff --git a/test/field-edges.spec.ts b/test/field-edges.spec.ts new file mode 100644 index 0000000..05a6044 --- /dev/null +++ b/test/field-edges.spec.ts @@ -0,0 +1,463 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from '@jest/globals'; +import type { JSONSchema7 } from 'json-schema'; +import type { FormanSchemaField } from '../src/index.js'; +import { activeFieldEdges, fieldEdges, toJSONSchema, validateFormanWithDomains } from '../src/index.js'; + +const child = (name: string): FormanSchemaField => ({ name, type: 'text' }); + +describe('fieldEdges', () => { + describe('conditional edges', () => { + it('yields one gated edge per option carrying its own nested, in a plain options array', () => { + const field: FormanSchemaField = { + name: 'mode', + type: 'select', + options: [ + { value: 'a', nested: [child('onA')] }, + { value: 'b' }, + { value: 'c', nested: [child('onC')] }, + ], + }; + + expect(fieldEdges(field)).toEqual([ + { gate: { name: 'mode', value: 'a' }, children: [child('onA')] }, + { gate: { name: 'mode', value: 'c' }, children: [child('onC')] }, + ]); + }); + + it('reads the extended store, unwrapping option groups', () => { + const field: FormanSchemaField = { + name: 'mode', + type: 'select', + options: { + store: [ + { label: 'Group', options: [{ value: 'g1', nested: [child('onG1')] }] }, + { value: 'top', nested: [child('onTop')] }, + ], + }, + }; + + expect(fieldEdges(field)).toEqual([ + { gate: { name: 'mode', value: 'g1' }, children: [child('onG1')] }, + { gate: { name: 'mode', value: 'top' }, children: [child('onTop')] }, + ]); + }); + + it('carries the domain of a per-option { store, domain } nested', () => { + const field: FormanSchemaField = { + name: 'mode', + type: 'select', + options: { store: [{ value: 'mappable', nested: { domain: 'expect', store: [child('template')] } }] }, + }; + + expect(fieldEdges(field)).toEqual([ + { gate: { name: 'mode', value: 'mappable' }, domain: 'expect', children: [child('template')] }, + ]); + }); + + it('yields a remote edge for an option whose nested is an rpc reference', () => { + const field: FormanSchemaField = { + name: 'mode', + type: 'select', + options: [{ value: 'dynamic', nested: 'rpc://form' }], + }; + + expect(fieldEdges(field)).toEqual([{ gate: { name: 'mode', value: 'dynamic' }, remote: 'rpc://form' }]); + }); + + it('gates each option on the key named by options.value', () => { + const field: FormanSchemaField = { + name: 'mode', + type: 'select', + options: { store: [{ id: 'a', nested: [child('onA')] } as never], value: 'id' }, + }; + + expect(fieldEdges(field)).toEqual([{ gate: { name: 'mode', value: 'a' }, children: [child('onA')] }]); + }); + + it('gates the placeholder nested on the empty selection of a non-required select only', () => { + const placeholder = { label: 'None', nested: [child('whenEmpty')] }; + + expect( + fieldEdges({ name: 'mode', type: 'select', options: { store: [{ value: 'x' }], placeholder } }), + ).toEqual([{ gate: { name: 'mode', value: '' }, children: [child('whenEmpty')] }]); + expect( + fieldEdges({ + name: 'mode', + type: 'select', + required: true, + options: { store: [{ value: 'x' }], placeholder }, + }), + ).toEqual([]); + expect(fieldEdges({ name: 'mode', type: 'text', options: { placeholder } })).toEqual([]); + }); + + it("gates a boolean field's own nested on true, or on false under reversedNested", () => { + const nested = [child('whenOn')]; + + expect(fieldEdges({ name: 'flag', type: 'boolean', nested })).toEqual([ + { gate: { name: 'flag', value: true }, children: nested }, + ]); + expect(fieldEdges({ name: 'flag', type: 'boolean', reversedNested: true, nested })).toEqual([ + { gate: { name: 'flag', value: false }, children: nested }, + ]); + expect(fieldEdges({ name: 'flag', type: 'checkbox', nested })).toEqual([ + { gate: { name: 'flag', value: true }, children: nested }, + ]); + }); + + it('resolves the boolean type through the shared alias and casing rules', () => { + const nested = [child('whenOn')]; + + for (const type of ['bool', 'Boolean', 'CHECKBOX'] as FormanSchemaField['type'][]) { + expect(fieldEdges({ name: 'flag', type, nested })).toEqual([ + { gate: { name: 'flag', value: true }, children: nested }, + ]); + } + }); + + it('yields one edge per branch of the boolean { true, false } form', () => { + const field: FormanSchemaField = { + name: 'flag', + type: 'boolean', + nested: { true: [child('whenOn')], false: 'rpc://offForm' }, + }; + + expect(fieldEdges(field)).toEqual([ + { gate: { name: 'flag', value: true }, children: [child('whenOn')] }, + { gate: { name: 'flag', value: false }, remote: 'rpc://offForm' }, + ]); + }); + }); + + describe('unconditional edges', () => { + it('reads options.nested in the plain and { store, domain } forms', () => { + expect( + fieldEdges({ name: 'conn', type: 'account', options: { store: 'rpc://c', nested: [child('a')] } }), + ).toEqual([{ children: [child('a')] }]); + + expect( + fieldEdges({ + name: 'conn', + type: 'account', + options: { nested: { domain: 'expect', store: [child('a')] } }, + }), + ).toEqual([{ domain: 'expect', children: [child('a')] }]); + }); + + it('yields a remote edge for options.nested written as an rpc reference, plain or in a store', () => { + expect(fieldEdges({ name: 'x', type: 'select', options: { nested: 'rpc://form' } })).toEqual([ + { remote: 'rpc://form' }, + ]); + + expect( + fieldEdges({ + name: 'type', + type: 'udt', + options: { nested: { store: 'rpc://udt', domain: 'expect' } }, + }), + ).toEqual([{ remote: 'rpc://udt', domain: 'expect' }]); + }); + + it("reads a non-boolean field's own nested as unconditional", () => { + expect(fieldEdges({ name: 'id', type: 'text', nested: [child('sub')] })).toEqual([ + { children: [child('sub')] }, + ]); + expect( + fieldEdges({ name: 'id', type: 'text', nested: { store: [child('sub')], domain: 'expect' } }), + ).toEqual([{ domain: 'expect', children: [child('sub')] }]); + }); + + it('keeps bare rpc strings inside a child list verbatim', () => { + expect( + fieldEdges({ name: 'x', type: 'select', options: { nested: ['rpc://banner', child('a')] } }), + ).toEqual([{ children: ['rpc://banner', child('a')] }]); + }); + + it("reads the field's own nested over options.nested when a field declares both, as the validator does", () => { + const field: FormanSchemaField = { + name: 'agent', + type: 'aiagent', + options: { store: 'rpc://agents', nested: { store: 'rpc://agentForm' } }, + nested: [child('contextId')], + }; + + expect(fieldEdges(field)).toEqual([{ children: [child('contextId')] }]); + }); + }); + + describe('edge cases', () => { + it('returns nothing for a field without children, whatever its options encoding', () => { + expect(fieldEdges({ name: 'a', type: 'text' })).toEqual([]); + expect(fieldEdges({ name: 'a', type: 'select', options: [{ value: 1 }, { value: 2 }] })).toEqual([]); + expect(fieldEdges({ name: 'a', type: 'select', options: 'rpc://options' })).toEqual([]); + expect(fieldEdges({ name: 'a', type: 'select', options: { store: 'rpc://options' } })).toEqual([]); + }); + + it('emits option children unconditionally when the field has no name to gate on', () => { + expect(fieldEdges({ type: 'select', options: [{ value: 'a', nested: [child('x')] }] })).toEqual([ + { children: [child('x')] }, + ]); + }); + }); +}); + +describe('activeFieldEdges', () => { + const select: FormanSchemaField = { + name: 'mode', + type: 'select', + options: { + store: [{ value: 'a', nested: [child('onA')] }, { value: 'b' }], + nested: [child('always')], + placeholder: { label: 'None', nested: [child('whenEmpty')] }, + }, + }; + + it('returns the matching gated edge in place of the unconditional ones', () => { + expect(activeFieldEdges(select, 'a')).toEqual([ + { gate: { name: 'mode', value: 'a' }, children: [child('onA')] }, + ]); + }); + + it('falls back to the unconditional edges for an option without its own nested', () => { + expect(activeFieldEdges(select, 'b')).toEqual([{ children: [child('always')] }]); + }); + + it('falls back to the unconditional edges for a value outside the static options', () => { + expect(activeFieldEdges(select, '{{1.custom}}')).toEqual([{ children: [child('always')] }]); + }); + + it('treats undefined, null and the empty string as the placeholder selection', () => { + for (const empty of [undefined, null, '']) { + expect(activeFieldEdges(select, empty)).toEqual([ + { gate: { name: 'mode', value: '' }, children: [child('whenEmpty')] }, + ]); + } + }); + + it('matches the option through the key named by options.value', () => { + const field: FormanSchemaField = { + name: 'mode', + type: 'select', + options: { store: [{ id: 'a', nested: [child('onA')] } as never, { id: 'b' } as never], value: 'id' }, + }; + + expect(activeFieldEdges(field, 'a')).toEqual([ + { gate: { name: 'mode', value: 'a' }, children: [child('onA')] }, + ]); + expect(activeFieldEdges(field, 'b')).toEqual([]); + }); + + it('reveals a boolean branch only for the matching value, nothing when unset', () => { + const flag: FormanSchemaField = { name: 'flag', type: 'boolean', nested: [child('whenOn')] }; + + expect(activeFieldEdges(flag, true)).toEqual([ + { gate: { name: 'flag', value: true }, children: [child('whenOn')] }, + ]); + expect(activeFieldEdges(flag, false)).toEqual([]); + expect(activeFieldEdges(flag, undefined)).toEqual([]); + }); + + it("reveals a boolean's single-branch nested for an IML value, and neither branch of the two-branch form", () => { + const flag: FormanSchemaField = { name: 'flag', type: 'boolean', nested: [child('whenOn')] }; + const reversed: FormanSchemaField = { ...flag, reversedNested: true }; + const branched: FormanSchemaField = { + name: 'flag', + type: 'boolean', + nested: { true: [child('whenOn')], false: [child('whenOff')] }, + }; + + expect(activeFieldEdges(flag, '{{1.x}}')).toEqual([ + { gate: { name: 'flag', value: true }, children: [child('whenOn')] }, + ]); + expect(activeFieldEdges(reversed, '{{1.x}}')).toEqual([ + { gate: { name: 'flag', value: false }, children: [child('whenOn')] }, + ]); + expect(activeFieldEdges(branched, '{{1.x}}')).toEqual([]); + }); +}); + +describe('activeFieldEdges against the validator, per selection rule', () => { + /** The names a renderer walking `activeFieldEdges` shows, in the validator's depth-first order. */ + function liveNames(fields: FormanSchemaField[], values: Record, out: string[] = []) { + for (const field of fields) { + if (field.name) out.push(field.name); + for (const edge of activeFieldEdges(field, values[field.name ?? ''])) { + liveNames( + (edge.children ?? []).filter((c): c is FormanSchemaField => typeof c !== 'string'), + values, + out, + ); + } + } + return out; + } + + const select = (extra: Partial): FormanSchemaField => ({ + name: 'mode', + type: 'select', + options: { + store: [{ value: 'a', nested: [child('onA')] }, { value: 'b' }], + nested: [child('always')], + placeholder: { label: 'None', nested: [child('whenEmpty')] }, + }, + ...extra, + }); + const flag = (extra: Partial): FormanSchemaField => ({ + name: 'flag', + type: 'boolean', + nested: [child('whenOn')], + ...extra, + }); + + const cases: [string, FormanSchemaField[], Record][] = [ + ['an option with its own nested replaces the field-level nested', [select({})], { mode: 'a' }], + ['an option without nested falls back to the field-level nested', [select({})], { mode: 'b' }], + ['an IML value on a select falls back to the field-level nested', [select({})], { mode: '{{1.x}}' }], + ['an empty non-required select reveals the placeholder nested', [select({})], {}], + ['an empty required select reveals nothing', [select({ required: true })], {}], + ['a placeholder on a non-select field reveals nothing', [{ ...select({}), type: 'text' }], {}], + [ + "the field's own nested wins over options.nested", + [ + select({ + nested: [child('fromField')], + options: { store: [{ value: 'a' }], nested: [child('fromOptions')] }, + }), + ], + { mode: 'a' }, + ], + [ + 'options.value names the option key', + [ + select({ + options: { + store: [{ id: 'a', nested: [child('onA')] } as never, { id: 'b' } as never], + value: 'id', + }, + }), + ], + { mode: 'a' }, + ], + ['a boolean set to true reveals its nested', [flag({})], { flag: true }], + ['a boolean set to false hides its nested', [flag({})], { flag: false }], + ['a reversed boolean set to false reveals its nested', [flag({ reversedNested: true })], { flag: false }], + ['a boolean holding an IML value reveals its nested', [flag({})], { flag: '{{1.x}}' }], + [ + 'a two-branch boolean reveals the matching branch', + [flag({ nested: { true: [child('on')], false: [child('off')] } })], + { flag: false }, + ], + [ + 'a two-branch boolean holding an IML value reveals nothing', + [flag({ nested: { true: [child('on')], false: [child('off')] } })], + { flag: '{{1.x}}' }, + ], + ['an unset boolean hides its nested', [flag({})], {}], + ]; + + it.each(cases)('%s', async (_, schema, values) => { + const result = await validateFormanWithDomains( + { default: { schema, values } }, + { schemas: true, allowDynamicValues: true }, + ); + + expect(liveNames(schema, values)).toEqual(result.resolvedSchemas!['default']!.map(field => field.name)); + }); +}); + +describe('fieldEdges against the converter and the validator', () => { + const addRow = JSON.parse(readFileSync('./test/mocks/google-sheets-add-row.json').toString()) as { + parameters: FormanSchemaField[]; + expect: FormanSchemaField[]; + }; + + /** Every field name reachable through edges, by the domain the edges place it in. */ + function reachableNames(fields: FormanSchemaField[], domain: string, out = new Map>()) { + for (const field of fields) { + if (field.name) (out.get(domain) ?? out.set(domain, new Set()).get(domain)!).add(field.name); + for (const edge of fieldEdges(field)) { + const children = (edge.children ?? []).filter((c): c is FormanSchemaField => typeof c !== 'string'); + reachableNames(children, edge.domain ?? domain, out); + } + } + return out; + } + + /** Every property name the converter emitted under `schema`, at any depth of properties, allOf/then and x-nested. */ + function emittedNames(schema: JSONSchema7, out = new Set()): Set { + for (const [name, property] of Object.entries(schema.properties ?? {})) { + out.add(name); + if (typeof property === 'object') { + emittedNames(property, out); + const nested = (property as Record)['x-nested']; + if (nested && typeof nested === 'object') emittedNames(nested as JSONSchema7, out); + } + } + for (const entry of schema.allOf ?? []) { + if (typeof entry === 'object' && typeof entry.then === 'object') emittedNames(entry.then, out); + } + return out; + } + + it('reaches exactly the fields the converter emits, per domain, on the google-sheets addRow module', () => { + const reachable = reachableNames(addRow.parameters, 'default'); + reachableNames(addRow.expect, 'expect', reachable); + + const schema = toJSONSchema({ + type: 'collection', + spec: [ + { name: 'default', type: 'collection', spec: addRow.parameters }, + { name: 'expect', type: 'collection', 'x-domain-root': 'expect', spec: addRow.expect }, + ], + }); + + for (const domain of ['default', 'expect']) { + const emitted = emittedNames((schema.properties![domain] as JSONSchema7) ?? {}); + expect([...emitted].sort()).toEqual([...(reachable.get(domain) ?? [])].sort()); + } + }); + + /** The live form for `values`, walking only the edges each value reveals — what a renderer shows. */ + function liveForm( + fields: FormanSchemaField[], + values: Record, + domain: string, + out = new Map(), + ) { + for (const field of fields) { + if (field.name) (out.get(domain) ?? out.set(domain, []).get(domain)!).push(field.name); + for (const edge of activeFieldEdges(field, values[field.name ?? ''] ?? field.default)) { + const children = (edge.children ?? []).filter((c): c is FormanSchemaField => typeof c !== 'string'); + liveForm(children, values, edge.domain ?? domain, out); + } + } + return out; + } + + it('reveals, for chosen values, the same form the validator resolves', async () => { + const values = { __IMTCONN__: 1, mode: 'select', from: 'drive', spreadsheetId: 'abc' }; + const result = await validateFormanWithDomains( + { + default: { values: { __IMTCONN__: values.__IMTCONN__ }, schema: addRow.parameters }, + expect: { + values: { mode: values.mode, from: values.from, spreadsheetId: values.spreadsheetId }, + schema: addRow.expect, + }, + }, + { + schemas: true, + allowDynamicValues: true, + async resolveRemote(path) { + return path.startsWith('api://') ? [{ value: 1 }] : []; + }, + }, + ); + + const form = liveForm(addRow.parameters, values, 'default'); + liveForm(addRow.expect, values, 'expect', form); + + expect(form.get('default')).toEqual(result.resolvedSchemas!['default']!.map(field => field.name)); + expect(form.get('expect')).toEqual(result.resolvedSchemas!['expect']!.map(field => field.name)); + }); +}); diff --git a/test/remote-fragments.spec.ts b/test/remote-fragments.spec.ts new file mode 100644 index 0000000..e378f56 --- /dev/null +++ b/test/remote-fragments.spec.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from '@jest/globals'; +import type { JSONSchema7 } from 'json-schema'; +import type { FormanSchemaField } from '../src/index.js'; +import { toJSONSchema, toJSONSchemaAdvanced } from '../src/index.js'; + +describe('excludeRemoteFragments', () => { + const banner = 'rpc://banner'; + + describe('default behaviour', () => { + it('renders a bare rpc string in a collection as an allOf $ref and reports nothing', () => { + const result = toJSONSchemaAdvanced({ + name: 'w', + type: 'collection', + spec: [banner, { name: 'a', type: 'text' }] as FormanSchemaField[], + }); + + expect(result.schema.allOf).toEqual([{ $ref: banner }]); + expect(result.skippedPaths).toBeUndefined(); + }); + }); + + describe('with the option on', () => { + const options = { excludeRemoteFragments: true }; + + it('drops a bare rpc string from a collection and reports the list path with the reference', () => { + const result = toJSONSchemaAdvanced( + { name: 'w', type: 'collection', spec: [banner, { name: 'a', type: 'text' }] as FormanSchemaField[] }, + options, + ); + + expect(result.schema).toEqual({ type: 'object', properties: { a: { type: 'string' } }, required: [] }); + expect(result.schema.allOf).toBeUndefined(); + expect(result.skippedPaths).toEqual({ remoteFragments: [`w (${banner})`] }); + }); + + it('drops a fragment of any scheme — every string in a field list is fetched remotely', () => { + const result = toJSONSchemaAdvanced( + { + name: 'w', + type: 'collection', + spec: ['api://form', { name: 'a', type: 'text' }] as FormanSchemaField[], + }, + options, + ); + + expect(result.schema.allOf).toBeUndefined(); + expect(result.skippedPaths).toEqual({ remoteFragments: ['w (api://form)'] }); + }); + + it('reports the empty path for a fragment at the root of an anonymous collection', () => { + const result = toJSONSchemaAdvanced( + { type: 'collection', spec: [banner] as unknown as FormanSchemaField[] }, + options, + ); + + expect(result.skippedPaths).toEqual({ remoteFragments: [` (${banner})`] }); + }); + + it('drops a fragment from a branch nested list, leaving a plain collection under then', () => { + const result = toJSONSchemaAdvanced( + { + name: 'w', + type: 'collection', + spec: [ + { + name: 'mode', + type: 'select', + options: [{ value: 'a', label: 'A', nested: [banner, { name: 'onA', type: 'text' }] }], + }, + ], + }, + options, + ); + + const branch = result.schema.allOf![0] as JSONSchema7; + expect(branch.then).toEqual({ type: 'object', properties: { onA: { type: 'string' } }, required: [] }); + expect(JSON.stringify(result.schema)).not.toContain(banner); + expect(result.skippedPaths).toEqual({ remoteFragments: [`w.mode (${banner})`] }); + }); + + it('drops a fragment from an unconditional nested list, leaving a plain collection under x-nested', () => { + const result = toJSONSchemaAdvanced( + { + name: 'w', + type: 'collection', + spec: [ + { + name: 'conn', + type: 'account:google', + options: { nested: [banner, { name: 'sub', type: 'text' }] }, + }, + ], + }, + options, + ); + + const conn = result.schema.properties!['conn'] as JSONSchema7; + expect(Object.getOwnPropertyDescriptor(conn, 'x-nested')?.value).toEqual({ + type: 'object', + properties: { sub: { type: 'string' } }, + required: [], + }); + expect(result.skippedPaths).toEqual({ remoteFragments: [`w.conn (${banner})`] }); + }); + + it('reports a field-level fragment once, however many options fall back to it', () => { + const result = toJSONSchemaAdvanced( + { + name: 'w', + type: 'collection', + spec: [ + { + name: 'mode', + type: 'select', + options: [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + ], + nested: [banner, { name: 'f', type: 'text' }], + }, + ], + }, + options, + ); + + expect(JSON.stringify(result.schema)).not.toContain(banner); + expect(result.skippedPaths).toEqual({ remoteFragments: [`w.mode (${banner})`] }); + }); + + it('leaves no marker or branch behind when a list held only fragments', () => { + const result = toJSONSchemaAdvanced( + { + name: 'w', + type: 'collection', + spec: [ + { name: 'conn', type: 'text', nested: [banner] }, + { name: 'acc', type: 'account:google', options: { nested: [banner] } }, + { name: 'mode', type: 'select', options: [{ value: 'a', label: 'A', nested: [banner] }] }, + ], + }, + options, + ); + + expect(Object.getOwnPropertyDescriptor(result.schema.properties!['conn'], 'x-nested')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(result.schema.properties!['acc'], 'x-nested')).toBeUndefined(); + expect(result.schema.allOf).toBeUndefined(); + expect(result.skippedPaths).toEqual({ + remoteFragments: [`w.conn (${banner})`, `w.acc (${banner})`, `w.mode (${banner})`], + }); + }); + + it('reports a fragment under a nameless field at the enclosing path', () => { + const result = toJSONSchemaAdvanced( + { name: 'arr', type: 'array', spec: { type: 'text', nested: [banner] } }, + options, + ); + + expect(result.skippedPaths).toEqual({ remoteFragments: [`arr[] (${banner})`] }); + }); + + it('drops a fragment relocated to another domain, at the field that declares it', () => { + const result = toJSONSchemaAdvanced( + { + type: 'collection', + spec: [ + { + name: 'parameters', + type: 'collection', + spec: [ + { + name: 'conn', + type: 'account:slack', + options: { + nested: { domain: 'expect', store: [banner, { name: 'text', type: 'text' }] }, + }, + }, + ], + }, + { name: 'mapper', type: 'collection', 'x-domain-root': 'expect', spec: [] }, + ], + }, + options, + ); + + const mapper = result.schema.properties!['mapper'] as JSONSchema7; + expect(mapper.properties).toEqual({ text: { type: 'string' } }); + expect(mapper.allOf).toBeUndefined(); + expect(result.skippedPaths).toEqual({ remoteFragments: [`parameters.conn (${banner})`] }); + }); + + it('keeps a whole-list remote as the x-nested $ref marker', () => { + const schema = toJSONSchema( + { name: 'w', type: 'collection', spec: [{ name: 'wsdl', type: 'text', nested: 'rpc://soapForm' }] }, + options, + ); + + const wsdl = schema.properties!['wsdl'] as JSONSchema7; + expect(Object.getOwnPropertyDescriptor(wsdl, 'x-nested')?.value).toEqual({ + $ref: 'rpc://soapForm?wsdl={{wsdl}}', + }); + }); + + it('keeps a whole-branch remote as the then $ref', () => { + const schema = toJSONSchema( + { + name: 'w', + type: 'collection', + spec: [{ name: 'mode', type: 'select', options: [{ value: 'a', nested: 'rpc://aForm' }] }], + }, + options, + ); + + expect((schema.allOf![0] as JSONSchema7).then).toEqual({ $ref: 'rpc://aForm?mode={{mode}}' }); + }); + + it('reports alongside the other skip reasons', () => { + const spec: FormanSchemaField[] = [ + banner, + { name: 'odd', type: 'somethingNew' }, + { name: 'adv', type: 'text', advanced: true }, + ] as FormanSchemaField[]; + const result = toJSONSchemaAdvanced( + { name: 'w', type: 'collection', spec }, + { ...options, excludeAdvancedFields: true }, + ); + + expect(result.skippedPaths).toEqual({ + advanced: ['w.adv'], + unconvertible: ['w.odd (unknown type: somethingNew)'], + remoteFragments: [`w (${banner})`], + }); + }); + }); +}); diff --git a/test/test.spec.ts b/test/test.spec.ts index a93fdac..2a7fbd1 100644 --- a/test/test.spec.ts +++ b/test/test.spec.ts @@ -68,6 +68,7 @@ describe('Forman Schema', () => { }, editor: { type: 'string', + 'x-editor': true, }, file: { title: 'File', @@ -512,7 +513,7 @@ describe('Forman Schema', () => { required: false, }, { - type: 'text', + type: 'editor', name: 'editor', required: false, },