From 1b034058c11347165cd3e31dbcc3fc875f60f32a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 06:30:20 +0000 Subject: [PATCH 1/3] test(plugin-detail): pin the remembered empty-field choice (red) and add the record-page preference seam Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- ...ction.emptyFieldsRemembered-25883.test.tsx | 194 ++++++++++++++++++ .../context/RecordPagePreferencesContext.tsx | 77 +++++++ ui/packages/react/src/context/index.ts | 1 + 3 files changed, 272 insertions(+) create mode 100644 ui/packages/plugin-detail/src/__tests__/DetailSection.emptyFieldsRemembered-25883.test.tsx create mode 100644 ui/packages/react/src/context/RecordPagePreferencesContext.tsx diff --git a/ui/packages/plugin-detail/src/__tests__/DetailSection.emptyFieldsRemembered-25883.test.tsx b/ui/packages/plugin-detail/src/__tests__/DetailSection.emptyFieldsRemembered-25883.test.tsx new file mode 100644 index 00000000000..16ed7963657 --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/DetailSection.emptyFieldsRemembered-25883.test.tsx @@ -0,0 +1,194 @@ +/** + * 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#25883 (item 2) — "Show N empty fields" is one remembered choice + * per user and object. + * + * A reader who opened a section's empty-field row on an opportunity found it + * folded again in every other section, after a reload, and on the next + * opportunity: the choice lived in each section's own component state. Under a + * host's record-page preference store (`RecordPagePreferencesProvider`; the + * Console keeps it in the `ui.record.` `sys_user_preference` row), the + * section reads and writes the store's choice for its object instead: + * + * 1. toggled, unmounted and mounted again, the section still shows its empty + * fields; + * 2. the choice holds for every section of that object's page; + * 3. another object's sections are unaffected; + * 4. the store receives the choice per object, both ways; + * 5. with no store (no host, no signed-in user) the toggle is what it was: + * per section, gone on unmount. + */ + +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, within } from '@testing-library/react'; +import { + RecordPagePreferencesProvider, + type RecordPagePreference, + type RecordPagePreferencesStore, +} from '@object-ui/react'; +import type { DetailViewSection } from '@object-ui/types'; +import { DetailSection } from '../DetailSection'; + +afterEach(() => cleanup()); + +/** Four declared rows, two of them empty: the section folds the two. */ +const section = (title: string): DetailViewSection => ({ + title, + fields: [ + { name: 'name', label: `${title} Name` }, + { name: 'stage', label: `${title} Stage` }, + { name: 'next_step', label: `${title} Next Step` }, + { name: 'lead_source', label: `${title} Lead Source` }, + ], +}); + +const RECORD = { name: 'Big Deal', stage: 'Proposal', next_step: null, lead_source: '' }; + +/** An in-memory store, the shape a host hands down; `writes` records every update. */ +function memoryStore(initial: Record = {}) { + const state: Record = { ...initial }; + const writes: Array<[string, RecordPagePreference]> = []; + const listeners = new Set<() => void>(); + const store: RecordPagePreferencesStore = { + get: (objectName) => state[objectName] ?? {}, + update: (objectName, patch) => { + writes.push([objectName, patch]); + state[objectName] = { ...(state[objectName] ?? {}), ...patch }; + listeners.forEach((l) => l()); + }, + }; + return { store, writes, listeners }; +} + +/** A host that re-renders its subtree when its store changes, as a real provider does. */ +function Host({ mem, children }: { mem: ReturnType; children: React.ReactNode }) { + const [version, setVersion] = React.useState(0); + React.useEffect(() => { + const l = () => setVersion((v) => v + 1); + mem.listeners.add(l); + return () => { + mem.listeners.delete(l); + }; + }, [mem]); + // A new value per change, so context consumers see it. + const value = React.useMemo( + () => ({ get: (o) => mem.store.get(o), update: (o, p) => mem.store.update(o, p) }), + // eslint-disable-next-line react-hooks/exhaustive-deps -- `version` is the change signal + [mem, version], + ); + return {children}; +} + +const Section = ({ title, objectName }: { title: string; objectName: string }) => ( +
+ +
+); + +const inSection = (title: string) => within(screen.getByTestId(title)); +const emptyShown = (title: string) => inSection(title).queryByText(`${title} Next Step`) !== null; +const showButton = (title: string) => inSection(title).getByRole('button', { name: /show 2 empty fields/i }); + +describe('the empty-field toggle is one remembered choice per object (objectstack#25883)', () => { + it('toggled, unmounted and mounted again, the section still shows its empty fields', () => { + const mem = memoryStore(); + const first = render( + +
+ , + ); + expect(emptyShown('Details')).toBe(false); + fireEvent.click(showButton('Details')); + expect(emptyShown('Details')).toBe(true); + first.unmount(); + + render( + +
+ , + ); + expect(emptyShown('Details')).toBe(true); + expect(inSection('Details').getByRole('button', { name: /hide empty fields/i })).toBeTruthy(); + }); + + it('one choice holds for every section of the object, and another object is unaffected', () => { + const mem = memoryStore(); + render( + +
+
+
+ , + ); + fireEvent.click(showButton('Details')); + expect(emptyShown('Details')).toBe(true); + expect(emptyShown('Pipeline')).toBe(true); + expect(emptyShown('Company')).toBe(false); + expect(showButton('Company')).toBeTruthy(); + }); + + it('the store receives the choice for the object, shown and then hidden', () => { + const mem = memoryStore(); + render( + +
+ , + ); + fireEvent.click(showButton('Details')); + fireEvent.click(inSection('Details').getByRole('button', { name: /hide empty fields/i })); + expect(mem.writes).toEqual([ + ['opportunity', { showEmptyFields: true }], + ['opportunity', { showEmptyFields: false }], + ]); + expect(emptyShown('Details')).toBe(false); + }); + + it('a stored choice opens the page with the empty fields shown', () => { + const mem = memoryStore({ opportunity: { showEmptyFields: true } }); + render( + +
+ , + ); + expect(emptyShown('Details')).toBe(true); + expect(mem.writes).toEqual([]); + }); + + it('with no store, the toggle stays per section and is gone on unmount', () => { + const first = render( + <> +
+
+ , + ); + fireEvent.click(showButton('Details')); + expect(emptyShown('Details')).toBe(true); + expect(emptyShown('Pipeline')).toBe(false); + first.unmount(); + + render(
); + expect(emptyShown('Details')).toBe(false); + }); + + it('a section with no object name keeps its own toggle under a store', () => { + const mem = memoryStore(); + render( + +
+ +
+
, + ); + fireEvent.click(showButton('Loose')); + expect(emptyShown('Loose')).toBe(true); + expect(mem.writes).toEqual([]); + }); +}); diff --git a/ui/packages/react/src/context/RecordPagePreferencesContext.tsx b/ui/packages/react/src/context/RecordPagePreferencesContext.tsx new file mode 100644 index 00000000000..621b79fbd54 --- /dev/null +++ b/ui/packages/react/src/context/RecordPagePreferencesContext.tsx @@ -0,0 +1,77 @@ +/** + * 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. + */ + +/** + * RecordPagePreferencesContext — a signed-in user's own record-page choices, + * per object, offered to the record page's renderers by their host + * (objectstack#25883). + * + * The one choice it carries today is "show empty fields": a reader who opens a + * section's "Show N empty fields" row wants to see the whole record, and wants + * it on every section of the page, on the next record of the same object and + * after a reload. The host keeps that choice where it keeps the user's list + * state, a `sys_user_preference` row per object (the Console's key is + * `ui.record.`, beside `ui.list.`). + * + * ## Why a context, and why here + * + * The record page's sections reach the page through `ComponentRegistry`, so no + * host prop reaches them; a context does, the same way `HostNavigationContext` + * and `HostMapStyleContext` reach the renderers they serve. The persistence + * (the preference read, the row writes, the signed-in user) belongs to the + * host, and the renderer package must not depend on the host's package, so the + * seam lives in this package, which both already depend on. + * + * ## Absent is a supported state + * + * Outside a provider, or under one given `null` (no signed-in user, no + * preference store), {@link useRecordPagePreferences} answers `null`, and a + * renderer keeps the choice in its own component state as it did before: per + * section, until the page unmounts. + * + * ⛔ Not metadata. Nothing here is authorable and nothing changes what a view + * declares; it is one user's own reading preference, invisible to every other + * user. + */ + +import React, { createContext, useContext } from 'react'; + +/** One user's record-page choices for one object. */ +export interface RecordPagePreference { + /** + * The reader chose to see the fields the page folds away as empty. Absent + * means no choice was made: the page folds them, as it always has. + */ + showEmptyFields?: boolean; +} + +/** The host's store of record-page choices, one entry per object. */ +export interface RecordPagePreferencesStore { + /** The user's choices for `objectName`; `{}` when they made none. */ + get(objectName: string): RecordPagePreference; + /** Record a choice for `objectName`. The parts the patch names replace the stored ones. */ + update(objectName: string, patch: RecordPagePreference): void; +} + +export const RecordPagePreferencesContext = createContext(null); + +export const RecordPagePreferencesProvider: React.FC<{ + /** The host's store, or `null` when the host has none to offer right now. */ + value: RecordPagePreferencesStore | null; + children: React.ReactNode; +}> = ({ value, children }) => ( + {children} +); + +/** + * The host's record-page preference store, or `null` when no host provided + * one. See the module docblock. + */ +export function useRecordPagePreferences(): RecordPagePreferencesStore | null { + return useContext(RecordPagePreferencesContext); +} diff --git a/ui/packages/react/src/context/index.ts b/ui/packages/react/src/context/index.ts index d4b834cd142..31060ec35b7 100644 --- a/ui/packages/react/src/context/index.ts +++ b/ui/packages/react/src/context/index.ts @@ -11,5 +11,6 @@ export * from './DiscussionContext.js'; export * from './DrillNavigationContext.js'; export * from './HostNavigationContext.js'; export * from './HostMapStyleContext.js'; +export * from './RecordPagePreferencesContext.js'; export * from './RelatedRecordActionsContext.js'; export * from './PrintPageContext.js'; From ac6fd34ffc09d50d05985dce7ac11c294fb85a19 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 06:42:53 +0000 Subject: [PATCH 2/3] fix(plugin-detail): remember the record page's empty-field choice per user and object The Console keeps it in the ui.record. sys_user_preference row and hands it to the sections through @object-ui/react's RecordPagePreferencesProvider. With no store a section keeps its own toggle. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .changeset/25883-empty-fields-remembered.md | 21 ++ .../app-shell/src/console/ConsoleShell.tsx | 80 ++++--- .../consoleUserPreferenceBudget.test.tsx | 53 +++-- .../src/context/RecordPreferencesProvider.tsx | 113 ++++++++++ .../src/context/UserStateAdapters.tsx | 28 +++ .../RecordPreferencesProvider-25883.test.tsx | 196 ++++++++++++++++++ .../plugin-detail/src/DetailSection.tsx | 33 ++- 7 files changed, 475 insertions(+), 49 deletions(-) create mode 100644 .changeset/25883-empty-fields-remembered.md create mode 100644 ui/packages/app-shell/src/context/RecordPreferencesProvider.tsx create mode 100644 ui/packages/app-shell/src/context/__tests__/RecordPreferencesProvider-25883.test.tsx diff --git a/.changeset/25883-empty-fields-remembered.md b/.changeset/25883-empty-fields-remembered.md new file mode 100644 index 00000000000..61fcd2f6dcd --- /dev/null +++ b/.changeset/25883-empty-fields-remembered.md @@ -0,0 +1,21 @@ +--- +'@object-ui/react': patch +'@object-ui/plugin-detail': patch +'@object-ui/app-shell': patch +--- + +fix(plugin-detail): the record page remembers "Show empty fields" per user and object + +Clause-②: no +Metadata-protocol: none + +On a record page, each section's "Show N empty fields" row used to open that one section only, and the choice was gone after a reload or on the next record. A reader who wanted the whole record clicked once per section, every time. + +What changes: + +- In the Console, the choice is now the signed-in user's, per object. One click shows the empty fields in every section of that object's record page. The choice holds on the next record of the same object, after a reload and on another device. Other objects keep their own choice. +- It is stored in a `sys_user_preference` row per object, key `ui.record.`, with the value `{ "showEmptyFields": true }` or `false`. The row sits beside the list page's `ui.list.` rows and loads in the same single preference read. +- `@object-ui/react` adds `RecordPagePreferencesProvider`, `useRecordPagePreferences` and the `RecordPagePreference` and `RecordPagePreferencesStore` types. A host uses them to hand its store to the record page's sections. +- With no store (no signed-in user, or a host that provides none), each section keeps the toggle in its own state, as before. + +What to do: nothing. Nothing a view or object declares changes. diff --git a/ui/packages/app-shell/src/console/ConsoleShell.tsx b/ui/packages/app-shell/src/console/ConsoleShell.tsx index 99e3961489c..f0c3f99074e 100644 --- a/ui/packages/app-shell/src/console/ConsoleShell.tsx +++ b/ui/packages/app-shell/src/console/ConsoleShell.tsx @@ -39,12 +39,17 @@ import { FlowPaletteRecentsProvider } from '../context/FlowPaletteRecentsProvide import { UserStateAdaptersProvider, useAttachListStateFamily, + useAttachRecordStateFamily, useAttachUserStateAdapters, } from '../context/UserStateAdapters.js'; import { LIST_PREFERENCE_PREFIX, ListPreferencesProvider, } from '../context/ListPreferencesProvider.js'; +import { + RECORD_PREFERENCE_PREFIX, + RecordPreferencesProvider, +} from '../context/RecordPreferencesProvider.js'; import { ThemeProvider } from '../chrome/ThemeProvider.js'; import { LoadingScreen } from '../chrome/LoadingScreen.js'; import { RedirectWithSplash } from '../chrome/RedirectWithSplash.js'; @@ -194,36 +199,38 @@ function ConsoleShellProviders({ children }: { children: ReactNode }) { - {/* objectui#4467 — the impersonation indicator. Above the - routes, so it is chrome for EVERY console page (home has - its own layout and would otherwise carry no indicator), - and in flow rather than overlaid, so it never covers the - header it warns about. Renders null on every ordinary - session. */} - - {/* objectui#9954 — the environment admin's read-rate report. - Beside the impersonation indicator for the same reason it - is here: chrome for EVERY console page, including `/home`, - which has its own layout and would otherwise carry no - report. Renders null unless the control plane's verdict is - `anomalous`, so an ordinary session and an unmeasured - environment both see nothing — and a non-admin session - never even issues the request, nor does any session on a - runtime whose config does not serve `storageUsage` - (objectui#11002). */} - - {/* objectui#10439 — the environment admin's storage-capacity - banner, beside the read-rate report and behind the same - two gates (the admin, and the runtime serving - `storageUsage`); the two read one `/usage/storage` response. - Renders null unless the runtime's own verdict says `warn` - (80% and up) or `blocked` (uploads and imports refused). */} - - }>{children} - {/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */} - - - + + {/* objectui#4467 — the impersonation indicator. Above the + routes, so it is chrome for EVERY console page (home has + its own layout and would otherwise carry no indicator), + and in flow rather than overlaid, so it never covers the + header it warns about. Renders null on every ordinary + session. */} + + {/* objectui#9954 — the environment admin's read-rate report. + Beside the impersonation indicator for the same reason it + is here: chrome for EVERY console page, including `/home`, + which has its own layout and would otherwise carry no + report. Renders null unless the control plane's verdict is + `anomalous`, so an ordinary session and an unmeasured + environment both see nothing — and a non-admin session + never even issues the request, nor does any session on a + runtime whose config does not serve `storageUsage` + (objectui#11002). */} + + {/* objectui#10439 — the environment admin's storage-capacity + banner, beside the read-rate report and behind the same + two gates (the admin, and the runtime serving + `storageUsage`); the two read one `/usage/storage` response. + Renders null unless the runtime's own verdict says `warn` + (80% and up) or `blocked` (uploads and imports refused). */} + + }>{children} + {/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */} + + + + @@ -350,6 +357,7 @@ function UserStateBridge() { const dataSource = useAdapter(); const attach = useAttachUserStateAdapters(); const attachListState = useAttachListStateFamily(); + const attachRecordState = useAttachRecordStateFamily(); useEffect(() => { if (!user?.id || !dataSource) { @@ -357,6 +365,7 @@ function UserStateBridge() { attach('recent', null); attach('flowPaletteRecents', null); attachListState(null); + attachRecordState(null); return; } const favorites = createObjectStackUserStateAdapter({ @@ -384,14 +393,23 @@ function UserStateBridge() { attach('favorites', favorites); attach('recent', recent); attach('flowPaletteRecents', flowPaletteRecents); + // objectstack#25883 — every object's record-page state, `ui.record.` + // ("show empty fields"): a second key family in the same one read. + const recordState = createObjectStackUserPreferenceFamily({ + dataSource, + userId: user.id, + prefix: RECORD_PREFERENCE_PREFIX, + }); attachListState(listState); + attachRecordState(recordState); return () => { attach('favorites', null); attach('recent', null); attach('flowPaletteRecents', null); attachListState(null); + attachRecordState(null); }; - }, [user?.id, dataSource, attach, attachListState]); + }, [user?.id, dataSource, attach, attachListState, attachRecordState]); return null; } diff --git a/ui/packages/app-shell/src/console/__tests__/consoleUserPreferenceBudget.test.tsx b/ui/packages/app-shell/src/console/__tests__/consoleUserPreferenceBudget.test.tsx index e1bfc05aa66..1480626e993 100644 --- a/ui/packages/app-shell/src/console/__tests__/consoleUserPreferenceBudget.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/consoleUserPreferenceBudget.test.tsx @@ -21,6 +21,11 @@ * stays ONE read, and the read asks for the three keys and the `ui.list.` * prefix. * + * objectstack#25883 adds a fifth: every object's record-page state + * (`ui.record.`, the "show empty fields" choice), a second key family + * in the same read. The budget stays ONE read, asking for the three keys and + * both prefixes. + * * ## The tree * * The real `ConsoleShell` providers and the real `ConnectedShell`: its @@ -34,7 +39,7 @@ * ## The pins * * 1. One boot puts ONE `sys_user_preference` GET on the wire, and it asks - * for the three keys and the list-state prefix. + * for the three keys, the list-state prefix and the record-state prefix. * 2. Every consumer receives its own row from that one answer, the list * state of an object included. * 3. Each adapter still writes its own row, and only its own; the list state @@ -87,6 +92,7 @@ import { useFavorites } from '../../context/FavoritesProvider'; import { useRecentItems } from '../../context/RecentItemsProvider'; import { useFlowPaletteRecents } from '../../context/FlowPaletteRecentsProvider'; import { useObjectListPreferences, type ObjectListPreference } from '../../context/ListPreferencesProvider'; +import { useRecordPagePreferences } from '@object-ui/react'; import { createPreferenceFramework, preferenceRowId as rowId, type PreferenceFramework } from './preferenceFramework'; const FAVORITES_KEY = 'ui.favorites'; @@ -96,6 +102,10 @@ const BRIDGE_KEYS = [FAVORITES_KEY, RECENT_KEY, FLOW_KEY]; /** objectui#12418 — the list-state family, and one object's row of it. */ const LIST_PREFIX = 'ui.list.'; const LIST_KEY = 'ui.list.account'; +/** objectstack#25883 — the record-state family, and one object's row of it. */ +const RECORD_PREFIX = 'ui.record.'; +const RECORD_KEY = 'ui.record.account'; +const BOTH_PREFIXES = [LIST_PREFIX, RECORD_PREFIX].sort(); /** Each user's stored rows, one distinct payload per key, so a starved consumer shows up. */ const STORE: Record> = { @@ -104,12 +114,14 @@ const STORE: Record> = { [RECENT_KEY]: [{ id: 'object:lead', type: 'object', name: 'lead', href: '/lead', visitedAt: '2026-10-01T00:00:00.000Z' }], [FLOW_KEY]: ['flow_step_alpha'], [LIST_KEY]: { viewOrder: ['account.mine', 'account.all'] }, + [RECORD_KEY]: { showEmptyFields: true }, }, u2: { [FAVORITES_KEY]: [{ id: 'object:invoice', label: 'Invoice', href: '/invoice', type: 'object' }], [RECENT_KEY]: [{ id: 'object:quote', type: 'object', name: 'quote', href: '/quote', visitedAt: '2026-10-02T00:00:00.000Z' }], [FLOW_KEY]: ['flow_step_omega'], [LIST_KEY]: { viewOrder: ['account.all', 'account.closed'] }, + [RECORD_KEY]: { showEmptyFields: false }, }, }; @@ -125,6 +137,8 @@ interface Seen { flow: string[]; /** The `account` list state's tab order (objectui#12418). */ listOrder: string[]; + /** The `account` record page's "show empty fields" choice (objectstack#25883). */ + recordShowEmpty: boolean | undefined; } /** @@ -132,7 +146,7 @@ interface Seen { * — the same shape `MetadataProvider.requestBudget.test.tsx` uses. Not props, * because writing through a prop is what the react-compiler rule refuses. */ -const EMPTY_SEEN: Seen = { favorites: [], recent: [], flow: [], listOrder: [] }; +const EMPTY_SEEN: Seen = { favorites: [], recent: [], flow: [], listOrder: [], recordShowEmpty: undefined }; const latest: { current: Seen } = { current: EMPTY_SEEN }; const actions: { current: { @@ -140,6 +154,7 @@ const actions: { addRecentItem?: ReturnType['addRecentItem']; recordRecent?: ReturnType['recordRecent']; patchListView?: ReturnType['patchView']; + updateRecordPage?: NonNullable>['update']; }; } = { current: {} }; @@ -151,15 +166,19 @@ function Consumers() { const listPreference: ObjectListPreference = list.preference; const listOrderKey = JSON.stringify(listPreference.viewOrder ?? []); const patchListView = list.patchView; + const recordPage = useRecordPagePreferences(); + const recordShowEmpty = recordPage?.get('account').showEmptyFields; + const updateRecordPage = recordPage?.update; useEffect(() => { latest.current = { favorites: favorites.map((f) => f.id), recent: recentItems.map((r) => r.id), flow: [...recents], listOrder: JSON.parse(listOrderKey), + recordShowEmpty, }; - actions.current = { addFavorite, addRecentItem, recordRecent, patchListView }; - }, [favorites, recentItems, recents, listOrderKey, addFavorite, addRecentItem, recordRecent, patchListView]); + actions.current = { addFavorite, addRecentItem, recordRecent, patchListView, updateRecordPage }; + }, [favorites, recentItems, recents, listOrderKey, recordShowEmpty, addFavorite, addRecentItem, recordRecent, patchListView, updateRecordPage]); return
content
; } @@ -186,11 +205,11 @@ async function settle(ms = 150) { const U1_SEEN: Seen = { favorites: ['object:contact'], recent: ['object:lead'], flow: ['flow_step_alpha'], - listOrder: ['account.mine', 'account.all'], + listOrder: ['account.mine', 'account.all'], recordShowEmpty: true, }; const U2_SEEN: Seen = { favorites: ['object:invoice'], recent: ['object:quote'], flow: ['flow_step_omega'], - listOrder: ['account.all', 'account.closed'], + listOrder: ['account.all', 'account.closed'], recordShowEmpty: false, }; describe('console sys_user_preference request budget (objectui#12187)', () => { @@ -207,7 +226,7 @@ describe('console sys_user_preference request budget (objectui#12187)', () => { vi.unstubAllGlobals(); }); - it('one boot puts one sys_user_preference read on the wire, asking for the three keys and the list-state prefix', async () => { + it('one boot puts one sys_user_preference read on the wire, asking for the three keys and both family prefixes', async () => { boot(); await waitFor(() => expect(wire().gets.length).toBeGreaterThan(0)); await settle(); @@ -215,8 +234,9 @@ describe('console sys_user_preference request budget (objectui#12187)', () => { expect(wire().gets).toHaveLength(1); expect(wire().gets[0].user).toBe('u1'); expect([...wire().gets[0].keys].sort()).toEqual([...BRIDGE_KEYS].sort()); - // objectui#12418 — every object's list state rides the same read. - expect(wire().gets[0].prefixes).toEqual([LIST_PREFIX]); + // objectui#12418 / objectstack#25883 — every object's list state and + // record-page state ride the same read. + expect([...wire().gets[0].prefixes].sort()).toEqual(BOTH_PREFIXES); }); it('every consumer receives its own row from that one answer', async () => { @@ -238,17 +258,19 @@ describe('console sys_user_preference request budget (objectui#12187)', () => { actions.current.addRecentItem?.({ id: 'object:deal', type: 'object', name: 'deal', href: '/deal' }); actions.current.recordRecent?.('flow_step_beta'); actions.current.patchListView?.('account.all', { sort: [{ field: 'name', order: 'desc' }] }); + actions.current.updateRecordPage?.('account', { showEmptyFields: false }); }); - // Each provider debounces its write (500 ms); all four land, once each. - await waitFor(() => expect(wire().writes).toHaveLength(4), { timeout: 3000 }); + // Each provider debounces its write (500 ms; the record page writes on the + // click); all five land, once each. + await waitFor(() => expect(wire().writes).toHaveLength(5), { timeout: 3000 }); await settle(); - expect(wire().writes).toHaveLength(4); + expect(wire().writes).toHaveLength(5); const byId = new Map(wire().writes.map((w) => [w.id, w])); // An update of the row its own load found — no insert, no other row. expect(wire().writes.every((w) => w.method === 'PATCH' || w.method === 'PUT')).toBe(true); - expect([...byId.keys()].sort()).toEqual([...BRIDGE_KEYS, LIST_KEY].map((k) => rowId('u1', k)).sort()); + expect([...byId.keys()].sort()).toEqual([...BRIDGE_KEYS, LIST_KEY, RECORD_KEY].map((k) => rowId('u1', k)).sort()); expect((byId.get(rowId('u1', FAVORITES_KEY))?.value as { id: string }[]).map((f) => f.id)).toContain( 'object:account', ); @@ -259,6 +281,7 @@ describe('console sys_user_preference request budget (objectui#12187)', () => { viewOrder: ['account.mine', 'account.all'], perView: { 'account.all': { sort: [{ field: 'name', order: 'desc' }] } }, }); + expect(byId.get(rowId('u1', RECORD_KEY))?.value).toEqual({ showEmptyFields: false }); // A write found its row from the load: it asked the server nothing more. expect(wire().gets).toHaveLength(1); }); @@ -277,10 +300,10 @@ describe('console sys_user_preference request budget (objectui#12187)', () => { const afterSwitch = wire().gets.slice(before); const forU2 = afterSwitch.filter((g) => g.user === 'u2'); - // One read for the new user, asking for all three keys and the prefix. + // One read for the new user, asking for all three keys and both prefixes. expect(forU2).toHaveLength(1); expect([...forU2[0].keys].sort()).toEqual([...BRIDGE_KEYS].sort()); - expect(forU2[0].prefixes).toEqual([LIST_PREFIX]); + expect([...forU2[0].prefixes].sort()).toEqual(BOTH_PREFIXES); // No u2 read was answered with u1's rows: every consumer shows u2's. expect(latest.current).toEqual(U2_SEEN); }); diff --git a/ui/packages/app-shell/src/context/RecordPreferencesProvider.tsx b/ui/packages/app-shell/src/context/RecordPreferencesProvider.tsx new file mode 100644 index 00000000000..3b68b8f1b6b --- /dev/null +++ b/ui/packages/app-shell/src/context/RecordPreferencesProvider.tsx @@ -0,0 +1,113 @@ +/** + * RecordPreferencesProvider — a console user's own record-page choices, per + * object (objectstack#25883). + * + * The one choice today is "show empty fields": a section's "Show N empty + * fields" row used to open that one section until the page unmounted, so a + * reader who wanted the whole record clicked once per section, on every + * record, after every reload. It is now the reader's choice for the object: + * one click shows the empty fields of every section of that object's record + * page, on every record of it, on any device. + * + * ## Where it is kept + * + * One `sys_user_preference` row per object, key `ui.record.`, beside + * the list page's `ui.list.` (objectui#12418) and through the same + * mechanism: a key family the console's `UserStateBridge` attaches, loaded by + * prefix in the console's ONE preference read per load, each object's row + * written on its own. + * + * ```json + * { "showEmptyFields": true } + * ``` + * + * The renderers read it through `@object-ui/react`'s + * `RecordPagePreferencesProvider`, the seam a registry-rendered record page + * can reach without depending on this package. + * + * ## With no family attached + * + * No signed-in user, or a host without the bridge: the provider hands the + * renderers no store at all, and each section keeps the toggle in its own + * state, as before. There is no browser-local copy: the choice is either the + * user's, stored with their other preferences, or the section's own. + * + * @module + */ + +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { + RecordPagePreferencesProvider, + type RecordPagePreference, + type RecordPagePreferencesStore, +} from '@object-ui/react'; +import { useRecordStateFamily } from './UserStateAdapters.js'; + +/** The family's key prefix; an object's row is `ui.record.`. */ +export const RECORD_PREFERENCE_PREFIX = 'ui.record.'; + +/** The row key of one object's record-page state. */ +export function recordPreferenceKey(objectName: string): string { + return `${RECORD_PREFERENCE_PREFIX}${objectName}`; +} + +const EMPTY: RecordPagePreference = Object.freeze({}) as RecordPagePreference; + +/** Keep only the parts of a stored value this slot declares, well formed. */ +export function sanitizeRecordPreference(v: unknown): RecordPagePreference { + if (!v || typeof v !== 'object' || Array.isArray(v)) return {}; + const out: RecordPagePreference = {}; + const show = (v as Record).showEmptyFields; + if (typeof show === 'boolean') out.showEmptyFields = show; + return out; +} + +export function RecordPreferencesProvider({ children }: { children: ReactNode }) { + const family = useRecordStateFamily(); + const [states, setStates] = useState>({}); + const statesRef = useRef(states); + statesRef.current = states; + + // Hydrate when a family attaches (a sign-in, a user switch). What the + // previous user's session held is dropped first. A choice made before the + // read answered is newer than the stored row, so it is kept over it. + useEffect(() => { + statesRef.current = {}; + setStates({}); + if (!family) return; + let cancelled = false; + void family.load().then((rows) => { + if (cancelled) return; + const remote: Record = {}; + for (const [key, value] of Object.entries(rows)) { + if (!key.startsWith(RECORD_PREFERENCE_PREFIX)) continue; + remote[key.slice(RECORD_PREFERENCE_PREFIX.length)] = sanitizeRecordPreference(value); + } + statesRef.current = { ...remote, ...statesRef.current }; + setStates(statesRef.current); + }); + return () => { + cancelled = true; + }; + }, [family]); + + const update = useCallback( + (objectName: string, patch: RecordPagePreference) => { + if (!family || !objectName) return; + const prev = statesRef.current[objectName] ?? EMPTY; + const next = sanitizeRecordPreference({ ...prev, ...patch }); + statesRef.current = { ...statesRef.current, [objectName]: next }; + setStates(statesRef.current); + // One click is one write; the family runs the writes to one key in order. + void family.save(recordPreferenceKey(objectName), next); + }, + [family], + ); + + const store = useMemo( + () => (family ? { get: (objectName) => states[objectName] ?? EMPTY, update } : null), + [family, states, update], + ); + + return {children}; +} diff --git a/ui/packages/app-shell/src/context/UserStateAdapters.tsx b/ui/packages/app-shell/src/context/UserStateAdapters.tsx index 96481b73a7f..88b46512cbb 100644 --- a/ui/packages/app-shell/src/context/UserStateAdapters.tsx +++ b/ui/packages/app-shell/src/context/UserStateAdapters.tsx @@ -49,11 +49,18 @@ interface UserStateAdaptersValue { * is not one of the public {@link UserStateKind}s. */ listState: UserPreferenceFamily | null; + /** + * objectstack#25883 — the per-object record-page state (`ui.record.`), + * a key family loaded by prefix in the same shared read as `listState`. + * Console-internal, like `listState`. + */ + recordState: UserPreferenceFamily | null; } interface AttachApi { attach(kind: UserStateKind, adapter: UserDataAdapter | null): void; attachListState(family: UserPreferenceFamily | null): void; + attachRecordState(family: UserPreferenceFamily | null): void; } // --------------------------------------------------------------------------- @@ -65,6 +72,7 @@ const ReadCtx = createContext({ recent: null, flowPaletteRecents: null, listState: null, + recordState: null, }); const WriteCtx = createContext(null); @@ -78,6 +86,7 @@ export function UserStateAdaptersProvider({ children }: { children: ReactNode }) recent: null, flowPaletteRecents: null, listState: null, + recordState: null, }); // Stable attach API so bridge useEffect deps don't churn. @@ -88,6 +97,9 @@ export function UserStateAdaptersProvider({ children }: { children: ReactNode }) attachListState: (family) => { setAdapters(prev => (prev.listState === family ? prev : { ...prev, listState: family })); }, + attachRecordState: (family) => { + setAdapters(prev => (prev.recordState === family ? prev : { ...prev, recordState: family })); + }, }); return ( @@ -136,6 +148,22 @@ export function useAttachListStateFamily() { ); } +/** The attached per-object record-page family (objectstack#25883), or null. */ +export function useRecordStateFamily(): UserPreferenceFamily | null { + return useContext(ReadCtx).recordState as UserPreferenceFamily | null; +} + +/** Plug the record-page family in/out. Stable across re-renders. */ +export function useAttachRecordStateFamily() { + const ctx = useContext(WriteCtx); + return useCallback( + (family: UserPreferenceFamily | null) => { + ctx?.attachRecordState(family); + }, + [ctx], + ); +} + // --------------------------------------------------------------------------- // Shared helpers (used by FavoritesProvider / RecentItemsProvider) // --------------------------------------------------------------------------- diff --git a/ui/packages/app-shell/src/context/__tests__/RecordPreferencesProvider-25883.test.tsx b/ui/packages/app-shell/src/context/__tests__/RecordPreferencesProvider-25883.test.tsx new file mode 100644 index 00000000000..f339e26cdad --- /dev/null +++ b/ui/packages/app-shell/src/context/__tests__/RecordPreferencesProvider-25883.test.tsx @@ -0,0 +1,196 @@ +/** + * 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#25883 (item 2) — the console keeps a reader's "show empty + * fields" choice per object in the `ui.record.` `sys_user_preference` + * row, and hands it to the record page through `@object-ui/react`'s + * `RecordPagePreferencesProvider`. + * + * 1. With the record-state family attached, a record page's toggle survives + * a remount, holds for the object's other sections, leaves another object + * alone, and writes `ui.record.`. + * 2. The stored rows are the user's state: the page opens on them. + * 3. With no family attached (no signed-in user, a host without the bridge) + * there is no store, and the section keeps its own in-memory toggle. + * 4. A choice made before the read answers is kept over the stored row; a + * user switch drops the previous user's choices. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act, cleanup, fireEvent, render, renderHook, screen, waitFor, within } from '@testing-library/react'; +import { useEffect, type ReactNode } from 'react'; +import { useRecordPagePreferences } from '@object-ui/react'; +import { DetailSection } from '@object-ui/plugin-detail'; +import type { DetailViewSection } from '@object-ui/types'; +import { UserStateAdaptersProvider, useAttachRecordStateFamily } from '../UserStateAdapters'; +import { + RECORD_PREFERENCE_PREFIX, + RecordPreferencesProvider, + recordPreferenceKey, + sanitizeRecordPreference, +} from '../RecordPreferencesProvider'; + +afterEach(() => cleanup()); + +/** A record-state family recording its traffic; `rows` is the user's stored rows. */ +function makeFamily(rows: Record = {}, opts: { held?: boolean } = {}) { + let release: () => void = () => {}; + const gate = opts.held ? new Promise((r) => (release = r)) : Promise.resolve(); + const family = { + prefix: RECORD_PREFERENCE_PREFIX, + load: vi.fn(async () => { + await gate; + return structuredClone(rows) as Record; + }), + save: vi.fn(async (_key: string, _value: unknown) => {}), + }; + return { family, release: () => release() }; +} + +function AttachFamily({ family }: { family: unknown }) { + const attach = useAttachRecordStateFamily(); + useEffect(() => { + attach(family as never); + return () => attach(null); + }, [attach, family]); + return null; +} + +function Providers({ family, children }: { family: unknown; children: ReactNode }) { + return ( + + {family ? : null} + {children} + + ); +} + +const section = (title: string): DetailViewSection => ({ + title, + fields: [ + { name: 'name', label: `${title} Name` }, + { name: 'stage', label: `${title} Stage` }, + { name: 'next_step', label: `${title} Next Step` }, + { name: 'lead_source', label: `${title} Lead Source` }, + ], +}); +const RECORD = { name: 'Big Deal', stage: 'Proposal', next_step: null, lead_source: '' }; + +const Section = ({ title, objectName }: { title: string; objectName: string }) => ( +
+ +
+); +const inSection = (title: string) => within(screen.getByTestId(title)); +const emptyShown = (title: string) => inSection(title).queryByText(`${title} Next Step`) !== null; +const showButton = (title: string) => inSection(title).getByRole('button', { name: /show 2 empty fields/i }); + +describe('RecordPreferencesProvider — the record page remembers "show empty fields" (objectstack#25883)', () => { + it('toggle, remount, still shown; the other sections of the object follow; another object is unaffected', async () => { + const { family } = makeFamily(); + const page = () => ( + +
+
+
+ + ); + const first = render(page()); + await waitFor(() => expect(family.load).toHaveBeenCalledTimes(1)); + await act(async () => {}); + + fireEvent.click(showButton('Details')); + expect(emptyShown('Details')).toBe(true); + expect(emptyShown('Pipeline')).toBe(true); + expect(emptyShown('Company')).toBe(false); + expect(family.save).toHaveBeenCalledTimes(1); + expect(family.save).toHaveBeenCalledWith('ui.record.opportunity', { showEmptyFields: true }); + + // A reload: the family loads what was written. + first.unmount(); + const reloaded = makeFamily({ [recordPreferenceKey('opportunity')]: family.save.mock.calls[0][1] }); + render( + +
+
+ , + ); + await waitFor(() => expect(emptyShown('Details')).toBe(true)); + expect(emptyShown('Company')).toBe(false); + }); + + it('with no family attached there is no store, and the toggle stays the section\'s own', async () => { + const { result } = renderHook(() => useRecordPagePreferences(), { + wrapper: ({ children }) => {children}, + }); + expect(result.current).toBeNull(); + cleanup(); + + const first = render( + +
+
+ , + ); + fireEvent.click(showButton('Details')); + expect(emptyShown('Details')).toBe(true); + expect(emptyShown('Pipeline')).toBe(false); + first.unmount(); + render( + +
+ , + ); + expect(emptyShown('Details')).toBe(false); + }); + + it('reads only its own family\'s rows, sanitized', async () => { + const { family } = makeFamily({ + 'ui.record.account': { showEmptyFields: true, stray: 1 }, + 'ui.record.contact': { showEmptyFields: 'yes' }, + 'ui.list.account': { showEmptyFields: false }, + }); + const { result } = renderHook(() => useRecordPagePreferences(), { + wrapper: ({ children }) => {children}, + }); + await waitFor(() => expect(result.current?.get('account')).toEqual({ showEmptyFields: true })); + expect(result.current?.get('contact')).toEqual({}); + expect(result.current?.get('lead')).toEqual({}); + expect(sanitizeRecordPreference(['x'])).toEqual({}); + }); + + it('a choice made before the read answers is kept over the stored row', async () => { + const { family, release } = makeFamily({ 'ui.record.account': { showEmptyFields: true } }, { held: true }); + const { result } = renderHook(() => useRecordPagePreferences(), { + wrapper: ({ children }) => {children}, + }); + await waitFor(() => expect(result.current).not.toBeNull()); + act(() => result.current?.update('account', { showEmptyFields: false })); + await act(async () => release()); + await waitFor(() => expect(family.load).toHaveBeenCalled()); + await act(async () => {}); + expect(result.current?.get('account')).toEqual({ showEmptyFields: false }); + expect(family.save).toHaveBeenCalledWith('ui.record.account', { showEmptyFields: false }); + }); + + it('a user switch drops the previous user\'s choices and reads the new user\'s rows', async () => { + const u1 = makeFamily({ 'ui.record.account': { showEmptyFields: true } }); + const u2 = makeFamily({}); + let current: unknown = u1.family; + const { result, rerender } = renderHook(() => useRecordPagePreferences(), { + wrapper: ({ children }) => {children}, + }); + await waitFor(() => expect(result.current?.get('account')).toEqual({ showEmptyFields: true })); + current = u2.family; + rerender(); + await waitFor(() => expect(u2.family.load).toHaveBeenCalledTimes(1)); + await act(async () => {}); + expect(result.current?.get('account')).toEqual({}); + }); +}); diff --git a/ui/packages/plugin-detail/src/DetailSection.tsx b/ui/packages/plugin-detail/src/DetailSection.tsx index 1582ff83050..ad8d11422e1 100644 --- a/ui/packages/plugin-detail/src/DetailSection.tsx +++ b/ui/packages/plugin-detail/src/DetailSection.tsx @@ -26,7 +26,14 @@ import { LazyIcon, } from '@object-ui/components'; import { ChevronDown, ChevronRight, Copy, Check, Eye, EyeOff, Pencil } from 'lucide-react'; -import { SchemaRenderer, toRenderableSchema, useInlineEdit, usePrintPage, usePredicateScope } from '@object-ui/react'; +import { + SchemaRenderer, + toRenderableSchema, + useInlineEdit, + usePrintPage, + usePredicateScope, + useRecordPagePreferences, +} from '@object-ui/react'; import { resolveFieldAffordance, isDeadlineSettled } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; import { @@ -194,7 +201,27 @@ export const DetailSection: React.FC = ({ const isCollapsed = !isPrintPage && collapsedState; const [copiedField, setCopiedField] = React.useState(null); const [visibleCount, setVisibleCount] = React.useState(undefined); - const [showEmptyOverride, setShowEmptyOverride] = React.useState(false); + // objectstack#25883 — "Show N empty fields" is the reader's choice for the + // whole object, not for this one section: under a host's record-page + // preference store it is read from, and written to, that store's entry for + // `objectName`, so it holds for every section of the page, for the next + // record of the object and after a reload. With no store (no host, no + // signed-in user) or no object name, it stays this section's own state, + // forgotten on unmount, as before. + const recordPagePreferences = useRecordPagePreferences(); + const [localShowEmpty, setLocalShowEmpty] = React.useState(false); + const rememberedShowEmpty = + recordPagePreferences && objectName + ? { + shown: recordPagePreferences.get(objectName).showEmptyFields === true, + set: (shown: boolean) => recordPagePreferences.update(objectName, { showEmptyFields: shown }), + } + : null; + const showEmptyOverride = rememberedShowEmpty ? rememberedShowEmpty.shown : localShowEmpty; + const toggleShowEmpty = () => { + if (rememberedShowEmpty) rememberedShowEmpty.set(!rememberedShowEmpty.shown); + else setLocalShowEmpty((s) => !s); + }; const { t } = useDetailTranslation(); // The word a read-only boolean row is drawn with (objectui#12933): the same // `common.yes` / `common.no` pair every read-only boolean face reads @@ -870,7 +897,7 @@ export const DetailSection: React.FC = ({ type="button" variant="ghost" size="sm" - onClick={() => setShowEmptyOverride((s) => !s)} + onClick={toggleShowEmpty} className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground" > {showEmptyOverride ? ( From 2646abdc78be4d82390f5ebd54c461d1a5cffdf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 06:44:58 +0000 Subject: [PATCH 3/3] fix(plugin-detail): keep the authored all-empty hide under a remembered show-empty choice Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .../plugin-detail/src/DetailSection.tsx | 6 +++++- ...ction.emptyFieldsRemembered-25883.test.tsx | 20 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/ui/packages/plugin-detail/src/DetailSection.tsx b/ui/packages/plugin-detail/src/DetailSection.tsx index ad8d11422e1..013f91bd36e 100644 --- a/ui/packages/plugin-detail/src/DetailSection.tsx +++ b/ui/packages/plugin-detail/src/DetailSection.tsx @@ -417,8 +417,12 @@ export const DetailSection: React.FC = ({ const foldAllEmptySection = allFieldsEmpty && !hideAllEmptySection && !isEditing && !isPrintPage; + // The authored all-empty hide is not the reader's to undo: it applies + // whatever the reader's "show empty fields" choice is, now that the choice is + // one per object and reaches sections that never drew the toggle + // (objectstack#25883). const hideEmptyEffective = - !showEmptyOverride && (shouldAutoHideEmpty || hideAllEmptySection || foldAllEmptySection); + hideAllEmptySection || (!showEmptyOverride && (shouldAutoHideEmpty || foldAllEmptySection)); // Filter out empty fields when the auto-hide heuristic kicked in, when an // all-empty section is folded, or when one is hiding itself (its early diff --git a/ui/packages/plugin-detail/src/__tests__/DetailSection.emptyFieldsRemembered-25883.test.tsx b/ui/packages/plugin-detail/src/__tests__/DetailSection.emptyFieldsRemembered-25883.test.tsx index 16ed7963657..9f1ada277e6 100644 --- a/ui/packages/plugin-detail/src/__tests__/DetailSection.emptyFieldsRemembered-25883.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/DetailSection.emptyFieldsRemembered-25883.test.tsx @@ -23,7 +23,8 @@ * 3. another object's sections are unaffected; * 4. the store receives the choice per object, both ways; * 5. with no store (no host, no signed-in user) the toggle is what it was: - * per section, gone on unmount. + * per section, gone on unmount; + * 6. the choice never undoes an authored `hideEmpty: true` all-empty section. */ import React from 'react'; @@ -162,6 +163,23 @@ describe('the empty-field toggle is one remembered choice per object (objectstac expect(mem.writes).toEqual([]); }); + it('a remembered choice does not undo an authored `hideEmpty: true` on an all-empty section', () => { + const mem = memoryStore({ opportunity: { showEmptyFields: true } }); + render( + +
+ + , + ); + expect(emptyShown('Details')).toBe(true); + expect(screen.queryByText('Shipping')).toBeNull(); + expect(screen.queryByText('Shipping Name')).toBeNull(); + }); + it('with no store, the toggle stays per section and is gone on unmount', () => { const first = render( <>