Skip to content
Merged
42 changes: 42 additions & 0 deletions .changeset/9868-nav-label-inherits-target.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'@object-ui/types': minor
'@object-ui/layout': minor
'@object-ui/app-shell': patch
---

feat(types,layout,app-shell): a navigation entry with no `label` shows its target's current label, resolved at render time (objectui#9868)

`@objectstack/spec` 17.5.0 made a navigation entry's `label` optional on every
entry arm of its `NavigationItemSchema` (the separator still carries none), with
a declared semantic (the cloud#2021 letter-A ruling): absent ⇒ the entry
shows the CURRENT label of what it opens — the view's label when it names a
labelled view, else the object's / dashboard's label; present ⇒ rendered
verbatim. Nothing is stored for the absent case, so a renamed target shows its
new name on the next render.

- `@object-ui/types` (widening): `NavigationEntryItem.label` is optional, and
`NavigationItemSchema` accepts a label-less entry of every type the spec does,
filling nothing in at parse time. `id` is still required. An EMPTY label is
still refused — `''` is a present label that would render empty text — and the
message says to omit the key instead. `menuItemToNavigationItem` no longer
turns a missing legacy label into `''`.
- `@object-ui/layout` (additive public surface): `resolveNavItemLabel`
resolves an absent label through a new `resolveTargetLabel` resolver
(`NavigationRenderer` prop, types
`NavLabelTarget` / `NavTargetLabelResolver`), walking view → object /
dashboard, and falls back to the target's machine name (`viewName`,
`objectName`, `dashboardName`, `pageName`, `reportName`, `url`,
`componentRef`, the action name; a group shows its `id`). An authored label is
never replaced by the target's label, including one spelled like the machine
name. Action entries, the search filter and `AppSchemaRenderer`'s mobile tab
bar read the label through the same function, so an unlabelled entry no longer
throws there.
- `@object-ui/app-shell`: the sidebar, the `nav:menu` block, the ⌘K command
palette and the full-page search all name a nav entry through
`resolveNavItemLabel` with one hook over the metadata cache the shell already
holds (object schema, merged list views, dashboards) — no request per nav
entry. Read raw, a label-less entry drew a blank palette row searched as
`undefined`, and the search page listed it by its machine name.
Navigation sync stops writing `label: pageName` / `label: dashboardName` for an
unnamed page or dashboard; an author label is still written, and stored labels
are kept.
10 changes: 9 additions & 1 deletion content/docs/guide/designing-app-navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,15 @@ field — there is no generic `path` and no `kind`:

Requirements:

- `id` (snake_case), `type`, and `label` are mandatory on every item.
- `id` (snake_case) and `type` are mandatory on every item. `label` is
optional: an entry without one shows its target's **current** label, resolved
when the navigation renders — the view's label for an entry that names a
labelled view, else the object's or dashboard's label, else the target's
machine name. A renamed object, view or dashboard therefore shows its new
name on the next load without touching the navigation. Entries of other types
fall back to their target's name (`pageName`, `reportName`, the `url`) and a
group to its `id`, so give those a `label`. A `label` you do write renders
verbatim. Never write an empty `label` — omit the key instead.
- The target field must match the type: `objectName`, `pageName`,
`dashboardName`, `reportName`, or `url`. Keys like `path` or `kind` are
ignored at runtime and rejected at save.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* 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.
*/

/**
* The ⌘K palette names an app navigation entry the way the sidebar does,
* including an entry with NO `label` (objectui#9868, contract review finding 1).
*
* Since objectui#9868 `NavigationSyncEffect` writes every page and dashboard
* created in the console WITHOUT a `label` — absent means "inherit the target's
* current label at render time". The palette read `resolveKeyedI18nLabel(
* item.label, t)`, which answers `undefined` for an absent label, so such an
* entry drew a BLANK row whose cmdk filter value read `page undefined my_page`.
* It now resolves through `resolveNavItemLabel` with the same
* `useNavTargetLabel` resolver the sidebar and `nav:menu` use.
*
* Pinned through the real `CommandPaletteProvider` (opened by its `?palette=1`
* deep link) under a real `MetadataCtx`, asserting both what the row SHOWS and
* the cmdk `data-value` it is searched by:
*
* - a label-less page shows its machine-name backstop (`pageName`), the text
* the old sync used to store;
* - a label-less dashboard and a label-less view entry show their targets'
* CURRENT metadata labels, and a rename shows on the next render;
* - CONTROL: an authored label renders verbatim.
*/

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

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({ user: { id: 'u1' }, activeOrganization: null }),
}));

import { CommandPalette } from './CommandPalette';
import { CommandPaletteProvider } from '../context/CommandPaletteProvider';

const APP = {
name: 'crm',
label: 'CRM',
navigation: [
// What NavigationSyncEffect writes for a page / dashboard since objectui#9868.
{ id: 'nav_page', type: 'page', pageName: 'my_page' },
{ id: 'nav_dash', type: 'dashboard', dashboardName: 'sales_overview' },
{ id: 'nav_board', type: 'object', objectName: 'customer', viewName: 'board' },
// Control: an authored label.
{ id: 'nav_home', type: 'page', pageName: 'home', label: 'Team Home' },
],
};

function metadata(viewLabel: string, dashboardLabel: string) {
return {
apps: [APP],
objects: [
{
name: 'customer',
label: 'Customers',
listViews: { 'customer.board': { name: 'customer.board', label: viewLabel } },
},
],
dashboards: [{ name: 'sales_overview', label: dashboardLabel }],
reports: [],
pages: [{ name: 'my_page' }, { name: 'home', label: 'Home' }],
loading: false,
error: null,
refresh: async () => {},
invalidate: () => {},
ensureType: async () => [],
getItem: async () => null,
getItemsByType: () => [],
getTypeStatus: () => 'ready' as const,
};
}

function tree(value: ReturnType<typeof metadata>) {
return (
<MemoryRouter initialEntries={['/apps/crm?palette=1']}>
<MetadataCtx.Provider value={value as never}>
<CommandPaletteProvider>
<CommandPalette apps={[APP]} activeApp={APP} objects={value.objects} onAppChange={() => {}} />
</CommandPaletteProvider>
</MetadataCtx.Provider>
</MemoryRouter>
);
}

/** Every nav row of one kind: its visible text and its cmdk filter value. */
function rows(kind: string): Array<{ text: string; value: string }> {
return Array.from(document.querySelectorAll(`[cmdk-item][data-value^="${kind} "]`)).map((el) => ({
text: (el.textContent ?? '').trim(),
value: el.getAttribute('data-value') ?? '',
}));
}

afterEach(() => cleanup());

describe('objectui#9868 — the ⌘K palette names a label-less nav entry like the sidebar does', () => {
it('a label-less page shows its pageName, and is searched by it — never blank, never "undefined"', () => {
render(tree(metadata('客户管理仪表盘', 'Sales Overview')));
if (!document.querySelector('[cmdk-input]')) throw new Error('the palette did not open');

const page = rows('page').find((r) => r.value.includes('my_page'));
expect(page).toBeDefined();
expect(page!.text).toBe('my_page');
expect(page!.value).not.toContain('undefined');
expect(page!.value).toBe('page my_page my_page');
});

it('a label-less dashboard and view entry show their targets’ current labels, and follow a rename', () => {
const utils = render(tree(metadata('客户管理仪表盘', 'Sales Overview')));

expect(rows('dashboard').map((r) => r.text)).toEqual(['Sales Overview']);
expect(rows('dashboard')[0].value.toLowerCase()).toContain('sales overview');
const board = rows('object').find((r) => r.value.includes('customer'));
expect(board?.text).toBe('客户管理仪表盘');
expect(board?.value).toContain('客户管理仪表盘');

utils.rerender(tree(metadata('Customer Board', 'Revenue')));
expect(rows('dashboard').map((r) => r.text)).toEqual(['Revenue']);
expect(rows('object').find((r) => r.value.includes('customer'))?.text).toBe('Customer Board');
});

it('control: an authored label renders verbatim', () => {
render(tree(metadata('Board', 'Sales Overview')));
const home = rows('page').find((r) => r.value.includes('home'));
expect(home?.text).toBe('Team Home');
expect(home?.value.toLowerCase()).toContain('team home');
});
});
27 changes: 18 additions & 9 deletions packages/app-shell/src/chrome/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ import { resolveKeyedI18nLabel, getRecordDisplayName, appRouteSegment } from '..
import { getIcon } from '../utils/getIcon.js';
import { useRecentItems } from '../context/RecentItemsProvider.js';
import { useCommandPalette } from '../context/CommandPaletteProvider.js';
import { resolveHref } from '@object-ui/layout';
import { resolveHref, resolveNavItemLabel } from '@object-ui/layout';
import type { NavigationItem } from '@object-ui/types';
import { useNavTargetLabel } from '../hooks/useNavTargetLabel.js';
import { useAuth } from '@object-ui/auth';

interface CommandPaletteProps {
Expand All @@ -59,6 +61,13 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour
const { setTheme } = useTheme();
const { evaluator } = useExpressionContext();
const { t } = useObjectTranslation();
// A nav entry's text, the way the sidebar and `nav:menu` name it: an entry
// with NO `label` shows its target's current label, else its target's machine
// name (objectui#9868 — `NavigationSyncEffect` writes such entries). A present
// label resolves exactly as `resolveKeyedI18nLabel(item.label, t)` did here.
const targetLabel = useNavTargetLabel();
const navLabel = (item: NavigationItem) =>
resolveNavItemLabel(item, undefined, t, undefined, undefined, targetLabel);

// The ⌘K / Ctrl+K accelerator and the open-state source of truth now live in
// CommandPaletteProvider so the keyboard shortcut, the header button, and the
Expand Down Expand Up @@ -266,11 +275,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour
return (
<CommandItem
key={item.id}
value={`object ${resolveKeyedI18nLabel(item.label, t)} ${item.objectName}`}
value={`object ${navLabel(item)} ${item.objectName}`}
onSelect={() => runCommand(() => navigate(resolveHref(item, baseUrl, templateContext).href))}
>
<Icon className="mr-2 h-4 w-4" />
<span>{resolveKeyedI18nLabel(item.label, t)}</span>
<span>{navLabel(item)}</span>
</CommandItem>
);
})}
Expand All @@ -285,11 +294,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour
.map(item => (
<CommandItem
key={item.id}
value={`dashboard ${resolveKeyedI18nLabel(item.label, t)} ${item.dashboardName}`}
value={`dashboard ${navLabel(item)} ${item.dashboardName}`}
onSelect={() => runCommand(() => navigate(resolveHref(item, baseUrl, templateContext).href))}
>
<LayoutDashboard className="mr-2 h-4 w-4" />
<span>{resolveKeyedI18nLabel(item.label, t)}</span>
<span>{navLabel(item)}</span>
</CommandItem>
))}
</CommandGroup>
Expand All @@ -303,11 +312,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour
.map(item => (
<CommandItem
key={item.id}
value={`page ${resolveKeyedI18nLabel(item.label, t)} ${item.pageName}`}
value={`page ${navLabel(item)} ${item.pageName}`}
onSelect={() => runCommand(() => navigate(resolveHref(item, baseUrl, templateContext).href))}
>
<FileText className="mr-2 h-4 w-4" />
<span>{resolveKeyedI18nLabel(item.label, t)}</span>
<span>{navLabel(item)}</span>
</CommandItem>
))}
</CommandGroup>
Expand All @@ -321,11 +330,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour
.map(item => (
<CommandItem
key={item.id}
value={`report ${resolveKeyedI18nLabel(item.label, t)} ${item.reportName}`}
value={`report ${navLabel(item)} ${item.reportName}`}
onSelect={() => runCommand(() => navigate(resolveHref(item, baseUrl, templateContext).href))}
>
<BarChart3 className="mr-2 h-4 w-4" />
<span>{resolveKeyedI18nLabel(item.label, t)}</span>
<span>{navLabel(item)}</span>
</CommandItem>
))}
</CommandGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* 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.
*/

/**
* Pinning a navigation entry with NO `label` stores text, not `undefined`
* (objectui#9868).
*
* `togglePin` writes a portable `type: 'nav'` favorite record. It used to copy
* `item.label` verbatim, which is `undefined` for an entry that inherits its
* target's label at render time — a record whose `label: string` member then
* held nothing. It now stores `resolveNavItemLabel(item)`: the machine-name
* text the renderer falls back to (the sidebar renders the live nav tree's
* label for a nav pin, so this stored text is a placeholder, like the one
* `migrateLegacyNavPins` writes). CONTROL: an authored label is stored verbatim.
*/

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

const favorites = vi.hoisted(() => ({
addFavorite: vi.fn(),
removeFavorite: vi.fn(),
setPinned: vi.fn(),
}));

vi.mock('../../context/FavoritesProvider', () => ({
useFavorites: () => ({ favorites: [], pinnedNavIds: new Set<string>(), ...favorites }),
}));

import { useNavPins } from '../useNavPins';

beforeEach(() => {
vi.clearAllMocks();
});

describe('objectui#9868 — pinning a label-less nav entry', () => {
it('stores the machine-name text the renderer falls back to, never undefined', () => {
const { result } = renderHook(() => useNavPins());
result.current.togglePin('nav_page', true, { id: 'nav_page', type: 'page', pageName: 'my_page' }, '/apps/crm');

expect(favorites.addFavorite).toHaveBeenCalledTimes(1);
const record = favorites.addFavorite.mock.calls[0][0];
expect(record).toMatchObject({ id: 'nav:nav_page', type: 'nav', navId: 'nav_page', label: 'my_page' });
expect(typeof record.label).toBe('string');
});

it('control: an authored label is stored verbatim', () => {
const { result } = renderHook(() => useNavPins());
result.current.togglePin(
'nav_home',
true,
{ id: 'nav_home', type: 'page', pageName: 'home', label: 'Team Home' },
'/apps/crm',
);
expect(favorites.addFavorite.mock.calls[0][0]).toMatchObject({ label: 'Team Home' });
});
});
Loading
Loading