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
29 changes: 29 additions & 0 deletions .changeset/11276-object-chart-properties-bag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@object-ui/types': minor
---

feat(types): an authored `object-chart` takes its props in the `properties` bag; the flat spelling is refused by name (objectui#11276)

**BREAKING (authoring):** an `object-chart` node that writes its props on the node itself is now refused by `safeValidateSchema`, `validateSchema` and the strict authoring face, and so by `objectui validate`. Move each prop into the node's `properties` bag:

```json
{ "type": "object-chart", "properties": { "chartType": "bar", "dataset": "task_metrics", "dimensions": ["status"], "values": ["task_count"] } }
```

Nothing changes at render time: `SchemaRenderer` hoists every `properties` key onto the node before `ObjectChart` runs, so the bag node draws what the flat one drew, and a node built in code (`ObjectView`, `ListView`, the dashboard renderers and the react-page wrapper all build a flat `object-chart` node) keeps working with its flat keys.

**Clause-②: yes (narrowing)** — the accept set narrows (a flat `object-chart` node that parsed is refused) and widens (a bag node that was refused parses). Released as `minor` under the objectui narrowing rule, with this banner.

**Why.** `@objectstack/spec`'s strict `PageComponentSchema` refuses a prop written on any page component itself as mis-layered (ADR-0089 D3a): `properties` is the only home of a component's props. The arm here was the flat mirror of the TypeScript `ObjectChartSchema`, so the two validators disagreed both ways: the objectstack showcase's command-center charts, written in the bag, were refused (the flat mirror found no chart family on the node), and the flat node `os validate` refuses was accepted.

**What changed, in observable terms.**

- `@object-ui/types/zod` exports `ObjectChartBlockSchema`, built the way `ObjectFormBlockSchema` and `ObjectMapBlockSchema` are: `BaseSchema`, the `object-chart` literal, the node-level `responsiveStyles`, and `properties`. One difference: `@objectstack/spec` has no `ComponentPropsMap['object-chart']` row, so the spec's page component takes any bag on this type, and no row is invented here. The bag's members are the members `ObjectChartSchema` already declares (beyond the node base), the same schema objects, so their value checks and spec bindings (`aggregate`, `series`, `drillDown`, `xAxis`, `yAxis`) judge the bag exactly as they judged the flat node.
- Each of those members written flat on the node is refused on both faces, with a message that names its bag member (`Did you mean \`chartType\` → \`properties.chartType\`?`). The refusal set is read off the bag. The three list-view spellings retired by objectui#10608 (`xAxisField`, `yAxisFields`, `aggregation`) keep their retirement message, on the node and in the bag.
- The chart-family floor stays, read in the bag: the node needs `properties.chartType` (or `properties.specType`). A node with neither is refused at `properties.chartType`, as the flat node was refused at `chartType`. The spec has no such rule.
- A key the chart does not declare stays as before, one layer down: unjudged by the tolerant face, refused by name by the strict face. `BaseSchema`'s keys (`className`, `hidden`, …) stay on the node. `dataSource` stays undeclared on this node, as objectui#11070 left it. `body` / `children` stay refused (objectui#9256).
- `ObjectChartBlockSchema` reaches `AnyComponentSchema` through `ObjectQLPublicBlockComponentSchema`. `ObjectChartSchema` leaves `ObjectQLComponentSchema`.

**The same release's `.changeset/10518-object-chart-y-axis-declared.md`, `.changeset/10608-object-chart-legacy-axis-keys-retired.md`, `.changeset/10770-object-chart-react-tier-node.md` and `.changeset/10872-flat-arm-responsive-styles.md`** describe what the validators say about an `object-chart` written flat. On an authored node those readings now happen in the bag: a malformed `xAxis` is refused at `properties.xAxis`, a retired key at `properties.KEY`, a missing family at `properties.chartType`; `responsiveStyles` stays node-level and is judged there as they say. The flat mirror they name still behaves as they say.

**What did not move.** The TypeScript `ObjectChartSchema` and its zod mirror `ObjectChartSchema` stay published and unchanged in shape. They are the node as `ObjectChart` reads it after the hoist, and as code composes it or hands it to `<ObjectChart schema={…}>` directly.
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ const ROWS: ReadonlyArray<readonly [type: string, mirror: Mirror, required: Reco
['object-view', ObjectViewMirror as unknown as Mirror, { objectName: 'account' }],
['object-gantt', ObjectGanttMirror as unknown as Mirror, { objectName: 'account' }],
['object-calendar', ObjectCalendarMirror as unknown as Mirror, { objectName: 'account' }],
// As `object-form` above: the authored node's spelling is `AUTHORED_REQUIRED`
// below (objectui#11276).
['object-chart', ObjectChartMirror as unknown as Mirror, { objectName: 'account', chartType: 'bar' }],
['detail-view', DetailViewMirror as unknown as Mirror, { objectName: 'account' }],
['email', InputShorthandMirror as unknown as Mirror, {}],
Expand All @@ -134,14 +136,15 @@ const ROWS: ReadonlyArray<readonly [type: string, mirror: Mirror, required: Reco

/**
* The authored spelling of a row's required members, where it differs from the
* mirror's: an authored `object-form` or `object-map` takes its props in the
* spec's `properties` bag, and `AnyComponentSchema` refuses them flat
* (objectui#10859 batches 4 and 5). The mirror rows above keep the post-hoist
* spelling.
* mirror's: an authored `object-form`, `object-map` or `object-chart` takes its
* props in the `properties` bag, and `AnyComponentSchema` refuses them flat
* (objectui#10859 batches 4 and 5; objectui#11276). The mirror rows above keep
* the post-hoist spelling.
*/
const AUTHORED_REQUIRED: Readonly<Record<string, Record<string, unknown>>> = {
'object-form': { properties: { objectName: 'account', mode: 'create' } },
'object-map': { properties: { objectName: 'account' } },
'object-chart': { properties: { objectName: 'account', chartType: 'bar' } },
};

const CHANNELS = ['body', 'children'] as const;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@
*
* The three arms spread the ONE shared fragment the public-block arms spread,
* `PAGE_COMPONENT_ENVELOPE`, so the member is the same object on all 33 arms.
*
* ⚠️ objectui#11276 moved `object-chart`'s AUTHORED arm into the public-block
* set (`ObjectChartBlockSchema`, its props in the `properties` bag), so the
* population below is the two arms the node union still selects a flat mirror
* for. The flat `ObjectChartSchema` keeps the shared member as the post-hoist
* reading, and the authored chart arm is pinned with the other public blocks in
* `./public-block-responsive-styles-10872.test.ts`.
* The key is NOT declared on `BaseSchema` and NOT on any arm without a measured
* producer: `grid`, `stack` and `container` below are the control, and the
* whole arm list is read at run time so an arm that gains the member silently
Expand Down Expand Up @@ -122,14 +129,16 @@ function armsByLiteral(): Map<string, Arm> {
}

/**
* The three arms this batch widens: the arms outside the public-block set that
* a producer writes `responsiveStyles` on (objectui#10872's batch-9 pull). NAMED,
* not derived, because the population is a measurement of producers.
* The arms this batch widens that the node union still selects: the arms
* outside the public-block set that a producer writes `responsiveStyles` on
* (objectui#10872's batch-9 pull). NAMED, not derived, because the population
* is a measurement of producers. `object-chart` was the third until
* objectui#11276 moved its authored arm into the public-block set; its flat
* mirror keeps the member (the post-hoist row below).
*/
const FLAT_ARMS = [
['flex', FlexSchema],
['object-grid', ObjectGridSchema],
['object-chart', ObjectChartSchema],
] as const;

/** Arms with no producer: the strict face still refuses the key on them. */
Expand Down Expand Up @@ -161,15 +170,6 @@ const VALID_NODES = {
columns: ['title', 'status'],
responsiveStyles: { large: { minWidth: '0', display: 'block' } },
},
'object-chart': {
type: 'object-chart',
id: 'cc_status_c',
dataset: 'showcase_task_metrics',
dimensions: ['status'],
values: ['task_count'],
chartType: 'bar',
responsiveStyles: { large: { width: '100%', minWidth: '0' }, xsmall: { display: 'none' } },
},
} as const;

/** Values the spec refuses: a breakpoint it does not have, a non-object, a non-CSS value. */
Expand Down Expand Up @@ -220,21 +220,28 @@ describe('the population (objectui#10872 batch 9)', () => {
});
});

describe('`responsiveStyles` is declared on the three arms, from the public blocks\' fragment (objectui#10872 batch 9)', () => {
describe('`responsiveStyles` is declared on the batch-9 arms, from the public blocks\' fragment (objectui#10872 batch 9)', () => {
it.each(FLAT_ARMS)('%s carries the member, and it is the SAME object the public-block arms carry', (_type, arm) => {
const shared = PageSectionBlockSchema.shape.responsiveStyles;
expect(shared).toBeDefined();
expect(arm.shape.responsiveStyles).toBe(shared);
});

it('the flat `ObjectChartSchema` mirror keeps the SAME member as the post-hoist reading (objectui#11276)', () => {
// Its authored arm left the node union for the public-block set; the
// mirror is the node as `ObjectChart` reads it, and it still declares the key.
expect(ObjectChartSchema.shape.responsiveStyles).toBe(PageSectionBlockSchema.shape.responsiveStyles);
expect(armsByLiteral().get('object-chart')).not.toBe(ObjectChartSchema);
});

it('the member is the spec\'s own map: the same breakpoints, each the spec\'s own value schema', () => {
const member = (FlexSchema.shape.responsiveStyles as z.ZodOptional).unwrap() as z.ZodObject;
const spec = SpecResponsiveStylesSchema as unknown as z.ZodObject;
expect(Object.keys(member.shape).sort()).toEqual(Object.keys(spec.shape).sort());
for (const key of Object.keys(spec.shape)) expect(member.shape[key]).toBe(spec.shape[key]);
});

it('exactly the public-block arms and these three declare it: no other arm, and not `BaseSchema`', () => {
it('exactly the public-block arms and these flat arms declare it: no other arm, and not `BaseSchema`', () => {
expect(Object.keys(BaseSchema.shape)).not.toContain('responsiveStyles');
const publicBlocks = [
...optionsOf(PublicBlockComponentSchema),
Expand Down Expand Up @@ -282,7 +289,7 @@ describe('an invalid `responsiveStyles` is refused on BOTH faces, with the spec\
});
});

describe('the other five envelope keys stay undeclared on the three arms (objectui#10872 batch 9)', () => {
describe('the other five envelope keys stay undeclared on the batch-9 arms (objectui#10872 batch 9)', () => {
it('no arm gained any of them: `dataSource` only where objectui#11070 declared it', () => {
const declared = FLAT_ARMS.flatMap(([type, arm]) =>
UNDECLARED_ENVELOPE.filter((key) => key in arm.shape).map((key) => `${type}#${key}`),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ import { safeValidateSchema } from '../zod/index.zod';

const CHART = { type: 'object-chart', chartType: 'bar' } as const;
const chart = (extra: Record<string, unknown>) => ({ ...CHART, ...extra });
/**
* The AUTHORED spelling of `chart(extra)` (objectui#11276): an authored
* `object-chart` takes its props in the `properties` bag, and the public door
* judges that arm, so every `safeValidateSchema` row below hands it this. The
* mirror rows keep `chart(extra)`, the node as `ObjectChart` reads it.
*/
const authored = (extra: Record<string, unknown>) => ({ type: 'object-chart', properties: { chartType: 'bar', ...extra } });

const parsed = (doc: unknown) => {
const r = ObjectChartMirror.safeParse(doc);
Expand Down Expand Up @@ -124,6 +131,12 @@ const SHOWCASE_NODE = {
yAxis: [{ field: 'task_count', stepSize: 1 }],
} as const;

/** The same producer node as the producer WRITES it — its props in the bag (objectui#11276). */
const SHOWCASE_AUTHORED = (() => {
const { id, type, responsiveStyles, ...props } = SHOWCASE_NODE;
return { id, type, responsiveStyles, properties: props };
})();

type Shape = Record<string, unknown>;
const mirrorShape = (ObjectChartMirror as unknown as { shape: Shape }).shape;

Expand Down Expand Up @@ -200,7 +213,7 @@ describe('objectui#10518 (b) — LIT CONTROL: the showcase producer\'s node pars
});

it('through `safeValidateSchema`, the door `objectui validate` / `objectui check` run', () => {
expect(safeValidateSchema(SHOWCASE_NODE).success).toBe(true);
expect(safeValidateSchema(SHOWCASE_AUTHORED).success).toBe(true);
});
});

Expand All @@ -220,14 +233,14 @@ describe('objectui#10518 (c) — a malformed `yAxis` entry is refused at its own
});

it('the refusal reaches the public door — `safeValidateSchema` refuses the node', () => {
expect(safeValidateSchema(chart({ yAxis: [{ field: 'task_count', stepSize: 'big' }] })).success).toBe(false);
expect(safeValidateSchema(authored({ yAxis: [{ field: 'task_count', stepSize: 'big' }] })).success).toBe(false);
});

/* CONTROL for every refusal above: the same documents minus the defect parse. */
it('CONTROL — the same entries, minus the defect, are accepted', () => {
expect(() => parsed(chart({ yAxis: [{ field: 'task_count', stepSize: 2 }] }))).not.toThrow();
expect(() => parsed(chart({ yAxis: [{ field: 'task_count', position: 'right', logarithmic: true }] }))).not.toThrow();
expect(safeValidateSchema(chart({ yAxis: [{ field: 'task_count', stepSize: 2 }] })).success).toBe(true);
expect(safeValidateSchema(authored({ yAxis: [{ field: 'task_count', stepSize: 2 }] })).success).toBe(true);
});
});

Expand Down Expand Up @@ -343,7 +356,7 @@ describe('objectui#10518 (g) — LIT CONTROL: the showcase `renewals-pipeline` a
});

it('through `safeValidateSchema`, the door `objectui validate` / `objectui check` run', () => {
expect(safeValidateSchema(chart(RENEWALS_AXES)).success).toBe(true);
expect(safeValidateSchema(authored(RENEWALS_AXES)).success).toBe(true);
});
});

Expand All @@ -363,7 +376,7 @@ describe('objectui#10518 (h) — a malformed `xAxis` object is refused at `xAxis
});

it('the refusal reaches the public door — `safeValidateSchema` refuses the node', () => {
expect(safeValidateSchema(chart({ xAxis: { field: 'status', min: 'zero' } })).success).toBe(false);
expect(safeValidateSchema(authored({ xAxis: { field: 'status', min: 'zero' } })).success).toBe(false);
});

/* CONTROL for every refusal above: the same objects, minus the defect, parse. */
Expand All @@ -388,7 +401,7 @@ describe('objectui#10518 (i) — `xAxis` is the spec object, and only that', ()
});

it('the bare string is refused at the public door too', () => {
expect(safeValidateSchema(chart({ xAxis: 'status' })).success).toBe(false);
expect(safeValidateSchema(authored({ xAxis: 'status' })).success).toBe(false);
});

it('CONTROL — the same column as the spec object parses, and a malformed OBJECT is not told the remedy', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ import { ListViewSchema, safeValidateSchema } from '../zod/index.zod';

const CHART = { type: 'object-chart', chartType: 'bar' } as const;
const chart = (extra: Record<string, unknown>) => ({ ...CHART, ...extra });
/**
* The AUTHORED node (objectui#11276): its props in the `properties` bag, the
* arm the public door judges. `authoredFlat` writes `extra` on that node
* itself. The mirror rows keep `chart(extra)`, the node as `ObjectChart` reads it.
*/
const authored = (extra: Record<string, unknown>) => ({ type: 'object-chart', properties: { chartType: 'bar', ...extra } });
const authoredFlat = (extra: Record<string, unknown>) => ({ type: 'object-chart', properties: { chartType: 'bar' }, ...extra });

/**
* The retired keys, each with the values an author would plausibly have written
Expand Down Expand Up @@ -102,10 +109,17 @@ describe.each(RETIRED_KEYS)('objectui#10608 (a) — `%s` is RETIRED on `object-c
it.each(values.map((v) => [JSON.stringify(v), v] as const))(
'refuses `%s` at the public door and on the mirror: ONE `invalid_type` issue at the key, naming the remedy',
(_label, value) => {
for (const r of [safeValidateSchema(chart({ [key]: value })), ObjectChartMirror.safeParse(chart({ [key]: value }))]) {
// objectui#11276: at the door the key is retired in the authored node's
// bag AND written flat on it (the flat mirror's own tombstone, by
// reference, not a pointer at a bag member that is itself retired).
for (const [r, path] of [
[safeValidateSchema(authored({ [key]: value })), ['properties', key]],
[safeValidateSchema(authoredFlat({ [key]: value })), [key]],
[ObjectChartMirror.safeParse(chart({ [key]: value })), [key]],
] as const) {
expect(r.success, `an authored \`${key}: ${JSON.stringify(value)}\` was ACCEPTED`).toBe(false);
const issues = issuesOf(r);
expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([{ code: 'invalid_type', path: [key] }]);
expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([{ code: 'invalid_type', path }]);
expect(issues[0]!.message).toContain(`\`${key}\``);
expect(issues[0]!.message).toContain(remedy);
}
Expand All @@ -130,12 +144,12 @@ describe('objectui#10608 (b) — LIT CONTROL: each remedy parses on the same nod
['`yAxis: [{ field }]` for `yAxisFields`', { yAxis: [{ field: 'amount' }] }],
['`aggregate: { field, function, groupBy }` for `aggregation`', { objectName: 'task', aggregate: { field: 'amount', function: 'sum', groupBy: 'status' } }],
])('%s', (_label, extra) => {
expect(issuesOf(safeValidateSchema(chart(extra)))).toEqual([]);
expect(issuesOf(safeValidateSchema(authored(extra)))).toEqual([]);
expect(issuesOf(ObjectChartMirror.safeParse(chart(extra)))).toEqual([]);
});

it('all three remedies together, on one node', () => {
const node = chart({
const node = authored({
objectName: 'task',
aggregate: { field: 'amount', function: 'sum', groupBy: 'status' },
xAxis: { field: 'status' },
Expand Down
Loading
Loading