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
34 changes: 34 additions & 0 deletions .changeset/10585-hook-object-roster-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@object-ui/app-shell': patch
---

The Hook inspector's object picker stops calling a selected object "not
published", and stops telling the author to publish one, while its object
roster is still loading or after the fetch failed (objectui#10585).

The picker made two claims from the object catalog: a selected object the
catalog does not list was flagged `(not published)`, and an empty list printed
"No objects found — publish an object, then pick it here." Both were read off
the list alone, and the list is empty while the fetch is in flight and after it
failed, exactly as it is for a catalog with no objects. So every selected
object was called unpublished, and the author was told to publish one, before
the catalog had answered; on a failed fetch the claims never cleared.

`useObjectOptions` now reports a failure as its own fact: beside `options` and
`loading` it returns `error`, spelled the way `useObjectFields` spells it, and
`null` while in flight and after an answer. `options` and `loading` are
unchanged, so the action and page-block object pickers, which read only
`options` and fall back to a free-text input while it is empty, behave as
before.

The Hook inspector folds `loading` and `error` through `rosterFrom`, the rule
the objectui#8862 / objectui#9651 family set for the designer's pickers: only
an answered roster may make a claim.

- Answered: unchanged — a selected object the catalog does not list is flagged,
and an empty catalog prints the "publish an object" copy.
- In flight: a selected object is shown bare, and the empty list says it is
loading.
- Failed: a selected object is shown bare and stays toggleable, and a status
notice says the options could not be loaded and names the cause, with the
same localized title the designer's other pickers use for a failed catalog.
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#10585 — the Hook inspector's object picker makes its two claims
* only from a roster that ANSWERED.
*
* The picker makes two claims from the object catalog `useObjectOptions`
* fetches: a selected object the catalog does not list is flagged
* `(not published)`, and an empty list prints "No objects found — publish an
* object, then pick it here." Both used to be read off `options` alone, and
* `options` is `[]` while the fetch is in flight and after it failed — exactly
* what a catalog with no objects leaves. So every selected object was called
* unpublished, and the author was told to publish one, before the catalog had
* spoken and, on a failed fetch, permanently.
*
* The rule is the one objectui#8862 / objectui#9651 set for the designer's
* pickers, and the arms are the ones `ViewColumnInspector.rosterFailure.test.tsx`
* pins for that inspector:
*
* • in flight → no flag, no "publish" copy, no failure notice
* • FAILED → no flag, no "publish" copy, plus a notice naming the cause,
* and the selection stays editable
* • answered, object genuinely absent → flag, and an empty answered catalog
* still prints the "publish" copy (the controls that the
* claims still fire when they are true)
*
* The real `useObjectOptions` runs; only the metadata client is stubbed, and
* its `list` is settled by the test.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, act, waitFor, fireEvent } from '@testing-library/react';

const state = vi.hoisted(() => {
let settle = {
resolve: (_d: unknown) => {},
reject: (_e: unknown) => {},
};
const pending = { current: new Promise<unknown>(() => {}) };
return {
metadataClient: {
get: vi.fn(async () => undefined),
list: vi.fn(() => pending.current),
},
/** Arm a fresh unsettled `list` response; call before mounting. */
hold() {
pending.current = new Promise<unknown>((resolve, reject) => {
settle = { resolve, reject };
});
// The hook attaches its own handler only after mount; without this the
// rejection is "unhandled" for one turn and fails the test itself.
pending.current.catch(() => {});
},
land(docs: unknown) {
settle.resolve(docs);
},
fail(err: unknown) {
settle.reject(err);
},
};
});

vi.mock('../useMetadata', () => ({
useMetadataClient: () => state.metadataClient,
}));

import { HookDefaultInspector } from './HookDefaultInspector';

afterEach(cleanup);

const FLAGGED = 'ghost_object (not published)';
const PUBLISH_COPY = /publish an object/;

function mount(object?: string) {
state.hold();
const onPatch = vi.fn();
render(
<HookDefaultInspector
type="hook"
name="audit_hook"
draft={{ name: 'audit_hook', events: ['beforeInsert'], ...(object ? { object } : {}) }}
onPatch={onPatch}
readOnly={false}
locale="en-US"
/>,
);
return { onPatch };
}

/** The failure notice the picker renders for a failed roster, if any. */
const failureNotice = () => screen.queryByTestId('hook-object-roster-failure');

/**
* Reject the in-flight request and flush the hook's `catch` plus the re-render
* it schedules — explicitly, not by waiting for the notice, so the flag and
* empty-copy rows do not depend on the notice existing.
*/
async function failRequest(err: unknown): Promise<void> {
await act(async () => {
state.fail(err);
await Promise.resolve();
await Promise.resolve();
});
}

describe('HookDefaultInspector — the object roster FAILED to load (objectui#10585)', () => {
it('does not call a selected object "not published" after the fetch failed', async () => {
mount('ghost_object');
await failRequest(new Error('503 Service Unavailable'));

expect(
screen.getByRole('checkbox', { name: 'ghost_object' }),
'the selection stays on screen, bare',
).toBeTruthy();
expect(
screen.queryByText(FLAGGED),
'a roster that never answered cannot testify that the object is unpublished',
).toBeNull();
});

it('does not tell the author to publish an object after the fetch failed', async () => {
mount();
await failRequest(new Error('503 Service Unavailable'));

expect(screen.queryByText(PUBLISH_COPY)).toBeNull();
expect(
screen.queryByText('Loading options…'),
'a failed request is no longer in flight — it must not read as one',
).toBeNull();
});

it('tells the author the roster failed, and names the cause', async () => {
mount('ghost_object');
await failRequest(new Error('503 Service Unavailable'));

expect(failureNotice(), 'the failure reached the picker').not.toBeNull();
expect(failureNotice()?.getAttribute('role')).toBe('status');
expect(failureNotice()?.textContent).toContain('Options could not be loaded');
expect(failureNotice()?.textContent).toContain('503 Service Unavailable');
});

it('leaves the selection EDITABLE after the failure — un-ticking it reaches the draft', async () => {
const { onPatch } = mount('ghost_object');
await failRequest(new Error('boom'));

fireEvent.click(screen.getByRole('checkbox', { name: 'ghost_object' }));
expect(
onPatch,
'a failed catalog must not also block authoring',
).toHaveBeenCalledWith({ object: [] });
});
});

describe('HookDefaultInspector — the object roster is IN FLIGHT (objectui#10585)', () => {
it('shows a selected object bare, unflagged, with no failure notice', () => {
// No `waitFor`: waiting is what hides the in-flight window.
mount('ghost_object');
expect(screen.getByRole('checkbox', { name: 'ghost_object' })).toBeTruthy();
expect(screen.queryByText(FLAGGED)).toBeNull();
expect(failureNotice(), 'an unanswered request is not a failed one').toBeNull();
});

it('does not tell the author to publish an object while loading', () => {
mount();
expect(screen.queryByText(PUBLISH_COPY)).toBeNull();
expect(screen.getByText('Loading options…')).toBeTruthy();
});
});

describe('HookDefaultInspector — CONTROLS: an ANSWERED roster still makes its claims', () => {
it('flags a selected object the answered catalog does not list, with no notice', async () => {
mount('ghost_object');
await act(async () => {
state.land([{ name: 'account', label: 'Account' }]);
});

await waitFor(() => {
expect(screen.getByRole('checkbox', { name: FLAGGED })).toBeTruthy();
});
expect(failureNotice(), 'a completed load is not a failure').toBeNull();
});

it('prints the "publish an object" copy for a catalog that answered empty', async () => {
mount();
await act(async () => {
state.land([]);
});

await waitFor(() => {
expect(screen.getByText(PUBLISH_COPY)).toBeTruthy();
});
expect(failureNotice()).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
* `object` is spec `string | string[]` (or `'*'` for every object). The picker
* normalises to/from that: `'*'` ⇒ all, one selection ⇒ a string, many ⇒ an
* array. Any already-selected object not in the live catalog is preserved as a
* synthesized option so a value is never silently dropped.
* synthesized option so a value is never silently dropped; it is flagged as not
* published only once the catalog has answered (objectui#10585).
*/

import * as React from 'react';
Expand All @@ -35,6 +36,7 @@ import {
InspectorNumberField,
InspectorCheckboxField,
flagUnknownValue,
rosterFrom,
} from './_shared.js';
import { useObjectOptions } from '../previews/useObjectOptions.js';
import { ConditionBuilder, RECORD_CONDITION_SUBJECTS } from './ConditionBuilder.js';
Expand Down Expand Up @@ -125,18 +127,32 @@ export function HookDefaultInspector({
const events: string[] = Array.isArray(draft.events) ? (draft.events as string[]) : [];
const { all: allObjects, names: objectNames } = readObjects(draft.object);

const { options: objectOptions } = useObjectOptions();
const {
options: objectOptions,
loading: objectsLoading,
error: objectsError,
} = useObjectOptions();
// objectui#10585 — the roster's ONE state, read the way the objectui#8862 /
// objectui#9651 family reads it: only an ANSWERED roster may say an object is
// missing. While the fetch is in flight, or after it failed, `objectOptions`
// is `[]` exactly as it is for a catalog with no objects, so a claim made
// from the list alone told the author that every selected object was
// unpublished and that they should publish one.
const roster = rosterFrom({ loading: objectsLoading, error: objectsError });
const rosterAnswered = roster.status === 'loaded';
// Preserve any selected object missing from the live catalog (draft-only /
// cross-package) so it is never dropped from the picker. Its flag reads in
// the designer's locale (objectui#10448).
// the designer's locale (objectui#10448), and it is drawn only once the
// roster answered — until then the name is shown bare, which asserts nothing.
const pickerOptions = React.useMemo(() => {
const known = new Set(objectOptions.map((o) => o.value));
const notPublished = t('engine.form.notPublished', locale);
const extra = objectNames
.filter((n) => !known.has(n))
.map((n) => ({ value: n, label: flagUnknownValue(n, notPublished, locale) }));
.map((n) => ({ value: n, label: rosterAnswered ? flagUnknownValue(n, notPublished, locale) : n }));
return [...extra, ...objectOptions];
}, [objectOptions, objectNames, locale]);
}, [objectOptions, objectNames, locale, rosterAnswered]);
const rosterFailure = roster.status === 'error' ? roster.message : undefined;

const patchBody = (p: Record<string, unknown>) => onPatch({ body: { ...body, ...p } });

Expand Down Expand Up @@ -206,10 +222,18 @@ export function HookDefaultInspector({
onCommit={(on) => onPatch({ object: on ? ALL_OBJECTS : writeObjects(false, objectNames) })}
disabled={readOnly}
/>
{!allObjects && (
{/* The empty-list copy is a MEASUREMENT ("no objects found"), so only
an answered roster may print it; in flight it says it is loading.
A failed roster with nothing selected draws no list at all — the
notice below is the whole answer there. */}
{!allObjects && (pickerOptions.length > 0 || rosterFailure === undefined) && (
<div className="max-h-40 space-y-1 overflow-auto rounded-md border p-2">
{pickerOptions.length === 0 ? (
<p className="text-[11px] text-muted-foreground">No objects found — publish an object, then pick it here.</p>
<p className="text-[11px] text-muted-foreground">
{rosterAnswered
? 'No objects found — publish an object, then pick it here.'
: tr('engine.form.loadingOptions')}
</p>
) : (
pickerOptions.map((o) => (
<InspectorCheckboxField
Expand All @@ -223,6 +247,27 @@ export function HookDefaultInspector({
)}
</div>
)}
{/* objectui#10585 — a failed roster says so, with its cause. This is
the notice `InspectorSelectField` renders for the same fact
(objectui#9651): the same role, tone, localized title and cause
span. That notice lives inside the primitive and is not exported,
and this picker is a checkbox list rather than a select, so the
markup is repeated here; the wording is the shared catalogue key. */}
{!allObjects && rosterFailure !== undefined && (
<p
role="status"
data-testid="hook-object-roster-failure"
className="text-[11px] leading-snug text-amber-600 dark:text-amber-300"
>
{tr('engine.form.optionsLoadFailedTitle')}
{rosterFailure ? (
<>
{' '}
<span className="break-words font-mono text-[10px] opacity-80">{rosterFailure}</span>
</>
) : null}
</p>
)}
{!allObjects && objectNames.length === 0 && (
<p className="text-[11px] text-amber-600 dark:text-amber-400">Pick at least one object (or All objects).</p>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#10585 — `useObjectOptions` tells a failed fetch apart from a catalog
* that answered with no objects.
*
* Both leave `options: []` with `loading: false`; before this card nothing
* else was returned, so a caller could not tell the two apart and made the
* claims of an answered roster ("not published", "publish an object") on a
* failure. The hook now publishes `error`, spelled the way `useObjectFields`
* spells it, so `rosterFrom({ loading, error })` reads both hooks alike.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { renderHook, waitFor, cleanup } from '@testing-library/react';

const state = vi.hoisted(() => ({
metadataClient: {
list: vi.fn(async (): Promise<unknown[]> => []),
},
}));

vi.mock('../useMetadata', () => ({
useMetadataClient: () => state.metadataClient,
}));

import { useObjectOptions } from './useObjectOptions';

afterEach(cleanup);

describe('useObjectOptions — failure is its own fact (objectui#10585)', () => {
it('a rejected `client.list` sets `error` to the cause, with `loading` false and no options', async () => {
state.metadataClient.list.mockImplementationOnce(async () => {
throw new Error('503 Service Unavailable');
});
const { result } = renderHook(() => useObjectOptions());

await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe('503 Service Unavailable');
expect(result.current.options).toEqual([]);
});

it('a resolved EMPTY list leaves `error` null — an answer, not a failure', async () => {
state.metadataClient.list.mockImplementationOnce(async () => []);
const { result } = renderHook(() => useObjectOptions());

await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBeNull();
expect(result.current.options).toEqual([]);
});

it('in flight, `error` is null and `loading` is true', () => {
state.metadataClient.list.mockImplementationOnce(() => new Promise<unknown[]>(() => {}));
const { result } = renderHook(() => useObjectOptions());

expect(result.current.loading).toBe(true);
expect(result.current.error).toBeNull();
});
});
Loading
Loading