diff --git a/.changeset/27052-workspace-preferences.md b/.changeset/27052-workspace-preferences.md new file mode 100644 index 00000000000..288b9cf2e37 --- /dev/null +++ b/.changeset/27052-workspace-preferences.md @@ -0,0 +1,19 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/components': patch +'@object-ui/plugin-detail': patch +'@object-ui/i18n': patch +--- + +feat(app-shell,plugin-detail,components,i18n): the record page's timeline and related columns are each reader's switches, per object, toggled from the crumb row + +Clause-②: no +Metadata-protocol: none + +Whether the record page shows its timeline column and its related column is now the reader's own choice, per object, kept in the browser. It is not metadata: no key, no spec change. + +- **Defaults derived from the object.** A reader who never chose gets the timeline on for a "worked" object and off otherwise, and the related column on. An object is worked when `stageField` names a field, when a field's options declare a lifecycle `kind` (unless `stageField: false`), or when it sets `enable.files: true` — only what the object declares of itself. Being another object's `master_detail` parent does not count: a container that owns child rows (a price book, a campaign) is not a worked record. `recordTimelineDefaultReason` and `recordPageColumnDefaults` in `@object-ui/plugin-detail` say which. +- **A switched-off column frees its room.** With the timeline off, a wide page draws details | related, and the timeline's blocks (the composer and History) become an Activity card at the foot of the related column. With the related column off, it stays behind the rail toggle. `recordPageArrangementFor` is the rule, and `RecordPageColumns` takes the reader's `switches`. Below the column widths the page is tabbed as before, the switches are kept for when it widens, and `?tab=` is unchanged. +- **Panel toggles.** `PanelToggles` in `@object-ui/components` is a group of icon toggles with `aria-pressed`, a tinted pressed state, and a tooltip naming the shortcut. The record page draws it on its crumb row, right side, only in a column tier. It is not drawn in the drawer or split pane. +- **Shortcuts.** `⌘⇧.` / `Ctrl+Shift+.` toggles the timeline and `⌘⇧,` / `Ctrl+Shift+,` the related column, matched by the physical key. Not `⌘⇧T` / `⌘⇧R`: the browser keeps those (reopen closed tab, hard reload) and the page never receives them. They do nothing while a text field has focus, and the keyboard-shortcuts dialog lists them while the record page is open. +- **One preference store.** `app-shell`'s `workspacePreference` module keeps every reader preference by preference and scope (`user`, or `object:OBJECT_NAME`). Each value is a small record with a version `v`. It reads live through `useSyncExternalStore`, follows other tabs, and falls back to the default with no storage. The record-open preference (`os:record-open-mode`) is now a thin alias over it and keeps its stored spelling, so a reader's earlier choice reads back unchanged. diff --git a/ui/packages/app-shell/src/utils/recordOpenPreference.ts b/ui/packages/app-shell/src/utils/recordOpenPreference.ts index 4d4c91b1e91..b8acc0db624 100644 --- a/ui/packages/app-shell/src/utils/recordOpenPreference.ts +++ b/ui/packages/app-shell/src/utils/recordOpenPreference.ts @@ -6,7 +6,13 @@ * LICENSE file in the root directory of this source tree. */ -import { useCallback, useSyncExternalStore } from 'react'; +import { + defineWorkspacePreference, + readWorkspacePreference, + useWorkspacePreference, + writeWorkspacePreference, + workspacePreferenceStorage, +} from './workspacePreference.js'; /** * How the console's object page opens a record a reader clicks, when the @@ -34,6 +40,13 @@ import { useCallback, useSyncExternalStore } from 'react'; * It never overrides a declaration: an object whose resolved mode is `page`, * `new_window` or `none` ignores it, because the host reads it only for a * drawer-mode record. + * + * A thin alias over the console's one preference store, + * `workspacePreference.ts` (objectstack#27052): the preference is + * {@link RECORD_OPEN_PREFERENCE} there, in the `user` scope, and it keeps the + * spelling it was first stored with — a bare `drawer` / `split` under + * {@link RECORD_OPEN_PREFERENCE_KEY} — so a reader's earlier choice reads + * back unchanged. */ export type RecordOpenPreference = 'drawer' | 'split'; @@ -49,15 +62,19 @@ export type RecordOpenPreferenceStorage = Pick; /** The storage surface this module writes — `localStorage`, or a test double. */ export type RecordOpenPreferenceWriteStorage = Pick; -function defaultStorage(): Storage | null { - if (typeof window === 'undefined') return null; - try { - return window.localStorage; - } catch { - // Accessing `localStorage` itself throws on a blocked origin. - return null; - } -} +/** + * The preference in the console's store: one key for every object (`user` + * scope), stored as the bare value. Any other stored spelling — an unknown + * value, a retired mode name, another case — is no choice. + */ +export const RECORD_OPEN_PREFERENCE = defineWorkspacePreference({ + name: 'record.open-mode', + storageKey: () => RECORD_OPEN_PREFERENCE_KEY, + codec: { + decode: (stored) => (stored === 'split' || stored === 'drawer' ? stored : undefined), + encode: (value) => value, + }, +}); /** * The reader's record-open preference, or {@link DEFAULT_RECORD_OPEN_PREFERENCE} @@ -66,30 +83,11 @@ function defaultStorage(): Storage | null { * says there is none. */ export function readRecordOpenPreference( - storage: RecordOpenPreferenceStorage | null = defaultStorage(), + storage: RecordOpenPreferenceStorage | null = workspacePreferenceStorage(), ): RecordOpenPreference { - if (!storage) return DEFAULT_RECORD_OPEN_PREFERENCE; - try { - const stored = storage.getItem(RECORD_OPEN_PREFERENCE_KEY); - return stored === 'split' || stored === 'drawer' ? stored : DEFAULT_RECORD_OPEN_PREFERENCE; - } catch { - return DEFAULT_RECORD_OPEN_PREFERENCE; - } + return readWorkspacePreference(RECORD_OPEN_PREFERENCE, 'user', storage) ?? DEFAULT_RECORD_OPEN_PREFERENCE; } -/** - * A choice storage would not keep, held for this page: a browser whose - * storage refuses the write (private mode, a full quota, a blocked origin) - * would otherwise have the reader pick 分屏 and keep reading 抽屉 back. - * `undefined` while storage keeps the choice — storage is then the one answer - * — and cleared by the next kept write and by another tab's write (see - * `subscribe`). - */ -let pageChoice: RecordOpenPreference | undefined; - -/** The live {@link useRecordOpenPreference} callers on this page. */ -const listeners = new Set<() => void>(); - /** * Keep the reader's choice. `storage` defaults to the browser's * `localStorage`; `null` says there is none. With no storage, or one that @@ -103,42 +101,9 @@ const listeners = new Set<() => void>(); */ export function writeRecordOpenPreference( next: RecordOpenPreference, - storage: RecordOpenPreferenceWriteStorage | null = defaultStorage(), + storage: RecordOpenPreferenceWriteStorage | null = workspacePreferenceStorage(), ): void { - pageChoice = next; - if (storage) { - try { - storage.setItem(RECORD_OPEN_PREFERENCE_KEY, next); - pageChoice = undefined; - } catch { - // Not kept past this page — see above. - } - } - for (const listener of Array.from(listeners)) listener(); -} - -function subscribe(onChange: () => void): () => void { - listeners.add(onChange); - const onStorage = (event: StorageEvent) => { - // `key === null` is a `clear()` in another tab. - if (event.key === RECORD_OPEN_PREFERENCE_KEY || event.key === null) { - pageChoice = undefined; - onChange(); - } - }; - if (typeof window !== 'undefined') window.addEventListener('storage', onStorage); - return () => { - listeners.delete(onChange); - if (typeof window !== 'undefined') window.removeEventListener('storage', onStorage); - }; -} - -function snapshot(): RecordOpenPreference { - return pageChoice ?? readRecordOpenPreference(); -} - -function serverSnapshot(): RecordOpenPreference { - return DEFAULT_RECORD_OPEN_PREFERENCE; + writeWorkspacePreference(RECORD_OPEN_PREFERENCE, 'user', next, storage); } /** @@ -148,7 +113,5 @@ function serverSnapshot(): RecordOpenPreference { * object page decides its split face from the value the reader just chose. */ export function useRecordOpenPreference(): [RecordOpenPreference, (next: RecordOpenPreference) => void] { - const preference = useSyncExternalStore(subscribe, snapshot, serverSnapshot); - const setPreference = useCallback((next: RecordOpenPreference) => writeRecordOpenPreference(next), []); - return [preference, setPreference]; + return useWorkspacePreference(RECORD_OPEN_PREFERENCE, 'user', DEFAULT_RECORD_OPEN_PREFERENCE); } diff --git a/ui/packages/app-shell/src/utils/workspacePreference.hook.test.tsx b/ui/packages/app-shell/src/utils/workspacePreference.hook.test.tsx new file mode 100644 index 00000000000..5eb467e11ee --- /dev/null +++ b/ui/packages/app-shell/src/utils/workspacePreference.hook.test.tsx @@ -0,0 +1,106 @@ +/** + * 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#27052 — `useWorkspacePreference`, the live read: the fallback + * while there is no choice, every caller of the same preference and scope + * re-rendered by a write, a scope apart from every other, another tab's write + * heard through the `storage` event, and a choice a refusing storage would + * not keep held for this page. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, cleanup, act, fireEvent } from '@testing-library/react'; +import { + RECORD_TIMELINE_COLUMN_PREFERENCE, + objectPreferenceScope, + useWorkspacePreference, + workspacePreferenceStorageKey, + type PreferenceSwitch, + type WorkspacePreferenceScope, +} from './workspacePreference'; + +const LEAD = objectPreferenceScope('crm_lead'); +const PRODUCT = objectPreferenceScope('crm_product'); +const LEAD_KEY = workspacePreferenceStorageKey(RECORD_TIMELINE_COLUMN_PREFERENCE, LEAD); + +function Probe({ id, scope, fallback }: { id: string; scope: WorkspacePreferenceScope; fallback: PreferenceSwitch }) { + const [value, setValue] = useWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, scope, fallback); + return ( + + ); +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + window.localStorage.clear(); +}); + +describe('useWorkspacePreference (objectstack#27052)', () => { + it('answers the fallback with no choice; a write re-renders every caller of the same scope, and no other', () => { + render( + <> + + + + , + ); + expect(screen.getByTestId('a').textContent).toBe('on'); + expect(screen.getByTestId('product').textContent).toBe('off'); + fireEvent.click(screen.getByTestId('a')); + expect(screen.getByTestId('a').textContent).toBe('off'); + expect(screen.getByTestId('b').textContent).toBe('off'); + expect(screen.getByTestId('product').textContent).toBe('off'); + expect(JSON.parse(window.localStorage.getItem(LEAD_KEY)!)).toEqual({ value: 'off', v: 1 }); + }); + + it('the choice survives a remount (a reload reads the same storage)', () => { + const first = render(); + fireEvent.click(screen.getByTestId('a')); + first.unmount(); + render(); + expect(screen.getByTestId('a').textContent).toBe('off'); + }); + + it("hears another tab's write through the `storage` event", () => { + render(); + act(() => { + window.localStorage.setItem(LEAD_KEY, JSON.stringify({ value: 'off', v: 1 })); + window.dispatchEvent(new StorageEvent('storage', { key: LEAD_KEY })); + }); + expect(screen.getByTestId('a').textContent).toBe('off'); + act(() => { + window.localStorage.clear(); + window.dispatchEvent(new StorageEvent('storage', { key: null })); + }); + expect(screen.getByTestId('a').textContent).toBe('on'); + }); + + it('holds a choice a refusing storage would not keep, for this page', () => { + const real = window.localStorage; + const refusing = { + getItem: (key: string) => real.getItem(key), + setItem: () => { + throw new Error('QuotaExceededError'); + }, + }; + Object.defineProperty(window, 'localStorage', { value: refusing, configurable: true }); + try { + render(); + fireEvent.click(screen.getByTestId('a')); + expect(screen.getByTestId('a').textContent).toBe('off'); + expect(real.getItem(LEAD_KEY)).toBeNull(); + } finally { + Object.defineProperty(window, 'localStorage', { value: real, configurable: true }); + } + }); +}); diff --git a/ui/packages/app-shell/src/utils/workspacePreference.test.ts b/ui/packages/app-shell/src/utils/workspacePreference.test.ts new file mode 100644 index 00000000000..039a23d66ab --- /dev/null +++ b/ui/packages/app-shell/src/utils/workspacePreference.test.ts @@ -0,0 +1,118 @@ +/** + * 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#27052 — the console's one workspace-preference store: a + * preference × scope keyed `localStorage` entry holding a small versioned + * record, read as "no choice" whenever it cannot be read. This file runs in + * the node project (no `window`); the live hook and its cross-tab path are + * driven in `workspacePreference.hook.test.tsx`. + */ + +import { describe, it, expect } from 'vitest'; +import { + RECORD_RELATED_COLUMN_PREFERENCE, + RECORD_TIMELINE_COLUMN_PREFERENCE, + defineWorkspacePreference, + objectPreferenceScope, + readWorkspacePreference, + versionedPreferenceCodec, + workspacePreferenceStorageKey, + writeWorkspacePreference, +} from './workspacePreference'; + +const memoryStorage = () => { + const kept = new Map(); + return { + kept, + getItem: (key: string) => kept.get(key) ?? null, + setItem: (key: string, value: string) => void kept.set(key, value), + }; +}; + +describe('workspacePreference keys (objectstack#27052)', () => { + it('one key per preference × scope; the per-object scope names the object', () => { + expect(objectPreferenceScope('crm_lead')).toBe('object:crm_lead'); + expect(workspacePreferenceStorageKey(RECORD_TIMELINE_COLUMN_PREFERENCE, objectPreferenceScope('crm_lead'))).toBe( + 'os:pref:record.columns.timeline@object:crm_lead', + ); + expect(workspacePreferenceStorageKey(RECORD_RELATED_COLUMN_PREFERENCE, objectPreferenceScope('crm_lead'))).toBe( + 'os:pref:record.columns.related@object:crm_lead', + ); + const userScoped = defineWorkspacePreference({ name: 'demo.user', codec: { decode: (s) => s, encode: (v: string) => v } }); + expect(workspacePreferenceStorageKey(userScoped)).toBe('os:pref:demo.user'); + }); + + it('a preference with a spelling of its own keeps it', () => { + const legacy = defineWorkspacePreference({ + name: 'demo.legacy', + storageKey: () => 'os:demo-legacy', + codec: { decode: (s) => s, encode: (v: string) => v }, + }); + const storage = memoryStorage(); + writeWorkspacePreference(legacy, 'user', 'kept', storage); + expect(storage.kept.get('os:demo-legacy')).toBe('kept'); + }); +}); + +describe('the column switches — a versioned record (objectstack#27052)', () => { + const lead = objectPreferenceScope('crm_lead'); + const product = objectPreferenceScope('crm_product'); + + it('stores a record carrying `v`, and reads it back per object', () => { + const storage = memoryStorage(); + writeWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, lead, 'off', storage); + const stored = JSON.parse(storage.kept.get('os:pref:record.columns.timeline@object:crm_lead')!); + expect(stored).toEqual({ value: 'off', v: 1 }); + expect(readWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, lead, storage)).toBe('off'); + // Another object, and the other column, have no choice. + expect(readWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, product, storage)).toBeUndefined(); + expect(readWorkspacePreference(RECORD_RELATED_COLUMN_PREFERENCE, lead, storage)).toBeUndefined(); + writeWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, lead, 'on', storage); + expect(readWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, lead, storage)).toBe('on'); + }); + + it('reads no choice from a record of another version, a value it does not know, or a spelling that is not a record', () => { + const key = 'os:pref:record.columns.timeline@object:crm_lead'; + const storageWith = (value: string) => ({ getItem: (k: string) => (k === key ? value : null) }); + for (const spelling of ['{"value":"off","v":2}', '{"value":"off"}', '{"value":"hidden","v":1}', 'off', '[1]', 'null', '{']) { + expect(readWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, lead, storageWith(spelling))).toBeUndefined(); + } + }); + + it('reads no choice with no storage, or with one that throws; a write to either throws nothing', () => { + expect(readWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, lead, null)).toBeUndefined(); + const throwing = { getItem: () => { throw new Error('SecurityError'); } }; + expect(readWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, lead, throwing)).toBeUndefined(); + expect(() => writeWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, lead, 'off', null)).not.toThrow(); + const refusing = { setItem: () => { throw new Error('QuotaExceededError'); } }; + expect(() => writeWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, product, 'off', refusing)).not.toThrow(); + }); + + it('with no `window` (SSR — this file runs in the node project), the default storage is absent: no choice', () => { + expect(typeof window).toBe('undefined'); + expect(readWorkspacePreference(RECORD_RELATED_COLUMN_PREFERENCE, lead)).toBeUndefined(); + }); +}); + +describe('versionedPreferenceCodec (objectstack#27052)', () => { + it('a record-shaped value round-trips; `v` is written last and always', () => { + const codec = versionedPreferenceCodec<{ order: string[]; collapsed: string[] }>({ + version: 3, + toRecord: (value) => ({ ...value }), + fromRecord: (record) => + Array.isArray(record.order) && Array.isArray(record.collapsed) + ? { order: record.order as string[], collapsed: record.collapsed as string[] } + : undefined, + }); + const spelling = codec.encode({ order: ['a', 'b'], collapsed: ['b'] }); + expect(JSON.parse(spelling)).toEqual({ order: ['a', 'b'], collapsed: ['b'], v: 3 }); + expect(codec.decode(spelling)).toEqual({ order: ['a', 'b'], collapsed: ['b'] }); + expect(codec.decode('{"order":[],"collapsed":[],"v":2}')).toBeUndefined(); + }); +}); diff --git a/ui/packages/app-shell/src/utils/workspacePreference.ts b/ui/packages/app-shell/src/utils/workspacePreference.ts new file mode 100644 index 00000000000..94f65bc3a2e --- /dev/null +++ b/ui/packages/app-shell/src/utils/workspacePreference.ts @@ -0,0 +1,295 @@ +/** + * 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 { useCallback, useSyncExternalStore } from 'react'; + +/** + * The console's per-reader workspace preferences — how a reader likes the + * workspace laid out, kept per browser and never metadata (objectstack#27052). + * + * A preference is a READER's choice: which record-page columns they keep + * shown, how a drawer-mode object opens its records. An author declares none + * of it, so none of it is a key in `@objectstack/spec`; a preference that has + * no stored choice answers a default the host derives (from the object, for a + * per-object preference), and the default is the host's, never stored. + * + * ## One store, keyed by preference × scope + * + * A preference is declared once ({@link defineWorkspacePreference}) and read + * in a scope: `user` (the reader, across objects) or `object:OBJECT_NAME` + * (the reader, for one object — {@link objectPreferenceScope}). Each pair has + * one `localStorage` key ({@link workspacePreferenceStorageKey}). + * + * ## The value is a small versioned record + * + * What is stored is a JSON record carrying `v`, the shape's version + * ({@link versionedPreferenceCodec}): a reader of a newer version than it + * knows, or of an unreadable record, answers "no choice", so a shape change is + * a version bump with a migration in its codec, never a silent misread. A + * preference that already had a stored spelling before this module keeps it + * byte for byte through a codec of its own — `recordOpenPreference.ts` is the + * one today (`os:record-open-mode`, a bare `drawer` / `split`). + * + * ## Read, write, and live + * + * {@link readWorkspacePreference} / {@link writeWorkspacePreference} are the + * plain pair; {@link useWorkspacePreference} is the live one, through + * `useSyncExternalStore`: a write in this page re-renders every caller of + * the same preference and scope, and a write in another tab of the origin + * arrives through the browser's `storage` event. With no `window` (SSR), no + * storage, or a storage that throws (private mode, a blocked origin, a full + * quota), a read answers "no choice" and a write throws nothing — and a choice + * the storage would not keep is held for this page, so the reader who picks a + * value keeps reading it back until the page unloads. + */ + +/** Whose choice a preference is: the reader across objects, or the reader for one object. */ +export type WorkspacePreferenceScope = 'user' | `object:${string}`; + +/** The scope of a per-object preference for `objectName`. */ +export function objectPreferenceScope(objectName: string): WorkspacePreferenceScope { + return `object:${objectName}`; +} + +/** How a preference's value is spelled in storage. */ +export interface WorkspacePreferenceCodec { + /** The value a stored string spells, or `undefined` for one this codec does not read. */ + decode(stored: string): T | undefined; + /** The stored spelling of `value`. */ + encode(value: T): string; +} + +/** A declared preference — see {@link defineWorkspacePreference}. */ +export interface WorkspacePreference { + /** The preference's name, dotted (`record.columns.timeline`). */ + readonly name: string; + readonly codec: WorkspacePreferenceCodec; + /** + * The `localStorage` key for `scope`. Absent: the module's own spelling, + * `os:pref:NAME` for `user` and `os:pref:NAME@SCOPE` otherwise. + */ + readonly storageKey?: (scope: WorkspacePreferenceScope) => string; +} + +/** Declare a preference. Pass a module-level constant to the hooks. */ +export function defineWorkspacePreference(definition: WorkspacePreference): WorkspacePreference { + return Object.freeze({ ...definition }); +} + +/** The `localStorage` key `preference` is kept under in `scope`. */ +export function workspacePreferenceStorageKey( + preference: WorkspacePreference, + scope: WorkspacePreferenceScope = 'user', +): string { + if (preference.storageKey) return preference.storageKey(scope); + return scope === 'user' ? `os:pref:${preference.name}` : `os:pref:${preference.name}@${scope}`; +} + +/** + * The codec of a versioned record: what is stored is `{ v: version, ...fields }`. + * `fromRecord` reads a record of exactly `version` (its migration point for an + * older one, when a later version needs it), and answers `undefined` for a + * record it does not read; a record of another version, or one that is not a + * JSON object, is "no choice". + */ +export function versionedPreferenceCodec(options: { + version: number; + toRecord: (value: T) => Record; + fromRecord: (record: Record) => T | undefined; +}): WorkspacePreferenceCodec { + const { version, toRecord, fromRecord } = options; + return { + encode: (value) => JSON.stringify({ ...toRecord(value), v: version }), + decode: (stored) => { + let parsed: unknown; + try { + parsed = JSON.parse(stored); + } catch { + return undefined; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined; + const record = parsed as Record; + if (record.v !== version) return undefined; + return fromRecord(record); + }, + }; +} + +/** + * An on | off preference — a record-page column shown or not. Stored as + * `{"value":"on"|"off","v":1}`. + */ +export type PreferenceSwitch = 'on' | 'off'; + +const switchCodec = versionedPreferenceCodec({ + version: 1, + toRecord: (value) => ({ value }), + fromRecord: (record) => (record.value === 'on' || record.value === 'off' ? record.value : undefined), +}); + +/** + * Whether the record page shows its timeline column, per object + * (scope `object:OBJECT_NAME`). With no choice the host derives the default + * from the object (`recordPageColumnDefaults` in `@object-ui/plugin-detail`). + */ +export const RECORD_TIMELINE_COLUMN_PREFERENCE = defineWorkspacePreference({ + name: 'record.columns.timeline', + codec: switchCodec, +}); + +/** + * Whether the record page shows its related column, per object + * (scope `object:OBJECT_NAME`). With no choice: shown. + */ +export const RECORD_RELATED_COLUMN_PREFERENCE = defineWorkspacePreference({ + name: 'record.columns.related', + codec: switchCodec, +}); + +/** The storage surface a read takes — `localStorage`, or a test double. */ +export type WorkspacePreferenceReadStorage = Pick; + +/** The storage surface a write takes — `localStorage`, or a test double. */ +export type WorkspacePreferenceWriteStorage = Pick; + +/** The browser's `localStorage`, or `null` with no `window` or a storage that throws on access. */ +export function workspacePreferenceStorage(): Storage | null { + if (typeof window === 'undefined') return null; + try { + return window.localStorage; + } catch { + // Accessing `localStorage` itself throws on a blocked origin. + return null; + } +} + +/** + * Choices storage would not keep, held for this page by storage key: a + * browser whose storage refuses the write would otherwise have the reader + * pick a value and keep reading the old one back. An entry exists only while + * storage did not keep the choice — storage is then the one answer — and is + * cleared by the next kept write and by another tab's write. + */ +const pageChoices = new Map(); + +/** The live {@link useWorkspacePreference} callers on this page, by storage key. */ +const listeners = new Map void>>(); + +function storedSpelling(key: string, storage: WorkspacePreferenceReadStorage | null): string | null { + if (!storage) return null; + try { + return storage.getItem(key); + } catch { + return null; + } +} + +/** + * The reader's choice for `preference` in `scope` as `storage` keeps it, or + * `undefined` when it keeps none: nothing stored, a spelling the codec does + * not read, or no readable storage. `storage` defaults to the browser's + * `localStorage`; `null` says there is none. A choice held for this page only + * (storage refused it) is the live hook's, {@link useWorkspacePreference}. + */ +export function readWorkspacePreference( + preference: WorkspacePreference, + scope: WorkspacePreferenceScope = 'user', + storage: WorkspacePreferenceReadStorage | null = workspacePreferenceStorage(), +): T | undefined { + const stored = storedSpelling(workspacePreferenceStorageKey(preference, scope), storage); + return stored === null ? undefined : preference.codec.decode(stored); +} + +/** + * Keep the reader's choice for `preference` in `scope`. With no storage, or + * one that refuses the write, nothing is kept and nothing throws: the choice + * holds on this page and the next page load reads "no choice". Every live + * caller of the same preference and scope on this page hears the write; + * another tab hears it through the browser's `storage` event. + */ +export function writeWorkspacePreference( + preference: WorkspacePreference, + scope: WorkspacePreferenceScope, + next: T, + storage: WorkspacePreferenceWriteStorage | null = workspacePreferenceStorage(), +): void { + const key = workspacePreferenceStorageKey(preference, scope); + const spelling = preference.codec.encode(next); + pageChoices.set(key, spelling); + if (storage) { + try { + storage.setItem(key, spelling); + pageChoices.delete(key); + } catch { + // Not kept past this page — see `pageChoices`. + } + } + for (const listener of Array.from(listeners.get(key) ?? [])) listener(); +} + +function subscribeTo(key: string, onChange: () => void): () => void { + let keyListeners = listeners.get(key); + if (!keyListeners) { + keyListeners = new Set(); + listeners.set(key, keyListeners); + } + keyListeners.add(onChange); + const onStorage = (event: StorageEvent) => { + // `key === null` is a `clear()` in another tab. + if (event.key === key || event.key === null) { + pageChoices.delete(key); + onChange(); + } + }; + if (typeof window !== 'undefined') window.addEventListener('storage', onStorage); + return () => { + keyListeners!.delete(onChange); + if (keyListeners!.size === 0) listeners.delete(key); + if (typeof window !== 'undefined') window.removeEventListener('storage', onStorage); + }; +} + +/** + * Decoded values by storage key and the spelling they were decoded from, so a + * snapshot is the same value until the spelling changes — what + * `useSyncExternalStore` requires of a record-shaped preference. + */ +const decoded = new Map(); + +function snapshotOf(preference: WorkspacePreference, key: string): T | undefined { + const spelling = pageChoices.get(key) ?? storedSpelling(key, workspacePreferenceStorage()); + if (spelling === null) return undefined; + const cached = decoded.get(key); + if (cached && cached.spelling === spelling) return cached.value as T | undefined; + const value = preference.codec.decode(spelling); + decoded.set(key, { spelling, value }); + return value; +} + +/** + * The reader's choice for `preference` in `scope` as live state — `fallback` + * while there is none (and on the server) — and the setter that keeps a new + * one. A write here, in another component of this page, or in another tab + * re-renders every caller of the same preference and scope. + */ +export function useWorkspacePreference( + preference: WorkspacePreference, + scope: WorkspacePreferenceScope, + fallback: T, +): [T, (next: T) => void] { + const key = workspacePreferenceStorageKey(preference, scope); + const subscribe = useCallback((onChange: () => void) => subscribeTo(key, onChange), [key]); + const getSnapshot = useCallback(() => snapshotOf(preference, key), [preference, key]); + const getServerSnapshot = useCallback((): T | undefined => undefined, []); + const choice = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + const setChoice = useCallback( + (next: T) => writeWorkspacePreference(preference, scope, next), + [preference, scope], + ); + return [choice === undefined ? fallback : choice, setChoice]; +} 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 new file mode 100644 index 00000000000..114d19d77bd --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx @@ -0,0 +1,302 @@ +/** + * 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#27052 — the record page's panel toggles: whether the timeline + * and the related column are shown is each reader's switch, per object, with + * a default derived from the object and never metadata. + * + * Measured from a real `RecordDetailView` mount (the `recordPageTiers-26678` + * harness: the view root's measured width is the one input varied). + * + * - a worked object (its status options declare lifecycle kinds) opens with + * three columns; switching the timeline off draws details | related with + * the timeline folded into a 「动态」 card, and switching it on restores; + * - the choice survives a remount (a reload reads the same storage) and is + * per object; + * - 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. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, 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 LEAD = 'crm_lead'; +const PRODUCT = 'crm_product'; +const PRICE_BOOK = 'crm_price_book'; + +const BASE_FIELDS = { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, +}; + +const OBJECTS = [ + { + name: LEAD, + label: 'Lead', + pluralLabel: 'Leads', + managedBy: 'platform', + // Worked: the status options declare lifecycle kinds. + fields: { + ...BASE_FIELDS, + status: { + type: 'select', + label: 'Status', + options: [ + { value: 'new', label: 'New', kind: 'open' }, + { value: 'converted', label: 'Converted', kind: 'won' }, + ], + }, + }, + }, + { name: PRODUCT, label: 'Product', pluralLabel: 'Products', managedBy: 'platform', fields: BASE_FIELDS }, + // A container: it owns its entries through their `master_detail`, and + // declares no stage, no lifecycle kinds and no files. + { name: PRICE_BOOK, label: 'Price Book', pluralLabel: 'Price Books', managedBy: 'platform', fields: BASE_FIELDS }, + { + name: 'crm_price_book_entry', + label: 'Price Book Entry', + pluralLabel: 'Price Book Entries', + managedBy: 'platform', + fields: { ...BASE_FIELDS, price_book: { type: 'master_detail', reference: PRICE_BOOK, label: 'Price Book' } }, + }, + { + name: 'crm_note', + label: 'Note', + pluralLabel: 'Notes', + managedBy: 'platform', + fields: { + ...BASE_FIELDS, + lead: { type: 'lookup', reference: LEAD, label: 'Lead' }, + product: { type: 'lookup', reference: PRODUCT, label: 'Product' }, + }, + }, +]; + +let rootWidth = 0; + +function makeDataSource() { + return { + 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: `Record ${recordId}` })), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + }; +} + +function renderPage({ object = LEAD, width = 1440, embedded = false }: { object?: string; width?: number; embedded?: boolean } = {}) { + rootWidth = width; + const metadata = { + objects: OBJECTS, + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + } as never; + return render( + + + {}} + objectNameOverride={object} + recordIdOverride="r-1" + embedded={embedded} + /> + + , + ); +} + +const shown = (container: HTMLElement) => + container.querySelector('[data-record-columns]')?.getAttribute('data-record-columns-shown') ?? null; +const toggle = (name: RegExp) => screen.getByRole('button', { name }); +// What a browser hands the page for ⌘⇧. / ⌘⇧, on a US layout: Shift types +// `>` / `<`, and `code` names the key itself. +const CHORD_KEYS = { timeline: { key: '>', code: 'Period' }, related: { key: '<', code: 'Comma' } } as const; +const press = (chord: keyof typeof CHORD_KEYS | { key: string; code: string }, target: Element = document.body) => + fireEvent.keyDown(target, { + ...(typeof chord === 'string' ? CHORD_KEYS[chord] : chord), + metaKey: true, + shiftKey: true, + bubbles: true, + }); + +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: rootWidth, bottom: 800, width: rootWidth, height: 800, toJSON: () => ({}) } as DOMRect; + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +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(); + expect(shown(container)).toBe('details timeline related'); + expect(toggle(/^Timeline$/).getAttribute('aria-pressed')).toBe('true'); + expect(toggle(/^Related column$/).getAttribute('aria-pressed')).toBe('true'); + + fireEvent.click(toggle(/^Timeline$/)); + await waitFor(() => expect(shown(container)).toBe('details related')); + expect(toggle(/^Timeline$/).getAttribute('aria-pressed')).toBe('false'); + const related = container.querySelector('[data-record-column="related"]')!; + expect(related.querySelector('[data-record-folded-timeline]')).not.toBeNull(); + + fireEvent.click(toggle(/^Timeline$/)); + await waitFor(() => expect(shown(container)).toBe('details timeline related')); + expect(container.querySelector('[data-record-folded-timeline]')).toBeNull(); + }); + + it('the choice survives a remount and is per object', async () => { + const first = renderPage(); + fireEvent.click(await screen.findByRole('button', { name: /^Timeline$/ }, { timeout: 5000 })); + await waitFor(() => expect(shown(first.container)).toBe('details related')); + first.unmount(); + + const again = renderPage(); + await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); + expect(shown(again.container)).toBe('details related'); + expect(toggle(/^Timeline$/).getAttribute('aria-pressed')).toBe('false'); + again.unmount(); + + // The product's switch is its own: still its default. + const product = renderPage({ object: PRODUCT }); + await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); + expect(window.localStorage.getItem(`os:pref:record.columns.timeline@object:${PRODUCT}`)).toBeNull(); + expect(shown(product.container)).toBe('details related'); + }); + + it('an object that is not worked opens with the timeline off: two columns, details | related', async () => { + const { container } = renderPage({ object: PRODUCT }); + await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); + expect(shown(container)).toBe('details related'); + expect(toggle(/^Timeline$/).getAttribute('aria-pressed')).toBe('false'); + expect(container.querySelector('[data-record-folded-timeline]')).not.toBeNull(); + }); + + it('an object that is only another object\'s `master_detail` parent is not worked: the timeline is off', async () => { + const { container } = renderPage({ object: PRICE_BOOK }); + await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); + expect(shown(container)).toBe('details related'); + expect(toggle(/^Timeline$/).getAttribute('aria-pressed')).toBe('false'); + }); + + it('⌘⇧. / ⌘⇧, flip the switches; with a text field focused they do nothing', async () => { + const { container } = renderPage(); + await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); + + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + press('timeline', input); + press('related', input); + expect(shown(container)).toBe('details timeline related'); + input.remove(); + + // The browser's chords are not the page's: reopen-tab and hard-reload. + press({ key: 'T', code: 'KeyT' }); + press({ key: 'R', code: 'KeyR' }); + expect(shown(container)).toBe('details timeline related'); + expect(window.localStorage.getItem(`os:pref:record.columns.timeline@object:${LEAD}`)).toBeNull(); + + press('timeline'); + await waitFor(() => expect(shown(container)).toBe('details related')); + press('related'); + await waitFor(() => expect(shown(container)).toBe('details')); + expect(toggle(/^Related column$/).getAttribute('aria-pressed')).toBe('false'); + press('timeline'); + await waitFor(() => 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 }); + expect(toggle(/^Timeline$/).getAttribute('aria-keyshortcuts')).toMatch(/^(Meta|Control)\+Shift\+\.$/); + expect(toggle(/^Related column$/).getAttribute('aria-keyshortcuts')).toMatch(/^(Meta|Control)\+Shift\+,$/); + }); + + it('not drawn in the tabbed tier, nor in the drawer / split pane; the shortcut does nothing there', async () => { + const tabbed = renderPage({ width: 900 }); + expect(await screen.findByRole('tablist', undefined, { timeout: 5000 })).toBeTruthy(); + expect(screen.queryByRole('group', { name: 'Panels' })).toBeNull(); + press('timeline'); + expect(window.localStorage.getItem(`os:pref:record.columns.timeline@object:${LEAD}`)).toBeNull(); + tabbed.unmount(); + + renderPage({ width: 1440, embedded: true }); + expect(await screen.findByRole('tablist', undefined, { timeout: 5000 })).toBeTruthy(); + expect(screen.queryByRole('group', { name: 'Panels' })).toBeNull(); + }); +}); 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 7606dce7bb5..f2ce5d861c0 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 @@ -81,7 +81,20 @@ const OBJECTS = [ managedBy: 'platform', // History is on for this object, so the synthesized page carries it. enable: { trackHistory: true }, - fields: BASE_FIELDS, + // A worked object — its status options declare lifecycle kinds — so the + // reader's timeline switch defaults on and the wide tier is three + // columns (objectstack#27052; owning the tasks below does not count). + fields: { + ...BASE_FIELDS, + status: { + type: 'select', + label: 'Status', + options: [ + { value: 'active', label: 'Active', kind: 'open' }, + { value: 'done', label: 'Done', kind: 'won' }, + ], + }, + }, }, { name: 'showcase_task', @@ -192,7 +205,8 @@ describe('RecordDetailView — one record page at two widths (objectstack#26678) it('1100 px: two columns and the related toggle; the lists mount when the rail opens', async () => { const { container } = renderPage({ width: 1100 }); - const toggle = await screen.findByRole('button', { name: /Related/ }, { timeout: 5000 }); + // The rail toggle — not the crumb row's related-column switch (objectstack#27052). + const toggle = await screen.findByTestId('record-related-rail-toggle', undefined, { timeout: 5000 }); expect(tierOf(container)).toBe('two-column'); expect(screen.queryByRole('tablist')).toBeNull(); expect(column(container, 'details')).not.toBeNull(); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index f58d6e20fd4..9ecc36c4e1f 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -10,8 +10,8 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; -import { activityRowToFeedItem, InlineEditSaveBar, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, resolveTitleField, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; -import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions } from '@object-ui/components'; +import { activityRowToFeedItem, InlineEditSaveBar, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageColumnDefaults, resolveTitleField, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; +import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions, 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 } from 'lucide-react'; +import { Database, ChevronLeft, Lock, AlertTriangle, RotateCw, Printer, FileText, CheckCircle2, Inbox, Rows3, LayoutGrid } from 'lucide-react'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { SkeletonDetail } from '../skeletons/index.js'; import { ManagedByBadge, useManagedByNote } from '../components/ManagedByBadge.js'; @@ -69,6 +69,13 @@ import './record-approvals-renderer.js'; import { getRecordDisplayName } from '../utils/index.js'; import { parseAuditValue, collectAuditChanges, collectLookupIds, formatAuditValue } from '../utils/auditHistoryDisplay.js'; import { useFavorites } from '../hooks/useFavorites.js'; +import { + RECORD_RELATED_COLUMN_PREFERENCE, + RECORD_TIMELINE_COLUMN_PREFERENCE, + objectPreferenceScope, + useWorkspacePreference, +} from '../utils/workspacePreference.js'; +import { shortcutKeycaps, useAdvertiseShortcut, type AdvertisedShortcut, type ShortcutChord, type ShortcutLabelTranslate } from '../chrome/advertisedShortcuts.js'; import { useActionModal } from '../hooks/useActionModal.js'; import { useRecentItems } from '../hooks/useRecentItems.js'; import { useObjectPresence } from '../hooks/useObjectPresence.js'; @@ -100,6 +107,67 @@ interface RecordDetailViewProps { const FALLBACK_USER = { id: 'current-user', name: 'Demo User' }; +/** + * `⌘⇧.` / `Ctrl+Shift+.` — show or hide the record page's timeline column + * (objectstack#27052). The record page owns the listener and advertises this + * beside it, under the same gate: a full record page in a column tier. + * + * Not `⌘⇧T` / `⌘⇧R`: Chromium and Firefox keep Ctrl/⌘+Shift+T (reopen the + * closed tab) and Ctrl/⌘+Shift+R (hard reload) for themselves, and the page + * never receives them. + */ +const TOGGLE_TIMELINE_SHORTCUT: AdvertisedShortcut = { + id: 'record-toggle-timeline', + group: 'general', + chord: { key: '.', mod: true, shift: true }, + label: (t: ShortcutLabelTranslate) => t('console.shortcuts.toggleTimeline'), +}; + +/** `⌘⇧,` / `Ctrl+Shift+,` — show or hide the record page's related column (objectstack#27052). */ +const TOGGLE_RELATED_SHORTCUT: AdvertisedShortcut = { + id: 'record-toggle-related', + group: 'general', + chord: { key: ',', mod: true, shift: true }, + label: (t: ShortcutLabelTranslate) => t('console.shortcuts.toggleRelated'), +}; + +/** + * The physical key of each punctuation chord key. 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`. + */ +const PANEL_CHORD_CODES: Readonly> = { '.': 'Period', ',': 'Comma' }; + +function matchesPanelChord( + e: Pick, + chord: ShortcutChord, +): boolean { + 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; +} + +/** + * A keydown aimed at something the reader types into — a text field, a + * select, a content-editable region, an ARIA textbox: the panel shortcuts + * leave those alone, so a reader typing in the composer keeps their keys. + */ +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + if (target instanceof HTMLElement && target.isContentEditable) return true; + return target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"]') !== null; +} + +/** `aria-keyshortcuts` spelling of a chord, for the platform the page runs on. */ +function ariaKeyShortcutsOf(chord: ShortcutChord): string { + const mac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || ''); + return [chord.mod ? (mac ? 'Meta' : 'Control') : null, chord.shift ? 'Shift' : null, chord.key.toUpperCase()] + .filter(Boolean) + .join('+'); +} + /** * The `user` seeded into this view's own `` — identity plus, once * resolved, the caller's system capabilities. @@ -827,6 +895,49 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri !assignedPage && !namedPageBlocked && !(assignedSlots && (assignedSlots as { tabs?: unknown }).tabs !== undefined); const recordPageTier = useRecordPageTier(pageRootEl, { embedded: !!embedded, enabled: pageColumnsEligible }); const pageInColumnTier = recordPageTier === 'two-column' || recordPageTier === 'three-column'; + // ─── The reader's column switches (objectstack#27052) ──────────────── + // Whether the timeline and the related column take part in a column tier + // is each reader's choice, per object, kept in the console's + // workspace-preference store — never metadata. A reader who never chose + // gets the object's derived default: the timeline for a worked object + // (`recordPageColumnDefaults`), the related column always. The tabbed tier + // does not read them; the choice is kept for when the page widens. + const columnDefaults = recordPageColumnDefaults(objectDef as never); + const columnScope = objectPreferenceScope(objectName ?? ''); + const [timelineSwitch, setTimelineSwitch] = useWorkspacePreference( + RECORD_TIMELINE_COLUMN_PREFERENCE, + columnScope, + columnDefaults.timeline ? 'on' : 'off', + ); + const [relatedSwitch, setRelatedSwitch] = useWorkspacePreference( + RECORD_RELATED_COLUMN_PREFERENCE, + columnScope, + columnDefaults.related ? 'on' : 'off', + ); + const columnSwitches = { timeline: timelineSwitch === 'on', related: relatedSwitch === 'on' }; + // Which switches the toolbar draws — the columns the page has content for — + // set at render below and read by the shortcuts, so a shortcut never flips + // a switch the reader cannot see. + const drawnPanelSwitches = useRef<{ timeline: boolean; related: boolean }>({ timeline: false, related: false }); + useEffect(() => { + if (!pageInColumnTier) return undefined; + const onKeyDown = (e: KeyboardEvent) => { + if (e.defaultPrevented || e.isComposing) return; + const timeline = matchesPanelChord(e, TOGGLE_TIMELINE_SHORTCUT.chord); + const related = !timeline && matchesPanelChord(e, TOGGLE_RELATED_SHORTCUT.chord); + if (!timeline && !related) return; + if (isTypingTarget(e.target)) return; + if (timeline && !drawnPanelSwitches.current.timeline) return; + if (related && !drawnPanelSwitches.current.related) return; + e.preventDefault(); + if (timeline) setTimelineSwitch(timelineSwitch === 'on' ? 'off' : 'on'); + else setRelatedSwitch(relatedSwitch === 'on' ? 'off' : 'on'); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [pageInColumnTier, timelineSwitch, relatedSwitch, setTimelineSwitch, setRelatedSwitch]); + useAdvertiseShortcut(TOGGLE_TIMELINE_SHORTCUT, pageInColumnTier); + useAdvertiseShortcut(TOGGLE_RELATED_SHORTCUT, pageInColumnTier); const [pageRecord, setPageRecord] = useState(null); // 'idle' | 'loading' | 'loaded' | 'missing' | 'forbidden' | 'failed' — // distinguishes "haven't tried yet" from each way the read can end. The @@ -3738,6 +3849,39 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // partition cannot read, which then keeps its tabs. const pageColumns = pageInColumnTier ? buildDefaultPageColumns(renderedPage) : null; + // 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). + const panelToggleItems: PanelToggleItem[] = []; + if (pageColumns && pageColumns.timeline.length > 0) { + panelToggleItems.push({ + id: 'timeline', + label: t('detail.timeline', { defaultValue: 'Timeline' }), + icon: Rows3, + pressed: columnSwitches.timeline, + onPressedChange: (pressed) => setTimelineSwitch(pressed ? 'on' : 'off'), + shortcut: shortcutKeycaps(TOGGLE_TIMELINE_SHORTCUT.chord), + ariaKeyShortcuts: ariaKeyShortcutsOf(TOGGLE_TIMELINE_SHORTCUT.chord), + }); + } + if (pageColumns && pageColumns.related.length > 0) { + panelToggleItems.push({ + id: 'related', + // 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, + pressed: columnSwitches.related, + onPressedChange: (pressed) => setRelatedSwitch(pressed ? 'on' : 'off'), + shortcut: shortcutKeycaps(TOGGLE_RELATED_SHORTCUT.chord), + ariaKeyShortcuts: ariaKeyShortcutsOf(TOGGLE_RELATED_SHORTCUT.chord), + }); + } + drawnPanelSwitches.current = { + timeline: panelToggleItems.some((item) => item.id === 'timeline'), + related: panelToggleItems.some((item) => item.id === 'related'), + }; + // objectui#11958 (card ② of the objectstack#8346 ruling, M1, zero server) — // the record page's "print" action. The page this view renders for the // record is the page bound to it, and when that page declares `print` it is @@ -3896,12 +4040,15 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri >
- {/* The way back: the origin link, and on a page a drawer's ⤢ - opened, the way back into that drawer (objectstack#26679). - Neither 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 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. */} {!embedded && ( -
+
{originFrom?.pathname && originFrom?.label && ( {originFrom.label} )} -
+
+ {panelToggleItems.length > 0 && ( + + )}
)} @@ -4020,6 +4173,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri ; + +function Host({ initial = { timeline: true, related: false } }: { initial?: { timeline: boolean; related: boolean } }) { + const [shown, setShown] = React.useState(initial); + const items: PanelToggleItem[] = [ + { + id: 'timeline', + label: 'Timeline', + icon: Icon, + pressed: shown.timeline, + onPressedChange: (timeline) => setShown((s) => ({ ...s, timeline })), + shortcut: ['⌘', '⇧', '.'], + ariaKeyShortcuts: 'Meta+Shift+.', + }, + { + id: 'related', + label: 'Related', + icon: Icon, + pressed: shown.related, + onPressedChange: (related) => setShown((s) => ({ ...s, related })), + }, + ]; + return ; +} + +afterEach(() => cleanup()); + +describe('PanelToggles (objectstack#27052)', () => { + it('draws a named group of toggles whose pressed state is the panel shown', () => { + render(); + expect(screen.getByRole('group', { name: 'Panels' })).toBeTruthy(); + const timeline = screen.getByRole('button', { name: 'Timeline' }); + const related = screen.getByRole('button', { name: 'Related' }); + 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'); + }); + + it('a click flips its own panel and no other', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Timeline' })); + expect(screen.getByRole('button', { name: 'Timeline' }).getAttribute('aria-pressed')).toBe('false'); + expect(screen.getByRole('button', { name: 'Related' }).getAttribute('aria-pressed')).toBe('false'); + fireEvent.click(screen.getByRole('button', { name: 'Related' })); + expect(screen.getByRole('button', { name: 'Related' }).getAttribute('aria-pressed')).toBe('true'); + }); + + it('the tooltip names the panel and its shortcut', async () => { + render(); + fireEvent.focus(screen.getByRole('button', { name: 'Timeline' })); + const tip = await waitFor(() => { + const el = document.querySelector('[data-slot="kbd-group"]'); + expect(el).not.toBeNull(); + return el!; + }); + expect(Array.from(tip.querySelectorAll('kbd')).map((k) => k.textContent)).toEqual(['⌘', '⇧', '.']); + }); + + it('draws nothing with no panels', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); +}); diff --git a/ui/packages/components/src/custom/panel-toggles.tsx b/ui/packages/components/src/custom/panel-toggles.tsx new file mode 100644 index 00000000000..86f50a0f905 --- /dev/null +++ b/ui/packages/components/src/custom/panel-toggles.tsx @@ -0,0 +1,92 @@ +/** + * 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 * as React from 'react'; +import { Toggle } from '../ui/toggle'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'; +import { cn } from '../lib/utils'; +import { Kbd, KbdGroup } from './kbd'; + +/** One panel a {@link PanelToggles} group shows or hides. */ +export interface PanelToggleItem { + /** Stable identity within the group; also the button's `data-panel-toggle`. */ + id: string; + /** The panel's name — the button's accessible name and its tooltip text. */ + label: string; + /** The panel's icon. Decorative: the label names the button. */ + icon: React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }>; + /** Whether the panel is shown. */ + pressed: boolean; + onPressedChange: (pressed: boolean) => void; + /** The keycaps of the shortcut that toggles the panel, drawn in the tooltip (`['⌘', '⇧', '.']`). */ + shortcut?: readonly string[]; + /** The same shortcut for assistive technology, in `aria-keyshortcuts` syntax (`Meta+Shift+.`). */ + ariaKeyShortcuts?: string; +} + +export interface PanelTogglesProps { + items: readonly PanelToggleItem[]; + /** The group's accessible name. */ + label: string; + className?: string; +} + +/** + * A row of icon buttons that show and hide 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. + * + * Pure presentation: the host owns the state and the shortcut handlers, and + * hands in localized labels. + */ +export function PanelToggles({ items, label, className }: PanelTogglesProps) { + if (items.length === 0) return null; + return ( + +
+ {items.map((item) => { + const Icon = item.icon; + return ( + + + + + + + + {item.label} + {item.shortcut && item.shortcut.length > 0 && ( + + {item.shortcut.map((cap, index) => ( + {cap} + ))} + + )} + + + ); + })} +
+
+ ); +} diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 2ad024a7685..5abe698882a 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1446,6 +1446,10 @@ const ar = { concurrentUpdateRecordLabel: "هذا السجل", openInNewTab: "فتح في علامة تبويب جديدة", activity: "النشاط", + timeline: "الخط الزمني", + timelineFolded: "النشاط", + relatedColumn: "عمود العناصر ذات الصلة", + panelToggles: "اللوحات", history: "السجل", historyEmpty: "لا يوجد سجل بعد", editRow: "تعديل", @@ -2276,6 +2280,8 @@ const ar = { newChat: "محادثة جديدة", toggleChatsList: "تبديل قائمة المحادثات", goHome: "العودة إلى الرئيسية", + toggleTimeline: "إظهار / إخفاء الخط الزمني", + toggleRelated: "إظهار / إخفاء العناصر ذات الصلة", }, reportIndex: { title: "التقارير", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 2b8341e7c60..d619b981248 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1292,6 +1292,10 @@ const de = { concurrentUpdateRecordLabel: "diesem Datensatz", openInNewTab: "In neuem Tab öffnen", activity: "Aktivität", + timeline: "Zeitleiste", + timelineFolded: "Aktivität", + relatedColumn: "Verknüpfte Spalte", + panelToggles: "Bereiche", history: "Verlauf", historyEmpty: "Noch kein Verlauf", editRow: "Bearbeiten", @@ -2077,6 +2081,8 @@ const de = { newChat: "Neuer Chat", toggleChatsList: "Chatliste umschalten", goHome: "Zurück zur Startseite", + toggleTimeline: "Zeitleiste ein- / ausblenden", + toggleRelated: "Verknüpfte ein- / ausblenden", }, reportIndex: { title: "Berichte", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index adef92955e8..2499dbb9f28 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1478,6 +1478,10 @@ const en = { new: 'New', add: 'Add', activity: 'Activity', + timeline: 'Timeline', + timelineFolded: 'Activity', + relatedColumn: 'Related column', + panelToggles: 'Panels', history: 'History', historyEmpty: 'No history yet', editRow: 'Edit', @@ -2243,6 +2247,8 @@ const en = { newChat: 'New chat', toggleChatsList: 'Toggle conversations list', goHome: 'Back to home', + toggleTimeline: 'Show / hide timeline', + toggleRelated: 'Show / hide related', }, reportIndex: { title: 'Reports', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index d70bb257d7f..90f6ceb7cae 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1332,6 +1332,10 @@ const es = { concurrentUpdateRecordLabel: "este registro", openInNewTab: "Abrir en nueva pestaña", activity: "Actividad", + timeline: "Cronología", + timelineFolded: "Actividad", + relatedColumn: "Columna relacionada", + panelToggles: "Paneles", history: "Historial", historyEmpty: "Aún no hay historial", editRow: "Editar", @@ -2129,6 +2133,8 @@ const es = { newChat: "Nuevo chat", toggleChatsList: "Alternar lista de chats", goHome: "Volver al inicio", + toggleTimeline: "Mostrar / ocultar la cronología", + toggleRelated: "Mostrar / ocultar relacionados", }, reportIndex: { title: "Informes", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index b47970a0311..fde490cd556 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1329,6 +1329,10 @@ const fr = { concurrentUpdateRecordLabel: "cet enregistrement", openInNewTab: "Ouvrir dans un nouvel onglet", activity: "Activité", + timeline: "Chronologie", + timelineFolded: "Activité", + relatedColumn: "Colonne associée", + panelToggles: "Panneaux", history: "Historique", historyEmpty: "Aucun historique pour le moment", editRow: "Modifier", @@ -2125,6 +2129,8 @@ const fr = { newChat: "Nouvelle discussion", toggleChatsList: "Basculer la liste des discussions", goHome: "Retour à l'accueil", + toggleTimeline: "Afficher / masquer la chronologie", + toggleRelated: "Afficher / masquer les éléments associés", }, reportIndex: { title: "Rapports", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 948a3145f21..7205735d81f 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1139,6 +1139,10 @@ const ja = { new: "新規", add: "追加", activity: "アクティビティ", + timeline: "タイムライン", + timelineFolded: "アクティビティ", + relatedColumn: "関連列", + panelToggles: "パネル", editRow: "編集", deleteRow: "削除", deleteRowConfirmation: "「{{name}}」を削除してもよろしいですか?", @@ -2079,6 +2083,8 @@ const ja = { newChat: "新しいチャット", toggleChatsList: "チャット一覧を切り替え", goHome: "ホームに戻る", + toggleTimeline: "タイムラインの表示 / 非表示", + toggleRelated: "関連の表示 / 非表示", }, reportIndex: { title: "レポート", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 2b7b27eef32..205ac6b097b 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1292,6 +1292,10 @@ const ko = { concurrentUpdateRecordLabel: "이 레코드", openInNewTab: "새 탭에서 열기", activity: "활동", + timeline: "타임라인", + timelineFolded: "활동", + relatedColumn: "관련 열", + panelToggles: "패널", history: "기록", historyEmpty: "아직 기록이 없습니다", editRow: "편집", @@ -2077,6 +2081,8 @@ const ko = { newChat: "새 채팅", toggleChatsList: "채팅 목록 전환", goHome: "홈으로 돌아가기", + toggleTimeline: "타임라인 표시 / 숨기기", + toggleRelated: "관련 표시 / 숨기기", }, reportIndex: { title: "보고서", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index e550570edf1..fb54e9d90a9 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1329,6 +1329,10 @@ const pt = { concurrentUpdateRecordLabel: "este registro", openInNewTab: "Abrir em nova aba", activity: "Atividade", + timeline: "Linha do tempo", + timelineFolded: "Atividade", + relatedColumn: "Coluna relacionada", + panelToggles: "Painéis", history: "Histórico", historyEmpty: "Nenhum histórico ainda", editRow: "Editar", @@ -2124,6 +2128,8 @@ const pt = { newChat: "Nova conversa", toggleChatsList: "Alternar lista de conversas", goHome: "Voltar ao início", + toggleTimeline: "Mostrar / ocultar a linha do tempo", + toggleRelated: "Mostrar / ocultar relacionados", }, reportIndex: { title: "Relatórios", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index ae88634a670..40ebe272826 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1206,6 +1206,10 @@ const ru = { new: "Создать", add: "Добавить", activity: "Активность", + timeline: "Хронология", + timelineFolded: "Активность", + relatedColumn: "Связанная колонка", + panelToggles: "Панели", editRow: "Редактировать", deleteRow: "Удалить", deleteRowConfirmation: "Удалить «{{name}}»?", @@ -2183,6 +2187,8 @@ const ru = { newChat: "Новый чат", toggleChatsList: "Переключить список чатов", goHome: "Вернуться на главную", + toggleTimeline: "Показать / скрыть хронологию", + toggleRelated: "Показать / скрыть связанные", }, reportIndex: { title: "Отчёты", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index fd66f2691cb..b061ae8fba0 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1214,6 +1214,10 @@ const zh = { new: '新建', add: '添加', activity: '活动', + timeline: '时间线', + timelineFolded: '动态', + relatedColumn: '关联', + panelToggles: '面板', history: '历史', historyEmpty: '暂无历史记录', editRow: '编辑', @@ -1865,6 +1869,8 @@ const zh = { newChat: '新建对话', toggleChatsList: '切换对话列表', goHome: '返回首页', + toggleTimeline: '显示 / 隐藏时间线', + toggleRelated: '显示 / 隐藏相关', }, reportIndex: { title: '报表', diff --git a/ui/packages/plugin-detail/src/RecordPageColumns.tsx b/ui/packages/plugin-detail/src/RecordPageColumns.tsx index 358199778a2..c1fb1d73d53 100644 --- a/ui/packages/plugin-detail/src/RecordPageColumns.tsx +++ b/ui/packages/plugin-detail/src/RecordPageColumns.tsx @@ -10,7 +10,11 @@ import * as React from 'react'; import { Badge, Button, cn } from '@object-ui/components'; import { SchemaRenderer, toRenderableSchema } from '@object-ui/react'; import { PanelRightClose, PanelRightOpen } from 'lucide-react'; -import type { DefaultPageColumns } from './synth/recordPageColumns'; +import { + recordPageArrangementFor, + type DefaultPageColumns, + type RecordPageColumnSwitches, +} from './synth/recordPageColumns'; import { useDetailTranslation } from './useDetailTranslation'; import { RelatedListCompactContext } from './relatedListCompact'; @@ -26,9 +30,17 @@ export interface RecordPageColumnsProps { * two-column tier (where the related column is a rail that may be closed). */ 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. + */ + switches?: RecordPageColumnSwitches; className?: string; } +const ALL_COLUMNS_ON: RecordPageColumnSwitches = { timeline: true, related: true }; + /** * Grid templates by which columns have content. Literal strings, so * Tailwind's scanner emits each one. Details and timeline share the width @@ -82,114 +94,148 @@ const COLUMN_STACK = 'flex min-w-0 flex-col gap-4 [&>*:has(>[hidden]:only-child) * tier: three columns do not fit. Closed, its lists are not mounted, so they * read nothing until opened. Whether it is open is this view's ephemeral * state. + * + * 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. */ -export const RecordPageColumns: React.FC = ({ tier, columns, footer, className }) => { +export const RecordPageColumns: React.FC = ({ + tier, + columns, + footer, + switches = ALL_COLUMNS_ON, + className, +}) => { const { t } = useDetailTranslation(); const railId = React.useId(); const toggleRef = React.useRef(null); const [railOpen, setRailOpen] = React.useState(false); + const arrangement = recordPageArrangementFor(tier, switches); const hasTimeline = columns.timeline.length > 0; - const hasRelated = columns.related.length > 0; + const hasRelatedLists = columns.related.length > 0; + // objectstack#27052 — a timeline the reader switched off is not a column: + // its blocks are the related column's foot card, under the lists. + const foldTimeline = arrangement.timelineFolded && hasTimeline; + const hasRelated = hasRelatedLists || foldTimeline; + const residentTimeline = hasTimeline && arrangement.columns.includes('timeline'); + const residentRelated = hasRelated && !arrangement.relatedRail; + const relatedInRail = hasRelated && arrangement.relatedRail; const relatedLabel = t('detail.related'); + const foldedTimelineLabel = t('detail.timelineFolded'); - // The provenance footer closes the related column; a page with no related - // column (three-column tier) closes its details column with it instead. - const footerUnderDetails = tier === 'three-column' && !hasRelated; + // The provenance footer closes the related column where it is resident; a + // three-column page without one closes its details column with it instead, + // and a two-column page draws it under the columns. + const footerUnderRelated = residentRelated; + const footerUnderDetails = !residentRelated && tier === 'three-column'; + const footerUnderColumns = !footerUnderRelated && !footerUnderDetails; const detailsColumn = (
{drawNodes(columns.details)} {footerUnderDetails && footer}
); - const timelineColumn = hasTimeline ? ( + const timelineColumn = residentTimeline ? (
{drawNodes(columns.timeline)}
) : null; + const relatedContent = ( + <> + {drawRelatedNodes(columns.related)} + {foldTimeline && ( +
+

{foldedTimelineLabel}

+ {drawNodes(columns.timeline)} +
+ )} + + ); - let body: React.ReactNode; - if (tier === 'three-column') { - const template = hasTimeline - ? hasRelated ? THREE_COLUMN_TEMPLATE.all : THREE_COLUMN_TEMPLATE.noRelated - : hasRelated ? THREE_COLUMN_TEMPLATE.noTimeline : THREE_COLUMN_TEMPLATE.detailsOnly; - body = ( -
- {detailsColumn} - {timelineColumn} - {hasRelated && ( - - )} -
- ); - } else { - const closeRail = () => { - setRailOpen(false); - toggleRef.current?.focus(); - }; - body = ( - <> - {hasRelated && ( -
- -
- )} -
-
{ + setRailOpen(false); + toggleRef.current?.focus(); + }; + const body = ( + <> + {relatedInRail && ( +
+
- {hasRelated && railOpen && ( -
+ )} +
+
+ {detailsColumn} + {timelineColumn} + {residentRelated && ( + )}
- {footer} - - ); - } + {relatedInRail && railOpen && ( + + )} +
+ {footerUnderColumns && footer} + + ); return (
({ + ...(await importOriginal>()), + SchemaRenderer: ({ schema }: { schema: any }) => ( +
{schema?.properties?.objectName ?? schema?.type}
+ ), +})); + +import { RecordPageColumns } from '../RecordPageColumns'; +import { buildDefaultPageColumns } from '../synth/recordPageColumns'; +import { buildDefaultPageSchema } from '../synth/buildDefaultPageSchema'; + +const page = buildDefaultPageSchema( + { name: 'lead', label: 'Lead', fields: { name: { name: 'name', type: 'text' } } }, + { + related: [ + { objectName: 'task', relationshipField: 'lead_id', title: 'Tasks' }, + { objectName: 'note', relationshipField: 'parent_id', title: 'Notes' }, + ], + history: { entries: [] }, + }, +); +const columns = buildDefaultPageColumns(page)!; + +const column = (container: HTMLElement, name: string) => + container.querySelector(`[data-record-column="${name}"]`) as HTMLElement | null; +const nodesIn = (el: Element | null) => + Array.from(el?.querySelectorAll('[data-node]') ?? []).map((n) => n.getAttribute('data-node')); +const shown = (container: HTMLElement) => + container.querySelector('[data-record-columns]')?.getAttribute('data-record-columns-shown'); + +afterEach(() => cleanup()); + +describe('RecordPageColumns — the column switches (objectstack#27052)', () => { + it('both on draws what it drew before', () => { + const { container } = render( + , + ); + expect(shown(container)).toBe('details timeline related'); + expect(container.querySelector('[data-record-folded-timeline]')).toBeNull(); + }); + + it('timeline off, three columns: details | related, the timeline folded into its 「动态」 card under the lists', () => { + const { container } = render( + meta

} + />, + ); + expect(shown(container)).toBe('details related'); + expect(column(container, 'timeline')).toBeNull(); + const related = column(container, 'related')!; + const folded = related.querySelector('[data-record-folded-timeline]')!; + expect(folded).not.toBeNull(); + expect(folded.getAttribute('aria-label')).toBe('Activity'); + expect(nodesIn(folded)).toEqual(['record:discussion', 'record:history']); + // Lists first, the folded card after them, the footer last. + expect(nodesIn(related)).toEqual(['record:related_list', 'record:related_list', 'record:discussion', 'record:history']); + expect(related.lastElementChild?.getAttribute('data-testid')).toBe('meta-footer'); + // The related lists stay siblings of one parent (one empty-fold stack). + const lists = related.querySelectorAll('[data-node="record:related_list"]'); + expect(lists[0].parentElement).toBe(lists[1].parentElement); + }); + + it('timeline off, two columns: details | related resident, no rail toggle', () => { + const { container } = render( + , + ); + expect(shown(container)).toBe('details related'); + expect(screen.queryByTestId('record-related-rail-toggle')).toBeNull(); + expect(within(column(container, 'related')!).getAllByText('task')).toHaveLength(1); + }); + + it('related off, three columns: details | timeline, the related column behind the rail toggle', () => { + 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', () => { + 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(); + }); +}); diff --git a/ui/packages/plugin-detail/src/index.tsx b/ui/packages/plugin-detail/src/index.tsx index 432dbb5d166..21796eb75a8 100644 --- a/ui/packages/plugin-detail/src/index.tsx +++ b/ui/packages/plugin-detail/src/index.tsx @@ -198,10 +198,21 @@ export { RECORD_PAGE_THREE_COLUMN_MIN_WIDTH, RECORD_PAGE_TWO_COLUMN_MIN_WIDTH, recordPageTierFor, + recordPageArrangementFor, + recordPageColumnDefaults, + recordTimelineDefaultReason, recordPageColumnOfTab, buildDefaultPageColumns, } from './synth/recordPageColumns'; -export type { RecordPageTier, RecordPageColumn, DefaultPageColumns } from './synth/recordPageColumns'; +export type { + RecordPageTier, + RecordPageColumn, + DefaultPageColumns, + RecordPageColumnSwitches, + RecordPageArrangement, + RecordPageObjectLike, + RecordTimelineDefaultReason, +} from './synth/recordPageColumns'; export { useRecordPageTier } from './useRecordPageTier'; export type { UseRecordPageTierOptions } from './useRecordPageTier'; export { RecordPageColumns } from './RecordPageColumns'; 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 new file mode 100644 index 00000000000..163dab65246 --- /dev/null +++ b/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.switches-27052.test.ts @@ -0,0 +1,124 @@ +/** + * 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#27052 — the record page's column switches: the default a + * reader who never chose gets, derived from the object with no key of its + * own (one row per branch of `recordTimelineDefaultReason`), and how a column + * tier re-flows when a switch is off (`recordPageArrangementFor`). + */ + +import { describe, it, expect } from 'vitest'; +import { + recordPageArrangementFor, + recordPageColumnDefaults, + recordTimelineDefaultReason, +} from '../recordPageColumns'; + +const lead = { + name: 'crm_lead', + fields: { + status: { + type: 'select', + options: [ + { value: 'new', label: 'New', kind: 'open' }, + { value: 'converted', label: 'Converted', kind: 'won' }, + ], + }, + }, +}; + +describe('recordTimelineDefaultReason — the timeline is on for a worked object (objectstack#27052)', () => { + it('stage-field: `stageField` names a field', () => { + expect(recordTimelineDefaultReason({ stageField: 'phase', fields: {} })).toBe('stage-field'); + }); + + it('option-kinds: a field whose options declare a lifecycle kind', () => { + expect(recordTimelineDefaultReason(lead)).toBe('option-kinds'); + // An array-shaped field list reads the same. + expect(recordTimelineDefaultReason({ fields: Object.values(lead.fields) })).toBe('option-kinds'); + }); + + it('`stageField: false` turns the stage role off: option kinds no longer count', () => { + expect(recordTimelineDefaultReason({ ...lead, stageField: false })).toBeNull(); + }); + + it('files: `enable.files: true`', () => { + expect(recordTimelineDefaultReason({ enable: { files: true }, fields: {} })).toBe('files'); + expect(recordTimelineDefaultReason({ enable: { files: false }, fields: {} })).toBeNull(); + }); + + it('a `master_detail` parent and nothing else is off: owning child rows is a container, not a worked record', () => { + const priceBook = { name: 'price_book', fields: { name: { type: 'text' } } }; + const entry = { name: 'price_book_entry', fields: { price_book: { type: 'master_detail', reference: 'price_book' } } }; + expect(recordTimelineDefaultReason(priceBook)).toBeNull(); + expect(recordPageColumnDefaults(priceBook)).toEqual({ timeline: false, related: true }); + // The child that holds the `master_detail` is not worked by it either. + expect(recordTimelineDefaultReason(entry)).toBeNull(); + }); + + it('none of them: off — a product, a price-book entry', () => { + const product = { name: 'product', fields: { name: { type: 'text' }, family: { type: 'select', options: [{ value: 'a', label: 'A' }] } } }; + expect(recordTimelineDefaultReason(product)).toBeNull(); + expect(recordTimelineDefaultReason(undefined)).toBeNull(); + }); +}); + +describe('recordPageColumnDefaults (objectstack#27052)', () => { + it('the timeline follows the derivation; the related column is on', () => { + expect(recordPageColumnDefaults(lead)).toEqual({ timeline: true, related: true }); + expect(recordPageColumnDefaults({ fields: {} })).toEqual({ timeline: false, related: true }); + }); +}); + +describe('recordPageArrangementFor — a switched-off column frees its room (objectstack#27052)', () => { + it('three columns, both on: details | timeline | related', () => { + expect(recordPageArrangementFor('three-column', { timeline: true, related: true })).toEqual({ + columns: ['details', 'timeline', 'related'], + timelineFolded: false, + relatedRail: false, + }); + }); + + it('three columns, timeline off: details | related, the timeline folded into it', () => { + expect(recordPageArrangementFor('three-column', { timeline: false, related: true })).toEqual({ + columns: ['details', 'related'], + timelineFolded: true, + relatedRail: false, + }); + }); + + it('three columns, related off: details | timeline, the related column in the rail', () => { + expect(recordPageArrangementFor('three-column', { timeline: true, related: false })).toEqual({ + columns: ['details', 'timeline'], + timelineFolded: false, + relatedRail: true, + }); + }); + + it('two columns: details | timeline with the rail, or details | related when the timeline is off', () => { + expect(recordPageArrangementFor('two-column', { timeline: true, related: true })).toEqual({ + columns: ['details', 'timeline'], + timelineFolded: false, + relatedRail: true, + }); + expect(recordPageArrangementFor('two-column', { timeline: false, related: true })).toEqual({ + columns: ['details', 'related'], + timelineFolded: true, + relatedRail: false, + }); + }); + + it('both off: details alone, the related column (timeline folded in) in the rail', () => { + expect(recordPageArrangementFor('three-column', { timeline: false, related: false })).toEqual({ + columns: ['details'], + timelineFolded: true, + relatedRail: true, + }); + }); +}); diff --git a/ui/packages/plugin-detail/src/synth/recordPageColumns.ts b/ui/packages/plugin-detail/src/synth/recordPageColumns.ts index 5a16cb1de05..3f8db83bfe6 100644 --- a/ui/packages/plugin-detail/src/synth/recordPageColumns.ts +++ b/ui/packages/plugin-detail/src/synth/recordPageColumns.ts @@ -66,6 +66,121 @@ export function recordPageTierFor(width: number, options: { embedded?: boolean } /** The three resident columns of the wide record page. */ export type RecordPageColumn = 'details' | 'timeline' | 'related'; +/** + * Which of the two optional columns a reader keeps shown — the reader's own + * switches (objectstack#27052), kept per object in the console's + * workspace-preference store; never metadata. Details is always shown. + */ +export interface RecordPageColumnSwitches { + timeline: boolean; + related: boolean; +} + +/** + * How a column tier lays the page out for the reader's switches + * (objectstack#27052). The tier says how many columns the WIDTH fits; the + * switches say which columns take part. A column that is off is not drawn: + * + * - 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. + * + * 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. + */ +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. */ + relatedRail: boolean; +} + +/** + * The arrangement of a column tier for the reader's switches. A pure reading + * of the two inputs, so the page re-flows by the same rule wherever it is + * drawn: a column that is off frees its room for the next. + */ +export function recordPageArrangementFor( + tier: 'two-column' | 'three-column', + switches: RecordPageColumnSwitches, +): RecordPageArrangement { + const room = tier === 'three-column' ? 3 : 2; + const wanted: RecordPageColumn[] = ['details']; + if (switches.timeline) wanted.push('timeline'); + if (switches.related) wanted.push('related'); + const columns = wanted.slice(0, room); + return { + columns, + timelineFolded: !switches.timeline, + relatedRail: !columns.includes('related'), + }; +} + +/** The object-definition shape {@link recordPageColumnDefaults} reads. */ +export interface RecordPageObjectLike { + stageField?: unknown; + enable?: { files?: unknown } | null; + fields?: Record | unknown[] | null; +} + +function fieldsOf(object: RecordPageObjectLike | null | undefined): Array> { + const fields = object?.fields; + const list = Array.isArray(fields) ? fields : fields && typeof fields === 'object' ? Object.values(fields) : []; + return list.filter((f): f is Record => !!f && typeof f === 'object' && !Array.isArray(f)); +} + +/** Why {@link recordPageColumnDefaults} turned the timeline on — or `null`, off. */ +export type RecordTimelineDefaultReason = 'stage-field' | 'option-kinds' | 'files' | null; + +/** + * Why an object is "worked" — the reason its record page shows the timeline + * by default — or `null` when it is not (objectstack#27052, the maintainer's + * ruling that the timeline suits only some objects and its presence is each + * reader's switch with an object-derived default). Read off the object as it + * is declared, with no key of its own, in this order: + * + * 1. `stageField` names a field — the object declares a lifecycle; + * 2. otherwise (and unless `stageField: false` turned the stage role off), + * a field whose options declare a lifecycle `kind` (`open` / `won` / + * `lost`) — the same declaration a stage path reads; + * 3. `enable.files: true` — records collect attachments, work products. + * + * Only what the object declares of itself counts. Being the `master_detail` + * parent of another object does not: a parent that merely owns child rows (a + * price book, a campaign, an event) is a container, not a worked record, and + * its middle column would be an empty stream. + */ +export function recordTimelineDefaultReason( + object: RecordPageObjectLike | null | undefined, +): RecordTimelineDefaultReason { + if (!object) return null; + if (typeof object.stageField === 'string' && object.stageField.length > 0) return 'stage-field'; + if (object.stageField !== false) { + const declaresKinds = fieldsOf(object).some( + (field) => + Array.isArray(field.options) && + field.options.some((option) => !!option && typeof option === 'object' && (option as { kind?: unknown }).kind !== undefined), + ); + if (declaresKinds) return 'option-kinds'; + } + if (object.enable?.files === true) return 'files'; + return null; +} + +/** + * The column switches a reader who never chose gets for `object`: the + * timeline on when the object is worked ({@link recordTimelineDefaultReason}), + * the related column on. + */ +export function recordPageColumnDefaults(object: RecordPageObjectLike | null | undefined): RecordPageColumnSwitches { + return { timeline: recordTimelineDefaultReason(object) !== null, related: true }; +} + /** * The column a synthesized tab lands in, by the tab's stable `value`; `null` * for a value the synthesizer does not emit. diff --git a/ui/packages/plugin-detail/src/useDetailTranslation.ts b/ui/packages/plugin-detail/src/useDetailTranslation.ts index 1765a526e58..46f12b73541 100644 --- a/ui/packages/plugin-detail/src/useDetailTranslation.ts +++ b/ui/packages/plugin-detail/src/useDetailTranslation.ts @@ -103,6 +103,10 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.new': 'New', 'detail.noValue': 'No value', 'detail.activity': 'Activity', + // objectstack#27052 — the record page's timeline switch, and the card the + // timeline's blocks fold into when the reader switches the column off. + 'detail.timeline': 'Timeline', + 'detail.timelineFolded': 'Activity', 'detail.copyRecordId': 'Copy record ID', 'detail.showEmptyFields': 'Show {{count}} empty fields', 'detail.showEmptyFields_one': 'Show {{count}} empty field',