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
69 changes: 69 additions & 0 deletions .changeset/spotty-donkeys-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
'@object-ui/data-objectstack': minor
'@object-ui/plugin-list': patch
'@object-ui/i18n': patch
---

fix(list): an `OBJECT_API_DISABLED` list request renders an honest cannot-work state instead of the empty state

A list pointed at an object whose `enable` block withholds the API rendered its ordinary
empty state, so *"this page cannot work, and never could"* reached the user as *"you have no
records"* (objectui#4408). The reported instance — `Setup › Advanced › Signing Keys`, whose
`sys_jwks` declares `enable.apiEnabled: false` — could not load for any persona and said so
to nobody. That is also why the upstream defect objectstack#7544 survived review for its
whole life: a merely unpopulated page invites nobody to click through.

The masking had two halves, in two packages, and neither package could see the other:

- **`@object-ui/data-objectstack`** (minor — see the grading note below) — `find()` degraded
**every** 404 into `{ data: [], total: 0 }` and memoised the resource, so the denial arrived
at the surface as a successful empty result, indistinguishable from a genuinely empty
object. The two `enable`-block denials are now let through instead: `OBJECT_API_DISABLED`
(404) and `OBJECT_API_METHOD_NOT_ALLOWED` (405). The memo skips them too — absorbing one
would have pinned the object to "empty" for the rest of the session.
- **`@object-ui/plugin-list`** — the load-error panel gained an `api-disabled` kind. The 405
half was never swallowed, so it already reached this panel, but classified as `network`:
*"check your connection and try again"* for a condition no retry can change. It now says
the object is not exposed through the API, that this is a setting on the object rather than
a permission, and it offers **no Retry** button, because every retry re-fetches the
identical refusal.

Both denials are pure functions of the object's metadata — no user, no permission, no
context — so neither is transient or per-user, which is exactly the case where a silent empty
state is most misleading. Discrimination is on the ADR-0112 `code`, never the status: a
missing collection, a missing record and a disabled object are all 404.

**A genuinely empty object still renders the ordinary empty state**, and a backend without an
optional collection still degrades to empty — pinned in both directions, at the adapter, at
the view, and once end-to-end over a real adapter and a real `ListView`.

Also closes a code-propagation gap on the same path: `find()`'s raw `$expand`/`$search`
branch bypasses `@objectstack/client` and hand-rolled its own error, stamping only `status`.
It now carries the ADR-0112 envelope (`code` + `httpStatus`), so a denial arriving on the
branch a list takes whenever it expands a lookup or runs a search is no longer anonymous.

New strings: `list.loadErrorApiDisabledTitle` / `list.loadErrorApiDisabledMessage`, in the
`en` pack and mirrored in the list defaults map.

## Grading note — why `@object-ui/data-objectstack` is **minor** and not patch

Two independent reasons, either of which is sufficient under this repo's precedent
(objectui#4403 / #4177, and #4485's grading of `@object-ui/core`'s `toDomProps` lift):

1. **The emitted `.d.ts` grows two NEW exports.** `isApiAccessDeniedError(error: unknown):
boolean` and `API_ACCESS_DENIED_CODES` (the readonly tuple
`['OBJECT_API_DISABLED', 'OBJECT_API_METHOD_NOT_ALLOWED']`) are added to the package's
public surface. Additive surface growth is minor.
2. **Observable behaviour on a published API moves.** `ObjectStackDataSource.find()` now
**REJECTS** for the two `enable`-block denial codes where it previously **RESOLVED** with
`{ data: [], total: 0 }`. No signature changed and nothing was removed, but a caller that
relied on those two codes arriving as a successful empty result now receives a rejected
promise carrying `code` + `httpStatus`, and must handle it.

Deliberately unchanged, and still resolving to an empty result exactly as before: a bare 404
with no code, `OBJECT_NOT_FOUND` (still memoised) and `RECORD_NOT_FOUND`. The behaviour move
is scoped to the two denial codes named above and to nothing else.

Not major: this follows AGENTS.md's version-alignment rule — objectui's major tracks
`@objectstack`'s, so this repo's own breaking semantics are declared as minor with the change
described in the body, which is what this note is.
156 changes: 156 additions & 0 deletions packages/app-shell/src/views/objectListApiDisabled-4408.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* 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#4408 — the masking, pinned end to end.
*
* The two halves of this defect live in two packages and neither can see the
* other: `@object-ui/data-objectstack` turned the server's 404 into a resolved
* empty result, and `@object-ui/plugin-list` rendered a resolved empty result
* as its ordinary empty state. Each package's own suite can only pin its own
* half — plugin-list is deliberately backend-agnostic (AGENTS.md #1) and does
* not depend on any adapter. `app-shell` is where both already meet, the same
* reason the defaults-map mirror gate lives here, so the composition is
* asserted here: a real `ObjectStackAdapter` over a stubbed transport, feeding
* the real `ListView`.
*
* The scenario is the reported one. `sys_jwks` declares
* `enable.apiEnabled: false`, so `GET /data/sys_jwks` answers **404** with
* `code: 'OBJECT_API_DISABLED'`, and the Setup page carried the `managedBy`
* empty-state override ("No identity records"). Every persona saw an ordinary,
* unpopulated list. Nobody clicked through, which is also how the upstream
* defect objectstack#7544 survived review for its entire life.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { ListView } from '@object-ui/plugin-list';
import { ObjectStackAdapter } from '@object-ui/data-objectstack';
import { SchemaRendererProvider } from '@object-ui/react';
import type { ListViewSchema } from '@object-ui/types';

/** The Setup page's schema: the `managedBy` empty-state override, as shipped. */
const schema: ListViewSchema = {
type: 'list-view',
objectName: 'sys_jwks',
fields: ['name'],
emptyState: {
icon: 'ShieldAlert',
title: 'No identity records',
message:
'These records are created by the authentication provider — through sign-in, provisioning, and security flows — not added by hand here.',
},
} as ListViewSchema;

/**
* An adapter whose transport answers the data route with `status` + `body`.
* Discovery is answered 200 so `connect()` resolves normally.
*/
function makeAdapter(status: number, body: unknown) {
const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
const href = String(url);
if (href.includes('/data/')) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(
JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
});
const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
return ds;
}

function renderList(ds: any) {
return render(
<SchemaRendererProvider dataSource={ds}>
<ListView schema={schema} dataSource={ds} />
</SchemaRendererProvider>,
);
}

describe('object list · an OBJECT_API_DISABLED 404 is not an empty list (#4408)', () => {
it('renders the honest cannot-work state, NOT the generic empty state', async () => {
const ds = makeAdapter(404, {
code: 'OBJECT_API_DISABLED',
message: 'Object API is disabled for sys_jwks',
});
const { container } = renderList(ds);

const panel = await waitFor(() => {
const el = container.querySelector('[data-testid="list-error-state"]');
expect(el).not.toBeNull();
return el as HTMLElement;
});

expect(panel.getAttribute('data-error-kind')).toBe('api-disabled');

// The masking, gone: the empty state must not be what this page shows, and
// the override copy that used to stand in for the failure must be absent.
expect(container.querySelector('[data-testid="empty-state"]')).toBeNull();
expect(container.textContent).not.toContain('No identity records');

// And the panel says the true thing.
expect(panel.textContent).toMatch(/API/);
expect(panel.textContent).not.toMatch(/connection/i);
});

it('renders the same honest state for the 405 sibling', async () => {
const ds = makeAdapter(405, {
error: { code: 'OBJECT_API_METHOD_NOT_ALLOWED', message: 'Method not allowed' },
});
const { container } = renderList(ds);

const panel = await waitFor(() => {
const el = container.querySelector('[data-testid="list-error-state"]');
expect(el).not.toBeNull();
return el as HTMLElement;
});
expect(panel.getAttribute('data-error-kind')).toBe('api-disabled');
expect(container.querySelector('[data-testid="empty-state"]')).toBeNull();
});

/**
* The binding control, in the card's own words: a genuinely empty object must
* STILL render the ordinary empty state. Empty is the overwhelmingly common
* case and the one the empty state exists for — a fix that turned "no records
* yet" into an error surface would be worse than the bug it repairs.
*/
it('CONTROL: a 2xx with zero rows still renders the ordinary empty state', async () => {
const ds = makeAdapter(200, { success: true, data: { records: [], total: 0 } });
const { container } = renderList(ds);

await waitFor(() => {
expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull();
});

// The override copy is intact, and no error surface appeared.
expect(container.textContent).toContain('No identity records');
expect(container.querySelector('[data-testid="list-error-state"]')).toBeNull();
});

/**
* The other control: a 404 that is NOT an enable-block denial. A backend
* without this optional collection still degrades to empty — the probes
* (AppHeader's `sys_presence` / `sys_activity`, …) read empty data as
* "feature unavailable", and turning those into error panels would be a
* second, louder regression.
*/
it('CONTROL: a bare 404 (collection absent) still renders the empty state', async () => {
const ds = makeAdapter(404, { message: 'Not found' });
const { container } = renderList(ds);

await waitFor(() => {
expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull();
});
expect(container.querySelector('[data-testid="list-error-state"]')).toBeNull();
});
});
Loading
Loading