diff --git a/.changeset/7143-data-error-state-migration.md b/.changeset/7143-data-error-state-migration.md new file mode 100644 index 000000000..1d490b3e2 --- /dev/null +++ b/.changeset/7143-data-error-state-migration.md @@ -0,0 +1,58 @@ +--- +'@object-ui/components': minor +'@object-ui/plugin-list': minor +--- + +`DataErrorState` accepts the icon props `DataEmptyState` already had, and `ListView`'s +load-failure panel is now rendered by the error state instead of the empty state +(objectui#7143; maintainer ruling 2026-09-01, director decision batch #27). + +`ListView` rendered its load FAILURE through `DataEmptyState` — the component named for +the *empty* case — passing it a destructive icon, error copy and a retry action, while +`DataErrorState`, in the same file and with the same layout, had no consumer anywhere in +the repo. objectui#7132 closed the accessibility half of that collision (the panel now +declares `role="alert"` over the empty state's `role="status"` default) and deliberately +left the structural half alone: `DataErrorState` hardcoded its icon, so the swap was a +props-surface question plus a visual change rather than a rename. + +**`@object-ui/components` — three additive optional props on `DataErrorState`**, mirrored +from `DataEmptyState` in the same file rather than spelled a second way: + +- `icon?: React.ReactNode` — rendered above the title; falls back to the `AlertCircle` + glyph the component has always drawn. +- `showIcon?: boolean` (default `true`) — `false` omits the icon container entirely. +- `iconWrapperClassName?: string` — REPLACES the wrapper's default class rather than + merging with it, so `""` renders the icon raw. `DataEmptyState` resolves it with `??` + against its own default and this does the same, against + `flex size-10 items-center justify-center rounded-lg bg-destructive/10` — the destructive + square `DataErrorState` already drew. + +Same names, same types, same default semantics as the empty state's; nothing existing on +`DataErrorState` changed, and a call site that passes none of the three renders exactly +what it rendered before. `illustration` and `action` were deliberately NOT mirrored — the +ruling pins three props, and this component's retry affordance is already spelled +`onRetry` / `retryLabel` (plus `children` for a call site that needs its own control). + +One non-prop addition rides along, called out rather than folded in: the icon wrapper now +carries `data-slot="data-error-state-icon"`, mirroring the empty state's +`data-empty-state-icon`. Without it the wrapper `iconWrapperClassName` governs has no +name — untestable and unstylable — and migrating a call site off `DataEmptyState` would +DROP that identifier rather than rename it. + +**`@object-ui/plugin-list` — the panel changes component identity, not pixels.** The call +site passes the same custom icon through the new `icon` prop, the same +`iconWrapperClassName="mb-3"`, the same title, and the same copy through `message` (the +error state's spelling of `description`); its retry ` )} - /> + ) : loading && data.length === 0 ? (
Promise) { + const ds = { + find: vi.fn().mockImplementation(find), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + return render( + + + , + ); +} + +const failing = (error: unknown) => renderWith(() => Promise.reject(error)); +const forbidden = () => Object.assign(new Error('Forbidden'), { httpStatus: 403 }); + +async function panel(container: HTMLElement, testId: string): Promise { + await waitFor(() => { + expect(container.querySelector(`[data-testid="${testId}"]`)).not.toBeNull(); + }); + return container.querySelector(`[data-testid="${testId}"]`) as HTMLElement; +} + +describe('ListView — the load failure is a DataErrorState (#7143)', () => { + it('IDENTITY: the panel is data-slot="data-error-state"', async () => { + const { container } = failing(forbidden()); + const box = await panel(container, 'list-error-state'); + expect(box.getAttribute('data-slot')).toBe('data-error-state'); + // Named in both directions: the component it used to borrow must be gone + // from this node, not merely joined by a second slot value. + expect(box.getAttribute('data-slot')).not.toBe('data-empty-state'); + }); + + it('UNCHANGED: role, test id and error kind survive the swap', async () => { + const { container } = failing(forbidden()); + const box = await panel(container, 'list-error-state'); + expect(box.getAttribute('role')).toBe('alert'); + expect(box.getAttribute('data-error-kind')).toBe('forbidden'); + expect(box.textContent).toMatch(/permission|access/i); + }); + + it('UNCHANGED: the per-kind glyph still reaches the panel, in a stripped wrapper', async () => { + // The reason `DataErrorState` needed props at all: this panel draws an + // outage differently from a denial, and it removes the primitive's box. + const { container } = failing(new TypeError('Failed to fetch')); + const box = await panel(container, 'list-error-state'); + const icon = box.querySelector('[data-slot="data-error-state-icon"]'); + expect(icon).not.toBeNull(); + expect(icon!.querySelector('svg')).not.toBeNull(); + // `iconWrapperClassName` REPLACES rather than merges, so the destructive + // square the primitive draws by default must not have survived. + expect(icon!.className).toBe('mb-3'); + }); + + it('UNCHANGED: the retry button is still rendered inside the panel', async () => { + const { container } = failing(forbidden()); + const box = await panel(container, 'list-error-state'); + const retry = box.querySelector('[data-testid="list-error-retry"]'); + expect(retry).not.toBeNull(); + expect(retry!.textContent).toMatch(/retry/i); + }); + + it('UNCHANGED: an enable-block denial still offers no retry', async () => { + const err = Object.assign(new Error('Object API is disabled'), { + httpStatus: 404, + code: 'OBJECT_API_DISABLED', + }); + const { container } = failing(err); + const box = await panel(container, 'list-error-state'); + expect(box.querySelector('[data-testid="list-error-retry"]')).toBeNull(); + // The panel itself is still there — "no retry" must not be reached by + // rendering nothing at all. + expect(box.getAttribute('data-slot')).toBe('data-error-state'); + }); + + it('CONTROL: the genuine empty branch is still a DataEmptyState with role="status"', async () => { + const { container } = renderWith(() => Promise.resolve([])); + const box = await panel(container, 'empty-state'); + expect(box.getAttribute('data-slot')).toBe('data-empty-state'); + expect(box.getAttribute('role')).toBe('status'); + }); +});