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
10 changes: 10 additions & 0 deletions .changeset/6846-studio-design-empty-registry-pins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
---

Pin the two studio-design consumers that were already honest on an empty
metadata registry — `ObjectSettingsPanel` ("No default object inspector
registered.") and `ObjectHooksPanel` (a working generic `SchemaForm`) — and
add the populated-registry contrast for the Data pillar's four consumers, so a
future refactor cannot move either into the silent class, and an over-eager
empty-state branch cannot swallow the populated path (objectui#6846). Test
only; no package is released by this change.
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#6846 — the Data pillar's four registry consumers on a POPULATED
* registry: the contrast half of the empty-registry pins.
*
* ## The wrong fix this file exists to catch
*
* The empty-state repairs (#7120) and pins (#7120 + this card) all say what
* a consumer must show when its registry read returns `undefined`. The
* plausible wrong fix on that axis is an empty-state branch that fires
* whenever the lookup is falsy in some wider sense — a guard inverted, a
* `!!` dropped, a branch reordered — so that "the empty case now speaks"
* while every populated screen goes blank or shows the notice. None of the
* empty-registry files can see that: their registries are empty by
* construction. So this file registers the builtin inspectors FIRST, proves
* they are there, and pins that with them present:
*
* - a field click opens the REAL `ObjectFieldInspector`;
* - Actions mounts the REAL `ActionDefaultInspector`;
* - Hooks mounts the curated `HookDefaultInspector`, not the generic form;
* - Settings mounts the REAL `ObjectDefaultInspector`;
*
* and that none of the empty-state sentences appears anywhere on screen.
* Each case asserts a control only the real editor renders BEFORE it asserts
* the notice's absence, so a blank screen reds on the positive, not on an
* absence that a blank screen would satisfy.
*
* The fifth consumer, the Interfaces canvas, has its populated contrast in
* {@link file://./StudioDesignSurface.designerRegistryPartial.test.tsx}.
*
* ## Why a separate file
*
* The registries are plain `Map`s — module state shared by every test in a
* file. Splitting empty from populated is what lets each file assert its own
* precondition instead of depending on test order. Same convention as the
* `designerRegistryMissing` / `designerRegistryPartial` pair.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const objectDef = {
name: 'showcase_task',
label: 'Task',
fields: [{ name: 'title', label: 'Title', type: 'text' }],
actions: [{ name: 'send_email', label: 'Send Email', type: 'quick' }],
};

const hook = {
name: 'guard_hook',
label: 'Guard',
object: 'showcase_task',
events: ['beforeInsert'],
handler: 'guard_fn',
};

const mockClient = {
save: vi.fn(async () => ({})),
list: vi.fn(async (type: string) => {
if (type === 'object') return [{ name: 'showcase_task', label: 'Task' }];
if (type === 'hook') return [hook];
return [];
}),
listDrafts: vi.fn(async () => []),
layered: vi.fn(async () => ({ effective: objectDef, code: objectDef })),
getDraft: vi.fn(async () => null),
// The curated hook editor resolves the bound object's field catalog through
// `useObjectFields` -> `client.get`.
get: vi.fn(async () => null),
};

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

// objectui#5813 — the advanced tabs live in a Radix DropdownMenu; this file
// measures which editor mounts, not radix's open/close machinery, so the menu
// renders as plain passthroughs (same convention as DataPillar.panelGate).
type Passthrough = { children?: React.ReactNode };
vi.mock('@object-ui/components', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/components')>();
return {
...mod,
DropdownMenu: (p: Passthrough) => <div>{p.children}</div>,
DropdownMenuTrigger: (p: Passthrough) => <div>{p.children}</div>,
DropdownMenuContent: (p: Passthrough) => <div>{p.children}</div>,
DropdownMenuItem: (p: Passthrough & { onSelect?: () => void }) => (
<button type="button" onClick={() => p.onSelect?.()}>{p.children}</button>
),
};
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => ({}) };
});

import { DataPillar } from './StudioDesignSurface';
import { registerBuiltinInspectors } from '../metadata-admin/inspectors';
import { listMetadataInspectorTypes, getMetadataInspector } from '../metadata-admin/inspector-registry';
import { getMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry';

// The populated precondition — registered here, proved in every test below.
registerBuiltinInspectors();

/** The four empty-state sentences the Data pillar's consumers can show. */
const EMPTY_STATE_SENTENCES = [
'No field inspector is registered in this session, so this field’s properties cannot be edited here.',
'No action editor is registered in this session, so this action’s properties cannot be edited here.',
'No default object inspector registered.',
'No metadata designers are registered in this session',
];

afterEach(cleanup);

/**
* Assert the registries really are populated — stated, not assumed, so an
* assertion below cannot pass for the wrong reason (a builtin that silently
* stopped registering would otherwise turn every case here into an
* empty-registry case that happens to render).
*/
function assertRegistriesPopulated(): void {
expect(listMetadataInspectorTypes()).toContain('object');
expect(getMetadataInspector('object')).toBeTypeOf('function');
expect(getMetadataDefaultInspector('object')).toBeTypeOf('function');
expect(getMetadataDefaultInspector('action')).toBeTypeOf('function');
expect(getMetadataDefaultInspector('hook')).toBeTypeOf('function');
}

function expectNoEmptyStateSentence(): void {
const text = document.body.textContent ?? '';
for (const sentence of EMPTY_STATE_SENTENCES) expect(text).not.toContain(sentence);
}

function renderPillar() {
render(
<MemoryRouter initialEntries={['/studio/com.example.showcase/data']}>
<DataPillar packageId="com.example.showcase" />
</MemoryRouter>,
);
}

describe('Data pillar consumers with the registries POPULATED (#6846 contrast)', () => {
it('a field click opens the real field inspector, not the missing-inspector notice', async () => {
assertRegistriesPopulated();
renderPillar();

fireEvent.click(await screen.findByRole('button', { name: 'Form' }));
const card = (await screen.findByText('Title')).closest('.cursor-grab') as HTMLElement;
expect(card).toBeTruthy();
fireEvent.click(card);

const rail = await waitFor(() => {
const el = document.querySelector('aside');
expect(el).toBeTruthy();
return el as HTMLElement;
});
expect(rail).toHaveTextContent('Field properties');
// `ObjectFieldInspector`'s own API-name control, carrying the field's name.
expect(await within(rail).findByDisplayValue('title')).toBeInTheDocument();
expectNoEmptyStateSentence();
});

it('Actions mounts the real action editor, not the missing-editor notice', async () => {
assertRegistriesPopulated();
renderPillar();

fireEvent.click(await screen.findByRole('button', { name: 'Actions' }));
// `ActionDefaultInspector`'s Name control, carrying the action's identifier.
expect(await screen.findByDisplayValue('send_email')).toBeInTheDocument();
expectNoEmptyStateSentence();
});

it('Hooks mounts the curated hook editor, not the generic form', async () => {
assertRegistriesPopulated();
renderPillar();

fireEvent.click(await screen.findByRole('button', { name: 'Hooks' }));
fireEvent.click(await screen.findByText('Guard'));
// Controls only `HookDefaultInspector` renders…
expect(await screen.findByTestId('hook-name')).toBeInTheDocument();
expect(screen.getByTestId('hook-body-source')).toBeInTheDocument();
// …and none the generic `SchemaForm` would have synthesised from the
// hook's keys (the empty-registry pin asserts this one is PRESENT).
expect(screen.queryByLabelText('Handler')).toBeNull();
expectNoEmptyStateSentence();
});

it('Settings mounts the real object inspector, not the missing-inspector notice', async () => {
assertRegistriesPopulated();
renderPillar();

fireEvent.click(await screen.findByRole('button', { name: 'Settings' }));
// `ObjectDefaultInspector`'s Name control, carrying the object's name.
expect(await screen.findByTestId('object-name-input')).toHaveValue('showcase_task');
expect(screen.getByTestId('object-access-posture')).toBeInTheDocument();
expectNoEmptyStateSentence();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#6846 — the Hooks panel on an EMPTY default-inspector registry.
*
* The second of the two consumers #7120 (#6795 part C) measured as already
* honest and deliberately left unpinned: with `getMetadataDefaultInspector('hook')`
* undefined the panel falls back to the generic `SchemaForm`, which synthesises
* a control per top-level key of the selected hook. That is a working editor,
* not an empty state — so the pin has to prove it WORKS, not merely that
* something rendered: an edit in the generic form dirties the panel, enables
* its own Save, and the save writes the edited value through.
*
* "Something rendered" would pass on the hook's bare label — exactly the shape
* of the `ObjectActionsPanel` defect the card repaired — which is why the
* assertions below name the generic form's controls and drive one of them.
*
* The populated contrast, where the curated `HookDefaultInspector` mounts and
* the generic form must NOT, is
* {@link file://./DataPillar.designerRegistryPopulated.test.tsx}.
*
* ⚠️ This file must never register a designer — its subject is the empty
* branch, and the registries are module state shared by every test in a file.
* Emptiness is asserted FIRST, with a control that must hit.
*/

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

const hook = {
name: 'guard_hook',
label: 'Guard',
object: 'showcase_task',
events: ['beforeInsert'],
handler: 'guard_fn',
};

const mockClient = {
list: vi.fn(async () => [hook]),
listDrafts: vi.fn(async () => []),
getDraft: vi.fn(async () => null),
get: vi.fn(async () => null),
save: vi.fn(async () => ({})),
};

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient };
});

import { ObjectHooksPanel } from './ObjectHooksPanel';
import { listMetadataPreviewTypes } from '../metadata-admin/preview-registry';
import { listMetadataInspectorTypes } from '../metadata-admin/inspector-registry';
import { getMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry';
import { getStudioCanvasPreview } from './studio-canvas-preview';

afterEach(() => {
cleanup();
mockClient.save.mockClear();
});

/** Same shape as the sibling pins: a control that MUST hit, then the zeros. */
function assertRegistriesEmptyWithControl(): void {
expect(getStudioCanvasPreview('object')).toBeTypeOf('function'); // control — MUST hit
expect(listMetadataPreviewTypes()).toEqual([]);
expect(listMetadataInspectorTypes()).toEqual([]);
expect(getMetadataDefaultInspector('hook')).toBeUndefined();
expect(getMetadataDefaultInspector('object')).toBeUndefined();
expect(getMetadataDefaultInspector('action')).toBeUndefined();
}

describe('ObjectHooksPanel — no curated hook editor registered (#6846)', () => {
it('falls back to the generic SchemaForm, and that form edits and saves the hook', async () => {
assertRegistriesEmptyWithControl();

render(<ObjectHooksPanel objectName="showcase_task" packageId="com.example.showcase" />);
fireEvent.click(await screen.findByText('Guard'));

// The generic form: one labelled control per top-level key of the hook,
// carrying the hook's own values. These labels are synthesised from the
// keys, which is what makes them the generic form's signature.
const handler = await screen.findByLabelText('Handler');
expect(handler).toHaveValue('guard_fn');
expect(screen.getByLabelText('Name')).toHaveValue('guard_hook');
// The curated editor is NOT what rendered — its controls carry test ids
// the generic form never produces.
expect(screen.queryByTestId('hook-name')).toBeNull();
// Nothing on screen promises recovery, and nothing calls this an empty
// state — it is an editor.
expect(document.body.textContent ?? '').not.toMatch(/loading|try again/i);

// "Working": an edit in the generic form dirties the panel and enables
// its own Save…
const save = screen.getByRole('button', { name: /Save/i });
expect(save).toBeDisabled();
fireEvent.change(handler, { target: { value: 'guard_fn_v2' } });
await waitFor(() => expect(save).toBeEnabled());
// …and Save writes the edited value through, as a draft of THIS hook.
fireEvent.click(save);
await waitFor(() => expect(mockClient.save).toHaveBeenCalledTimes(1));
expect(mockClient.save).toHaveBeenCalledWith(
'hook',
'guard_hook',
expect.objectContaining({ handler: 'guard_fn_v2' }),
{ mode: 'draft', packageId: 'com.example.showcase' },
);
});
});
Loading
Loading