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/studio-interfaces-action-nav-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@object-ui/app-shell': patch
---

The Studio Interfaces rail opens `action` nav entries instead of greying them out.

The Interfaces pillar's rail is the current package's App `navigation` tree, and
each leaf opens the design surface of whatever it binds to. `resolveSurface`
bound five shapes — `page`, `object`, `dashboard`, `report`, `view` — and not
`action`, so an action entry rendered `disabled`: visible in the designer, 40%
opacity, inert on click. The same entry works in the shipped product, where
`NavigationRenderer` renders it and `useNavActionDispatch` resolves and executes
it (framework#4509), and `action` has carried both a registered preview
(`ActionPreview`) and a registered default inspector (`ActionDefaultInspector`)
the whole time. Only the binding was missing, so the one nav variant naming an
authorable metadata item was the one variant the designer could not author.

Clicking such an entry now opens the action on the standard surface — the
`ActionPreview` canvas plus the action's inspector — and draft-saves through the
same generic path as the pillar's other leaves.

Scope, stated because the neighbours are deliberately untouched: `url`,
`separator` and `component` leaves stay unresolvable, and are not a gap — an
external link, a divider, and a first-party UI shipped in code have no metadata
item to design. Object-scoped actions keep their existing home, the object's
Actions tab: `ActionNavItemSchema` is strict `{ actionName, params? }` with no
`objectName`, so a nav action is a global action by construction and this path
cannot reach an object-scoped one.

`actionDef.actionName` is read as the only spelling. The spec answers `action` /
`name` / `args` / `input` there with a named rejection rather than accepting
them (objectstack#4001, measured on spec 17.0.0-rc.6), and a tolerant read here
would re-open exactly what that closed: an entry that dispatches an action its
author did not declare.
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Acceptance for objectui#4019's `action` half, at the pillar level: the
* Studio Interfaces rail must LIST an action nav entry and OPEN it on the
* standard design surface.
*
* Before this, the entry rendered — the rail walks every nav leaf — but with
* `disabled` set, because `resolveSurface` had no `action` case. So the one
* nav variant that names an authorable metadata item was the one the designer
* could not open, while the shipped sidebar rendered and dispatched it
* (framework#4509). The two assertions below are exactly those two verbs.
*/
import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const NAV = [
{ id: 'nav_home', type: 'page', label: 'Home', pageName: 'home' },
{ id: 'nav_run_sync', type: 'action', label: 'Run Sync', actionDef: { actionName: 'sync_now' } },
];

/** Spec-valid (`ActionSchema`, 17.0.0-rc.6) — the item the rail must open. */
const ACTION = {
name: 'sync_now',
label: 'Sync Now',
type: 'script',
target: 'sync',
locations: ['list_toolbar'],
};

const mockClient = {
list: vi.fn(async (type: string) =>
type === 'app' ? [{ name: 'acme_app', label: 'Acme' }] : [],
),
listDrafts: vi.fn(async () => []),
layered: vi.fn(async (type: string, name: string) => {
if (type === 'app') return { effective: { name: 'acme_app', label: 'Acme', navigation: NAV } };
if (type === 'action' && name === 'sync_now') return { effective: ACTION };
return { effective: { name, kind: 'blocks', blocks: [] } };
}),
getDraft: vi.fn(async () => null),
save: vi.fn(async () => ({})),
get: vi.fn(async () => undefined),
};

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 () => []) };
});

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

import { InterfacesPillar } from './StudioDesignSurface';
import { registerMetadataPreview } from '../metadata-admin/preview-registry';
import { ActionPreview } from '../metadata-admin/previews/ActionPreview';

// Registered directly rather than via `registerBuiltinPreviews()` — the barrel
// pulls every preview module (flow canvas, dashboard, report…) into this file's
// graph for one canvas. Same trade-off, and same precedent, as
// `EmbeddedItemEditor.preview.test.tsx`. That the barrel itself still carries
// the `action` line is the pre-existing registration this card builds on
// (`previews/index.ts`), not something this test can drift from.
//
// The right-hand inspector is deliberately not asserted here: `action`'s
// default inspector was already registered and already has its own coverage
// (`inspectors/ActionDefaultInspector.celGate.test.tsx`); mounting it would
// drag the CEL condition-builder tree into a rail test.
registerMetadataPreview('action', ActionPreview);

afterEach(cleanup);

function renderPillar() {
return render(
<MemoryRouter initialEntries={['/studio/com.acme.app/interfaces']}>
<InterfacesPillar packageId="com.acme.app" />
</MemoryRouter>,
);
}

describe('Interfaces pillar — action nav entries (objectui#4019)', () => {
it('lists the action entry as an ENABLED rail item', async () => {
renderPillar();

const entry = await screen.findByTitle('action · sync_now');
expect(entry).toBeInTheDocument();
// The verb that was broken: the rail renders every leaf, but an
// unresolvable one is `disabled` and cannot be opened.
expect(entry).toBeEnabled();
});

it('opens it on the standard design surface when clicked', async () => {
renderPillar();

// The first resolvable leaf (`Home`) auto-opens, so this also proves the
// rail SWITCHES to the action rather than merely defaulting to it.
const entry = await screen.findByTitle('action · sync_now');
fireEvent.click(entry);

// Canvas breadcrumb — the surface the pillar is now editing. Matched on
// the element's whole text: the breadcrumb is `{type} · {name}` in JSX, so
// it reaches the DOM as three sibling text nodes and a plain string query
// would never match it.
await waitFor(() =>
expect(
screen.getAllByText((_content, el) => el?.tagName === 'SPAN' && el.textContent === 'action · sync_now'),
).not.toHaveLength(0),
);
// ...rendered by the registered `ActionPreview`, which draws the action's
// own label as the faux button an author is designing.
await waitFor(() => expect(screen.getAllByText('Sync Now').length).toBeGreaterThan(0));
});
});
75 changes: 3 additions & 72 deletions packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import {
import { SourcePageEditor } from '../metadata-admin/previews/SourcePageEditor';
import { formatMetadataError, formatPublishFailures, type PublishFailure } from './metadataError';
import { loadPackageSurfaces } from './packageSurfaces';
import { resolveSurface, findSurfaceInTree, type NavNode, type Surface } from './navSurface';
import { useSurfaceDeepLink, resolveSurfaceDeepLink } from './useSurfaceDeepLink';
import { buildObjectSkeleton, buildFlowSkeleton, buildAppSkeleton, buildPermissionSkeleton } from './skeletons';
import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n';
Expand Down Expand Up @@ -129,87 +130,17 @@ const PILLARS: ReadonlyArray<{ key: string; label: string; Icon: LucideIcon }> =
{ key: 'access', label: 'Access', Icon: Shield },
];

interface Surface {
type: string;
name: string;
label: string;
/** Lucide icon name from the object's metadata (`icon` field); falls back per getIcon. */
icon?: string;
}

interface NavNode {
id?: string;
label?: string;
type?: string;
icon?: string;
children?: NavNode[];
pageName?: string;
page?: string;
objectName?: string;
object?: string;
dashboardName?: string;
dashboard?: string;
reportName?: string;
report?: string;
viewName?: string;
view?: string;
[k: string]: unknown;
}

const KIND_ICON: Record<string, LucideIcon> = {
group: Folder,
page: FileText,
object: Database,
dashboard: LayoutDashboard,
report: BarChart3,
view: Table2,
action: MousePointer2,
};
const navIcon = (type?: string): LucideIcon => KIND_ICON[type ?? ''] ?? Compass;

/** Resolve a leaf nav node → the surface {type,name} it binds to. */
function resolveSurface(node: NavNode): Surface | null {
const label = String(node.label ?? '');
switch (node.type) {
case 'page':
return node.pageName || node.page ? { type: 'page', name: String(node.pageName || node.page), label } : null;
case 'object':
return node.objectName || node.object
? { type: 'object', name: String(node.objectName || node.object), label }
: null;
case 'dashboard':
return node.dashboardName || node.dashboard
? { type: 'dashboard', name: String(node.dashboardName || node.dashboard), label }
: null;
case 'report':
return node.reportName || node.report
? { type: 'report', name: String(node.reportName || node.report), label }
: null;
case 'view':
return node.viewName || node.view ? { type: 'view', name: String(node.viewName || node.view), label } : null;
default:
return null;
}
}

/**
* Walk the nav tree for the leaf that binds to `{type,name}`, returning its
* resolved Surface (carrying the node's label so the canvas title / highlight
* match). Backs the `?surface=` deep-link restore — a shared URL only names
* the target, so we re-derive the label from the live tree.
*/
function findSurfaceInTree(nodes: NavNode[], target: { type: string; name: string }): Surface | null {
for (const node of nodes) {
if (node.type === 'group' || node.children?.length) {
const hit = findSurfaceInTree(node.children ?? [], target);
if (hit) return hit;
} else {
const s = resolveSurface(node);
if (s && s.type === target.type && s.name === target.name) return s;
}
}
return null;
}

/** Normalize the framework draft envelope `{ type, name, item }` → body | null. */
function extractDraftBody(resp: unknown): Record<string, unknown> | null {
if (!resp || typeof resp !== 'object') return null;
Expand Down Expand Up @@ -1027,7 +958,7 @@ function StudioNavItemInspector({
);
}

function InterfacesPillar({
export function InterfacesPillar({
packageId,
publishNonce = 0,
draftNonce = 0,
Expand Down
101 changes: 101 additions & 0 deletions packages/app-shell/src/views/studio-design/navSurface.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Interfaces-pillar nav-leaf binding (objectui#4019).
*
* The gap this pins: an `action` nav item is a LIVE runtime surface — the
* shipped sidebar renders it and `useNavActionDispatch` executes it
* (framework#4509) — but the Studio Interfaces rail could not resolve it to a
* design surface, so the very same entry rendered permanently DISABLED in the
* designer while it worked in the running app. `action` has had both a
* registered preview (`ActionPreview`) and a registered default inspector
* (`ActionDefaultInspector`) all along; only this binding was missing.
*
* The fixtures are parsed against the spec's own `NavigationItemSchema` rather
* than asserted by eye, so this file cannot drift into pinning a shape the
* schema rejects (the phantom-rule trap): the positive fixture must be
* spec-VALID for the designer to be required to open it, and the rejected
* alias spelling must stay unresolvable here because the schema refuses it by
* name — Commandment #0.1, no second dialect in the consumer.
*/
import { describe, expect, it } from 'vitest';
import { NavigationItemSchema } from '@objectstack/spec/ui';
import { resolveSurface, findSurfaceInTree, type NavNode } from './navSurface';

/** A spec-valid global-action nav item. */
const ACTION_NODE: NavNode = {
id: 'nav_run_sync',
type: 'action',
label: 'Run Sync',
actionDef: { actionName: 'sync_now' },
};

describe('resolveSurface — action nav items (objectui#4019)', () => {
it('the fixture is a real authoring surface: the spec accepts it whole', () => {
const parsed = NavigationItemSchema.safeParse(ACTION_NODE);
expect(parsed.success).toBe(true);
});

it('binds an action nav leaf to the `action` design surface', () => {
expect(resolveSurface(ACTION_NODE)).toEqual({
type: 'action',
name: 'sync_now',
label: 'Run Sync',
});
});

it('leaves an action item with no actionName unresolved (stays disabled)', () => {
expect(resolveSurface({ id: 'nav_x', type: 'action', label: 'Nothing' })).toBeNull();
expect(resolveSurface({ id: 'nav_x', type: 'action', label: 'Nothing', actionDef: {} })).toBeNull();
});

it('reads the canonical key ONLY — a spelling the schema rejects stays unresolved', () => {
// `action` / `name` inside `actionDef` are REJECTED aliases carrying a
// redirect (objectstack#4001), not second spellings. Measured on
// spec 17.0.0-rc.6: `unrecognized_keys` on `actionDef` plus a missing
// `actionName`. A tolerant `??` limb here would resurrect exactly the bug
// #4001 closed — an entry that dispatches an action the author did not
// declare — so the designer must refuse what the schema refuses.
const aliasNode = {
id: 'nav_run_sync',
type: 'action',
label: 'Run Sync',
actionDef: { action: 'sync_now' },
};
const parsed = NavigationItemSchema.safeParse(aliasNode);
expect(parsed.success).toBe(false);
expect(resolveSurface(aliasNode as NavNode)).toBeNull();
});

it('reaches an action leaf nested in a group (the `?surface=` deep-link path)', () => {
const tree: NavNode[] = [
{ id: 'g1', type: 'group', label: 'Ops', children: [ACTION_NODE] },
];
expect(findSurfaceInTree(tree, { type: 'action', name: 'sync_now' })).toEqual({
type: 'action',
name: 'sync_now',
label: 'Run Sync',
});
});
});

describe('resolveSurface — the variants around the new one are unchanged', () => {
it('still binds the surface-bearing leaves', () => {
expect(resolveSurface({ type: 'page', pageName: 'home', label: 'Home' })?.type).toBe('page');
expect(resolveSurface({ type: 'object', objectName: 'crm_lead', label: 'Leads' })?.name).toBe('crm_lead');
expect(resolveSurface({ type: 'dashboard', dashboardName: 'sales', label: 'Sales' })?.name).toBe('sales');
expect(resolveSurface({ type: 'report', reportName: 'pipeline', label: 'Pipeline' })?.name).toBe('pipeline');
});

it('leaves the variants with no authorable target unresolved', () => {
// Deliberately NOT openable — `url` points out of the product, `separator`
// is a divider, and `component` names a first-party UI shipped in code, so
// none of the three has a metadata item to design. Only `action` was a
// metadata type sitting in this bucket by omission.
expect(resolveSurface({ id: 'nav_docs', type: 'url', label: 'Docs', url: 'https://example.com' })).toBeNull();
expect(resolveSurface({ id: 'nav_sep', type: 'separator' })).toBeNull();
expect(
resolveSurface({ id: 'nav_dir', type: 'component', label: 'Directory', componentRef: 'metadata:directory' }),
).toBeNull();
});
});
Loading
Loading