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
43 changes: 43 additions & 0 deletions .changeset/7509-dashboard-root-title-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@object-ui/app-shell': minor
'@object-ui/plugin-dashboard': minor
'@object-ui/plugin-designer': minor
---

Retire the dashboard-**root** `title` read across all five surfaces (objectui#7509,
maintainer ruling 2026-09-04, decision batch #29, option C, under ADR-0049).

**What changes for an operator.** A stored dashboard whose header came from a legacy
root `title` now shows its `label`. `label` is the only header source, then the raw
`name`.

Per surface:

- Console dashboard page (`DashboardView`) — header falls to `label`, then `name`.
- Standalone dashboard embed (`DashboardRenderer`) — `header` shows `label`; a document
with no `label` now shows no header title at all.
- The `dashboard-grid` SDUI component (`DashboardGridLayout`) — heading falls to
`label`, then the generic `Dashboard`.
- Studio dashboard designer (`DashboardEditor` preview panel, `DashboardDesignPage`
heading) — both fall to `label`, then `name` / the generic heading.

**Why now.** `@objectstack/spec`'s `DashboardSchema` refuses a root `title` **by name**
(`unrecognized_keys(title)`), and the save route answers `422 INVALID_METADATA` — so no
authored dashboard can acquire the key, and what retires is compatibility with documents
stored before that refusal existed. Until now five surfaces read the legacy spelling
independently, which meant a legacy document could show one header in the console and a
different one in the designer. One spelling now answers everywhere.

**Migration.** `label` is REQUIRED on `DashboardSchema`, so a spec-valid stored dashboard
already carries it and needs no change — it simply starts showing that `label` instead of
the legacy `title`. A document carrying `title` and no `label` was already invalid; give
it a `label`. No in-repo document needed migrating: a sweep of all 627 tracked JSON found
9 dashboard-shaped nodes, and the 6 carrying a root `title` are `type: 'dashboard'`
component examples that declare no `header`, so none of them rendered a header title
either before or after.

**Not affected: widget titles.** `DashboardWidget.title` is a different, spec-**declared**
key (the spec's `I18nLabel`) on a different receiver, and is untouched — widget headings,
the designer's widget-title input and its per-locale write path all behave exactly as
before. Root and widget arms were separated by receiver, and the retirement's pins carry
widget-level controls on every surface for that reason.
171 changes: 171 additions & 0 deletions packages/app-shell/src/views/DashboardView.rootTitleRetired.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* 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.
*/

/**
* Retirement pin — the dashboard-ROOT `title` read arm (objectui#7509).
*
* Maintainer ruling 2026-09-04 (decision batch #29, option C): the five root
* `title` read arms retire together under ADR-0049, and `label` — REQUIRED on
* `@objectstack/spec`'s `DashboardSchema` — becomes the only header source,
* then the raw `name`. This file pins THIS view's arm; the four siblings carry
* their own, in the same shape.
*
* Shaped like the #5830 / #5852 retirements: the assertion is what a document
* carrying the retired key RENDERS, not that the code still compiles. A
* compile-only pin would have passed with the arm still in place.
*
* Why the retired key can still arrive at all: the spec refuses root `title` BY
* NAME (`unrecognized_keys(title)` at the document root), so the save route
* answers `422 INVALID_METADATA` and no AUTHORED document can acquire it. What
* retired is compatibility with documents STORED before the refusal existed —
* a renderer cannot refuse to receive stored metadata, so it is pinned rather
* than assumed away.
*
* ⛔ Widget-level `widget.title` is a DIFFERENT, DECLARED key
* (`DashboardWidget.title`, the spec's `I18nLabel`) and is NOT retired. The
* last case is the negative control for exactly that: root and widget arms are
* told apart by RECEIVER, never by grep, and a sweep that confused them would
* delete live contract-declared behaviour.
*/

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

// The renderer is stubbed: the header <h1> under test is rendered by the VIEW,
// and capturing the props also proves the widgets (with their own `title`)
// reach the renderer untouched.
const cap = vi.hoisted(() => ({ props: null as any }));
vi.mock('@object-ui/plugin-dashboard', () => ({
DashboardRenderer: (props: any) => {
cap.props = props;
return null;
},
}));

const meta = vi.hoisted(() => ({ value: null as any }));
vi.mock('../providers/MetadataProvider', () => ({ useMetadata: () => meta.value }));

vi.mock('react-router-dom', () => ({
useParams: () => ({ dashboardName: 'sales_overview' }),
useNavigate: () => vi.fn(),
useLocation: () => ({ pathname: '/dashboards/sales_overview', search: '' }),
}));

vi.mock('./useOpenRecordList', () => ({ useOpenRecordList: () => vi.fn() }));
vi.mock('./MetadataInspector', () => ({
MetadataPanel: () => null,
useMetadataInspector: () => ({ showDebug: false }),
}));
vi.mock('../providers/AdapterProvider', () => ({ useAdapter: () => ({}) }));
vi.mock('../providers/ExpressionProvider', () => ({ useExpressionContext: () => ({ app: undefined }) }));
vi.mock('@object-ui/i18n', () => ({
useObjectTranslation: () => ({ t: (k: string) => k }),
// Pass-through: the i18n bundle is a SEPARATE channel with its own tests, and
// resolving through it here would let a bundle entry answer for the key this
// file is measuring.
useObjectLabel: () => ({
dashboardLabel: ({ label, name }: any) => label ?? name,
dashboardDescription: ({ description }: any) => description,
}),
createSafeTranslation: (defaults: Record<string, string>) => () => ({
t: (k: string) => defaults?.[k] ?? k,
}),
}));

import { DashboardView } from './DashboardView';

const LEGACY_TITLE = 'Legacy Title From A Stored Document';
const CANONICAL_LABEL = 'Sales Overview';

/** Mount the view over exactly one stored dashboard document. */
async function mountWith(dashboard: Record<string, unknown>) {
meta.value = {
apps: [],
objects: [],
dashboards: [dashboard],
reports: [],
pages: [],
loading: false,
error: null,
refresh: async () => {},
invalidate: () => {},
ensureType: async () => [],
getItem: vi.fn(async () => null),
getItemsByType: () => [],
getTypeStatus: () => 'ready',
};

const { container } = render(
<MetadataCtx.Provider value={meta.value as any}>
<DashboardView />
</MetadataCtx.Provider>,
);

// The view renders a skeleton first; the header only exists once loading ends.
await waitFor(() => expect(container.querySelector('h1')).not.toBeNull());
return container.querySelector('h1')!;
}

beforeEach(() => {
cap.props = null;
vi.clearAllMocks();
});
afterEach(cleanup);

describe('DashboardView — the root `title` read arm is retired (objectui#7509)', () => {
it('renders the `label` header for a document carrying BOTH, and never the `title`', async () => {
// The ruling's stated, VISIBLE change: a legacy document that also carries
// the required `label` now shows the `label`.
const h1 = await mountWith({
name: 'sales_overview',
label: CANONICAL_LABEL,
title: LEGACY_TITLE,
widgets: [],
});

expect(h1.textContent).toBe(CANONICAL_LABEL);
expect(screen.queryByText(LEGACY_TITLE)).toBeNull();
});

it('falls through to the raw `name` for a document carrying ONLY the retired key', async () => {
// `label` is REQUIRED on DashboardSchema, so this document was already
// invalid; it is pinned because a renderer cannot refuse stored metadata,
// and because it is where the retirement is actually felt.
const h1 = await mountWith({ name: 'sales_overview', title: LEGACY_TITLE, widgets: [] });

expect(h1.textContent).toBe('sales_overview');
expect(screen.queryByText(LEGACY_TITLE)).toBeNull();
});

it('CONTROL — a document with only `label` renders it, so the two assertions above are not vacuous', async () => {
// Without this, "the title is absent" would also be satisfied by a header
// that renders nothing at all, and both cases above would pass for the
// wrong reason.
const h1 = await mountWith({ name: 'sales_overview', label: CANONICAL_LABEL, widgets: [] });

expect(h1.textContent).toBe(CANONICAL_LABEL);
});

it('CONTROL — widget-level `title` is a different DECLARED key and reaches the renderer intact', async () => {
// `DashboardWidget.title` is the spec's `I18nLabel`. A grep-driven sweep
// over these files would have taken it too; this is the receiver-level
// proof that it survived.
await mountWith({
name: 'sales_overview',
label: CANONICAL_LABEL,
title: LEGACY_TITLE,
widgets: [{ id: 'w1', type: 'metric', title: 'Revenue' }],
});

await waitFor(() => expect(cap.props).not.toBeNull());
expect(cap.props.schema.widgets).toHaveLength(1);
expect(cap.props.schema.widgets[0].title).toBe('Revenue');
});
});
48 changes: 28 additions & 20 deletions packages/app-shell/src/views/DashboardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,28 +170,36 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-3 sm:gap-4 p-4 sm:p-6 border-b shrink-0">
<div className="min-w-0 flex-1">
{(() => {
// `title` is NOT a spec key — it is the LEGACY objectui spelling,
// read here only so a stored dashboard document that predates
// `label` still gets a header. Measured on @objectstack/spec
// 17.2.0: `DashboardSchema` refuses `title` BY NAME
// (`unrecognized_keys(title)` at the document root) and spells the
// display name `label`; `header` declares `showTitle` /
// `showDescription` / `actions` only, so it TOGGLES a title and
// never carries one. So authored dashboard metadata must use
// `label`: writing `title` earns a `422 INVALID_METADATA` /
// `unrecognized_keys` from the save route before persistence
// (see `MetadataService`), not a header.
// Header source: `label`, then the raw `name`. There is no `title`
// arm — the legacy root `title` read RETIRED here under ADR-0049
// (objectui#7509, maintainer ruling 2026-09-04), together with the
// four sibling arms in `DashboardRenderer`, `DashboardGridLayout`,
// `DashboardEditor` and `DashboardDesignPage`, so one spelling
// answers on every surface instead of two disagreeing.
//
// `previewSchema` is NOT a host-supplied preview channel — it is
// this view's own widget-pruned copy of `dashboard` (above), so
// either arm of `headerSrc` reads the same stored document.
// `DashboardRenderer` reads the same legacy-then-canonical pair.
// Order: legacy `title`, then `label`, then the raw `name`.
const headerSrc = (previewSchema as any) || dashboard;
const resolvedTitle = resolveKeyedI18nLabel(headerSrc.title, t);
// Measured on @objectstack/spec 17.2.0: `DashboardSchema` refuses
// `title` BY NAME (`unrecognized_keys(title)` at the document root)
// and spells the display name `label`, which is REQUIRED; `header`
// declares `showTitle` / `showDescription` / `actions` only, so it
// TOGGLES a title and never carries one. Writing `title` earns a
// `422 INVALID_METADATA` from the save route before persistence
// (see `MetadataService`), not a header — so no authored document
// can acquire the key, and what retired is legacy-document
// compatibility only. A spec-valid stored document always carries
// `label`, so a legacy document holding BOTH now shows its `label`;
// one holding `title` and no `label` was already invalid and falls
// through to `name`.
//
// ⛔ Widget-level `widget.title` is a DIFFERENT, DECLARED key
// (`DashboardWidget.title`, the spec's `I18nLabel`) and is
// untouched. Root and widget arms are told apart by RECEIVER.
//
// `previewSchema` was never a host-supplied preview channel — it is
// this view's own widget-pruned copy of `dashboard` (above) — so
// the retired arm read the same stored document either way, which
// is why it is gone rather than re-pointed.
const resolvedLabel = resolveKeyedI18nLabel(dashboard.label, t);
const fallbackLabel = dashboardLabel({ name: dashboard.name, label: resolvedLabel });
const display = resolvedTitle || fallbackLabel || dashboard.name;
const display = dashboardLabel({ name: dashboard.name, label: resolvedLabel }) || dashboard.name;
return (
<h1 className="text-lg sm:text-xl md:text-2xl font-bold tracking-tight truncate">{display}</h1>
);
Expand Down
21 changes: 17 additions & 4 deletions packages/plugin-dashboard/src/DashboardGridLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -386,12 +386,25 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
component: `pickLocalized` is objectui's limb-for-limb twin of the spec's
`resolveI18nLabel` (objectstack#6765), differing only in how it spells a
miss (`''` vs `undefined`) — pinned in
`plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts`. The `||`
chain is preserved exactly: a miss yields `''`, which is falsy, so
`'Dashboard'` still backstops it.
`plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts`. A miss
yields `''`, which is falsy, so `'Dashboard'` still backstops it.

`schema.label` is the ONLY header source. A legacy root `title` used
to be read ahead of it; that arm RETIRED under ADR-0049
(objectui#7509, maintainer ruling 2026-09-04) together with the four
sibling root arms in `DashboardView`, `DashboardRenderer`,
`DashboardEditor` and `DashboardDesignPage` — @objectstack/spec's
`DashboardSchema` refuses root `title` BY NAME
(`unrecognized_keys(title)`) and requires `label`, so what retired is
legacy-document compatibility, not an authoring surface.

⛔ NOT the widget arm: `widget.title` is `DashboardWidget.title`, the
spec's `I18nLabel` — a different DECLARED key, read ~100 lines below
and untouched. The two are told apart by RECEIVER; this one's receiver
is the dashboard ROOT.
*/}
<h2 className="text-2xl font-bold">
{schema.title || pickLocalized(schema.label, language) || 'Dashboard'}
{pickLocalized(schema.label, language) || 'Dashboard'}
</h2>
<div className="flex gap-2">
{editMode ? (
Expand Down
23 changes: 18 additions & 5 deletions packages/plugin-dashboard/src/DashboardRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -940,11 +940,24 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
return <Fragment key={widgetKey}>{renderedNode}</Fragment>;
};

// The spec-canonical dashboard display name is `label` (@objectstack/spec
// DashboardSchema); `title` is the legacy objectui spelling. Read both so
// spec-compliant dashboards get their header title (framework#1878/#1891;
// mirrors the DashboardGridLayout fallback from #2666).
const headerTitle = schema.title || schema.label;
// The dashboard display name is `label` (@objectstack/spec
// `DashboardSchema`, where it is REQUIRED) and nothing else. The legacy
// objectui `title` spelling used to be read first here; that arm RETIRED
// under ADR-0049 (objectui#7509, maintainer ruling 2026-09-04) together
// with the four sibling root arms in `DashboardView`,
// `DashboardGridLayout`, `DashboardEditor` and `DashboardDesignPage`, so
// the same stored document can no longer show one header in the console and
// a different one in the designer (framework#1878/#1891 record where the
// legacy spelling came from; #2666 is the fallback this mirrored).
//
// The spec refuses root `title` BY NAME (`unrecognized_keys(title)`), so no
// authored document can acquire the key and only legacy-document
// compatibility retires here.
//
// ⛔ NOT the widget arm: `widget.title` is `DashboardWidget.title`, the
// spec's `I18nLabel`, a different DECLARED key that stays. The two are told
// apart by RECEIVER — this one's receiver is the dashboard ROOT.
const headerTitle = schema.label;
/**
* Decide what the header would actually SHOW before deciding whether to
* render its wrapper at all.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,22 @@ describe('DashboardGridLayout heading — inline locale map label (objectui#4580
});

/**
* The `||` chain around the label is preserved exactly, in both directions.
* A resolver miss yields `''` (falsy), so the `'Dashboard'` backstop still
* fires — if the resolution had been spelled with the spec resolver's
* `undefined` miss it would behave the same here, but a `?? ''` written in the
* wrong place would have swallowed the backstop.
* INVERTED by objectui#7509 (maintainer ruling 2026-09-04, decision batch
* #29). This case used to read "keeps `title` ahead of `label` in the
* precedence chain" and asserted `Pipeline`. The dashboard-root `title` read
* arm retired under ADR-0049 across all five surfaces, so `label` is now the
* only header source — and the case is inverted rather than deleted, because
* a legacy `title` sitting in front of the map is precisely what used to stop
* this file's subject (the resolver) from running at all.
*
* That makes this the strongest non-vacuity control in the file: before the
* retirement, a document carrying both NEVER exercised `pickLocalized`.
*/
it('keeps `title` ahead of `label` in the precedence chain', () => {
it('resolves the `label` map even when a legacy root `title` is also present', () => {
renderGrid({ title: 'Pipeline', label: INLINE_MAP }, 'zh-CN');
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Pipeline');
const heading = screen.getByRole('heading', { level: 2 });
expect(heading).toHaveTextContent('负责人');
expect(heading).not.toHaveTextContent('Pipeline');
});

it("falls back to 'Dashboard' when the map resolves to nothing", () => {
Expand Down
Loading
Loading