Skip to content

Commit 9dacccf

Browse files
objectstack-fleet[bot]claudehotlong
authored
feat(app-shell,plugin-detail,components,i18n): the record page's timeline and related columns are each reader's per-object switches, toggled from the crumb row (#27120)
Fixes #27052 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:记录页的「时间线」「关联」两列变成读者自己的开关,按对象记住;默认值从对象推导(被工作的对象开时间线,否则关),不是元数据。crumb 行右侧多了一组图标开关(☴ 时间线 ⌘⇧T、⊞ 关联 ⌘⇧R),关掉时间线时它的内容折进关联列底部的「动态」卡。 - **为什么改**:维护者裁决「时间线是否显示,是否可以切换……顶部有很多切换图标」——产品、价格表这类对象的中间列是一条空的流和一个输入框。 - **风险与回滚**:只动前端(app-shell / plugin-detail / components / i18n),无 spec、无元数据键;回滚即 revert 本 PR。偏好存在浏览器 localStorage,回滚后旧键残留无害。 - **先看**:`ui/packages/plugin-detail/src/synth/recordPageColumns.ts`(默认值推导与排布规则)、`ui/packages/app-shell/src/views/RecordDetailView.tsx`(crumb 行与快捷键)、`ui/packages/app-shell/src/utils/workspacePreference.ts`(偏好存储)。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx`,预期 6 passed;或看下方证据分支的三张截图。 - **需要你做的**:无,席位已裁两条 open questions(见下方 Resolved)并落地。 ## What changes - **One workspace-preference store (`app-shell/src/utils/workspacePreference.ts`).** A preference is declared once and read per scope: `user`, or `object:OBJECT_NAME`. Each preference and scope pair has one `localStorage` key. The value is a small JSON record carrying `v`, and a record of another version reads as "no choice". Reads are live through `useSyncExternalStore`, other tabs arrive through the `storage` event, and with no storage (SSR, a blocked origin, a refused write) a read falls back to the default while a refused choice is held for the page. - **`recordOpenPreference` is a thin alias over it.** Its key `os:record-open-mode` and its bare `drawer` / `split` spelling are unchanged, so a reader's earlier choice reads back and the record-open preference tests pass unchanged. - **Two new preferences.** `record.columns.timeline` and `record.columns.related` take `on` / `off` per object. With no choice, `recordPageColumnDefaults` derives the default: the timeline is on for a worked object and off otherwise, and the related column is on. `recordTimelineDefaultReason` names the branch: `stageField` names a field, a field's options declare a lifecycle `kind` (unless `stageField: false`), or `enable.files: true`. Being the `master_detail` parent of another object is not a signal (seat ruling 1, PR comment 6464903026): a container that owns child rows is not a worked record. The derivation reads only the object itself, so `recordTimelineDefaultReason` / `recordPageColumnDefaults` take no object list. - **The column tiers read the switches.** `recordPageArrangementFor(tier, switches)` is the rule, and `RecordPageColumns` takes `switches`. With the timeline off, a wide page draws details | related and the timeline's blocks become an Activity card at the foot of the related column. With related off, the related column stays behind the existing rail toggle. Below the column widths the page is tabbed as before, the switches are kept, and `?tab=` is untouched. Authored `kind: full` pages and `slots.tabs` never reach this code. - **`PanelToggles` (`@object-ui/components`).** It is a named group of icon toggles with `aria-pressed`, a tinted pressed state and a tooltip showing the shortcut's keycaps. The related switch is named `detail.relatedColumn` (「关联列」), apart from the rail toggle and the Related tab, so the row never carries two controls named 「关联」. The record page draws it on its crumb row, right side, only in a column tier. It is not drawn in the drawer or split pane. - **Shortcuts.** `⌘⇧.` / `Ctrl+Shift+.` (timeline) and `⌘⇧,` / `Ctrl+Shift+,` (related) flip the switches while the full record page is in a column tier — matched by physical key (`KeyboardEvent.code` `Period` / `Comma`), since Shift changes the character those keys type. The card's `⌘⇧T` / `⌘⇧R` are browser-reserved (reopen closed tab, hard reload) and were replaced by seat ruling 2; a `⌘⇧T` / `⌘⇧R` keydown now leaves the columns and the stored preference untouched. They do nothing while a text field has focus, and they are advertised to the keyboard-shortcuts dialog under the same gate. - **i18n.** `detail.timeline`, `detail.timelineFolded`, `detail.relatedColumn`, `detail.panelToggles`, `console.shortcuts.toggleTimeline` and `console.shortcuts.toggleRelated` are added in all ten packs; no pack names a chord. ### Merge order with the record-page navigation PR The navigation PR for the record page, #27038, adds ↑ ↓ and a back link to the same crumb row. This PR does not depend on it. Whichever lands second places its controls so the row reads ↑ ↓ · divider · panel toggles. The first to land does not wait for the other. ## Tests - `workspacePreference.test.ts` covers keys per preference and scope, the versioned record, unreadable spellings, no storage and SSR. `workspacePreference.hook.test.tsx` covers the live hook: same-scope callers re-render, scopes stay apart, cross-tab `storage` events arrive, and a refusing storage holds the choice for the page. - `recordPageColumns.switches-27052.test.ts` has one row per derivation branch and per arrangement case. `RecordPageColumns.switches-27052.test.tsx` checks where each block lands when a column is off. - `panel-toggles.test.tsx` covers the group name, `aria-pressed`, the tint class, click behaviour and the tooltip keycaps. - `RecordDetailView.panelToggles-27052.test.tsx` mounts the real view. It covers the card's acceptance 1: timeline off on a worked object draws details | related with the folded card, and on restores it. The choice survives a remount and is per object. An object that is not worked opens with the timeline off. `⌘⇧T` with an input focused does nothing. Nothing is drawn in the tabbed tier or when embedded. - `recordOpenPreference.test.ts` and the two `ObjectView` record-open suites pass unchanged (acceptance 3). ## Acceptance notes - **Screenshots (acceptance 2).** They were taken on HotCRM `f660fd8`, fresh DB, with this branch's console at 1720 px wide. HotCRM's `crm_lead` carries an authored `kind: 'full'` record page, which the card leaves untouched, so the column tiers never draw there. The worked-object shots therefore use `crm_contact`, which has `enable.files: true` and no authored page. Evidence branch (never merge): `claude/issue-27052-evidence`, folder `evidence-27052/`. - `1-contact-both-columns.png`: three columns and both toggles pressed. - `1b-contact-toggle-tooltip.png`: the tooltip with its keycaps. - `2-contact-timeline-off.png`: details | related. - `3-product-default.png`: `crm_product` opens as details | related, with the timeline folded at the foot of the related column. - A browser readback on that run confirmed three more things. The contact's choice survived a reload. `Ctrl+Shift+T` sent by Playwright turned the timeline back on (that run predates ruling 2; the chord is now `Ctrl+Shift+.`, pinned in `panelToggles-27052`, not re-measured in a real browser: Playwright injects keys below the browser's accelerator layer). The root measured 1464 px. - **The drawer width is not yet read through the store.** The card asked for the `ov:drawer-width:OBJECT` key to go through the store too. That read and write live in `navigation-overlay.tsx`, which this dispatch fences off because the navigation PR edits it. It is left for after that PR lands. - **⌘K.** The command palette on main has no command registry. `CommandPaletteProvider` only opens and closes it, and `CommandPalette` hard-codes its groups. So this PR adds the shortcuts and no palette commands. - **Two private helpers repeated.** The chord matcher and the text-field check repeat `AppHeader`'s, which are private to that file. Sharing them would edit a file outside this card's surface. - **New preferences only carry `v`.** The record-open preference keeps its bare stored value without `v`, because its tests pin the stored bytes. ### HotCRM reading of the default derivation Read with `recordPageColumnDefaults` over the 20 object definitions of HotCRM `f660fd8`, loaded from source, on head `fd0f941f70` (after ruling 1): | object | timeline | branch | |:---|:---|:---| | crm_lead, crm_opportunity, crm_task, crm_case | ON | option-kinds | | crm_account, crm_contact, crm_contract, crm_quote | ON | files | | crm_campaign, crm_price_book, crm_event, crm_knowledge_article, crm_product, crm_forecast, crm_opportunity_line_item, crm_quote_line_item, crm_price_book_entry, crm_campaign_member, crm_event_attendee, crm_article_feedback | OFF | none | 8 ON / 12 OFF; the related column defaults to ON for all 20. This matches the card's expected list exactly. Before ruling 1 the four container objects (campaign, price book, event, knowledge article) were ON through a `master_detail`-parent branch that is now gone. ### Open questions — resolved by the seat (PR comment 6464903026), both landed in patch round 1 1. **The master_detail-parent branch: B, dropped.** A container that owns child rows is not a worked record (ruling 2 on the card); every object the card expects ON stays ON through stage kinds or files; the rule reads two declared signals of the object itself. Pinned by a view-level case (an object that is only another object's `master_detail` parent opens with the timeline off) and the HotCRM table above. 2. **Chords: B, moved off the browser's.** `⌘⇧.` / `⌘⇧,` (`Ctrl+Shift+.` / `Ctrl+Shift+,`), matched by physical key; tooltip keycaps, `aria-keyshortcuts` and the shortcuts dialog read the same constants. Delivery through a real browser's accelerator layer is NOT MEASURED on this host (Playwright and devtools inject below it); the page-side behaviour is pinned. ## Verification Final head `fd0f941f70`. Cloud run: report comment 6464041380 on the card (34 / 34 derived gates exit 0 on `b36136e2`, ablation of the typing-target guard). Patch round 1 (local, user-token pushes): report comment 6466430387 (34 / 34 gates on `a6d6ac6fb4` with the list re-derived identical on `fd0f941f70`; 634 files / 6348 tests across plugin-detail, i18n, components custom, app-shell utils / chrome / RecordDetailView* / ObjectView*; plugin-detail + app-shell type-check with their test tsconfigs; two reverse verifications with blob hashes and a clean `git diff HEAD`). CI on `fd0f941f70`: Test UI 17 / 17 shards, Type Check UI, Lint UI, Build & E2E UI all success. --- _Generated by [Claude Code](https://claude.ai/code/session_01Q4EjjR2sbXvZiQ3HsgSBhb)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
1 parent 4265176 commit 9dacccf

27 files changed

Lines changed: 1786 additions & 167 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/components': patch
4+
'@object-ui/plugin-detail': patch
5+
'@object-ui/i18n': patch
6+
---
7+
8+
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
9+
10+
Clause-②: no
11+
Metadata-protocol: none
12+
13+
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.
14+
15+
- **Defaults derived from the object.** A reader who never chose gets the timeline on for a "worked" object and off otherwise, and the related column on. An object is worked when `stageField` names a field, when a field's options declare a lifecycle `kind` (unless `stageField: false`), or when it sets `enable.files: true` — only what the object declares of itself. Being another object's `master_detail` parent does not count: a container that owns child rows (a price book, a campaign) is not a worked record. `recordTimelineDefaultReason` and `recordPageColumnDefaults` in `@object-ui/plugin-detail` say which.
16+
- **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.
17+
- **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.
18+
- **Shortcuts.** `⌘⇧.` / `Ctrl+Shift+.` toggles the timeline and `⌘⇧,` / `Ctrl+Shift+,` the related column, matched by the physical key. Not `⌘⇧T` / `⌘⇧R`: the browser keeps those (reopen closed tab, hard reload) and the page never receives them. They do nothing while a text field has focus, and the keyboard-shortcuts dialog lists them while the record page is open.
19+
- **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.

‎ui/packages/app-shell/src/utils/recordOpenPreference.ts‎

Lines changed: 32 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88

9-
import { useCallback, useSyncExternalStore } from 'react';
9+
import {
10+
defineWorkspacePreference,
11+
readWorkspacePreference,
12+
useWorkspacePreference,
13+
writeWorkspacePreference,
14+
workspacePreferenceStorage,
15+
} from './workspacePreference.js';
1016

1117
/**
1218
* How the console's object page opens a record a reader clicks, when the
@@ -34,6 +40,13 @@ import { useCallback, useSyncExternalStore } from 'react';
3440
* It never overrides a declaration: an object whose resolved mode is `page`,
3541
* `new_window` or `none` ignores it, because the host reads it only for a
3642
* drawer-mode record.
43+
*
44+
* A thin alias over the console's one preference store,
45+
* `workspacePreference.ts` (objectstack#27052): the preference is
46+
* {@link RECORD_OPEN_PREFERENCE} there, in the `user` scope, and it keeps the
47+
* spelling it was first stored with — a bare `drawer` / `split` under
48+
* {@link RECORD_OPEN_PREFERENCE_KEY} — so a reader's earlier choice reads
49+
* back unchanged.
3750
*/
3851
export type RecordOpenPreference = 'drawer' | 'split';
3952

@@ -49,15 +62,19 @@ export type RecordOpenPreferenceStorage = Pick<Storage, 'getItem'>;
4962
/** The storage surface this module writes — `localStorage`, or a test double. */
5063
export type RecordOpenPreferenceWriteStorage = Pick<Storage, 'setItem'>;
5164

52-
function defaultStorage(): Storage | null {
53-
if (typeof window === 'undefined') return null;
54-
try {
55-
return window.localStorage;
56-
} catch {
57-
// Accessing `localStorage` itself throws on a blocked origin.
58-
return null;
59-
}
60-
}
65+
/**
66+
* The preference in the console's store: one key for every object (`user`
67+
* scope), stored as the bare value. Any other stored spelling — an unknown
68+
* value, a retired mode name, another case — is no choice.
69+
*/
70+
export const RECORD_OPEN_PREFERENCE = defineWorkspacePreference<RecordOpenPreference>({
71+
name: 'record.open-mode',
72+
storageKey: () => RECORD_OPEN_PREFERENCE_KEY,
73+
codec: {
74+
decode: (stored) => (stored === 'split' || stored === 'drawer' ? stored : undefined),
75+
encode: (value) => value,
76+
},
77+
});
6178

6279
/**
6380
* The reader's record-open preference, or {@link DEFAULT_RECORD_OPEN_PREFERENCE}
@@ -66,30 +83,11 @@ function defaultStorage(): Storage | null {
6683
* says there is none.
6784
*/
6885
export function readRecordOpenPreference(
69-
storage: RecordOpenPreferenceStorage | null = defaultStorage(),
86+
storage: RecordOpenPreferenceStorage | null = workspacePreferenceStorage(),
7087
): RecordOpenPreference {
71-
if (!storage) return DEFAULT_RECORD_OPEN_PREFERENCE;
72-
try {
73-
const stored = storage.getItem(RECORD_OPEN_PREFERENCE_KEY);
74-
return stored === 'split' || stored === 'drawer' ? stored : DEFAULT_RECORD_OPEN_PREFERENCE;
75-
} catch {
76-
return DEFAULT_RECORD_OPEN_PREFERENCE;
77-
}
88+
return readWorkspacePreference(RECORD_OPEN_PREFERENCE, 'user', storage) ?? DEFAULT_RECORD_OPEN_PREFERENCE;
7889
}
7990

80-
/**
81-
* A choice storage would not keep, held for this page: a browser whose
82-
* storage refuses the write (private mode, a full quota, a blocked origin)
83-
* would otherwise have the reader pick 分屏 and keep reading 抽屉 back.
84-
* `undefined` while storage keeps the choice — storage is then the one answer
85-
* — and cleared by the next kept write and by another tab's write (see
86-
* `subscribe`).
87-
*/
88-
let pageChoice: RecordOpenPreference | undefined;
89-
90-
/** The live {@link useRecordOpenPreference} callers on this page. */
91-
const listeners = new Set<() => void>();
92-
9391
/**
9492
* Keep the reader's choice. `storage` defaults to the browser's
9593
* `localStorage`; `null` says there is none. With no storage, or one that
@@ -103,42 +101,9 @@ const listeners = new Set<() => void>();
103101
*/
104102
export function writeRecordOpenPreference(
105103
next: RecordOpenPreference,
106-
storage: RecordOpenPreferenceWriteStorage | null = defaultStorage(),
104+
storage: RecordOpenPreferenceWriteStorage | null = workspacePreferenceStorage(),
107105
): void {
108-
pageChoice = next;
109-
if (storage) {
110-
try {
111-
storage.setItem(RECORD_OPEN_PREFERENCE_KEY, next);
112-
pageChoice = undefined;
113-
} catch {
114-
// Not kept past this page — see above.
115-
}
116-
}
117-
for (const listener of Array.from(listeners)) listener();
118-
}
119-
120-
function subscribe(onChange: () => void): () => void {
121-
listeners.add(onChange);
122-
const onStorage = (event: StorageEvent) => {
123-
// `key === null` is a `clear()` in another tab.
124-
if (event.key === RECORD_OPEN_PREFERENCE_KEY || event.key === null) {
125-
pageChoice = undefined;
126-
onChange();
127-
}
128-
};
129-
if (typeof window !== 'undefined') window.addEventListener('storage', onStorage);
130-
return () => {
131-
listeners.delete(onChange);
132-
if (typeof window !== 'undefined') window.removeEventListener('storage', onStorage);
133-
};
134-
}
135-
136-
function snapshot(): RecordOpenPreference {
137-
return pageChoice ?? readRecordOpenPreference();
138-
}
139-
140-
function serverSnapshot(): RecordOpenPreference {
141-
return DEFAULT_RECORD_OPEN_PREFERENCE;
106+
writeWorkspacePreference(RECORD_OPEN_PREFERENCE, 'user', next, storage);
142107
}
143108

144109
/**
@@ -148,7 +113,5 @@ function serverSnapshot(): RecordOpenPreference {
148113
* object page decides its split face from the value the reader just chose.
149114
*/
150115
export function useRecordOpenPreference(): [RecordOpenPreference, (next: RecordOpenPreference) => void] {
151-
const preference = useSyncExternalStore(subscribe, snapshot, serverSnapshot);
152-
const setPreference = useCallback((next: RecordOpenPreference) => writeRecordOpenPreference(next), []);
153-
return [preference, setPreference];
116+
return useWorkspacePreference(RECORD_OPEN_PREFERENCE, 'user', DEFAULT_RECORD_OPEN_PREFERENCE);
154117
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#27052 — `useWorkspacePreference`, the live read: the fallback
11+
* while there is no choice, every caller of the same preference and scope
12+
* re-rendered by a write, a scope apart from every other, another tab's write
13+
* heard through the `storage` event, and a choice a refusing storage would
14+
* not keep held for this page.
15+
*/
16+
17+
import * as React from 'react';
18+
import { describe, it, expect, afterEach, vi } from 'vitest';
19+
import { render, screen, cleanup, act, fireEvent } from '@testing-library/react';
20+
import {
21+
RECORD_TIMELINE_COLUMN_PREFERENCE,
22+
objectPreferenceScope,
23+
useWorkspacePreference,
24+
workspacePreferenceStorageKey,
25+
type PreferenceSwitch,
26+
type WorkspacePreferenceScope,
27+
} from './workspacePreference';
28+
29+
const LEAD = objectPreferenceScope('crm_lead');
30+
const PRODUCT = objectPreferenceScope('crm_product');
31+
const LEAD_KEY = workspacePreferenceStorageKey(RECORD_TIMELINE_COLUMN_PREFERENCE, LEAD);
32+
33+
function Probe({ id, scope, fallback }: { id: string; scope: WorkspacePreferenceScope; fallback: PreferenceSwitch }) {
34+
const [value, setValue] = useWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, scope, fallback);
35+
return (
36+
<button type="button" data-testid={id} onClick={() => setValue(value === 'on' ? 'off' : 'on')}>
37+
{value}
38+
</button>
39+
);
40+
}
41+
42+
afterEach(() => {
43+
cleanup();
44+
vi.restoreAllMocks();
45+
window.localStorage.clear();
46+
});
47+
48+
describe('useWorkspacePreference (objectstack#27052)', () => {
49+
it('answers the fallback with no choice; a write re-renders every caller of the same scope, and no other', () => {
50+
render(
51+
<>
52+
<Probe id="a" scope={LEAD} fallback="on" />
53+
<Probe id="b" scope={LEAD} fallback="on" />
54+
<Probe id="product" scope={PRODUCT} fallback="off" />
55+
</>,
56+
);
57+
expect(screen.getByTestId('a').textContent).toBe('on');
58+
expect(screen.getByTestId('product').textContent).toBe('off');
59+
fireEvent.click(screen.getByTestId('a'));
60+
expect(screen.getByTestId('a').textContent).toBe('off');
61+
expect(screen.getByTestId('b').textContent).toBe('off');
62+
expect(screen.getByTestId('product').textContent).toBe('off');
63+
expect(JSON.parse(window.localStorage.getItem(LEAD_KEY)!)).toEqual({ value: 'off', v: 1 });
64+
});
65+
66+
it('the choice survives a remount (a reload reads the same storage)', () => {
67+
const first = render(<Probe id="a" scope={LEAD} fallback="on" />);
68+
fireEvent.click(screen.getByTestId('a'));
69+
first.unmount();
70+
render(<Probe id="a" scope={LEAD} fallback="on" />);
71+
expect(screen.getByTestId('a').textContent).toBe('off');
72+
});
73+
74+
it("hears another tab's write through the `storage` event", () => {
75+
render(<Probe id="a" scope={LEAD} fallback="on" />);
76+
act(() => {
77+
window.localStorage.setItem(LEAD_KEY, JSON.stringify({ value: 'off', v: 1 }));
78+
window.dispatchEvent(new StorageEvent('storage', { key: LEAD_KEY }));
79+
});
80+
expect(screen.getByTestId('a').textContent).toBe('off');
81+
act(() => {
82+
window.localStorage.clear();
83+
window.dispatchEvent(new StorageEvent('storage', { key: null }));
84+
});
85+
expect(screen.getByTestId('a').textContent).toBe('on');
86+
});
87+
88+
it('holds a choice a refusing storage would not keep, for this page', () => {
89+
const real = window.localStorage;
90+
const refusing = {
91+
getItem: (key: string) => real.getItem(key),
92+
setItem: () => {
93+
throw new Error('QuotaExceededError');
94+
},
95+
};
96+
Object.defineProperty(window, 'localStorage', { value: refusing, configurable: true });
97+
try {
98+
render(<Probe id="a" scope={LEAD} fallback="on" />);
99+
fireEvent.click(screen.getByTestId('a'));
100+
expect(screen.getByTestId('a').textContent).toBe('off');
101+
expect(real.getItem(LEAD_KEY)).toBeNull();
102+
} finally {
103+
Object.defineProperty(window, 'localStorage', { value: real, configurable: true });
104+
}
105+
});
106+
});
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#27052 — the console's one workspace-preference store: a
11+
* preference × scope keyed `localStorage` entry holding a small versioned
12+
* record, read as "no choice" whenever it cannot be read. This file runs in
13+
* the node project (no `window`); the live hook and its cross-tab path are
14+
* driven in `workspacePreference.hook.test.tsx`.
15+
*/
16+
17+
import { describe, it, expect } from 'vitest';
18+
import {
19+
RECORD_RELATED_COLUMN_PREFERENCE,
20+
RECORD_TIMELINE_COLUMN_PREFERENCE,
21+
defineWorkspacePreference,
22+
objectPreferenceScope,
23+
readWorkspacePreference,
24+
versionedPreferenceCodec,
25+
workspacePreferenceStorageKey,
26+
writeWorkspacePreference,
27+
} from './workspacePreference';
28+
29+
const memoryStorage = () => {
30+
const kept = new Map<string, string>();
31+
return {
32+
kept,
33+
getItem: (key: string) => kept.get(key) ?? null,
34+
setItem: (key: string, value: string) => void kept.set(key, value),
35+
};
36+
};
37+
38+
describe('workspacePreference keys (objectstack#27052)', () => {
39+
it('one key per preference × scope; the per-object scope names the object', () => {
40+
expect(objectPreferenceScope('crm_lead')).toBe('object:crm_lead');
41+
expect(workspacePreferenceStorageKey(RECORD_TIMELINE_COLUMN_PREFERENCE, objectPreferenceScope('crm_lead'))).toBe(
42+
'os:pref:record.columns.timeline@object:crm_lead',
43+
);
44+
expect(workspacePreferenceStorageKey(RECORD_RELATED_COLUMN_PREFERENCE, objectPreferenceScope('crm_lead'))).toBe(
45+
'os:pref:record.columns.related@object:crm_lead',
46+
);
47+
const userScoped = defineWorkspacePreference({ name: 'demo.user', codec: { decode: (s) => s, encode: (v: string) => v } });
48+
expect(workspacePreferenceStorageKey(userScoped)).toBe('os:pref:demo.user');
49+
});
50+
51+
it('a preference with a spelling of its own keeps it', () => {
52+
const legacy = defineWorkspacePreference({
53+
name: 'demo.legacy',
54+
storageKey: () => 'os:demo-legacy',
55+
codec: { decode: (s) => s, encode: (v: string) => v },
56+
});
57+
const storage = memoryStorage();
58+
writeWorkspacePreference(legacy, 'user', 'kept', storage);
59+
expect(storage.kept.get('os:demo-legacy')).toBe('kept');
60+
});
61+
});
62+
63+
describe('the column switches — a versioned record (objectstack#27052)', () => {
64+
const lead = objectPreferenceScope('crm_lead');
65+
const product = objectPreferenceScope('crm_product');
66+
67+
it('stores a record carrying `v`, and reads it back per object', () => {
68+
const storage = memoryStorage();
69+
writeWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, lead, 'off', storage);
70+
const stored = JSON.parse(storage.kept.get('os:pref:record.columns.timeline@object:crm_lead')!);
71+
expect(stored).toEqual({ value: 'off', v: 1 });
72+
expect(readWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, lead, storage)).toBe('off');
73+
// Another object, and the other column, have no choice.
74+
expect(readWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, product, storage)).toBeUndefined();
75+
expect(readWorkspacePreference(RECORD_RELATED_COLUMN_PREFERENCE, lead, storage)).toBeUndefined();
76+
writeWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, lead, 'on', storage);
77+
expect(readWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, lead, storage)).toBe('on');
78+
});
79+
80+
it('reads no choice from a record of another version, a value it does not know, or a spelling that is not a record', () => {
81+
const key = 'os:pref:record.columns.timeline@object:crm_lead';
82+
const storageWith = (value: string) => ({ getItem: (k: string) => (k === key ? value : null) });
83+
for (const spelling of ['{"value":"off","v":2}', '{"value":"off"}', '{"value":"hidden","v":1}', 'off', '[1]', 'null', '{']) {
84+
expect(readWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, lead, storageWith(spelling))).toBeUndefined();
85+
}
86+
});
87+
88+
it('reads no choice with no storage, or with one that throws; a write to either throws nothing', () => {
89+
expect(readWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, lead, null)).toBeUndefined();
90+
const throwing = { getItem: () => { throw new Error('SecurityError'); } };
91+
expect(readWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, lead, throwing)).toBeUndefined();
92+
expect(() => writeWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, lead, 'off', null)).not.toThrow();
93+
const refusing = { setItem: () => { throw new Error('QuotaExceededError'); } };
94+
expect(() => writeWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, product, 'off', refusing)).not.toThrow();
95+
});
96+
97+
it('with no `window` (SSR — this file runs in the node project), the default storage is absent: no choice', () => {
98+
expect(typeof window).toBe('undefined');
99+
expect(readWorkspacePreference(RECORD_RELATED_COLUMN_PREFERENCE, lead)).toBeUndefined();
100+
});
101+
});
102+
103+
describe('versionedPreferenceCodec (objectstack#27052)', () => {
104+
it('a record-shaped value round-trips; `v` is written last and always', () => {
105+
const codec = versionedPreferenceCodec<{ order: string[]; collapsed: string[] }>({
106+
version: 3,
107+
toRecord: (value) => ({ ...value }),
108+
fromRecord: (record) =>
109+
Array.isArray(record.order) && Array.isArray(record.collapsed)
110+
? { order: record.order as string[], collapsed: record.collapsed as string[] }
111+
: undefined,
112+
});
113+
const spelling = codec.encode({ order: ['a', 'b'], collapsed: ['b'] });
114+
expect(JSON.parse(spelling)).toEqual({ order: ['a', 'b'], collapsed: ['b'], v: 3 });
115+
expect(codec.decode(spelling)).toEqual({ order: ['a', 'b'], collapsed: ['b'] });
116+
expect(codec.decode('{"order":[],"collapsed":[],"v":2}')).toBeUndefined();
117+
});
118+
});

0 commit comments

Comments
 (0)