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
16 changes: 16 additions & 0 deletions .changeset/28950-list-personalized-marker.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion content/docs/ui/views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
118 changes: 118 additions & 0 deletions ui/packages/app-shell/src/views/ListPersonalizedMarker.tsx
Original file line number Diff line number Diff line change
@@ -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<NonNullable<ListViewProps['onPersonalizationChange']>>[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<ListPersonalizationPart>;
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<ListPersonalizationPart, string> = {
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
aria-label={label}
title={label}
data-testid="list-personalized-marker"
className={cn(
'h-6 shrink-0 gap-1 rounded-full px-1.5 sm:px-2 text-xs font-normal text-muted-foreground shadow-none',
className,
)}
>
<UserPen className="h-3.5 w-3.5" aria-hidden="true" />
<span className="hidden sm:inline">{label}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-3" data-testid="list-personalized-popover">
<p className="text-sm font-medium">{t('console.objectView.personalizedHeading')}</p>
<ul className="mt-2 flex flex-wrap gap-1.5" data-testid="list-personalized-parts">
{ordered.map((part) => (
<li
key={part}
data-part={part}
className="rounded-md bg-muted px-1.5 py-0.5 text-xs text-foreground"
>
{partLabels[part]}
</li>
))}
</ul>
<p className="mt-3 text-xs text-muted-foreground">{t('console.objectView.resetViewTitle')}</p>
<Button
type="button"
size="sm"
variant="outline"
className="mt-3 w-full gap-1.5"
data-testid="list-personalized-reset"
onClick={() => {
setOpen(false);
onReset();
}}
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />
{t('console.objectView.resetView')}
</Button>
</PopoverContent>
</Popover>
);
}
Loading
Loading