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
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack-ai/objectstack#26743 — the Interfaces nav editor's target picker
* offers a draft-only page only when its draft body can be a nav target.
*
* A `page` nav entry opens a static page URL with no record id, so a record
* page is never a valid target (`isStaticPageOption`). The picker filtered the
* published rows by the body's `type`, but added every draft-only row
* unfiltered: a `_drafts` header names the metadata type (`page`), not the
* body's `type`. A draft record page was offered, and picking it gave a dead
* entry once the app was published. The picker now reads a draft-only row's
* draft body and judges it as it judges a published row:
*
* - a draft record page is not offered;
* - a draft static page is offered;
* - a published record page is still not offered;
* - a draft action bound to an object is not offered (the nav runs global
* actions only), a draft global action is;
* - CONTROL: a draft whose body cannot be read is still offered, as a row
* with no `type` is.
*/

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

const PKG = 'com.acme.app';

/** Published pages, as `client.list('page')` serves them: bodies, with `type`. */
const PUBLISHED_PAGES = [
{ name: 'ops_home', label: 'Ops home', type: 'app' },
{ name: 'ticket_detail', label: 'Ticket detail', type: 'record' },
];

/** Draft bodies the `?state=draft` read serves, keyed `type:name`. */
const DRAFT_BODIES: Record<string, Record<string, unknown> | undefined> = {
'page:case_record': { name: 'case_record', label: 'Case record', type: 'record' },
'page:case_board': { name: 'case_board', label: 'Case board', type: 'list' },
'action:close_case': { name: 'close_case', label: 'Close case', objectName: 'repairs_case' },
'action:open_inbox': { name: 'open_inbox', label: 'Open inbox' },
};

const mockClient = vi.hoisted(() => ({
list: vi.fn(async (): Promise<unknown[]> => []),
// The `_drafts` headers: metadata type and name, never the body's `type`.
listDrafts: vi.fn(async (options?: { packageId?: string; type?: string }): Promise<unknown[]> => {
if (options?.type === 'page') {
return [
{ type: 'page', name: 'case_record', packageId: 'com.acme.app', label: 'Case record' },
{ type: 'page', name: 'case_board', packageId: 'com.acme.app', label: 'Case board' },
{ type: 'page', name: 'case_unread', packageId: 'com.acme.app', label: 'Case unread' },
];
}
if (options?.type === 'action') {
return [
{ type: 'action', name: 'close_case', packageId: 'com.acme.app', label: 'Close case' },
{ type: 'action', name: 'open_inbox', packageId: 'com.acme.app', label: 'Open inbox' },
];
}
return [];
}),
getDraft: vi.fn(async (): Promise<unknown> => null),
get: vi.fn(async () => undefined),
withPreviewDrafts: vi.fn((): unknown => mockClient),
}));

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

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

vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), dismiss: vi.fn() },
Toaster: () => null,
}));

vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null }));
vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null }));
vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null }));
vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null }));

import { StudioNavItemInspector } from './StudioDesignSurface';

window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver =
(globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver ??
class {
observe() {}
unobserve() {}
disconnect() {}
};

const METADATA = {
apps: [],
objects: [],
dashboards: [],
reports: [],
pages: [],
loading: false,
error: null,
refresh: async () => {},
invalidate: () => {},
ensureType: async () => [],
getItem: async () => null,
getItemsByType: () => [],
getTypeStatus: () => 'ready' as const,
};

beforeEach(() => {
for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear();
mockClient.list.mockImplementation(async (type?: unknown) => (type === 'page' ? PUBLISHED_PAGES : []));
mockClient.getDraft.mockImplementation(async (type?: unknown, name?: unknown) => {
if (name === 'case_unread') throw new Error('draft read failed');
const body = DRAFT_BODIES[`${String(type)}:${String(name)}`];
// The served envelope, decorated as the framework serves it.
return body ? { type, name, item: { ...body, _draft: true } } : null;
});
});

afterEach(() => {
cleanup();
});

async function openPicker(navigation: unknown[], name: string) {
render(
<MetadataCtx.Provider value={METADATA as never}>
<StudioNavItemInspector
navId="navigation[0]"
appDraft={{ navigation }}
objects={[]}
packageId={PKG}
onNavPatch={vi.fn()}
onClear={vi.fn()}
/>
</MetadataCtx.Provider>,
);
fireEvent.keyDown(screen.getByRole('combobox', { name }), { key: 'ArrowDown' });
return screen.findByRole('listbox');
}

describe('objectstack-ai/objectstack#26743 — the nav page picker judges a draft-only page on its draft body', () => {
it('a draft record page is not offered', async () => {
const listbox = await openPicker([{ id: 'nav_page', type: 'page' }], 'Page');
// Wait for the draft static page, read in the same pass, before asserting an absence.
await within(listbox).findByRole('option', { name: 'Case board (case_board)' });
expect(within(listbox).queryByRole('option', { name: /case_record/ })).toBeNull();
});

it('a draft static page is offered', async () => {
const listbox = await openPicker([{ id: 'nav_page', type: 'page' }], 'Page');
expect(await within(listbox).findByRole('option', { name: 'Case board (case_board)' })).toBeInTheDocument();
});

it('a published record page is still not offered, a published static page is', async () => {
const listbox = await openPicker([{ id: 'nav_page', type: 'page' }], 'Page');
expect(await within(listbox).findByRole('option', { name: 'Ops home (ops_home)' })).toBeInTheDocument();
expect(within(listbox).queryByRole('option', { name: /ticket_detail/ })).toBeNull();
});

it('CONTROL: a draft whose body cannot be read is still offered', async () => {
const listbox = await openPicker([{ id: 'nav_page', type: 'page' }], 'Page');
expect(await within(listbox).findByRole('option', { name: 'Case unread (case_unread)' })).toBeInTheDocument();
});

it('a draft action bound to an object is not offered, a draft global action is', async () => {
const listbox = await openPicker([{ id: 'nav_action', type: 'action' }], 'Action');
expect(await within(listbox).findByRole('option', { name: 'Open inbox (open_inbox)' })).toBeInTheDocument();
expect(within(listbox).queryByRole('option', { name: /close_case/ })).toBeNull();
});

it('a draft body is read only for a type whose verdict reads the body', async () => {
await openPicker([{ id: 'nav_page', type: 'page' }], 'Page');
await screen.findByRole('option', { name: 'Case board (case_board)' });
expect(mockClient.getDraft).toHaveBeenCalledWith('page', 'case_record', { packageId: PKG });
// A published row is never re-read as a draft.
expect(mockClient.getDraft).not.toHaveBeenCalledWith('page', 'ops_home', expect.anything());
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2980,16 +2980,45 @@ const NAV_TARGET_KEY = {
* Whether a listed row can be a nav target. A record page needs a record id a
* `page` entry cannot pass (`isStaticPageOption`), and an action bound to an
* object is not addressable from the nav, which runs GLOBAL actions only
* (`useNavActionDispatch`). Only a CONFIRMED one is left out: a draft header
* carries no body, so a draft-only row is kept, as `isStaticPageOption` keeps
* a row with no `type`.
* (`useNavActionDispatch`). Only a CONFIRMED one is left out, as
* `isStaticPageOption` keeps a row with no `type`.
*
* objectstack-ai/objectstack#26743 — a `_drafts` header names the METADATA
* type (`page`), never the body's own `type` (`record`), so a draft-only row
* is judged on its draft body instead ({@link NAV_TARGET_BODY_KEYED}). A draft
* record page is still a record page once it is published with the app.
*/
function isNavTargetRow(metaType: string, row: Record<string, unknown>): boolean {
if (metaType === 'page') return isStaticPageOption(row as { type?: string });
if (metaType === 'action') return !row.objectName;
return true;
}

/** The types whose {@link isNavTargetRow} verdict reads a body key, so a draft-only row's body is read for it. */
const NAV_TARGET_BODY_KEYED: ReadonlySet<string> = new Set(['page', 'action']);

/**
* Whether a draft-only row can be a nav target, judged on its draft body. A
* body that cannot be read (no draft served, a failed read) leaves the row in,
* as a published row with no `type` is left in: only a confirmed non-target
* is dropped.
*/
async function isDraftNavTarget(
client: MetadataClient,
metaType: string,
name: string,
packageId: string,
): Promise<boolean> {
if (!NAV_TARGET_BODY_KEYED.has(metaType)) return true;
let body: Record<string, unknown> | null;
try {
body = extractDraftBody(await client.getDraft(metaType, name, { packageId }));
} catch {
return true;
}
return !body || isNavTargetRow(metaType, body);
}

/**
* The package's items of `metaType`, published ∪ draft by name (the published
* row wins, for its label), as the pillar's object list is read
Expand Down Expand Up @@ -3021,10 +3050,19 @@ function usePackageNavTargets(
// objectui#11862 — a draft-only target carries the label its `_drafts`
// row serves (objectstack-ai/objectstack#22200), so the picker offers
// it by that label, as it offers a published one.
const draftOnly: NavTargetRow[] = [];
for (const draft of (drafts ?? []) as Array<{ name?: string | null; label?: I18nLabel | null }>) {
const name = typeof draft?.name === 'string' ? draft.name : '';
if (name && !byName.has(name)) byName.set(name, { name, label: draft.label ?? undefined });
if (name && !byName.has(name) && !draftOnly.some((row) => row.name === name)) {
draftOnly.push({ name, label: draft.label ?? undefined });
}
}
const verdicts = await Promise.all(
draftOnly.map((row) => isDraftNavTarget(client, metaType, row.name, packageId)),
);
draftOnly.forEach((row, i) => {
if (verdicts[i]) byName.set(row.name, row);
});
} catch {
/* non-fatal — the picker just stays empty */
}
Expand Down
Loading