Skip to content

Commit 45425bc

Browse files
committed
wip(app-shell): personal view conditions as Filter panel rows, save to the view (#30073)
Claude-Session: https://claude.ai/code/session_01QSeKYpFBMr917PmVkG9up7 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 31fe289 commit 45425bc

6 files changed

Lines changed: 222 additions & 18 deletions

File tree

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
* (`useObjectViewListUrlState`'s `handleResetView`). Nothing here is
1010
* configurable: no metadata key or option reaches it.
1111
*
12+
* On one of the reader's own views (objectstack#30073) it also offers 保存
13+
* (`onSave`): the list on screen becomes the view's stored body. Edits not
14+
* saved stay the reader's temporary list state, never written to the view.
15+
*
1216
* Below `sm` the trigger is an icon alone (named by its `aria-label`), so on a
1317
* narrow row it never pushes the view name out.
1418
*/
@@ -17,7 +21,7 @@ import { useState } from 'react';
1721
import { Button, Popover, PopoverContent, PopoverTrigger, cn } from '@object-ui/components';
1822
import { useObjectTranslation } from '@object-ui/i18n';
1923
import type { ListViewProps } from '@object-ui/plugin-list';
20-
import { RotateCcw, UserPen } from 'lucide-react';
24+
import { RotateCcw, Save, UserPen } from 'lucide-react';
2125

2226
/** One part of a list that can differ from its view (`ListView`'s vocabulary). */
2327
export type ListPersonalizationPart = Parameters<NonNullable<ListViewProps['onPersonalizationChange']>>[0][number];
@@ -40,10 +44,13 @@ const LIST_PERSONALIZATION_PART_ORDER: readonly ListPersonalizationPart[] = [
4044
export function ListPersonalizedMarker({
4145
parts,
4246
onReset,
47+
onSave,
4348
className,
4449
}: {
4550
parts: ReadonlyArray<ListPersonalizationPart>;
4651
onReset: () => void;
52+
/** Save the list on screen into the open view; offered only for a view the reader owns and may update. */
53+
onSave?: () => void;
4754
className?: string;
4855
}) {
4956
const { t } = useObjectTranslation();
@@ -97,6 +104,21 @@ export function ListPersonalizedMarker({
97104
</li>
98105
))}
99106
</ul>
107+
{onSave ? (
108+
<Button
109+
type="button"
110+
size="sm"
111+
className="mt-3 w-full gap-1.5"
112+
data-testid="list-personalized-save"
113+
onClick={() => {
114+
setOpen(false);
115+
onSave();
116+
}}
117+
>
118+
<Save className="h-4 w-4" aria-hidden="true" />
119+
{t('common.save')}
120+
</Button>
121+
) : null}
100122
<p className="mt-3 text-xs text-muted-foreground">{t('console.objectView.resetViewTitle')}</p>
101123
<Button
102124
type="button"

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

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,13 @@ import { ObjectViewRecordOverlay } from './objectViewRecordOverlay.js';
7676
import { useObjectViewListRenderer } from './objectViewListRenderer.js';
7777
import { ListPersonalizedMarker } from './ListPersonalizedMarker.js';
7878
import { useObjectViewViewCrud } from './objectViewViewCrud.js';
79-
import { PERSONAL_VIEW_OBJECT, buildPersonalViewConfig, personalViewEntry, usePersonalViews } from './personalViews.js';
79+
import {
80+
PERSONAL_VIEW_OBJECT,
81+
buildPersonalViewConfig,
82+
personalViewEntry,
83+
personalViewPanelFilters,
84+
usePersonalViews,
85+
} from './personalViews.js';
8086
import { PersonalViewNameDialog } from './PersonalViewNameDialog.js';
8187
import { parseListStateParams } from './userFilterUrlState.js';
8288
import { FILTER_FOLD_REFUSAL_KEYS } from './viewFilterFold.js';
@@ -657,6 +663,22 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
657663
const activeView = viewDraft && viewDraft.id === baseView?.id
658664
? { ...baseView, ...viewDraft }
659665
: baseView;
666+
// The list's toolbar state is read through this field gate wherever it is
667+
// read (the seed, Save as my view, 保存): a field the object does not
668+
// declare, or one this reader cannot read, is no list state.
669+
const acceptListField = (field: string) =>
670+
!!objectDef.fields?.[field]
671+
&& (!perms.isLoaded || perms.checkField(objectDef.name, field, 'read'));
672+
// objectstack#30073 — a personal view's own conditions are Filter panel
673+
// rows (`personalViewPanelFilters`), edited there and saved back by 保存.
674+
// Keyed on the stored conditions' content (ui/AGENTS.md #10).
675+
const activeIsPersonal = !!activeView?.id && personalViewIds.has(activeView.id);
676+
const panelFiltersKey = activeIsPersonal ? JSON.stringify((activeView as any)?.filter ?? null) : '';
677+
const viewPanelFilters = useMemo(
678+
() => (panelFiltersKey ? personalViewPanelFilters(activeView, acceptListField) : undefined),
679+
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the content the fold reads
680+
[panelFiltersKey, objectDef, perms.isLoaded],
681+
);
660682

661683
// objectstack#25992 slice 8: the header toolbar actions, the nav `runAction` deep link
662684
// and the `action:bar` node moved to `./objectViewHeaderToolbar.ts`.
@@ -675,6 +697,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
675697
handleListPersonalizationChange,
676698
listPersonalizedParts,
677699
handleResetView,
700+
clearSourceListState,
678701
} = useObjectViewListUrlState({
679702
objectName,
680703
objectDef,
@@ -689,8 +712,42 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
689712
// objectstack#28950 — the 「Personalized」 marker beside the list title (the
690713
// page header's badge slot, and the phone title): drawn only while the
691714
// reader's list differs from the view, and the one way back to it.
715+
// objectstack#30073 — 保存: the list on screen written back into the open
716+
// personal view, through the body Save as my view builds (so the stored
717+
// shape is the same), then the reader's list state for it is cleared and
718+
// the list opens again on the saved view. A panel the fold cannot write
719+
// losslessly keeps the refusal Save as my view gives.
720+
const canUpdatePersonalView = activeIsPersonal
721+
&& personalViews.status === 'ready'
722+
&& (!perms.isLoaded || perms.can(PERSONAL_VIEW_OBJECT, 'update'));
723+
const handleSaveToPersonalView = async () => {
724+
if (!activeView || !activeIsPersonal) return;
725+
const built = buildPersonalViewConfig(objectDef.name, activeView, {
726+
url: parseListStateParams(searchParams, acceptListField),
727+
preference: listPrefs.preference.perView?.[activeView.id],
728+
panelFilters: viewPanelFilters,
729+
});
730+
if (!built.ok) {
731+
toast.error(t('form.saveError'), { description: t(FILTER_FOLD_REFUSAL_KEYS[built.reason]) });
732+
return;
733+
}
734+
try {
735+
await personalViews.update(activeView.id, built.config);
736+
handleResetView();
737+
} catch (err) {
738+
console.error('[ObjectView] Failed to save a personal view:', err);
739+
toast.error(t('form.saveError'), {
740+
description: err instanceof Error ? err.message : String(err),
741+
classNames: { description: 'whitespace-pre-line' },
742+
});
743+
}
744+
};
692745
const personalizedMarker = !isEnvironmentList && listPersonalizedParts.length > 0 ? (
693-
<ListPersonalizedMarker parts={listPersonalizedParts} onReset={handleResetView} />
746+
<ListPersonalizedMarker
747+
parts={listPersonalizedParts}
748+
onReset={handleResetView}
749+
onSave={canUpdatePersonalView ? () => { void handleSaveToPersonalView(); } : undefined}
750+
/>
694751
) : null;
695752

696753
// objectstack#25992 slice 11b: the view-config save and create handlers, view switching
@@ -750,19 +807,22 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
750807
if (!activeView) return false;
751808
// The list's toolbar state is the address's (`uf_*`), through the same
752809
// field gate its seed reads it with.
753-
const acceptField = (field: string) =>
754-
!!objectDef.fields?.[field]
755-
&& (!perms.isLoaded || perms.checkField(objectDef.name, field, 'read'));
756810
const built = buildPersonalViewConfig(objectDef.name, activeView, {
757-
url: parseListStateParams(searchParams, acceptField),
811+
url: parseListStateParams(searchParams, acceptListField),
758812
preference: listPrefs.preference.perView?.[activeView.id],
813+
panelFilters: viewPanelFilters,
759814
});
760815
if (!built.ok) {
761816
toast.error(t('form.saveError'), { description: t(FILTER_FOLD_REFUSAL_KEYS[built.reason]) });
762817
return false;
763818
}
764819
try {
765-
openPersonalView(await personalViews.save(label, built.config));
820+
const savedId = await personalViews.save(label, built.config);
821+
// objectstack#30073 — the list now lives in the saved view; the
822+
// view it came from returns to its own (no temporary conditions,
823+
// not personalized).
824+
clearSourceListState(activeView.id);
825+
openPersonalView(savedId);
766826
return true;
767827
} catch (err) {
768828
console.error('[ObjectView] Failed to save a personal view:', err);
@@ -1006,6 +1066,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
10061066
initialUfSelections,
10071067
handleUserFilterSelectionsChange,
10081068
handleListTotalChange,
1069+
viewPanelFilters,
10091070
});
10101071

10111072
// objectstack#25992 slice 9: the merged views and the plugin's `ObjectViewSchema`

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const ObjectChart = lazy(() =>
1717
);
1818
import { ListView, type ListViewProps } from '@object-ui/plugin-list';
1919
import type { ListViewSchema, TreeViewConfig } from '@object-ui/types';
20+
import type { FilterBuilderRowGroup } from '@object-ui/components';
2021
import type { useAuth } from '@object-ui/auth';
2122
import type { useActionTextLocalizer } from '@object-ui/react';
2223
import { useLocalization, type useObjectLabel, type useObjectTranslation } from '@object-ui/i18n';
@@ -77,6 +78,7 @@ export function useObjectViewListRenderer({
7778
initialUfSelections,
7879
handleUserFilterSelectionsChange,
7980
handleListTotalChange,
81+
viewPanelFilters,
8082
}: {
8183
objectName: string | undefined;
8284
inSetup: ReturnType<typeof isSetupAppSegment>;
@@ -121,6 +123,13 @@ export function useObjectViewListRenderer({
121123
handleUserFilterSelectionsChange: ReturnType<typeof useObjectViewUrlFilters>['handleUserFilterSelectionsChange'];
122124
/** objectstack#27594 — the records the active view matched, for the title's count. */
123125
handleListTotalChange: (total: number) => void;
126+
/**
127+
* objectstack#30073 — the active view's own conditions when the Filter
128+
* panel holds them as rows (a personal view, `personalViewPanelFilters`):
129+
* the panel opens on them, they are not applied under the list a second
130+
* time, and the list judges its 「Personalized」 filters against them.
131+
*/
132+
viewPanelFilters?: FilterBuilderRowGroup;
124133
}) {
125134
// objectui#10694 — does the active view, as AUTHORED, declare a non-empty
126135
// `columns`? Booleans, so the relay keys on values and not on the memo's
@@ -529,7 +538,9 @@ export function useObjectViewListRenderer({
529538
// URL filters). Now: base ?? listSchema, concatenated with the URL
530539
// filters, and substituted as a whole.
531540
filter: (() => {
532-
const base = (viewDef as any).filter ?? listSchema.filter;
541+
// A view whose conditions the panel holds is filtered by the
542+
// panel (objectstack#30073), never by its stored list as well.
543+
const base = viewPanelFilters ? undefined : ((viewDef as any).filter ?? listSchema.filter);
533544
const baseArr = Array.isArray(base) ? base : base ? [base] : [];
534545
const combined = urlFilters.length ? [...baseArr, ...urlFilters] : base;
535546
const resolved = substituteFilterTokens(combined, filterScope);
@@ -885,7 +896,7 @@ export function useObjectViewListRenderer({
885896
// objectstack#28950 — and the view's own grouping, under a
886897
// grouping the URL carries (the `grouping` rung above), so the
887898
// list can tell the reader's from the view's.
888-
viewDefaults={{ hiddenFields: viewHiddenFields, rowHeight: viewRowHeight, sort: viewSort, grouping: listSchema.grouping }}
899+
viewDefaults={{ hiddenFields: viewHiddenFields, rowHeight: viewRowHeight, sort: viewSort, grouping: listSchema.grouping, filters: viewPanelFilters }}
889900
// objectstack#28948 — those resets clear the user's state.
890901
onRestoreViewDefaults={restoreViewDefaults}
891902
// objectstack#28950 — what of the list differs from the view,
@@ -951,7 +962,9 @@ export function useObjectViewListRenderer({
951962
onColumnStateChange={persistListColumnState}
952963
userFilterSelections={initialUfSelections}
953964
onUserFilterSelectionsChange={handleUserFilterSelectionsChange}
954-
initialFilters={listSeed?.filters}
965+
// objectstack#30073 — with no list state of the reader's own,
966+
// a personal view's panel opens on the view's own conditions.
967+
initialFilters={listSeed?.filters ?? viewPanelFilters}
955968
initialSearchTerm={listSeed?.search}
956969
// objectui#12606 — Setup's list opens without row numbers in
957970
// the leading cell. A host prop, not a view key: a console
@@ -979,7 +992,7 @@ export function useObjectViewListRenderer({
979992
dataSource={ds}
980993
/>
981994
);
982-
}, [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, workspaceTimeZone]);
995+
}, [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, workspaceTimeZone, viewPanelFilters]);
983996

984997
return { renderListView };
985998
}

‎ui/packages/app-shell/src/views/objectViewListUrlState.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,22 @@ export function useObjectViewListUrlState({
303303
resetPendingRef.current = true;
304304
setListResetGeneration(g => g + 1);
305305
}, [activeView?.id, listPrefs.resetView, user?.id, objectName]);
306+
/**
307+
* objectstack#30073 — the list the reader just saved as a view of its own
308+
* leaves the view it came from: that view's stored list state (sort,
309+
* filters, column layout, density, hidden fields, every part the saved
310+
* body carries) and its cached search term go, and the address the page
311+
* is leaving loses its `uf_*` params, so neither Back nor the next open
312+
* of that view brings the conditions back, nor marks it personalized.
313+
* The caller navigates to the saved view next.
314+
*/
315+
const clearSourceListState = useCallback((viewId: string) => {
316+
listPrefs.resetView(viewId);
317+
clearListFilterState(buildListFilterKey(user?.id, objectName, viewId));
318+
setSearchParams(prev => withoutListStateParams(prev), { replace: true });
319+
}, [listPrefs.resetView, user?.id, objectName, setSearchParams]);
306320
return {
321+
clearSourceListState,
307322
listIdentityKey,
308323
listSeedRef,
309324
ownListParamsRef,

0 commit comments

Comments
 (0)