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
15 changes: 15 additions & 0 deletions .changeset/30073-personal-view-conditions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/app-shell': patch
'@object-ui/plugin-list': patch
---

feat(app-shell,plugin-list): a personal view's own conditions are Filter panel rows, and 保存 writes the panel back into that view

Clause-②: no
Metadata-protocol: none

- **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.
- **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.
- **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.
- Both saves read the Filter panel as the list reports it, not the address's copy, so an emptied panel saves as no filter.
- `@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.
24 changes: 23 additions & 1 deletion ui/packages/app-shell/src/views/ListPersonalizedMarker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
* (`useObjectViewListUrlState`'s `handleResetView`). Nothing here is
* configurable: no metadata key or option reaches it.
*
* On one of the reader's own views (objectstack#30073) it also offers 保存
* (`onSave`): the list on screen becomes the view's stored body. Edits not
* saved stay the reader's temporary list state, never written to the view.
*
* 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.
*/
Expand All @@ -17,7 +21,7 @@ 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';
import { RotateCcw, Save, 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];
Expand All @@ -40,10 +44,13 @@ const LIST_PERSONALIZATION_PART_ORDER: readonly ListPersonalizationPart[] = [
export function ListPersonalizedMarker({
parts,
onReset,
onSave,
className,
}: {
parts: ReadonlyArray<ListPersonalizationPart>;
onReset: () => void;
/** Save the list on screen into the open view; offered only for a view the reader owns and may update. */
onSave?: () => void;
className?: string;
}) {
const { t } = useObjectTranslation();
Expand Down Expand Up @@ -97,6 +104,21 @@ export function ListPersonalizedMarker({
</li>
))}
</ul>
{onSave ? (
<Button
type="button"
size="sm"
className="mt-3 w-full gap-1.5"
data-testid="list-personalized-save"
onClick={() => {
setOpen(false);
onSave();
}}
>
<Save className="h-4 w-4" aria-hidden="true" />
{t('common.save')}
</Button>
) : null}
<p className="mt-3 text-xs text-muted-foreground">{t('console.objectView.resetViewTitle')}</p>
<Button
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ vi.mock('@object-ui/collaboration', async (importOriginal) => ({
useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }),
}));

// objectstack#30073 — a refused save is told through the toast.
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: () => {} }),
Expand All @@ -91,8 +99,9 @@ vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null }));
import { ObjectView } from './ObjectView';
import { ExpressionProvider } from '../providers/ExpressionProvider';
import { UserStateAdaptersProvider, useAttachListStateFamily } from '../context/UserStateAdapters';
import { ListPreferencesProvider, listPreferenceKey } from '../context/ListPreferencesProvider';
import { ListPreferencesProvider, listPreferenceKey, localListStateKey } from '../context/ListPreferencesProvider';
import { applyListStateParams } from './userFilterUrlState';
import { toast } from 'sonner';

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

Expand Down Expand Up @@ -543,3 +553,126 @@ describe('objectstack#30034 — a personal view opened by its address never carr
expect(JSON.stringify(listReads(page.dataSource).at(-1))).not.toContain('Rome');
});
});

/**
* objectstack#30073 (ruling A) — a personal view's own conditions are Filter
* panel rows: opening the view seeds them, editing them offers 保存 to that
* view (the same stored shape Save as my view writes), and saving the list as
* a view of the reader's own leaves the view it came from as that view
* declares it. A panel the fold cannot write keeps today's refusal.
*/
describe('objectstack#30073 — a personal view\'s conditions are Filter panel rows, saved back by 保存', () => {
const ROME = { id: 'root', logic: 'and' as const, conditions: [{ id: 'r1', field: 'city', operator: 'equals', value: 'Rome' }] };
const romeLink = (path: string) => `${path}?${applyListStateParams(new URLSearchParams(), { filters: ROME as never }).toString()}`;
const storeListState = (state: unknown) => localStorage.setItem(localListStateKey('u1', 'crm_lead'), JSON.stringify(state));
const storedListState = () => JSON.parse(localStorage.getItem(localListStateKey('u1', 'crm_lead')) ?? '{}');
const filterTrigger = () => screen.getByTestId('toolbar-filter-trigger');
const lastLeadRead = (dataSource: ReturnType<typeof mountLeads>['dataSource']) => JSON.stringify(leadReads(dataSource).at(-1));

it('opening a personal view seeds its conditions as panel rows, applied once, and the list is not personalized', async () => {
const { dataSource } = mountLeads('/apps/crm/crm_lead/view/pv_mine');
await listToolbar();
expect(switcher()).toHaveTextContent('Paris leads');
await waitFor(() => expect(filterTrigger()).toHaveAttribute('aria-label', 'Filter, 1 active'));
fireEvent.click(filterTrigger());
expect(await screen.findByDisplayValue('Paris')).toBeInTheDocument();
// The panel row is the condition: it is not laid under the list as well.
await waitFor(() => expect(lastLeadRead(dataSource)).toContain('Paris'));
expect(lastLeadRead(dataSource).match(/Paris/g)).toHaveLength(1);
expect(screen.queryByTestId('list-personalized-marker')).toBeNull();
expect(dataSource.update).not.toHaveBeenCalled();
});

it('an edited panel is personalized and offers 保存, which writes the panel into that view in the stored shape', async () => {
const { dataSource } = mountLeads(romeLink('/apps/crm/crm_lead/view/pv_mine'));
await listToolbar();
const marker = await screen.findByTestId('list-personalized-marker');
// Nothing is written back while the edit is only on screen.
expect(dataSource.update).not.toHaveBeenCalled();
fireEvent.click(marker);
fireEvent.click(await screen.findByTestId('list-personalized-save'));

await waitFor(() => expect(dataSource.update).toHaveBeenCalledTimes(1));
const [resource, id, patch] = dataSource.update.mock.calls[0];
expect([resource, id, Object.keys(patch)]).toEqual(['sys_personal_view', 'pv_mine', ['config']]);
expect(ListViewSchema.safeParse(patch.config).success).toBe(true);
expect(patch.config.data).toEqual({ provider: 'object', object: 'crm_lead' });
expect(patch.config.filter).toEqual([{ field: 'city', operator: 'equals', value: 'Rome' }]);
// The list opens again on the saved view: its rows are the view's own.
await waitFor(() => expect(screen.queryByTestId('list-personalized-marker')).toBeNull());
await waitFor(() => expect(lastLeadRead(dataSource)).toContain('Rome'));
expect(lastLeadRead(dataSource)).not.toContain('Paris');
});

it('a reader without update on their views is not offered 保存', async () => {
grants.isLoaded = true;
grants.denied.add('sys_personal_view:update');
mountLeads(romeLink('/apps/crm/crm_lead/view/pv_mine'));
await listToolbar();
fireEvent.click(await screen.findByTestId('list-personalized-marker'));
await screen.findByTestId('list-personalized-reset');
expect(screen.queryByTestId('list-personalized-save')).toBeNull();
});

it('a panel the fold cannot write keeps the refusal: nothing is written to the view', async () => {
storeListState({
perView: {
pv_mine: {
filters: { id: 'root', logic: 'and', conditions: [{ id: 'g1', logic: 'or', conditions: [{ id: 'c1', field: 'city', operator: 'equals', value: 'Rome' }] }] },
},
},
});
const { dataSource } = mountLeads('/apps/crm/crm_lead/view/pv_mine');
await listToolbar();
fireEvent.click(await screen.findByTestId('list-personalized-marker'));
fireEvent.click(await screen.findByTestId('list-personalized-save'));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ description: expect.stringContaining('nested condition groups') }),
));
expect(dataSource.update).not.toHaveBeenCalled();
});

it('a stored list the panel cannot hold stays under the list, as for any other view', async () => {
const orBeside = {
...OWN,
config: body({ filter: [{ field: 'status', operator: 'equals', value: 'open' }, { or: [{ field: 'city', operator: 'equals', value: 'Paris' }] }] }),
};
const { dataSource } = mountLeads('/apps/crm/crm_lead/view/pv_mine', { personalRows: [orBeside] });
await listToolbar();
await waitFor(() => expect(lastLeadRead(dataSource)).toContain('Paris'));
expect(lastLeadRead(dataSource)).toContain('open');
expect(filterTrigger()).toHaveAttribute('aria-label', 'Filter');
});

it('Save as my view leaves the view it came from without the temporary conditions and not personalized', async () => {
storeListState({ perView: { v1: { filters: ROME } } });
const first = mountLeads(romeLink('/apps/crm/crm_lead/view/v1'));
await listToolbar();
await screen.findByTestId('list-personalized-marker');

const menu = openSwitcher();
await waitFor(() => expect(within(menu).getByTestId('list-view-switcher-save-mine')).toBeInTheDocument());
fireEvent.click(within(menu).getByTestId('list-view-switcher-save-mine'));
const dialog = await screen.findByTestId('personal-view-name-dialog');
fireEvent.change(within(dialog).getByTestId('personal-view-name-input'), { target: { value: 'Rome leads' } });
fireEvent.click(within(dialog).getByTestId('personal-view-name-submit'));

await waitFor(() => expect(first.dataSource.create).toHaveBeenCalledTimes(1));
expect(first.dataSource.create.mock.calls[0][1].config.filter).toEqual([{ field: 'city', operator: 'equals', value: 'Rome' }]);
await waitFor(() => expect(switcher()).toHaveTextContent('Rome leads'));
// The new view holds the conditions as its own rows: nothing reads personalized.
await waitFor(() => expect(screen.queryByTestId('list-personalized-marker')).toBeNull());
// The source's stored list state is gone.
expect(storedListState().perView?.v1).toBeUndefined();
first.unmount();

const again = mountLeads('/apps/crm/crm_lead/view/v1');
await listToolbar();
expect(switcher()).toHaveTextContent('View 1');
await waitFor(() => expect(leadReads(again.dataSource).length).toBeGreaterThan(0));
expect(lastLeadRead(again.dataSource)).not.toContain('Rome');
expect(filterTrigger()).toHaveAttribute('aria-label', 'Filter');
expect(screen.queryByTestId('list-personalized-marker')).toBeNull();
});
});
Loading
Loading