Skip to content

Commit 6fca654

Browse files
fix(style-editor): Enhance rollback functionality on errors (#34383)
https://github.com/user-attachments/assets/e0afb73a-29ff-497f-9598-fde99196ba6f This PR fixes: #34085
1 parent 57e1706 commit 6fca654

File tree

4 files changed

+281
-51
lines changed

4 files changed

+281
-51
lines changed

core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/dot-uve-style-editor-form.component.spec.ts

Lines changed: 206 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
import { InferInputSignals } from '@ngneat/spectator';
22
import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest';
3+
import { throwError } from 'rxjs';
34

45
import { 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';
68
import { FormGroup } from '@angular/forms';
79

810
import { Accordion, AccordionModule } from 'primeng/accordion';
911
import { MessageService } from 'primeng/api';
1012
import { ButtonModule } from 'primeng/button';
1113

1214
import { DotMessageService, DotWorkflowsActionsService } from '@dotcms/data-access';
15+
import { DotCMSPageAsset } from '@dotcms/types';
1316
import { StyleEditorFormSchema } from '@dotcms/uve';
1417

1518
import { DotUveStyleEditorFormComponent } from './dot-uve-style-editor-form.component';
1619

1720
import { DotPageApiService } from '../../../../../services/dot-page-api.service';
21+
import { STYLE_EDITOR_DEBOUNCE_TIME } from '../../../../../shared/consts';
1822
import { ActionPayload } from '../../../../../shared/models';
1923
import { 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

Comments
 (0)