From 11d269ac59ee8c921c778d5ec803f0181fbf1762 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 14:44:50 +0000 Subject: [PATCH 1/2] feat(app-shell): an object list's title names the object ahead of the view switcher Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyHsuwJSdPnjSmNWsB --- .../app-shell/src/views/ObjectView.tsx | 4 +- .../src/views/objectViewPageHeader.tsx | 72 ++++++++++++++++--- .../src/views/objectViewTitleSwitcher.tsx | 5 +- .../src/views/objectViewViewCrud.tsx | 9 ++- 4 files changed, 76 insertions(+), 14 deletions(-) diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index 9f5711f8e0b..e439e321960 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -803,7 +803,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // objectstack#27594: the list title is the view switcher, which replaced the // view tab strip; the view items `ManageViewsDialog` lists come with it. - const { viewTabItems, titleSwitcher } = useObjectViewTitleSwitcher({ + const { viewTabItems, titleSwitcher, titleLabel } = useObjectViewTitleSwitcher({ views, savedViews, objectDef, @@ -967,6 +967,8 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co + {objectListHref ? ( + + {objectLabel} + + ) : ( + {objectLabel} + )} + + {titleSwitcher} + + ); +} + export function ObjectViewPageHeader({ objectDef, titleSwitcher, + titleLabel, + objectListHref, objectPluralLabel, objectDesc, createButton, @@ -55,6 +95,10 @@ export function ObjectViewPageHeader({ objectDef: ConsoleObjectViewProps['objects'][number]; /** The list's view switcher (`./objectViewTitleSwitcher.tsx`), or `null` with no view to switch. */ titleSwitcher: ReactNode; + /** The switcher's text: the open view's label, or the plural for a view with none. */ + titleLabel: string; + /** The object's default list (`/apps/APP/OBJECT`), or `undefined` off an app route. */ + objectListHref: string | undefined; objectPluralLabel: ReturnType['objectPluralLabel']; objectDesc: ReturnType['objectDescription']; createButton: ReturnType['createButton']; @@ -72,17 +116,25 @@ export function ObjectViewPageHeader({ handleResetView: ReturnType['handleResetView']; foldedHeaderActions: ReturnType['foldedHeaderActions']; }) { + const pluralLabel = objectPluralLabel(objectDef); + // A view with no label is already called by the plural (objectui#12774): + // the switcher names the object, and a crumb would say it twice. + const title = titleSwitcher == null + ? pluralLabel + : titleLabel === pluralLabel + ? titleSwitcher + : ; return (
} diff --git a/ui/packages/app-shell/src/views/objectViewTitleSwitcher.tsx b/ui/packages/app-shell/src/views/objectViewTitleSwitcher.tsx index f56efc474cc..4e4ccd5eb0f 100644 --- a/ui/packages/app-shell/src/views/objectViewTitleSwitcher.tsx +++ b/ui/packages/app-shell/src/views/objectViewTitleSwitcher.tsx @@ -431,11 +431,12 @@ export function useObjectViewTitleSwitcher({ const openViewId = viewAccess.refusal ? '' : activeViewId ?? ''; const openView = viewTabItems.find((item) => item.id === openViewId); + const titleLabel = openView?.label ?? unlabelledViewText; const titleSwitcher = views.length >= 1 ? ( ) : null; - return { viewTabItems, titleSwitcher }; + return { viewTabItems, titleSwitcher, titleLabel }; } diff --git a/ui/packages/app-shell/src/views/objectViewViewCrud.tsx b/ui/packages/app-shell/src/views/objectViewViewCrud.tsx index 4cf6676b7de..a05153fc75e 100644 --- a/ui/packages/app-shell/src/views/objectViewViewCrud.tsx +++ b/ui/packages/app-shell/src/views/objectViewViewCrud.tsx @@ -255,7 +255,10 @@ export function useObjectViewViewCrud({ // Mobile view switcher — registers our view list with the AppHeader so // the topbar can render a ` ▾` dropdown instead of the static // page label. Desktop ignores this (the page title switches there, - // objectstack#27594). + // objectstack#27594). The trigger names the object first, 「OBJECT / VIEW」 + // (objectstack#27950): the bar truncates the end, so the view label gives + // way before the object's. A view with no label is the plural already + // (`unlabelledViewText`, objectui#12774) and reads alone. const mobileViewSwitcherItems = useMemo(() => { return (views || []).map((view: any) => { const Icon = VIEW_TYPE_ICONS[view.type as keyof typeof VIEW_TYPE_ICONS]; @@ -266,9 +269,13 @@ export function useObjectViewViewCrud({ }; }); }, [views, objectDef.name, viewLabel, unlabelledViewText, servedViews, language]); + const activeMobileLabel = mobileViewSwitcherItems.find((item) => item.id === activeViewId)?.label; useMobileViewSwitcherRegistration({ views: mobileViewSwitcherItems, activeViewId: activeViewId ?? '', + triggerLabel: activeMobileLabel && activeMobileLabel !== unlabelledViewText + ? `${unlabelledViewText} / ${activeMobileLabel}` + : undefined, onChange: handleViewChange, enabled: mobileViewSwitcherItems.length > 0 && !!activeViewId, }); From eaaf95e85a7785366b6e6a74a159416f218171d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 14:51:12 +0000 Subject: [PATCH 2/2] test(app-shell): pin the object label in the list title, the split pane, the sidebar states and the phone bar; docs and changeset Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyHsuwJSdPnjSmNWsB --- .../27950-object-list-title-names-object.md | 15 ++ content/docs/ui/views.mdx | 7 +- .../ObjectView.objectListTitle-27950.test.tsx | 253 ++++++++++++++++++ 3 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 .changeset/27950-object-list-title-names-object.md create mode 100644 ui/packages/app-shell/src/views/ObjectView.objectListTitle-27950.test.tsx diff --git a/.changeset/27950-object-list-title-names-object.md b/.changeset/27950-object-list-title-names-object.md new file mode 100644 index 00000000000..9ac673ceef3 --- /dev/null +++ b/.changeset/27950-object-list-title-names-object.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': patch +--- + +feat(app-shell): an object list's title names the object ahead of the view switcher + +Clause-②: no +Metadata-protocol: none + +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). + +- A view with no `label` is already called by the plural, so its title reads the plural alone. +- Where the row is short, the object's label never shrinks; the view's label truncates first. +- On a phone the app bar's view switcher reads 「OBJECT / VIEW」, so the view's label is the part cut at the end. +- 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. diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 4fee7d3e743..254b323f41d 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -360,7 +360,12 @@ gantt: { ### Switching views In the Console the list's title is the view switcher: it reads the open view's `label` and a -▾, and on a grid the number of records the view holds. It opens a dropdown with a search box +▾, and on a grid the number of records the view holds. Ahead of it the title names the object +with its plural label, as **Leads / My Leads ▾ · 12**, so the page says which object it lists +with the sidebar collapsed or the list opened from a link; the object's name leads back to its +default list. A view with no `label` is already called by the plural and reads alone. Where the +row is short (the split pane, a phone) the object's name stays whole and the view's label is +cut first. The switcher opens a dropdown with a search box and three sections: **Pinned**, **Recent** (the user's last views of this object) and **All**, where the views you author come first and the views users save at runtime follow. The split pane beside an open record keeps the same switcher. diff --git a/ui/packages/app-shell/src/views/ObjectView.objectListTitle-27950.test.tsx b/ui/packages/app-shell/src/views/ObjectView.objectListTitle-27950.test.tsx new file mode 100644 index 00000000000..58a4fbf5ff3 --- /dev/null +++ b/ui/packages/app-shell/src/views/ObjectView.objectListTitle-27950.test.tsx @@ -0,0 +1,253 @@ +/** + * 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. + */ + +/** + * objectstack#27950 — an object list's title names the object. + * + * Since objectstack#27594 the list title is the view switcher, and the app bar + * draws no object crumb (objectui#12461: the page header is the one place a + * page is named). The maintainer's ruling B puts the object's plural ahead of + * the switcher: 「OBJECT / VIEW ▾ · COUNT」, the plural linking to the object's + * default list; where the row is short the object label stays whole and the + * view label gives way. + * + * The real chain, as `ObjectView.titleViewSwitcher-27594`: the real + * `ObjectView`, the real plugin `ObjectView` and the real `ListView`. + * + * WHAT IS PINNED. + * - On the object's bare address and on a view's, the heading reads the + * plural, then the switcher; the plural is a link to `/apps/APP/OBJECT`, + * and following it opens the object's default view. + * - The same with the sidebar expanded and collapsed (the real + * `SidebarProvider`, whose state is read back). + * - A record open beside the list (the split pane) leaves one object label. + * - A view with no label is the plural already: the heading names it once. + * - Narrow rows: the object label is `shrink-0` and unwrapped, the view label + * is the switcher's `truncate` span. The test DOM computes no layout, so the + * classes that decide it are what is read. + * - The phone bar's trigger reads 「OBJECT / VIEW」, the object first. + * + * The record route's crumb is not drawn by this page (`RecordDetailView`); + * `AppHeader.appBarLeftGroup-12461` pins that it stays absent from the app bar. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, cleanup, screen, waitFor, within, fireEvent } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; +import { SidebarProvider, useSidebar } from '@object-ui/components'; + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + // Stable identities — `ListView` names `perms` in its fetch dependencies. + const perms = { + check: () => ({ allowed: true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }; + const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] }; + return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Sales Manager' }, activeOrganization: { id: 'org1', name: 'Acme' } }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRealtimeSubscription: () => ({ lastMessage: null }), + useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }), +})); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +import { ObjectView } from './ObjectView'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; +import { RECORD_OPEN_PREFERENCE_KEY } from '../utils/recordOpenPreference'; +import { MobileViewSwitcherProvider, useMobileViewSwitcher } from '../layout/MobileViewSwitcherContext'; + +// The page's own reads (the security `explain` probe among them) are relative +// fetches, which happy-dom would send to a real socket. One double, installed +// at module scope and never torn down, so no read the mounted tree issues +// after a test body returns can reach the network. +vi.stubGlobal('fetch', vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }))); + +const LEAD = { + name: 'crm_lead', + label: 'Lead', + pluralLabel: 'Leads', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + }, + listViews: { + all: { name: 'all', label: 'All Leads', type: 'grid', columns: ['name'] }, + mine: { name: 'mine', label: 'My Leads', type: 'grid', columns: ['name'] }, + }, +}; + +/** An object whose one view the author left unnamed: the view is called by the plural. */ +const NOTE = { + name: 'crm_note', + label: 'Note', + pluralLabel: 'Notes', + fields: { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } }, + listViews: { all: { type: 'grid', columns: ['name'] } }, +}; + +const OBJECTS = [LEAD, NOTE]; +const ROWS = [{ id: 'lead_1', name: 'Ada' }]; + +/** Where the router is, read back after a click. */ +function LocationProbe() { + const location = useLocation(); + return {location.pathname}; +} + +/** The sidebar state the page renders under, read back. */ +function SidebarStateProbe() { + return {useSidebar().state}; +} + +/** The phone bar's trigger label, as `AppHeader` reads it. */ +function MobileTriggerProbe() { + return {useMobileViewSwitcher()?.triggerLabel ?? ''}; +} + +function mount(entry: string, { sidebarOpen = true }: { sidebarOpen?: boolean } = {}) { + const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); + const dataSource = { + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), + findOne: vi.fn(async () => null), + getObjectSchema: vi.fn(async (name: string) => OBJECTS.find((o) => o.name === name) ?? null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + } as never; + const page = {}} />; + return render( + + + + + + + + + + + + + + + + + , + ); +} + +/** The list's toolbar has drawn: its Filter control is on screen. */ +const listToolbar = () => + waitFor(() => expect(screen.getByTestId('toolbar-filter-trigger')).toBeInTheDocument(), { timeout: 5000 }); + +const heading = () => screen.getByRole('heading', { level: 1 }); +const crumb = () => within(heading()).getByTestId('object-list-crumb'); +const switcher = () => within(heading()).getByTestId('list-view-switcher'); + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + cleanup(); +}); + +describe('objectstack#27950 — the list title names the object ahead of the view switcher', () => { + it.each([ + ['the object\'s address', '/apps/crm/crm_lead', 'All Leads'], + ['a view\'s address', '/apps/crm/crm_lead/view/mine', 'My Leads'], + ])('on %s the heading reads 「Leads / VIEW ▾」, the plural linking to the default list', async (_where, entry, view) => { + mount(entry); + await listToolbar(); + expect(crumb()).toHaveTextContent(/^Leads$/); + expect(crumb().tagName).toBe('A'); + expect(crumb()).toHaveAttribute('href', '/apps/crm/crm_lead'); + expect(switcher()).toHaveTextContent(view); + // The plural comes first, the switcher after it. + expect(crumb().compareDocumentPosition(switcher()) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(heading().textContent).toMatch(new RegExp(`^Leads/${view}`)); + }); + + it('following the object label opens the object\'s default list', async () => { + mount('/apps/crm/crm_lead/view/mine'); + await listToolbar(); + fireEvent.click(crumb()); + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent(/^\/apps\/crm\/crm_lead$/)); + await waitFor(() => expect(switcher()).toHaveTextContent('All Leads')); + expect(crumb()).toHaveTextContent(/^Leads$/); + }); + + it.each([ + ['expanded', true], + ['collapsed', false], + ])('with the sidebar %s the heading names the object once', async (state, open) => { + mount('/apps/crm/crm_lead/view/mine', { sidebarOpen: open }); + await listToolbar(); + expect(screen.getByTestId('sidebar-state')).toHaveTextContent(state); + expect(screen.getAllByTestId('object-list-crumb')).toHaveLength(1); + expect(crumb()).toHaveTextContent(/^Leads$/); + }); + + it('a record open beside the list (the split pane) leaves one object label and one switcher', async () => { + localStorage.setItem(RECORD_OPEN_PREFERENCE_KEY, 'split'); + mount('/apps/crm/crm_lead/view/mine?recordId=lead_1'); + await listToolbar(); + expect(screen.getAllByTestId('object-list-crumb')).toHaveLength(1); + expect(screen.getAllByTestId('list-view-switcher')).toHaveLength(1); + expect(crumb()).toHaveTextContent(/^Leads$/); + }); + + it('a view with no label is the plural already: the heading names the object once', async () => { + mount('/apps/crm/crm_note'); + await listToolbar(); + expect(within(heading()).queryByTestId('object-list-crumb')).toBeNull(); + expect(switcher()).toHaveTextContent('Notes'); + expect(screen.getByTestId('mobile-trigger')).toHaveTextContent(/^$/); + }); + + it('a short row keeps the object label whole: it never shrinks, the view label truncates', async () => { + mount('/apps/crm/crm_lead/view/mine'); + await listToolbar(); + expect(crumb()).toHaveClass('shrink-0', 'whitespace-nowrap'); + const viewLabel = within(switcher()).getByText('My Leads'); + expect(viewLabel).toHaveClass('truncate'); + expect(switcher()).toHaveClass('min-w-0'); + expect(within(heading()).getByTestId('object-list-title')).toHaveClass('min-w-0'); + }); + + it('the phone bar\'s trigger reads 「Leads / VIEW」, the object first', async () => { + mount('/apps/crm/crm_lead/view/mine'); + await listToolbar(); + await waitFor(() => expect(screen.getByTestId('mobile-trigger')).toHaveTextContent(/^Leads \/ My Leads$/)); + }); +});