@@ -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+
8593vi . mock ( './MetadataInspector' , ( ) => ( {
8694 MetadataPanel : ( ) => null ,
8795 useMetadataInspector : ( ) => ( { showDebug : false , toggle : ( ) => { } } ) ,
@@ -91,8 +99,9 @@ vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null }));
9199import { ObjectView } from './ObjectView' ;
92100import { ExpressionProvider } from '../providers/ExpressionProvider' ;
93101import { UserStateAdaptersProvider , useAttachListStateFamily } from '../context/UserStateAdapters' ;
94- import { ListPreferencesProvider , listPreferenceKey } from '../context/ListPreferencesProvider' ;
102+ import { ListPreferencesProvider , listPreferenceKey , localListStateKey } from '../context/ListPreferencesProvider' ;
95103import { applyListStateParams } from './userFilterUrlState' ;
104+ import { toast } from 'sonner' ;
96105
97106/** Every request the page sends through `fetch`: the owner's door among them. */
98107const 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 ( / P a r i s / 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