Skip to content

Commit 499ed50

Browse files
committed
test(app-shell): pin personal view conditions as panel rows, save back, source cleared (#30073)
Claude-Session: https://claude.ai/code/session_01QSeKYpFBMr917PmVkG9up7 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 45425bc commit 499ed50

4 files changed

Lines changed: 242 additions & 4 deletions

File tree

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

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ vi.mock('@object-ui/collaboration', async (importOriginal) => ({
8282
useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }),
8383
}));
8484

85+
// objectstack#30073 — a refused save is told through the toast.
86+
vi.mock('sonner', () => ({
87+
toast: Object.assign(vi.fn(), {
88+
success: vi.fn(), error: vi.fn(), info: vi.fn(),
89+
warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(),
90+
}),
91+
}));
92+
8593
vi.mock('./MetadataInspector', () => ({
8694
MetadataPanel: () => null,
8795
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
@@ -91,8 +99,9 @@ vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null }));
9199
import { ObjectView } from './ObjectView';
92100
import { ExpressionProvider } from '../providers/ExpressionProvider';
93101
import { UserStateAdaptersProvider, useAttachListStateFamily } from '../context/UserStateAdapters';
94-
import { ListPreferencesProvider, listPreferenceKey } from '../context/ListPreferencesProvider';
102+
import { ListPreferencesProvider, listPreferenceKey, localListStateKey } from '../context/ListPreferencesProvider';
95103
import { applyListStateParams } from './userFilterUrlState';
104+
import { toast } from 'sonner';
96105

97106
/** Every request the page sends through `fetch`: the owner's door among them. */
98107
const fetchCalls: Array<{ url: string; method: string }> = [];
@@ -219,6 +228,7 @@ beforeEach(() => {
219228
grants.denied.clear();
220229
localStorage.clear();
221230
fetchCalls.length = 0;
231+
vi.mocked(toast.error).mockClear();
222232
Object.defineProperty(window, 'innerWidth', { value: 1440, configurable: true, writable: true });
223233
});
224234

@@ -543,3 +553,126 @@ describe('objectstack#30034 — a personal view opened by its address never carr
543553
expect(JSON.stringify(listReads(page.dataSource).at(-1))).not.toContain('Rome');
544554
});
545555
});
556+
557+
/**
558+
* objectstack#30073 (ruling A) — a personal view's own conditions are Filter
559+
* panel rows: opening the view seeds them, editing them offers 保存 to that
560+
* view (the same stored shape Save as my view writes), and saving the list as
561+
* a view of the reader's own leaves the view it came from as that view
562+
* declares it. A panel the fold cannot write keeps today's refusal.
563+
*/
564+
describe('objectstack#30073 — a personal view\'s conditions are Filter panel rows, saved back by 保存', () => {
565+
const ROME = { id: 'root', logic: 'and' as const, conditions: [{ id: 'r1', field: 'city', operator: 'equals', value: 'Rome' }] };
566+
const romeLink = (path: string) => `${path}?${applyListStateParams(new URLSearchParams(), { filters: ROME as never }).toString()}`;
567+
const storeListState = (state: unknown) => localStorage.setItem(localListStateKey('u1', 'crm_lead'), JSON.stringify(state));
568+
const storedListState = () => JSON.parse(localStorage.getItem(localListStateKey('u1', 'crm_lead')) ?? '{}');
569+
const filterTrigger = () => screen.getByTestId('toolbar-filter-trigger');
570+
const lastLeadRead = (dataSource: ReturnType<typeof mountLeads>['dataSource']) => JSON.stringify(leadReads(dataSource).at(-1));
571+
572+
it('opening a personal view seeds its conditions as panel rows, applied once, and the list is not personalized', async () => {
573+
const { dataSource } = mountLeads('/apps/crm/crm_lead/view/pv_mine');
574+
await listToolbar();
575+
expect(switcher()).toHaveTextContent('Paris leads');
576+
await waitFor(() => expect(filterTrigger()).toHaveAttribute('aria-label', 'Filter, 1 active'));
577+
fireEvent.click(filterTrigger());
578+
expect(await screen.findByDisplayValue('Paris')).toBeInTheDocument();
579+
// The panel row is the condition: it is not laid under the list as well.
580+
await waitFor(() => expect(lastLeadRead(dataSource)).toContain('Paris'));
581+
expect(lastLeadRead(dataSource).match(/Paris/g)).toHaveLength(1);
582+
expect(screen.queryByTestId('list-personalized-marker')).toBeNull();
583+
expect(dataSource.update).not.toHaveBeenCalled();
584+
});
585+
586+
it('an edited panel is personalized and offers 保存, which writes the panel into that view in the stored shape', async () => {
587+
const { dataSource } = mountLeads(romeLink('/apps/crm/crm_lead/view/pv_mine'));
588+
await listToolbar();
589+
const marker = await screen.findByTestId('list-personalized-marker');
590+
// Nothing is written back while the edit is only on screen.
591+
expect(dataSource.update).not.toHaveBeenCalled();
592+
fireEvent.click(marker);
593+
fireEvent.click(await screen.findByTestId('list-personalized-save'));
594+
595+
await waitFor(() => expect(dataSource.update).toHaveBeenCalledTimes(1));
596+
const [resource, id, patch] = dataSource.update.mock.calls[0];
597+
expect([resource, id, Object.keys(patch)]).toEqual(['sys_personal_view', 'pv_mine', ['config']]);
598+
expect(ListViewSchema.safeParse(patch.config).success).toBe(true);
599+
expect(patch.config.data).toEqual({ provider: 'object', object: 'crm_lead' });
600+
expect(patch.config.filter).toEqual([{ field: 'city', operator: 'equals', value: 'Rome' }]);
601+
// The list opens again on the saved view: its rows are the view's own.
602+
await waitFor(() => expect(screen.queryByTestId('list-personalized-marker')).toBeNull());
603+
await waitFor(() => expect(lastLeadRead(dataSource)).toContain('Rome'));
604+
expect(lastLeadRead(dataSource)).not.toContain('Paris');
605+
});
606+
607+
it('a reader without update on their views is not offered 保存', async () => {
608+
grants.isLoaded = true;
609+
grants.denied.add('sys_personal_view:update');
610+
mountLeads(romeLink('/apps/crm/crm_lead/view/pv_mine'));
611+
await listToolbar();
612+
fireEvent.click(await screen.findByTestId('list-personalized-marker'));
613+
await screen.findByTestId('list-personalized-reset');
614+
expect(screen.queryByTestId('list-personalized-save')).toBeNull();
615+
});
616+
617+
it('a panel the fold cannot write keeps the refusal: nothing is written to the view', async () => {
618+
storeListState({
619+
perView: {
620+
pv_mine: {
621+
filters: { id: 'root', logic: 'and', conditions: [{ id: 'g1', logic: 'or', conditions: [{ id: 'c1', field: 'city', operator: 'equals', value: 'Rome' }] }] },
622+
},
623+
},
624+
});
625+
const { dataSource } = mountLeads('/apps/crm/crm_lead/view/pv_mine');
626+
await listToolbar();
627+
fireEvent.click(await screen.findByTestId('list-personalized-marker'));
628+
fireEvent.click(await screen.findByTestId('list-personalized-save'));
629+
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(
630+
expect.any(String),
631+
expect.objectContaining({ description: expect.stringContaining('nested condition groups') }),
632+
));
633+
expect(dataSource.update).not.toHaveBeenCalled();
634+
});
635+
636+
it('a stored list the panel cannot hold stays under the list, as for any other view', async () => {
637+
const orBeside = {
638+
...OWN,
639+
config: body({ filter: [{ field: 'status', operator: 'equals', value: 'open' }, { or: [{ field: 'city', operator: 'equals', value: 'Paris' }] }] }),
640+
};
641+
const { dataSource } = mountLeads('/apps/crm/crm_lead/view/pv_mine', { personalRows: [orBeside] });
642+
await listToolbar();
643+
await waitFor(() => expect(lastLeadRead(dataSource)).toContain('Paris'));
644+
expect(lastLeadRead(dataSource)).toContain('open');
645+
expect(filterTrigger()).toHaveAttribute('aria-label', 'Filter');
646+
});
647+
648+
it('Save as my view leaves the view it came from without the temporary conditions and not personalized', async () => {
649+
storeListState({ perView: { v1: { filters: ROME } } });
650+
const first = mountLeads(romeLink('/apps/crm/crm_lead/view/v1'));
651+
await listToolbar();
652+
await screen.findByTestId('list-personalized-marker');
653+
654+
const menu = openSwitcher();
655+
await waitFor(() => expect(within(menu).getByTestId('list-view-switcher-save-mine')).toBeInTheDocument());
656+
fireEvent.click(within(menu).getByTestId('list-view-switcher-save-mine'));
657+
const dialog = await screen.findByTestId('personal-view-name-dialog');
658+
fireEvent.change(within(dialog).getByTestId('personal-view-name-input'), { target: { value: 'Rome leads' } });
659+
fireEvent.click(within(dialog).getByTestId('personal-view-name-submit'));
660+
661+
await waitFor(() => expect(first.dataSource.create).toHaveBeenCalledTimes(1));
662+
expect(first.dataSource.create.mock.calls[0][1].config.filter).toEqual([{ field: 'city', operator: 'equals', value: 'Rome' }]);
663+
await waitFor(() => expect(switcher()).toHaveTextContent('Rome leads'));
664+
// The new view holds the conditions as its own rows: nothing reads personalized.
665+
await waitFor(() => expect(screen.queryByTestId('list-personalized-marker')).toBeNull());
666+
// The source's stored list state is gone.
667+
expect(storedListState().perView?.v1).toBeUndefined();
668+
first.unmount();
669+
670+
const again = mountLeads('/apps/crm/crm_lead/view/v1');
671+
await listToolbar();
672+
expect(switcher()).toHaveTextContent('View 1');
673+
await waitFor(() => expect(leadReads(again.dataSource).length).toBeGreaterThan(0));
674+
expect(lastLeadRead(again.dataSource)).not.toContain('Rome');
675+
expect(filterTrigger()).toHaveAttribute('aria-label', 'Filter');
676+
expect(screen.queryByTestId('list-personalized-marker')).toBeNull();
677+
});
678+
});

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
698698
listPersonalizedParts,
699699
handleResetView,
700700
clearSourceListState,
701+
readPanelFilters,
701702
} = useObjectViewListUrlState({
702703
objectName,
703704
objectDef,
@@ -723,7 +724,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
723724
const handleSaveToPersonalView = async () => {
724725
if (!activeView || !activeIsPersonal) return;
725726
const built = buildPersonalViewConfig(objectDef.name, activeView, {
726-
url: parseListStateParams(searchParams, acceptListField),
727+
// The panel's group is the one the list shows (an emptied panel
728+
// included), not the address's copy of it.
729+
url: { ...parseListStateParams(searchParams, acceptListField), filters: readPanelFilters() },
727730
preference: listPrefs.preference.perView?.[activeView.id],
728731
panelFilters: viewPanelFilters,
729732
});
@@ -808,7 +811,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
808811
// The list's toolbar state is the address's (`uf_*`), through the same
809812
// field gate its seed reads it with.
810813
const built = buildPersonalViewConfig(objectDef.name, activeView, {
811-
url: parseListStateParams(searchParams, acceptListField),
814+
// The panel's group is the one the list shows (an emptied panel
815+
// included), not the address's copy of it.
816+
url: { ...parseListStateParams(searchParams, acceptListField), filters: readPanelFilters() },
812817
preference: listPrefs.preference.perView?.[activeView.id],
813818
panelFilters: viewPanelFilters,
814819
});

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,9 +240,22 @@ export function useObjectViewListUrlState({
240240
// resolution that ends on the fall-back keeps the identity unchanged.)
241241
// eslint-disable-next-line react-hooks/exhaustive-deps -- see above
242242
}, [listIdentityKey, listPrefs.ready, viewResolving]);
243+
/**
244+
* objectstack#30073 — the Filter panel's group as the list last reported
245+
* it, held with the list identity it came from. The address cannot say
246+
* this alone: a panel the reader emptied leaves no `uf_*` param, exactly as
247+
* an untouched one does, and a group the URL form cannot carry is dropped
248+
* from it. A save reads the panel here ({@link readPanelFilters}).
249+
*/
250+
const listIdentityKeyRef = useRef(listIdentityKey);
251+
listIdentityKeyRef.current = listIdentityKey;
252+
const reportedPanelFiltersRef = useRef<{ key: string | undefined; filters: ListStatePatch['filters'] } | undefined>(undefined);
243253
/** Write the list's own change into the URL — replace, never a history entry per keystroke. */
244254
const writeListUrlState = useCallback(
245255
(patch: ListStatePatch) => {
256+
if ('filters' in patch) {
257+
reportedPanelFiltersRef.current = { key: listIdentityKeyRef.current, filters: patch.filters };
258+
}
246259
setSearchParams(prev => {
247260
const next = applyListStateParams(prev, patch);
248261
ownListParamsRef.current = userFilterParamsKey(next);
@@ -312,13 +325,28 @@ export function useObjectViewListUrlState({
312325
* of that view brings the conditions back, nor marks it personalized.
313326
* The caller navigates to the saved view next.
314327
*/
328+
/**
329+
* objectstack#30073 — the Filter panel's group on screen: the list's last
330+
* report for this list, else the group the list opened with (its seed).
331+
* `undefined` means the panel holds what the view hands it untouched; a
332+
* panel the reader emptied is an empty group, never `undefined`.
333+
*/
334+
const readPanelFilters = useCallback((): ListUrlState['filters'] => {
335+
const reported = reportedPanelFiltersRef.current;
336+
if (reported && reported.key === listIdentityKeyRef.current) {
337+
return reported.filters ?? { id: 'root', logic: 'and', conditions: [] };
338+
}
339+
const seed = listSeedRef.current;
340+
return seed && seed.key === listIdentityKeyRef.current ? seed.state.filters : undefined;
341+
}, []);
315342
const clearSourceListState = useCallback((viewId: string) => {
316343
listPrefs.resetView(viewId);
317344
clearListFilterState(buildListFilterKey(user?.id, objectName, viewId));
318345
setSearchParams(prev => withoutListStateParams(prev), { replace: true });
319346
}, [listPrefs.resetView, user?.id, objectName, setSearchParams]);
320347
return {
321348
clearSourceListState,
349+
readPanelFilters,
322350
listIdentityKey,
323351
listSeedRef,
324352
ownListParamsRef,

‎ui/packages/app-shell/src/views/personalViews.test.ts‎

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import { describe, expect, it } from 'vitest';
1212
import { ListViewSchema } from '@objectstack/spec/ui';
13-
import { buildPersonalViewConfig, personalViewEntry, readPersonalViewRows } from './personalViews';
13+
import { buildPersonalViewConfig, personalViewEntry, personalViewPanelFilters, readPersonalViewRows } from './personalViews';
1414

1515
const row = (extra: Record<string, unknown> = {}) => ({
1616
id: 'pv_1',
@@ -106,3 +106,75 @@ describe('buildPersonalViewConfig', () => {
106106
expect(buildPersonalViewConfig('crm_lead', VIEW, { url: { filters: nested as never } })).toEqual({ ok: false, reason: 'nested_group' });
107107
});
108108
});
109+
110+
/**
111+
* objectstack#30073 — a personal view's own conditions are Filter panel rows,
112+
* and 保存 writes the panel back as the view's filter in the stored shape.
113+
*/
114+
describe('personalViewPanelFilters', () => {
115+
const every = () => true;
116+
117+
it('reads a stored AND list as one panel row per condition, operator and value as stored', () => {
118+
const group = personalViewPanelFilters({
119+
filter: [
120+
{ field: 'stage', operator: 'not_in', value: ['closed_won', 'closed_lost'] },
121+
{ field: 'close_date', operator: 'between', value: ['{quarter_start}', '{quarter_end}'] },
122+
],
123+
}, every);
124+
expect(group?.logic).toBe('and');
125+
expect(group?.conditions.map(({ field, operator, value }) => ({ field, operator, value }))).toEqual([
126+
{ field: 'stage', operator: 'not_in', value: ['closed_won', 'closed_lost'] },
127+
{ field: 'close_date', operator: 'between', value: ['{quarter_start}', '{quarter_end}'] },
128+
]);
129+
});
130+
131+
it('reads the one any-of group the fold writes for an OR panel as an OR group of rows', () => {
132+
const group = personalViewPanelFilters({
133+
filter: [{ or: [{ field: 'city', operator: 'equals', value: 'Paris' }, { field: 'city', operator: 'equals', value: 'Rome' }] }],
134+
}, every);
135+
expect(group?.logic).toBe('or');
136+
expect(group?.conditions.map((c) => c.value)).toEqual(['Paris', 'Rome']);
137+
});
138+
139+
it('leaves a list the panel cannot hold, a refused field, or no condition where every view keeps its conditions', () => {
140+
const orBeside = [{ field: 'status', operator: 'equals', value: 'open' }, { or: [{ field: 'city', operator: 'equals', value: 'Paris' }] }];
141+
expect(personalViewPanelFilters({ filter: orBeside }, every)).toBeUndefined();
142+
expect(personalViewPanelFilters({ filter: [{ field: 'secret', operator: 'equals', value: 1 }] }, (f) => f !== 'secret')).toBeUndefined();
143+
expect(personalViewPanelFilters({ filter: [] }, every)).toBeUndefined();
144+
expect(personalViewPanelFilters({}, every)).toBeUndefined();
145+
});
146+
});
147+
148+
describe('buildPersonalViewConfig — a view whose conditions the panel holds', () => {
149+
const OWN_FILTER = [{ field: 'city', operator: 'equals', value: 'Paris' }];
150+
const VIEW = { id: 'pv_1', label: 'Paris leads', type: 'grid', columns: ['name', 'city'], filter: OWN_FILTER };
151+
const panelFilters = personalViewPanelFilters(VIEW, () => true);
152+
153+
it('an untouched panel keeps the stored filter as it is', () => {
154+
const built = buildPersonalViewConfig('crm_lead', VIEW, { url: { filters: undefined }, panelFilters });
155+
expect(built.ok && built.config.filter).toEqual(OWN_FILTER);
156+
});
157+
158+
it('an edited panel is the whole filter, in the stored shape, never laid over the view\'s own', () => {
159+
const edited = { id: 'root', logic: 'and', conditions: [{ id: 'view-0', field: 'city', operator: 'equals', value: 'Rome' }] };
160+
const built = buildPersonalViewConfig('crm_lead', VIEW, { url: { filters: edited as never }, panelFilters });
161+
expect(built.ok).toBe(true);
162+
if (!built.ok) return;
163+
expect(built.config.filter).toEqual([{ field: 'city', operator: 'equals', value: 'Rome' }]);
164+
expect(ListViewSchema.safeParse(built.config).success).toBe(true);
165+
});
166+
167+
it('an emptied panel stores no filter', () => {
168+
const built = buildPersonalViewConfig('crm_lead', VIEW, {
169+
url: { filters: { id: 'root', logic: 'and', conditions: [] } as never },
170+
panelFilters,
171+
});
172+
expect(built.ok && built.config.filter).toBeUndefined();
173+
});
174+
175+
it('a group the fold cannot write losslessly keeps the refusal', () => {
176+
const nested = { logic: 'and', conditions: [{ logic: 'or', conditions: [{ field: 'city', operator: 'equals', value: 'Paris' }] }] };
177+
expect(buildPersonalViewConfig('crm_lead', VIEW, { url: { filters: nested as never }, panelFilters }))
178+
.toEqual({ ok: false, reason: 'nested_group' });
179+
});
180+
});

0 commit comments

Comments
 (0)