From ce224d9705fb468020b5f71a6b4bdcd4b499d140 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:51:27 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(plugin-detail,app-shell,components,i18?= =?UTF-8?q?n):=20record=20page=20round=202=20=E2=80=94=20related=20OFF=20h?= =?UTF-8?q?as=20no=20rail,=20one=20segmented=20panel=20control=20in=20the?= =?UTF-8?q?=20header=20row,=201120/760=20container=20thresholds,=20related?= =?UTF-8?q?Lists=20order=20and=20field-group=20placement,=20a=20standing?= =?UTF-8?q?=20restore-default-order=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_cdb7a717def74e719988f089fbaa18ff Co-authored-by: Claude --- ui/e2e/live/record-history-display.spec.ts | 9 +- .../app-shell/src/utils/deriveRelatedLists.ts | 17 +- ...cordDetailView.panelToggles-27052.test.tsx | 56 +++++- ...dDetailView.recordPageTiers-26678.test.tsx | 9 +- ...etailView.relatedListsOrder-27241.test.tsx | 170 ++++++++++++++++++ .../app-shell/src/views/RecordDetailView.tsx | 85 +++++---- .../src/custom/panel-toggles.test.tsx | 24 ++- .../components/src/custom/panel-toggles.tsx | 41 +++-- ui/packages/components/src/index.ts | 3 + .../src/renderers/layout/containers.tsx | 36 +++- .../layout/page-header-trailing-context.ts | 23 +++ ui/packages/i18n/src/locales/ar.ts | 1 + ui/packages/i18n/src/locales/de.ts | 1 + ui/packages/i18n/src/locales/en.ts | 1 + ui/packages/i18n/src/locales/es.ts | 1 + ui/packages/i18n/src/locales/fr.ts | 1 + ui/packages/i18n/src/locales/ja.ts | 1 + ui/packages/i18n/src/locales/ko.ts | 1 + ui/packages/i18n/src/locales/pt.ts | 1 + ui/packages/i18n/src/locales/ru.ts | 1 + ui/packages/i18n/src/locales/zh.ts | 1 + .../plugin-detail/src/ArrangedRelatedTab.tsx | 8 +- .../plugin-detail/src/RecordPageColumns.tsx | 13 +- .../plugin-detail/src/RelatedCardStack.tsx | 54 ++++-- .../RecordPageColumns.switches-27052.test.tsx | 26 ++- .../__tests__/RelatedCardStack-27055.test.tsx | 33 +++- ui/packages/plugin-detail/src/index.tsx | 2 + .../src/renderers/record-related-list.tsx | 12 +- ...DefaultPageSchema.bodyTables-27055.test.ts | 67 ++++++- .../recordPageColumns.switches-27052.test.ts | 17 +- .../synth/__tests__/recordPageColumns.test.ts | 28 ++- .../relatedArrangement-27055.test.ts | 34 ++++ .../src/synth/buildDefaultPageSchema.ts | 108 +++++++++-- .../src/synth/recordPageColumns.ts | 76 ++++++-- .../src/synth/relatedArrangement.ts | 19 +- .../src/synth/relatedListEntry.ts | 27 +++ 36 files changed, 849 insertions(+), 158 deletions(-) create mode 100644 ui/packages/app-shell/src/views/RecordDetailView.relatedListsOrder-27241.test.tsx create mode 100644 ui/packages/components/src/renderers/layout/page-header-trailing-context.ts create mode 100644 ui/packages/plugin-detail/src/synth/relatedListEntry.ts diff --git a/ui/e2e/live/record-history-display.spec.ts b/ui/e2e/live/record-history-display.spec.ts index 925f0f366b0..5f31efc9a03 100644 --- a/ui/e2e/live/record-history-display.spec.ts +++ b/ui/e2e/live/record-history-display.spec.ts @@ -40,11 +40,12 @@ async function seededUsers(request: APIRequestContext): Promise<{ actor: SysUser * The synthesized record page lays itself out by the width of the view it * occupies (objectstack#26678): the tab strip this spec opens exists only * below the two-column floor (`RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` in - * `@object-ui/plugin-detail`). The Desktop Chrome viewport leaves the record - * view right at that floor once the console sidebar takes its share, so the - * spec pins a viewport that keeps the page in its tabbed tier. + * `@object-ui/plugin-detail`). A 1024 px window with the console sidebar + * expanded is already two columns (objectstack#27241), so the spec pins a + * viewport whose record view stays under that floor once the 256 px sidebar + * takes its share — the page's tabbed tier. */ -test.use({ viewport: { width: 1200, height: 800 } }); +test.use({ viewport: { width: 1000, height: 800 } }); test('History tab shows display values (localized dates, option labels, resolved lookups, real actor) instead of raw audit payloads', async ({ page, request }) => { const { actor, other } = await seededUsers(request); diff --git a/ui/packages/app-shell/src/utils/deriveRelatedLists.ts b/ui/packages/app-shell/src/utils/deriveRelatedLists.ts index e5d369b340a..a7b64507a33 100644 --- a/ui/packages/app-shell/src/utils/deriveRelatedLists.ts +++ b/ui/packages/app-shell/src/utils/deriveRelatedLists.ts @@ -71,6 +71,7 @@ */ import { convertSortToQueryParams } from '@object-ui/core'; +import { relatedListEntryNames } from '@object-ui/plugin-detail'; /** Audit/ownership FKs that exist on nearly every object — never related lists. */ const AUDIT_FK_FIELDS = new Set(['created_by', 'updated_by', 'owner_id']); @@ -286,18 +287,12 @@ export function resolveRelatedListOrder { diff --git a/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx index 114d19d77bd..8244b001cb8 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx @@ -22,10 +22,16 @@ * - an object that is not worked opens with the timeline off — including * one whose only claim is being another object's `master_detail` parent; * - `⌘⇧.` / `⌘⇧,` flip the switches, matched by the physical key (Shift - * types `>` / `<` there), and do nothing while a text field has focus; - * the browser's own `⌘⇧T` / `⌘⇧R` are not theirs; - * - the toggles are drawn only in a column tier: not in the tabbed tier, not - * in the drawer / split pane. + * types `>` / `<` there) or, for an event that carries no `code`, by the + * character it typed (objectstack#27241), and do nothing while a text + * field has focus; the browser's own `⌘⇧T` / `⌘⇧R` are not theirs; + * - the toggles are ONE segmented control at the end of the record header's + * action row (objectstack#27241), in both column tiers — not on the crumb + * row, which keeps only the ways back and has no child without one; + * - they are drawn only in a column tier: not in the tabbed tier, not in the + * drawer / split pane; + * - the related switch decides whether the related column exists: OFF in the + * two-column tier leaves no rail toggle (objectstack#27241). */ import * as React from 'react'; @@ -203,7 +209,16 @@ describe('RecordDetailView — the panel toggles (objectstack#27052)', () => { it('a worked object opens with three columns; timeline off draws details | related with a folded 「动态」 card; on restores', async () => { const { container } = renderPage(); const group = await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); - expect(group.closest('[data-record-crumb-row]')).not.toBeNull(); + // objectstack#27241 — the end of the header's action row, after ⋯ and a + // hairline; the crumb row keeps only the ways back (none on a direct open). + expect(group.closest('[data-record-crumb-row]')).toBeNull(); + const toolbar = group.closest('[role="toolbar"]')!; + expect(toolbar).not.toBeNull(); + expect(toolbar.closest('[data-record-column]')).toBeNull(); + const trailing = group.closest('[data-page-header-trailing]')!; + expect(trailing.querySelector('[role="separator"][aria-orientation="vertical"]')).not.toBeNull(); + expect(toolbar.lastElementChild).toBe(trailing); + expect(container.querySelector('[data-record-crumb-row]')?.childElementCount ?? 0).toBe(0); expect(shown(container)).toBe('details timeline related'); expect(toggle(/^Timeline$/).getAttribute('aria-pressed')).toBe('true'); expect(toggle(/^Related column$/).getAttribute('aria-pressed')).toBe('true'); @@ -280,6 +295,35 @@ describe('RecordDetailView — the panel toggles (objectstack#27052)', () => { await waitFor(() => expect(shown(container)).toBe('details timeline')); }); + it('an event that carries no `code` still flips the switches by the character it typed (objectstack#27241)', async () => { + const { container } = renderPage(); + await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); + press({ key: '>', code: '' }); + await waitFor(() => expect(shown(container)).toBe('details related')); + press({ key: '.', code: '' }); + await waitFor(() => expect(shown(container)).toBe('details timeline related')); + press({ key: '<', code: '' }); + await waitFor(() => expect(shown(container)).toBe('details timeline')); + press({ key: ',', code: '' }); + await waitFor(() => expect(shown(container)).toBe('details timeline related')); + }); + + it('the two-column tier draws the same control in the header row; related OFF leaves no rail toggle (objectstack#27241)', async () => { + const { container } = renderPage({ width: 900 }); + const group = await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); + expect(container.querySelector('[data-record-page-tier]')?.getAttribute('data-record-page-tier')).toBe('two-column'); + expect(group.closest('[role="toolbar"]')).not.toBeNull(); + expect(shown(container)).toBe('details timeline'); + // Related ON with no room: the rail toggle, and it opens the column. + fireEvent.click(screen.getByTestId('record-related-rail-toggle')); + await waitFor(() => expect(container.querySelector('[data-record-column="related"]')).not.toBeNull()); + // Related OFF: no column, no rail toggle. + fireEvent.click(toggle(/^Related column$/)); + await waitFor(() => expect(screen.queryByTestId('record-related-rail-toggle')).toBeNull()); + expect(container.querySelector('[data-record-column="related"]')).toBeNull(); + expect(shown(container)).toBe('details timeline'); + }); + it('the tooltip names each shortcut, and the shortcut is announced', async () => { renderPage(); await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); @@ -288,7 +332,7 @@ describe('RecordDetailView — the panel toggles (objectstack#27052)', () => { }); it('not drawn in the tabbed tier, nor in the drawer / split pane; the shortcut does nothing there', async () => { - const tabbed = renderPage({ width: 900 }); + const tabbed = renderPage({ width: 700 }); expect(await screen.findByRole('tablist', undefined, { timeout: 5000 })).toBeTruthy(); expect(screen.queryByRole('group', { name: 'Panels' })).toBeNull(); press('timeline'); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordPageTiers-26678.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordPageTiers-26678.test.tsx index f2ce5d861c0..aa2ec1b462c 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.recordPageTiers-26678.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordPageTiers-26678.test.tsx @@ -19,7 +19,8 @@ * the related column, and History is resident, so its read goes out at open. * - 1100 px: two columns and the related toggle; the related lists are not * mounted until the rail opens. - * - 900 px, and an embedded view at any width: the tab strip as before. + * - 700 px (under the 760 px two-column floor, objectstack#27241), and an + * embedded view at any width: the tab strip as before. * - `?tab=` addresses the strip, so a column tier ignores it. */ @@ -218,8 +219,8 @@ describe('RecordDetailView — one record page at two widths (objectstack#26678) expect(column(container, 'related')!.textContent).toContain('Notes'); }); - it('900 px: the tab strip, as before; History waits for its tab', async () => { - const { container } = renderPage({ width: 900 }); + it('700 px: the tab strip, as before; History waits for its tab', async () => { + const { container } = renderPage({ width: 700 }); expect(await screen.findByRole('tablist', undefined, { timeout: 5000 })).toBeTruthy(); expect(screen.getByRole('tab', { name: /^Details/ })).toBeTruthy(); expect(screen.getByRole('tab', { name: /^Related/ })).toBeTruthy(); @@ -234,7 +235,7 @@ describe('RecordDetailView — one record page at two widths (objectstack#26678) }); it('`?tab=` keeps working in the tabbed tier and is ignored in the column tiers', async () => { - const tabbed = renderPage({ width: 900, search: '?tab=related' }); + const tabbed = renderPage({ width: 700, search: '?tab=related' }); const relatedTab = await screen.findByRole('tab', { name: /^Related/ }, { timeout: 5000 }); expect(relatedTab.getAttribute('aria-selected')).toBe('true'); tabbed.unmount(); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.relatedListsOrder-27241.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.relatedListsOrder-27241.test.tsx new file mode 100644 index 00000000000..4f591899845 --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.relatedListsOrder-27241.test.tsx @@ -0,0 +1,170 @@ +/** + * 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#27069 / objectstack#27241 — the record page's default related + * order reads the object's declared `relatedLists`: a reader who never + * arranged the related column sees the cards in that order, the rest after + * them in the derived order (owned `master_detail` children first, then + * lookups). Measured from a real `RecordDetailView` mount at three columns + * (the `recordPageTiers-26678` harness: the root's measured width is the one + * input varied). + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MetadataCtx } from '@object-ui/react'; +import { RelatedCountStore } from '@object-ui/components'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }), +})); + +// Orthogonal chrome — stubbed so the only asynchrony here is the page's own. +vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +import { RecordDetailView } from './RecordDetailView'; + +const PARENT = 'showcase_project'; +const RECORD_ID = 'p-1'; +const BASE_FIELDS = { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } }; + +const objectsWith = (relatedLists?: string[]) => [ + { + name: PARENT, + label: 'Project', + pluralLabel: 'Projects', + managedBy: 'platform', + ...(relatedLists ? { relatedLists } : {}), + fields: BASE_FIELDS, + }, + { + name: 'showcase_task', + label: 'Task', + pluralLabel: 'Tasks', + managedBy: 'platform', + fields: { ...BASE_FIELDS, project: { type: 'master_detail', reference: PARENT, label: 'Project' } }, + }, + { + name: 'showcase_note', + label: 'Note', + pluralLabel: 'Notes', + managedBy: 'platform', + fields: { ...BASE_FIELDS, project: { type: 'lookup', reference: PARENT, label: 'Project' } }, + }, + { + name: 'showcase_call', + label: 'Call', + pluralLabel: 'Calls', + managedBy: 'platform', + fields: { ...BASE_FIELDS, project: { type: 'lookup', reference: PARENT, label: 'Project' } }, + }, +]; + +function renderPage(objects: ReturnType) { + const metadata = { + objects, + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + } as never; + const dataSource = { + find: vi.fn(async () => ({ data: [], total: 0 })), + create: vi.fn(async (_o: string, row: unknown) => row), + findOne: vi.fn(async (_o: string, recordId: string) => ({ id: recordId, name: `Project ${recordId}` })), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + }; + return render( + + + {}} + objectNameOverride={PARENT} + recordIdOverride={RECORD_ID} + /> + + , + ); +} + +const cardOrder = (container: HTMLElement) => + Array.from(container.querySelectorAll('[data-record-column="related"] [data-related-card-id]')).map((el) => + el.getAttribute('data-related-card-id'), + ); + +beforeEach(() => { + cleanup(); + window.localStorage.clear(); + RelatedCountStore._reset(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }), + ), + ); + const measure = HTMLElement.prototype.getBoundingClientRect; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (!this.hasAttribute('data-record-page-root')) return measure.call(this); + return { x: 0, y: 0, top: 0, left: 0, right: 1440, bottom: 800, width: 1440, height: 800, toJSON: () => ({}) } as DOMRect; + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('RecordDetailView — the related cards follow the object\'s relatedLists (objectstack#27069)', () => { + it('without relatedLists: the derived order — the owned child first, then the lookups in declaration order', async () => { + const { container } = renderPage(objectsWith()); + await waitFor(() => expect(cardOrder(container)).toHaveLength(3), { timeout: 5000 }); + expect(cardOrder(container)).toEqual(['showcase_task.project', 'showcase_note.project', 'showcase_call.project']); + }); + + it('with relatedLists: the declared lists first in that order, the rest after them in the derived order', async () => { + const { container } = renderPage(objectsWith(['showcase_call', 'showcase_note.project'])); + await waitFor(() => expect(cardOrder(container)).toHaveLength(3), { timeout: 5000 }); + expect(cardOrder(container)).toEqual(['showcase_call.project', 'showcase_note.project', 'showcase_task.project']); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 830641d07bf..4b37df1fcdd 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -11,7 +11,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; import { activityRowToFeedItem, ArrangedRelatedTab, auditEntriesToFeedItems, bodyTableIdsOf, claimQueuedRecord, RecordFieldChangesContext, DefaultRecordPageContext, EMPTY_RELATED_ARRANGEMENT, InlineEditSaveBar, RecordBodyTablesContext, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageColumnDefaults, recordSitsInQueue, resolveTitleField, useQueueClaimOffered, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; -import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions, PanelToggles, type PanelToggleItem } from '@object-ui/components'; +import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions, PageHeaderTrailingContext, PanelToggles, type PanelToggleItem } from '@object-ui/components'; import { useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; import { useDisplayLocale } from '@object-ui/i18n'; @@ -19,7 +19,7 @@ import { ActionProvider, useObjectTranslation, useObjectLabel, useActionTextLoca import { buildExpandFields, captureUpdateUndoData, recordDelete, resolveAffordance, resolveRecordIdParamSeed, userActionPredicates, withoutDeniedFields, type AffordanceGrantPrincipal, type SchemaLike } from '@object-ui/core'; import { toast } from 'sonner'; import { useRecordPresence, PresenceAvatars, isCommentAuthor } from '@object-ui/collaboration'; -import { Database, ChevronLeft, Lock, AlertTriangle, RotateCw, Printer, FileText, CheckCircle2, Inbox, Rows3, LayoutGrid } from 'lucide-react'; +import { Database, ChevronLeft, Lock, AlertTriangle, RotateCw, Printer, FileText, CheckCircle2, Inbox, Activity, PanelRight } from 'lucide-react'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { SkeletonDetail } from '../skeletons/index.js'; import { ManagedByBadge, useManagedByNote } from '../components/ManagedByBadge.js'; @@ -136,11 +136,17 @@ const TOGGLE_RELATED_SHORTCUT: AdvertisedShortcut = { }; /** - * The physical key of each punctuation chord key. Shift changes the character + * How each punctuation chord key is recognised. Shift changes the character * those keys type (`.` becomes `>` on a US layout), so a chord that holds - * Shift is matched by `KeyboardEvent.code`, the key itself, not by `key`. + * Shift is matched by `KeyboardEvent.code`, the physical key — and, beside it, + * by either character `key` reports for that key (objectstack#27241): an + * event that carries no `code` (synthetic or injected input, some IMEs and + * remote desktops) still names the key it typed. */ -const PANEL_CHORD_CODES: Readonly> = { '.': 'Period', ',': 'Comma' }; +const PANEL_CHORD_KEYS: Readonly> = { + '.': { code: 'Period', keys: ['.', '>'] }, + ',': { code: 'Comma', keys: [',', '<'] }, +}; function matchesPanelChord( e: Pick, @@ -149,8 +155,9 @@ function matchesPanelChord( if (e.altKey) return false; if (Boolean(chord.mod) !== (e.metaKey || e.ctrlKey)) return false; if (Boolean(chord.shift) !== e.shiftKey) return false; - const code = PANEL_CHORD_CODES[chord.key]; - return code !== undefined ? e.code === code : e.key.toLowerCase() === chord.key; + const punctuation = PANEL_CHORD_KEYS[chord.key]; + if (punctuation) return e.code === punctuation.code || punctuation.keys.includes(e.key); + return e.key.toLowerCase() === chord.key; } /** @@ -3428,12 +3435,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // its tab is shown (nothing is preloaded here), and header/row // affordances (+ New / View All / row navigation) are wired // downstream by `RelatedRecordActionsBridge`. - // The DEFAULT order of the related lists (objectstack#27055): the derived - // order with `relatedList: 'primary'` lists first, each group keeping - // `deriveRelatedLists`' order (owned before lookup, then declaration). - // ⛔ FENCE (objectstack#27069): the object's authored `relatedLists` - // order is not read — the spec key is not declared yet. When it lands, - // THIS is the one read point: order by it, then by this derived order. + // The DEFAULT order of the related lists (objectstack#27055, + // objectstack#27069): `relatedList: 'primary'` lists first, then the rest, + // each group in `deriveRelatedLists`' order — the object's declared + // `relatedLists` first, in that order (`resolveRelatedListOrder`), then + // the derived order (owned before lookup, then declaration). It is the + // order the related column's cards, the 关联 tab and the body tables + // show to a reader who never arranged them. const orderedRelations = [ ...childRelations.filter((relation) => relation.isPrimary), ...childRelations.filter((relation) => !relation.isPrimary), @@ -3975,15 +3983,17 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // no hook may run. const bodyTableIds = assignedPage || namedPageBlocked ? new Set() : bodyTableIdsOf(renderedPage); - // objectstack#27052 — the panel toggles on the crumb row: one per column the - // page has content for, drawn only where the columns are (never in the - // drawer / split pane, never in the tabbed tier). + // objectstack#27052 / objectstack#27241 — the panel switches: ONE segmented + // control, one segment per column the page has content for, drawn at the end + // of the record header's action row (`PageHeaderTrailingContext`), only + // where the columns are (never in the drawer / split pane, never in the + // tabbed tier), in both column tiers. const panelToggleItems: PanelToggleItem[] = []; if (pageColumns && pageColumns.timeline.length > 0) { panelToggleItems.push({ id: 'timeline', label: t('detail.timeline', { defaultValue: 'Timeline' }), - icon: Rows3, + icon: Activity, pressed: columnSwitches.timeline, onPressedChange: (pressed) => setTimelineSwitch(pressed ? 'on' : 'off'), shortcut: shortcutKeycaps(TOGGLE_TIMELINE_SHORTCUT.chord), @@ -3996,7 +4006,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // Not `detail.related`: the rail toggle and the Related tab carry that // name, and a second control with the same name would announce as them. label: t('detail.relatedColumn', { defaultValue: 'Related column' }), - icon: LayoutGrid, + icon: PanelRight, pressed: columnSwitches.related, onPressedChange: (pressed) => setRelatedSwitch(pressed ? 'on' : 'off'), shortcut: shortcutKeycaps(TOGGLE_RELATED_SHORTCUT.chord), @@ -4007,6 +4017,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri timeline: panelToggleItems.some((item) => item.id === 'timeline'), related: panelToggleItems.some((item) => item.id === 'related'), }; + const panelToggles = + panelToggleItems.length > 0 ? ( + + ) : null; // objectui#11958 (card ② of the objectstack#8346 ruling, M1, zero server) — // the record page's "print" action. The page this view renders for the @@ -4173,33 +4187,30 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri >
- {/* The crumb row: the way back on the left — the origin link, - and on a page a drawer's ⤢ opened, the way back into that - drawer (objectstack#26679) — and, on a page in a column - tier, the reader's panel toggles on the right - (objectstack#27052). None of it is drawn inside a drawer: - the drawer's own bar is its way back, and the page's - history state is not its. */} + {/* The crumb row: the ways back only — the origin link on the + left and, on a page a drawer's ⤢ opened, the way back into + that drawer on the right (objectstack#26679). The reader's + panel switches are not here: they end the record header's + action row (objectstack#27241). With neither way back the + row has no child, and `empty:hidden` takes it out of the + flow. None of it is drawn inside a drawer: the drawer's own + bar is its way back, and the page's history state is not + its. */} {!embedded && ( -
+
{originFrom?.pathname && originFrom?.label && ( {originFrom.label} )} -
- - {panelToggleItems.length > 0 && ( - - )} -
+
)} {/* objectui#12906 — the print page's screen-only chrome. A page @@ -4303,6 +4314,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri so it is read in the tabbed tier only. Nothing is drawn while the tier is unmeasured (one unpainted commit). */} {pageColumns && (recordPageTier === 'two-column' || recordPageTier === 'three-column') ? ( + } /> + ) : recordPageTier === null ? null : ( // objectstack#27055 — the synthesized default page is the // record page v3 in its tabbed tier too (frameless sections, diff --git a/ui/packages/components/src/custom/panel-toggles.test.tsx b/ui/packages/components/src/custom/panel-toggles.test.tsx index 633a73bc2fc..43d9ae0c143 100644 --- a/ui/packages/components/src/custom/panel-toggles.test.tsx +++ b/ui/packages/components/src/custom/panel-toggles.test.tsx @@ -7,9 +7,10 @@ */ /** - * objectstack#27052 — `PanelToggles`: a named group of icon toggles, each - * announcing whether its panel is shown (`aria-pressed`), tinted when it is, - * and naming its shortcut in the tooltip. + * objectstack#27052 / objectstack#27241 — `PanelToggles`: ONE named segmented + * control, each segment an icon and its label, announcing whether its panel + * is shown (`aria-pressed`), filled with the accent tint when it is, and + * naming its shortcut in the tooltip. */ import * as React from 'react'; @@ -53,8 +54,21 @@ describe('PanelToggles (objectstack#27052)', () => { expect(timeline.getAttribute('aria-pressed')).toBe('true'); expect(related.getAttribute('aria-pressed')).toBe('false'); expect(timeline.getAttribute('aria-keyshortcuts')).toBe('Meta+Shift+.'); - // The pressed state is tinted. - expect(timeline.className).toContain('aria-pressed:text-primary'); + // The pressed state is the accent fill with foreground text; an off + // segment is muted text (objectstack#27241). + expect(timeline.className).toContain('aria-pressed:bg-accent'); + expect(timeline.className).toContain('aria-pressed:text-accent-foreground'); + expect(related.className).toContain('text-muted-foreground'); + }); + + it('each segment draws its icon and its label as visible text (objectstack#27241)', () => { + render(); + const timeline = screen.getByRole('button', { name: 'Timeline' }); + expect(timeline.textContent).toBe('Timeline'); + expect(timeline.querySelector('[data-testid="icon"]')).not.toBeNull(); + // One control: both segments in the one group. + const group = screen.getByRole('group', { name: 'Panels' }); + expect(group.querySelectorAll('[data-panel-toggle]')).toHaveLength(2); }); it('a click flips its own panel and no other', () => { diff --git a/ui/packages/components/src/custom/panel-toggles.tsx b/ui/packages/components/src/custom/panel-toggles.tsx index 86f50a0f905..ba257fb0fce 100644 --- a/ui/packages/components/src/custom/panel-toggles.tsx +++ b/ui/packages/components/src/custom/panel-toggles.tsx @@ -12,13 +12,13 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/ import { cn } from '../lib/utils'; import { Kbd, KbdGroup } from './kbd'; -/** One panel a {@link PanelToggles} group shows or hides. */ +/** One panel a {@link PanelToggles} control shows or hides. */ export interface PanelToggleItem { - /** Stable identity within the group; also the button's `data-panel-toggle`. */ + /** Stable identity within the control; also the segment's `data-panel-toggle`. */ id: string; - /** The panel's name — the button's accessible name and its tooltip text. */ + /** The panel's name — the segment's visible text, its accessible name and its tooltip text. */ label: string; - /** The panel's icon. Decorative: the label names the button. */ + /** The panel's icon, drawn before the label. Decorative: the label names the segment. */ icon: React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }>; /** Whether the panel is shown. */ pressed: boolean; @@ -31,26 +31,36 @@ export interface PanelToggleItem { export interface PanelTogglesProps { items: readonly PanelToggleItem[]; - /** The group's accessible name. */ + /** The control's accessible name. */ label: string; className?: string; } /** - * A row of icon buttons that show and hide a page's panels — the reader's + * ONE segmented control that shows and hides a page's panels — the reader's * own layout switches (objectstack#27052), the way an editor's title bar - * toggles its side panels. Each button is a toggle: `aria-pressed` says - * whether its panel is shown, the pressed state is tinted, and its tooltip - * names the panel and the shortcut that toggles it. + * toggles its side panels. Each segment is a toggle with an icon and its + * label (objectstack#27241): `aria-pressed` says whether its panel is shown; + * a segment that is on is filled with the accent tint and reads in the + * foreground colour, a segment that is off is plain muted text — the state + * reads at a glance, in both themes, without hovering. Its tooltip names the + * panel and the shortcut that toggles it. * * Pure presentation: the host owns the state and the shortcut handlers, and - * hands in localized labels. + * hands in localized labels. The console's record page draws it at the end + * of the record header's action row; any other host may place it where its + * own chrome is. */ export function PanelToggles({ items, label, className }: PanelTogglesProps) { if (items.length === 0) return null; return ( -
+
{items.map((item) => { const Icon = item.icon; return ( @@ -60,17 +70,18 @@ export function PanelToggles({ items, label, className }: PanelTogglesProps) { size="sm" pressed={item.pressed} onPressedChange={item.onPressedChange} - aria-label={item.label} aria-keyshortcuts={item.ariaKeyShortcuts} className={cn( - 'h-8 min-w-8 px-2 text-muted-foreground', + 'h-[1.625rem] min-w-0 gap-1.5 rounded-[5px] px-2 text-xs font-medium text-muted-foreground', + 'hover:bg-muted hover:text-foreground', // `aria-pressed`, not Radix's `data-state`: the tooltip // trigger writes its own `data-state` onto this button. - 'aria-pressed:bg-primary/10 aria-pressed:text-primary hover:aria-pressed:bg-primary/15', + 'aria-pressed:bg-accent aria-pressed:text-accent-foreground hover:aria-pressed:bg-accent', )} data-panel-toggle={item.id} > - + + {item.label} diff --git a/ui/packages/components/src/index.ts b/ui/packages/components/src/index.ts index 876af84aee2..5e1151c6627 100644 --- a/ui/packages/components/src/index.ts +++ b/ui/packages/components/src/index.ts @@ -99,6 +99,9 @@ export { usePageTabCountReporter } from './renderers/layout/containers'; // console's record view, which draws the row in its screen-only chrome on a // page that declares `print`, where `page:header` is not a printable block. export { PageHeaderActions } from './renderers/layout/containers'; +// The host slot at the end of that row (objectstack#27241): the console's +// record view puts the reader's panel switches there. +export { PageHeaderTrailingContext } from './renderers/layout/page-header-trailing-context'; export type { PageHeaderActionsProps } from './renderers/layout/containers'; // The field-face seam (objectui#13216): the slot `@object-ui/fields` fills diff --git a/ui/packages/components/src/renderers/layout/containers.tsx b/ui/packages/components/src/renderers/layout/containers.tsx index e3b40b493b7..ea3f8b2accf 100644 --- a/ui/packages/components/src/renderers/layout/containers.tsx +++ b/ui/packages/components/src/renderers/layout/containers.tsx @@ -59,6 +59,7 @@ import { import { RecordTitleChip } from '../../custom/RecordTitleChip'; import { resolveFieldReadFace } from '../../lib/field-face'; import { PageDescriptionContext } from './page-description-context'; +import { PageHeaderTrailingContext } from './page-header-trailing-context'; import { renderBlockChildren } from './page-block-grid'; import { isFileIdToken } from '@objectstack/spec/data'; import { readActionEntryParamValues } from '../action/static-params'; @@ -1735,6 +1736,13 @@ export interface PageHeaderActionsProps { * (刷新收进 ⋯)」). With a refresh and no action, the row is the `⋯` alone. */ refresh?: PageHeaderRefreshItem; + /** + * Host chrome drawn as the row's last segment, after the `⋯` menu and set + * apart from the actions by a vertical hairline (objectstack#27241) — the + * console's record view hands in the reader's panel switches. With it and + * no action, the row is the trailing chrome alone. + */ + trailing?: React.ReactNode; } /** The refresh entry of the header's `⋯` menu — see {@link PageHeaderActionsProps.refresh}. */ @@ -1764,6 +1772,7 @@ export const PageHeaderActions: React.FC = ({ mobileMaxVisible: rawMobileMaxVisible, emptySlot = null, refresh, + trailing, }) => { const ctx = useRecordContext(); // Record-level inline-edit session (objectui#2572 item 4): while a shared @@ -2208,8 +2217,29 @@ export const PageHeaderActions: React.FC = ({ void execute(dispatch); }, [record, execute]); + const hasTrailing = trailing !== null && trailing !== undefined && trailing !== false; + // The trailing segment (objectstack#27241): the host's chrome after the + // actions, a hairline between the two so the switches do not read as one + // more action. + const trailingSegment = hasTrailing ? ( + + + {trailing} + + ) : null; const renderHeaderActions = () => { - if (headerActions.length === 0 && !refresh) return null; + if (headerActions.length === 0 && !refresh) { + if (!hasTrailing) return null; + return ( +
+ {trailing} +
+ ); + } // The label is already resolved — the `headerActions` memo above ran every // action through the shared localizer (`{ns}.objects.{objectName} // ._actions.{name}.label`, authored literal as fallback), together with the @@ -2530,6 +2560,7 @@ export const PageHeaderActions: React.FC = ({ )} + {trailingSegment}
); }; @@ -2574,6 +2605,8 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { // title (objectui#12624): drawn under the title and subtitle, the order the // console page header uses, instead of above the title by the page. const pageDescription = React.useContext(PageDescriptionContext); + // The host's trailing chrome for the action row (objectstack#27241). + const headerTrailing = React.useContext(PageHeaderTrailingContext); const headerObjectSchema: any = (ctx as any)?.objectSchema; const headerObjectName: string | undefined = ctx?.objectName || headerObjectSchema?.name; const explicitTitle = interpolate( @@ -2631,6 +2664,7 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { mobileMaxVisible={schema?.mobileMaxVisible ?? schema?.properties?.mobileMaxVisible} emptySlot={emptySlot} refresh={hostRefresh ? { label: tt('common.refresh', 'Refresh'), onSelect: handleManualRefresh } : undefined} + trailing={headerTrailing} /> ); diff --git a/ui/packages/components/src/renderers/layout/page-header-trailing-context.ts b/ui/packages/components/src/renderers/layout/page-header-trailing-context.ts new file mode 100644 index 00000000000..b98d8687762 --- /dev/null +++ b/ui/packages/components/src/renderers/layout/page-header-trailing-context.ts @@ -0,0 +1,23 @@ +/** + * 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. + */ + +import React from 'react'; + +/** + * Host chrome a `page:header` draws as the LAST segment of its action row, + * after the `⋯` menu and set apart from the actions by a vertical hairline + * (objectstack#27241). + * + * It is a host slot, never metadata: no `page:header` key says it, and an + * authored page cannot put anything here. The console's record view provides + * the reader's panel switches (`PanelToggles`) here on a page in a column + * tier, so the switches sit in the header's action row instead of on a row of + * their own. `null` (the default) draws nothing, and the action row is + * exactly what it was. + */ +export const PageHeaderTrailingContext = React.createContext(null); diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 16822b31ca9..e237b6c8efa 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1485,6 +1485,7 @@ const ar = { relatedCardCount_other: "السجلات: {{count}} · تكبير", relatedDefaultChanged: "تم تحديث الترتيب الافتراضي", relatedRestoreDefault: "استعادة الافتراضي", + relatedRestoreDefaultOrder: "استعادة الترتيب الافتراضي", relatedHiddenCount: "المخفية: {{count}}", relatedHiddenCount_zero: "المخفية: {{count}}", relatedHiddenCount_one: "المخفية: {{count}}", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 6a48ff7d4cd..2c7d25b68b8 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1323,6 +1323,7 @@ const de = { relatedCardCount_other: "{{count}} Datensätze – vergrößern", relatedDefaultChanged: "Standardreihenfolge aktualisiert", relatedRestoreDefault: "Standard wiederherstellen", + relatedRestoreDefaultOrder: "Standardreihenfolge wiederherstellen", relatedHiddenCount: "{{count}} ausgeblendet", relatedHiddenCount_one: "{{count}} ausgeblendet", relatedHiddenCount_other: "{{count}} ausgeblendet", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 8bd7fbfe8be..c7ac5ebb325 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1511,6 +1511,7 @@ const en = { relatedCardCount_other: '{{count}} records — enlarge', relatedDefaultChanged: 'Default order updated', relatedRestoreDefault: 'Restore default', + relatedRestoreDefaultOrder: 'Restore default order', relatedHiddenCount: '{{count}} hidden', relatedHiddenCount_one: '{{count}} hidden', relatedHiddenCount_other: '{{count}} hidden', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 0e2665a8731..ed4f6249dd8 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1365,6 +1365,7 @@ const es = { relatedCardCount_other: "{{count}} registros · ampliar", relatedDefaultChanged: "Orden predeterminado actualizado", relatedRestoreDefault: "Restaurar predeterminado", + relatedRestoreDefaultOrder: "Restaurar orden predeterminado", relatedHiddenCount: "{{count}} ocultas", relatedHiddenCount_one: "{{count}} oculta", relatedHiddenCount_many: "{{count}} ocultas", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 292904cc1f9..0ba37c96947 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1362,6 +1362,7 @@ const fr = { relatedCardCount_other: "{{count}} enregistrements · agrandir", relatedDefaultChanged: "Ordre par défaut mis à jour", relatedRestoreDefault: "Rétablir par défaut", + relatedRestoreDefaultOrder: "Rétablir l'ordre par défaut", relatedHiddenCount: "{{count}} masquées", relatedHiddenCount_one: "{{count}} masquée", relatedHiddenCount_many: "{{count}} masquées", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 5787bf08041..c00d0f5753e 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1166,6 +1166,7 @@ const ja = { relatedCardCount_other: "{{count}} 件 · 拡大", relatedDefaultChanged: "既定の順序が更新されました", relatedRestoreDefault: "既定に戻す", + relatedRestoreDefaultOrder: "既定の順序に戻す", relatedHiddenCount: "{{count}} 件非表示", relatedHiddenCount_one: "{{count}} 件非表示", relatedHiddenCount_other: "{{count}} 件非表示", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 56bdc7b7bba..3c3c2a5104e 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1323,6 +1323,7 @@ const ko = { relatedCardCount_other: "{{count}}개 레코드 · 확대", relatedDefaultChanged: "기본 순서가 업데이트됨", relatedRestoreDefault: "기본값 복원", + relatedRestoreDefaultOrder: "기본 순서 복원", relatedHiddenCount: "{{count}}개 숨김", relatedHiddenCount_one: "{{count}}개 숨김", relatedHiddenCount_other: "{{count}}개 숨김", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 9f3a8ed8572..230654f48c0 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1362,6 +1362,7 @@ const pt = { relatedCardCount_other: "{{count}} registros · ampliar", relatedDefaultChanged: "Ordem padrão atualizada", relatedRestoreDefault: "Restaurar padrão", + relatedRestoreDefaultOrder: "Restaurar ordem padrão", relatedHiddenCount: "{{count}} ocultas", relatedHiddenCount_one: "{{count}} oculta", relatedHiddenCount_many: "{{count}} ocultas", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 0a49aff9ad7..67e8c4a6229 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1237,6 +1237,7 @@ const ru = { relatedCardCount_other: "{{count}} записи · увеличить", relatedDefaultChanged: "Порядок по умолчанию обновлён", relatedRestoreDefault: "Восстановить по умолчанию", + relatedRestoreDefaultOrder: "Восстановить порядок по умолчанию", relatedHiddenCount: "Скрыто: {{count}}", relatedHiddenCount_one: "Скрыто: {{count}}", relatedHiddenCount_few: "Скрыто: {{count}}", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index c9d36262cf3..bafa8f90baf 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1245,6 +1245,7 @@ const zh = { relatedCardCount_other: '{{count}} 条记录 · 放大', relatedDefaultChanged: '默认顺序已更新', relatedRestoreDefault: '恢复默认', + relatedRestoreDefaultOrder: '恢复默认顺序', relatedHiddenCount: '已隐藏 {{count}} 张', relatedHiddenCount_one: '已隐藏 {{count}} 张', relatedHiddenCount_other: '已隐藏 {{count}} 张', diff --git a/ui/packages/plugin-detail/src/ArrangedRelatedTab.tsx b/ui/packages/plugin-detail/src/ArrangedRelatedTab.tsx index 3f72c27b55c..c6aafb04746 100644 --- a/ui/packages/plugin-detail/src/ArrangedRelatedTab.tsx +++ b/ui/packages/plugin-detail/src/ArrangedRelatedTab.tsx @@ -18,7 +18,8 @@ import { useRelatedArrangement } from './useRelatedArrangement'; * the tabbed tier's 关联 tab」). Read by the `record:related_list` renderer: a * collapsed list draws as its title + count (no rows read), every other list * gets its chrome (the ⋯ menu), and the tab's LAST list draws the column's - * two lines after itself — the changed-default hint and 「已隐藏 N 张 · 显示」. + * two lines after itself — the changed-default hint and the foot line + * (「已隐藏 N 张 · 显示」 · 「恢复默认顺序」). */ export interface RelatedArrangementScope { chromeOf: (id: RelatedCardId) => RelatedCardChrome | null; @@ -30,6 +31,9 @@ export interface RelatedArrangementScope { showHidden: () => void; defaultChanged: boolean; restoreDefault: () => void; + /** The reader moved or hid a card: the foot offers 「恢复默认顺序」 (objectstack#27241). */ + rearranged: boolean; + restoreDefaultArrangement: () => void; } export const RelatedArrangementScopeContext = React.createContext(null); @@ -111,6 +115,8 @@ export const ArrangedRelatedTab: React.FC<{ showHidden: () => edit({ kind: 'show-hidden' }), defaultChanged: resolved.defaultChanged, restoreDefault: () => edit({ kind: 'restore-default-order' }), + rearranged: resolved.rearranged, + restoreDefaultArrangement: () => edit({ kind: 'restore-default-arrangement' }), }; return ( {children(arrangedPage)} diff --git a/ui/packages/plugin-detail/src/RecordPageColumns.tsx b/ui/packages/plugin-detail/src/RecordPageColumns.tsx index fd098b204e2..cca9c4064e5 100644 --- a/ui/packages/plugin-detail/src/RecordPageColumns.tsx +++ b/ui/packages/plugin-detail/src/RecordPageColumns.tsx @@ -35,8 +35,10 @@ export interface RecordPageColumnsProps { footer?: React.ReactNode; /** * The reader's column switches (objectstack#27052): a column that is off is - * not drawn — the timeline folds into the related column's 「动态」 card, the - * related column into the rail (`recordPageArrangementFor`). Absent: both on. + * not drawn — the timeline folds into the related column's 「动态」 card; the + * related column is gone, rail and all (objectstack#27241). A related column + * that is on but has no room is the rail (`recordPageArrangementFor`). + * Absent: both on. */ switches?: RecordPageColumnSwitches; /** @@ -173,8 +175,9 @@ const COLUMN_STACK = 'flex min-w-0 flex-col gap-4 [&>*:has(>[hidden]:only-child) * The reader's switches (objectstack#27052) take a column out: with the * timeline off the related column is resident in its place and the * timeline's blocks are its foot card (「动态」); with the related column off - * it stays in the rail. `data-record-columns-shown` names the resident - * columns. + * there is no related column and no rail toggle (objectstack#27241: the + * switch decides whether the column exists, the rail toggle only opens one + * the switch allows). `data-record-columns-shown` names the resident columns. */ export const RecordPageColumns: React.FC = ({ tier, @@ -205,7 +208,7 @@ export const RecordPageColumns: React.FC = ({ const foldTimeline = arrangement.timelineFolded && hasTimeline; const hasRelated = hasRelatedLists || foldTimeline; const residentTimeline = hasTimeline && arrangement.columns.includes('timeline'); - const residentRelated = hasRelated && !arrangement.relatedRail; + const residentRelated = hasRelated && arrangement.columns.includes('related'); const relatedInRail = hasRelated && arrangement.relatedRail; const relatedLabel = t('detail.related'); const foldedTimelineLabel = t('detail.timelineFolded'); diff --git a/ui/packages/plugin-detail/src/RelatedCardStack.tsx b/ui/packages/plugin-detail/src/RelatedCardStack.tsx index 6e1eca10f76..cebbabfc98d 100644 --- a/ui/packages/plugin-detail/src/RelatedCardStack.tsx +++ b/ui/packages/plugin-detail/src/RelatedCardStack.tsx @@ -132,8 +132,9 @@ const FreshBadge: React.FC = () => { * its title and count and reads no rows. The order, collapsed and hidden * state are the reader's (the host's workspace preference), laid over the * default order by `resolveRelatedArrangement`; the column says when the - * default changed under a dragged order (「默认顺序已更新 · 恢复默认」) and how - * many cards are hidden (「已隐藏 N 张 · 显示」). + * default changed under a dragged order (「默认顺序已更新 · 恢复默认」), and its + * foot says how many cards are hidden (「已隐藏 N 张 · 显示」) and, once the + * reader has moved or hidden any, offers 「恢复默认顺序」 (objectstack#27241). * * ⛔ No wrapper element per card: the lists stay DOM siblings, so the * empty-list fold (`RelatedListEmptyFold`, which reads its stack off the @@ -239,9 +240,12 @@ export const RelatedCardStack: React.FC = ({ {rest.map((node, index) => ( ))} - {resolved.hidden.length > 0 && ( - edit({ kind: 'show-hidden' })} /> - )} + edit({ kind: 'show-hidden' })} + rearranged={resolved.rearranged} + onRestoreDefault={() => edit({ kind: 'restore-default-arrangement' })} + /> ); }; @@ -263,16 +267,40 @@ export const RelatedDefaultChangedHint: React.FC<{ onRestore: () => void }> = ({ ); }; -/** 「已隐藏 N 张 · 显示」 — the cards the reader hid, brought back together (rule 6). */ -export const RelatedHiddenLine: React.FC<{ count: number; onShow: () => void }> = ({ count, onShow }) => { +/** + * The related column's foot line (objectstack#27055 rule 6, objectstack#27241): + * 「已隐藏 N 张 · 显示」 — the cards the reader hid, brought back together — and, + * on the same line, 「恢复默认顺序」 whenever the column is not the default one + * (the reader moved or hid a card). Restoring lays the default order back and + * shows every card; it is always there, not only when the author's default + * changed (that is {@link RelatedDefaultChangedHint}). Draws nothing for a + * column in its default arrangement. + */ +export const RelatedFootLine: React.FC<{ + hiddenCount: number; + onShowHidden: () => void; + rearranged: boolean; + onRestoreDefault: () => void; +}> = ({ hiddenCount, onShowHidden, rearranged, onRestoreDefault }) => { const { t } = useDetailTranslation(); + if (hiddenCount === 0 && !rearranged) return null; return ( -

- {t('detail.relatedHiddenCount', { count, defaultValue: '{{count}} hidden' })} - {' · '} - +

+ {hiddenCount > 0 && ( + + {t('detail.relatedHiddenCount', { count: hiddenCount, defaultValue: '{{count}} hidden' })} + {' · '} + + + )} + {hiddenCount > 0 && rearranged && ' · '} + {rearranged && ( + + )}

); }; diff --git a/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.switches-27052.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.switches-27052.test.tsx index e63f2786a42..1bdd2f418df 100644 --- a/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.switches-27052.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RecordPageColumns.switches-27052.test.tsx @@ -90,22 +90,40 @@ describe('RecordPageColumns — the column switches (objectstack#27052)', () => expect(within(column(container, 'related')!).getAllByText('task')).toHaveLength(1); }); - it('related off, three columns: details | timeline, the related column behind the rail toggle', () => { + it('related off, three columns: details | timeline, no related column and no rail toggle (objectstack#27241)', () => { const { container } = render( , ); expect(shown(container)).toBe('details timeline'); expect(column(container, 'related')).toBeNull(); + expect(screen.queryByTestId('record-related-rail-toggle')).toBeNull(); + }); + + it('related off, two columns: no rail toggle — the switch decides whether the column exists (objectstack#27241)', () => { + const { container } = render( + , + ); + expect(shown(container)).toBe('details timeline'); + expect(screen.queryByTestId('record-related-rail-toggle')).toBeNull(); + expect(column(container, 'related')).toBeNull(); + }); + + it('related on, two columns: the rail toggle is there and opens the related column (objectstack#27241)', () => { + const { container } = render( + , + ); + expect(shown(container)).toBe('details timeline'); + expect(column(container, 'related')).toBeNull(); fireEvent.click(screen.getByTestId('record-related-rail-toggle')); expect(nodesIn(column(container, 'related'))).toEqual(['record:related_list', 'record:related_list']); }); - it('both off: details alone; the rail holds the lists and the folded timeline', () => { + it('both off: details alone, no rail', () => { const { container } = render( , ); expect(shown(container)).toBe('details'); - fireEvent.click(screen.getByTestId('record-related-rail-toggle')); - expect(column(container, 'related')!.querySelector('[data-record-folded-timeline]')).not.toBeNull(); + expect(screen.queryByTestId('record-related-rail-toggle')).toBeNull(); + expect(container.querySelector('[data-record-folded-timeline]')).toBeNull(); }); }); diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx index bd683d0636f..1641fe5bef8 100644 --- a/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx @@ -47,7 +47,7 @@ function StubNode({ schema }: { schema: any }) { import { RecordPageColumns } from '../RecordPageColumns'; import { buildDefaultPageColumns } from '../synth/recordPageColumns'; import { buildDefaultPageSchema } from '../synth/buildDefaultPageSchema'; -import { editRelatedArrangement, type RelatedArrangement } from '../synth/relatedArrangement'; +import { editRelatedArrangement, relatedDefaultOrderHash, type RelatedArrangement } from '../synth/relatedArrangement'; const RELATED = [ { objectName: 'task', relationshipField: 'lead_id', title: 'Tasks' }, @@ -127,6 +127,37 @@ describe('RelatedCardStack — the reader arranges the related cards (objectstac expect(container.querySelector('[data-related-hidden-line]')).toBeNull(); }); + it('「恢复默认顺序」 stands at the column foot once a card moved, and restores the default (objectstack#27241)', () => { + const onWrite = vi.fn(); + const { container } = render(); + // The default arrangement: no foot line at all. + expect(container.querySelector('[data-related-foot-line]')).toBeNull(); + fireEvent.click(screen.getByText('down task')); + expect(cardOrder(container)).toEqual(['note.parent_id', 'task.lead_id', 'call.lead_id']); + const foot = container.querySelector('[data-record-column="related"] [data-related-foot-line]')!; + expect(foot).not.toBeNull(); + // Not the changed-default hint: the author's default did not change. + expect(container.querySelector('[data-related-default-changed]')).toBeNull(); + const restore = foot.querySelector('[data-related-restore-default-order]') as HTMLButtonElement; + expect(restore.textContent).toBe('Restore default order'); + fireEvent.click(restore); + expect(cardOrder(container)).toEqual(DEFAULT); + const written = onWrite.mock.calls.at(-1)![0]; + expect(written.order).toEqual([]); + expect(written.basedOn).toBe(relatedDefaultOrderHash(DEFAULT)); + expect(container.querySelector('[data-related-foot-line]')).toBeNull(); + }); + + it('a hidden card puts 「恢复默认顺序」 on the hidden line, and it shows every card again (objectstack#27241)', () => { + const { container } = render(); + fireEvent.click(screen.getByText('hide note')); + const foot = container.querySelector('[data-related-foot-line]')!; + expect(foot.querySelector('[data-related-hidden-line]')).not.toBeNull(); + fireEvent.click(foot.querySelector('[data-related-restore-default-order]')!); + expect(cardOrder(container)).toEqual(DEFAULT); + expect(container.querySelector('[data-related-foot-line]')).toBeNull(); + }); + it('says when the default changed under a dragged order, and restores it', () => { const dragged = editRelatedArrangement(DEFAULT, undefined, { kind: 'move', id: 'call.lead_id', to: 0 }, Date.now()); const reordered = [RELATED[1], RELATED[0], RELATED[2]]; diff --git a/ui/packages/plugin-detail/src/index.tsx b/ui/packages/plugin-detail/src/index.tsx index 8e53ece4978..8551633359a 100644 --- a/ui/packages/plugin-detail/src/index.tsx +++ b/ui/packages/plugin-detail/src/index.tsx @@ -211,6 +211,8 @@ export { buildDefaultPageColumns, bodyTableIdsOf, } from './synth/recordPageColumns'; +// objectstack#27069 — the one reading of a related-list entry. +export { relatedListEntryNames } from './synth/relatedListEntry'; export type { RecordPageTier, RecordPageColumn, diff --git a/ui/packages/plugin-detail/src/renderers/record-related-list.tsx b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx index 90d22d2239b..e17a0e75423 100644 --- a/ui/packages/plugin-detail/src/renderers/record-related-list.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx @@ -37,7 +37,7 @@ import { import type { RecordRelatedListComponentProps } from '@object-ui/types'; import { RelatedList } from '../RelatedList'; import { RelatedArrangementScopeContext } from '../ArrangedRelatedTab'; -import { CollapsedRelatedCard, RelatedDefaultChangedHint, RelatedHiddenLine } from '../RelatedCardStack'; +import { CollapsedRelatedCard, RelatedDefaultChangedHint, RelatedFootLine } from '../RelatedCardStack'; import { RelatedCardChromeContext } from '../relatedListCompact'; import { relatedCardId } from '../synth/relatedArrangement'; import { useRecordAriaProps } from './recordComponentAria'; @@ -879,7 +879,8 @@ export const RecordRelatedListRenderer: React.FC // objectstack#27055 — inside the tabbed tier's 关联 tab the reader's // arrangement applies (`ArrangedRelatedTab`): a collapsed list draws as its // title + count and reads no rows; every other list carries its chrome; the - // tab's last list closes it with the changed-default hint and the hidden line. + // tab's last list closes it with the changed-default hint and the foot line + // (the hidden cards, 「恢复默认顺序」). const scope = React.useContext(RelatedArrangementScopeContext); const arrangedId = scope && typeof schema?.objectName === 'string' && typeof schema?.relationshipField === 'string' @@ -890,7 +891,12 @@ export const RecordRelatedListRenderer: React.FC const lines = scope.lastId === arrangedId && ( <> {scope.defaultChanged && } - {scope.hiddenCount > 0 && } + ); if (scope.isCollapsed(arrangedId)) { diff --git a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts index 6db11c3e769..d35c323123e 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts @@ -15,7 +15,9 @@ * `relatedList: 'primary'`) is a table section of the Details tab, placed * right after the FIRST field section; several stack in the order given; the * remaining sections follow. Every other list is a side card in the one - * `Related` tab. The `fieldGroups` placement entry is fenced (#27069). + * `Related` tab. A field group whose `relatedList` names a body table's + * relationship places that table at the group's position instead + * (objectstack#27069, objectstack#27241). */ import { describe, expect, it } from 'vitest'; @@ -43,7 +45,7 @@ const items = (tabs: any) => tabs.properties.items; const shape = (nodes: any[]) => nodes.map((n) => n.type === 'record:details' - ? `details(${(n.properties.sections ?? []).map((s: any) => s.label).join(',')})` + ? `details(${(n.properties.sections ?? []).map((s: any) => s.label ?? s.group).join(',')})` : `${n.type === 'record:related_list' ? 'table' : n.type}(${n.properties.objectName})`, ); @@ -101,3 +103,64 @@ describe('the page knows its body tables', () => { expect(columns.relatedCount).toBe(1); }); }); + +describe('a field group places a body table at its position (objectstack#27069, objectstack#27241)', () => { + // 基本信息 · 条款 · [明细行] · 备注 — the author put the line items after the terms. + const placed = (entry: string, groupsOrder = ['basic', 'terms', 'lines', 'notes']) => ({ + name: 'quote', + fields: { + name: { type: 'text', label: 'Name', group: 'basic' }, + terms: { type: 'textarea', label: 'Terms', group: 'terms' }, + notes: { type: 'textarea', label: 'Notes', group: 'notes' }, + }, + fieldGroups: groupsOrder.map((key) => (key === 'lines' ? { key, label: 'Lines', relatedList: entry } : { key, label: key })), + }); + const detailsOf = (def: any, related: any[]) => shape(items(buildDefaultTabs(def, { related }))[0].children); + + it('the table named by a group (child object name) sits at that group; the others keep "after the first section"', () => { + expect(detailsOf(placed('quote_line_item'), [LINES, SCHEDULE])).toEqual([ + 'details(basic)', + 'table(quote_schedule)', + 'details(terms)', + 'table(quote_line_item)', + 'details(notes)', + ]); + }); + + it('an `object.field` entry names the one FK; an entry naming another FK places nothing', () => { + expect(detailsOf(placed('quote_line_item.quote'), [LINES])).toEqual([ + 'details(basic,terms)', + 'table(quote_line_item)', + 'details(notes)', + ]); + expect(detailsOf(placed('quote_line_item.other_fk'), [LINES])).toEqual([ + 'details(basic)', + 'table(quote_line_item)', + 'details(terms,notes)', + ]); + }); + + it('a group declared before every field group puts its table first', () => { + expect(detailsOf(placed('quote_line_item', ['lines', 'basic', 'terms', 'notes']), [LINES])).toEqual([ + 'table(quote_line_item)', + 'details(basic,terms,notes)', + ]); + }); + + it('a group declared last puts its table after every field section', () => { + expect(detailsOf(placed('quote_line_item', ['basic', 'terms', 'notes', 'lines']), [LINES])).toEqual([ + 'details(basic,terms,notes)', + 'table(quote_line_item)', + ]); + }); + + it('the host-passed sections place the same way (the `{ group }` sections RecordDetailView passes)', () => { + const def = placed('quote_line_item'); + const sections = [{ group: 'basic' }, { group: 'terms' }, { group: 'notes' }]; + expect(shape(items(buildDefaultTabs(def, { sections, related: [LINES] }))[0].children)).toEqual([ + 'details(basic,terms)', + 'table(quote_line_item)', + 'details(notes)', + ]); + }); +}); diff --git a/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.switches-27052.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.switches-27052.test.ts index 163dab65246..54fb6b15252 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.switches-27052.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.switches-27052.test.ts @@ -93,11 +93,19 @@ describe('recordPageArrangementFor — a switched-off column frees its room (obj }); }); - it('three columns, related off: details | timeline, the related column in the rail', () => { + it('three columns, related off: details | timeline, and no rail (objectstack#27241)', () => { expect(recordPageArrangementFor('three-column', { timeline: true, related: false })).toEqual({ columns: ['details', 'timeline'], timelineFolded: false, - relatedRail: true, + relatedRail: false, + }); + }); + + it('two columns, related off: details | timeline, and no rail — the switch decides whether the column exists (objectstack#27241)', () => { + expect(recordPageArrangementFor('two-column', { timeline: true, related: false })).toEqual({ + columns: ['details', 'timeline'], + timelineFolded: false, + relatedRail: false, }); }); @@ -114,11 +122,12 @@ describe('recordPageArrangementFor — a switched-off column frees its room (obj }); }); - it('both off: details alone, the related column (timeline folded in) in the rail', () => { + it('both off: details alone, and no rail (objectstack#27241)', () => { expect(recordPageArrangementFor('three-column', { timeline: false, related: false })).toEqual({ columns: ['details'], timelineFolded: true, - relatedRail: true, + relatedRail: false, }); + expect(recordPageArrangementFor('two-column', { timeline: false, related: false }).relatedRail).toBe(false); }); }); diff --git a/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.test.ts index cd9371867fc..29ec9095f27 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.test.ts @@ -60,16 +60,26 @@ const maximal = { }; describe('recordPageTierFor (objectstack#26678)', () => { - it('three columns from 1280 px, two from 1024 px, the tabs below', () => { - expect(RECORD_PAGE_THREE_COLUMN_MIN_WIDTH).toBe(1280); - expect(RECORD_PAGE_TWO_COLUMN_MIN_WIDTH).toBe(1024); + it('three columns from 1120 px, two from 760 px, the tabs below (objectstack#27241)', () => { + expect(RECORD_PAGE_THREE_COLUMN_MIN_WIDTH).toBe(1120); + expect(RECORD_PAGE_TWO_COLUMN_MIN_WIDTH).toBe(760); expect(recordPageTierFor(1440)).toBe('three-column'); - expect(recordPageTierFor(1280)).toBe('three-column'); - expect(recordPageTierFor(1279.5)).toBe('two-column'); - expect(recordPageTierFor(1100)).toBe('two-column'); - expect(recordPageTierFor(1024)).toBe('two-column'); - expect(recordPageTierFor(1023)).toBe('tabs'); - expect(recordPageTierFor(900)).toBe('tabs'); + expect(recordPageTierFor(1120)).toBe('three-column'); + expect(recordPageTierFor(1119.5)).toBe('two-column'); + expect(recordPageTierFor(900)).toBe('two-column'); + expect(recordPageTierFor(760)).toBe('two-column'); + expect(recordPageTierFor(759)).toBe('tabs'); + expect(recordPageTierFor(600)).toBe('tabs'); + }); + + it('the windows the thresholds serve, with the 256 px sidebar expanded (objectstack#27241)', () => { + const SIDEBAR = 256; + // 「1440 窗口(含侧栏)即三栏」 — the record page root measured 1184 px there. + expect(recordPageTierFor(1440 - SIDEBAR)).toBe('three-column'); + // A 1024 px window with the sidebar stays two columns (768 px of container). + expect(recordPageTierFor(1024 - SIDEBAR)).toBe('two-column'); + // A 1280 px window with the sidebar: 1024 px of container, two columns. + expect(recordPageTierFor(1280 - SIDEBAR)).toBe('two-column'); }); it('an embedded page is tabbed at every width', () => { diff --git a/ui/packages/plugin-detail/src/synth/__tests__/relatedArrangement-27055.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/relatedArrangement-27055.test.ts index 14d4577ec84..9799c6f76d5 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/relatedArrangement-27055.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/relatedArrangement-27055.test.ts @@ -179,3 +179,37 @@ describe('the stored record', () => { expect('seen' in relatedArrangementToRecord(dragged())).toBe(false); }); }); + +describe('「恢复默认顺序」 is a standing entry once the column is not the default one (objectstack#27241)', () => { + it('rearranged: false for a reader who never chose, or only collapsed', () => { + expect(resolveRelatedArrangement(DEFAULT, undefined, NOW).rearranged).toBe(false); + const collapsedOnly = editRelatedArrangement(DEFAULT, undefined, { kind: 'collapse', id: B, collapsed: true }, NOW); + expect(resolveRelatedArrangement(DEFAULT, collapsedOnly, NOW).rearranged).toBe(false); + }); + + it('rearranged: true after a move or a hide; a move back to the default order reads as the default', () => { + expect(resolveRelatedArrangement(DEFAULT, dragged(), NOW).rearranged).toBe(true); + const hid = editRelatedArrangement(DEFAULT, undefined, { kind: 'hide', id: B }, NOW); + expect(resolveRelatedArrangement(DEFAULT, hid, NOW).rearranged).toBe(true); + const back = editRelatedArrangement(DEFAULT, dragged(), { kind: 'move', id: C, to: 2 }, NOW); + expect(resolveRelatedArrangement(DEFAULT, back, NOW).order).toEqual(DEFAULT); + expect(resolveRelatedArrangement(DEFAULT, back, NOW).rearranged).toBe(false); + }); + + it('restore-default-arrangement: the default order, every card shown, basedOn the current default; collapse stays', () => { + const moved = dragged(); + const hidden = editRelatedArrangement(DEFAULT, moved, { kind: 'hide', id: A }, NOW); + const collapsed = editRelatedArrangement(DEFAULT, hidden, { kind: 'collapse', id: B, collapsed: true }, NOW); + // The author has since reordered the default too. + const current = [B, A, C]; + const restored = editRelatedArrangement(current, collapsed, { kind: 'restore-default-arrangement' }, NOW); + expect(restored.order).toEqual([]); + expect(restored.hidden).toEqual([]); + expect(restored.basedOn).toBe(relatedDefaultOrderHash(current)); + expect(restored.collapsed).toEqual([B]); + const resolved = resolveRelatedArrangement(current, restored, NOW); + expect(resolved.order).toEqual(current); + expect(resolved.rearranged).toBe(false); + expect(resolved.defaultChanged).toBe(false); + }); +}); diff --git a/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts b/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts index 3d9744470eb..7736561f626 100644 --- a/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts +++ b/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts @@ -33,6 +33,7 @@ import { detectStatusField } from '@object-ui/types'; import { isRetiredFieldType, reportRetiredFieldType, resolveNameField } from '@object-ui/core'; import { inferDetailColumns } from '../autoLayout'; import { recordTimelineDefaultReason } from './recordPageColumns'; +import { relatedListEntryNames } from './relatedListEntry'; /** Minimal shape of an object definition we read here. We deliberately * duck-type so this helper has zero hard dependency on a specific @@ -911,17 +912,23 @@ export const BODY_TABLE_PAGE_SIZE = 10; /** * The Details tab's body with the record's body tables placed in it - * (objectstack#27055, Target 8): every body table right after the FIRST field - * section (conventionally 基本信息), stacked in the order given, and the - * remaining sections after them — the details node split in two around the - * tables. With one section or none, the tables follow the details. + * (objectstack#27055, Target 8; objectstack#27069): * - * ⛔ FENCE (objectstack#27069): an author who needs a table elsewhere will - * place it through a `fieldGroups` entry that references the relationship. - * That entry is not in the spec yet, so it is not read here; when it lands, - * THIS function is the one read point: place each table at the group entry - * that references it, and fall back to "after the first section" for the - * rest. + * - a body table some field group places — the group's `relatedList` names + * its relationship (a child object name, or `object.field`; the ONE + * reading is `relatedListEntryNames`) — is drawn at that group's position + * among the object's `fieldGroups`: after the sections of the groups + * declared before it, before those declared after it. Such a group holds + * no fields (the spec refuses one that does), so it is a position, never a + * section of its own; + * - every other body table goes right after the FIRST field section + * (conventionally 基本信息), stacked in the order given; with one section + * or none, after the details. + * + * The details node is split around the tables: each run of consecutive + * sections is one `record:details`. A section names its group by `group` (the + * spec's reference form) or by `name` (a resolved section); the ungrouped + * bucket names none and stays after every declared group. */ export function bodyTablePlacement( def: ObjectDefLike | undefined, @@ -930,13 +937,75 @@ export function bodyTablePlacement( bodies: any[], ): any[] { if (bodies.length === 0) return [buildDefaultDetails(def, sections, hideFields)]; - const resolved = resolveDetailSections(def, sections); - if (!resolved || resolved.length <= 1) return [buildDefaultDetails(def, sections, hideFields), ...bodies]; - return [ - buildDefaultDetails(def, resolved.slice(0, 1), hideFields), - ...bodies, - buildDefaultDetails(def, resolved.slice(1), hideFields), - ]; + const resolved = resolveDetailSections(def, sections) ?? []; + + // The declared groups in order, and the position of each group that places + // a body table. + const groups = Array.isArray(def?.fieldGroups) ? def!.fieldGroups : []; + const groupIndex = new Map(); + const placingGroups: Array<{ index: number; entry: string }> = []; + groups.forEach((group, index) => { + if (!group || typeof group.key !== 'string') return; + if (!groupIndex.has(group.key)) groupIndex.set(group.key, index); + const entry = (group as { relatedList?: unknown }).relatedList; + if (typeof entry === 'string' && entry.length > 0) placingGroups.push({ index, entry }); + }); + const placedAt = (node: any): number | null => { + const p = { ...node, ...(node?.properties ?? {}) }; + if (typeof p.objectName !== 'string' || typeof p.relationshipField !== 'string') return null; + const list = { childObject: p.objectName, referenceField: p.relationshipField }; + const group = placingGroups.find(({ entry }) => relatedListEntryNames(entry, list)); + return group ? group.index : null; + }; + const placed = bodies + .map((node, order) => ({ node, order, at: placedAt(node) })) + .filter((b): b is { node: any; order: number; at: number } => b.at !== null) + .sort((a, b) => a.at - b.at || a.order - b.order); + const unplaced = bodies.filter((node) => placedAt(node) === null); + const sectionGroupIndex = (section: Record): number => { + const key = typeof section?.group === 'string' ? section.group : typeof section?.name === 'string' ? section.name : undefined; + const index = key !== undefined ? groupIndex.get(key) : undefined; + return index ?? Number.POSITIVE_INFINITY; + }; + + if (placed.length === 0 && resolved.length <= 1) { + return [buildDefaultDetails(def, sections, hideFields), ...bodies]; + } + + // Lay the sections and the tables out in one sequence, then fold each run + // of consecutive sections into one details node. + type Slot = { section: Record } | { table: any }; + const slots: Slot[] = []; + let next = 0; + const flushPlacedBefore = (index: number) => { + while (next < placed.length && placed[next].at < index) slots.push({ table: placed[next++].node }); + }; + resolved.forEach((section, i) => { + flushPlacedBefore(sectionGroupIndex(section)); + slots.push({ section }); + if (i === 0) for (const node of unplaced) slots.push({ table: node }); + }); + if (resolved.length === 0) for (const node of unplaced) slots.push({ table: node }); + while (next < placed.length) slots.push({ table: placed[next++].node }); + + const out: any[] = []; + let run: Array> = []; + const closeRun = () => { + if (run.length > 0) out.push(buildDefaultDetails(def, run, hideFields)); + run = []; + }; + for (const slot of slots) { + if ('section' in slot) run.push(slot.section); + else { + closeRun(); + out.push(slot.table); + } + } + closeRun(); + // No section at all: the details node still leads (it draws the object's + // highlight fields, `resolveDetailSections`' rule 3). + if (resolved.length === 0) out.unshift(buildDefaultDetails(def, sections, hideFields)); + return out; } /** @@ -989,8 +1058,9 @@ export function buildDefaultTabs( // objectstack#27055, Target 8 — a list that is the record's own body // (`isBody`, else `isPrimary`) is a table section of the Details tab; every // other list is a side card in the one `Related` tab. Body tables keep the - // order the host passed (the derived order — the authored `relatedLists` - // order waits for objectstack#27069, see `bodyTablePlacement`). + // order the host passed (the object's `relatedLists`, then the derived + // order — objectstack#27069), unless a field group places one + // (`bodyTablePlacement`). const related = Array.isArray(options.related) ? options.related : []; const isBody = (rel: (typeof related)[number]) => rel.isBody ?? rel.isPrimary === true; const bodies = related.filter(isBody).map((rel) => relatedNode(rel, BODY_TABLE_PAGE_SIZE)); diff --git a/ui/packages/plugin-detail/src/synth/recordPageColumns.ts b/ui/packages/plugin-detail/src/synth/recordPageColumns.ts index 962f2749edc..cf52ec41e45 100644 --- a/ui/packages/plugin-detail/src/synth/recordPageColumns.ts +++ b/ui/packages/plugin-detail/src/synth/recordPageColumns.ts @@ -13,12 +13,14 @@ * also what Studio's page-create seeds and what a slotted page fills — and the * WIDTH decides how its content is laid out: * - * - 1280 px and up: three resident columns, details | timeline | related; - * - 1024 – 1279 px: details | timeline, the related column folded into a rail + * - 1120 px and up: three resident columns, details | timeline | related; + * - 760 – 1119 px: details | timeline, the related column folded into a rail * behind a toggle; - * - below 1024 px, and whenever the page is embedded (drawer / split pane): + * - below 760 px, and whenever the page is embedded (drawer / split pane): * the tab strip, exactly as before. * + * (Container widths — see the two constants for how they are derived.) + * * ## Why the columns are derived, never authored * * The tiers are the renderer's. No metadata key, no page template and no node @@ -39,11 +41,49 @@ * and the host draws the tabs. Tabs are the correct rendering of any strip. */ -/** A container at least this wide lays the default record page out in three columns. */ -export const RECORD_PAGE_THREE_COLUMN_MIN_WIDTH = 1280; +/** + * The console sidebar's width when it is expanded: `SIDEBAR_WIDTH` (16rem) of + * the sidebar primitive in `@object-ui/components`. Measured on the console + * (HotCRM contact record page, sidebar expanded, objectstack#27241): the + * record page root is the window minus this — 1184 px in a 1440 px window, + * 1024 px in 1280, 768 px in 1024. + */ +const EXPANDED_SIDEBAR_WIDTH = 256; + +/** + * A container at least this wide lays the default record page out in three + * columns. + * + * Derived from the window it must serve, not picked (objectstack#27241, the + * maintainer's ruling 「1440 窗口(含侧栏)即三栏」): a 1440 px window with the + * sidebar expanded is three columns. + * + * 1440 (window) − 256 (expanded sidebar) = 1184 px of container + * 1184 − 64 (headroom: a classic 15–17 px scrollbar, browser zoom + * rounding, a sidebar a few px wider) = 1120 + * + * At 1120 the v3 template (`1.25fr : 1fr : 20rem`, 24 px page padding each + * side, 24 px gaps) leaves 1120 − 48 − 48 − 320 = 704 px for details + + * timeline (≈ 391 + 313). It stays container-measured: the same page in a + * drawer is narrow on a wide screen. + */ +export const RECORD_PAGE_THREE_COLUMN_MIN_WIDTH = 1440 - EXPANDED_SIDEBAR_WIDTH - 64; -/** A container at least this wide (and narrower than the three-column floor) lays it out in two. */ -export const RECORD_PAGE_TWO_COLUMN_MIN_WIDTH = 1024; +/** + * A container at least this wide (and narrower than the three-column floor) + * lays it out in two. + * + * Derived the same way (objectstack#27241): a 1024 px window with the sidebar + * expanded stays two columns, not the tabs. + * + * 1024 (window) − 256 (expanded sidebar) = 768 px of container + * 768 − 8 (headroom) = 760 + * + * At 760 the two-column grid (24 px page padding each side, a 24 px gap) + * gives each column (760 − 48 − 24) / 2 = 344 px — the narrowest a details + * section's two-field row stays legible. + */ +export const RECORD_PAGE_TWO_COLUMN_MIN_WIDTH = 1024 - EXPANDED_SIDEBAR_WIDTH - 8; /** How the synthesized default record page is laid out at the width it has. */ export type RecordPageTier = 'tabs' | 'two-column' | 'three-column'; @@ -84,20 +124,28 @@ export interface RecordPageColumnSwitches { * * - timeline off: its blocks fold into a 「动态」 (Activity) card at the foot * of the related column, and the related column takes the timeline's place; - * - related off: the related column (with any folded timeline) stays behind - * the rail toggle, as it does when the width cannot fit it. + * - related off: there is no related column and no rail + * (objectstack#27241, 「关联 OFF 就没有栏」) — the switch decides whether + * the column exists; the rail toggle only opens or closes a column the + * switch allows, when the width cannot fit it resident. * * So timeline off on a three-column page draws details | related, related off - * draws details | timeline with the rail, and in the two-column tier timeline - * off draws details | related. Below the column tiers the page is tabbed and - * the switches are not read; they are kept for when it widens. + * draws details | timeline (no rail), and in the two-column tier related on + * draws details | timeline with the rail while timeline off draws details | + * related. With both off the page is the details column alone. Below the + * column tiers the page is tabbed and the switches are not read; they are + * kept for when it widens. */ export interface RecordPageArrangement { /** The resident columns, left to right. Always starts with `details`. */ columns: RecordPageColumn[]; /** The timeline's blocks are drawn as the related column's foot card. */ timelineFolded: boolean; - /** The related column is drawn as a rail behind a toggle, not resident. */ + /** + * The related column is drawn as a rail behind a toggle, not resident: the + * switch keeps it (`related` on) and the tier has no room for it. Never set + * when the switch is off. + */ relatedRail: boolean; } @@ -118,7 +166,7 @@ export function recordPageArrangementFor( return { columns, timelineFolded: !switches.timeline, - relatedRail: !columns.includes('related'), + relatedRail: switches.related && !columns.includes('related'), }; } diff --git a/ui/packages/plugin-detail/src/synth/relatedArrangement.ts b/ui/packages/plugin-detail/src/synth/relatedArrangement.ts index 555d8b0db5e..4c7724584f4 100644 --- a/ui/packages/plugin-detail/src/synth/relatedArrangement.ts +++ b/ui/packages/plugin-detail/src/synth/relatedArrangement.ts @@ -123,6 +123,13 @@ export interface ResolvedRelatedArrangement { defaultChanged: boolean; /** The reader has dragged (their order is not the default's). */ dragged: boolean; + /** + * The column the reader sees is not the default one: the cards stand in + * another order than the default, or some are hidden. The column's foot + * then offers 「恢复默认顺序」 (objectstack#27241, a standing entry beside + * 「已隐藏 N 张 · 显示」). + */ + rearranged: boolean; } /** @@ -190,15 +197,17 @@ export function resolveRelatedArrangement( for (const [id, at] of Object.entries(seen)) { if (exists.has(id) && now - at < RELATED_CARD_NEW_BADGE_MS) fresh.add(id); } + const hidden = fullOrder.filter((id) => hiddenSet.has(id)); return { order: fullOrder.filter((id) => !hiddenSet.has(id)), fullOrder, collapsed, - hidden: fullOrder.filter((id) => hiddenSet.has(id)), + hidden, fresh, unseen, defaultChanged: dragged && stored!.basedOn !== relatedDefaultOrderHash(defaultOrder), dragged, + rearranged: hidden.length > 0 || fullOrder.some((id, index) => id !== defaultOrder[index]), }; } @@ -211,6 +220,12 @@ export type RelatedArrangementEdit = | { kind: 'hide'; id: RelatedCardId } | { kind: 'show-hidden' } | { kind: 'restore-default-order' } + /** + * 「恢复默认顺序」 (objectstack#27241): the default order with every card + * shown — the column a reader who never arranged it sees; collapsed state + * stays the reader's. + */ + | { kind: 'restore-default-arrangement' } | { kind: 'mark-seen'; ids: RelatedCardId[]; at: number }; /** @@ -286,6 +301,8 @@ export function editRelatedArrangement( return finish({ ...base, hidden: [] }); case 'restore-default-order': return finish({ ...base, order: [], basedOn: hash, seen: {} }); + case 'restore-default-arrangement': + return finish({ ...base, order: [], hidden: [], basedOn: hash, seen: {} }); case 'mark-seen': { const seen = { ...(base.seen ?? {}) }; for (const id of edit.ids) if (exists.has(id) && typeof seen[id] !== 'number') seen[id] = edit.at; diff --git a/ui/packages/plugin-detail/src/synth/relatedListEntry.ts b/ui/packages/plugin-detail/src/synth/relatedListEntry.ts new file mode 100644 index 00000000000..36622866109 --- /dev/null +++ b/ui/packages/plugin-detail/src/synth/relatedListEntry.ts @@ -0,0 +1,27 @@ +/** + * 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. + */ + +/** + * Does one related-list entry name this list (objectstack#27069)? The entry + * is an `ObjectSchema.relatedLists` item or a field group's `relatedList`, as + * `@objectstack/spec` spells both: a bare child object name names every list + * of that child; `object.field` names the one FK. + * + * The ONE reading of an entry. It lives here, below the console, because the + * synthesized record page reads it (`bodyTablePlacement`, the field group + * that places a body table); app-shell's related-list order and the Studio + * editors that author entries import it from this package. + */ +export function relatedListEntryNames( + entry: string, + list: { childObject: string; referenceField: string }, +): boolean { + const dot = entry.indexOf('.'); + if (dot === -1) return list.childObject === entry; + return list.childObject === entry.slice(0, dot) && list.referenceField === entry.slice(dot + 1); +} From 0d0b3b080c25e6ba5001985137b9ce713a3bff21 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:55:35 +0800 Subject: [PATCH 2/5] test(plugin-detail): type the host's group-reference sections in the placement pin Claude-Session: https://claude.ai/code/session_cdb7a717def74e719988f089fbaa18ff Co-authored-by: Claude --- .../__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts index d35c323123e..55bd0342417 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts @@ -156,7 +156,9 @@ describe('a field group places a body table at its position (objectstack#27069, it('the host-passed sections place the same way (the `{ group }` sections RecordDetailView passes)', () => { const def = placed('quote_line_item'); - const sections = [{ group: 'basic' }, { group: 'terms' }, { group: 'notes' }]; + // The `{ group }` reference form, as the host writes it (the options type + // names the enumerated form only). + const sections: Array> = [{ group: 'basic' }, { group: 'terms' }, { group: 'notes' }]; expect(shape(items(buildDefaultTabs(def, { sections, related: [LINES] }))[0].children)).toEqual([ 'details(basic,terms)', 'table(quote_line_item)', From 9a0c450ebde8d4c8382d5cbad57b9d33db7cc1b9 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:57:08 +0800 Subject: [PATCH 3/5] chore(changeset): the record page round-2 patch Claude-Session: https://claude.ai/code/session_cdb7a717def74e719988f089fbaa18ff Co-authored-by: Claude --- ...27241-record-page-toggles-and-threshold.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .changeset/27241-record-page-toggles-and-threshold.md diff --git a/.changeset/27241-record-page-toggles-and-threshold.md b/.changeset/27241-record-page-toggles-and-threshold.md new file mode 100644 index 00000000000..7760977b033 --- /dev/null +++ b/.changeset/27241-record-page-toggles-and-threshold.md @@ -0,0 +1,25 @@ +--- +'@object-ui/plugin-detail': patch +'@object-ui/components': patch +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(plugin-detail,app-shell,components): the record page's related switch decides whether the column exists, its panel switches are one segmented control in the header's action row, a 1440 px window with the sidebar is three columns, and the related order and body-table placement read the object's `relatedLists` and field groups + +Clause-②: no +Metadata-protocol: none + +The synthesized default record page (an object with no assigned page). Nothing an author writes changes meaning: the two read points below are keys the spec already declares. + +- **The related switch.** With the related column switched off there is no related column and no 「Related」 rail toggle, in every column tier. With it on and no room for it (the two-column tier), the rail toggle opens and closes it. Before, the switch did nothing in the two-column tier and the rail still opened the column. +- **One segmented control in the header row.** The two panel switches are now one control with an icon and a label per segment (Timeline, Related column). It is drawn at the right end of the record header's action row, after `⋯`, behind a vertical hairline, in both column tiers. A segment that is on is filled with the accent tint; one that is off is muted text, so the state reads in the dark theme too. The crumb row keeps only the ways back and is not drawn when there is none. The shortcuts are unchanged (`⌘⇧.` / `⌘⇧,`); they now also match a key event that carries the typed character (`.` / `>`, `,` / `<`) but no physical key code. +- **Thresholds.** Three columns from a 1120 px container, two from 760 px, the tabs below (were 1280 / 1024). Measured on the console: the expanded sidebar is 256 px, so a 1440 px window is 1184 px of record page (three columns), and a 1024 px window is 768 px (two columns). `RECORD_PAGE_THREE_COLUMN_MIN_WIDTH` and `RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` carry the new values and their derivation. +- **Related order.** The default order of the related cards, the Related tab and the body tables is the object's `relatedLists` first, then the derived order. That order already reached the page through `deriveRelatedLists`; the stale comment that said it did not is gone, and a mounted pin now holds it. +- **Body-table placement.** A field group whose `relatedList` names a body table's relationship (a child object name, or `object.field`) places that table at the group's position among the field groups. Other body tables stay after the first field section. +- **Restore default order.** Once a reader has moved or hidden a related card, the foot of the related column (and of the Related tab) offers 「Restore default order」 on the same line as 「N hidden · Show」. It lays the default order back, shows every card, and records the current default as the one the arrangement is based on. The reader's stored arrangement keeps its shape. The hint shown when the author's default changed stays as it was. +- **`@object-ui/components`.** `PanelToggles` is the segmented control. `PageHeaderTrailingContext` (new export) is the host slot at the end of `page:header`'s action row, and `PageHeaderActions` takes a `trailing` node for it. No metadata key reaches it. +- **`@object-ui/plugin-detail`.** `relatedListEntryNames` (new export) is the one reading of a related-list entry; app-shell's related-list order and the Studio editors import it from here. +- **`@object-ui/i18n`.** New `detail.relatedRestoreDefaultOrder` in the ten packs. + +What to do: nothing. From 421fa4e7d996729fb678a7bb25f435e94b704580 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:03:31 +0800 Subject: [PATCH 4/5] fix(components): the header action row wraps inside the header, so the trailing panel switches never overflow a narrow record page Claude-Session: https://claude.ai/code/session_cdb7a717def74e719988f089fbaa18ff Co-authored-by: Claude --- .../src/renderers/layout/containers.tsx | 36 ++++++++----------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/ui/packages/components/src/renderers/layout/containers.tsx b/ui/packages/components/src/renderers/layout/containers.tsx index ea3f8b2accf..a52cde46797 100644 --- a/ui/packages/components/src/renderers/layout/containers.tsx +++ b/ui/packages/components/src/renderers/layout/containers.tsx @@ -2218,28 +2218,8 @@ export const PageHeaderActions: React.FC = ({ }, [record, execute]); const hasTrailing = trailing !== null && trailing !== undefined && trailing !== false; - // The trailing segment (objectstack#27241): the host's chrome after the - // actions, a hairline between the two so the switches do not read as one - // more action. - const trailingSegment = hasTrailing ? ( - - - {trailing} - - ) : null; const renderHeaderActions = () => { - if (headerActions.length === 0 && !refresh) { - if (!hasTrailing) return null; - return ( -
- {trailing} -
- ); - } + if (headerActions.length === 0 && !refresh && !hasTrailing) return null; // The label is already resolved — the `headerActions` memo above ran every // action through the shared localizer (`{ns}.objects.{objectName} // ._actions.{name}.label`, authored literal as fallback), together with the @@ -2368,6 +2348,18 @@ export const PageHeaderActions: React.FC = ({ // the menu (a separator under nothing sets nothing apart). const firstDestructiveIdx = overflowActions.findIndex(isDestructiveAction); const useOverflow = overflowActions.length > 0 || !!refresh; + // The trailing segment (objectstack#27241): the host's chrome after the + // actions, a hairline between the two so the switches do not read as one + // more action. `ml-auto`: when the row wraps (a narrow header), the + // segment ends its own line at the right edge. + const trailingSegment = hasTrailing ? ( + + {(inlineActions.length > 0 || useOverflow) && ( + + )} + {trailing} + + ) : null; // Resolve a `disabled` predicate against the record — a boolean OR an // expression (`'record.status == …'` or the `{ dialect, source }` // envelope). Without this a CEL `disabled` silently did nothing (only @@ -2479,7 +2471,7 @@ export const PageHeaderActions: React.FC = ({ }; return (
From 9c82d929a8e16588d489990cdeba33a199f3381a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:04:52 +0800 Subject: [PATCH 5/5] test(components): pin the page:header trailing host slot Claude-Session: https://claude.ai/code/session_cdb7a717def74e719988f089fbaa18ff Co-authored-by: Claude --- .../page-header-trailing-27241.test.tsx | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 ui/packages/components/src/__tests__/page-header-trailing-27241.test.tsx diff --git a/ui/packages/components/src/__tests__/page-header-trailing-27241.test.tsx b/ui/packages/components/src/__tests__/page-header-trailing-27241.test.tsx new file mode 100644 index 00000000000..381ec644b65 --- /dev/null +++ b/ui/packages/components/src/__tests__/page-header-trailing-27241.test.tsx @@ -0,0 +1,89 @@ +/** + * 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#27241 — the host slot at the end of `page:header`'s action row. + * The console's record view hands in the reader's panel switches through + * `PageHeaderTrailingContext`; the row draws them as its LAST segment, after + * the `⋯` menu, behind a vertical hairline, and wraps inside the header + * (`max-w-full`) rather than running past a narrow page. No slot, no change: + * the row is what it was. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { ActionProvider, RecordContextProvider } from '@object-ui/react'; +import { PageHeaderActions, PageHeaderTrailingContext } from '../index'; + +const RECORD = { id: 'lead-1', name: 'Ada' }; +const SYSTEM = [ + { name: 'sys_edit', label: 'Edit', type: 'script', locations: ['record_header'], order: 100, onClick: () => {} }, + { name: 'sys_delete', label: 'Delete', type: 'script', locations: ['record_header'], order: 120, component: 'action:menu', onClick: () => {} }, +]; + +function inRecord(node: React.ReactNode, headerSystemActions: unknown[] = SYSTEM) { + return ( + + + {node} + + + ); +} + +const SWITCHES =
switches
; + +afterEach(() => cleanup()); + +describe('page:header — the trailing host slot (objectstack#27241)', () => { + it('draws the slot as the toolbar’s last segment, after ⋯, behind a vertical hairline', () => { + const PageHeader = ComponentRegistry.get('page:header')!; + const { container, getByTestId } = render( + inRecord( + + + , + ), + ); + const toolbar = container.querySelector('[role="toolbar"]')!; + const trailing = toolbar.lastElementChild!; + expect(trailing.hasAttribute('data-page-header-trailing')).toBe(true); + expect(trailing.contains(getByTestId('switches'))).toBe(true); + const rule = trailing.querySelector('[role="separator"]')!; + expect(rule.getAttribute('aria-orientation')).toBe('vertical'); + // The ⋯ trigger comes before the segment. + const more = toolbar.querySelector('[aria-label="More actions"]')!; + expect(more.compareDocumentPosition(trailing) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + // The row wraps inside the header instead of overflowing it. + expect(toolbar.className.split(/\s+/)).toEqual(expect.arrayContaining(['flex-wrap', 'shrink-0', 'max-w-full'])); + }); + + it('with no action the row is the slot alone, with no hairline', () => { + const { container, getByTestId } = render(inRecord(, [])); + const toolbar = container.querySelector('[role="toolbar"]')!; + expect(toolbar.contains(getByTestId('switches'))).toBe(true); + expect(toolbar.querySelector('[role="separator"]')).toBeNull(); + }); + + it('no slot: no trailing segment, and the empty slot still stands in for an empty row', () => { + const PageHeader = ComponentRegistry.get('page:header')!; + const withActions = render(inRecord()); + expect(withActions.container.querySelector('[data-page-header-trailing]')).toBeNull(); + cleanup(); + const empty = render(inRecord(} />, [])); + expect(empty.container.querySelector('[role="toolbar"]')).toBeNull(); + expect(empty.getByTestId('empty')).toBeTruthy(); + }); +});