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
2 changes: 1 addition & 1 deletion packages/flow-to-blocks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"renderer"
],
"dependencies": {
"@fbp/evaluator": "^1.3.0",
"@fbp/evaluator": "^1.5.0",
"@fbp/types": "^1.4.0",
"blocks-schema": "workspace:^",
"json-schema-to-blocks": "workspace:^"
Expand Down
25 changes: 25 additions & 0 deletions packages/json-renderer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { resolveBinding } from 'json-renderer/bindings';
| **Compose** | Fragment expansion, slot filling, overrides, merge | `compose.ts` |
| **Bindings** | `{{ scope.path }}` resolution and scope layering | `bindings.ts` |
| **Registry** | Layered node type → handler resolution | `registry.ts` |
| **Rules** | `FieldDescriptor` and the ordered field → node rule pipeline | `rules.ts` |
| **Adapter** | The interface a renderer implements | `adapter.ts` |

## Document
Expand Down Expand Up @@ -95,6 +96,30 @@ composition:
composeEnvelope(document, { fragments, vocabulary: { fragmentNodeType: 'include' } });
```

## Field rules

A document source (a JSON Schema, a database table, a task's input contract)
describes each field as a `FieldDescriptor` and lets rules decide the widget.
Rules are data — an ordered list, first match wins, defaults last — so a host
prepends its own instead of forking the converter, and a rule written once
applies to every source.

```ts
import { applyWidgetRules, composeWidgetRules, fieldNodeProps } from 'json-renderer';

const rules = composeWidgetRules(defaults, [
{ name: 'file', match: (field) => field.format === 'uri', node: 'FileUpload' },
{ name: 'big-enum', match: (field) => (field.enumValues?.length ?? 0) > 20, node: 'Combobox' },
]);

const partial = applyWidgetRules(descriptor, rules, 'Input');
const props = { ...fieldNodeProps(descriptor), ...partial.props };
```

A source extends the descriptor with its own facts for rules that need them —
`json-schema-to-blocks`' `FieldContext` adds the raw schema — while every
source-neutral decision reads the shared fields.

## Adapter contract

A renderer is generic over the handler it resolves a node type to (`THandler`)
Expand Down
127 changes: 127 additions & 0 deletions packages/json-renderer/src/__tests__/rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';

import {
applyWidgetRules,
compareFieldOrder,
composeWidgetRules,
fieldNodeProps,
type FieldDescriptor,
type WidgetRule,
} from '../rules';

function field(overrides: Partial<FieldDescriptor> = {}): FieldDescriptor {
return { name: 'city', path: 'address.city', required: false, hints: {}, ...overrides };
}

const stringRule: WidgetRule = { name: 'string', match: (ctx) => ctx.dataType === 'string', node: 'Input' };

describe('composeWidgetRules', () => {
it('puts caller rules ahead of the defaults so they win', () => {
const custom: WidgetRule = { name: 'custom', match: () => true, node: 'Custom' };
expect(composeWidgetRules([stringRule], [custom]).map((rule) => rule.name)).toEqual(['custom', 'string']);
});

it('returns the defaults when no rules are supplied', () => {
expect(composeWidgetRules([stringRule])).toEqual([stringRule]);
});

it('drops the defaults when the caller replaces them', () => {
expect(composeWidgetRules([stringRule], undefined, true)).toEqual([]);
});

it('does not alias the arrays it was handed', () => {
const defaults = [stringRule];
composeWidgetRules(defaults).push({ name: 'extra', match: () => true, node: 'Extra' });
expect(defaults).toHaveLength(1);
});
});

describe('applyWidgetRules', () => {
it('takes the first matching rule', () => {
const rules: WidgetRule[] = [
{ name: 'hint', match: (ctx) => Boolean(ctx.hints.widget), node: (ctx) => ctx.hints.widget as string },
stringRule,
];
expect(applyWidgetRules(field({ dataType: 'string', hints: { widget: 'Markdown' } }), rules, 'Input')).toEqual({
type: 'Markdown',
});
});

it('passes a partial node through untouched', () => {
const rules: WidgetRule[] = [
{ name: 'enum', match: (ctx) => Boolean(ctx.enumValues), node: () => ({ type: 'Select', props: { searchable: true } }) },
];
expect(applyWidgetRules(field({ enumValues: ['a', 'b'] }), rules, 'Input')).toEqual({
type: 'Select',
props: { searchable: true },
});
});

it('falls back when nothing matches', () => {
expect(applyWidgetRules(field({ dataType: 'geometry' }), [stringRule], 'JsonEditor')).toEqual({
type: 'JsonEditor',
});
});
});

describe('fieldNodeProps', () => {
it('derives the shared props of a field node', () => {
expect(
fieldNodeProps(
field({
required: true,
label: 'City',
description: 'Billing city',
nullable: true,
defaultValue: 'Austin',
}),
),
).toEqual({
name: 'address.city',
label: 'City',
description: 'Billing city',
required: true,
nullable: true,
defaultValue: 'Austin',
});
});

it('lets hints override the derived label and description', () => {
expect(fieldNodeProps(field({ label: 'City', description: 'From the schema', hints: { label: 'Town' } }))).toMatchObject(
{ label: 'Town', description: 'From the schema' },
);
});

it('omits absent props rather than emitting undefined', () => {
expect(fieldNodeProps(field())).toEqual({ name: 'address.city' });
});

it('disables a read-only field', () => {
expect(fieldNodeProps(field({ readOnly: true }))).toMatchObject({ disabled: true });
});

it('keeps a null default, which is a value', () => {
expect(fieldNodeProps(field({ defaultValue: null }))).toMatchObject({ defaultValue: null });
});
});

describe('compareFieldOrder', () => {
it('keeps source order when no field declares one', () => {
expect([
{ index: 1, order: undefined },
{ index: 0, order: undefined },
].sort(compareFieldOrder)).toEqual([{ index: 0, order: undefined }, { index: 1, order: undefined }]);
});

it('sorts ordered fields ahead of unordered ones', () => {
expect(
[
{ index: 0 },
{ index: 1, order: 2 },
{ index: 2, order: 1 },
]
.sort(compareFieldOrder)
.map((entry) => entry.index),
).toEqual([2, 1, 0]);
});
});
1 change: 1 addition & 0 deletions packages/json-renderer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export * from './fields';
export * from './json-schema';
export * from './node';
export * from './registry';
export * from './rules';
export * from './zod';
142 changes: 142 additions & 0 deletions packages/json-renderer/src/rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* The field lowering layer: a source-neutral description of one field, and the
* ordered rule pipeline that turns it into a node.
*
* Every document source (JSON Schema, database metadata, a flow) knows how to
* describe a field but should not own the widget decisions — "`format: uri`
* renders as a file picker", "enums over twenty values render as a combobox".
* Those decisions are data: an ordered list of rules, first match wins, defaults
* last. Rules written against `FieldDescriptor` are shared across sources, and a
* source may extend the descriptor with its own facts (the raw schema, the
* column) for rules that need them.
*/

import type { NodeConstraints } from './constraints';
import type { DocumentNode, NodeProps } from './node';

/** Author-supplied hints that override what a rule would otherwise decide. */
export interface FieldHints<TType extends string = string> {
/** Force a node type, bypassing the rules. */
widget?: TType;
label?: string;
description?: string;
placeholder?: string;
hidden?: boolean;
disabled?: boolean;
className?: string;
/** Sort weight within its group; lower comes first, unset keeps source order. */
order?: number;
/** Extra props merged onto the produced node. */
props?: NodeProps;
}

/**
* One field, described independently of where it came from. `dataType` and
* `format` are deliberately open strings: a JSON Schema contributes
* `'string'`/`'uri'`, a database column `'text'`/`'json'`, and a rule matches on
* whichever it cares about.
*/
export interface FieldDescriptor<TType extends string = string> {
/** Field name within its parent, e.g. `city`. */
name: string;
/** Path from the document root, e.g. `billing.address.city`. */
path: string;
required: boolean;
dataType?: string;
format?: string;
label?: string;
description?: string;
enumValues?: readonly unknown[];
nullable?: boolean;
readOnly?: boolean;
defaultValue?: unknown;
constraints?: NodeConstraints;
hints: FieldHints<TType>;
}

/** A rule's contribution to the node built for a field. */
export interface PartialNode<TType extends string = string, TNode extends DocumentNode<TType> = DocumentNode<TType>> {
type?: TType;
props?: NodeProps;
constraints?: NodeConstraints;
children?: TNode[];
}

/**
* A widget rule maps one field to a node type. Rules are tried in order and the
* first match wins, so app-specific rules are prepended rather than replacing
* the defaults.
*/
export interface WidgetRule<
TContext extends FieldDescriptor = FieldDescriptor,
TType extends string = string,
TNode extends DocumentNode<TType> = DocumentNode<TType>,
> {
/** Identifies the rule so a consumer can replace exactly one default. */
name: string;
match: (context: TContext) => boolean;
/** Node type to render, or a partial node merged over the derived one. */
node: TType | ((context: TContext) => TType | PartialNode<TType, TNode>);
}

/**
* Order a rule set: caller rules first (so they win), defaults last, unless the
* caller replaces the defaults outright.
*/
export function composeWidgetRules<TRule extends { name: string }>(
defaults: readonly TRule[],
rules?: readonly TRule[],
replaceDefaults?: boolean,
): TRule[] {
if (replaceDefaults) return [...(rules ?? [])];
return rules?.length ? [...rules, ...defaults] : [...defaults];
}

/** Run the pipeline: the first matching rule's contribution, else the fallback type. */
export function applyWidgetRules<TContext extends FieldDescriptor, TType extends string, TNode extends DocumentNode<TType>>(
context: TContext,
rules: readonly WidgetRule<TContext, TType, TNode>[],
fallbackType: TType,
): PartialNode<TType, TNode> {
for (const rule of rules) {
if (!rule.match(context)) continue;
const result = typeof rule.node === 'function' ? rule.node(context) : rule.node;
return typeof result === 'string' ? { type: result } : result;
}
return { type: fallbackType };
}

/**
* The props every lowered field node carries, before a rule's own props are
* merged on top. Sources share this so a generated field looks the same whether
* it came from a schema or a column.
*/
export function fieldNodeProps(descriptor: FieldDescriptor): NodeProps {
const { hints } = descriptor;
const label = hints.label ?? descriptor.label;
const description = hints.description ?? descriptor.description;

return {
name: descriptor.path,
...(label !== undefined ? { label } : {}),
...(description !== undefined ? { description } : {}),
...(hints.placeholder ? { placeholder: hints.placeholder } : {}),
...(descriptor.required ? { required: true } : {}),
...(hints.hidden ? { hidden: true } : {}),
...(hints.disabled || descriptor.readOnly ? { disabled: true } : {}),
...(hints.className ? { className: hints.className } : {}),
...(descriptor.nullable ? { nullable: true } : {}),
...(descriptor.defaultValue !== undefined ? { defaultValue: descriptor.defaultValue } : {}),
};
}

/** Sort key honouring `hints.order`; fields without one keep source order. */
export function compareFieldOrder(
left: { order?: number; index: number },
right: { order?: number; index: number },
): number {
if (left.order == null && right.order == null) return left.index - right.index;
if (left.order == null) return 1;
if (right.order == null) return -1;
return left.order - right.order;
}
3 changes: 2 additions & 1 deletion packages/json-schema-to-blocks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
"renderer"
],
"dependencies": {
"blocks-schema": "workspace:^"
"blocks-schema": "workspace:^",
"json-renderer": "workspace:^"
},
"devDependencies": {
"makage": "^0.6.0",
Expand Down
Loading
Loading