Skip to content

Commit dfafae8

Browse files
feat(plugin-list): a reader can add any field they may read to their own list (#30077) (#30162)
domain:ui#2 · os-justin · session_01QSeKYnE9YNkSZen7sijLA3 Fixes #30077 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:列表的「隐藏字段」面板(工具栏的和「显示」面板里的同一节)在视图自带的列之后,列出该对象里读者有权读取的其余字段,默认不勾选;勾选就把它加成读者自己列表里的一列,取消勾选就拿掉。 - 为什么改:按本卡裁决 A(「其他全部同意」),业务员想在自己的列表里看负责人、折扣等字段时,不必再请管理员替全公司改视图。 - 风险与回滚:只存在读者个人偏好里(`sys_user_preference` 的 `ui.list.OBJECT` 行新增 `addedFields`),不写视图、不改元数据和 spec;回滚即 revert,旧版本的清洗函数会丢掉 `addedFields`,没有残留副作用。 - 先看:`ui/packages/plugin-list/src/ListView.tsx`(`addableFields` 的取数与 `updateListedFields` 里「取消勾选已加列」的分支);`ui/packages/app-shell/src/views/objectViewListRenderer.tsx`(`withAddedListColumns` 与 `columns` 那一级的拼接);`ui/packages/app-shell/src/context/ListPreferencesProvider.tsx`(`addedFields` 的清洗)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-list/src/__tests__/ListView.fieldListRead-11984.test.tsx packages/app-shell/src/views/ObjectView.userListState-12418.test.tsx`,预期两个文件全部通过。 - 需要你做的:无,席位落地。 ## What changes - **The offer (`@object-ui/plugin-list`).** Two new `ListView` host props: `addedFields` (the names among `schema.columns` the reader added) and `onAddedFieldsChange` (the door that stores them). When the door is wired, both hide-fields lists — the wide toolbar's Hide fields popover and the Hide Fields section of the Display panel (`ViewSettingsPopover`, the same door: it writes through the same `updateHiddenFields` callback) — list, after the view's columns, every other field of the object's definition that the caller may read (`canReadField`, the predicate every toolbar field list asks), that the definition does not mark `hidden: true`, and that is not already a column; business fields first, then system fields (`isSystemManagedField`), unticked. Ticking one calls `onAddedFieldsChange` with the whole new list; unticking an added column calls it without that name instead of hiding it; adding a name a stale hidden entry carries clears that entry too. A reader-added column is labelled from the definition in the popover. Without the door the popover is byte-for-byte today's. - **The store (`@object-ui/app-shell`).** `ListViewPreference` gains `addedFields` beside `hiddenFields` in the reader's `ui.list.OBJECT` row: sanitized to unique names, and an empty list is dropped (nothing added is the view's own). "Reset to view" removes it with the rest of the view's entry (`resetViewPreference`), unchanged code path. - **The host (`objectViewListRenderer`).** `withAddedListColumns` appends the stored added names to the `columns` rung in the view's own column shape (bare names beside bare names, `{ field }` beside column objects), skipping any name the view itself declares; the list therefore fetches (`$select`), expands (`$expand`, through `buildExpandFields` over `schema.columns`) and draws an added column exactly as a declared one. The door is wired where a Hide Fields choice is stored (a view that declares `columns`, or a saved view); on a system view or fallback tab that declares no columns it is not wired, matching the session-only hide there. - **The marker (`objectViewListUrlState`).** A stored `addedFields` reports the existing `columns` part, so the Personalized marker draws and names "Column layout" with no new copy. - `ui/packages/plugin-list/README.md` documents the two host props (ui/AGENTS.md #2). ## Tests All runs on this branch's worktree, under `scripts/pm/os-verify-lock.sh`; head at the final run: `916241ae1`. - New pins, `ListView.fieldListRead-11984.test.tsx` (top-level describe, objectstack#30077): wide and compact, restricted caller is offered `Title, Status, Priority` and not the two unreadable fields; full-read control offers them; door absent offers the view's columns alone; tick adds through the door and leaves hide/show of a view column unchanged; untick of an added column removes it and writes no hidden list; a stale hidden entry is cleared on add; the Display panel adds through the same door; a `hidden: true` field is not offered and system fields follow business fields; an added lookup is in the next `$select` and `$expand`. - New pins, `ObjectView.userListState-12418.test.tsx` (top-level describe): adding writes no view and lands as one save of `addedFields`; the list is handed the column appended; a fresh browser opens with it; another user does not see it; "Reset to view" drops it (the stored addition alone draws the marker); a name the view declares is not duplicated; a saved view that declares columns stores it the same way. - New pins, `ListPreferencesProvider.slot-12418.test.ts`: sanitize, patch/clear and reset of `addedFields`. <details><summary>Readings</summary> - Three touched test files on `916241ae1`: see Verification. - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-list/` (pre-merge head): `Test Files 1 failed | 188 passed (189)`, `Tests 1 failed | 1805 passed (1806)`; the one failure was this PR's own new lookup pin, which asserted a grid header this harness does not render (the grid registers lazily here). The pin was narrowed to the `$select`/`$expand` and popover assertions and re-run: `ListView.fieldListRead-11984.test.tsx` `Tests 31 passed (31)`. - app-shell, 101 files touching list preferences, the relay or the marker (every `ObjectView*` and `InterfaceListPage*` test plus the `git grep` hits for `ListPreferences`/`listPrefs`/`personaliz`/`onHiddenFieldsChange`): `Test Files 101 passed (101)`, `Tests 880 passed (880)`. The full app-shell suite (2172 files) is declared to CI's sharded Test UI. - `pnpm --filter @object-ui/plugin-list type-check` exit 0; `pnpm --filter @object-ui/app-shell type-check` exit 0 (pre-merge, and again post-merge, see Verification). - Reverse verification (one-shot, no permanent file): `addedFields={listColumns.addedFields}` replaced by `addedFields={42}` with `scripts/ablation-replace.mjs` (anchor 1 to 0, blob `91185df42a4e` to `8287f4f432e4`), `tsc --noEmit` in app-shell: `TSC_EXIT=2`, `error TS2322: Type 'number' is not assignable to type 'readonly string[]'`; restored, blob equal to HEAD (`91185df42a4e`) and `git diff HEAD` empty. The rebuilt `dist/ListView.d.ts` carries both props and `ListViewProps` declares no index signature. </details> ## Acceptance notes - **File surface.** The claim named the store path under `ui/packages/app-shell/src/views/**`; the store is `ui/packages/app-shell/src/context/ListPreferencesProvider.tsx` (its sanitizer drops any undeclared part, so an added list cannot persist without it). `objectViewListUrlState.ts` (under `views/**`) carries the marker part. `ui/packages/plugin-list/README.md` is added for ui/AGENTS.md #2. - **Lookup columns (card "Not measured").** No server change needed: `$expand` is built from `schema.columns` by `buildExpandFields`, so an added lookup is expanded as a declared one; pinned. No field type is excluded from the offer. - **Where it is not offered.** A system view or fallback tab that declares no `columns` (its hide is session-only by ruling 5839344270 on objectui#10694), and every host that does not wire `onAddedFieldsChange` (InterfaceListPage, page blocks). - **Copy unchanged.** The trigger still reads "Hide fields" and the section "Hide Fields"; the marker reports added fields as "Column layout", and the reset sentence already names "column layout". The Display trigger's count does not count added fields. Renaming the control would touch ten locale packs and is left as a note, not filed. - **Shared list.** The Group editor and Row color select read the same `allFields`, so they also list a column the reader added (it is a column of their list); they never list a field not on the list. - **Query width.** An added column widens `$select` by one field; the page size and existing caps are unchanged. - **Grid header of an added column** was not measured in a browser: this harness does not render the lazily registered grid. The popover label is pinned. ## Verification <details><summary>Post-merge head and gates</summary> Head `916241ae1` (this branch merged with `origin/main`; the incoming commits touch none of this diff's files): - `pnpm turbo run build --filter='@object-ui/app-shell^...' --concurrency=2`: exit 0, `Tasks: 67 successful, 67 total`. - `pnpm --filter @object-ui/app-shell type-check`: exit 0. - The three touched test files: `Test Files 3 passed (3)`, `Tests 84 passed (84)`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 36 commands on this head; 35 re-run here on `916241ae1`, each exit 0; `pnpm --dir ui exec vitest run scripts/` exit 0 (`Test Files 170 passed | 2 skipped (172)`) on the pre-merge head, and the merge brings no `ui/scripts` change. `--ran` reconciliation: `36 derived, 36 run, 0 NOT-MEASURED, 0 UNRUN` (a derived zero, every line with its exit code). - Also run: `pnpm check:changeset-fixed` exit 0; `pnpm check:new-line-citations` (under `ui/`) exit 0, `0 new citation(s)`; `check:doc-types`, `check:doc-json`, `check:doc-fences`, `check:doc-example-readers` exit 0 over the README change. NOT MEASURED: `check:doc-snippets` / `check:doc-examples`, reason: each needs its own scoped 33-package build first (exit 2, prerequisite), left to CI. - Not run locally, declared to CI: the full app-shell suite (2172 files, sharded Test UI), Lint UI, Type Check UI, and the lint/type-check lanes the derivation lists as CI-only. </details> ## Browser check Real Chromium (Playwright, real clicks and real typing), head `916241ae1`. Example CRM backend (`objectstack dev --seed-admin --fresh -p 4377` in `examples/app-crm`), Console built from this branch's `ui/` source (`vite --port 5377 --strictPort`, `DEV_PROXY_TARGET=http://localhost:4377`), locale en-US, signed in as the seeded admin, on Opportunities / All Opportunities (`crm_opportunity.all`). The organization's first-run "Set the default currency" dialog was answered once. 14 of 14 assertions passed. The screenshots are kept in the seat's scratchpad, not uploaded; the seat viewed `04-grid-added.png` and `11-labels.png`. 1. **List opens on the view's columns.** Headers: Opportunity Name, Account, Stage, Deal Value, Win Probability (%), Expected Revenue, Expected Close. PASS 2. **Offered unticked.** In Display, Hide Fields, Owner (`owner_id`, a lookup) and Days to Close (a formula field) are listed after the view's columns, unticked. Discount (%), Approval Status, Renewal Of and Products Total are offered too. PASS 3. **Tick both.** The grid header gains Owner and Days to Close. Owner cells read "Dev Admin" (the lookup's display name, not the id) and Days to Close cells carry values. The reader's preference `ui.list.crm_opportunity` reads `perView.crm_opportunity.all.addedFields = ["owner_id","days_to_close"]`. PASS 4. **Reload.** Both columns are still there. PASS 5. **Reset to view.** The Personalized marker names "Column layout". After the reset both columns are gone and the stored row is `{}`. PASS 6. **Untick an added column.** Days to Close goes back to the add list unticked; the stored row is `addedFields = ["owner_id"]` with no `hiddenFields`. After a reload it is still absent and still offered, so it was removed, not hidden. PASS **Rework round 2: labels** (head `cfcc48813`). The first run listed two of the view's own label-less columns under their machine names (`account`, `expected_revenue`) while the grid header read Account and Expected Revenue. `allFields` in `ListView.tsx` now falls back to the object definition's field label for every column with no label of its own. Order: translation, then the column's own label, then the definition's label, then the field name. 7. **Labels.** Hide Fields lists Opportunity Name, Account, Stage, Deal Value, Win Probability (%), Expected Revenue, Expected Close, equal item for item to the grid header row. 3 of 3 assertions passed. PASS Pin: `ListView.fieldListRead-11984.test.tsx`, "a column with no label of its own is listed under the definition's label, not its machine name"; ablated red (`expected [ 'title', 'status', 'Priority ] to include 'Status'`), restored green. The Group and Row color pickers share this list; the one pin that spelled the old raw name (`ListView.rowColorSelect-12376.test.tsx`, `subject` → `Subject`) was updated with its intent unchanged. Not changed here: the list puts a translation before a column's own label, while the grid header puts the column's own label first and prettifies a bare name. They agree whenever a column has no label, which is the case fixed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYnE9YNkSZen7sijLA3)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent bedb96e commit dfafae8

11 files changed

Lines changed: 600 additions & 20 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/plugin-list': patch
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
feat(plugin-list): a reader can add any field they may read to their own list from the Hide Fields popover (#30077)
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
The Hide Fields popover on a Console object list (the toolbar's, and the Hide Fields section of the Display panel) listed only the columns the view already has, so a reader who wanted another field on their list had to ask an administrator to change the view for everyone. After the view's columns it now lists every other field of the object the reader may read, unticked: business fields first, then system fields such as Owner. A field the reader may not read is not offered, and neither is a field the object definition marks `hidden`. Ticking one adds it as a column of the reader's own list; unticking it takes it off again.
12+
13+
The addition is the reader's personalization, stored beside their hidden fields in their `ui.list.<object>` preference row (`perView.<view>.addedFields`), never written into the view: another user sees the view's columns, and "Reset to view" drops it with the rest of the reader's list. While it is stored the list's Personalized marker names "Column layout". An added column is fetched and drawn like a declared one, and an added lookup field is expanded in the list read as a declared lookup column is.
14+
15+
A column the view declares with no label of its own (a bare `{ field }` or field name) is now listed in the Hide Fields popover (and in the Group and Row color pickers, which share its list) under the object definition's field label, the label the grid's header shows, instead of under its machine name (`expected_revenue` now reads Expected Revenue); a column's own label and a translation still come first.
16+
17+
It is offered wherever a Hide Fields choice is stored: a view that declares `columns`, and a saved view. On a system view or fallback tab that declares no columns the popover lists the view's columns alone, as before.
18+
19+
For a `ListView` host: two new host props, `addedFields` (the names among `schema.columns` the reader added) and `onAddedFieldsChange` (the door that stores them). Without `onAddedFieldsChange` the popover is exactly what it was. No metadata key and no spec change.

‎ui/packages/app-shell/src/context/ListPreferencesProvider.tsx‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
*
55
* What a user does to a list for themselves — the order of the view tabs, and
66
* per view the sort, the Filter panel's conditions, the column order and
7-
* widths, the row density and the hidden fields (objectui#12474) — is THEIR
8-
* preference: it follows them to another browser, and it
7+
* widths, the row density, the hidden fields (objectui#12474) and the fields
8+
* they added as columns (objectstack#30077) — is THEIR preference: it follows them to another browser, and it
99
* never changes the view for anyone else. It used to live in two wrong places:
1010
* the tab order and the filters in this browser's localStorage only, and the
1111
* sort and the column layout written INTO the shared view (`/meta/view`), so
@@ -19,7 +19,8 @@
1919
* ```json
2020
* { "viewOrder": ["<view id>", …],
2121
* "perView": { "<view id>": { "sort": […], "filters": {…}, "columnState": {…},
22-
* "rowHeight": "medium", "hiddenFields": ["<field>", …] } } }
22+
* "rowHeight": "medium", "hiddenFields": ["<field>", …],
23+
* "addedFields": ["<field>", …] } } }
2324
* ```
2425
*
2526
* The rows are loaded by prefix in the console's ONE preference read per load
@@ -102,6 +103,15 @@ export interface ListViewPreference {
102103
* absent.
103104
*/
104105
hiddenFields?: string[];
106+
/**
107+
* objectstack#30077 — the object's fields this user added to their list as
108+
* columns with the Hide Fields popover: an ADDITION to the columns the view
109+
* declares (the list draws `columns` plus these, then minus
110+
* `hiddenFields`). Only a field the reader may read is offered; a stored
111+
* name they can no longer read is kept and draws nothing. An empty list is
112+
* "nothing added", the same as absent, so it is not kept.
113+
*/
114+
addedFields?: string[];
105115
}
106116

107117
/** A user's state for one object: the `ui.list.<object>` row's value. */
@@ -172,6 +182,12 @@ function sanitizeHiddenFields(v: unknown): string[] | undefined {
172182
return [...new Set(v.filter((x): x is string => typeof x === 'string' && x.length > 0))];
173183
}
174184

185+
/** The added fields, de-duplicated; an empty list is no part at all. */
186+
function sanitizeAddedFields(v: unknown): string[] | undefined {
187+
const names = sanitizeHiddenFields(v);
188+
return names && names.length > 0 ? names : undefined;
189+
}
190+
175191
function sanitizeView(v: unknown): ListViewPreference | undefined {
176192
if (!isRecord(v)) return undefined;
177193
const out: ListViewPreference = {};
@@ -185,6 +201,8 @@ function sanitizeView(v: unknown): ListViewPreference | undefined {
185201
if (rowHeight) out.rowHeight = rowHeight;
186202
const hiddenFields = sanitizeHiddenFields(v.hiddenFields);
187203
if (hiddenFields) out.hiddenFields = hiddenFields;
204+
const addedFields = sanitizeAddedFields(v.addedFields);
205+
if (addedFields) out.addedFields = addedFields;
188206
return Object.keys(out).length > 0 ? out : undefined;
189207
}
190208

@@ -210,7 +228,8 @@ export function sanitizeObjectListPreference(v: unknown): ObjectListPreference {
210228
* with no order and no width. An empty sort is NOT a clear: `sort: []` is kept,
211229
* because a user who removed every sort level (the Sort builder emits `[]`)
212230
* chose "no sort", which is not the view's authored sort. The same holds for
213-
* `hiddenFields: []` ("show every column"). The ways back to the view's own
231+
* `hiddenFields: []` ("show every column"); `addedFields: []` is a clear, since
232+
* nothing added is the view's own. The ways back to the view's own
214233
* are "Reset view" (the whole entry, `resetViewPreference`) and the list's
215234
* "Reset to view default" for one part, which patches that part to `null`
216235
* (objectstack#28948) instead of storing the view's value as a choice. A view
@@ -222,9 +241,10 @@ export type ListViewPreferencePatch = {
222241
columnState?: ListColumnStatePreference | null;
223242
rowHeight?: RowHeight | null;
224243
hiddenFields?: readonly string[] | null;
244+
addedFields?: readonly string[] | null;
225245
};
226246

227-
const VIEW_PREFERENCE_PARTS = ['sort', 'filters', 'columnState', 'rowHeight', 'hiddenFields'] as const;
247+
const VIEW_PREFERENCE_PARTS = ['sort', 'filters', 'columnState', 'rowHeight', 'hiddenFields', 'addedFields'] as const;
228248

229249
/** Apply a patch to one view's entry of an object's state. Pure. */
230250
export function patchViewPreference(
@@ -251,7 +271,8 @@ export function patchViewPreference(
251271

252272
/**
253273
* Remove one view's entry — every part of it: the sort, filters, column state,
254-
* density and hidden fields (objectui#12474). Pure.
274+
* density, hidden fields (objectui#12474) and added fields (objectstack#30077).
275+
* Pure.
255276
*/
256277
export function resetViewPreference(state: ObjectListPreference, viewId: string): ObjectListPreference {
257278
if (!state.perView || !(viewId in state.perView)) return state;
@@ -493,7 +514,7 @@ export interface ObjectListPreferences {
493514
setViewOrder(ids: readonly string[]): void;
494515
/** Store (or clear, see {@link ListViewPreferencePatch}) part of one view's state. */
495516
patchView(viewId: string, patch: ListViewPreferencePatch): void;
496-
/** Drop one view's whole entry (sort, filters, columns, density, hidden fields): back to the view's own. */
517+
/** Drop one view's whole entry (sort, filters, columns, density, hidden and added fields): back to the view's own. */
497518
resetView(viewId: string): void;
498519
}
499520

‎ui/packages/app-shell/src/context/__tests__/ListPreferencesProvider.slot-12418.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,28 @@ describe('the density and the hidden fields are two more parts of a view\'s entr
159159
expect(resetViewPreference(state, 'v1')).toEqual({ viewOrder: ['v2', 'v1'], perView: { v2: { rowHeight: 'short' } } });
160160
});
161161
});
162+
163+
describe('the added fields are one more part of a view\'s entry (objectstack#30077)', () => {
164+
it('keeps the added fields as names, once each, and drops an empty list: nothing added is the view\'s own', () => {
165+
expect(sanitizeObjectListPreference({
166+
perView: {
167+
v1: { addedFields: ['owner', 3, '', 'owner', 'discount'] },
168+
v2: { addedFields: [] },
169+
v3: { addedFields: 'owner' },
170+
},
171+
})).toEqual({ perView: { v1: { addedFields: ['owner', 'discount'] } } });
172+
});
173+
174+
it('a patch sets the added fields beside the hidden ones; an empty list or `null` clears only that part', () => {
175+
let state = patchViewPreference({}, 'v1', { hiddenFields: ['amount'] });
176+
state = patchViewPreference(state, 'v1', { addedFields: ['owner'] });
177+
expect(state).toEqual({ perView: { v1: { hiddenFields: ['amount'], addedFields: ['owner'] } } });
178+
expect(patchViewPreference(state, 'v1', { addedFields: [] })).toEqual({ perView: { v1: { hiddenFields: ['amount'] } } });
179+
expect(patchViewPreference(state, 'v1', { addedFields: null })).toEqual({ perView: { v1: { hiddenFields: ['amount'] } } });
180+
});
181+
182+
it('"Reset view" drops the added fields with the rest of the view\'s entry', () => {
183+
const state = { perView: { v1: { addedFields: ['owner'] }, v2: { addedFields: ['discount'] } } };
184+
expect(resetViewPreference(state, 'v1')).toEqual({ perView: { v2: { addedFields: ['discount'] } } });
185+
});
186+
});

‎ui/packages/app-shell/src/views/ObjectView.userListState-12418.test.tsx‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@
5252
* sort) is told apart from a choice: the reset parts are CLEARED from
5353
* the stored entry, so the user follows later changes to the view; a
5454
* deliberate `sort: []` / `hiddenFields: []` is still stored.
55+
* 6e. objectstack#30077 — a field the reader adds with the Hide Fields
56+
* popover is theirs: it writes no view and lands as ONE save
57+
* (`addedFields`), the list is handed it appended to the view's
58+
* columns, a fresh browser opens with it, another user does not see it,
59+
* and "Reset to view" drops it.
5560
* 7. CONTROL — no provider (a host without the console shell): the state is
5661
* this browser's, as the tab order and filters were before; a sort still
5762
* writes no view; the older `viewOrder:<object>` key is still honoured.
@@ -889,6 +894,102 @@ describe('the list\'s "Reset to view default" clears the user\'s stored part ins
889894
});
890895
});
891896

897+
describe('a field the reader adds to their list is theirs, stored with their personalization (objectstack#30077)', () => {
898+
const STORED = { [LIST_KEY]: { perView: { [SYSTEM_ID]: { addedFields: ['owner'] } } } };
899+
900+
it('adding a field writes no view, lands as one save, and the list is handed it as a column', async () => {
901+
const ds = makeDataSource();
902+
const { family } = makeFamily({});
903+
mountWithFamily(ds, family);
904+
await listReady();
905+
expect(listProps.schema.columns).toEqual(['name', 'amount']);
906+
expect(listProps.addedFields).toEqual([]);
907+
expect(typeof listProps.onAddedFieldsChange).toBe('function');
908+
909+
act(() => listProps.onAddedFieldsChange(['owner']));
910+
await wait(700);
911+
912+
expect(viewWrites(ds)).toBe(0);
913+
expect(toast.error).not.toHaveBeenCalled();
914+
expect(family.save).toHaveBeenCalledTimes(1);
915+
expect(family.save).toHaveBeenCalledWith(LIST_KEY, { perView: { [SYSTEM_ID]: { addedFields: ['owner'] } } });
916+
expect(listProps.schema.columns).toEqual(['name', 'amount', 'owner']);
917+
expect(listProps.addedFields).toEqual(['owner']);
918+
});
919+
920+
it('a fresh browser opens with the added column once the family has loaded', async () => {
921+
const ds = makeDataSource();
922+
const { family, release } = makeFamily(structuredClone(STORED), { held: true });
923+
mountWithFamily(ds, family);
924+
await screen.findByTestId('object-view-list-preferences-pending');
925+
await act(async () => release());
926+
await listReady();
927+
928+
expect(listProps.schema.columns).toEqual(['name', 'amount', 'owner']);
929+
expect(listProps.addedFields).toEqual(['owner']);
930+
});
931+
932+
it('another user, with no state of their own, sees the view\'s columns alone', async () => {
933+
session.userId = 'u2';
934+
const ds = makeDataSource();
935+
const { family } = makeFamily({});
936+
mountWithFamily(ds, family);
937+
await listReady();
938+
939+
expect(listProps.schema.columns).toEqual(['name', 'amount']);
940+
expect(listProps.addedFields).toEqual([]);
941+
});
942+
943+
it('"Reset to view" drops the added field: the stored added fields alone draw the marker, and the list opens on the view\'s columns', async () => {
944+
const ds = makeDataSource();
945+
const { family } = makeFamily(structuredClone(STORED));
946+
mountWithFamily(ds, family);
947+
await listReady();
948+
expect(listProps.schema.columns).toEqual(['name', 'amount', 'owner']);
949+
950+
// Nothing reported by the list: the stored addition is a part of its own.
951+
fireEvent.click(await screen.findByTestId('list-personalized-marker'));
952+
const parts = await screen.findByTestId('list-personalized-parts');
953+
expect([...parts.querySelectorAll('li')].map((li) => li.getAttribute('data-part'))).toEqual(['columns']);
954+
await act(async () => {
955+
fireEvent.click(screen.getByTestId('list-personalized-reset'));
956+
});
957+
await wait(700);
958+
959+
expect(viewWrites(ds)).toBe(0);
960+
expect(family.save).toHaveBeenCalledTimes(1);
961+
expect(family.save).toHaveBeenCalledWith(LIST_KEY, {});
962+
expect(listProps.schema.columns).toEqual(['name', 'amount']);
963+
expect(listProps.addedFields).toEqual([]);
964+
expect(screen.queryByTestId('list-personalized-marker')).toBeNull();
965+
});
966+
967+
it('a stored name the view declares itself is the view\'s column, not an addition: no duplicate, nothing handed as added', async () => {
968+
const ds = makeDataSource();
969+
const { family } = makeFamily({ [LIST_KEY]: { perView: { [SYSTEM_ID]: { addedFields: ['amount', 'owner'] } } } });
970+
mountWithFamily(ds, family);
971+
await listReady();
972+
973+
expect(listProps.schema.columns).toEqual(['name', 'amount', 'owner']);
974+
expect(listProps.addedFields).toEqual(['owner']);
975+
});
976+
977+
it('on a saved view that declares `columns` the addition is stored and applied the same way', async () => {
978+
const ds = makeDataSource();
979+
const { family } = makeFamily({});
980+
mountWithFamily(ds, family, SAVED_ID);
981+
await listReady();
982+
expect(listProps.schema.columns).toEqual(['name']);
983+
984+
act(() => listProps.onAddedFieldsChange(['amount']));
985+
await wait(700);
986+
987+
expect(viewWrites(ds)).toBe(0);
988+
expect(family.save).toHaveBeenCalledWith(LIST_KEY, { perView: { [SAVED_ID]: { addedFields: ['amount'] } } });
989+
expect(listProps.schema.columns).toEqual(['name', 'amount']);
990+
});
991+
});
992+
892993
describe('CONTROL — no provider: this browser\'s state, as before (objectui#12418)', () => {
893994
function mountBare(ds: ConsoleObjectViewProps['dataSource'], viewId = SYSTEM_ID) {
894995
return render(page(ds, viewId));

‎ui/packages/app-shell/src/views/objectViewListRenderer.tsx‎

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*/
1010

1111
import { useCallback, useRef, lazy, Suspense, type ReactNode } from 'react';
12-
import { DENSITY_MODE_TO_ROW_HEIGHT, normalizeListViewSchema, type FilterTokenScope } from '@object-ui/core';
12+
import { DENSITY_MODE_TO_ROW_HEIGHT, columnIdentity, normalizeListViewSchema, type FilterTokenScope } from '@object-ui/core';
1313
import { buildListFilterKey, writeListFilterState } from './listFilterStorage.js';
1414
import type { useObjectListPreferences, ListViewPreference, ListViewPreferencePatch } from '../context/ListPreferencesProvider.js';
1515
const ObjectChart = lazy(() =>
@@ -43,6 +43,29 @@ import type { useObjectViewToolbarAffordances } from './objectViewToolbarAfforda
4343
import type { useObjectViewListUrlState, useObjectViewUrlFilters } from './objectViewListUrlState.js';
4444
import type { ConsoleObjectViewProps } from './ObjectView.js';
4545

46+
/**
47+
* objectstack#30077 — the view's columns with the fields this user added
48+
* appended, in the shape the view's own columns take (a bare name beside bare
49+
* names, `{ field }` beside column objects), and the added names that are not
50+
* already one of the view's columns. A name the view itself declares is the
51+
* view's column, not an addition: it is left out of both, so hiding it goes
52+
* through `hiddenFields` as before. With nothing added the columns are
53+
* returned as they came.
54+
*/
55+
export function withAddedListColumns(
56+
columns: ListViewSchema['columns'],
57+
added: readonly string[] | undefined,
58+
): { columns: ListViewSchema['columns']; addedFields: string[] } {
59+
if (!added || added.length === 0) return { columns, addedFields: [] };
60+
const declared = Array.isArray(columns) ? (columns as unknown[]) : [];
61+
const names = new Set(declared.map((c) => columnIdentity(c)).filter((n): n is string => !!n));
62+
const addedFields = added.filter((name) => !names.has(name));
63+
if (addedFields.length === 0) return { columns, addedFields };
64+
const asObjects = declared.some((c) => typeof c !== 'string');
65+
const appended = addedFields.map((field) => (asObjects ? { field } : field));
66+
return { columns: [...declared, ...appended] as ListViewSchema['columns'], addedFields };
67+
}
68+
4669
export function useObjectViewListRenderer({
4770
objectName,
4871
inSetup,
@@ -383,6 +406,27 @@ export function useObjectViewListRenderer({
383406
listPrefs.patchView(viewDef.id, { hiddenFields: hidden });
384407
};
385408

409+
/**
410+
* objectstack#30077 (ruling 6631126993, option A) — the object's fields
411+
* this user ADDED to their list as columns with the same popover, stored
412+
* beside their hidden fields (`addedFields`) and dropped with the rest of
413+
* the entry by "Reset to view". The `columns` rung below appends them to
414+
* the view's columns, so the list fetches, expands and draws them as it
415+
* does a declared column; `ListView` offers only fields the caller may
416+
* read. Offered where a Hide Fields choice is stored, never on the
417+
* system view or fallback tab whose hide is session-only (objectui#10694
418+
* ruling 5839344270): there the door is not wired, so the popover lists
419+
* the view's columns alone, as before.
420+
*/
421+
const addedFieldsStored = activeViewDeclaresColumns || activeViewIsSaved;
422+
const listColumns = withAddedListColumns(
423+
listSchema.columns,
424+
addedFieldsStored ? viewPref?.addedFields : undefined,
425+
);
426+
const persistAddedFields = (added: string[]) => {
427+
listPrefs.patchView(viewDef.id, { addedFields: added });
428+
};
429+
386430
/**
387431
* objectui#12949 — the VIEW'S OWN density and hidden fields, before
388432
* this user's preference (objectui#12474) is laid over them by the
@@ -457,7 +501,9 @@ export function useObjectViewListRenderer({
457501
// objectstack#27626 — `columns` is named by identity in `ListView`'s
458502
// fetch effect too, and opening or closing a record hands this page
459503
// new object documents of equal content: hold it like the blocks.
460-
columns: holdBlock('columns', listSchema.columns),
504+
// objectstack#30077 — plus the fields this user added (see
505+
// `withAddedListColumns`).
506+
columns: holdBlock('columns', listColumns.columns),
461507
// objectui#10035 — the refresh signal, in place of the remount the
462508
// key used to force. The spread above carries only the plugin
463509
// ObjectView's counter; this page's own counter (actions, import,
@@ -946,6 +992,10 @@ export function useObjectViewListRenderer({
946992
writeListUrlState({ search });
947993
}}
948994
onHiddenFieldsChange={persistHiddenFields}
995+
// objectstack#30077 — the fields this user added as columns, and
996+
// the door that stores a change to them (see `persistAddedFields`).
997+
addedFields={listColumns.addedFields}
998+
onAddedFieldsChange={addedFieldsStored ? persistAddedFields : undefined}
949999
// objectui#5144 (ruling E): session-only, writes nothing.
9501000
onInlineEditChange={keepInlineEditModeForTheSession}
9511001
onColumnStateChange={persistListColumnState}

0 commit comments

Comments
 (0)