diff --git a/.changeset/30077-list-add-columns.md b/.changeset/30077-list-add-columns.md new file mode 100644 index 00000000000..b454546debe --- /dev/null +++ b/.changeset/30077-list-add-columns.md @@ -0,0 +1,19 @@ +--- +'@object-ui/plugin-list': patch +'@object-ui/app-shell': patch +--- + +feat(plugin-list): a reader can add any field they may read to their own list from the Hide Fields popover (#30077) + +Clause-②: no +Metadata-protocol: none + +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. + +The addition is the reader's personalization, stored beside their hidden fields in their `ui.list.` preference row (`perView..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. + +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. + +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. + +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. diff --git a/ui/packages/app-shell/src/context/ListPreferencesProvider.tsx b/ui/packages/app-shell/src/context/ListPreferencesProvider.tsx index 0267ae99e3a..0b8e6745e5d 100644 --- a/ui/packages/app-shell/src/context/ListPreferencesProvider.tsx +++ b/ui/packages/app-shell/src/context/ListPreferencesProvider.tsx @@ -4,8 +4,8 @@ * * What a user does to a list for themselves — the order of the view tabs, and * per view the sort, the Filter panel's conditions, the column order and - * widths, the row density and the hidden fields (objectui#12474) — is THEIR - * preference: it follows them to another browser, and it + * widths, the row density, the hidden fields (objectui#12474) and the fields + * they added as columns (objectstack#30077) — is THEIR preference: it follows them to another browser, and it * never changes the view for anyone else. It used to live in two wrong places: * the tab order and the filters in this browser's localStorage only, and the * sort and the column layout written INTO the shared view (`/meta/view`), so @@ -19,7 +19,8 @@ * ```json * { "viewOrder": ["", …], * "perView": { "": { "sort": […], "filters": {…}, "columnState": {…}, - * "rowHeight": "medium", "hiddenFields": ["", …] } } } + * "rowHeight": "medium", "hiddenFields": ["", …], + * "addedFields": ["", …] } } } * ``` * * The rows are loaded by prefix in the console's ONE preference read per load @@ -102,6 +103,15 @@ export interface ListViewPreference { * absent. */ hiddenFields?: string[]; + /** + * objectstack#30077 — the object's fields this user added to their list as + * columns with the Hide Fields popover: an ADDITION to the columns the view + * declares (the list draws `columns` plus these, then minus + * `hiddenFields`). Only a field the reader may read is offered; a stored + * name they can no longer read is kept and draws nothing. An empty list is + * "nothing added", the same as absent, so it is not kept. + */ + addedFields?: string[]; } /** A user's state for one object: the `ui.list.` row's value. */ @@ -172,6 +182,12 @@ function sanitizeHiddenFields(v: unknown): string[] | undefined { return [...new Set(v.filter((x): x is string => typeof x === 'string' && x.length > 0))]; } +/** The added fields, de-duplicated; an empty list is no part at all. */ +function sanitizeAddedFields(v: unknown): string[] | undefined { + const names = sanitizeHiddenFields(v); + return names && names.length > 0 ? names : undefined; +} + function sanitizeView(v: unknown): ListViewPreference | undefined { if (!isRecord(v)) return undefined; const out: ListViewPreference = {}; @@ -185,6 +201,8 @@ function sanitizeView(v: unknown): ListViewPreference | undefined { if (rowHeight) out.rowHeight = rowHeight; const hiddenFields = sanitizeHiddenFields(v.hiddenFields); if (hiddenFields) out.hiddenFields = hiddenFields; + const addedFields = sanitizeAddedFields(v.addedFields); + if (addedFields) out.addedFields = addedFields; return Object.keys(out).length > 0 ? out : undefined; } @@ -210,7 +228,8 @@ export function sanitizeObjectListPreference(v: unknown): ObjectListPreference { * with no order and no width. An empty sort is NOT a clear: `sort: []` is kept, * because a user who removed every sort level (the Sort builder emits `[]`) * chose "no sort", which is not the view's authored sort. The same holds for - * `hiddenFields: []` ("show every column"). The ways back to the view's own + * `hiddenFields: []` ("show every column"); `addedFields: []` is a clear, since + * nothing added is the view's own. The ways back to the view's own * are "Reset view" (the whole entry, `resetViewPreference`) and the list's * "Reset to view default" for one part, which patches that part to `null` * (objectstack#28948) instead of storing the view's value as a choice. A view @@ -222,9 +241,10 @@ export type ListViewPreferencePatch = { columnState?: ListColumnStatePreference | null; rowHeight?: RowHeight | null; hiddenFields?: readonly string[] | null; + addedFields?: readonly string[] | null; }; -const VIEW_PREFERENCE_PARTS = ['sort', 'filters', 'columnState', 'rowHeight', 'hiddenFields'] as const; +const VIEW_PREFERENCE_PARTS = ['sort', 'filters', 'columnState', 'rowHeight', 'hiddenFields', 'addedFields'] as const; /** Apply a patch to one view's entry of an object's state. Pure. */ export function patchViewPreference( @@ -251,7 +271,8 @@ export function patchViewPreference( /** * Remove one view's entry — every part of it: the sort, filters, column state, - * density and hidden fields (objectui#12474). Pure. + * density, hidden fields (objectui#12474) and added fields (objectstack#30077). + * Pure. */ export function resetViewPreference(state: ObjectListPreference, viewId: string): ObjectListPreference { if (!state.perView || !(viewId in state.perView)) return state; @@ -493,7 +514,7 @@ export interface ObjectListPreferences { setViewOrder(ids: readonly string[]): void; /** Store (or clear, see {@link ListViewPreferencePatch}) part of one view's state. */ patchView(viewId: string, patch: ListViewPreferencePatch): void; - /** Drop one view's whole entry (sort, filters, columns, density, hidden fields): back to the view's own. */ + /** Drop one view's whole entry (sort, filters, columns, density, hidden and added fields): back to the view's own. */ resetView(viewId: string): void; } diff --git a/ui/packages/app-shell/src/context/__tests__/ListPreferencesProvider.slot-12418.test.ts b/ui/packages/app-shell/src/context/__tests__/ListPreferencesProvider.slot-12418.test.ts index 19630fd1c01..23765b77cd7 100644 --- a/ui/packages/app-shell/src/context/__tests__/ListPreferencesProvider.slot-12418.test.ts +++ b/ui/packages/app-shell/src/context/__tests__/ListPreferencesProvider.slot-12418.test.ts @@ -159,3 +159,28 @@ describe('the density and the hidden fields are two more parts of a view\'s entr expect(resetViewPreference(state, 'v1')).toEqual({ viewOrder: ['v2', 'v1'], perView: { v2: { rowHeight: 'short' } } }); }); }); + +describe('the added fields are one more part of a view\'s entry (objectstack#30077)', () => { + it('keeps the added fields as names, once each, and drops an empty list: nothing added is the view\'s own', () => { + expect(sanitizeObjectListPreference({ + perView: { + v1: { addedFields: ['owner', 3, '', 'owner', 'discount'] }, + v2: { addedFields: [] }, + v3: { addedFields: 'owner' }, + }, + })).toEqual({ perView: { v1: { addedFields: ['owner', 'discount'] } } }); + }); + + it('a patch sets the added fields beside the hidden ones; an empty list or `null` clears only that part', () => { + let state = patchViewPreference({}, 'v1', { hiddenFields: ['amount'] }); + state = patchViewPreference(state, 'v1', { addedFields: ['owner'] }); + expect(state).toEqual({ perView: { v1: { hiddenFields: ['amount'], addedFields: ['owner'] } } }); + expect(patchViewPreference(state, 'v1', { addedFields: [] })).toEqual({ perView: { v1: { hiddenFields: ['amount'] } } }); + expect(patchViewPreference(state, 'v1', { addedFields: null })).toEqual({ perView: { v1: { hiddenFields: ['amount'] } } }); + }); + + it('"Reset view" drops the added fields with the rest of the view\'s entry', () => { + const state = { perView: { v1: { addedFields: ['owner'] }, v2: { addedFields: ['discount'] } } }; + expect(resetViewPreference(state, 'v1')).toEqual({ perView: { v2: { addedFields: ['discount'] } } }); + }); +}); diff --git a/ui/packages/app-shell/src/views/ObjectView.userListState-12418.test.tsx b/ui/packages/app-shell/src/views/ObjectView.userListState-12418.test.tsx index 1762b35300b..f1cf92227a4 100644 --- a/ui/packages/app-shell/src/views/ObjectView.userListState-12418.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.userListState-12418.test.tsx @@ -52,6 +52,11 @@ * sort) is told apart from a choice: the reset parts are CLEARED from * the stored entry, so the user follows later changes to the view; a * deliberate `sort: []` / `hiddenFields: []` is still stored. + * 6e. objectstack#30077 — a field the reader adds with the Hide Fields + * popover is theirs: it writes no view and lands as ONE save + * (`addedFields`), the list is handed it appended to the view's + * columns, a fresh browser opens with it, another user does not see it, + * and "Reset to view" drops it. * 7. CONTROL — no provider (a host without the console shell): the state is * this browser's, as the tab order and filters were before; a sort still * writes no view; the older `viewOrder:` key is still honoured. @@ -889,6 +894,102 @@ describe('the list\'s "Reset to view default" clears the user\'s stored part ins }); }); +describe('a field the reader adds to their list is theirs, stored with their personalization (objectstack#30077)', () => { + const STORED = { [LIST_KEY]: { perView: { [SYSTEM_ID]: { addedFields: ['owner'] } } } }; + + it('adding a field writes no view, lands as one save, and the list is handed it as a column', async () => { + const ds = makeDataSource(); + const { family } = makeFamily({}); + mountWithFamily(ds, family); + await listReady(); + expect(listProps.schema.columns).toEqual(['name', 'amount']); + expect(listProps.addedFields).toEqual([]); + expect(typeof listProps.onAddedFieldsChange).toBe('function'); + + act(() => listProps.onAddedFieldsChange(['owner'])); + await wait(700); + + expect(viewWrites(ds)).toBe(0); + expect(toast.error).not.toHaveBeenCalled(); + expect(family.save).toHaveBeenCalledTimes(1); + expect(family.save).toHaveBeenCalledWith(LIST_KEY, { perView: { [SYSTEM_ID]: { addedFields: ['owner'] } } }); + expect(listProps.schema.columns).toEqual(['name', 'amount', 'owner']); + expect(listProps.addedFields).toEqual(['owner']); + }); + + it('a fresh browser opens with the added column once the family has loaded', async () => { + const ds = makeDataSource(); + const { family, release } = makeFamily(structuredClone(STORED), { held: true }); + mountWithFamily(ds, family); + await screen.findByTestId('object-view-list-preferences-pending'); + await act(async () => release()); + await listReady(); + + expect(listProps.schema.columns).toEqual(['name', 'amount', 'owner']); + expect(listProps.addedFields).toEqual(['owner']); + }); + + it('another user, with no state of their own, sees the view\'s columns alone', async () => { + session.userId = 'u2'; + const ds = makeDataSource(); + const { family } = makeFamily({}); + mountWithFamily(ds, family); + await listReady(); + + expect(listProps.schema.columns).toEqual(['name', 'amount']); + expect(listProps.addedFields).toEqual([]); + }); + + 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 () => { + const ds = makeDataSource(); + const { family } = makeFamily(structuredClone(STORED)); + mountWithFamily(ds, family); + await listReady(); + expect(listProps.schema.columns).toEqual(['name', 'amount', 'owner']); + + // Nothing reported by the list: the stored addition is a part of its own. + fireEvent.click(await screen.findByTestId('list-personalized-marker')); + const parts = await screen.findByTestId('list-personalized-parts'); + expect([...parts.querySelectorAll('li')].map((li) => li.getAttribute('data-part'))).toEqual(['columns']); + await act(async () => { + fireEvent.click(screen.getByTestId('list-personalized-reset')); + }); + await wait(700); + + expect(viewWrites(ds)).toBe(0); + expect(family.save).toHaveBeenCalledTimes(1); + expect(family.save).toHaveBeenCalledWith(LIST_KEY, {}); + expect(listProps.schema.columns).toEqual(['name', 'amount']); + expect(listProps.addedFields).toEqual([]); + expect(screen.queryByTestId('list-personalized-marker')).toBeNull(); + }); + + it('a stored name the view declares itself is the view\'s column, not an addition: no duplicate, nothing handed as added', async () => { + const ds = makeDataSource(); + const { family } = makeFamily({ [LIST_KEY]: { perView: { [SYSTEM_ID]: { addedFields: ['amount', 'owner'] } } } }); + mountWithFamily(ds, family); + await listReady(); + + expect(listProps.schema.columns).toEqual(['name', 'amount', 'owner']); + expect(listProps.addedFields).toEqual(['owner']); + }); + + it('on a saved view that declares `columns` the addition is stored and applied the same way', async () => { + const ds = makeDataSource(); + const { family } = makeFamily({}); + mountWithFamily(ds, family, SAVED_ID); + await listReady(); + expect(listProps.schema.columns).toEqual(['name']); + + act(() => listProps.onAddedFieldsChange(['amount'])); + await wait(700); + + expect(viewWrites(ds)).toBe(0); + expect(family.save).toHaveBeenCalledWith(LIST_KEY, { perView: { [SAVED_ID]: { addedFields: ['amount'] } } }); + expect(listProps.schema.columns).toEqual(['name', 'amount']); + }); +}); + describe('CONTROL — no provider: this browser\'s state, as before (objectui#12418)', () => { function mountBare(ds: ConsoleObjectViewProps['dataSource'], viewId = SYSTEM_ID) { return render(page(ds, viewId)); diff --git a/ui/packages/app-shell/src/views/objectViewListRenderer.tsx b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx index 2c54fd62de4..91185df42a4 100644 --- a/ui/packages/app-shell/src/views/objectViewListRenderer.tsx +++ b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx @@ -9,7 +9,7 @@ */ import { useCallback, useRef, lazy, Suspense, type ReactNode } from 'react'; -import { DENSITY_MODE_TO_ROW_HEIGHT, normalizeListViewSchema, type FilterTokenScope } from '@object-ui/core'; +import { DENSITY_MODE_TO_ROW_HEIGHT, columnIdentity, normalizeListViewSchema, type FilterTokenScope } from '@object-ui/core'; import { buildListFilterKey, writeListFilterState } from './listFilterStorage.js'; import type { useObjectListPreferences, ListViewPreference, ListViewPreferencePatch } from '../context/ListPreferencesProvider.js'; const ObjectChart = lazy(() => @@ -43,6 +43,29 @@ import type { useObjectViewToolbarAffordances } from './objectViewToolbarAfforda import type { useObjectViewListUrlState, useObjectViewUrlFilters } from './objectViewListUrlState.js'; import type { ConsoleObjectViewProps } from './ObjectView.js'; +/** + * objectstack#30077 — the view's columns with the fields this user added + * appended, in the shape the view's own columns take (a bare name beside bare + * names, `{ field }` beside column objects), and the added names that are not + * already one of the view's columns. A name the view itself declares is the + * view's column, not an addition: it is left out of both, so hiding it goes + * through `hiddenFields` as before. With nothing added the columns are + * returned as they came. + */ +export function withAddedListColumns( + columns: ListViewSchema['columns'], + added: readonly string[] | undefined, +): { columns: ListViewSchema['columns']; addedFields: string[] } { + if (!added || added.length === 0) return { columns, addedFields: [] }; + const declared = Array.isArray(columns) ? (columns as unknown[]) : []; + const names = new Set(declared.map((c) => columnIdentity(c)).filter((n): n is string => !!n)); + const addedFields = added.filter((name) => !names.has(name)); + if (addedFields.length === 0) return { columns, addedFields }; + const asObjects = declared.some((c) => typeof c !== 'string'); + const appended = addedFields.map((field) => (asObjects ? { field } : field)); + return { columns: [...declared, ...appended] as ListViewSchema['columns'], addedFields }; +} + export function useObjectViewListRenderer({ objectName, inSetup, @@ -383,6 +406,27 @@ export function useObjectViewListRenderer({ listPrefs.patchView(viewDef.id, { hiddenFields: hidden }); }; + /** + * objectstack#30077 (ruling 6631126993, option A) — the object's fields + * this user ADDED to their list as columns with the same popover, stored + * beside their hidden fields (`addedFields`) and dropped with the rest of + * the entry by "Reset to view". The `columns` rung below appends them to + * the view's columns, so the list fetches, expands and draws them as it + * does a declared column; `ListView` offers only fields the caller may + * read. Offered where a Hide Fields choice is stored, never on the + * system view or fallback tab whose hide is session-only (objectui#10694 + * ruling 5839344270): there the door is not wired, so the popover lists + * the view's columns alone, as before. + */ + const addedFieldsStored = activeViewDeclaresColumns || activeViewIsSaved; + const listColumns = withAddedListColumns( + listSchema.columns, + addedFieldsStored ? viewPref?.addedFields : undefined, + ); + const persistAddedFields = (added: string[]) => { + listPrefs.patchView(viewDef.id, { addedFields: added }); + }; + /** * objectui#12949 — the VIEW'S OWN density and hidden fields, before * this user's preference (objectui#12474) is laid over them by the @@ -457,7 +501,9 @@ export function useObjectViewListRenderer({ // objectstack#27626 — `columns` is named by identity in `ListView`'s // fetch effect too, and opening or closing a record hands this page // new object documents of equal content: hold it like the blocks. - columns: holdBlock('columns', listSchema.columns), + // objectstack#30077 — plus the fields this user added (see + // `withAddedListColumns`). + columns: holdBlock('columns', listColumns.columns), // objectui#10035 — the refresh signal, in place of the remount the // key used to force. The spread above carries only the plugin // ObjectView's counter; this page's own counter (actions, import, @@ -946,6 +992,10 @@ export function useObjectViewListRenderer({ writeListUrlState({ search }); }} onHiddenFieldsChange={persistHiddenFields} + // objectstack#30077 — the fields this user added as columns, and + // the door that stores a change to them (see `persistAddedFields`). + addedFields={listColumns.addedFields} + onAddedFieldsChange={addedFieldsStored ? persistAddedFields : undefined} // objectui#5144 (ruling E): session-only, writes nothing. onInlineEditChange={keepInlineEditModeForTheSession} onColumnStateChange={persistListColumnState} diff --git a/ui/packages/app-shell/src/views/objectViewListUrlState.ts b/ui/packages/app-shell/src/views/objectViewListUrlState.ts index 068d2fe758c..d9a91433b03 100644 --- a/ui/packages/app-shell/src/views/objectViewListUrlState.ts +++ b/ui/packages/app-shell/src/views/objectViewListUrlState.ts @@ -257,7 +257,8 @@ export function useObjectViewListUrlState({ * the list reports it (`ListView`'s `onPersonalizationChange`), held with * the list identity it came from so a report never outlives its list. The * column layout is the one part only this page knows: a stored column - * state is the reader's own. + * state is the reader's own, and so are the fields they added as columns + * (objectstack#30077), which the list draws as columns of the view. */ const [reportedPersonalization, setReportedPersonalization] = useState<{ key: string; @@ -275,7 +276,8 @@ export function useObjectViewListUrlState({ ? reportedPersonalization.parts : []), ]; - if (activeView?.id && listPrefs.preference.perView?.[activeView.id]?.columnState + const storedViewPreference = activeView?.id ? listPrefs.preference.perView?.[activeView.id] : undefined; + if ((storedViewPreference?.columnState || storedViewPreference?.addedFields?.length) && !listPersonalizedParts.includes('columns')) { listPersonalizedParts.push('columns'); } @@ -284,7 +286,7 @@ export function useObjectViewListUrlState({ * objectstack#28950 — "Reset to view", the marker's one way back: the * reader's whole list for the active view returns to the view's own. Their * stored entry goes (sort, filters, column layout, density, hidden fields, - * objectui#12418), the cached search term with it, and the list opens + * objectui#12418; added fields, objectstack#30077), the cached search term with it, and the list opens * again from that cleared state (the identity moves, so it remounts and * re-seeds; a reset is the one case that WANTS the list's own state thrown * away). The new seed ignores the `uf_*` params and its write-back strips diff --git a/ui/packages/plugin-list/README.md b/ui/packages/plugin-list/README.md index 3bc10ffef0a..45a9e3e7429 100644 --- a/ui/packages/plugin-list/README.md +++ b/ui/packages/plugin-list/README.md @@ -266,6 +266,32 @@ import { ListView } from '@object-ui/plugin-list'; /> ``` +### Host props: `addedFields` and `onAddedFieldsChange` + +A host that lets a reader add fields to their own list wires +`onAddedFieldsChange`. The Hide Fields popover (the toolbar's and the Display +panel's section) then lists, after the view's columns, every other field of the +object the reader may read and the definition does not mark `hidden`, unticked. +Ticking one calls `onAddedFieldsChange` with the whole new list of added names; +the host stores it as the reader's own and appends those fields to +`schema.columns`, naming them in `addedFields`, so unticking an added column +takes it off the list instead of hiding it. These are React props for the host +app, not schema keys: list metadata cannot set them. Without +`onAddedFieldsChange` the popover lists the view's columns alone. The console +wires both on the object list page, where the added fields are kept with the +reader's other list preferences and cleared by "Reset to view". + +```tsx +import { ListView } from '@object-ui/plugin-list'; + +// The view declares `name` and `amount`; the reader has added `owner`. + console.log('Added:', added)} +/> +``` + ## Schema The ListView component accepts a `ListViewSchema`, exported from diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index 244a5ad7db1..527e40c328b 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -343,6 +343,27 @@ export interface ListViewProps { onSearchChange?: (search: string) => void; /** Called when the user toggles fields via the Hide Fields popover. */ onHiddenFieldsChange?: (hidden: string[]) => void; + /** + * objectstack#30077 — the names among `schema.columns` that the READER added + * to their own list (`onAddedFieldsChange`), not the view. The host appends + * them to the columns it hands in `schema`; this prop is its split of that + * one value into its two sources, so unticking an added column takes it off + * the list again instead of hiding it. A HOST prop, never a schema key. + * Read by content, never by identity. + */ + addedFields?: ReadonlyArray; + /** + * objectstack#30077 — the door that lets a reader add a field to their list. + * When it is wired, the Hide Fields popover (the toolbar's and the Display + * panel's) lists, after the view's columns, every other field of the object + * the caller may read (`canReadField`) and the definition does not mark + * `hidden`, unticked; ticking one calls this with the whole new list of + * added names, and unticking an added column calls it without that name. + * The host stores the list as the reader's own and appends it to + * `schema.columns`. A HOST prop, never a schema key. Absent ⇒ the popover + * lists the view's columns alone, exactly as before. + */ + onAddedFieldsChange?: (added: string[]) => void; /** * Called when the user toggles inline record editing. Wiring it is also what * offers the wide toolbar's toggle. The toggle switches the grid's edit MODE, @@ -1736,6 +1757,8 @@ export const ListView = React.forwardRef(({ onGroupingChange, onSearchChange, onHiddenFieldsChange, + addedFields, + onAddedFieldsChange, onInlineEditChange, onColumnStateChange, onRowClick, @@ -5567,11 +5590,22 @@ export const ListView = React.forwardRef(({ // field still shows: `GroupingEditor` mounts a value its options do not // carry as its own entry, labelled with the field name, so the level can be // removed, and no other level or "Add group field" offers it. + // + // objectstack#30077 — the label, in order: the translation of the field, + // then the column's own `label`, then the object definition's field label, + // then the field name. A column with no label of its own (a bare `{ field }` + // or name, and every column a reader added) is listed under the label the + // definition declares, the source the grid's header falls back to, rather + // than under its machine name. const allFields = React.useMemo(() => { + const definitionLabel = (name: string): string | undefined => { + const label = objectDef?.fields?.[name]?.label; + return typeof label === 'string' && label ? label : undefined; + }; return (Array.isArray(schema.columns) ? (schema.columns as any[]) : []).flatMap((f: any) => { if (typeof f === 'string') { if (!canReadField(perms, schema.objectName, f)) return []; - return [{ name: f, label: tFieldLabel(f, f) }]; + return [{ name: f, label: tFieldLabel(f, definitionLabel(f) || f) }]; } // `name` here is this popover's OWN key (it drives `hiddenFields`), which // is why it keeps that shape — but the value is the column identity, so @@ -5580,9 +5614,9 @@ export const ListView = React.forwardRef(({ // No resolvable identity → nothing to hide or show; it used to render a // checkbox keyed `undefined` that could never match a column. if (!name || !canReadField(perms, schema.objectName, name)) return []; - return [{ name, label: tFieldLabel(name, f.label || name) }]; + return [{ name, label: tFieldLabel(name, f.label || definitionLabel(name) || name) }]; }); - }, [schema.columns, tFieldLabel, perms, schema.objectName]); + }, [schema.columns, tFieldLabel, perms, schema.objectName, objectDef]); // objectui#11984 — what the two hide-fields lists (the toolbar popover and // the View settings section) are handed of `hiddenFields`. An entry naming a @@ -5602,6 +5636,57 @@ export const ListView = React.forwardRef(({ const updateOfferedHiddenFields = (next: Set) => updateHiddenFields(withheldHiddenFields.length === 0 ? next : new Set([...next, ...withheldHiddenFields])); + // objectstack#30077 — the fields a reader may add to their own list, offered + // by both hide-fields lists after the view's columns when the host wires + // `onAddedFieldsChange`: every field of the object's definition that is not + // already a column (`allFields`, which carries the added ones the host + // appended), that the caller may read (`canReadField`, the predicate every + // field list asks), and that the definition does not mark `hidden: true`. + // Business fields first, then the system fields, each in the definition's + // order, as the Filter panel ranks them. Nothing is offered until the + // definition has loaded. Plain per-render values, not memos (AGENTS.md #10). + const addedFieldNames = addedFields ?? []; + const addableFields: Array<{ name: string; label: string }> = []; + if (onAddedFieldsChange && objectDef?.fields) { + const defs = objectDef.fields as Record; + const listed = new Set(allFields.map((f) => f.name)); + const candidates = Object.entries(defs).filter(([name, def]) => + !listed.has(name) && def?.hidden !== true && canReadField(perms, schema.objectName, name)); + for (const tier of [false, true]) { + for (const [name, def] of candidates) { + if (isSystemManagedField(name, def) !== tier) continue; + addableFields.push({ name, label: tFieldLabel(name, def?.label || name) }); + } + } + } + // What both hide-fields lists write through. Unticking a column the reader + // added takes it off their list (`onAddedFieldsChange` without it) rather + // than hiding it; every other change is a hide or a show, as before. + const updateListedFields = (next: Set) => { + const dropped = onAddedFieldsChange + ? addedFieldNames.filter((name) => next.has(name) && !offeredHiddenFields.has(name)) + : []; + if (dropped.length === 0) { + updateOfferedHiddenFields(next); + return; + } + onAddedFieldsChange!(addedFieldNames.filter((name) => !dropped.includes(name))); + const hidden = new Set([...next].filter((name) => !dropped.includes(name))); + if (hidden.size !== offeredHiddenFields.size || [...hidden].some((name) => !offeredHiddenFields.has(name))) { + updateOfferedHiddenFields(hidden); + } + }; + // Ticking an offered field adds it to the reader's list. A stale hidden + // entry with the same name (a column the view has since dropped) is cleared + // with it, or the added column would be drawn hidden. + const addListedField = (name: string) => { + if (!onAddedFieldsChange || addedFieldNames.includes(name)) return; + onAddedFieldsChange([...addedFieldNames, name]); + if (offeredHiddenFields.has(name)) { + updateOfferedHiddenFields(new Set([...offeredHiddenFields].filter((n) => n !== name))); + } + }; + // objectui#11984 — a row-color rule on a field the caller may not read is // withheld from both Row color selects the same way: they show "None" with // no Clear, and the trigger is not marked active. The rule itself stays in @@ -6217,13 +6302,29 @@ export const ListView = React.forwardRef(({ } else { next.add(field.name); } - updateOfferedHiddenFields(next); + updateListedFields(next); }} className="rounded border-input" /> {field.label} ))} + {/* objectstack#30077 — the object's other readable fields, to add to the reader's list. */} + {addableFields.length > 0 && ( +
+ {addableFields.map((field) => ( + + ))} +
+ )} @@ -6542,7 +6643,9 @@ export const ListView = React.forwardRef(({ : density as any} showHideFields={toolbarFlags.showHideFields} hiddenFields={offeredHiddenFields} - updateHiddenFields={updateOfferedHiddenFields} + updateHiddenFields={updateListedFields} + addableFields={addableFields} + onAddField={addListedField} rowNumbers={rowNumbers} setRowNumbers={currentView === 'grid' ? setRowNumbers : undefined} /* [#4647] The Display panel's inline-edit entry — the SECOND diff --git a/ui/packages/plugin-list/src/__tests__/ListView.fieldListRead-11984.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.fieldListRead-11984.test.tsx index 79bf76c1010..e6feff5abab 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.fieldListRead-11984.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.fieldListRead-11984.test.tsx @@ -560,3 +560,206 @@ describe('a stored configuration on an unreadable field is kept as stored (objec expect(screen.getByTestId('filter-badge-status')).toBeInTheDocument(); }); }); + +// --------------------------------------------------------------------------- +// objectstack#30077 — adding a field the reader may read to their own list +// --------------------------------------------------------------------------- + +describe('both hide-fields lists offer the fields a reader may add, and only fields they may read (objectstack#30077)', () => { + afterEach(() => cleanup()); + + const TITLE_ONLY = [{ field: 'title', label: 'Title' }]; + + /** Mount with `getObjectSchema` answering `fields`, for a definition this file's fixture does not have. */ + function mountWithFields(perms: Perms, fields: Record>, schemaExtra: Record, props: Record) { + const dataSource = makeDataSource(); + dataSource.getObjectSchema = vi.fn().mockResolvedValue({ name: OBJECT, fields }); + render( + + + {listFor(schemaExtra, props, dataSource)} + + , + ); + } + + /** The Display panel's Hide Fields section, opened when it starts closed. */ + async function displayHideFields(read: (content: HTMLElement) => Promise | string[] | void): Promise { + return inPopover(viewSettings(), async (content) => { + if (content.querySelectorAll('input[type="checkbox"]').length === 0) { + fireEvent.click(within(content).getByRole('button', { name: /^Hide Fields/ })); + } + return read(content); + }); + } + + const LAYOUTS = { + wide: { schemaExtra: {}, read: () => inPopover(toolbarButton(/^hide fields/i), checkboxLabels) }, + compact: { schemaExtra: { compactToolbar: true }, read: () => displayHideFields(checkboxLabels) as Promise }, + } as const; + + for (const [layout, { schemaExtra, read }] of Object.entries(LAYOUTS)) { + it(`${layout} toolbar: after the view's columns, the object's other readable fields, unticked; an unreadable one is not offered`, async () => { + const onAddedFieldsChange = vi.fn(); + mount(RESTRICTED, { columns: TITLE_ONLY, ...schemaExtra }, { onAddedFieldsChange }); + await definitionLoaded(); + let labels: string[] = []; + await waitFor(async () => { + labels = await read(); + expect(labels).toContain('Status'); + }); + expect(labels).toEqual(['Title', 'Status', 'Priority']); + cleanup(); + + // Control: with full read the same list offers the field the restricted caller was not offered. + mount(FULL_READ, { columns: TITLE_ONLY, ...schemaExtra }, { onAddedFieldsChange }); + await definitionLoaded(); + await waitFor(async () => { + labels = await read(); + expect(labels).toContain('Status'); + }); + expect(labels).toEqual(FIELD_LABELS); + expect(onAddedFieldsChange).not.toHaveBeenCalled(); + }); + + it(`${layout} toolbar, control: without the host's door the list offers the view's columns alone, as before`, async () => { + mount(FULL_READ, { columns: TITLE_ONLY, ...schemaExtra }); + await definitionLoaded(); + expect(await read()).toEqual(['Title']); + }); + } + + it('ticking an offered field adds it through the door; hiding and showing a column of the view are unchanged', async () => { + const onAddedFieldsChange = vi.fn(); + const onHiddenFieldsChange = vi.fn(); + mount(FULL_READ, { columns: TITLE_ONLY }, { onAddedFieldsChange, onHiddenFieldsChange }); + await definitionLoaded(); + const content = await openPopover(toolbarButton(/^hide fields/i)); + await waitFor(() => expect(within(content).getByRole('checkbox', { name: 'Priority' })).not.toBeChecked()); + fireEvent.click(within(content).getByRole('checkbox', { name: 'Priority' })); + expect(onAddedFieldsChange).toHaveBeenLastCalledWith(['priority']); + expect(onHiddenFieldsChange).not.toHaveBeenCalled(); + fireEvent.click(within(content).getByRole('checkbox', { name: 'Title' })); + expect(onHiddenFieldsChange).toHaveBeenLastCalledWith(['title']); + expect(onAddedFieldsChange).toHaveBeenCalledTimes(1); + }); + + it('unticking a column the reader added takes it off their list instead of hiding it', async () => { + const onAddedFieldsChange = vi.fn(); + const onHiddenFieldsChange = vi.fn(); + // The host hands the added column appended to the view's, and names it. + mount( + FULL_READ, + { columns: [...TITLE_ONLY, { field: 'priority' }, { field: 'status' }] }, + { addedFields: ['priority', 'status'], onAddedFieldsChange, onHiddenFieldsChange }, + ); + await definitionLoaded(); + const content = await openPopover(toolbarButton(/^hide fields/i)); + expect(within(content).getByRole('checkbox', { name: 'Priority' })).toBeChecked(); + fireEvent.click(within(content).getByRole('checkbox', { name: 'Priority' })); + expect(onAddedFieldsChange).toHaveBeenLastCalledWith(['status']); + expect(onHiddenFieldsChange).not.toHaveBeenCalled(); + }); + + it('adding a field a stale hidden entry names clears that entry too, or the column would be drawn hidden', async () => { + const onAddedFieldsChange = vi.fn(); + const onHiddenFieldsChange = vi.fn(); + mount(FULL_READ, { columns: TITLE_ONLY, hiddenFields: ['priority'] }, { onAddedFieldsChange, onHiddenFieldsChange }); + await definitionLoaded(); + const content = await openPopover(toolbarButton(/^hide fields/i)); + await waitFor(() => expect(within(content).getByRole('checkbox', { name: 'Priority' })).not.toBeChecked()); + fireEvent.click(within(content).getByRole('checkbox', { name: 'Priority' })); + expect(onAddedFieldsChange).toHaveBeenLastCalledWith(['priority']); + expect(onHiddenFieldsChange).toHaveBeenLastCalledWith([]); + }); + + it('the Display panel adds through the same door', async () => { + const onAddedFieldsChange = vi.fn(); + mount(FULL_READ, { columns: TITLE_ONLY, compactToolbar: true }, { onAddedFieldsChange }); + await definitionLoaded(); + await waitFor(async () => expect(await displayHideFields(checkboxLabels)).toContain('Priority')); + await displayHideFields((content) => { + fireEvent.click(within(content).getByRole('checkbox', { name: 'Priority' })); + }); + expect(onAddedFieldsChange).toHaveBeenLastCalledWith(['priority']); + }); + + it('an added lookup column is fetched and expanded as a declared column is, and listed under its definition label', async () => { + // A host that stores the added names and appends them to the view's columns, as the console does. + const fields = { + title: { type: 'text', label: 'Title' }, + status: { type: 'select', label: 'Status', options: [{ label: 'Open', value: 'open' }] }, + account: { type: 'lookup', label: 'Account', reference: 'account' }, + }; + const dataSource = makeDataSource(); + dataSource.getObjectSchema = vi.fn().mockResolvedValue({ name: OBJECT, fields }); + function Host() { + const [added, setAdded] = React.useState([]); + const schema = { + type: 'list-view', + objectName: OBJECT, + viewType: 'grid', + columns: [...TITLE_ONLY, ...added.map((field) => ({ field }))], + userActions: { hideFields: true }, + userFilters: { element: 'dropdown' }, + compactToolbar: false, + } as unknown as ListViewSchema; + return ; + } + render( + + + + + , + ); + await definitionLoaded(); + const lastQuery = () => dataSource.find.mock.calls.at(-1)?.[1] ?? {}; + await waitFor(() => expect(lastQuery().$select).toEqual(expect.arrayContaining(['title']))); + expect(lastQuery().$select).not.toContain('account'); + + const content = await openPopover(toolbarButton(/^hide fields/i)); + await waitFor(() => expect(within(content).getByRole('checkbox', { name: 'Account' })).not.toBeChecked()); + fireEvent.click(within(content).getByRole('checkbox', { name: 'Account' })); + await waitFor(() => expect(lastQuery().$select).toEqual(expect.arrayContaining(['title', 'account']))); + expect(lastQuery().$expand).toEqual(['account']); + // Now a column of the reader's list, ticked, under the definition's label. + expect(within(content).getByRole('checkbox', { name: 'Account' })).toBeChecked(); + expect(checkboxLabels(content)).toEqual(['Title', 'Account', 'Status']); + }); + + it('a column with no label of its own is listed under the definition\'s label, not its machine name', async () => { + // A bare `{ field }` and a bare name, neither labelled and with no translation: the grid's header falls back to + // the definition's label, and so does the list. + mount(FULL_READ, { columns: [{ field: 'title' }, { field: 'status' }, 'priority'] }); + await definitionLoaded(); + let labels: string[] = []; + await waitFor(async () => { + labels = await inPopover(toolbarButton(/^hide fields/i), checkboxLabels); + expect(labels).toContain('Status'); + }); + expect(labels).toEqual(['Title', 'Status', 'Priority']); + }); + + it('a field the definition marks hidden is not offered; system fields follow the business fields', async () => { + mountWithFields( + FULL_READ, + { + title: { type: 'text', label: 'Title' }, + owner_id: { type: 'lookup', label: 'Owner', system: true }, + organization_id: { type: 'lookup', label: 'Organization', hidden: true, system: true }, + discount: { type: 'percent', label: 'Discount' }, + status: { type: 'select', label: 'Status', options: [{ label: 'Open', value: 'open' }] }, + }, + { columns: TITLE_ONLY }, + { onAddedFieldsChange: vi.fn() }, + ); + await definitionLoaded(); + let labels: string[] = []; + await waitFor(async () => { + labels = await inPopover(toolbarButton(/^hide fields/i), checkboxLabels); + expect(labels).toContain('Discount'); + }); + expect(labels).toEqual(['Title', 'Discount', 'Status', 'Owner']); + }); +}); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.rowColorSelect-12376.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.rowColorSelect-12376.test.tsx index 659569a86b8..512822d662e 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.rowColorSelect-12376.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.rowColorSelect-12376.test.tsx @@ -168,8 +168,9 @@ describe('ListView — a row colour on a non-column field is projected (objectui const labels = within(listbox).getAllByRole('option').map((o) => o.textContent); // The rule in force as the outside value, which re-picking writes nothing // for (`ListView.sharedSelect-11865.test.tsx`), "None", and one entry per - // column (`subject` is the only one; a string column is labelled by its - // name here). No other object field: `is_escalated` is not offered. - expect(labels).toEqual(['is_sla_violated', 'None', 'subject']); + // column (`subject` is the only one; a string column with no label of its + // own is listed under the definition's label, objectstack#30077). No other + // object field: `is_escalated` is not offered. + expect(labels).toEqual(['is_sla_violated', 'None', 'Subject']); }); }); diff --git a/ui/packages/plugin-list/src/components/ViewSettingsPopover.tsx b/ui/packages/plugin-list/src/components/ViewSettingsPopover.tsx index 7e7d1982131..399c76805bb 100644 --- a/ui/packages/plugin-list/src/components/ViewSettingsPopover.tsx +++ b/ui/packages/plugin-list/src/components/ViewSettingsPopover.tsx @@ -188,6 +188,15 @@ export interface ViewSettingsPopoverProps { showHideFields?: boolean; hiddenFields?: Set; updateHiddenFields?: (next: Set) => void; + /** + * objectstack#30077 — the object's fields that are not columns of the list, + * drawn unticked after `allFields` in the Hide Fields section; ticking one + * calls `onAddField`, which adds it to the reader's own list. The host + * passes only fields the caller may read. Absent or empty ⇒ the section + * lists `allFields` alone, as before. + */ + addableFields?: ViewSettingsField[]; + onAddField?: (name: string) => void; /** * Row numbers on the grid's leading cell — a session-only setting the host @@ -496,6 +505,8 @@ export function ViewSettingsPopover(props: ViewSettingsPopoverProps) { showHideFields, hiddenFields, updateHiddenFields, + addableFields, + onAddField, rowNumbers, setRowNumbers, showInlineEdit, @@ -658,6 +669,24 @@ export function ViewSettingsPopover(props: ViewSettingsPopoverProps) { {field.label || field.name} ))} + {onAddField && addableFields && addableFields.length > 0 && ( +
+ {addableFields.map((field) => ( + + ))} +
+ )} )}