Skip to content

Commit 41df5eb

Browse files
feat(app-shell): an object list's title names the object ahead of the view switcher (#28438)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoS8upiKFfSUfnU5rfNgvNej)_ Fixes #27950 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:对象列表页标题从「我的线索 ▾ · 12」变为「线索 / 我的线索 ▾ · 12」,对象复数名在视图切换器前面,点击回到该对象的默认列表;手机顶栏的视图切换按钮同样读作「线索 / 我的线索」。 - 为什么改:侧栏收起或从链接直接打开列表时,页面上没有文字说明这是哪个对象;按维护者裁决 B(页头对象名前置视图切换器,窄列表对象名优先保留)。 - 风险与回滚:只动 Console 列表页标题这一处界面,不碰顶栏 AppHeader、记录页与任何元数据;回滚即还原本 PR。 - 先看:`ui/packages/app-shell/src/views/objectViewPageHeader.tsx`(`ObjectListTitle` 与 `title` 的三种取值)、`ui/packages/app-shell/src/views/objectViewViewCrud.tsx`(手机按钮文字)、新测试 `ObjectView.objectListTitle-27950.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/ObjectView.objectListTitle-27950.test.tsx`,预期 9 个测试全过;或在 Console 打开 CRM 线索的任一视图,标题左侧可见「线索 /」。 - 需要你做的:无,席位落地。 ## What changes The maintainer's ruling B on #27950 (comment 6539906268), verbatim: 「#27950 裁 B:页头对象名前置视图切换器;窄列表下对象名优先保留、视图名截断或换行、计数可省」. - **Page header** (`objectViewPageHeader.tsx`): when the list has a view switcher, the title is 「OBJECT / VIEW ▾ · COUNT」. The object's plural label (the reader's language, through `objectPluralLabel`) comes first, as a link to `/apps/APP/OBJECT`. That bare address opens the reader's pinned view, or else the declared default. Off an app route it is plain text. - A view with no `label` is already called by the plural (objectui#12774). Its title stays the switcher alone, so the object is named once and never as 「Leads / Leads」. - With no view to switch, the title is the plural, as before. - **Narrow rows** (the split pane's narrowed list, #27545): the object label is `shrink-0 whitespace-nowrap`. The view label is the switcher's own `truncate` span inside a `min-w-0` row, so the view label is cut first and the object label never is. The count stays: it is a few characters, and the ruling makes dropping it optional. - **Phone** (`objectViewViewCrud.tsx`): the page header is not drawn below `sm`. The app bar's view switcher trigger now receives `triggerLabel` 「OBJECT / VIEW」 through the existing `MobileViewSwitcherContext` field. That span truncates its end, so the view label gives way before the object's. A label-less view keeps the plain label. `AppHeader.tsx` is not touched. - `useObjectViewTitleSwitcher` also returns the `titleLabel` it already computed, and `ObjectView` passes it and the object list href to the header. - Docs: `content/docs/ui/views.mdx` › Switching views. Changeset: `.changeset/27950-object-list-title-names-object.md` (`@object-ui/app-shell` patch). ## Tests New `ui/packages/app-shell/src/views/ObjectView.objectListTitle-27950.test.tsx` (9 cells, real `ObjectView` → plugin `ObjectView` → `ListView` chain, the `ObjectView.titleViewSwitcher-27594` harness): - On the object's address and on a view's address, the heading reads `Leads` then the switcher. The label is an `A` with `href="/apps/crm/crm_lead"`, and following it opens the default view. - With the sidebar expanded and collapsed (the real `SidebarProvider`; `useSidebar().state` is read back as `expanded` / `collapsed`), the object label is drawn exactly once. - A record open beside the list (split preference, `?recordId=`) gives one object label and one switcher. - A label-less view: no crumb, and the switcher reads `Notes`. - The narrow-row classes decide the cut: the crumb is `shrink-0` + `whitespace-nowrap`, and the view label is `truncate`. - The phone trigger reads `Leads / My Leads`. The record route's crumb is not drawn by this page: `RecordDetailView` is a separate route chunk. The existing `AppHeader.appBarLeftGroup-12461` cells ("an object's view and record routes draw no crumb either") pin the app bar, and they pass unchanged in the run below. <details><summary>Ablations (scripts/ablation-replace.mjs, WRAP mode, under os-verify-lock, trap restore)</summary> No dist leg: the suite imports `./ObjectView` from source. | leg | mutation | result | |:--|:--|:--| | A1 | title back to the switcher alone | `Tests 7 failed / 2 passed (9)` (the label-less and phone cells stay green) | | A2 | phone `triggerLabel` dropped | `Tests 1 failed / 8 passed (9)` | | A3 | crumb class `shrink-0 whitespace-nowrap` → `min-w-0 truncate` | `Tests 1 failed / 8 passed (9)` | | A4 | label-less guard removed | `Tests 1 failed / 8 passed (9)` | Every leg: `ok mutation landed: anchor 1 -> 0`. Every restore: `blob == HEAD` (`objectViewPageHeader.tsx` 540eb8b, `objectViewViewCrud.tsx` a05153f) and `git diff HEAD` empty. These ablations ran at the pre-merge head, where both files were byte-identical to the final head. </details> ## Acceptance notes - The card's original file surface (`AppHeader.tsx`) was replaced by the ruling. The diff lands in `objectViewPageHeader.tsx`, `objectViewViewCrud.tsx`, `objectViewTitleSwitcher.tsx` (one returned value) and `ObjectView.tsx` (two props), as the decision comment's 裁后执行 line named. - Seat 1's #28242 (header action pressed state) works on the header's actions cluster. This diff touches only the `title` prop of the same `PageHeader` call in `objectViewPageHeader.tsx`, a different region. - The phone bar's trigger is still capped at 180px by `AppHeader`. An object plural longer than that would itself be cut. No authored object in this tree comes close, and widening the cap is `AppHeader`'s, outside this ruling. - No new i18n key: the separator is a glyph (`aria-hidden`), and the link's name is the object's own label. - Not taken: a browser reading at 1440 / 464 / 390 px. The test DOM computes no layout, so the narrow-row behaviour is pinned by the classes that decide it. The landing seat may take the reading. ## Verification All at head `750e100f` (branch merged with `origin/main` `5da3a8ce`, which carries #28394), except the ablations as noted. <details><summary>Derived gates — 66 derived, 66 run, all exit 0</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 66 commands from 7 paths vs merge base `5da3a8ce3`. Each ran with `</dev/null`, and each exit code was recorded before any pipe. This includes `node scripts/check-sdui-manifest.mjs` (exit 0) and `pnpm --dir ui exec vitest run scripts/` (`Test Files 169 passed | 2 skipped (171)`, `Tests 4901 passed | 2 skipped (4903)`). Reconciliation: `dispatch-gates --ran`: `66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN`. Before the gates: `env -u OS_SKIP_DTS pnpm turbo run build --filter=@objectstack/spec --filter='@objectstack/lint...' --filter='@objectstack/client-react...'` (38/38). </details> <details><summary>Tests and type-check</summary> - 106 ui suites (every `app-shell` `ObjectView.*` and `AppHeader.*` suite, plus every test naming `MobileViewSwitcher`, the page header, the title switcher or the view CRUD hook) under os-verify-lock: `Test Files 106 passed (106)`, `Tests 818 passed (818)`, VERDICT command-exit 0. - `pnpm --filter @object-ui/app-shell type-check` (both `tsconfig.json` and `tsconfig.test.json`; the test config lists the new suite) after `env -u OS_SKIP_DTS pnpm turbo run build --filter='@object-ui/app-shell^...'` (64/64): VERDICT command-exit 0. - `eslint --no-inline-config` on the four changed `app-shell` files: 0 errors (34 pre-existing warnings, none on the new lines). `pnpm check:new-line-citations` (ui): 0 new citations. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibyHsuwJSdPnjSmNWsB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f4a8eed commit 41df5eb

7 files changed

Lines changed: 350 additions & 15 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
feat(app-shell): an object list's title names the object ahead of the view switcher
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The Console list title has been the view switcher, 「VIEW ▾ · COUNT」, and the app bar draws no object crumb, so with the sidebar collapsed to its rail or the list opened from a link nothing on the page said which object it listed. The title now reads 「OBJECT / VIEW ▾ · COUNT」: the object's plural label, in the reader's language, comes first and links to the object's default list (`/apps/APP/OBJECT`, which opens the reader's pinned view or the declared default).
11+
12+
- A view with no `label` is already called by the plural, so its title reads the plural alone.
13+
- Where the row is short, the object's label never shrinks; the view's label truncates first.
14+
- On a phone the app bar's view switcher reads 「OBJECT / VIEW」, so the view's label is the part cut at the end.
15+
- The app bar is unchanged: it still draws no crumb on an object's list, view or record route. The record page's own header is unchanged.

‎content/docs/ui/views.mdx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,12 @@ gantt: {
360360
### Switching views
361361

362362
In the Console the list's title is the view switcher: it reads the open view's `label` and a
363-
▾, and on a grid the number of records the view holds. It opens a dropdown with a search box
363+
▾, and on a grid the number of records the view holds. Ahead of it the title names the object
364+
with its plural label, as **Leads / My Leads ▾ · 12**, so the page says which object it lists
365+
with the sidebar collapsed or the list opened from a link; the object's name leads back to its
366+
default list. A view with no `label` is already called by the plural and reads alone. Where the
367+
row is short (the split pane, a phone) the object's name stays whole and the view's label is
368+
cut first. The switcher opens a dropdown with a search box
364369
and three sections: **Pinned**, **Recent** (the user's last views of this object) and
365370
**All**, where the views you author come first and the views users save at runtime follow.
366371
The split pane beside an open record keeps the same switcher.
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#27950 — an object list's title names the object.
11+
*
12+
* Since objectstack#27594 the list title is the view switcher, and the app bar
13+
* draws no object crumb (objectui#12461: the page header is the one place a
14+
* page is named). The maintainer's ruling B puts the object's plural ahead of
15+
* the switcher: 「OBJECT / VIEW ▾ · COUNT」, the plural linking to the object's
16+
* default list; where the row is short the object label stays whole and the
17+
* view label gives way.
18+
*
19+
* The real chain, as `ObjectView.titleViewSwitcher-27594`: the real
20+
* `ObjectView`, the real plugin `ObjectView` and the real `ListView`.
21+
*
22+
* WHAT IS PINNED.
23+
* - On the object's bare address and on a view's, the heading reads the
24+
* plural, then the switcher; the plural is a link to `/apps/APP/OBJECT`,
25+
* and following it opens the object's default view.
26+
* - The same with the sidebar expanded and collapsed (the real
27+
* `SidebarProvider`, whose state is read back).
28+
* - A record open beside the list (the split pane) leaves one object label.
29+
* - A view with no label is the plural already: the heading names it once.
30+
* - Narrow rows: the object label is `shrink-0` and unwrapped, the view label
31+
* is the switcher's `truncate` span. The test DOM computes no layout, so the
32+
* classes that decide it are what is read.
33+
* - The phone bar's trigger reads 「OBJECT / VIEW」, the object first.
34+
*
35+
* The record route's crumb is not drawn by this page (`RecordDetailView`);
36+
* `AppHeader.appBarLeftGroup-12461` pins that it stays absent from the app bar.
37+
*/
38+
39+
import * as React from 'react';
40+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
41+
import { render, cleanup, screen, waitFor, within, fireEvent } from '@testing-library/react';
42+
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
43+
import { createI18n, I18nProvider } from '@object-ui/i18n';
44+
import { SidebarProvider, useSidebar } from '@object-ui/components';
45+
46+
vi.mock('@object-ui/permissions', async (importOriginal) => {
47+
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
48+
// Stable identities — `ListView` names `perms` in its fetch dependencies.
49+
const perms = {
50+
check: () => ({ allowed: true }),
51+
checkField: () => true,
52+
getFieldPermissions: () => [],
53+
getRowFilter: () => undefined,
54+
getObjectApiOperations: () => undefined,
55+
roles: [],
56+
isLoaded: false,
57+
hasCapabilities: () => true,
58+
can: () => true,
59+
cannot: () => false,
60+
};
61+
const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] };
62+
return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms };
63+
});
64+
65+
vi.mock('@object-ui/auth', async (importOriginal) => ({
66+
...(await importOriginal<Record<string, unknown>>()),
67+
useAuth: () => ({ user: { id: 'u1', name: 'Sales Manager' }, activeOrganization: { id: 'org1', name: 'Acme' } }),
68+
useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }),
69+
}));
70+
71+
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
72+
...(await importOriginal<Record<string, unknown>>()),
73+
useRealtimeSubscription: () => ({ lastMessage: null }),
74+
useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }),
75+
}));
76+
77+
vi.mock('./MetadataInspector', () => ({
78+
MetadataPanel: () => null,
79+
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
80+
}));
81+
vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null }));
82+
83+
import { ObjectView } from './ObjectView';
84+
import { ExpressionProvider } from '../providers/ExpressionProvider';
85+
import { RECORD_OPEN_PREFERENCE_KEY } from '../utils/recordOpenPreference';
86+
import { MobileViewSwitcherProvider, useMobileViewSwitcher } from '../layout/MobileViewSwitcherContext';
87+
88+
// The page's own reads (the security `explain` probe among them) are relative
89+
// fetches, which happy-dom would send to a real socket. One double, installed
90+
// at module scope and never torn down, so no read the mounted tree issues
91+
// after a test body returns can reach the network.
92+
vi.stubGlobal('fetch', vi.fn(async () =>
93+
new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } })));
94+
95+
const LEAD = {
96+
name: 'crm_lead',
97+
label: 'Lead',
98+
pluralLabel: 'Leads',
99+
fields: {
100+
id: { type: 'text', label: 'Id' },
101+
name: { type: 'text', label: 'Name' },
102+
},
103+
listViews: {
104+
all: { name: 'all', label: 'All Leads', type: 'grid', columns: ['name'] },
105+
mine: { name: 'mine', label: 'My Leads', type: 'grid', columns: ['name'] },
106+
},
107+
};
108+
109+
/** An object whose one view the author left unnamed: the view is called by the plural. */
110+
const NOTE = {
111+
name: 'crm_note',
112+
label: 'Note',
113+
pluralLabel: 'Notes',
114+
fields: { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } },
115+
listViews: { all: { type: 'grid', columns: ['name'] } },
116+
};
117+
118+
const OBJECTS = [LEAD, NOTE];
119+
const ROWS = [{ id: 'lead_1', name: 'Ada' }];
120+
121+
/** Where the router is, read back after a click. */
122+
function LocationProbe() {
123+
const location = useLocation();
124+
return <output data-testid="location">{location.pathname}</output>;
125+
}
126+
127+
/** The sidebar state the page renders under, read back. */
128+
function SidebarStateProbe() {
129+
return <output data-testid="sidebar-state">{useSidebar().state}</output>;
130+
}
131+
132+
/** The phone bar's trigger label, as `AppHeader` reads it. */
133+
function MobileTriggerProbe() {
134+
return <output data-testid="mobile-trigger">{useMobileViewSwitcher()?.triggerLabel ?? ''}</output>;
135+
}
136+
137+
function mount(entry: string, { sidebarOpen = true }: { sidebarOpen?: boolean } = {}) {
138+
const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
139+
const dataSource = {
140+
find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })),
141+
findOne: vi.fn(async () => null),
142+
getObjectSchema: vi.fn(async (name: string) => OBJECTS.find((o) => o.name === name) ?? null),
143+
create: vi.fn(async () => ({})),
144+
update: vi.fn(async () => ({})),
145+
delete: vi.fn(async () => ({})),
146+
} as never;
147+
const page = <ObjectView dataSource={dataSource} objects={OBJECTS} onEdit={() => {}} />;
148+
return render(
149+
<I18nProvider instance={i18n}>
150+
<ExpressionProvider user={{ id: 'u1', name: 'Sales Manager' }}>
151+
<SidebarProvider open={sidebarOpen}>
152+
<MobileViewSwitcherProvider>
153+
<MemoryRouter initialEntries={[entry]}>
154+
<LocationProbe />
155+
<SidebarStateProbe />
156+
<MobileTriggerProbe />
157+
<Routes>
158+
<Route path="/apps/:appName/:objectName/view/:viewId" element={page} />
159+
<Route path="/apps/:appName/:objectName" element={page} />
160+
</Routes>
161+
</MemoryRouter>
162+
</MobileViewSwitcherProvider>
163+
</SidebarProvider>
164+
</ExpressionProvider>
165+
</I18nProvider>,
166+
);
167+
}
168+
169+
/** The list's toolbar has drawn: its Filter control is on screen. */
170+
const listToolbar = () =>
171+
waitFor(() => expect(screen.getByTestId('toolbar-filter-trigger')).toBeInTheDocument(), { timeout: 5000 });
172+
173+
const heading = () => screen.getByRole('heading', { level: 1 });
174+
const crumb = () => within(heading()).getByTestId('object-list-crumb');
175+
const switcher = () => within(heading()).getByTestId('list-view-switcher');
176+
177+
beforeEach(() => {
178+
localStorage.clear();
179+
});
180+
181+
afterEach(() => {
182+
cleanup();
183+
});
184+
185+
describe('objectstack#27950 — the list title names the object ahead of the view switcher', () => {
186+
it.each([
187+
['the object\'s address', '/apps/crm/crm_lead', 'All Leads'],
188+
['a view\'s address', '/apps/crm/crm_lead/view/mine', 'My Leads'],
189+
])('on %s the heading reads 「Leads / VIEW ▾」, the plural linking to the default list', async (_where, entry, view) => {
190+
mount(entry);
191+
await listToolbar();
192+
expect(crumb()).toHaveTextContent(/^Leads$/);
193+
expect(crumb().tagName).toBe('A');
194+
expect(crumb()).toHaveAttribute('href', '/apps/crm/crm_lead');
195+
expect(switcher()).toHaveTextContent(view);
196+
// The plural comes first, the switcher after it.
197+
expect(crumb().compareDocumentPosition(switcher()) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
198+
expect(heading().textContent).toMatch(new RegExp(`^Leads/${view}`));
199+
});
200+
201+
it('following the object label opens the object\'s default list', async () => {
202+
mount('/apps/crm/crm_lead/view/mine');
203+
await listToolbar();
204+
fireEvent.click(crumb());
205+
await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent(/^\/apps\/crm\/crm_lead$/));
206+
await waitFor(() => expect(switcher()).toHaveTextContent('All Leads'));
207+
expect(crumb()).toHaveTextContent(/^Leads$/);
208+
});
209+
210+
it.each([
211+
['expanded', true],
212+
['collapsed', false],
213+
])('with the sidebar %s the heading names the object once', async (state, open) => {
214+
mount('/apps/crm/crm_lead/view/mine', { sidebarOpen: open });
215+
await listToolbar();
216+
expect(screen.getByTestId('sidebar-state')).toHaveTextContent(state);
217+
expect(screen.getAllByTestId('object-list-crumb')).toHaveLength(1);
218+
expect(crumb()).toHaveTextContent(/^Leads$/);
219+
});
220+
221+
it('a record open beside the list (the split pane) leaves one object label and one switcher', async () => {
222+
localStorage.setItem(RECORD_OPEN_PREFERENCE_KEY, 'split');
223+
mount('/apps/crm/crm_lead/view/mine?recordId=lead_1');
224+
await listToolbar();
225+
expect(screen.getAllByTestId('object-list-crumb')).toHaveLength(1);
226+
expect(screen.getAllByTestId('list-view-switcher')).toHaveLength(1);
227+
expect(crumb()).toHaveTextContent(/^Leads$/);
228+
});
229+
230+
it('a view with no label is the plural already: the heading names the object once', async () => {
231+
mount('/apps/crm/crm_note');
232+
await listToolbar();
233+
expect(within(heading()).queryByTestId('object-list-crumb')).toBeNull();
234+
expect(switcher()).toHaveTextContent('Notes');
235+
expect(screen.getByTestId('mobile-trigger')).toHaveTextContent(/^$/);
236+
});
237+
238+
it('a short row keeps the object label whole: it never shrinks, the view label truncates', async () => {
239+
mount('/apps/crm/crm_lead/view/mine');
240+
await listToolbar();
241+
expect(crumb()).toHaveClass('shrink-0', 'whitespace-nowrap');
242+
const viewLabel = within(switcher()).getByText('My Leads');
243+
expect(viewLabel).toHaveClass('truncate');
244+
expect(switcher()).toHaveClass('min-w-0');
245+
expect(within(heading()).getByTestId('object-list-title')).toHaveClass('min-w-0');
246+
});
247+
248+
it('the phone bar\'s trigger reads 「Leads / VIEW」, the object first', async () => {
249+
mount('/apps/crm/crm_lead/view/mine');
250+
await listToolbar();
251+
await waitFor(() => expect(screen.getByTestId('mobile-trigger')).toHaveTextContent(/^Leads \/ My Leads$/));
252+
});
253+
});

‎ui/packages/app-shell/src/views/ObjectView.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -803,7 +803,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
803803

804804
// objectstack#27594: the list title is the view switcher, which replaced the
805805
// view tab strip; the view items `ManageViewsDialog` lists come with it.
806-
const { viewTabItems, titleSwitcher } = useObjectViewTitleSwitcher({
806+
const { viewTabItems, titleSwitcher, titleLabel } = useObjectViewTitleSwitcher({
807807
views,
808808
savedViews,
809809
objectDef,
@@ -967,6 +967,8 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
967967
<ObjectViewPageHeader
968968
objectDef={objectDef}
969969
titleSwitcher={titleSwitcher}
970+
titleLabel={titleLabel}
971+
objectListHref={appName && objectName ? `/apps/${appName}/${objectName}` : undefined}
970972
objectPluralLabel={objectPluralLabel}
971973
objectDesc={objectDesc}
972974
createButton={createButton}

0 commit comments

Comments
 (0)