From 69a3fda4aa56be81b54cac56e2fbf83b65e5dc09 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 05:41:07 +0000 Subject: [PATCH 01/10] feat(app-shell): one workspace-preference store, preference x scope, with recordOpenPreference as its alias Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Q4EjjR2sbXvZiQ3HsgSBhb --- .../src/utils/recordOpenPreference.ts | 101 ++---- .../utils/workspacePreference.live.test.tsx | 105 +++++++ .../src/utils/workspacePreference.test.ts | 118 +++++++ .../src/utils/workspacePreference.ts | 295 ++++++++++++++++++ 4 files changed, 550 insertions(+), 69 deletions(-) create mode 100644 ui/packages/app-shell/src/utils/workspacePreference.live.test.tsx create mode 100644 ui/packages/app-shell/src/utils/workspacePreference.test.ts create mode 100644 ui/packages/app-shell/src/utils/workspacePreference.ts 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.live.test.tsx b/ui/packages/app-shell/src/utils/workspacePreference.live.test.tsx new file mode 100644 index 00000000000..7df5de0d02f --- /dev/null +++ b/ui/packages/app-shell/src/utils/workspacePreference.live.test.tsx @@ -0,0 +1,105 @@ +/** + * 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, +} 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: `object:${string}`; 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..354d7706c5e --- /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.live.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]; +} From 2e301cf4d17f1ddbd881e2731f92a476ef89e612 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 05:46:57 +0000 Subject: [PATCH 02/10] feat(plugin-detail,components,app-shell,i18n): the record page's timeline and related columns are the reader's switches, per object, drawn as panel toggles on the crumb row Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Q4EjjR2sbXvZiQ3HsgSBhb --- ...cordDetailView.panelToggles-27052.test.tsx | 268 ++++++++++++++++++ .../app-shell/src/views/RecordDetailView.tsx | 167 ++++++++++- ui/packages/components/src/custom/index.ts | 1 + .../src/custom/panel-toggles.test.tsx | 85 ++++++ .../components/src/custom/panel-toggles.tsx | 90 ++++++ ui/packages/i18n/src/locales/ar.ts | 5 + ui/packages/i18n/src/locales/de.ts | 5 + ui/packages/i18n/src/locales/en.ts | 5 + ui/packages/i18n/src/locales/es.ts | 5 + ui/packages/i18n/src/locales/fr.ts | 5 + ui/packages/i18n/src/locales/ja.ts | 5 + ui/packages/i18n/src/locales/ko.ts | 5 + ui/packages/i18n/src/locales/pt.ts | 5 + ui/packages/i18n/src/locales/ru.ts | 5 + ui/packages/i18n/src/locales/zh.ts | 5 + .../plugin-detail/src/RecordPageColumns.tsx | 218 ++++++++------ .../RecordPageColumns.switches-27052.test.tsx | 111 ++++++++ ui/packages/plugin-detail/src/index.tsx | 13 +- .../recordPageColumns.switches-27052.test.ts | 128 +++++++++ .../src/synth/recordPageColumns.ts | 125 ++++++++ .../plugin-detail/src/useDetailTranslation.ts | 4 + 21 files changed, 1162 insertions(+), 98 deletions(-) create mode 100644 ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx create mode 100644 ui/packages/components/src/custom/panel-toggles.test.tsx create mode 100644 ui/packages/components/src/custom/panel-toggles.tsx create mode 100644 ui/packages/plugin-detail/src/__tests__/RecordPageColumns.switches-27052.test.tsx create mode 100644 ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.switches-27052.test.ts 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..3fe423653a2 --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx @@ -0,0 +1,268 @@ +/** + * 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; + * - `⌘⇧T` / `⌘⇧R` flip the switches, and do nothing while a text field has + * focus; + * - 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 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 }, + { + 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 }); +const press = (key: string, target: EventTarget = document.body) => + fireEvent.keyDown(target, { key, 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$/).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('⌘⇧T / ⌘⇧R 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('T', input); + press('R', input); + expect(shown(container)).toBe('details timeline related'); + input.remove(); + + press('T'); + await waitFor(() => expect(shown(container)).toBe('details related')); + press('R'); + await waitFor(() => expect(shown(container)).toBe('details')); + expect(toggle(/^Related$/).getAttribute('aria-pressed')).toBe('false'); + press('T'); + 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\+T$/); + expect(toggle(/^Related$/).getAttribute('aria-keyshortcuts')).toMatch(/^(Meta|Control)\+Shift\+R$/); + }); + + 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('T'); + 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.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 2db4cf58cd1..e06cc5c7be7 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'; @@ -67,6 +67,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'; @@ -98,6 +105,55 @@ interface RecordDetailViewProps { const FALLBACK_USER = { id: 'current-user', name: 'Demo User' }; +/** + * `⌘⇧T` / `Ctrl+Shift+T` — 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. + */ +const TOGGLE_TIMELINE_SHORTCUT: AdvertisedShortcut = { + id: 'record-toggle-timeline', + group: 'general', + chord: { key: 't', mod: true, shift: true }, + label: (t: ShortcutLabelTranslate) => t('console.shortcuts.toggleTimeline'), +}; + +/** `⌘⇧R` / `Ctrl+Shift+R` — show or hide the record page's related column (objectstack#27052). */ +const TOGGLE_RELATED_SHORTCUT: AdvertisedShortcut = { + id: 'record-toggle-related', + group: 'general', + chord: { key: 'r', mod: true, shift: true }, + label: (t: ShortcutLabelTranslate) => t('console.shortcuts.toggleRelated'), +}; + +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; + return 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. @@ -825,6 +881,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, objects); + 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 @@ -3730,6 +3829,37 @@ 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'), + 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', + label: t('detail.related'), + 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 @@ -3888,14 +4018,28 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri >
- {originFrom?.pathname && originFrom?.label && ( - - - {originFrom.label} - + {/* The crumb row: the way back on the left, and — on a page in + a column tier — the reader's panel toggles on the right + (objectstack#27052). */} + {((originFrom?.pathname && originFrom?.label) || panelToggleItems.length > 0) && ( +
+ {originFrom?.pathname && originFrom?.label && ( + + + {originFrom.label} + + )} + {panelToggleItems.length > 0 && ( + + )} +
)} {/* objectui#12906 — the print page's screen-only chrome. A page that declares `print` holds only the printable component @@ -4001,6 +4145,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: ['⌘', '⇧', 'T'], + ariaKeyShortcuts: 'Meta+Shift+T', + }, + { + 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+T'); + // The pressed state is tinted. + expect(timeline.className).toContain('data-[state=on]:text-primary'); + expect(timeline.getAttribute('data-state')).toBe('on'); + }); + + 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(['⌘', '⇧', 'T']); + }); + + 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..296d3f82d09 --- /dev/null +++ b/ui/packages/components/src/custom/panel-toggles.tsx @@ -0,0 +1,90 @@ +/** + * 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 (`['⌘', '⇧', 'T']`). */ + shortcut?: readonly string[]; + /** The same shortcut for assistive technology, in `aria-keyshortcuts` syntax (`Meta+Shift+T`). */ + 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 78c176f8c4a..85967e86d66 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1441,6 +1441,9 @@ const ar = { concurrentUpdateRecordLabel: "هذا السجل", openInNewTab: "فتح في علامة تبويب جديدة", activity: "النشاط", + timeline: "الخط الزمني", + timelineFolded: "النشاط", + panelToggles: "اللوحات", history: "السجل", historyEmpty: "لا يوجد سجل بعد", editRow: "تعديل", @@ -2271,6 +2274,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 b3ce0ae5a3d..b87a29f6ea9 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1287,6 +1287,9 @@ const de = { concurrentUpdateRecordLabel: "diesem Datensatz", openInNewTab: "In neuem Tab öffnen", activity: "Aktivität", + timeline: "Zeitleiste", + timelineFolded: "Aktivität", + panelToggles: "Bereiche", history: "Verlauf", historyEmpty: "Noch kein Verlauf", editRow: "Bearbeiten", @@ -2072,6 +2075,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 471caa66034..033f8526750 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1471,6 +1471,9 @@ const en = { new: 'New', add: 'Add', activity: 'Activity', + timeline: 'Timeline', + timelineFolded: 'Activity', + panelToggles: 'Panels', history: 'History', historyEmpty: 'No history yet', editRow: 'Edit', @@ -2229,6 +2232,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 2ce0bfe0e91..71cf865f056 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1327,6 +1327,9 @@ const es = { concurrentUpdateRecordLabel: "este registro", openInNewTab: "Abrir en nueva pestaña", activity: "Actividad", + timeline: "Cronología", + timelineFolded: "Actividad", + panelToggles: "Paneles", history: "Historial", historyEmpty: "Aún no hay historial", editRow: "Editar", @@ -2124,6 +2127,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 80ce043e483..2dde8875754 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1324,6 +1324,9 @@ const fr = { concurrentUpdateRecordLabel: "cet enregistrement", openInNewTab: "Ouvrir dans un nouvel onglet", activity: "Activité", + timeline: "Chronologie", + timelineFolded: "Activité", + panelToggles: "Panneaux", history: "Historique", historyEmpty: "Aucun historique pour le moment", editRow: "Modifier", @@ -2120,6 +2123,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 f9ac1f7f32e..e47d3338cbc 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1134,6 +1134,9 @@ const ja = { new: "新規", add: "追加", activity: "アクティビティ", + timeline: "タイムライン", + timelineFolded: "アクティビティ", + panelToggles: "パネル", editRow: "編集", deleteRow: "削除", deleteRowConfirmation: "「{{name}}」を削除してもよろしいですか?", @@ -2074,6 +2077,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 55ffb353162..7c36a888303 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1287,6 +1287,9 @@ const ko = { concurrentUpdateRecordLabel: "이 레코드", openInNewTab: "새 탭에서 열기", activity: "활동", + timeline: "타임라인", + timelineFolded: "활동", + panelToggles: "패널", history: "기록", historyEmpty: "아직 기록이 없습니다", editRow: "편집", @@ -2072,6 +2075,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 709c4981bde..a74939075cd 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1324,6 +1324,9 @@ const pt = { concurrentUpdateRecordLabel: "este registro", openInNewTab: "Abrir em nova aba", activity: "Atividade", + timeline: "Linha do tempo", + timelineFolded: "Atividade", + panelToggles: "Painéis", history: "Histórico", historyEmpty: "Nenhum histórico ainda", editRow: "Editar", @@ -2119,6 +2122,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 a972b5bf442..712bee540f5 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1201,6 +1201,9 @@ const ru = { new: "Создать", add: "Добавить", activity: "Активность", + timeline: "Хронология", + timelineFolded: "Активность", + panelToggles: "Панели", editRow: "Редактировать", deleteRow: "Удалить", deleteRowConfirmation: "Удалить «{{name}}»?", @@ -2178,6 +2181,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 996974c7887..7e542f39950 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1209,6 +1209,9 @@ const zh = { new: '新建', add: '添加', activity: '活动', + timeline: '时间线', + timelineFolded: '动态', + panelToggles: '面板', history: '历史', historyEmpty: '暂无历史记录', editRow: '编辑', @@ -1854,6 +1857,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..60e7e4394cc --- /dev/null +++ b/ui/packages/plugin-detail/src/synth/__tests__/recordPageColumns.switches-27052.test.ts @@ -0,0 +1,128 @@ +/** + * 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({ name: 'deal', 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({ name: 'x', 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({ name: 'account', enable: { files: true }, fields: {} })).toBe('files'); + expect(recordTimelineDefaultReason({ name: 'account', enable: { files: false }, fields: {} })).toBeNull(); + }); + + it('master-detail-parent: another object owns child records through a `master_detail` to it', () => { + const objects = [ + { name: 'quote', fields: {} }, + { name: 'quote_line', fields: { quote: { type: 'master_detail', reference: 'quote' } } }, + { name: 'note', fields: { account: { type: 'lookup', reference: 'account' } } }, + ]; + expect(recordTimelineDefaultReason(objects[0], objects)).toBe('master-detail-parent'); + // A lookup is not ownership. + expect(recordTimelineDefaultReason({ name: 'account', fields: {} }, objects)).toBeNull(); + // The child itself is not a parent. + expect(recordTimelineDefaultReason(objects[1], objects)).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, [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({ name: 'product', 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..9069e22d76e 100644 --- a/ui/packages/plugin-detail/src/synth/recordPageColumns.ts +++ b/ui/packages/plugin-detail/src/synth/recordPageColumns.ts @@ -66,6 +66,131 @@ 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 { + name?: string; + 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' | 'master-detail-parent' | 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; + * 4. some object in `objects` has a `master_detail` field whose `reference` + * is this object — its records own child work. + */ +export function recordTimelineDefaultReason( + object: RecordPageObjectLike | null | undefined, + objects: readonly unknown[] = [], +): 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'; + const name = object.name; + if (typeof name === 'string' && name.length > 0) { + const parent = objects.some((other) => + fieldsOf(other as RecordPageObjectLike).some((field) => field.type === 'master_detail' && field.reference === name), + ); + if (parent) return 'master-detail-parent'; + } + 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. `objects` is the app's object list, read for the + * `master_detail` parent branch. + */ +export function recordPageColumnDefaults( + object: RecordPageObjectLike | null | undefined, + objects: readonly unknown[] = [], +): RecordPageColumnSwitches { + return { timeline: recordTimelineDefaultReason(object, objects) !== 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', From 01b76c88b40732e73e80c9c523afc4c44e0a6b6a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 05:49:54 +0000 Subject: [PATCH 03/10] fix(app-shell,components): panel toggles carry their default labels and tint by aria-pressed; changeset Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Q4EjjR2sbXvZiQ3HsgSBhb --- .changeset/27052-workspace-preferences.md | 19 +++++++++++++++++++ .../app-shell/src/views/RecordDetailView.tsx | 6 +++--- .../src/custom/panel-toggles.test.tsx | 3 +-- .../components/src/custom/panel-toggles.tsx | 4 +++- 4 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 .changeset/27052-workspace-preferences.md diff --git a/.changeset/27052-workspace-preferences.md b/.changeset/27052-workspace-preferences.md new file mode 100644 index 00000000000..96bcc425aa5 --- /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`), when it sets `enable.files: true`, or when another object has a `master_detail` field to it. `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.** `⌘⇧T` / `Ctrl+Shift+T` toggles the timeline and `⌘⇧R` / `Ctrl+Shift+R` the related column. 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/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index e06cc5c7be7..c67e661d386 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -3836,7 +3836,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri if (pageColumns && pageColumns.timeline.length > 0) { panelToggleItems.push({ id: 'timeline', - label: t('detail.timeline'), + label: t('detail.timeline', { defaultValue: 'Timeline' }), icon: Rows3, pressed: columnSwitches.timeline, onPressedChange: (pressed) => setTimelineSwitch(pressed ? 'on' : 'off'), @@ -3847,7 +3847,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri if (pageColumns && pageColumns.related.length > 0) { panelToggleItems.push({ id: 'related', - label: t('detail.related'), + label: t('detail.related', { defaultValue: 'Related' }), icon: LayoutGrid, pressed: columnSwitches.related, onPressedChange: (pressed) => setRelatedSwitch(pressed ? 'on' : 'off'), @@ -4035,7 +4035,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri {panelToggleItems.length > 0 && ( )} diff --git a/ui/packages/components/src/custom/panel-toggles.test.tsx b/ui/packages/components/src/custom/panel-toggles.test.tsx index 5741de85b7b..ac7110a1311 100644 --- a/ui/packages/components/src/custom/panel-toggles.test.tsx +++ b/ui/packages/components/src/custom/panel-toggles.test.tsx @@ -54,8 +54,7 @@ describe('PanelToggles (objectstack#27052)', () => { expect(related.getAttribute('aria-pressed')).toBe('false'); expect(timeline.getAttribute('aria-keyshortcuts')).toBe('Meta+Shift+T'); // The pressed state is tinted. - expect(timeline.className).toContain('data-[state=on]:text-primary'); - expect(timeline.getAttribute('data-state')).toBe('on'); + expect(timeline.className).toContain('aria-pressed:text-primary'); }); 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 296d3f82d09..09af6d7b26d 100644 --- a/ui/packages/components/src/custom/panel-toggles.tsx +++ b/ui/packages/components/src/custom/panel-toggles.tsx @@ -64,7 +64,9 @@ export function PanelToggles({ items, label, className }: PanelTogglesProps) { aria-keyshortcuts={item.ariaKeyShortcuts} className={cn( 'h-8 min-w-8 px-2 text-muted-foreground', - 'data-[state=on]:bg-primary/10 data-[state=on]:text-primary', + // `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', )} data-panel-toggle={item.id} > From bf9c0d5d286ebc399dc51b51e273d483e0e27b20 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 05:59:36 +0000 Subject: [PATCH 04/10] test(app-shell): type the preference-scope probe and the shortcut target Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Q4EjjR2sbXvZiQ3HsgSBhb --- .../app-shell/src/utils/workspacePreference.live.test.tsx | 3 ++- .../src/views/RecordDetailView.panelToggles-27052.test.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/packages/app-shell/src/utils/workspacePreference.live.test.tsx b/ui/packages/app-shell/src/utils/workspacePreference.live.test.tsx index 7df5de0d02f..5eb467e11ee 100644 --- a/ui/packages/app-shell/src/utils/workspacePreference.live.test.tsx +++ b/ui/packages/app-shell/src/utils/workspacePreference.live.test.tsx @@ -23,13 +23,14 @@ import { 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: `object:${string}`; fallback: PreferenceSwitch }) { +function Probe({ id, scope, fallback }: { id: string; scope: WorkspacePreferenceScope; fallback: PreferenceSwitch }) { const [value, setValue] = useWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, scope, fallback); return (