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
46 changes: 46 additions & 0 deletions .changeset/8504-emptyvalue-remaining-carriers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
'@object-ui/app-shell': minor
'@object-ui/plugin-dashboard': minor
'@object-ui/plugin-grid': minor
'@object-ui/plugin-chatbot': minor
'@object-ui/console': minor
---

Six hand-rolled em-dash placeholders now draw the shared `EmptyValue` from
`@object-ui/components` (objectui#8504), closing the *no accessible name* half
of the class objectui#8491 / PR #8503 opened.

**The accessibility defect.** Each site built its own `<span>` holding a bare em
dash. `EmptyValue` carries three things none of them had: a `data-slot` of
`empty-value`, an `aria-label` resolved through the i18n label hook, and
`select-none` / `no-underline` / `pointer-events-none`. So a screen-reader user
reaching one of these cells heard a naked punctuation mark, while a neighbouring
cell drawn by a type-aware renderer was announced as "No value". Two of the
sites make the inconsistency reachable inside one surface: the metadata list
renders column 0's placeholder *inside the row's `<Link>`*, where the
hand-rolled span inherited the link colour and stayed selectable, and the
dashboard record drawer sits next to renderers that already returned the shared
component.

The six: the metadata list's `defaultCell` and the Audit tab's lock column
(`@object-ui/app-shell`), the dashboard record drawer's empty `<dd>`
(`@object-ui/plugin-dashboard`), the import wizard's saved-mapping transform
cell (`@object-ui/plugin-grid`), the AI-approvals `JsonBlock`
(`@object-ui/plugin-chatbot`), and the Public Forms object column
(`@object-ui/console`).

**A deliberate visual change, not a no-op.** Five sites drop
`text-muted-foreground` (or `/60`) for the shared `text-muted-foreground/50`, so
every placeholder in the workspace is now one colour. The glyph is unchanged
everywhere. The sixth, `JsonBlock`, keeps its `text-xs` through `className`
because it stands where a `text-xs <pre>` would and has no shared neighbour to
match — its delta is the accessible name and the three affordances only.

**Two adjacent lines in the same file, taken deliberately.** The AI-approvals
drawer's `proposed_by` / `decided_by` fields fell back to a bare `'—'` text node
inside a plain `<div>` — a different source spelling of the same rendered
defect, individually verified on the card rather than swept up. They are
converted too. `formatRelative`'s `if (!s) return '—'` is not: that helper is
declared `: string`, so its fallback is not a node.

Filled values are untouched in every path.
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The Public Forms table's Object column draws the shared `EmptyValue`
* (objectui#8504).
*
* ## The defect
*
* A form declaring no `object` fell to `<span
* className="text-muted-foreground">—</span>` — no `data-slot`, no
* `aria-label`, none of the shared component's `select-none` / `no-underline` /
* `pointer-events-none`. In a column headed "Object", a screen-reader user
* heard a naked punctuation mark while the row above announced "showcase_task".
*
* ## Reachability was CHECKED before the swap, not assumed
*
* This is the one carrier on the card that lives in `apps/`, not `packages/` —
* an app's dependency tier, not a library's, and outside the packages-only
* `git grep` pathspec every census in the thread used (a glob rooted at
* `packages`, which never sees `apps`). Measured: `@object-ui/components`
* is on `apps/console`'s `devDependencies` (`workspace:*`) and 29 files under
* `apps/console/src` already import from it — this page among them. `EmptyValue`
* joins an import list that was already there; no manifest edge was added, and
* none was needed.
*
* ## Which case DISCRIMINATES — MEASURED, not predicted
*
* The caricature was RUN: the Object cell rewritten to `<EmptyValue />`
* unconditionally, objects included. Every case goes red, on a different
* assertion:
*
* - `exactly ONE of the two rows draws a placeholder` fails on "and the
* filled row does NOT" — the assertion that fails BECAUSE a filled cell
* gained a placeholder.
* - `NON-REGRESSION` fails one assertion earlier, on "the object reaches the
* cell": the caricature also stops the column printing objects, so its own
* `no placeholder` half is never reached.
* - `THE DEFECT` fails ONLY on its control. Its headline claim — "the
* objectless row has an accessible name" — is equally true of a table that
* has stopped printing objects.
*
* Reverting the fix turns `THE DEFECT` and the one-of-two case red on their
* headline assertions and leaves `NON-REGRESSION` green.
*
* ## The visual delta
*
* `text-muted-foreground` (full opacity) becomes the shared
* `text-muted-foreground/50` — one deliberate step more muted, plus the three
* affordances and the accessible name. The glyph is unchanged.
*
* Assertions are scoped to ONE cell of ONE row (objectui#8495).
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor, within } from '@testing-library/react';
import '@testing-library/jest-dom';

/**
* Two published public forms — one declaring an `object`, one not. Both need
* `sharing.allowAnonymous` and a parseable `publicLink` to reach the table.
*/
const { ADAPTER } = vi.hoisted(() => {
const form = (name: string, slug: string, object?: string) => ({
name,
label: name,
...(object ? { object } : {}),
type: 'simple',
sections: [{ label: 'Task', fields: ['title'] }],
sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${slug}` },
});
// A STABLE singleton: a fresh object per render loops the page's load effect.
const ADAPTER = {
getClient: () => ({
meta: {
getItems: async (type: string) =>
type === 'view'
? [
{ spec: form('objectless_form', 'objectless') },
{ spec: form('task_form', 'log-time', 'showcase_task') },
]
: [],
saveItem: async () => ({ ok: true }),
},
}),
};
return { ADAPTER };
});

vi.mock('@object-ui/app-shell', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => ADAPTER,
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

// Imported AFTER the mocks so the page picks them up.
import { PublicFormsPage } from './PublicFormsPage';

afterEach(cleanup);

/** The shared placeholder inside ONE element, or null. */
const emptyIn = (el: HTMLElement): HTMLElement | null =>
el.querySelector('[data-slot="empty-value"]');

async function mount() {
const { container } = render(<PublicFormsPage />);
// `queryByText` THROWS on multiple matches, and the Name cell prints the
// label and the name — so a single-match query never resolves here.
await waitFor(() =>
expect(screen.queryAllByText('objectless_form').length).toBeGreaterThan(0),
);

const headers = () =>
Array.from(container.querySelectorAll('thead th')).map((th) =>
(th.textContent ?? '').trim(),
);

/** The cell under `header` in the row whose Name cell reads `name`. */
const cell = (name: string, header: string): HTMLElement => {
const idx = headers().indexOf(header);
expect(idx, `the ${header} column is present — headers were ${JSON.stringify(headers())}`)
.toBeGreaterThanOrEqual(0);
const tr = Array.from(container.querySelectorAll('tbody tr')).find((r) =>
(r.textContent ?? '').includes(name),
);
expect(tr, `the row for ${name} rendered`).toBeTruthy();
const td = (tr as HTMLElement).querySelectorAll('td')[idx];
expect(td, `the ${name} row has a cell under ${header}`).toBeTruthy();
return td as HTMLElement;
};
return { cell };
}

describe('PublicFormsPage object cell uses the shared EmptyValue (objectui#8504)', () => {
it('THE DEFECT — a form declaring no object carries an accessible name', async () => {
const { cell } = await mount();
const placeholder = emptyIn(cell('objectless_form', 'Object'));

expect(placeholder, 'the objectless cell draws the shared placeholder').not.toBeNull();
expect(placeholder, 'and therefore has an accessible name').toHaveAttribute('aria-label');
expect(
(placeholder as HTMLElement).getAttribute('aria-label'),
'the name is a word, never a naked punctuation mark',
).toBe('No value');
expect((placeholder as HTMLElement).textContent, 'the glyph is unchanged').toBe('—');
// CONTROL — without this, a table printing NO objects passes above.
expect(
within(cell('task_form', 'Object')).queryByText('showcase_task'),
'CONTROL: the sibling row still prints its object',
).not.toBeNull();
});

it('NON-REGRESSION — a form WITH an object renders its badge and NO placeholder', async () => {
const { cell } = await mount();
const filled = cell('task_form', 'Object');

expect(within(filled).queryByText('showcase_task'), 'the object reaches the cell')
.not.toBeNull();
// THE DISCRIMINATING HALF: red for an EmptyValue-everywhere implementation.
expect(emptyIn(filled), 'a cell with an object carries NO placeholder').toBeNull();
});

it('exactly ONE of the two rows draws a placeholder', () => {
// The assertion order matters, and it was measured. `NON-REGRESSION` above
// fails on its FIRST assertion under the caricature — the object stops
// reaching the cell — so its `no placeholder` half never runs. This case
// reaches that half: the empty row still has one, the filled row must not,
// and the second assertion is the one that fails BECAUSE a filled cell
// gained a placeholder.
return mount().then(({ cell }) => {
expect(emptyIn(cell('objectless_form', 'Object')), 'the empty row has one')
.not.toBeNull();
expect(emptyIn(cell('task_form', 'Object')), 'and the filled row does NOT')
.toBeNull();
});
});
});
3 changes: 2 additions & 1 deletion apps/console/src/pages/developer/PublicFormsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
DialogTitle,
Input,
Label,
EmptyValue,
} from '@object-ui/components';
import { Copy, ExternalLink, FormInput, RefreshCw, Code2, Link2, Settings2, Plus } from 'lucide-react';
import { toast } from 'sonner';
Expand Down Expand Up @@ -382,7 +383,7 @@ export function PublicFormsPage() {
{row.object ? (
<Badge variant="secondary">{row.object}</Badge>
) : (
<span className="text-muted-foreground">—</span>
<EmptyValue />
)}
</TableCell>
<TableCell>
Expand Down
Loading
Loading