11import { InferInputSignals } from '@ngneat/spectator' ;
22import { createComponentFactory , mockProvider , Spectator } from '@ngneat/spectator/jest' ;
3+ import { throwError } from 'rxjs' ;
34
45import { HttpClient } from '@angular/common/http' ;
5- import { signal } from '@angular/core' ;
6+ import { computed , signal } from '@angular/core' ;
7+ import { fakeAsync , tick } from '@angular/core/testing' ;
68import { FormGroup } from '@angular/forms' ;
79
810import { Accordion , AccordionModule } from 'primeng/accordion' ;
911import { MessageService } from 'primeng/api' ;
1012import { ButtonModule } from 'primeng/button' ;
1113
1214import { DotMessageService , DotWorkflowsActionsService } from '@dotcms/data-access' ;
15+ import { DotCMSPageAsset } from '@dotcms/types' ;
1316import { StyleEditorFormSchema } from '@dotcms/uve' ;
1417
1518import { DotUveStyleEditorFormComponent } from './dot-uve-style-editor-form.component' ;
1619
1720import { DotPageApiService } from '../../../../../services/dot-page-api.service' ;
21+ import { STYLE_EDITOR_DEBOUNCE_TIME } from '../../../../../shared/consts' ;
1822import { ActionPayload } from '../../../../../shared/models' ;
1923import { UVEStore } from '../../../../../store/dot-uve.store' ;
2024
@@ -80,6 +84,12 @@ describe('DotUveStyleEditorFormComponent', () => {
8084 let mockUveStore : {
8185 currentIndex : ReturnType < typeof signal < number > > ;
8286 activeContentlet : ReturnType < typeof signal < ActionPayload | null > > ;
87+ graphqlResponse : ReturnType < typeof signal < DotCMSPageAsset | null > > ;
88+ $customGraphqlResponse : ReturnType < typeof computed < DotCMSPageAsset | null > > ;
89+ saveStyleEditor : jest . Mock ;
90+ rollbackGraphqlResponse : jest . Mock ;
91+ addHistory : jest . Mock ;
92+ setGraphqlResponse : jest . Mock ;
8393 } ;
8494
8595 const createComponent = createComponentFactory ( {
@@ -98,10 +108,52 @@ describe('DotUveStyleEditorFormComponent', () => {
98108 ]
99109 } ) ;
100110
111+ const createMockGraphQLResponse = ( fontSize : number ) : DotCMSPageAsset =>
112+ ( {
113+ page : {
114+ identifier : 'test-page' ,
115+ title : 'Test Page'
116+ } ,
117+ containers : {
118+ 'test-container' : {
119+ contentlets : {
120+ 'uuid-test-uuid' : [
121+ {
122+ identifier : 'test-id' ,
123+ inode : 'test-inode' ,
124+ title : 'Test' ,
125+ contentType : 'test-content-type' ,
126+ dotStyleProperties : {
127+ 'font-size' : fontSize ,
128+ 'font-family' : 'Arial' ,
129+ 'text-decoration' : {
130+ underline : false ,
131+ overline : false
132+ } ,
133+ alignment : 'left'
134+ }
135+ }
136+ ]
137+ }
138+ }
139+ }
140+ } ) as unknown as DotCMSPageAsset ;
141+
101142 beforeEach ( ( ) => {
143+ const graphqlResponseSignal = signal < DotCMSPageAsset | null > ( null ) ;
144+ const customGraphqlResponseComputed = computed ( ( ) => graphqlResponseSignal ( ) ) ;
145+
102146 mockUveStore = {
103147 currentIndex : signal ( 0 ) ,
104- activeContentlet : signal ( null )
148+ activeContentlet : signal ( null ) ,
149+ graphqlResponse : graphqlResponseSignal ,
150+ $customGraphqlResponse : customGraphqlResponseComputed ,
151+ saveStyleEditor : jest . fn ( ) ,
152+ rollbackGraphqlResponse : jest . fn ( ) . mockReturnValue ( true ) ,
153+ addHistory : jest . fn ( ) ,
154+ setGraphqlResponse : jest . fn ( ( response : DotCMSPageAsset | null ) => {
155+ graphqlResponseSignal . set ( response ) ;
156+ } )
105157 } ;
106158
107159 spectator = createComponent ( {
@@ -219,4 +271,156 @@ describe('DotUveStyleEditorFormComponent', () => {
219271 expect ( textDecorationGroup . get ( 'overline' ) ?. value ) . toBe ( true ) ;
220272 } ) ;
221273 } ) ;
274+
275+ describe ( 'rollback and form restoration' , ( ) => {
276+ beforeEach ( ( ) => {
277+ // Set up activeContentlet with initial style properties
278+ // Include ALL fields from the schema to match the graphqlResponse structure
279+ mockUveStore . activeContentlet . set ( {
280+ contentlet : {
281+ identifier : 'test-id' ,
282+ inode : 'test-inode' ,
283+ title : 'Test' ,
284+ contentType : 'test-content-type' ,
285+ dotStyleProperties : {
286+ 'font-size' : 16 ,
287+ 'font-family' : 'Arial' ,
288+ 'text-decoration' : {
289+ underline : false ,
290+ overline : false
291+ } ,
292+ alignment : 'left'
293+ }
294+ } ,
295+ container : {
296+ acceptTypes : 'test' ,
297+ identifier : 'test-container' ,
298+ maxContentlets : 1 ,
299+ variantId : 'test-variant' ,
300+ uuid : 'test-uuid'
301+ } ,
302+ language_id : '1' ,
303+ pageContainers : [ ] ,
304+ pageId : 'test-page'
305+ } ) ;
306+
307+ // Set initial graphqlResponse
308+ const initialResponse = createMockGraphQLResponse ( 16 ) ;
309+ mockUveStore . graphqlResponse . set ( initialResponse ) ;
310+ } ) ;
311+
312+ it ( 'should restore form values after rollback on save failure' , fakeAsync ( ( ) => {
313+ // Create component with activeContentlet
314+ spectator = createComponent ( {
315+ props : {
316+ [ 'schema' as keyof InferInputSignals < DotUveStyleEditorFormComponent > ] :
317+ createMockSchema ( )
318+ }
319+ } ) ;
320+ spectator . detectChanges ( ) ;
321+
322+ let form = spectator . component . $form ( ) ;
323+ expect ( form ?. get ( 'font-size' ) ?. value ) . toBe ( 16 ) ;
324+
325+ // Mock saveStyleEditor to fail and simulate rollback by updating graphqlResponse
326+ const rolledBackResponse = createMockGraphQLResponse ( 16 ) ;
327+ mockUveStore . saveStyleEditor . mockReturnValue (
328+ throwError ( ( ) => {
329+ // Simulate store's rollback behavior: update graphqlResponse to rolled-back state
330+ mockUveStore . graphqlResponse . set ( rolledBackResponse ) ;
331+ return new Error ( 'Save failed' ) ;
332+ } )
333+ ) ;
334+
335+ // Change form value (this triggers the save flow)
336+ form ?. patchValue ( { 'font-size' : 20 } ) ;
337+ tick ( STYLE_EDITOR_DEBOUNCE_TIME + 100 ) ; // Wait for debounce + error handling
338+ spectator . detectChanges ( ) ; // Ensure change detection runs after rollback
339+
340+ // Get the NEW form reference after rollback (form is rebuilt, not patched)
341+ form = spectator . component . $form ( ) ;
342+
343+ // Verify form is restored to rolled-back value
344+ expect ( form ?. get ( 'font-size' ) ?. value ) . toBe ( 16 ) ;
345+ expect ( mockUveStore . saveStyleEditor ) . toHaveBeenCalled ( ) ;
346+ } ) ) ;
347+
348+ it ( 'should handle consecutive rollback failures correctly' , fakeAsync ( ( ) => {
349+ // Create component with activeContentlet
350+ spectator = createComponent ( {
351+ props : {
352+ [ 'schema' as keyof InferInputSignals < DotUveStyleEditorFormComponent > ] :
353+ createMockSchema ( )
354+ }
355+ } ) ;
356+ spectator . detectChanges ( ) ;
357+
358+ const rolledBackResponse = createMockGraphQLResponse ( 16 ) ;
359+
360+ // Mock saveStyleEditor to always fail and rollback to 16
361+ mockUveStore . saveStyleEditor . mockReturnValue (
362+ throwError ( ( ) => {
363+ mockUveStore . graphqlResponse . set ( rolledBackResponse ) ;
364+ return new Error ( 'Save failed' ) ;
365+ } )
366+ ) ;
367+
368+ // First failure: change from 16 to 20, then fail
369+ let form = spectator . component . $form ( ) ;
370+ form ?. patchValue ( { 'font-size' : 20 } ) ;
371+ tick ( STYLE_EDITOR_DEBOUNCE_TIME + 100 ) ;
372+ spectator . detectChanges ( ) ; // Ensure change detection runs after rollback
373+
374+ // Get the NEW form reference after first rollback (form is rebuilt)
375+ form = spectator . component . $form ( ) ;
376+ expect ( form ?. get ( 'font-size' ) ?. value ) . toBe ( 16 ) ; // Rolled back to 16
377+
378+ // Second failure: Get fresh form reference before patching
379+ // This ensures we're patching the current form instance
380+ form = spectator . component . $form ( ) ;
381+ form ?. patchValue ( { 'font-size' : 24 } ) ;
382+ tick ( STYLE_EDITOR_DEBOUNCE_TIME + 100 ) ;
383+ spectator . detectChanges ( ) ; // Ensure change detection runs after rollback
384+
385+ // Get the NEW form reference after second rollback
386+ form = spectator . component . $form ( ) ;
387+ expect ( form ?. get ( 'font-size' ) ?. value ) . toBe ( 16 ) ; // Should rollback to 16, not 24
388+ } ) ) ;
389+
390+ it ( 'should rebuild form instance on rollback (not patch existing form)' , fakeAsync ( ( ) => {
391+ // Create component with activeContentlet
392+ spectator = createComponent ( {
393+ props : {
394+ [ 'schema' as keyof InferInputSignals < DotUveStyleEditorFormComponent > ] :
395+ createMockSchema ( )
396+ }
397+ } ) ;
398+ spectator . detectChanges ( ) ;
399+
400+ // Get initial form reference
401+ const initialForm = spectator . component . $form ( ) ;
402+ expect ( initialForm ?. get ( 'font-size' ) ?. value ) . toBe ( 16 ) ;
403+
404+ // Mock saveStyleEditor to fail and simulate rollback
405+ const rolledBackResponse = createMockGraphQLResponse ( 16 ) ;
406+ mockUveStore . saveStyleEditor . mockReturnValue (
407+ throwError ( ( ) => {
408+ mockUveStore . graphqlResponse . set ( rolledBackResponse ) ;
409+ return new Error ( 'Save failed' ) ;
410+ } )
411+ ) ;
412+
413+ // Change form value to trigger save and rollback
414+ initialForm ?. patchValue ( { 'font-size' : 20 } ) ;
415+ tick ( STYLE_EDITOR_DEBOUNCE_TIME + 100 ) ;
416+ spectator . detectChanges ( ) ; // Ensure change detection runs after rollback
417+
418+ // Get form reference after rollback
419+ const rebuiltForm = spectator . component . $form ( ) ;
420+
421+ // Verify form was REBUILT (new instance), not patched
422+ expect ( rebuiltForm ) . not . toBe ( initialForm ) ;
423+ expect ( rebuiltForm ?. get ( 'font-size' ) ?. value ) . toBe ( 16 ) ;
424+ } ) ) ;
425+ } ) ;
222426} ) ;
0 commit comments