Skip to content

Commit eb6199b

Browse files
feat(app-shell,plugin-list): a personal view's own conditions are Filter panel rows, saved back by 保存 (#30242)
Fixes #30073 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:打开「我的视图」时,它自己的筛选条件以普通行出现在「筛选」面板里;改动条件后出现「已个性化」,标记里提供「保存」,把面板写回这个视图;「另存为我的视图」之后,原来那个视图的临时条件和「已个性化」一并清掉。 - 为什么改:按裁决 A(「其他全部同意」):销售要能直接调整自己视图的条件,不必重建再删除;另存后原列表不再少行、不再像是数据丢了。 - 风险与回滚:只动 Console 前端;存储形状不变、不加新键。面板容纳不了的条件(「或」分组夹在其他条件旁)或读不到的字段,仍按原样在列表底下生效、不进面板。回滚即还原本 PR。 - 先看:`ui/packages/app-shell/src/views/ObjectView.tsx`(保存与另存后的清理)、`ui/packages/app-shell/src/views/personalViews.ts`(条件变行、保存体)、`ui/packages/plugin-list/src/ListView.tsx`(「已个性化」的筛选判断)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/ObjectView.personalViews-29635.test.tsx`,预期 20 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes Ruling A on #30073 (comment 6631079402), the maintainer's words 「其他全部同意」: a personal view's conditions appear as ordinary rows in the filter panel, editing them offers 保存 to that view, saving a view also clears the source view's temporary conditions, and conditions that cannot be folded keep today's refusal. The stored shape does not change and no key is added. - **Opening a personal view seeds its conditions as Filter panel rows** (`personalViewPanelFilters` in `personalViews.ts`, reading the stored list through `@object-ui/core`'s `builderRulesOfSpecFilter`, the read direction of the fold). While the panel holds them they are the list's filter: the renderer no longer applies the stored `filter` under the list as well, so each condition is applied once. A stored list the panel cannot hold (an any-of group beside other conditions), or one naming a field the object does not declare or the reader cannot read, keeps today's behaviour: applied under the list, not drawn as rows. The field gate is the one the list state is read back through, so a row it would drop on reload never makes the view select more records than it declares. - **The 「Personalized」 judgement for filters** (`ListView`): a host that shows the view's own conditions as rows passes them as `viewDefaults.filters`; the panel's finished conditions are then compared with them (field, operator, value, and the logic once two rows make it matter), so the view's own rows are not a personalization and an edit, a removal or "Clear all" is. Without the key, behaviour is as before. - **保存 in the 「Personalized」 marker**, on a personal view, for a reader allowed to update `sys_personal_view`: it builds the body with `buildPersonalViewConfig` (the one Save as my view uses, so the stored `ListView` shape is the same), replaces the row's `config` through the data API's `update`, then clears the reader's list state for that view and the list opens again on the saved view. Nothing is written before the click. A panel the fold refuses (`nested_group`) shows the same refusal Save as my view gives and writes nothing. - **Save as my view clears the source view**: after the row is created, the source view's stored list state and cached search term are cleared and the address being left loses its `uf_*` params (`clearSourceListState`), so neither Back nor the next open of the source brings the conditions back or reads 已个性化. - **Both saves read the panel as the list reports it** (`readPanelFilters`), not the address's copy: an emptied panel leaves no `uf__filter` param, exactly as an untouched one, so the address alone could not tell "removed every condition" from "unchanged". Landing point: `ui/packages/app-shell/src/views/**` and the filter-state region of `ui/packages/plugin-list/src/ListView.tsx` (the `viewDefaults` prop type and the personalization judgement); the hidden-fields panel is untouched. ## Tests - `personalViews.test.ts`: `personalViewPanelFilters` (AND list, the fold's OR group, and the three cases that stay under the list); `buildPersonalViewConfig` with panel-held conditions (untouched keeps the stored list, edited is the whole filter and parses under `ListViewSchema`, emptied stores none, nested refuses). - `ObjectView.personalViews-29635.test.tsx`, new top-level `describe` in the shared harness: (1) opening `pv_mine` shows `Filter, 1 active`, the `Paris` row in the panel, `Paris` once in the list query, no marker; (2) an edited panel shows the marker, 保存 calls `update('sys_personal_view', 'pv_mine', { config })` with `filter: [{ field: 'city', operator: 'equals', value: 'Rome' }]`, the body parses under `ListViewSchema`, and the list reopens unpersonalized on `Rome`; no 保存 without the update grant; (3) Save as my view from a filtered `v1` stores the conditions in the new view, clears `v1`'s stored list state, and a fresh open of `v1` has no condition and no marker; the nested-group panel keeps the refusal and writes nothing; a stored list the panel cannot hold stays applied under the list. - `ListView.personalization-28950.test.tsx`: `viewDefaults.filters` equal rows report nothing, edited or emptied rows report `filters`. ## Acceptance notes - `personalViewPanelFilters` builds builder rows the way the Studio inspector's private `groupOfStoredRules` (`metadata-admin/widgets.tsx`) does; that file is outside this card's surface, so the two are not merged here. - A link to a personal view that carries `uf__filter` now opens with the link's rows as the whole panel (the panel is the filter), rather than those rows laid over the view's stored conditions. Personal views are the reader's own and the address this page writes already carries the whole panel. - 保存 and Save as my view clear the reader's whole stored entry for the view (sort, filters, column layout, density, hidden fields): every part that entry holds is in the saved body, except column widths, which no saved view body carries today. ## Verification All readings on head `3ee4384a5` (after merging `origin/main`), objectstack-ai/objectstack. <details><summary>Tests (isolated, as CI shards run them)</summary> - `pnpm --dir ui exec vitest run --isolate --maxWorkers=2 packages/plugin-list/`: Test Files 189 passed (189), Tests 1799 passed (1799); lock VERDICT command-exit 0. - app-shell, narrowed and declared: the 128 app-shell test files that import a touched module (`ObjectView`, `objectViewListRenderer`, `objectViewListUrlState`, `personalViews`, `ListPersonalizedMarker`), `--isolate --maxWorkers=2`: Test Files 128 passed (128), Tests 1125 passed (1125); lock VERDICT command-exit 0. The whole package (2177 files) does not fit a local run at two workers; Test UI in CI runs it. The narrowing is by import and is blind to a test that reaches these modules through another import. - `pnpm --dir ui exec vitest run scripts/` (after the seven `@objectstack` builds): Test Files 170 passed, 2 skipped (172); Tests 4985 passed, 2 skipped. - `pnpm exec turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/plugin-list` (each runs `tsc --noEmit` and `tsc -p tsconfig.test.json`): Tasks 69 successful, 69 total. </details> <details><summary>Reverse verification (each mutation applied by `scripts/ablation-replace.mjs`, which proved it landed and restored it; tree clean after each)</summary> - Base filter applied again for panel-held conditions (`const base = false ? undefined : (`): "applied once" goes red, `expected [ 'Paris', 'Paris' ] to have a length of 1 but got 2`. - `clearSourceListState(activeView.id)` deleted: "Save as my view leaves the view it came from ..." goes red, the source's stored `filters` still present. - `viewDefaults.filters` ignored in `ListView`: "applied once ... not personalized" goes red, the marker is drawn. </details> <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` derived 35 commands; all 35 run, each exit 0 (`check:type-check-debt` first returned exit 3 PREREQUISITE NOT MET on a lock queue timeout, then re-run under the lock: `re-measure: OK`, exit 0). `--ran` reconciliation: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. Also `pnpm check:changeset-fixed` (roster under `.changeset`) exit 0 and `pnpm check:new-line-citations` 0 new citations. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYpFBMr917PmVkG9up7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cd5f554 commit eb6199b

11 files changed

Lines changed: 508 additions & 22 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/plugin-list': patch
4+
---
5+
6+
feat(app-shell,plugin-list): a personal view's own conditions are Filter panel rows, and 保存 writes the panel back into that view
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Opening one of the reader's own views (My views) shows its conditions as ordinary rows in the Filter panel**, with the active count on the Filter control. They are the list's filter while they are there: not applied under the list a second time, and not a personalization. A stored filter the panel cannot hold (an any-of group beside other conditions), or one naming a field the object no longer declares or the reader cannot read, stays applied under the list, as for any other view.
12+
- **Editing those rows marks the list 已个性化, and the marker offers 保存 to that view** (for a reader allowed to update their views). 保存 replaces the row's `config` through the data API with the body Save as my view builds, so the stored `ListView` shape is unchanged and no key is added; the reader's temporary list state for the view is then cleared and the list opens on the saved view. Edits not saved stay the reader's temporary list state and are never written to the view. A panel the fold cannot write losslessly keeps the refusal Save as my view gives, and nothing is written.
13+
- **Save as my view now clears the view it came from**: that view's stored list state (sort, filters, column layout, density, hidden fields) and cached search term go, and the address being left loses its `uf_*` params, so the source no longer reads filtered or 已个性化 after the save.
14+
- Both saves read the Filter panel as the list reports it, not the address's copy, so an emptied panel saves as no filter.
15+
- `@object-ui/plugin-list`: `ListView`'s host prop `viewDefaults` accepts `filters`, the view's own conditions for a host that shows them as panel rows; the `filters` personalization part is then judged against them.

‎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.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+
});

0 commit comments

Comments
 (0)