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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/30077-list-add-columns.md
Original file line number Diff line number Diff line change
@@ -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.<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.

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.
35 changes: 28 additions & 7 deletions ui/packages/app-shell/src/context/ListPreferencesProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,7 +19,8 @@
* ```json
* { "viewOrder": ["<view id>", …],
* "perView": { "<view id>": { "sort": […], "filters": {…}, "columnState": {…},
* "rowHeight": "medium", "hiddenFields": ["<field>", …] } } }
* "rowHeight": "medium", "hiddenFields": ["<field>", …],
* "addedFields": ["<field>", …] } } }
* ```
*
* The rows are loaded by prefix in the console's ONE preference read per load
Expand Down Expand Up @@ -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.<object>` row's value. */
Expand Down Expand Up @@ -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 = {};
Expand All @@ -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;
}

Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'] } } });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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:<object>` key is still honoured.
Expand Down Expand Up @@ -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));
Expand Down
54 changes: 52 additions & 2 deletions ui/packages/app-shell/src/views/objectViewListRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() =>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}
Expand Down
Loading
Loading