Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/27052-workspace-preferences.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/app-shell': patch
'@object-ui/components': patch
'@object-ui/plugin-detail': patch
'@object-ui/i18n': patch
---

feat(app-shell,plugin-detail,components,i18n): the record page's timeline and related columns are each reader's switches, per object, toggled from the crumb row

Clause-②: no
Metadata-protocol: none

Whether the record page shows its timeline column and its related column is now the reader's own choice, per object, kept in the browser. It is not metadata: no key, no spec change.

- **Defaults derived from the object.** A reader who never chose gets the timeline on for a "worked" object and off otherwise, and the related column on. An object is worked when `stageField` names a field, when a field's options declare a lifecycle `kind` (unless `stageField: false`), or when it sets `enable.files: true` — only what the object declares of itself. Being another object's `master_detail` parent does not count: a container that owns child rows (a price book, a campaign) is not a worked record. `recordTimelineDefaultReason` and `recordPageColumnDefaults` in `@object-ui/plugin-detail` say which.
- **A switched-off column frees its room.** With the timeline off, a wide page draws details | related, and the timeline's blocks (the composer and History) become an Activity card at the foot of the related column. With the related column off, it stays behind the rail toggle. `recordPageArrangementFor` is the rule, and `RecordPageColumns` takes the reader's `switches`. Below the column widths the page is tabbed as before, the switches are kept for when it widens, and `?tab=` is unchanged.
- **Panel toggles.** `PanelToggles` in `@object-ui/components` is a group of icon toggles with `aria-pressed`, a tinted pressed state, and a tooltip naming the shortcut. The record page draws it on its crumb row, right side, only in a column tier. It is not drawn in the drawer or split pane.
- **Shortcuts.** `⌘⇧.` / `Ctrl+Shift+.` toggles the timeline and `⌘⇧,` / `Ctrl+Shift+,` the related column, matched by the physical key. Not `⌘⇧T` / `⌘⇧R`: the browser keeps those (reopen closed tab, hard reload) and the page never receives them. They do nothing while a text field has focus, and the keyboard-shortcuts dialog lists them while the record page is open.
- **One preference store.** `app-shell`'s `workspacePreference` module keeps every reader preference by preference and scope (`user`, or `object:OBJECT_NAME`). Each value is a small record with a version `v`. It reads live through `useSyncExternalStore`, follows other tabs, and falls back to the default with no storage. The record-open preference (`os:record-open-mode`) is now a thin alias over it and keeps its stored spelling, so a reader's earlier choice reads back unchanged.
101 changes: 32 additions & 69 deletions ui/packages/app-shell/src/utils/recordOpenPreference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';

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

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<RecordOpenPreference>({
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}
Expand All @@ -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
Expand All @@ -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);
}

/**
Expand All @@ -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);
}
106 changes: 106 additions & 0 deletions ui/packages/app-shell/src/utils/workspacePreference.hook.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectstack#27052 — `useWorkspacePreference`, the live read: the fallback
* while there is no choice, every caller of the same preference and scope
* re-rendered by a write, a scope apart from every other, another tab's write
* heard through the `storage` event, and a choice a refusing storage would
* not keep held for this page.
*/

import * as React from 'react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, screen, cleanup, act, fireEvent } from '@testing-library/react';
import {
RECORD_TIMELINE_COLUMN_PREFERENCE,
objectPreferenceScope,
useWorkspacePreference,
workspacePreferenceStorageKey,
type PreferenceSwitch,
type WorkspacePreferenceScope,
} from './workspacePreference';

const LEAD = objectPreferenceScope('crm_lead');
const PRODUCT = objectPreferenceScope('crm_product');
const LEAD_KEY = workspacePreferenceStorageKey(RECORD_TIMELINE_COLUMN_PREFERENCE, LEAD);

function Probe({ id, scope, fallback }: { id: string; scope: WorkspacePreferenceScope; fallback: PreferenceSwitch }) {
const [value, setValue] = useWorkspacePreference(RECORD_TIMELINE_COLUMN_PREFERENCE, scope, fallback);
return (
<button type="button" data-testid={id} onClick={() => setValue(value === 'on' ? 'off' : 'on')}>
{value}
</button>
);
}

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(
<>
<Probe id="a" scope={LEAD} fallback="on" />
<Probe id="b" scope={LEAD} fallback="on" />
<Probe id="product" scope={PRODUCT} fallback="off" />
</>,
);
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(<Probe id="a" scope={LEAD} fallback="on" />);
fireEvent.click(screen.getByTestId('a'));
first.unmount();
render(<Probe id="a" scope={LEAD} fallback="on" />);
expect(screen.getByTestId('a').textContent).toBe('off');
});

it("hears another tab's write through the `storage` event", () => {
render(<Probe id="a" scope={LEAD} fallback="on" />);
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(<Probe id="a" scope={LEAD} fallback="on" />);
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 });
}
});
});
118 changes: 118 additions & 0 deletions ui/packages/app-shell/src/utils/workspacePreference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectstack#27052 — the console's one workspace-preference store: a
* preference × scope keyed `localStorage` entry holding a small versioned
* record, read as "no choice" whenever it cannot be read. This file runs in
* the node project (no `window`); the live hook and its cross-tab path are
* driven in `workspacePreference.hook.test.tsx`.
*/

import { describe, it, expect } from 'vitest';
import {
RECORD_RELATED_COLUMN_PREFERENCE,
RECORD_TIMELINE_COLUMN_PREFERENCE,
defineWorkspacePreference,
objectPreferenceScope,
readWorkspacePreference,
versionedPreferenceCodec,
workspacePreferenceStorageKey,
writeWorkspacePreference,
} from './workspacePreference';

const memoryStorage = () => {
const kept = new Map<string, string>();
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();
});
});
Loading
Loading