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
4 changes: 4 additions & 0 deletions .changeset/alert-dialog-fixture-premise-pin-7693.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Test-only change in `@object-ui/types`: the pin that recorded objectui#7693's filed premise (the four alert-dialog schema-catalog fixtures authoring an `actions` array) is re-derived onto the read dialect now that the card has landed. No published behaviour changes — `overlay.ts` and `zod/overlay.zod.ts` are untouched, and the only file edited under `src/` is a test.
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,6 @@
"type": "button",
"label": "Delete Account"
},
"actions": [
{
"type": "button",
"label": "Cancel",
"variant": "outline"
},
{
"type": "button",
"label": "Continue",
"variant": "destructive"
}
]
"cancelText": "Cancel",
"actionText": "Continue"
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,6 @@
"label": "Close",
"variant": "outline"
},
"actions": [
{
"type": "button",
"label": "Don't Save",
"variant": "ghost"
},
{
"type": "button",
"label": "Save",
"variant": "default"
}
]
"cancelText": "Don't Save",
"actionText": "Save"
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,6 @@
"label": "Proceed",
"variant": "default"
},
"actions": [
{
"type": "button",
"label": "Go Back",
"variant": "outline"
},
{
"type": "button",
"label": "Yes, Continue",
"variant": "default"
}
]
"cancelText": "Go Back",
"actionText": "Yes, Continue"
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,6 @@
"label": "Delete",
"variant": "destructive"
},
"actions": [
{
"type": "button",
"label": "Cancel"
},
{
"type": "button",
"label": "Delete",
"variant": "destructive"
}
]
"cancelText": "Cancel",
"actionText": "Delete"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#7693 — the four `alert-dialog` catalog fixtures authored an
* `actions` array that no surface carries, so the docs page's own examples
* rendered an EMPTY footer.
*
* ## Why nothing was red before this file
*
* `BaseSchema` is `.passthrough()` (`packages/types/src/zod/base.zod.ts:241`),
* so `actions` rode through `safeValidateSchema` unvalidated and every gate
* stayed green while the rendered dialog had no buttons at all. The renderer
* (`packages/components/src/renderers/overlay/alert-dialog.tsx`) draws
* `AlertDialogCancel` ONLY from `schema.cancelText` and `AlertDialogAction`
* ONLY from `schema.actionText`; `schema.actions` has zero read sites.
*
* ## The two pins, and why both
*
* The DOM pin is the one that names the SYMPTOM the card reported (an empty
* footer), and it is the one that would have gone red on `origin/main`: all
* four fixtures drew zero footer buttons. The fixture-key pin is the cheap
* companion that closes the CLASS — it walks every catalog entry, not just the
* four, so a future `alert-dialog` node re-authored under `actions` reds here
* even if nobody adds a render assertion for it.
*
* Each pin is paired with a live control, because a render that draws nothing
* satisfies a "no `actions` key" assertion trivially and a harness that renders
* nothing at all satisfies both:
*
* - `RENDERS_IN_THE_READ_DIALECT` — an inline node in the read dialect MUST
* draw two footer buttons. If the harness cannot see a footer button, this
* control reds first and the fixture rows below mean nothing.
* - `THE_PRE_REPAIR_SHAPE_DRAWS_NOTHING` — the same node authored the way the
* fixtures were authored (an `actions` array, no label keys) MUST draw zero
* footer buttons, and MUST still validate. That is the defect reproduced in
* one place, and it is what makes the fixture rows a measurement of the
* SPELLING rather than of some unrelated render failure.
*
* ⛔ Do NOT "fix" a red row here by declaring `actions` on `AlertDialogSchema`.
* Making a variant-carrying action list live is a renderer + types feature on
* the manual floor, explicitly ruled out of this card's scope; the fixtures
* follow the dialect the renderer READS.
*/
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import '@object-ui/components';
import { SchemaRenderer, toRenderableSchema } from '@object-ui/react';
import { safeValidateSchema } from '@object-ui/types/zod';
import { allExamples, getExample } from '../src/index.js';

/** The four entries the docs page embeds, in the order it embeds them. */
const FIXTURE_IDS = [
'components-overlay-alert-dialog/basic-alert-dialog',
'components-overlay-alert-dialog/destructive-action',
'components-overlay-alert-dialog/confirmation-dialog',
'components-overlay-alert-dialog/custom-actions',
] as const;

/**
* Render one entry the way the docs gallery does, forced OPEN.
*
* `defaultOpen` is the only thing the test adds: the fixtures are authored
* closed (that is what the docs page wants — a trigger you click), and a closed
* Radix dialog mounts no content, so the footer could not be measured at all.
* Everything the assertions read comes from the fixture's own keys.
*/
function footerButtons(schema: unknown): string[] {
render(
<SchemaRenderer
schema={toRenderableSchema({ ...(schema as object), defaultOpen: true } as never) as never}
/>,
);
// Radix portals the content to document.body, so the RTL container is empty
// by construction — query the dialog itself. An `alert-dialog` has no close
// affordance of its own, so every button inside the content IS a footer
// button (the trigger stays behind, in the container).
const content = document.body.querySelector('[role="alertdialog"]');
if (!content) return [];
return Array.from(content.querySelectorAll('button')).map((b) => b.textContent ?? '');
}

/** Report the issues rather than `false`, so a red run says what broke. */
function reasons(schema: unknown): string[] {
const r = safeValidateSchema(schema);
return r.success ? [] : r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`);
}

const READ_DIALECT_NODE = {
type: 'alert-dialog',
title: 'Are you sure?',
trigger: { type: 'button', label: 'Open' },
cancelText: 'Cancel',
actionText: 'Continue',
};

const PRE_REPAIR_NODE = {
type: 'alert-dialog',
title: 'Are you sure?',
trigger: { type: 'button', label: 'Open' },
actions: [
{ type: 'button', label: 'Cancel', variant: 'outline' },
{ type: 'button', label: 'Continue', variant: 'destructive' },
],
};

describe('objectui#7693 — controls: the harness can tell a drawn footer from an empty one', () => {
it('RENDERS_IN_THE_READ_DIALECT: `cancelText` + `actionText` draw two footer buttons', () => {
expect(footerButtons(READ_DIALECT_NODE)).toEqual(['Cancel', 'Continue']);
});

it('THE_PRE_REPAIR_SHAPE_DRAWS_NOTHING: an `actions` array draws an empty footer', () => {
// The defect, reproduced in one place. `actions` is not refused — it is
// simply never read, which is why every gate stayed green.
expect(footerButtons(PRE_REPAIR_NODE)).toEqual([]);
expect(reasons(PRE_REPAIR_NODE)).toEqual([]);
});
});

describe('objectui#7693 — every alert-dialog fixture renders a real footer', () => {
it.each(FIXTURE_IDS)('%s draws at least one footer button', (id) => {
const labels = footerButtons(getExample(id).schema);
expect(labels.length).toBeGreaterThanOrEqual(1);
// ...and each one carries text, so a button rendered from an empty label
// cannot satisfy the count.
for (const label of labels) expect(label.trim().length).toBeGreaterThan(0);
});

it.each(FIXTURE_IDS)('%s still validates under safeValidateSchema', (id) => {
expect(reasons(getExample(id).schema)).toEqual([]);
});
});

describe('objectui#7693 — no alert-dialog node in the catalog authors `actions`', () => {
/** Every object in an example's schema tree, the example's id carried along. */
function* nodes(value: unknown, id: string): Generator<[string, Record<string, unknown>]> {
if (Array.isArray(value)) {
for (const item of value) yield* nodes(item, id);
} else if (value !== null && typeof value === 'object') {
yield [id, value as Record<string, unknown>];
for (const child of Object.values(value as Record<string, unknown>)) yield* nodes(child, id);
}
}

it('the whole catalog is clean, not just the four the docs page embeds', () => {
const offenders: string[] = [];
for (const example of allExamples()) {
for (const [id, node] of nodes(example.schema, example.id)) {
if (node.type === 'alert-dialog' && 'actions' in node) offenders.push(id);
}
}
expect(offenders).toEqual([]);
});

it('anti-vacuity: the walk really does reach alert-dialog nodes', () => {
// Without this, the assertion above passes just as well when the walk is
// broken, or when the catalog holds no alert-dialog at all.
const seen = new Set<string>();
for (const example of allExamples()) {
for (const [id, node] of nodes(example.schema, example.id)) {
if (node.type === 'alert-dialog') seen.add(id);
}
}
expect([...seen].sort()).toEqual([...FIXTURE_IDS].sort());
});

it('anti-vacuity: the offender test would catch one', () => {
// The same predicate, over the pre-repair shape, must report it.
const offenders: string[] = [];
for (const [id, node] of nodes(PRE_REPAIR_NODE, 'inline/pre-repair')) {
if (node.type === 'alert-dialog' && 'actions' in node) offenders.push(id);
}
expect(offenders).toEqual(['inline/pre-repair']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,15 @@ const NODE_CENSUS: Readonly<Record<string, { rendered: number; noElement: number
// carrying an action object on `onClick`, and are now the registered
// `toast` / `sonner` nodes their own renderers execute. Catalog-authored,
// no renderer touched — the case this table's header sanctions.
button: { rendered: 126, noElement: 0 },
//
// 126 -> 118 with objectui#7693: the four `components-overlay-alert-dialog/*`
// fixtures each authored two `type: 'button'` nodes under an `actions` array
// the `alert-dialog` renderer never reads, so those eight nodes drew nothing
// and the demos showed an empty footer. They are now the two label keys the
// renderer DOES read (`cancelText` / `actionText`), which are strings rather
// than nodes and so leave this walk. Catalog-authored, no renderer touched —
// the case this table's header sanctions.
button: { rendered: 118, noElement: 0 },
input: { rendered: 48, noElement: 0 },
checkbox: { rendered: 12, noElement: 0 },
switch: { rendered: 7, noElement: 0 },
Expand Down
67 changes: 55 additions & 12 deletions packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,18 @@
* and its own changeset grade (the objectui#7104 ruling); the pins below
* record today's state so that the PR which retires them re-derives these
* lines deliberately rather than passing unnoticed.
* - The four schema-catalog fixtures author `actions`, a key no surface
* carries, so the docs page's own examples render an empty footer —
* objectui#7693. Pinned here as that card's filed premise; its fix goes red
* here and re-derives the pin.
* - RESOLVED, and re-derived rather than deleted: the four schema-catalog
* fixtures used to author `actions`, a key no surface carries, so the docs
* page's own examples rendered an empty footer — objectui#7693. That card
* landed and this file went red exactly as predicted above, on all four
* membership legs. The block at the bottom is the SAME pin re-derived onto
* the other side of the flip: the fixtures now author the read dialect, and
* what it asserts is the TYPES-side reading (every key they author is a
* member of the mirror's shape), not the render-side one. The render-side
* half lives with the catalog, in
* `examples/schema-catalog/test/alert-dialog-footer-read-dialect-7693.test.tsx`
* — deliberately not duplicated here, because this package cannot see a
* renderer and that one cannot see the mirror's shape.
*/

import { describe, expect, it } from 'vitest';
Expand Down Expand Up @@ -318,17 +326,52 @@ describe('the docs page publishes the read dialect (objectui#7104)', () => {
});
});

describe('the schema-catalog fixtures still author `actions` — objectui#7693, pinned as its filed premise', () => {
it.each(FIXTURES)('%s.json carries an `actions` array and neither `cancelText` nor `actionText`', (name) => {
const fixture = JSON.parse(read(`${FIXTURE_DIR}/${name}.json`)) as Record<string, unknown>;
expect(Array.isArray(fixture.actions)).toBe(true);
expect(fixture).not.toHaveProperty('cancelText');
expect(fixture).not.toHaveProperty('actionText');
describe('the schema-catalog fixtures author the READ dialect — objectui#7693, the premise pin re-derived', () => {
const fixture = (name: string): Record<string, unknown> =>
JSON.parse(read(`${FIXTURE_DIR}/${name}.json`)) as Record<string, unknown>;

it.each(FIXTURES)('%s.json authors `cancelText` and `actionText` and no `actions` array', (name) => {
const json = fixture(name);
expect(json).not.toHaveProperty('actions');
expect(typeof json.cancelText).toBe('string');
expect(typeof json.actionText).toBe('string');
});

it('every key any fixture authors is a MEMBER of the mirror — the reading passthrough used to hide', () => {
// The types-side half of objectui#7693, and the only half this package can
// measure. `BaseSchema` is `.passthrough()`, so `safeParse` says nothing
// about whether an authored key is carried by anything; membership in the
// mirror's own shape is the question, and it is asked here rather than in
// the catalog because `AlertDialogZod.shape` is not visible from there.
const authored = new Set(FIXTURES.flatMap((name) => Object.keys(fixture(name))));
const undeclared = [...authored].filter((key) => !(key in shape)).sort();
expect(undeclared).toEqual([]);
});

it('control: `actions` is not a member, so the leg above really would catch it', () => {
// Without this, the assertion above passes just as well against a mirror
// that declares everything, and the four fixture legs read as a tautology.
expect('actions' in shape).toBe(false);
expect(['type', 'title', 'trigger', 'cancelText', 'actionText'].every((k) => k in shape)).toBe(true);
});

it('control: the pre-repair shape STILL parses green — passthrough did not change', () => {
// Why nothing red covered objectui#7693 before it landed, kept as a live
// reading rather than as history: re-authoring a fixture under `actions`
// would sail through `safeParse` again, which is why the membership leg
// above is the guard and `.success` is not.
const preRepair = {
type: 'alert-dialog',
title: 'Are you sure?',
trigger: { type: 'button', label: 'Open' },
actions: [{ type: 'button', label: 'Cancel' }, { type: 'button', label: 'Continue' }],
};
expect(AlertDialogZod.safeParse(preRepair).success).toBe(true);
});

it('and every one of them parses GREEN regardless — passthrough is why nothing red covers objectui#7693', () => {
it('and every one of them parses GREEN — now with the keys the renderer reads', () => {
for (const name of FIXTURES) {
expect(AlertDialogZod.safeParse(JSON.parse(read(`${FIXTURE_DIR}/${name}.json`))).success, name).toBe(true);
expect(AlertDialogZod.safeParse(fixture(name)).success, name).toBe(true);
}
});
});
Loading