diff --git a/.changeset/28950-list-personalized-marker.md b/.changeset/28950-list-personalized-marker.md new file mode 100644 index 00000000000..9405568b86c --- /dev/null +++ b/.changeset/28950-list-personalized-marker.md @@ -0,0 +1,16 @@ +--- +'@object-ui/plugin-list': patch +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(plugin-list, app-shell): one "Personalized" marker beside the list title holds the one reset to the view + +Clause-②: yes (widening) +Metadata-protocol: none + +- **The marker.** While a reader's list differs from its view, the Console draws a **Personalized** marker beside the list title (in the page header, and beside the phone title as an icon alone). Opening it names what differs (filters, search, sort, grouping, column layout, hidden fields, density, layout, row color, row numbers, inline editing) and holds **Reset to view**, with a sentence saying what it covers and what it leaves alone. It is not configurable: no metadata key or option. +- **The one reset.** It drops the active view's stored entry and the cached search term, removes the reader's whole `uf_*` family from the address bar (Filter panel, search, sort, grouping and every user-filter chip), and opens the list again, so a reload or Back does not bring the old list back. Not list state, so untouched: the record-open preference, pinned views and their order, favorites, and a "View all" link's `filter[...]` scope. +- **Gone.** The header ⋯ entry "Reset view", and, on the Console list, the Display panel's "Reset to view default". The sort popover's reset and the Filter panel's "Clear all" stay: each puts back one control. +- **`ListView` (`@object-ui/plugin-list`)** gains one optional host prop, `onPersonalizationChange(parts)`, reporting which parts of the list on screen differ from the view (`sort`, `filters`, `search`, `grouping`, `hiddenFields`, `density`, `layout`, `rowColor`, `rowNumbers`, `inlineEdit`; `columns` is in the part type for a host to add). With it the Display panel draws no reset of its own; without it nothing changes. `viewDefaults` also accepts `grouping`, the view's own grouping under one the host laid over it; a `viewDefaults` without that key leaves the grouping `schema` opened with as the view's. +- **Copy (`@object-ui/i18n`).** New `console.objectView.personalized`, `personalizedHeading` and `personalizedParts.*` in all ten packs; `console.objectView.resetView` and `resetViewTitle` now label the marker's reset and say everything it covers. No key is removed. diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index e768591f5e8..63df5419ef7 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -551,7 +551,14 @@ metadata is read-only. What you author (`sort`, filters, columns, `rowHeight`, - A link carrying list state (`uf_*` parameters) wins over the stored state. - A user's hidden fields only subtract from the columns the view shows, never add one. -- **Reset view** in the page header's ⋯ menu drops the active view's stored state. +- While a user's list differs from the view, a **Personalized** marker sits beside the list + title (an icon alone on a phone). Opening it names what differs: filters, search, sort, + grouping, column layout, hidden fields, density, and the Display panel's layout, row + color, row numbers and inline editing. Its **Reset to view** is the one way back: it + drops the active view's stored state and cached search, and removes the list's `uf_*` + parameters from the address bar, so the list is the view's own again after a reload too. + Pinned views, favorites, how records open and a "View all" link's `filter[...]` scope are + not list state and stay. Nothing about the marker is configurable. ### A view the user may not open diff --git a/ui/packages/app-shell/src/views/ListPersonalizedMarker.tsx b/ui/packages/app-shell/src/views/ListPersonalizedMarker.tsx new file mode 100644 index 00000000000..6a241e3b002 --- /dev/null +++ b/ui/packages/app-shell/src/views/ListPersonalizedMarker.tsx @@ -0,0 +1,118 @@ +/** + * objectstack#28950 — the 「Personalized」 marker of a Console list: drawn beside + * the list title while the reader's list differs from the view, and the ONE way + * back to the view. Opening it names what differs and offers a single reset, + * whose sentence says everything it puts back and what it leaves alone. + * + * The parts come from the list itself (`ListView`'s `onPersonalizationChange`) + * plus the host's stored column layout; the reset is the host's + * (`useObjectViewListUrlState`'s `handleResetView`). Nothing here is + * configurable: no metadata key or option reaches it. + * + * Below `sm` the trigger is an icon alone (named by its `aria-label`), so on a + * narrow row it never pushes the view name out. + */ + +import { useState } from 'react'; +import { Button, Popover, PopoverContent, PopoverTrigger, cn } from '@object-ui/components'; +import { useObjectTranslation } from '@object-ui/i18n'; +import type { ListViewProps } from '@object-ui/plugin-list'; +import { RotateCcw, UserPen } from 'lucide-react'; + +/** One part of a list that can differ from its view (`ListView`'s vocabulary). */ +export type ListPersonalizationPart = Parameters>[0][number]; + +/** The order the marker names the parts in: the toolbar's, then the Display panel's. */ +const LIST_PERSONALIZATION_PART_ORDER: readonly ListPersonalizationPart[] = [ + 'filters', + 'search', + 'sort', + 'grouping', + 'columns', + 'hiddenFields', + 'density', + 'layout', + 'rowColor', + 'rowNumbers', + 'inlineEdit', +]; + +export function ListPersonalizedMarker({ + parts, + onReset, + className, +}: { + parts: ReadonlyArray; + onReset: () => void; + className?: string; +}) { + const { t } = useObjectTranslation(); + const [open, setOpen] = useState(false); + if (parts.length === 0) return null; + const ordered = LIST_PERSONALIZATION_PART_ORDER.filter((part) => parts.includes(part)); + // Each label a literal key, so the i18n call-site and dead-key gates read it. + const partLabels: Record = { + filters: t('console.objectView.personalizedParts.filters'), + search: t('console.objectView.personalizedParts.search'), + sort: t('console.objectView.personalizedParts.sort'), + grouping: t('console.objectView.personalizedParts.grouping'), + columns: t('console.objectView.personalizedParts.columns'), + hiddenFields: t('console.objectView.personalizedParts.hiddenFields'), + density: t('console.objectView.personalizedParts.density'), + layout: t('console.objectView.personalizedParts.layout'), + rowColor: t('console.objectView.personalizedParts.rowColor'), + rowNumbers: t('console.objectView.personalizedParts.rowNumbers'), + inlineEdit: t('console.objectView.personalizedParts.inlineEdit'), + }; + const label = t('console.objectView.personalized'); + return ( + + + + + +

{t('console.objectView.personalizedHeading')}

+
    + {ordered.map((part) => ( +
  • + {partLabels[part]} +
  • + ))} +
+

{t('console.objectView.resetViewTitle')}

+ +
+
+ ); +} diff --git a/ui/packages/app-shell/src/views/ObjectView.listPersonalized-28950.test.tsx b/ui/packages/app-shell/src/views/ObjectView.listPersonalized-28950.test.tsx new file mode 100644 index 00000000000..f293654dbd7 --- /dev/null +++ b/ui/packages/app-shell/src/views/ObjectView.listPersonalized-28950.test.tsx @@ -0,0 +1,346 @@ +/** + * objectstack#28950 — the 「Personalized」 marker beside the list title and its + * one reset, at the host seam: the real object page, the real + * `ListPreferencesProvider` and the real router, with `ListView` captured (its + * own half, what of the list differs from the view, is pinned in plugin-list's + * `ListView.personalization-28950.test.tsx`). + * + * ## The pins + * + * 1. `withoutListStateParams` strips the reader's whole `uf_*` family (the + * Filter panel, search, sort, grouping and every chip) and keeps every + * other param: a `filter[...]` scope a "View all" link opened the list + * with, the open record. + * 2. The marker is drawn only while the list reports a part (or a column + * layout is stored), names the parts, and is the header's one reset: the + * ⋯ menu carries none. + * 3. After a reload (the list seeded from the URL), the reset opens the list + * as the view declares it: the address bar has no `uf_*` param left, a + * reload would not bring the old list back, and the chips start empty. + * This is the case the header's old "Reset view" re-seeded from the URL. + * 4. In session, the reset also drops the cached search term, which the old + * "Reset view" wrote back into the address bar. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, act, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; +import { MetadataClient } from '@object-ui/data-objectstack'; + +const { session } = vi.hoisted(() => ({ session: { userId: 'u1' } })); + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + // Stable identities: `ListView` names `perms` in its fetch dependencies. + const perms = { + check: () => ({ allowed: true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }; + const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] }; + return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: session.userId, name: 'Ada' }, activeOrganization: null }), + // Manage Views and the config panel are admin-only. + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRealtimeSubscription: () => ({ lastMessage: null }), + useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }), +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +/** + * The props the object page hands its list: captured, not rendered. The + * stand-in draws nothing. + */ +let listProps: any = null; +vi.mock('@object-ui/plugin-list', async (importOriginal) => ({ + ...(await importOriginal()), + ListView: (props: any) => { + listProps = props; + return null; + }, +})); + +const OBJECT_NAME = 'crm_deal'; +const SYSTEM_ID = `${OBJECT_NAME}.all`; +const SAVED_ID = `${OBJECT_NAME}.pipeline`; +const json = (body: unknown, status: number) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +let client: MetadataClient; +vi.mock('./metadata-admin/useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataClient: () => client, +})); + +import { ObjectView, type ConsoleObjectViewProps } from './ObjectView'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; +import { UserStateAdaptersProvider, useAttachListStateFamily } from '../context/UserStateAdapters'; +import { ListPreferencesProvider } from '../context/ListPreferencesProvider'; +import { withoutListStateParams } from './objectViewListUrlState'; +import { applyListStateParams, applyUserFilterParams } from './userFilterUrlState'; +import { buildListFilterKey } from './listFilterStorage'; + +const FIELDS = { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + stage: { type: 'text', label: 'Stage' }, + amount: { type: 'number', label: 'Amount' }, +}; +const AUTHORED_SORT = [{ field: 'name', order: 'asc' }]; +const SAVED_ROW = { name: SAVED_ID, object: OBJECT_NAME, label: 'Pipeline', type: 'grid', columns: ['name'] }; +const OBJECT = { + name: OBJECT_NAME, + label: 'Deal', + fields: FIELDS, + listViews: { + [SYSTEM_ID]: { name: SYSTEM_ID, label: 'All', type: 'grid', columns: ['name', 'stage', 'amount'], sort: AUTHORED_SORT }, + }, +}; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [], total: 0 })), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + getObjectSchema: vi.fn(async () => ({ name: OBJECT_NAME, fields: {} })), + listViews: vi.fn(async () => [SAVED_ROW]), + updateView: vi.fn(async () => ({})), + deleteView: vi.fn(async () => ({})), + } as unknown as ConsoleObjectViewProps['dataSource']; +} + +function makeFamily(rows: Record) { + return { + prefix: 'ui.list.', + load: vi.fn(async () => structuredClone(rows) as Record), + save: vi.fn(async (key: string, value: unknown) => { + rows[key] = structuredClone(value); + }), + }; +} + +function AttachFamily({ family }: { family: any }) { + const attach = useAttachListStateFamily(); + React.useEffect(() => { + attach(family); + return () => attach(null); + }, [attach, family]); + return null; +} + +/** The address bar, as the page last wrote it. */ +let currentSearch = ''; +function LocationProbe() { + const { search } = useLocation(); + React.useEffect(() => { + currentSearch = search; + }, [search]); + return null; +} + +function mount(family: any, query = '') { + return render( + + + + + + + + + {}} /> + + )} + /> + + + + + , + ); +} + +const wait = (ms: number) => act(() => new Promise((resolve) => setTimeout(resolve, ms))); + +async function listReady() { + await waitFor(() => expect(typeof listProps?.onPersonalizationChange).toBe('function')); + await wait(50); +} + +/** The `uf_*` params of the address bar. */ +const ufParams = () => [...new URLSearchParams(currentSearch).keys()].filter((key) => key.startsWith('uf_')); + +/** A link carrying a whole personalized list: panel filter, search, sort, grouping and a chip. */ +function personalizedQuery(): string { + let params = new URLSearchParams('filter[owner]=u1'); + params = applyListStateParams(params, { + filters: { id: 'root', logic: 'and', conditions: [{ id: 'c1', field: 'name', operator: 'contains', value: 'Acme' }] } as any, + search: 'Alan', + sort: [{ field: 'amount', order: 'desc' }], + grouping: { fields: [{ field: 'stage', order: 'asc', collapsed: false }] } as any, + }); + params = applyUserFilterParams(params, { stage: ['won'] }); + return `?${params.toString()}`; +} + +beforeEach(() => { + cleanup(); + session.userId = 'u1'; + listProps = null; + currentSearch = ''; + localStorage.clear(); + client = new MetadataClient({ baseUrl: 'http://localhost', fetch: vi.fn(async () => json({ data: [] }, 200)) as unknown as typeof fetch }); + vi.stubGlobal('fetch', vi.fn(async () => json({ data: [] }, 200))); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +describe('withoutListStateParams (objectstack#28950)', () => { + it('strips the reader\'s whole `uf_*` family and keeps every other param', () => { + const params = new URLSearchParams(personalizedQuery().slice(1)); + params.set('recordId', 'r1'); + expect([...params.keys()].filter((key) => key.startsWith('uf_')).length).toBe(5); + + const stripped = withoutListStateParams(params); + expect([...stripped.keys()].filter((key) => key.startsWith('uf_'))).toEqual([]); + expect(stripped.get('filter[owner]')).toBe('u1'); + expect(stripped.get('recordId')).toBe('r1'); + // A copy: the params handed in are untouched. + expect(params.get('uf__search')).toBe('Alan'); + }); +}); + +describe('the 「Personalized」 marker and its one reset (objectstack#28950)', () => { + it('is drawn only while the list reports a part, names the parts, and the header ⋯ carries no reset', async () => { + mount(makeFamily({})); + await listReady(); + expect(screen.queryByTestId('list-personalized-marker')).toBeNull(); + + act(() => listProps.onPersonalizationChange(['sort', 'search', 'rowColor'])); + const marker = await screen.findByTestId('list-personalized-marker'); + // Named by its label, which the icon-only phone face keeps as `aria-label`. + expect(marker.getAttribute('aria-label')).toBe('console.objectView.personalized'); + fireEvent.click(marker); + const parts = await screen.findByTestId('list-personalized-parts'); + expect([...parts.querySelectorAll('li')].map((li) => li.getAttribute('data-part'))).toEqual(['search', 'sort', 'rowColor']); + expect([...parts.querySelectorAll('li')].map((li) => li.textContent)).toEqual([ + 'console.objectView.personalizedParts.search', + 'console.objectView.personalizedParts.sort', + 'console.objectView.personalizedParts.rowColor', + ]); + expect(screen.queryByTestId('object-view-reset-view-button')).toBeNull(); + + act(() => listProps.onPersonalizationChange([])); + await waitFor(() => expect(screen.queryByTestId('list-personalized-marker')).toBeNull()); + }); + + it('after a reload, the reset opens the list as the view declares it and leaves no `uf_*` param behind', async () => { + const family = makeFamily({}); + mount(family, personalizedQuery()); + await listReady(); + // The list opened from the link: every part of it is the reader's. + expect(listProps.initialSearchTerm).toBe('Alan'); + expect(listProps.schema.sort).toEqual([{ field: 'amount', order: 'desc' }]); + expect(listProps.userFilterSelections).toEqual({ stage: ['won'] }); + expect(ufParams().length).toBe(5); + const before = listProps; + + act(() => listProps.onPersonalizationChange(['filters', 'search', 'sort', 'grouping'])); + fireEvent.click(await screen.findByTestId('list-personalized-marker')); + await act(async () => { + fireEvent.click(await screen.findByTestId('list-personalized-reset')); + }); + await wait(400); + + expect(listProps).not.toBe(before); + expect(listProps.initialSearchTerm).toBeUndefined(); + expect(listProps.initialFilters).toBeUndefined(); + expect(listProps.schema.sort).toEqual(AUTHORED_SORT); + expect(listProps.schema.grouping).toBeUndefined(); + expect(listProps.userFilterSelections).toBeUndefined(); + expect(ufParams()).toEqual([]); + // The drill scope is not the reader's list state: it stays. + expect(new URLSearchParams(currentSearch).get('filter[owner]')).toBe('u1'); + expect(screen.queryByTestId('list-personalized-marker')).toBeNull(); + }); + + it('in session, the reset drops the cached search term, which would otherwise seed the list again', async () => { + mount(makeFamily({})); + await listReady(); + const cacheKey = buildListFilterKey(session.userId, OBJECT_NAME, SYSTEM_ID); + + act(() => listProps.onSearchChange('Alan')); + await wait(400); + expect(JSON.parse(localStorage.getItem(cacheKey!) ?? '{}').search).toBe('Alan'); + expect(new URLSearchParams(currentSearch).get('uf__search')).toBe('Alan'); + + act(() => listProps.onPersonalizationChange(['search'])); + fireEvent.click(await screen.findByTestId('list-personalized-marker')); + await act(async () => { + fireEvent.click(await screen.findByTestId('list-personalized-reset')); + }); + await wait(400); + + expect(localStorage.getItem(cacheKey!)).toBeNull(); + expect(listProps.initialSearchTerm).toBeUndefined(); + expect(ufParams()).toEqual([]); + }); + + it('CONTROL: a stored column layout alone draws the marker, and the reset drops it with the rest of the entry', async () => { + const family = makeFamily({ [`ui.list.${OBJECT_NAME}`]: { perView: { [SYSTEM_ID]: { columnState: { widths: { name: 280 } } } } } }); + mount(family); + await listReady(); + + 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(family.save).toHaveBeenCalledWith(`ui.list.${OBJECT_NAME}`, {}); + expect(listProps.schema.columnState).toEqual({}); + expect(screen.queryByTestId('list-personalized-marker')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index e2e030562ba..95ed17e8bbb 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -74,6 +74,7 @@ import { useObjectViewHeaderChrome } from './objectViewHeaderChrome.js'; import { ObjectViewPageHeader } from './objectViewPageHeader.js'; import { ObjectViewRecordOverlay } from './objectViewRecordOverlay.js'; import { useObjectViewListRenderer } from './objectViewListRenderer.js'; +import { ListPersonalizedMarker } from './ListPersonalizedMarker.js'; import { useObjectViewViewCrud } from './objectViewViewCrud.js'; // objectstack#25992 slice 1: these module-level helpers moved to the sibling @@ -643,7 +644,8 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co listSeedRef, ownListParamsRef, writeListUrlState, - activeViewHasPreference, + handleListPersonalizationChange, + listPersonalizedParts, handleResetView, } = useObjectViewListUrlState({ objectName, @@ -655,6 +657,12 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co searchParams, setSearchParams, }); + // objectstack#28950 — the 「Personalized」 marker beside the list title (the + // page header's badge slot, and the phone title): drawn only while the + // reader's list differs from the view, and the one way back to it. + const personalizedMarker = !isEnvironmentList && listPersonalizedParts.length > 0 ? ( + + ) : null; // objectstack#25992 slice 11b: the view-config save and create handlers, view switching // and the view-tab CRUD callbacks moved to `./objectViewViewCrud.tsx` (the mobile view @@ -805,6 +813,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co setSearchParams, listIdentityKey, ownListParamsRef, + listSeedRef, }); // objectstack#27594: the list title is the view switcher, which replaced the @@ -865,8 +874,12 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co recordOpen, openAsPage, phoneTitle: !pageHeaderShown && titleSwitcher ? ( - {titleSwitcher} + + {titleSwitcher} + {personalizedMarker} + ) : null, + handleListPersonalizationChange, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, @@ -915,8 +928,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co importDisabled, setShowImport, listFavorite, - activeViewHasPreference, - handleResetView, isEnvironmentList, toolbarBar, viewAccess, @@ -996,8 +1007,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co environmentEntitlements={environmentEntitlements} toolbarBar={toolbarBar} listFavorite={listFavorite} - activeViewHasPreference={activeViewHasPreference} - handleResetView={handleResetView} + personalizedMarker={personalizedMarker} foldedHeaderActions={foldedHeaderActions} /> 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 1b532f3148c..1762b35300b 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 @@ -32,16 +32,18 @@ * 4. Cross-device: a fresh browser opens on the user's stored order, sort * and filters once the family has loaded; another user sees the view's * authored defaults. The list waits for that load. - * 5. "Reset view", an entry of the header's ⋯ menu shown only while the - * user has stored state for the view, drops that state and the list - * opens on the view's own sort again. + * 5. "Reset to view", the one reset of the 「Personalized」 marker beside + * the title (objectstack#28950; it replaced the header ⋯ "Reset view"), + * drops that state and the list opens on the view's own sort again. The + * marker is drawn while the list reports a part that differs, or while + * a column layout is stored. * 6. objectui#12474 — the density toggle and a Hide Fields change are the * user's too: each writes no view and lands as ONE save; a fresh browser - * opens on them, another user sees the view's own, and "Reset view" + * opens on them, another user sees the view's own, and "Reset to view" * drops them. * 6b. objectui#12505 — on a SAVED view that declares no `columns`, a Hide * Fields change is stored the same way and applied over the drawn - * defaults; another user sees the defaults; "Reset view" drops it. + * defaults; another user sees the defaults; "Reset to view" drops it. * 6c. objectui#12949 — the list is handed the view's own density and * hidden fields in `viewDefaults`, beside the user's in `schema`, so * its Display reset compares against the view; on an unprojected saved @@ -286,10 +288,18 @@ async function listReady() { await wait(50); } -/** Open the header's ⋯ menu (keyboard, as Radix opens it under test) and find one entry. */ -async function openOverflowItem(testId: string): Promise { - fireEvent.keyDown(screen.getByRole('button', { name: 'More actions' }), { key: 'Enter' }); - return screen.findByTestId(testId); +/** + * objectstack#28950 — the marker's one reset. The captured list reports what + * of it differs from the view first (`parts`), as the real list does; with + * none, the marker must already be drawn (a stored column layout). + */ +async function resetToView(parts: string[] = []) { + if (parts.length) act(() => listProps.onPersonalizationChange(parts)); + fireEvent.click(await screen.findByTestId('list-personalized-marker')); + const reset = await screen.findByTestId('list-personalized-reset'); + await act(async () => { + fireEvent.click(reset); + }); } /** The ⋯ menu's entries by test id; `[]` when the page draws no ⋯ at all. */ @@ -452,29 +462,33 @@ describe('the user\'s state follows them, and only them (objectui#12418)', () => expect(listProps.initialFilters).toBeUndefined(); // No layout of anyone's: the grid is handed none, so its own cache cannot stand in. expect(listProps.schema.columnState).toEqual({}); - // Nothing of theirs to reset: the ⋯ menu carries no "Reset view". + // Nothing of theirs differs: no marker, and no reset anywhere. + expect(screen.queryByTestId('list-personalized-marker')).toBeNull(); expect(await overflowTestIds()).not.toContain('object-view-reset-view-button'); }); - it('"Reset view" drops the user\'s state for the view and the list opens on the view\'s own sort again', async () => { + it('"Reset to view" drops the user\'s state for the view and the list opens on the view\'s own sort again', async () => { const ds = makeDataSource(); const { family } = makeFamily(structuredClone(STORED)); mountWithFamily(ds, family); await listReady(); expect(listProps.schema.sort).toEqual([{ field: 'amount', order: 'desc' }]); + expect(typeof listProps.onPersonalizationChange).toBe('function'); const before = listProps; - // An entry of the header's ⋯ menu, not a header button: nothing of it is - // drawn until the menu opens, so the one-row header keeps its shape. - expect(screen.queryByTestId('object-view-reset-view-button')).toBeNull(); - const item = await openOverflowItem('object-view-reset-view-button'); - expect(item.getAttribute('role')).toBe('menuitem'); + // The stored column layout alone draws the marker; the list adds the rest. + expect(screen.getByTestId('list-personalized-marker')).toBeTruthy(); + act(() => listProps.onPersonalizationChange(['sort', 'filters'])); + fireEvent.click(screen.getByTestId('list-personalized-marker')); + const parts = await screen.findByTestId('list-personalized-parts'); + expect([...parts.querySelectorAll('li')].map((li) => li.getAttribute('data-part'))).toEqual(['filters', 'sort', 'columns']); + const reset = screen.getByTestId('list-personalized-reset'); // This harness mounts no I18nProvider, so `t` answers with the key: the pin - // is WHICH key labels the entry and which titles it, not the English copy. - expect(item.textContent?.trim()).toBe('console.objectView.resetView'); - expect(item.getAttribute('title')).toBe('console.objectView.resetViewTitle'); + // is WHICH key labels the reset and which says what it covers. + expect(reset.textContent?.trim()).toBe('console.objectView.resetView'); + expect(screen.getByTestId('list-personalized-popover').textContent).toContain('console.objectView.resetViewTitle'); await act(async () => { - fireEvent.click(item); + fireEvent.click(reset); }); await wait(700); @@ -486,23 +500,22 @@ describe('the user\'s state follows them, and only them (objectui#12418)', () => expect(listProps.schema.sort).toEqual(AUTHORED_SORT); expect(listProps.initialFilters).toBeUndefined(); expect(listProps.schema.columnState).toEqual({}); - // Nothing stored for the view any more: nothing to reset, so no entry. + // The remounted list has reported nothing yet, and nothing is stored: no marker. + expect(screen.queryByTestId('list-personalized-marker')).toBeNull(); + // The header ⋯ has no reset of its own any more (objectstack#28950). expect(await overflowTestIds()).not.toContain('object-view-reset-view-button'); }); - it('with no favorites entry (no app segment), the ⋯ is drawn for "Reset view" alone, and only while there is state to reset', async () => { + it('with no favorites entry (no app segment), no ⋯ is drawn: the way back is the marker beside the title', async () => { const ds = makeDataSource(); const { family } = makeFamily(structuredClone(STORED)); mountWithFamily(ds, family, SYSTEM_ID, { app: false }); await listReady(); - expect(await overflowTestIds()).toEqual(['object-view-reset-view-button']); - await act(async () => { - fireEvent.click(await openOverflowItem('object-view-reset-view-button')); - }); - await wait(700); - // Nothing left to reset and no favorites entry: no ⋯ at all. expect(screen.queryByRole('button', { name: 'More actions' })).toBeNull(); + await resetToView(['sort']); + await wait(700); + expect(screen.queryByTestId('list-personalized-marker')).toBeNull(); }); it('"Clear all" in the Filter panel clears the user\'s filters for the view', async () => { @@ -586,16 +599,14 @@ describe('the density and the hidden fields are the user\'s too (objectui#12474) expect(listProps.schema.hiddenFields).toEqual(AUTHORED_HIDDEN); }); - it('"Reset view" drops the stored density and hidden fields, and the list opens on the view\'s own', async () => { + it('"Reset to view" drops the stored density and hidden fields, and the list opens on the view\'s own', async () => { const ds = makeDataSource(); const { family } = makeFamily(structuredClone(STORED)); mountWithFamily(ds, family); await listReady(); expect(listProps.schema.rowHeight).toBe('compact'); - await act(async () => { - fireEvent.click(await openOverflowItem('object-view-reset-view-button')); - }); + await resetToView(['density', 'hiddenFields']); await wait(700); expect(viewWrites(ds)).toBe(0); @@ -657,16 +668,14 @@ describe('on a saved view that declares no `columns`, the hidden fields are the expect(listProps.schema.hiddenFields).toBeUndefined(); }); - it('"Reset view" drops the stored list, and the list opens on the default columns', async () => { + it('"Reset to view" drops the stored list, and the list opens on the default columns', async () => { const ds = makeDataSource(UNPROJECTED_SAVED_ROW); const { family } = makeFamily(structuredClone(STORED)); mountWithFamily(ds, family, SAVED_ID); await listReady(); expect(listProps.schema.hiddenFields).toEqual(['amount']); - await act(async () => { - fireEvent.click(await openOverflowItem('object-view-reset-view-button')); - }); + await resetToView(['hiddenFields']); await wait(700); expect(viewWrites(ds)).toBe(0); @@ -826,12 +835,11 @@ describe('the list\'s "Reset to view default" clears the user\'s stored part ins } }); - it('a sort reset leaves no sort stored; with nothing else stored the entry and "Reset view" go', async () => { + it('a sort reset leaves no sort stored; with nothing else stored the entry goes', async () => { const ds = makeDataSource(); const { family } = makeFamily({ [LIST_KEY]: { perView: { [SYSTEM_ID]: { sort: [{ field: 'amount', order: 'desc' }] } } } }); mountWithFamily(ds, family); await listReady(); - expect(await overflowTestIds()).toContain('object-view-reset-view-button'); act(() => listProps.onRestoreViewDefaults(['sort'])); await wait(700); @@ -839,7 +847,6 @@ describe('the list\'s "Reset to view default" clears the user\'s stored part ins expect(viewWrites(ds)).toBe(0); expect(family.save).toHaveBeenCalledTimes(1); expect(family.save).toHaveBeenCalledWith(LIST_KEY, {}); - expect(await overflowTestIds()).not.toContain('object-view-reset-view-button'); }); it('after a sort reset a changed view sort reaches the user on the next open', async () => { diff --git a/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx b/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx index cfbdf76aa66..a3898274d7a 100644 --- a/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx +++ b/ui/packages/app-shell/src/views/objectViewHeaderChrome.tsx @@ -18,7 +18,6 @@ import { declaredPrimaryToolbarAction, MOBILE_FAB_LIST_CLEARANCE } from './objec import { ListHeaderActionBar } from './objectViewHeaderLaunchGate.js'; import type { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js'; import type { useObjectViewIdentity } from './objectViewIdentity.js'; -import type { useObjectViewListUrlState } from './objectViewListUrlState.js'; import type { useObjectViewHeaderToolbar } from './objectViewHeaderToolbar.js'; export function useObjectViewHeaderChrome({ @@ -35,8 +34,6 @@ export function useObjectViewHeaderChrome({ importDisabled, setShowImport, listFavorite, - activeViewHasPreference, - handleResetView, isEnvironmentList, toolbarBar, viewAccess, @@ -54,8 +51,6 @@ export function useObjectViewHeaderChrome({ importDisabled: ReturnType['importDisabled']; setShowImport: Dispatch>; listFavorite: ReturnType; - activeViewHasPreference: ReturnType['activeViewHasPreference']; - handleResetView: ReturnType['handleResetView']; isEnvironmentList: ReturnType['isEnvironmentList']; toolbarBar: ReturnType['toolbarBar']; viewAccess: ReturnType; @@ -86,7 +81,7 @@ export function useObjectViewHeaderChrome({ // objectui#12705 — the header's actions folded, for a header too narrow // for them and the description: the primary action stays labelled and // every other action goes into one `⋯` menu, above the page's own entries - // (favorites, Reset view). The header decides when; this decides what. + // (favorites). The header decides when; this decides what. // // The primary is DECLARED, never read off the row's order: the page's own // New when it draws one, else the toolbar action authored @@ -132,13 +127,6 @@ export function useObjectViewHeaderChrome({ type: 'script' as const, onClick: listFavorite.toggle, }] : []), - ...(activeViewHasPreference ? [{ - name: 'object_view_reset_view', - label: t('console.objectView.resetView'), - icon: 'rotate-ccw', - type: 'script' as const, - onClick: handleResetView, - }] : []), ], variant: 'ghost', size: 'icon', diff --git a/ui/packages/app-shell/src/views/objectViewListRenderer.tsx b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx index 89d930f9833..988592c00cc 100644 --- a/ui/packages/app-shell/src/views/objectViewListRenderer.tsx +++ b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx @@ -72,6 +72,7 @@ export function useObjectViewListRenderer({ recordOpen, openAsPage, phoneTitle, + handleListPersonalizationChange, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, @@ -113,6 +114,8 @@ export function useObjectViewListRenderer({ * a chart; `null` elsewhere, where the page header draws it. */ phoneTitle: ReactNode; + /** objectstack#28950 — what of the list differs from the view, keyed by the list identity. */ + handleListPersonalizationChange: ReturnType['handleListPersonalizationChange']; urlFilters: ReturnType['urlFilters']; initialUfSelections: ReturnType['initialUfSelections']; handleUserFilterSelectionsChange: ReturnType['handleUserFilterSelectionsChange']; @@ -401,7 +404,7 @@ export function useObjectViewListRenderer({ * popover's) names the parts it put back, and they are CLEARED from * this user's stored state rather than stored as the view's values, so * a later change to the view reaches them. A view left with nothing is - * removed (`patchViewPreference`), and with it the ⋯ "Reset view". + * removed (`patchViewPreference`). * The sort leaves the URL too, which would otherwise seed it again. * A deliberate `sort: []` or `hiddenFields: []` still arrives through * `persistListSort` / `persistHiddenFields` and stays stored. @@ -873,9 +876,16 @@ export function useObjectViewListRenderer({ // objectui#12949 — the view's own values under the user's // preference, for the Display control's reset and count, and // (objectstack#28948) the sort popover's reset. - viewDefaults={{ hiddenFields: viewHiddenFields, rowHeight: viewRowHeight, sort: viewSort }} + // objectstack#28950 — and the view's own grouping, under a + // grouping the URL carries (the `grouping` rung above), so the + // list can tell the reader's from the view's. + viewDefaults={{ hiddenFields: viewHiddenFields, rowHeight: viewRowHeight, sort: viewSort, grouping: listSchema.grouping }} // objectstack#28948 — those resets clear the user's state. onRestoreViewDefaults={restoreViewDefaults} + // objectstack#28950 — what of the list differs from the view, + // for the 「Personalized」 marker, which then owns the one reset + // (the Display control draws none of its own). + onPersonalizationChange={(parts) => handleListPersonalizationChange(identityKey, parts)} className={className} onEdit={editHandler} // objectui#13089 — the view's Add button is the header New: @@ -963,7 +973,7 @@ export function useObjectViewListRenderer({ dataSource={ds} /> ); - }, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, createRecord, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, handleListTotalChange, managedUsers, managedUserMarker, recordOpen, openAsPage, handleListRowsChange, phoneTitle]); + }, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, createRecord, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, handleListTotalChange, managedUsers, managedUserMarker, recordOpen, openAsPage, handleListRowsChange, phoneTitle, handleListPersonalizationChange]); return { renderListView }; } diff --git a/ui/packages/app-shell/src/views/objectViewListUrlState.ts b/ui/packages/app-shell/src/views/objectViewListUrlState.ts index cd7c1c66c7a..5a2435c807a 100644 --- a/ui/packages/app-shell/src/views/objectViewListUrlState.ts +++ b/ui/packages/app-shell/src/views/objectViewListUrlState.ts @@ -22,7 +22,7 @@ import { type ListUrlState, type ListSortRule, } from './userFilterUrlState.js'; -import { buildListFilterKey, readListFilterState } from './listFilterStorage.js'; +import { buildListFilterKey, readListFilterState, clearListFilterState } from './listFilterStorage.js'; import type { useObjectListPreferences, ListFilterGroupPreference, @@ -32,10 +32,26 @@ import { parseUrlEqualityFilterTriples } from './drillUrlFilters.js'; import { selectFilterParams } from './objectViewHelpers.js'; import type { buildViewTabs } from './objectViewTabs.js'; import type { ConsoleObjectViewProps } from './ObjectView.js'; +import type { ListPersonalizationPart } from './ListPersonalizedMarker.js'; const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); +/** + * objectstack#28950 — the params with the reader's whole `uf_*` list state + * removed: the Filter panel, search, sort and grouping, and every user-filter + * chip. Through the owning module's own writers, so the family has one + * definition. Every other param (`filter[...]` drill scope, `recordId`, ...) + * is kept. + */ +export function withoutListStateParams(prev: URLSearchParams): URLSearchParams { + const chips = parseUserFilterParams(prev); + const cleared = chips + ? applyUserFilterParams(prev, Object.fromEntries(Object.keys(chips).map(field => [field, []]))) + : new URLSearchParams(prev); + return applyListStateParams(cleared, { filters: null, search: null, sort: null, grouping: null }); +} + /** * objectstack#28949 — the stored sort through the seed's field gate: a level * naming a refused field is dropped and the levels around it keep their order. @@ -149,8 +165,12 @@ export function useObjectViewListUrlState({ const listIdentityKey = activeView?.id ? `${objectName}-${activeView.id}${listResetGeneration ? `~${listResetGeneration}` : ''}` : undefined; - const listSeedRef = useRef<{ key: string; source: 'url' | 'storage'; state: ListUrlState } | undefined>(undefined); + const listSeedRef = useRef<{ key: string; source: 'url' | 'storage'; state: ListUrlState; reset?: boolean } | undefined>(undefined); const ownListParamsRef = useRef(undefined); + // objectstack#28950 — set by "Reset to view" and taken by the next seed: + // that list opens from the (just cleared) stored state, never from the + // `uf_*` params, whoever wrote them, and its write-back strips them all. + const resetPendingRef = useRef(false); if (listIdentityKey && listPrefs.ready && listSeedRef.current?.key !== listIdentityKey) { // A field the object does not declare (a stale link, or a stored state // older than the field's deletion) or one this user cannot read is @@ -160,7 +180,9 @@ export function useObjectViewListUrlState({ !!objectDef.fields?.[field] && (!perms.isLoaded || perms.checkField(objectDef.name, field, 'read')); const carried = userFilterParamsKey(searchParams); - if (carried && carried !== ownListParamsRef.current) { + const reset = resetPendingRef.current; + resetPendingRef.current = false; + if (carried && carried !== ownListParamsRef.current && !reset) { listSeedRef.current = { key: listIdentityKey, source: 'url', @@ -177,13 +199,14 @@ export function useObjectViewListUrlState({ search: stored?.search || undefined, sort: acceptStoredSort(viewPref?.sort, acceptField), }, + reset, }; } } useEffect(() => { const seed = listSeedRef.current; if (!seed || seed.key !== listIdentityKey) return; - const next = applyListStateParams(searchParams, { + const next = applyListStateParams(seed.reset ? withoutListStateParams(searchParams) : searchParams, { filters: seed.state.filters ?? null, search: seed.state.search ?? null, sort: seed.state.sort ?? null, @@ -212,22 +235,63 @@ export function useObjectViewListUrlState({ ); /** - * objectui#12418 — "Reset view": drop this user's sort, filters and column - * layout for the active view, and open the list again as the view declares - * it. Offered only while they have stored something for it. + * objectstack#28950 — what of the reader's list differs from the view, as + * 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. + */ + const [reportedPersonalization, setReportedPersonalization] = useState<{ + key: string; + parts: ReadonlyArray; + } | undefined>(undefined); + const handleListPersonalizationChange = useCallback( + (key: string, parts: ReadonlyArray) => { + setReportedPersonalization(prev => + prev && prev.key === key && prev.parts.join(',') === parts.join(',') ? prev : { key, parts }); + }, + [], + ); + const listPersonalizedParts: ListPersonalizationPart[] = [ + ...(reportedPersonalization && reportedPersonalization.key === listIdentityKey + ? reportedPersonalization.parts + : []), + ]; + if (activeView?.id && listPrefs.preference.perView?.[activeView.id]?.columnState + && !listPersonalizedParts.includes('columns')) { + listPersonalizedParts.push('columns'); + } + + /** + * 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 + * 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 + * them, chips included, so neither a reload nor Back brings the old list + * back. The remount puts the grouping, layout, row color, row numbers, + * inline edit and the "In a queue" toggle back to the view's. + * + * Not this view's list state, so untouched: the record-open preference, + * the tab order (pinned views), favorites and a `filter[...]` scope a + * "View all" link opened the list with. */ - const activeViewHasPreference = !!(activeView?.id && listPrefs.preference.perView?.[activeView.id]); const handleResetView = useCallback(() => { if (!activeView?.id) return; listPrefs.resetView(activeView.id); + clearListFilterState(buildListFilterKey(user?.id, objectName, activeView.id)); + resetPendingRef.current = true; setListResetGeneration(g => g + 1); - }, [activeView?.id, listPrefs.resetView]); + }, [activeView?.id, listPrefs.resetView, user?.id, objectName]); return { listIdentityKey, listSeedRef, ownListParamsRef, writeListUrlState, - activeViewHasPreference, + handleListPersonalizationChange, + listPersonalizedParts, handleResetView, }; } @@ -237,11 +301,13 @@ export function useObjectViewUrlFilters({ setSearchParams, listIdentityKey, ownListParamsRef, + listSeedRef, }: { searchParams: URLSearchParams; setSearchParams: SetURLSearchParams; listIdentityKey: ReturnType['listIdentityKey']; ownListParamsRef: ReturnType['ownListParamsRef']; + listSeedRef?: ReturnType['listSeedRef']; }) { /** * URL-derived equality filters in the form `?filter[]=`. @@ -300,7 +366,10 @@ export function useObjectViewUrlFilters({ */ const ufSelectionsRef = useRef<{ key: string; selections: Record | undefined } | undefined>(undefined); if (listIdentityKey && ufSelectionsRef.current?.key !== listIdentityKey) { - ufSelectionsRef.current = { key: listIdentityKey, selections: parseUserFilterParams(searchParams) }; + // objectstack#28950 — the list "Reset to view" opened starts with no + // chip selected: its seed strips their params from the address bar. + const reset = listSeedRef?.current?.key === listIdentityKey && !!listSeedRef.current.reset; + ufSelectionsRef.current = { key: listIdentityKey, selections: reset ? undefined : parseUserFilterParams(searchParams) }; } const initialUfSelections = listIdentityKey ? ufSelectionsRef.current?.selections : undefined; const handleUserFilterSelectionsChange = useCallback( diff --git a/ui/packages/app-shell/src/views/objectViewPageHeader.tsx b/ui/packages/app-shell/src/views/objectViewPageHeader.tsx index 540eb8ba888..f26f8ea4f69 100644 --- a/ui/packages/app-shell/src/views/objectViewPageHeader.tsx +++ b/ui/packages/app-shell/src/views/objectViewPageHeader.tsx @@ -3,7 +3,8 @@ * title (the object's plural ahead of the view switcher, or the plural alone), * managed-by badge, description and icon, with the * header's actions (the create button, Import, the schema-driven toolbar and - * the overflow menu with favorites and "Reset view") and their folded face, + * the overflow menu with favorites) and their folded face, the 「Personalized」 + * marker beside the title (objectstack#28950), * moved out of `ObjectViewInner`'s JSX verbatim (objectstack#25992, slice 10). * Every value is passed in as a prop. */ @@ -15,10 +16,9 @@ import { Button, DropdownMenu, DropdownMenuContent, - DropdownMenuItem, DropdownMenuTrigger, } from '@object-ui/components'; -import { Upload, MoreHorizontal, RotateCcw } from 'lucide-react'; +import { Upload, MoreHorizontal } from 'lucide-react'; import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; import { FavoriteMenuItem } from '../layout/SidebarItemMenu.js'; import type { useObjectListFavorite } from '../layout/SidebarItemMenu.js'; @@ -29,7 +29,6 @@ import { EnvironmentListToolbar } from '../environment/EnvironmentListToolbar.js import { ListHeaderActionBar } from './objectViewHeaderLaunchGate.js'; import type { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js'; import type { useObjectViewHeaderToolbar } from './objectViewHeaderToolbar.js'; -import type { useObjectViewListUrlState } from './objectViewListUrlState.js'; import type { useObjectViewHeaderChrome } from './objectViewHeaderChrome.js'; import type { ConsoleObjectViewProps } from './ObjectView.js'; @@ -88,8 +87,7 @@ export function ObjectViewPageHeader({ environmentEntitlements, toolbarBar, listFavorite, - activeViewHasPreference, - handleResetView, + personalizedMarker, foldedHeaderActions, }: { objectDef: ConsoleObjectViewProps['objects'][number]; @@ -112,8 +110,12 @@ export function ObjectViewPageHeader({ environmentEntitlements: ReturnType['environmentEntitlements']; toolbarBar: ReturnType['toolbarBar']; listFavorite: ReturnType; - activeViewHasPreference: ReturnType['activeViewHasPreference']; - handleResetView: ReturnType['handleResetView']; + /** + * objectstack#28950 — the 「Personalized」 marker (`./ListPersonalizedMarker.tsx`), + * or `null` while the reader's list is the view's own. Drawn in the + * header's badge slot, beside the title, in both of the header's faces. + */ + personalizedMarker: ReactNode; foldedHeaderActions: ReturnType['foldedHeaderActions']; }) { const pluralLabel = objectPluralLabel(objectDef); @@ -137,7 +139,9 @@ export function ObjectViewPageHeader({ title={title} // The kind chip, in the header's own slot after the title // (objectui#12606). - badge={} + // objectstack#28950 — and, before it, the 「Personalized」 + // marker while the reader's list differs from the view. + badge={<>{personalizedMarker}} subtitle={objectDef.description ? objectDesc(objectDef) : undefined} // `createElement`, not a JSX tag: the looked-up icon is a value, // which react-hooks/static-components refuses in tag position @@ -191,10 +195,10 @@ export function ObjectViewPageHeader({ row: the favorites entry that replaced the header star. Favorites is a once-and-forget act, so it lives behind the ⋯ rather than in the header's most prominent slot. - "Reset view" (objectui#12418) is the same kind of act and - lives beside it, so the one-row header keeps its shape - as the user sorts. The ⋯ is drawn when either applies. */} - {(listFavorite || activeViewHasPreference) && ( + The list's way back to its view is the 「Personalized」 + marker beside the title (objectstack#28950), not an + entry here. */} + {listFavorite && (