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
21 changes: 21 additions & 0 deletions .changeset/25883-empty-fields-remembered.md
Original file line number Diff line number Diff line change
@@ -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.<object>`, with the value `{ "showEmptyFields": true }` or `false`. The row sits beside the list page's `ui.list.<object>` 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.
80 changes: 49 additions & 31 deletions ui/packages/app-shell/src/console/ConsoleShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -194,36 +199,38 @@ function ConsoleShellProviders({ children }: { children: ReactNode }) {
<RecentItemsProvider>
<FlowPaletteRecentsProvider>
<ListPreferencesProvider>
{/* 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. */}
<ImpersonationBanner />
{/* 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). */}
<ReadRateBanner />
{/* 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). */}
<StorageUsageBanner />
<Suspense fallback={<LoadingFallback />}>{children}</Suspense>
{/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */}
<RemediationOverlay />
<NotificationSnackbar />
<NotificationAlerts />
<RecordPreferencesProvider>
{/* 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. */}
<ImpersonationBanner />
{/* 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). */}
<ReadRateBanner />
{/* 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). */}
<StorageUsageBanner />
<Suspense fallback={<LoadingFallback />}>{children}</Suspense>
{/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */}
<RemediationOverlay />
<NotificationSnackbar />
<NotificationAlerts />
</RecordPreferencesProvider>
</ListPreferencesProvider>
</FlowPaletteRecentsProvider>
</RecentItemsProvider>
Expand Down Expand Up @@ -350,13 +357,15 @@ function UserStateBridge() {
const dataSource = useAdapter();
const attach = useAttachUserStateAdapters();
const attachListState = useAttachListStateFamily();
const attachRecordState = useAttachRecordStateFamily();

useEffect(() => {
if (!user?.id || !dataSource) {
attach('favorites', null);
attach('recent', null);
attach('flowPaletteRecents', null);
attachListState(null);
attachRecordState(null);
return;
}
const favorites = createObjectStackUserStateAdapter({
Expand Down Expand Up @@ -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.<object>`
// ("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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.<object>`, 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
Expand All @@ -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
Expand Down Expand Up @@ -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';
Expand All @@ -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<string, Record<string, unknown>> = {
Expand All @@ -104,12 +114,14 @@ const STORE: Record<string, Record<string, unknown>> = {
[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 },
},
};

Expand All @@ -125,21 +137,24 @@ 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;
}

/**
* Module-scope holders, written from an effect and never from the render phase
* — 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: {
addFavorite?: ReturnType<typeof useFavorites>['addFavorite'];
addRecentItem?: ReturnType<typeof useRecentItems>['addRecentItem'];
recordRecent?: ReturnType<typeof useFlowPaletteRecents>['recordRecent'];
patchListView?: ReturnType<typeof useObjectListPreferences>['patchView'];
updateRecordPage?: NonNullable<ReturnType<typeof useRecordPagePreferences>>['update'];
};
} = { current: {} };

Expand All @@ -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 <main data-testid="content">content</main>;
}

Expand All @@ -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)', () => {
Expand All @@ -207,16 +226,17 @@ 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();

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 () => {
Expand All @@ -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',
);
Expand All @@ -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);
});
Expand All @@ -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);
});
Expand Down
Loading
Loading