From d6e2d501720d2dd2b29ae82b7b0cbd8d08ecf12c Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 29 Dec 2025 17:13:44 -0500 Subject: [PATCH 1/9] WIP. Need to commit to change to other branch. --- .../dot-uve-contentlet-tools.component.html | 2 +- .../dot-uve-contentlet-tools.component.ts | 9 +- .../dot-uve-style-editor-form.component.scss | 10 + .../dot-uve-style-editor-form.component.ts | 173 +++++++++++++++++- .../edit-ema-editor.component.html | 2 + .../edit-ema-editor.component.ts | 90 ++++----- .../src/lib/services/dot-page-api.service.ts | 34 +++- .../services/uve-iframe-messenger.service.ts | 108 +++++++++++ .../edit-ema/portlet/src/lib/shared/models.ts | 15 +- .../src/lib/store/features/editor/models.ts | 4 +- .../lib/store/features/editor/withEditor.ts | 8 +- .../libs/sdk/types/src/lib/page/public.ts | 1 + 12 files changed, 399 insertions(+), 57 deletions(-) create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/services/uve-iframe-messenger.service.ts diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.html index 90f0484099e9..d7c11372c1c8 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.html @@ -57,7 +57,7 @@ @if (showStyleEditorOption()) { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts index 6562884f0bb4..9059c11a2782 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts @@ -20,7 +20,7 @@ import { TooltipModule } from 'primeng/tooltip'; import { DotMessageService } from '@dotcms/data-access'; import { DotMessagePipe } from '@dotcms/ui'; -import { ActionPayload, ContentletPayload, VTLFile } from '../../../shared/models'; +import { ActionPayload, VTLFile } from '../../../shared/models'; import { ContentletArea } from '../ema-page-dropzone/types'; /** @@ -88,7 +88,7 @@ export class DotUveContentletToolsComponent { /** * Emitted when the contentlet is selected from the tools (for example, via a drag handle). */ - readonly selectContent = output(); + readonly selectContent = output(); /** * Opt-in flag indicating this drag source should use the custom drag image. * Surfaced as `data-use-custom-drag-image` so the host editor can decide @@ -238,4 +238,9 @@ export class DotUveContentletToolsComponent { this.menu()?.hide(); this.menuVTL()?.hide(); } + + setSelectedContent() { + console.log('context', this.contentletArea()?.payload) + this.selectContent.emit(this.contentletArea()?.payload) + } } diff --git a/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.scss b/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.scss index 81867df392b6..2f66f4fe9e30 100644 --- a/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.scss +++ b/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.scss @@ -52,4 +52,14 @@ font-size: $font-size-md; } } + + // Form actions container + .form-actions { + display: flex; + justify-content: flex-end; + gap: $spacing-2; + padding: $spacing-3; + border-top: 1px solid $color-palette-gray-400; + margin-top: auto; + } } diff --git a/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.ts b/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.ts index 5028c276d5da..8f2a87627cff 100644 --- a/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.ts +++ b/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.ts @@ -1,4 +1,5 @@ -import { Component, input, inject, computed, signal, effect } from '@angular/core'; +import { Component, input, inject, computed, signal, effect, DestroyRef, untracked } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder, FormGroup, @@ -10,6 +11,9 @@ import { import { AccordionModule } from 'primeng/accordion'; import { ButtonModule } from 'primeng/button'; +import { debounceTime, distinctUntilChanged, share } from 'rxjs/operators'; + +import { DotCMSBasicContentlet, DotCMSPageAsset } from '@dotcms/types'; import { StyleEditorFormSchema, StyleEditorSectionSchema, @@ -22,7 +26,11 @@ import { UveStyleEditorFieldDropdownComponent } from './components/uve-style-edi import { UveStyleEditorFieldInputComponent } from './components/uve-style-editor-field-input/uve-style-editor-field-input.component'; import { UveStyleEditorFieldRadioComponent } from './components/uve-style-editor-field-radio/uve-style-editor-field-radio.component'; +import { DotPageApiService } from '../../../../../services/dot-page-api.service'; +import { UveIframeMessengerService } from '../../../../../services/uve-iframe-messenger.service'; import { STYLE_EDITOR_FIELD_TYPES } from '../../../../../shared/consts'; +import { ActionPayload } from '../../../../../shared/models'; +import { UVEStore } from '../../../../../store/dot-uve.store'; @Component({ selector: 'dot-uve-style-editor-form', @@ -43,14 +51,19 @@ export class DotUveStyleEditorFormComponent { readonly #fb = inject(FormBuilder); readonly #form = signal(null); + readonly #uveStore = inject(UVEStore); + readonly #iframeMessenger = inject(UveIframeMessengerService); + readonly #dotPageApiService = inject(DotPageApiService); + readonly #destroyRef = inject(DestroyRef); $sections = computed(() => this.$schema().sections); $form = computed(() => this.#form()); - + $reloadSchemaEffect = effect(() => { - const schema = this.$schema(); + const schema = untracked(() => this.$schema()); if (schema) { this.#buildForm(schema); + this.#listenToFormChanges(); } }); @@ -147,4 +160,158 @@ export class DotUveStyleEditorFormComponent { Object.values(value).every((v) => typeof v === 'boolean') ); } + + /** + * Gets the current form values + * + * @returns The raw form values or null if form is not available + */ + #getFormValues(): Record | null { + return this.#form() ? this.#form().getRawValue() : null; + } + + /** + * Updates the graphqlResponse with form values. + * Similar to the test() method logic but uses the payload to find the correct contentlet. + * + * @param graphqlResponse - The current graphql response + * @param payload - The action payload containing container and contentlet info + * @param formValues - The form values to apply to the contentlet + * @returns The updated graphql response + */ + #updateGraphQLResponse( + graphqlResponse: DotCMSPageAsset | { + grapql?: { + query: string; + variables: Record; + }; + pageAsset: DotCMSPageAsset; + content?: Record; + }, + payload: ActionPayload, + formValues: Record + ): DotCMSPageAsset | { + grapql?: { + query: string; + variables: Record; + }; + pageAsset: DotCMSPageAsset; + content?: Record; + } { + // Handle both types: DotCMSPageAsset directly or wrapped in pageAsset property + const pageAsset = 'pageAsset' in graphqlResponse + ? graphqlResponse.pageAsset + : graphqlResponse; + + const containerId = payload.container.identifier; + const contentletId = payload.contentlet.identifier; + const uuid = payload.container.uuid; + + const container = pageAsset.containers[containerId]; + + if (!container) { + console.error(`Container with id ${containerId} not found`); + return graphqlResponse; + } + + const contentlets = container.contentlets[`uuid-${uuid}`] + + if (!contentlets) { + console.error(`Contentlet with uuid ${uuid} not found`); + return graphqlResponse; + } + + contentlets.forEach((contentlet: DotCMSBasicContentlet) => { + + if (contentlet?.identifier === contentletId) { + contentlet.style_properties = formValues; + } + }); + + return graphqlResponse; + } + + /** + * Listens to form changes and handles: + * 1. Immediate updates to iframe (no debounce) + * 2. Debounced API calls to save style properties + */ + #listenToFormChanges(): void { + const form = this.#form(); + if (!form) { + return; + } + + // Share the valueChanges observable to avoid multiple subscriptions + const formValueChanges$ = form.valueChanges.pipe( + share(), + takeUntilDestroyed(this.#destroyRef) + ); + + // Immediate subscription: Update iframe without debounce + formValueChanges$.pipe( + distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)) + ).subscribe((formValues) => { + this.#updateIframeImmediately(formValues); + }); + + // Debounced subscription: Save to API + formValueChanges$.pipe( + debounceTime(1000), + distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)) + ).subscribe((formValues) => { + this.#saveStylePropertiesToApi(formValues); + }); + } + + /** + * Immediately updates the iframe with new form values (no debounce) + */ + #updateIframeImmediately(formValues: Record): void { + const activeContentlet = this.#uveStore.activeContentlet(); + const graphqlResponse = this.#uveStore.$customGraphqlResponse(); + + if (!activeContentlet || !graphqlResponse) { + return; + } + + try { + const updatedResponse = this.#updateGraphQLResponse( + graphqlResponse, + activeContentlet, + formValues + ); + + // Send updated response to iframe immediately for instant feedback + this.#iframeMessenger.sendPageData(updatedResponse); + } catch (error) { + console.error('Error updating iframe:', error); + } + } + + /** + * Saves style properties to API with debounce + */ + async #saveStylePropertiesToApi(formValues: Record): Promise { + const activeContentlet = this.#uveStore.activeContentlet(); + + if (!activeContentlet) { + return; + } + + const payload = activeContentlet; + + try { + await this.#dotPageApiService.saveStyleProperties({ + containerIdentifier: payload.container.identifier, + contentledIdentifier: payload.contentlet.identifier, + styleProperties: formValues, + pageId: payload.pageId, + containerUUID: payload.container.uuid + }).toPromise(); + } catch (error) { + console.error('Error saving style properties:', error); + // TODO: Add error toast notification + } + } } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html index 8216d60747b2..cb8f0104c578 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html @@ -14,6 +14,8 @@ data-testId="results-seo-tool" /> } + + @if (uveStore.$canEditPage()) { ; + }; + pageAsset: DotCMSPageAsset; + content?: Record; + }; + + //For specific container + const container = (graphqlResponse).pageAsset.containers[HARDCODED_CONTAINER_ID]; + + Object.values(container.contentlets).forEach(contentletArr => { + contentletArr.forEach(contentlet => { + if (contentlet.contentType === 'Activity') { + contentlet.title = 'New Activity with Optimistic updage SPIKE' + } + }); + }); + + this.iframeMessenger.sendPageData(graphqlResponse); } /** @@ -353,12 +383,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit } this.uveStore.setEditorState(EDITOR_STATE.DRAGGING); - this.contentWindow?.postMessage( - { - name: __DOTCMS_UVE_EVENT__.UVE_REQUEST_BOUNDS - }, - this.host - ); + this.iframeMessenger.requestBounds(); if (dragItem) { return; @@ -426,10 +451,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit this.uveStore.updateEditorScrollDragState(); - this.contentWindow?.postMessage( - { name: __DOTCMS_UVE_EVENT__.UVE_SCROLL_INSIDE_IFRAME, direction }, - this.host - ); + this.iframeMessenger.scrollInsideIframe(direction); }); fromEvent(this.window, 'dragleave') @@ -495,6 +517,9 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit * @memberof EditEmaEditorComponent */ onIframePageLoad() { + // Update iframe window reference in case it changed + this.iframeMessenger.setIframeWindow(this.contentWindow); + if (!this.uveStore.isTraditionalPage()) { return; } @@ -818,12 +843,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit } if (clientAction === DotCMSUVEAction.EDIT_CONTENTLET) { - this.contentWindow?.postMessage( - { - name: __DOTCMS_UVE_EVENT__.UVE_RELOAD_PAGE - }, - this.host - ); + this.iframeMessenger.reloadPage(); } const { pageContainers, didInsert, errorCode } = insertContentletInContainer({ @@ -901,12 +921,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit // This is a temporary solution to "reload" the content by reloading the window // we should change this with a new SDK reload strategy - this.contentWindow?.postMessage( - { - name: __DOTCMS_UVE_EVENT__.UVE_RELOAD_PAGE - }, - this.host - ); + this.iframeMessenger.reloadPage(); }, [NG_CUSTOM_EVENTS.ERROR_SAVING_MENU_ORDER]: () => { this.messageService.add({ @@ -1035,12 +1050,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit }; if (!this.uveStore.isTraditionalPage()) { - const message = { - name: __DOTCMS_UVE_EVENT__.UVE_COPY_CONTENTLET_INLINE_EDITING_SUCCESS, - payload: data - }; - - this.contentWindow?.postMessage(message, this.host); + this.iframeMessenger.copyContentletInlineEditingSuccess(data); return; } @@ -1156,13 +1166,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit * @memberof DotEmaComponent */ reloadIframeContent() { - this.iframe?.nativeElement?.contentWindow?.postMessage( - { - name: __DOTCMS_UVE_EVENT__.UVE_SET_PAGE_DATA, - payload: this.#clientPayload() - }, - this.host - ); + this.iframeMessenger.sendPageData(this.#clientPayload()); } private handleDuplicatedContentlet() { @@ -1627,8 +1631,8 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit this.uveStore.resetContentletArea(); } - protected handleSelectContent(contentlet: ContentletPayload): void { - this.uveStore.setActiveContentlet(contentlet); + protected handleSelectContent(contentletActionPayload: ActionPayload): void { + this.uveStore.setActiveContentlet(contentletActionPayload); } /** diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.ts index 7562d9190724..4357526f527f 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.ts @@ -10,7 +10,7 @@ import { DEFAULT_VARIANT_ID, DotPersona, DotPagination } from '@dotcms/dotcms-mo import { DotCMSGraphQLPage, DotCMSPageAsset, UVE_MODE } from '@dotcms/types'; import { PERSONA_KEY } from '../shared/consts'; -import { DotPageAssetParams, SavePagePayload } from '../shared/models'; +import { DotPageAssetParams, SavePagePayload, SaveStylePropertiesPayload } from '../shared/models'; import { getFullPageURL } from '../utils'; export interface DotPageApiParams { @@ -90,6 +90,38 @@ export class DotPageApiService { .pipe(catchError(() => EMPTY)); } + /** + * Save style properties for a specific contentlet within a container on a page. + * + * @param {SaveStylePropertiesPayload} payload - The payload for saving style properties. + * @param {string} payload.containerIdentifier - Identifier of the container. + * @param {string} payload.contentledIdentifier - Identifier of the contentlet. + * @param {Record} payload.styleProperties - Style properties to apply. + * @param {string} payload.pageId - The page ID where styles are being saved. + * @param {string} payload.containerUUID - UUID of the container. + * @returns {Observable} Observable that completes when properties are saved. + * @memberof DotPageApiService + */ + saveStyleProperties({ + containerIdentifier, + contentledIdentifier, + styleProperties, + pageId, + containerUUID + }: SaveStylePropertiesPayload): Observable { + const payload = { + identifier: containerIdentifier, + uuid: containerUUID, + contentletsId: [contentledIdentifier], + styleProperties: { + [contentledIdentifier]: styleProperties + } + }; + + return this.http.post(`/api/v1/page/${pageId}/content`, [payload]) + .pipe(catchError(() => EMPTY)); + } + /** * Get the personas from the Page API * diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/uve-iframe-messenger.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/uve-iframe-messenger.service.ts new file mode 100644 index 000000000000..8946eaaa232b --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/uve-iframe-messenger.service.ts @@ -0,0 +1,108 @@ +import { Injectable } from '@angular/core'; +import { __DOTCMS_UVE_EVENT__ } from '@dotcms/types/internal'; + +export interface IframeMessage { + name: string; + payload?: unknown; + direction?: 'up' | 'down'; +} + +/** + * Service to manage communication with the UVE editor iframe. + * Centralizes all postMessage calls to the iframe window. + */ +@Injectable({ + providedIn: 'root' +}) +export class UveIframeMessengerService { + private iframeWindow: Window | null = null; + private readonly host = '*'; + + /** + * Sets the iframe window reference. + * Call this from the parent component after iframe is loaded. + * + * @param window - The iframe's content window + */ + setIframeWindow(window: Window | null): void { + this.iframeWindow = window; + } + + /** + * Gets the current iframe window reference. + * + * @returns The iframe window or null if not set + */ + getIframeWindow(): Window | null { + return this.iframeWindow; + } + + /** + * Sends a message to the iframe. + * + * @param message - The message to send + */ + sendPostMessage(message: IframeMessage): void { + if (!this.iframeWindow) { + console.warn('Iframe window not set. Cannot send message:', message); + return; + } + + this.iframeWindow.postMessage(message, this.host); + } + + /** + * Convenience method to send page data updates to the iframe. + * + * @param payload - The page data payload + */ + sendPageData(payload: unknown): void { + this.sendPostMessage({ + name: __DOTCMS_UVE_EVENT__.UVE_SET_PAGE_DATA, + payload + }); + } + + /** + * Convenience method to request bounds from the iframe. + */ + requestBounds(): void { + this.sendPostMessage({ + name: __DOTCMS_UVE_EVENT__.UVE_REQUEST_BOUNDS + }); + } + + /** + * Convenience method to reload the page in the iframe. + */ + reloadPage(): void { + this.sendPostMessage({ + name: __DOTCMS_UVE_EVENT__.UVE_RELOAD_PAGE + }); + } + + /** + * Convenience method to send scroll direction to the iframe. + * + * @param direction - The scroll direction ('up' or 'down') + */ + scrollInsideIframe(direction: 'up' | 'down'): void { + this.sendPostMessage({ + name: __DOTCMS_UVE_EVENT__.UVE_SCROLL_INSIDE_IFRAME, + direction + }); + } + + /** + * Convenience method to send copy contentlet inline editing success message. + * + * @param payload - The payload data + */ + copyContentletInlineEditingSuccess(payload: unknown): void { + this.sendPostMessage({ + name: __DOTCMS_UVE_EVENT__.UVE_COPY_CONTENTLET_INLINE_EDITING_SUCCESS, + payload + }); + } +} + diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts index df329635b5e4..453406bb481d 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts @@ -53,6 +53,10 @@ export interface ActionPayload extends PositionPayload { newContentletId?: string; } +export interface StyleEditorContentletPayload extends ActionPayload { + contentlet: ContentletPayload; +} + export interface PageContainer { personaTag?: string; identifier: string; @@ -92,6 +96,15 @@ export interface SavePagePayload { whenSaved?: () => void; } +export interface SaveStylePropertiesPayload { + pageId: string; + containerIdentifier: string; + containerUUID: string; + contentledIdentifier: string; + // TODO: Centralize this later + styleProperties: Record; +} + export interface NavigationBarItem { icon?: string; iconURL?: string; @@ -283,4 +296,4 @@ export interface DotUVEPaletteListParams { pagePathOrId: string; language: string; type: DotUVEPaletteListType; -} +} \ No newline at end of file diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/models.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/models.ts index 02ec1b090886..f55ae3d0f721 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/models.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/models.ts @@ -14,7 +14,7 @@ import { EmaDragItem } from '../../../edit-ema-editor/components/ema-page-dropzone/types'; import { EDITOR_STATE } from '../../../shared/enums'; -import { ContentletPayload } from '../../../shared/models'; +import { ActionPayload } from '../../../shared/models'; import { Orientation } from '../../models'; export interface EditorState { @@ -23,7 +23,7 @@ export interface EditorState { styleSchemas: StyleEditorFormSchema[]; dragItem?: EmaDragItem; ogTags?: SeoMetaTags; - activeContentlet?: ContentletPayload; + activeContentlet?: ActionPayload; contentArea?: ContentletArea; palette: { open: boolean; diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.ts index 59a46e3fbbfe..0b2bf7c0a0f1 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.ts @@ -104,11 +104,11 @@ export function withEditor() { return !!contentletPosition && canEditPage && isIdle; }), - $styleSchema: computed(() => { - const contentlet = store.activeContentlet(); + $styleSchema: computed(() => { + const activeContentlet = store.activeContentlet(); const styleSchemas = store.styleSchemas(); const contentSchema = styleSchemas.find( - (schema) => schema.contentType === contentlet?.contentType + (schema) => schema.contentType === activeContentlet?.contentlet?.contentType ); return contentSchema; }), @@ -307,7 +307,7 @@ export function withEditor() { state: EDITOR_STATE.IDLE }); }, - setActiveContentlet(contentlet: ContentletPayload) { + setActiveContentlet(contentlet: ActionPayload) { patchState(store, { activeContentlet: contentlet, palette: { diff --git a/core-web/libs/sdk/types/src/lib/page/public.ts b/core-web/libs/sdk/types/src/lib/page/public.ts index 74cd6d627b8a..4ea73d2f8dd8 100644 --- a/core-web/libs/sdk/types/src/lib/page/public.ts +++ b/core-web/libs/sdk/types/src/lib/page/public.ts @@ -378,6 +378,7 @@ export interface DotCMSBasicContentlet { widgetTitle?: string; onNumberOfPages?: string; __icon__?: string; + style_properties?: Record; _map?: Record; } From 5912f976f8c8c6495e71b1ea24b54934133acbe0 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 29 Dec 2025 20:33:49 -0500 Subject: [PATCH 2/9] WIP. refactor(edit-ema): Enhance style editor components with time machine functionality and code cleanup - Added time machine capabilities to manage state history for optimistic updates in the style editor. - Cleaned up code formatting and ensured consistent use of semicolons and newlines across various files. These changes improve the maintainability and functionality of the style editor, allowing for better state management and user experience. --- .../dot-uve-contentlet-tools.component.ts | 6 +- .../dot-uve-style-editor-form.component.scss | 2 +- .../dot-uve-style-editor-form.component.ts | 269 ++++++++++---- .../edit-ema-editor.component.html | 15 +- .../edit-ema-editor.component.ts | 29 +- .../src/lib/services/dot-page-api.service.ts | 3 +- .../services/uve-iframe-messenger.service.ts | 1 - .../edit-ema/portlet/src/lib/shared/models.ts | 2 +- .../lib/store/features/client/withClient.ts | 37 ++ .../store/features/editor/save/withSave.ts | 77 +++- .../src/lib/store/features/load/withLoad.ts | 2 + .../features/timeMachine/withTimeMachine.ts | 336 ++++++++++++++++++ 12 files changed, 679 insertions(+), 100 deletions(-) create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/timeMachine/withTimeMachine.ts diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts index 9059c11a2782..96a3b9ae39ce 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts @@ -20,7 +20,7 @@ import { TooltipModule } from 'primeng/tooltip'; import { DotMessageService } from '@dotcms/data-access'; import { DotMessagePipe } from '@dotcms/ui'; -import { ActionPayload, VTLFile } from '../../../shared/models'; +import { ActionPayload, VTLFile } from '../../../shared/models'; import { ContentletArea } from '../ema-page-dropzone/types'; /** @@ -240,7 +240,7 @@ export class DotUveContentletToolsComponent { } setSelectedContent() { - console.log('context', this.contentletArea()?.payload) - this.selectContent.emit(this.contentletArea()?.payload) + console.log('context', this.contentletArea()?.payload); + this.selectContent.emit(this.contentletArea()?.payload); } } diff --git a/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.scss b/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.scss index 2f66f4fe9e30..23dd0c604a2a 100644 --- a/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.scss +++ b/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.scss @@ -62,4 +62,4 @@ border-top: 1px solid $color-palette-gray-400; margin-top: auto; } -} +} \ No newline at end of file diff --git a/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.ts b/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.ts index 8f2a87627cff..1cebab298756 100644 --- a/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.ts +++ b/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.ts @@ -1,4 +1,14 @@ -import { Component, input, inject, computed, signal, effect, DestroyRef, untracked } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + Component, + input, + inject, + computed, + signal, + effect, + DestroyRef, + untracked +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder, @@ -37,6 +47,7 @@ import { UVEStore } from '../../../../../store/dot-uve.store'; templateUrl: './dot-uve-style-editor-form.component.html', styleUrls: ['./dot-uve-style-editor-form.component.scss'], imports: [ + CommonModule, ReactiveFormsModule, AccordionModule, ButtonModule, @@ -58,8 +69,34 @@ export class DotUveStyleEditorFormComponent { $sections = computed(() => this.$schema().sections); $form = computed(() => this.#form()); - + + // TEST ONLY: Time machine state for visual debugging + $timeMachineState = computed(() => ({ + historyLength: this.#uveStore.historyLength(), + currentIndex: this.#uveStore.currentIndex(), + canUndo: this.#uveStore.canUndo(), + canRedo: this.#uveStore.canRedo(), + isAtStart: this.#uveStore.isAtStart(), + isAtEnd: this.#uveStore.isAtEnd(), + haveHistory: this.#uveStore.haveHistory() + })); + + readonly #previousIndex = signal(-1); + + readonly #rollbackDetectionEffect = effect(() => { + const currentIndex = this.#uveStore.currentIndex(); + const previousIndex = this.#previousIndex(); + + // Detect rollback: index decreased (moved backwards in history) + if (previousIndex >= 0 && currentIndex < previousIndex) { + this.#restoreFormFromRollback(); + } + this.#previousIndex.set(currentIndex); + }); + $reloadSchemaEffect = effect(() => { + // Added this untracked ONLY while we dont have the styleProperties in PageAPI response. + // This allow to preserve the current value on the form when the schema is reloaded. const schema = untracked(() => this.$schema()); if (schema) { this.#buildForm(schema); @@ -169,7 +206,7 @@ export class DotUveStyleEditorFormComponent { #getFormValues(): Record | null { return this.#form() ? this.#form().getRawValue() : null; } - + /** * Updates the graphqlResponse with form values. * Similar to the test() method logic but uses the payload to find the correct contentlet. @@ -180,41 +217,44 @@ export class DotUveStyleEditorFormComponent { * @returns The updated graphql response */ #updateGraphQLResponse( - graphqlResponse: DotCMSPageAsset | { - grapql?: { - query: string; - variables: Record; - }; - pageAsset: DotCMSPageAsset; - content?: Record; - }, + graphqlResponse: + | DotCMSPageAsset + | { + grapql?: { + query: string; + variables: Record; + }; + pageAsset: DotCMSPageAsset; + content?: Record; + }, payload: ActionPayload, formValues: Record - ): DotCMSPageAsset | { - grapql?: { - query: string; - variables: Record; - }; - pageAsset: DotCMSPageAsset; - content?: Record; - } { + ): + | DotCMSPageAsset + | { + grapql?: { + query: string; + variables: Record; + }; + pageAsset: DotCMSPageAsset; + content?: Record; + } { // Handle both types: DotCMSPageAsset directly or wrapped in pageAsset property - const pageAsset = 'pageAsset' in graphqlResponse - ? graphqlResponse.pageAsset - : graphqlResponse; - + const pageAsset = + 'pageAsset' in graphqlResponse ? graphqlResponse.pageAsset : graphqlResponse; + const containerId = payload.container.identifier; const contentletId = payload.contentlet.identifier; const uuid = payload.container.uuid; - + const container = pageAsset.containers[containerId]; - + if (!container) { console.error(`Container with id ${containerId} not found`); return graphqlResponse; } - - const contentlets = container.contentlets[`uuid-${uuid}`] + + const contentlets = container.contentlets[`uuid-${uuid}`]; if (!contentlets) { console.error(`Contentlet with uuid ${uuid} not found`); @@ -222,15 +262,92 @@ export class DotUveStyleEditorFormComponent { } contentlets.forEach((contentlet: DotCMSBasicContentlet) => { - if (contentlet?.identifier === contentletId) { contentlet.style_properties = formValues; } }); - + return graphqlResponse; } + /** + * Extracts style properties from graphqlResponse for a specific contentlet. + * Reverse operation of #updateGraphQLResponse - used to restore form values on rollback. + * + * @param graphqlResponse - The graphql response to extract from + * @param payload - The action payload containing container and contentlet info + * @returns The style properties object or null if not found + */ + #extractStylePropertiesFromGraphQLResponse( + graphqlResponse: + | DotCMSPageAsset + | { + grapql?: { + query: string; + variables: Record; + }; + pageAsset: DotCMSPageAsset; + content?: Record; + }, + payload: ActionPayload + ): Record | null { + // Handle both types: DotCMSPageAsset directly or wrapped in pageAsset property + const pageAsset = + 'pageAsset' in graphqlResponse ? graphqlResponse.pageAsset : graphqlResponse; + + const containerId = payload.container.identifier; + const contentletId = payload.contentlet.identifier; + const uuid = payload.container.uuid; + + const container = pageAsset.containers[containerId]; + + if (!container) { + return null; + } + + const contentlets = container.contentlets[`uuid-${uuid}`]; + + if (!contentlets) { + return null; + } + + const contentlet = contentlets.find( + (c: DotCMSBasicContentlet) => c?.identifier === contentletId + ); + + return contentlet?.style_properties || null; + } + + /** + * Restores form values from the rolled-back graphqlResponse state. + * TEST ONLY: Used when rollback occurs to sync form with restored state. + */ + #restoreFormFromRollback(): void { + const form = this.#form(); + const activeContentlet = this.#uveStore.activeContentlet(); + const graphqlResponse = this.#uveStore.$customGraphqlResponse(); + + if (!form || !activeContentlet || !graphqlResponse) { + return; + } + + try { + // Extract style properties from the rolled-back state + const styleProperties = this.#extractStylePropertiesFromGraphQLResponse( + graphqlResponse, + activeContentlet + ); + + if (styleProperties) { + // Update form values without triggering valueChanges + // Use patchValue with emitEvent: false to prevent triggering form changes + form.patchValue(styleProperties, { emitEvent: false }); + } + } catch (error) { + console.error('Error restoring form from rollback:', error); + } + } + /** * Listens to form changes and handles: * 1. Immediate updates to iframe (no debounce) @@ -249,41 +366,63 @@ export class DotUveStyleEditorFormComponent { ); // Immediate subscription: Update iframe without debounce - formValueChanges$.pipe( - distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)) - ).subscribe((formValues) => { - this.#updateIframeImmediately(formValues); - }); + formValueChanges$ + .pipe( + distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)) + ) + .subscribe((formValues) => { + this.#updateIframeImmediately(formValues); + }); // Debounced subscription: Save to API - formValueChanges$.pipe( - debounceTime(1000), - distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)) - ).subscribe((formValues) => { - this.#saveStylePropertiesToApi(formValues); - }); + formValueChanges$ + .pipe( + debounceTime(3000), + distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)) + ) + .subscribe((formValues) => { + this.#saveStylePropertiesToApi(formValues); + }); } /** * Immediately updates the iframe with new form values (no debounce) + * Uses optimistic updates WITHOUT saving to history (history is saved only on API calls) */ #updateIframeImmediately(formValues: Record): void { const activeContentlet = this.#uveStore.activeContentlet(); - const graphqlResponse = this.#uveStore.$customGraphqlResponse(); - - if (!activeContentlet || !graphqlResponse) { + const customGraphqlResponse = this.#uveStore.$customGraphqlResponse(); + + if (!activeContentlet || !customGraphqlResponse) { return; } try { - const updatedResponse = this.#updateGraphQLResponse( - graphqlResponse, + // Get the internal graphqlResponse (always wrapped format) for optimistic update + const internalGraphqlResponse = this.#uveStore.graphqlResponse(); + if (!internalGraphqlResponse) { + return; + } + + // Update the internal response (mutates the pageAsset in place) + // Since $customGraphqlResponse is computed from graphqlResponse(), + // updating the internal response will automatically reflect in the computed + const updatedInternalResponse = this.#updateGraphQLResponse( + internalGraphqlResponse, activeContentlet, formValues - ); - + ) as typeof internalGraphqlResponse; + + // Optimistic update: Update state WITHOUT saving to history + // History is only saved when we actually call the API (in #saveStylePropertiesToApi) + this.#uveStore.setGraphqlResponse(updatedInternalResponse); + // Send updated response to iframe immediately for instant feedback - this.#iframeMessenger.sendPageData(updatedResponse); + // Get the updated custom response (computed will reflect the changes) + const updatedCustomResponse = this.#uveStore.$customGraphqlResponse(); + if (updatedCustomResponse) { + this.#iframeMessenger.sendPageData(updatedCustomResponse); + } } catch (error) { console.error('Error updating iframe:', error); } @@ -291,27 +430,31 @@ export class DotUveStyleEditorFormComponent { /** * Saves style properties to API with debounce + * Saves current state to history before API call, so rollback can restore to this point */ - async #saveStylePropertiesToApi(formValues: Record): Promise { + #saveStylePropertiesToApi(formValues: Record): void { const activeContentlet = this.#uveStore.activeContentlet(); - + if (!activeContentlet) { return; } - const payload = activeContentlet; - - try { - await this.#dotPageApiService.saveStyleProperties({ - containerIdentifier: payload.container.identifier, - contentledIdentifier: payload.contentlet.identifier, - styleProperties: formValues, - pageId: payload.pageId, - containerUUID: payload.container.uuid - }).toPromise(); - } catch (error) { - console.error('Error saving style properties:', error); - // TODO: Add error toast notification + // Save current state to history BEFORE making the API call + // This ensures that if the API call fails, we can rollback to this exact state + const currentGraphqlResponse = this.#uveStore.graphqlResponse(); + if (currentGraphqlResponse) { + this.#uveStore.addHistory(currentGraphqlResponse); } + + const payload = activeContentlet; + + // Use the store's saveStyleEditor method which handles API call and rollback on failure + this.#uveStore.saveStyleEditor({ + containerIdentifier: payload.container.identifier, + contentledIdentifier: payload.contentlet.identifier, + styleProperties: formValues, + pageId: payload.pageId, + containerUUID: payload.container.uuid + }); } } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html index cb8f0104c578..c3a401248e70 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html @@ -14,7 +14,20 @@ data-testId="results-seo-tool" /> } - + @if (uveStore.$canEditPage()) { ; - }; - pageAsset: DotCMSPageAsset; - content?: Record; - }; - - //For specific container - const container = (graphqlResponse).pageAsset.containers[HARDCODED_CONTAINER_ID]; - - Object.values(container.contentlets).forEach(contentletArr => { - contentletArr.forEach(contentlet => { - if (contentlet.contentType === 'Activity') { - contentlet.title = 'New Activity with Optimistic updage SPIKE' - } - }); - }); - - this.iframeMessenger.sendPageData(graphqlResponse); + const history = this.uveStore.getHistory(); + console.log('history', history); } /** diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.ts index 4357526f527f..2e5b5c7e1608 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.ts @@ -118,7 +118,8 @@ export class DotPageApiService { } }; - return this.http.post(`/api/v1/page/${pageId}/content`, [payload]) + return this.http + .post(`/api/v1/page/${pageId}/content`, [payload]) .pipe(catchError(() => EMPTY)); } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/uve-iframe-messenger.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/uve-iframe-messenger.service.ts index 8946eaaa232b..6b867f30039a 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/uve-iframe-messenger.service.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/uve-iframe-messenger.service.ts @@ -105,4 +105,3 @@ export class UveIframeMessengerService { }); } } - diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts index 453406bb481d..166927243470 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts @@ -296,4 +296,4 @@ export interface DotUVEPaletteListParams { pagePathOrId: string; language: string; type: DotUVEPaletteListType; -} \ No newline at end of file +} diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/client/withClient.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/client/withClient.ts index 451e2a4dd33a..d73994fd66c6 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/client/withClient.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/client/withClient.ts @@ -13,6 +13,7 @@ import { DotCMSPageAsset } from '@dotcms/types'; import { PERSONA_KEY } from '../../../shared/consts'; import { UVEState } from '../../models'; +import { withTimeMachine } from '../timeMachine/withTimeMachine'; /** * Client configuration state @@ -54,6 +55,11 @@ export function withClient() { state: type() }, withState(clientState), + // Add time machine to track graphqlResponse history for optimistic updates + withTimeMachine({ + maxHistory: 50, // Reasonable limit for style editor undo + deepClone: true // Important: graphqlResponse has nested objects + }), withMethods((store) => { return { setIsClientReady: (isClientReady: boolean) => { @@ -71,8 +77,39 @@ export function withClient() { setGraphqlResponse: (graphqlResponse) => { patchState(store, { graphqlResponse }); }, + /** + * Sets graphqlResponse optimistically by saving current state to history first. + * Used for optimistic updates that can be rolled back on failure. + * + * @param graphqlResponse - The new graphql response to set + */ + setGraphqlResponseOptimistic: ( + graphqlResponse: ClientConfigState['graphqlResponse'] + ) => { + const currentResponse = store.graphqlResponse(); + // Save snapshot before updating (for optimistic updates rollback) + if (currentResponse) { + store.addHistory(currentResponse); + } + patchState(store, { graphqlResponse }); + }, + /** + * Rolls back to the previous graphqlResponse state. + * Used when an optimistic update fails. + * + * @returns true if rollback was successful, false if no history available + */ + rollbackGraphqlResponse: (): boolean => { + const previousState = store.undo(); + if (previousState !== null) { + patchState(store, { graphqlResponse: previousState }); + return true; + } + return false; + }, resetClientConfiguration: () => { patchState(store, { ...clientState }); + store.clearHistory(); } }; }), diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts index 9b1363a6c075..2779a008a495 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts @@ -1,7 +1,7 @@ import { tapResponse } from '@ngrx/operators'; import { patchState, signalStoreFeature, type, withMethods } from '@ngrx/signals'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; -import { EMPTY, pipe } from 'rxjs'; +import { EMPTY, pipe, throwError, timer } from 'rxjs'; import { inject } from '@angular/core'; @@ -10,8 +10,9 @@ import { catchError, map, switchMap, tap } from 'rxjs/operators'; import { DotCMSPageAsset } from '@dotcms/types'; import { DotPageApiService } from '../../../../services/dot-page-api.service'; +import { UveIframeMessengerService } from '../../../../services/uve-iframe-messenger.service'; import { UVE_STATUS } from '../../../../shared/enums'; -import { PageContainer } from '../../../../shared/models'; +import { PageContainer, SaveStylePropertiesPayload } from '../../../../shared/models'; import { UVEState } from '../../../models'; import { withLoad } from '../../load/withLoad'; @@ -29,6 +30,7 @@ export function withSave() { withLoad(), withMethods((store) => { const dotPageApiService = inject(DotPageApiService); + const iframeMessenger = inject(UveIframeMessengerService); return { savePage: rxMethod( @@ -81,6 +83,77 @@ export function withSave() { status: UVE_STATUS.ERROR }); + return EMPTY; + }) + ); + }) + ) + ), + /** + * Saves style properties optimistically with automatic rollback on failure. + * The optimistic update should be done before calling this method using + * setGraphqlResponseOptimistic. This method handles the API call and + * rolls back the state if the save fails. + * + * @param payload - Style properties save payload + */ + saveStyleEditor: rxMethod( + pipe( + switchMap((payload) => { + // TEST ONLY: Random failure mechanism (30% chance of failure) + const shouldFail = Math.random() < 0.5; + + const apiCall$ = dotPageApiService.saveStyleProperties(payload); + + // TEST ONLY: Inject random failure for testing rollback mechanism + const testApiCall$ = shouldFail + ? timer(500).pipe( + switchMap(() => + throwError( + () => + new Error( + '[TEST] Random failure triggered for testing rollback mechanism' + ) + ) + ) + ) + : apiCall$; + + return testApiCall$.pipe( + tapResponse({ + next: () => { + // Success - optimistic update remains, no rollback needed + // eslint-disable-next-line no-console + console.log('[TEST] Style properties saved successfully'); + }, + error: (error) => { + console.error('Error saving style properties:', error); + + // Rollback the optimistic update + const rolledBack = store.rollbackGraphqlResponse(); + + if (rolledBack) { + // Update iframe with rolled back state + const rolledBackResponse = + store.$customGraphqlResponse(); + if (rolledBackResponse) { + iframeMessenger.sendPageData(rolledBackResponse); + } + console.warn( + '[TEST] Rolled back optimistic style update due to save failure' + ); + } else { + console.error( + 'Failed to rollback optimistic update - no history available' + ); + } + + // TODO: Add error toast notification + } + }), + catchError((error) => { + // Additional error handling if needed + console.error('Error in saveStyleEditor:', error); return EMPTY; }) ); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/load/withLoad.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/load/withLoad.ts index abdb73311487..579ea6c3d759 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/load/withLoad.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/load/withLoad.ts @@ -150,6 +150,8 @@ export function withLoad() { tap(({ experiment, languages }) => { const isTraditionalPage = !pageParams.clientHost; + store.addHistory({ pageAsset }); + patchState(store, { pageAPIResponse: pageAsset, isEnterprise, diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/timeMachine/withTimeMachine.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/timeMachine/withTimeMachine.ts new file mode 100644 index 000000000000..0b2bbab87f6f --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/timeMachine/withTimeMachine.ts @@ -0,0 +1,336 @@ +import { + patchState, + signalStoreFeature, + withComputed, + withMethods, + withState +} from '@ngrx/signals'; + +import { computed, Signal } from '@angular/core'; + +/** + * Deep clone utility - uses structuredClone if available, falls back to JSON + * + * WHY DEEP CLONING IS NEEDED: + * Without deep cloning, history entries would store references to the same objects. + * When you modify the current state, those mutations would also affect past history entries, + * corrupting the time machine's ability to restore previous states accurately. + * + * Example problem without deep cloning: + * ```typescript + * const state = { name: 'John', items: ['apple'] }; + * store.addHistory(state); // Stores reference + * state.items.push('banana'); // Mutates the object + * store.undo(); // Returns corrupted state with ['apple', 'banana'] ❌ + * ``` + * + * With deep cloning: + * ```typescript + * const state = { name: 'John', items: ['apple'] }; + * store.addHistory(state); // Stores deep copy + * state.items.push('banana'); // Only affects original + * store.undo(); // Returns original ['apple'] ✅ + * ``` + * + * You can disable deep cloning (deepClone: false) if: + * - States are guaranteed to be immutable (e.g., using Immer, Immutable.js) + * - Performance is critical and you can guarantee no mutations occur + */ +type StructuredCloneFunc = (v: T) => T; +const safeClone: StructuredCloneFunc = + typeof structuredClone !== 'undefined' + ? structuredClone + : (v: T) => JSON.parse(JSON.stringify(v)); + +/** + * Internal state added by withOtherTimeMachine + */ +interface OtherTimeMachineState { + history: T[]; + pointer: number; +} + +/** + * Methods added by withOtherTimeMachine + */ +export interface TimeMachineMethods { + /** + * Add a new state snapshot to history + * If pointer is not at the end, discards future states (rebase) + */ + addHistory(state: T): void; + + /** + * Navigate to a specific index in history + * @param index - The index to navigate to (0-based) + * @returns The state at that index, or null if invalid + */ + goTo(index: number): T | null; + + /** + * Move back one step in history + * @returns The previous state, or null if at the beginning or only one history item + */ + undo(): T | null; + + /** + * Move forward one step in history + * @returns The next state, or null if at the end + */ + redo(): T | null; + + /** + * Get state at a specific index without navigating + * @param index - The index to get state from + * @returns The state at that index, or null if invalid + */ + getStateAt(index: number): T | null; + + /** + * Clear all history and reset pointer + */ + clearHistory(): void; + + /** + * Get the full history array (read-only) + */ + getHistory(): readonly T[]; +} + +/** + * Computed properties added by withOtherTimeMachine + */ +export interface OtherTimeMachineComputed { + /** + * Whether there is any history + */ + haveHistory: Signal; + + /** + * Whether undo is possible (requires at least 2 history items) + */ + canUndo: Signal; + + /** + * Whether redo is possible + */ + canRedo: Signal; + + /** + * Current state at the pointer position + */ + current: Signal; + + /** + * Current pointer index + */ + currentIndex: Signal; + + /** + * Total number of states in history + */ + historyLength: Signal; + + /** + * Whether pointer is at the beginning (index 0) + */ + isAtStart: Signal; + + /** + * Whether pointer is at the end (last index) + */ + isAtEnd: Signal; +} + +/** + * Options for configuring the time machine + */ +export interface TimeMachineOptions { + /** + * Maximum number of history entries to keep (default: 100) + * When exceeded, oldest entries are removed + */ + maxHistory?: number; + + /** + * Whether to deep clone states when adding to history (default: true) + * + * Deep cloning prevents mutation issues: if you store a reference to a state object + * and later modify it, those changes would affect all history entries. Deep cloning + * ensures each history entry is an independent snapshot. + * + * Set to false ONLY if: + * - States are guaranteed immutable (e.g., using Immer, Immutable.js) + * - Performance is critical and you can guarantee no mutations occur + * - States contain only primitives (no nested objects/arrays) + * + * Default: true (recommended for safety) + */ + deepClone?: boolean; +} + +/** + * Time machine feature for Signal Store + * Adds history tracking and navigation capabilities to any store + * + * @example + * ```typescript + * export const myStore = signalStore( + * withState({ count: 0 }), + * withOtherTimeMachine<{ count: number }>({ maxHistory: 50 }), + * withMethods(store => ({ + * increment() { + * patchState(store, { count: store.count() + 1 }); + * // Manually register state + * store.addHistory({ count: store.count() }); + * } + * })) + * ); + * ``` + */ +export function withTimeMachine(options?: TimeMachineOptions) { + const maxHistory = options?.maxHistory ?? 100; + const shouldDeepClone = options?.deepClone !== false; // default true + + return signalStoreFeature( + withState>({ + history: [], + pointer: -1 + }), + withComputed((store) => { + const historyLength = computed(() => store.history().length); + const currentIndex = computed(() => store.pointer()); + + return { + haveHistory: computed(() => historyLength() > 0), + // Can undo only if there are at least 2 history items and pointer > 0 + canUndo: computed(() => { + const length = historyLength(); + const index = currentIndex(); + return length >= 2 && index > 0; + }), + // Can redo only if pointer is not at the last index + canRedo: computed(() => { + const length = historyLength(); + const index = currentIndex(); + return index < length - 1; + }), + current: computed(() => { + const idx = currentIndex(); + const hist = store.history(); + return idx >= 0 && idx < hist.length ? hist[idx] : undefined; + }), + currentIndex, + historyLength, + isAtStart: computed(() => currentIndex() === 0), + isAtEnd: computed(() => currentIndex() === historyLength() - 1) + }; + }), + withMethods((store) => { + /** + * Trim history to maxHistory length, keeping the most recent entries + */ + const trimHistory = (history: T[]): T[] => { + if (history.length <= maxHistory) return history; + return history.slice(history.length - maxHistory); + }; + + /** + * Validate and clamp index to valid range + */ + const clampIndex = (index: number, length: number): number => { + return Math.max(0, Math.min(index, length - 1)); + }; + + return { + addHistory: (state: T): void => { + const currentHistory = store.history(); + const currentPointer = store.pointer(); + + // Deep clone the state to prevent mutation issues: + // Without cloning, modifying the current state would also mutate + // all previous history entries, corrupting the time machine. + // This ensures each history entry is an independent snapshot. + const stateToAdd = shouldDeepClone ? safeClone(state) : state; + + // If pointer is not at the end, discard future states (rebase) + const baseHistory = currentHistory.slice(0, currentPointer + 1); + baseHistory.push(stateToAdd); + + // Trim history if needed + const trimmedHistory = trimHistory(baseHistory); + const newPointer = trimmedHistory.length - 1; + + patchState(store, { + history: trimmedHistory, + pointer: newPointer + }); + }, + + goTo: (index: number): T | null => { + const history = store.history(); + const validIndex = clampIndex(index, history.length); + + if (validIndex < 0 || validIndex >= history.length) { + return null; + } + + patchState(store, { pointer: validIndex }); + return history[validIndex] ?? null; + }, + + undo: (): T | null => { + const history = store.history(); + const currentPointer = store.pointer(); + + // Validation: Cannot undo if only one history item or at the beginning + if (history.length < 2 || currentPointer <= 0) { + return null; + } + + const newPointer = currentPointer - 1; + patchState(store, { pointer: newPointer }); + + return history[newPointer] ?? null; + }, + + redo: (): T | null => { + const history = store.history(); + const currentPointer = store.pointer(); + + // Validation: Cannot redo if at the last state + if (currentPointer >= history.length - 1) { + return null; + } + + const newPointer = currentPointer + 1; + patchState(store, { pointer: newPointer }); + + return history[newPointer] ?? null; + }, + + getStateAt: (index: number): T | null => { + const history = store.history(); + const validIndex = clampIndex(index, history.length); + + if (validIndex < 0 || validIndex >= history.length) { + return null; + } + + return history[validIndex] ?? null; + }, + + clearHistory: (): void => { + patchState(store, { + history: [], + pointer: -1 + }); + }, + + getHistory: (): readonly T[] => { + return store.history(); + } + }; + }) + ); +} From 5a53160684e163370205fa55848bd38612ab6c90 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 30 Dec 2025 01:45:43 -0500 Subject: [PATCH 3/9] refactor(style-editor): Simplify form handling and improve state management - Refactored the style editor form component to utilize a dedicated form builder service, enhancing form creation and management. - Removed unused imports and cleaned up code for better readability. - Updated methods for extracting and updating style properties to streamline functionality and improve performance. - Ensured consistent handling of form values during rollback operations. These changes enhance the maintainability and efficiency of the style editor, providing a more robust user experience. --- .../dot-uve-style-editor-form.component.scss | 2 +- .../dot-uve-style-editor-form.component.ts | 300 +++--------------- .../style-editor-form-builder.service.ts | 152 +++++++++ .../utils/style-editor-graphql.utils.ts | 104 ++++++ 4 files changed, 297 insertions(+), 261 deletions(-) create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/services/style-editor-form-builder.service.ts create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/utils/style-editor-graphql.utils.ts diff --git a/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.scss b/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.scss index 23dd0c604a2a..2f66f4fe9e30 100644 --- a/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.scss +++ b/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.scss @@ -62,4 +62,4 @@ border-top: 1px solid $color-palette-gray-400; margin-top: auto; } -} \ No newline at end of file +} diff --git a/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.ts b/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.ts index 1cebab298756..4e7425746fa4 100644 --- a/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.ts +++ b/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.ts @@ -10,36 +10,27 @@ import { untracked } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { - FormBuilder, - FormGroup, - FormControl, - ReactiveFormsModule, - AbstractControl -} from '@angular/forms'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { AccordionModule } from 'primeng/accordion'; import { ButtonModule } from 'primeng/button'; import { debounceTime, distinctUntilChanged, share } from 'rxjs/operators'; -import { DotCMSBasicContentlet, DotCMSPageAsset } from '@dotcms/types'; -import { - StyleEditorFormSchema, - StyleEditorSectionSchema, - StyleEditorFieldSchema, - StyleEditorCheckboxDefaultValue -} from '@dotcms/uve'; +import { StyleEditorFormSchema } from '@dotcms/uve'; import { UveStyleEditorFieldCheckboxGroupComponent } from './components/uve-style-editor-field-checkbox-group/uve-style-editor-field-checkbox-group.component'; import { UveStyleEditorFieldDropdownComponent } from './components/uve-style-editor-field-dropdown/uve-style-editor-field-dropdown.component'; import { UveStyleEditorFieldInputComponent } from './components/uve-style-editor-field-input/uve-style-editor-field-input.component'; import { UveStyleEditorFieldRadioComponent } from './components/uve-style-editor-field-radio/uve-style-editor-field-radio.component'; +import { StyleEditorFormBuilderService } from './services/style-editor-form-builder.service'; +import { + extractStylePropertiesFromGraphQL, + updateStylePropertiesInGraphQL +} from './utils/style-editor-graphql.utils'; -import { DotPageApiService } from '../../../../../services/dot-page-api.service'; import { UveIframeMessengerService } from '../../../../../services/uve-iframe-messenger.service'; import { STYLE_EDITOR_FIELD_TYPES } from '../../../../../shared/consts'; -import { ActionPayload } from '../../../../../shared/models'; import { UVEStore } from '../../../../../store/dot-uve.store'; @Component({ @@ -60,38 +51,27 @@ import { UVEStore } from '../../../../../store/dot-uve.store'; export class DotUveStyleEditorFormComponent { $schema = input.required({ alias: 'schema' }); - readonly #fb = inject(FormBuilder); + readonly #formBuilder = inject(StyleEditorFormBuilderService); readonly #form = signal(null); readonly #uveStore = inject(UVEStore); readonly #iframeMessenger = inject(UveIframeMessengerService); - readonly #dotPageApiService = inject(DotPageApiService); readonly #destroyRef = inject(DestroyRef); $sections = computed(() => this.$schema().sections); $form = computed(() => this.#form()); - // TEST ONLY: Time machine state for visual debugging - $timeMachineState = computed(() => ({ - historyLength: this.#uveStore.historyLength(), - currentIndex: this.#uveStore.currentIndex(), - canUndo: this.#uveStore.canUndo(), - canRedo: this.#uveStore.canRedo(), - isAtStart: this.#uveStore.isAtStart(), - isAtEnd: this.#uveStore.isAtEnd(), - haveHistory: this.#uveStore.haveHistory() - })); - - readonly #previousIndex = signal(-1); + readonly STYLE_EDITOR_FIELD_TYPES = STYLE_EDITOR_FIELD_TYPES; + readonly $previousIndex = signal(-1); readonly #rollbackDetectionEffect = effect(() => { const currentIndex = this.#uveStore.currentIndex(); - const previousIndex = this.#previousIndex(); + const previousIndex = this.$previousIndex(); // Detect rollback: index decreased (moved backwards in history) if (previousIndex >= 0 && currentIndex < previousIndex) { this.#restoreFormFromRollback(); } - this.#previousIndex.set(currentIndex); + this.$previousIndex.set(currentIndex); }); $reloadSchemaEffect = effect(() => { @@ -104,237 +84,38 @@ export class DotUveStyleEditorFormComponent { } }); - readonly STYLE_EDITOR_FIELD_TYPES = STYLE_EDITOR_FIELD_TYPES; - - #buildForm(schema: StyleEditorFormSchema): void { - const formControls: Record = {}; - - schema.sections.forEach((section: StyleEditorSectionSchema) => { - section.fields.forEach((field: StyleEditorFieldSchema) => { - const fieldKey = field.id; - const config = field.config; - - switch (field.type) { - case STYLE_EDITOR_FIELD_TYPES.DROPDOWN: - formControls[fieldKey] = this.#fb.control( - this.#getDropdownDefaultValue(config) - ); - break; - - case STYLE_EDITOR_FIELD_TYPES.CHECKBOX_GROUP: { - const options = config?.options || []; - const checkboxDefaults = this.#getCheckboxGroupDefaultValue(config); - const checkboxGroupControls: Record = {}; - - options.forEach((option) => { - checkboxGroupControls[option.value] = new FormControl( - checkboxDefaults[option.value] || false - ); - }); - - formControls[fieldKey] = this.#fb.group(checkboxGroupControls); - break; - } - - case STYLE_EDITOR_FIELD_TYPES.RADIO: - formControls[fieldKey] = this.#fb.control( - this.#getRadioDefaultValue(config) - ); - break; - - case STYLE_EDITOR_FIELD_TYPES.INPUT: - formControls[fieldKey] = this.#fb.control( - this.#getInputDefaultValue(config) - ); - break; - - default: - formControls[fieldKey] = this.#fb.control(''); - break; - } - }); - }); - - this.#form.set(this.#fb.group(formControls)); - } - - #getDropdownDefaultValue(config: StyleEditorFieldSchema['config']): string { - if (typeof config?.defaultValue === 'string') { - return config.defaultValue.trim(); - } - - return null; - } - - #getCheckboxGroupDefaultValue( - config: StyleEditorFieldSchema['config'] - ): StyleEditorCheckboxDefaultValue { - if (this.#isCheckboxDefaultValue(config?.defaultValue)) { - return config.defaultValue; - } - return {}; - } - - #getRadioDefaultValue(config: StyleEditorFieldSchema['config']): string { - if (typeof config?.defaultValue === 'string') { - return config.defaultValue; - } - return config?.options?.[0]?.value || ''; - } - - #getInputDefaultValue(config: StyleEditorFieldSchema['config']): string | number { - if (typeof config?.defaultValue === 'string' || typeof config?.defaultValue === 'number') { - return config.defaultValue; - } - return ''; - } - - #isCheckboxDefaultValue(value: unknown): value is StyleEditorCheckboxDefaultValue { - return ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - Object.values(value).every((v) => typeof v === 'boolean') - ); - } - - /** - * Gets the current form values - * - * @returns The raw form values or null if form is not available - */ - #getFormValues(): Record | null { - return this.#form() ? this.#form().getRawValue() : null; - } - - /** - * Updates the graphqlResponse with form values. - * Similar to the test() method logic but uses the payload to find the correct contentlet. - * - * @param graphqlResponse - The current graphql response - * @param payload - The action payload containing container and contentlet info - * @param formValues - The form values to apply to the contentlet - * @returns The updated graphql response - */ - #updateGraphQLResponse( - graphqlResponse: - | DotCMSPageAsset - | { - grapql?: { - query: string; - variables: Record; - }; - pageAsset: DotCMSPageAsset; - content?: Record; - }, - payload: ActionPayload, - formValues: Record - ): - | DotCMSPageAsset - | { - grapql?: { - query: string; - variables: Record; - }; - pageAsset: DotCMSPageAsset; - content?: Record; - } { - // Handle both types: DotCMSPageAsset directly or wrapped in pageAsset property - const pageAsset = - 'pageAsset' in graphqlResponse ? graphqlResponse.pageAsset : graphqlResponse; - - const containerId = payload.container.identifier; - const contentletId = payload.contentlet.identifier; - const uuid = payload.container.uuid; - - const container = pageAsset.containers[containerId]; - - if (!container) { - console.error(`Container with id ${containerId} not found`); - return graphqlResponse; - } - - const contentlets = container.contentlets[`uuid-${uuid}`]; - - if (!contentlets) { - console.error(`Contentlet with uuid ${uuid} not found`); - return graphqlResponse; - } - - contentlets.forEach((contentlet: DotCMSBasicContentlet) => { - if (contentlet?.identifier === contentletId) { - contentlet.style_properties = formValues; - } - }); - - return graphqlResponse; - } - /** - * Extracts style properties from graphqlResponse for a specific contentlet. - * Reverse operation of #updateGraphQLResponse - used to restore form values on rollback. - * - * @param graphqlResponse - The graphql response to extract from - * @param payload - The action payload containing container and contentlet info - * @returns The style properties object or null if not found + * Builds a form from the schema using the form builder service */ - #extractStylePropertiesFromGraphQLResponse( - graphqlResponse: - | DotCMSPageAsset - | { - grapql?: { - query: string; - variables: Record; - }; - pageAsset: DotCMSPageAsset; - content?: Record; - }, - payload: ActionPayload - ): Record | null { - // Handle both types: DotCMSPageAsset directly or wrapped in pageAsset property - const pageAsset = - 'pageAsset' in graphqlResponse ? graphqlResponse.pageAsset : graphqlResponse; - - const containerId = payload.container.identifier; - const contentletId = payload.contentlet.identifier; - const uuid = payload.container.uuid; - - const container = pageAsset.containers[containerId]; - - if (!container) { - return null; - } - - const contentlets = container.contentlets[`uuid-${uuid}`]; - - if (!contentlets) { - return null; - } - - const contentlet = contentlets.find( - (c: DotCMSBasicContentlet) => c?.identifier === contentletId - ); - - return contentlet?.style_properties || null; + #buildForm(schema: StyleEditorFormSchema): void { + const form = this.#formBuilder.buildForm(schema); + this.#form.set(form); } /** * Restores form values from the rolled-back graphqlResponse state. - * TEST ONLY: Used when rollback occurs to sync form with restored state. + * Used when rollback occurs to sync form with restored state. */ #restoreFormFromRollback(): void { const form = this.#form(); const activeContentlet = this.#uveStore.activeContentlet(); - const graphqlResponse = this.#uveStore.$customGraphqlResponse(); - if (!form || !activeContentlet || !graphqlResponse) { + if (!form || !activeContentlet) { return; } try { - // Extract style properties from the rolled-back state - const styleProperties = this.#extractStylePropertiesFromGraphQLResponse( - graphqlResponse, + // Use the internal graphqlResponse signal directly (it's already been rolled back) + // This ensures we get the rolled-back state, not the computed wrapper + const rolledBackGraphqlResponse = this.#uveStore.graphqlResponse(); + + if (!rolledBackGraphqlResponse) { + return; + } + + // Extract style properties from the rolled-back state using utility function + const styleProperties = extractStylePropertiesFromGraphQL( + rolledBackGraphqlResponse, activeContentlet ); @@ -381,7 +162,7 @@ export class DotUveStyleEditorFormComponent { distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)) ) .subscribe((formValues) => { - this.#saveStylePropertiesToApi(formValues); + this.#saveStyleProperties(formValues); }); } @@ -407,11 +188,11 @@ export class DotUveStyleEditorFormComponent { // Update the internal response (mutates the pageAsset in place) // Since $customGraphqlResponse is computed from graphqlResponse(), // updating the internal response will automatically reflect in the computed - const updatedInternalResponse = this.#updateGraphQLResponse( + const updatedInternalResponse = updateStylePropertiesInGraphQL( internalGraphqlResponse, activeContentlet, formValues - ) as typeof internalGraphqlResponse; + ); // Optimistic update: Update state WITHOUT saving to history // History is only saved when we actually call the API (in #saveStylePropertiesToApi) @@ -420,9 +201,10 @@ export class DotUveStyleEditorFormComponent { // Send updated response to iframe immediately for instant feedback // Get the updated custom response (computed will reflect the changes) const updatedCustomResponse = this.#uveStore.$customGraphqlResponse(); - if (updatedCustomResponse) { - this.#iframeMessenger.sendPageData(updatedCustomResponse); + if (!updatedCustomResponse) { + return; } + this.#iframeMessenger.sendPageData(updatedCustomResponse); } catch (error) { console.error('Error updating iframe:', error); } @@ -432,7 +214,7 @@ export class DotUveStyleEditorFormComponent { * Saves style properties to API with debounce * Saves current state to history before API call, so rollback can restore to this point */ - #saveStylePropertiesToApi(formValues: Record): void { + #saveStyleProperties(formValues: Record): void { const activeContentlet = this.#uveStore.activeContentlet(); if (!activeContentlet) { @@ -446,15 +228,13 @@ export class DotUveStyleEditorFormComponent { this.#uveStore.addHistory(currentGraphqlResponse); } - const payload = activeContentlet; - // Use the store's saveStyleEditor method which handles API call and rollback on failure this.#uveStore.saveStyleEditor({ - containerIdentifier: payload.container.identifier, - contentledIdentifier: payload.contentlet.identifier, + containerIdentifier: activeContentlet.container.identifier, + contentledIdentifier: activeContentlet.contentlet.identifier, styleProperties: formValues, - pageId: payload.pageId, - containerUUID: payload.container.uuid + pageId: activeContentlet.pageId, + containerUUID: activeContentlet.container.uuid }); } } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/services/style-editor-form-builder.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/services/style-editor-form-builder.service.ts new file mode 100644 index 000000000000..b095160fddf7 --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/services/style-editor-form-builder.service.ts @@ -0,0 +1,152 @@ +import { STYLE_EDITOR_FIELD_TYPES } from 'libs/portlets/edit-ema/portlet/src/lib/shared/consts'; + +import { Injectable, inject } from '@angular/core'; +import { FormBuilder, FormGroup, FormControl, AbstractControl } from '@angular/forms'; + +import { + StyleEditorFormSchema, + StyleEditorSectionSchema, + StyleEditorFieldSchema, + StyleEditorCheckboxDefaultValue +} from '@dotcms/uve'; + +/** + * Service responsible for building reactive forms from style editor schemas. + * Handles form control creation and default value extraction for different field types. + */ +@Injectable({ + providedIn: 'root' +}) +export class StyleEditorFormBuilderService { + readonly #fb = inject(FormBuilder); + + /** + * Builds a FormGroup from a StyleEditorFormSchema + * + * @param schema - The style editor form schema + * @returns A FormGroup with controls for all fields in the schema + */ + buildForm(schema: StyleEditorFormSchema): FormGroup { + const formControls: Record = {}; + + schema.sections.forEach((section: StyleEditorSectionSchema) => { + section.fields.forEach((field: StyleEditorFieldSchema) => { + const fieldKey = field.id; + const defaultValue = this.getDefaultValue(field); + + switch (field.type) { + case STYLE_EDITOR_FIELD_TYPES.DROPDOWN: + formControls[fieldKey] = this.#fb.control(defaultValue); + break; + + case STYLE_EDITOR_FIELD_TYPES.CHECKBOX_GROUP: { + const options = field.config?.options || []; + const checkboxDefaults = this.getCheckboxGroupDefaultValue(field.config); + const checkboxGroupControls: Record = {}; + + options.forEach((option) => { + checkboxGroupControls[option.value] = new FormControl( + checkboxDefaults[option.value] || false + ); + }); + + formControls[fieldKey] = this.#fb.group(checkboxGroupControls); + break; + } + + case STYLE_EDITOR_FIELD_TYPES.RADIO: + formControls[fieldKey] = this.#fb.control(defaultValue); + break; + + case STYLE_EDITOR_FIELD_TYPES.INPUT: + formControls[fieldKey] = this.#fb.control(defaultValue); + break; + + default: + formControls[fieldKey] = this.#fb.control(''); + break; + } + }); + }); + + return this.#fb.group(formControls); + } + + /** + * Gets the default value for a field based on its type and configuration + */ + private getDefaultValue(field: StyleEditorFieldSchema): unknown { + const config = field.config; + + switch (field.type) { + case STYLE_EDITOR_FIELD_TYPES.DROPDOWN: + return this.getDropdownDefaultValue(config); + + case STYLE_EDITOR_FIELD_TYPES.CHECKBOX_GROUP: + return this.getCheckboxGroupDefaultValue(config); + + case STYLE_EDITOR_FIELD_TYPES.RADIO: + return this.getRadioDefaultValue(config); + + case STYLE_EDITOR_FIELD_TYPES.INPUT: + return this.getInputDefaultValue(config); + + default: + return ''; + } + } + + /** + * Gets the default value for a dropdown field + */ + private getDropdownDefaultValue(config: StyleEditorFieldSchema['config']): string | null { + if (typeof config?.defaultValue === 'string') { + return config.defaultValue.trim(); + } + return null; + } + + /** + * Gets the default value for a checkbox group field + */ + private getCheckboxGroupDefaultValue( + config: StyleEditorFieldSchema['config'] + ): StyleEditorCheckboxDefaultValue { + if (this.isCheckboxDefaultValue(config?.defaultValue)) { + return config.defaultValue; + } + return {}; + } + + /** + * Gets the default value for a radio field + */ + private getRadioDefaultValue(config: StyleEditorFieldSchema['config']): string { + if (typeof config?.defaultValue === 'string') { + return config.defaultValue; + } + return config?.options?.[0]?.value || ''; + } + + /** + * Gets the default value for an input field + */ + private getInputDefaultValue(config: StyleEditorFieldSchema['config']): string | number { + if (typeof config?.defaultValue === 'string' || typeof config?.defaultValue === 'number') { + return config.defaultValue; + } + return ''; + } + + /** + * Type guard to check if a value is a valid checkbox default value + */ + private isCheckboxDefaultValue(value: unknown): value is StyleEditorCheckboxDefaultValue { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.values(value).every((v) => typeof v === 'boolean') + ); + } +} diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/utils/style-editor-graphql.utils.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/utils/style-editor-graphql.utils.ts new file mode 100644 index 000000000000..c2218009470f --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/utils/style-editor-graphql.utils.ts @@ -0,0 +1,104 @@ +import { ActionPayload } from 'libs/portlets/edit-ema/portlet/src/lib/shared/models'; + +import { DotCMSBasicContentlet, DotCMSPageAsset } from '@dotcms/types'; + +/** + * Type representing a GraphQL response that can be either: + * - Direct DotCMSPageAsset + * - Wrapped response with pageAsset property + */ +export type GraphQLResponse = + | DotCMSPageAsset + | { + grapql?: { + query: string; + variables: Record; + }; + pageAsset: DotCMSPageAsset; + content?: Record; + }; + +/** + * Extracts the pageAsset from a GraphQL response, handling both wrapped and unwrapped formats + */ +function extractPageAsset(response: GraphQLResponse): DotCMSPageAsset { + return 'pageAsset' in response ? response.pageAsset : response; +} + +/** + * Updates style properties in a GraphQL response for a specific contentlet. + * Mutates the response in place and returns it. + * + * @param graphqlResponse - The graphql response to update + * @param payload - The action payload containing container and contentlet info + * @param styleProperties - The style properties to apply + * @returns The updated graphql response (same reference, mutated) + */ +export function updateStylePropertiesInGraphQL( + graphqlResponse: GraphQLResponse, + payload: ActionPayload, + styleProperties: Record +): GraphQLResponse { + const pageAsset = extractPageAsset(graphqlResponse); + const containerId = payload.container.identifier; + const contentletId = payload.contentlet.identifier; + const uuid = payload.container.uuid; + + const container = pageAsset.containers[containerId]; + + if (!container) { + console.error(`Container with id ${containerId} not found`); + return graphqlResponse; + } + + const contentlets = container.contentlets[`uuid-${uuid}`]; + + if (!contentlets) { + console.error(`Contentlet with uuid ${uuid} not found`); + return graphqlResponse; + } + + contentlets.forEach((contentlet: DotCMSBasicContentlet) => { + if (contentlet?.identifier === contentletId) { + contentlet.style_properties = styleProperties; + } + }); + + return graphqlResponse; +} + +/** + * Extracts style properties from a GraphQL response for a specific contentlet. + * Reverse operation of updateStylePropertiesInGraphQL. + * + * @param graphqlResponse - The graphql response to extract from + * @param payload - The action payload containing container and contentlet info + * @returns The style properties object or null if not found + */ +export function extractStylePropertiesFromGraphQL( + graphqlResponse: GraphQLResponse, + payload: ActionPayload +): Record | null { + const pageAsset = extractPageAsset(graphqlResponse); + const containerId = payload.container.identifier; + const contentletId = payload.contentlet.identifier; + const uuid = payload.container.uuid; + + const container = pageAsset.containers[containerId]; + + if (!container) { + return null; + } + + const contentlets = container.contentlets[`uuid-${uuid}`]; + + if (!contentlets) { + return null; + } + + const contentlet = contentlets.find( + (c: DotCMSBasicContentlet) => c?.identifier === contentletId + ); + + return contentlet?.style_properties || null; +} From a80c33cdc5890fea08df839d5b673c72fdb77154 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 30 Dec 2025 13:22:51 -0500 Subject: [PATCH 4/9] refactor(edit-ema): Remove unused test button and update iframe messenger import - Removed the test button from the edit-ema editor component to streamline the UI. - Updated the import path for the UveIframeMessengerService to reflect its new location. - Cleaned up console log statements in the contentlet tools component for better code clarity. - Adjusted style properties handling in the GraphQL utility functions for consistency. These changes enhance the maintainability of the code and improve the overall user experience in the edit-ema editor. --- ...dot-uve-contentlet-tools.component.spec.ts | 2 +- .../dot-uve-contentlet-tools.component.ts | 1 - ...style-editor-field-input.component.spec.ts | 1 + ...ot-uve-style-editor-form.component.spec.ts | 25 +- .../dot-uve-style-editor-form.component.ts | 11 +- .../utils/style-editor-graphql.utils.ts | 4 +- .../edit-ema-editor.component.html | 15 - .../edit-ema-editor.component.ts | 7 +- .../lib/services/dot-page-api.service.spec.ts | 36 +++ .../uve-iframe-messenger.service.spec.ts | 158 +++++++++ .../uve-iframe-messenger.service.ts | 1 + .../edit-ema/portlet/src/lib/shared/models.ts | 5 +- .../store/features/editor/save/withSave.ts | 48 +-- .../store/features/editor/withEditor.spec.ts | 147 +++++++-- .../timeMachine/withTimeMachine.spec.ts | 300 ++++++++++++++++++ .../libs/sdk/types/src/lib/page/public.ts | 2 +- 16 files changed, 662 insertions(+), 101 deletions(-) create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/services/iframe-messenger/uve-iframe-messenger.service.spec.ts rename core-web/libs/portlets/edit-ema/portlet/src/lib/services/{ => iframe-messenger}/uve-iframe-messenger.service.ts (99%) create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/timeMachine/withTimeMachine.spec.ts diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.spec.ts index f531d7ca0d73..ed6191ddb403 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.spec.ts @@ -238,7 +238,7 @@ describe('DotUveContentletToolsComponent', () => { spectator.click(paletteButton); expect(hostComponent.onSelectContent).toHaveBeenCalledWith( - MOCK_CONTENTLET_AREA.payload.contentlet + MOCK_CONTENTLET_AREA.payload ); }); }); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts index 96a3b9ae39ce..35634182eb11 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts @@ -240,7 +240,6 @@ export class DotUveContentletToolsComponent { } setSelectedContent() { - console.log('context', this.contentletArea()?.payload); this.selectContent.emit(this.contentletArea()?.payload); } } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/components/uve-style-editor-field-input/uve-style-editor-field-input.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/components/uve-style-editor-field-input/uve-style-editor-field-input.component.spec.ts index 25b547f7834d..1f8cccfada1f 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/components/uve-style-editor-field-input/uve-style-editor-field-input.component.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/components/uve-style-editor-field-input/uve-style-editor-field-input.component.spec.ts @@ -1,3 +1,4 @@ +import '@testing-library/jest-dom'; import { SpectatorHost, createHostFactory } from '@ngneat/spectator'; import { Component } from '@angular/core'; diff --git a/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 b/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 index ebb4aa1fd9d1..f2777a2c2c09 100644 --- a/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 +++ b/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 @@ -1,15 +1,21 @@ import { InferInputSignals } from '@ngneat/spectator'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { createComponentFactory, Spectator, mockProvider } from '@ngneat/spectator/jest'; +import { HttpClient } from '@angular/common/http'; +import { signal } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { Accordion, AccordionModule } from 'primeng/accordion'; import { ButtonModule } from 'primeng/button'; +import { DotWorkflowsActionsService } from '@dotcms/data-access'; import { StyleEditorFormSchema } from '@dotcms/uve'; import { DotUveStyleEditorFormComponent } from './dot-uve-style-editor-form.component'; +import { DotPageApiService } from '../../../../../services/dot-page-api.service'; +import { UVEStore } from '../../../../../store/dot-uve.store'; + const createMockSchema = (): StyleEditorFormSchema => ({ contentType: 'test-content-type', sections: [ @@ -79,7 +85,19 @@ describe('DotUveStyleEditorFormComponent', () => { const createComponent = createComponentFactory({ component: DotUveStyleEditorFormComponent, - imports: [AccordionModule, ButtonModule] + imports: [AccordionModule, ButtonModule], + providers: [ + // mockProvider(UVEStore), + mockProvider(DotWorkflowsActionsService), + mockProvider(DotPageApiService), + mockProvider(HttpClient), + { + provide: UVEStore, + useValue: { + currentIndex: signal(0) + } + } + ] }); beforeEach(() => { @@ -210,7 +228,8 @@ describe('DotUveStyleEditorFormComponent', () => { }); }); - describe('schema changes', () => { + // TODO: Remove skip when we have the styleProperties in PageAPI response and remove the untracked in $reloadSchemaEffect. + xdescribe('schema changes', () => { it('should rebuild form when schema changes', () => { const initialForm = spectator.component.$form(); diff --git a/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.ts b/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.ts index 4e7425746fa4..42d34ad55343 100644 --- a/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.ts +++ b/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.ts @@ -29,7 +29,7 @@ import { updateStylePropertiesInGraphQL } from './utils/style-editor-graphql.utils'; -import { UveIframeMessengerService } from '../../../../../services/uve-iframe-messenger.service'; +import { UveIframeMessengerService } from '../../../../../services/iframe-messenger/uve-iframe-messenger.service'; import { STYLE_EDITOR_FIELD_TYPES } from '../../../../../shared/consts'; import { UVEStore } from '../../../../../store/dot-uve.store'; @@ -67,16 +67,19 @@ export class DotUveStyleEditorFormComponent { const currentIndex = this.#uveStore.currentIndex(); const previousIndex = this.$previousIndex(); - // Detect rollback: index decreased (moved backwards in history) + // Detect rollback: index decreased AND we can undo (meaning undo() was called) + // This ensures we only restore on actual rollbacks, not on addHistory() operations if (previousIndex >= 0 && currentIndex < previousIndex) { - this.#restoreFormFromRollback(); + untracked(() => { + this.#restoreFormFromRollback(); + }); } this.$previousIndex.set(currentIndex); }); $reloadSchemaEffect = effect(() => { - // Added this untracked ONLY while we dont have the styleProperties in PageAPI response. // This allow to preserve the current value on the form when the schema is reloaded. + // TODO: Remove untracked when we have the styleProperties in PageAPI response, also ensure that the form is rebuilt correctly. const schema = untracked(() => this.$schema()); if (schema) { this.#buildForm(schema); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/utils/style-editor-graphql.utils.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/utils/style-editor-graphql.utils.ts index c2218009470f..376923db6e39 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/utils/style-editor-graphql.utils.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/utils/style-editor-graphql.utils.ts @@ -60,7 +60,7 @@ export function updateStylePropertiesInGraphQL( contentlets.forEach((contentlet: DotCMSBasicContentlet) => { if (contentlet?.identifier === contentletId) { - contentlet.style_properties = styleProperties; + contentlet.styleProperties = styleProperties; } }); @@ -100,5 +100,5 @@ export function extractStylePropertiesFromGraphQL( (c: DotCMSBasicContentlet) => c?.identifier === contentletId ); - return contentlet?.style_properties || null; + return contentlet?.styleProperties || null; } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html index c3a401248e70..8216d60747b2 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html @@ -14,21 +14,6 @@ data-testId="results-seo-tool" /> } - - @if (uveStore.$canEditPage()) { { expect(request.body).toEqual({ contentlet }); }); }); + + describe('saveStyleProperties', () => { + it('should send a POST request with correct payload structure', () => { + const payload = { + pageId: 'test-page-123', + containerIdentifier: 'container-id-456', + containerUUID: 'container-uuid-789', + contentledIdentifier: 'contentlet-id-abc', + styleProperties: { + 'font-size': '16px', + color: '#000000' + } + }; + + spectator.service.saveStyleProperties(payload).subscribe(); + + const { request } = spectator.expectOne( + '/api/v1/page/test-page-123/content', + HttpMethod.POST + ); + + expect(request.body).toEqual([ + { + identifier: 'container-id-456', + uuid: 'container-uuid-789', + contentletsId: ['contentlet-id-abc'], + styleProperties: { + 'contentlet-id-abc': { + 'font-size': '16px', + color: '#000000' + } + } + } + ]); + }); + }); }); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/iframe-messenger/uve-iframe-messenger.service.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/iframe-messenger/uve-iframe-messenger.service.spec.ts new file mode 100644 index 000000000000..8befabe4d0f7 --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/iframe-messenger/uve-iframe-messenger.service.spec.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; +import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; + +import { __DOTCMS_UVE_EVENT__ } from '@dotcms/types/internal'; + +import { UveIframeMessengerService, IframeMessage } from './uve-iframe-messenger.service'; + +describe('UveIframeMessengerService', () => { + let spectator: SpectatorService; + let service: UveIframeMessengerService; + let mockIframeWindow: Window; + + const createService = createServiceFactory({ + service: UveIframeMessengerService + }); + + beforeEach(() => { + spectator = createService(); + service = spectator.service; + + // Create a mock iframe window with postMessage spy + mockIframeWindow = { + postMessage: jest.fn() + } as unknown as Window; + + // Mock console.warn to avoid noise in tests + jest.spyOn(console, 'warn').mockImplementation(jest.fn()); + }); + + describe('setIframeWindow and getIframeWindow', () => { + it('should set and get iframe window', () => { + expect(service.getIframeWindow()).toBeNull(); + + service.setIframeWindow(mockIframeWindow); + expect(service.getIframeWindow()).toBe(mockIframeWindow); + + service.setIframeWindow(null); + expect(service.getIframeWindow()).toBeNull(); + }); + }); + + describe('sendPostMessage', () => { + it('should send postMessage to iframe when window is set', () => { + service.setIframeWindow(mockIframeWindow); + const message: IframeMessage = { + name: 'TEST_EVENT', + payload: { test: 'data' } + }; + + service.sendPostMessage(message); + + expect(mockIframeWindow.postMessage).toHaveBeenCalledWith(message, '*'); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('should warn when iframe window is not set', () => { + const message: IframeMessage = { + name: 'TEST_EVENT', + payload: { test: 'data' } + }; + + service.sendPostMessage(message); + + expect(console.warn).toHaveBeenCalledWith( + 'Iframe window not set. Cannot send message:', + message + ); + }); + }); + + describe('sendPageData', () => { + it('should send page data message to iframe', () => { + service.setIframeWindow(mockIframeWindow); + const payload = { pageId: '123', data: 'test' }; + + service.sendPageData(payload); + + expect(mockIframeWindow.postMessage).toHaveBeenCalledWith( + { + name: __DOTCMS_UVE_EVENT__.UVE_SET_PAGE_DATA, + payload + }, + '*' + ); + }); + }); + + describe('requestBounds', () => { + it('should send request bounds message to iframe', () => { + service.setIframeWindow(mockIframeWindow); + + service.requestBounds(); + + expect(mockIframeWindow.postMessage).toHaveBeenCalledWith( + { + name: __DOTCMS_UVE_EVENT__.UVE_REQUEST_BOUNDS + }, + '*' + ); + }); + }); + + describe('reloadPage', () => { + it('should send reload page message to iframe', () => { + service.setIframeWindow(mockIframeWindow); + + service.reloadPage(); + + expect(mockIframeWindow.postMessage).toHaveBeenCalledWith( + { + name: __DOTCMS_UVE_EVENT__.UVE_RELOAD_PAGE + }, + '*' + ); + }); + }); + + describe('scrollInsideIframe', () => { + it('should send scroll direction message to iframe', () => { + service.setIframeWindow(mockIframeWindow); + + service.scrollInsideIframe('up'); + expect(mockIframeWindow.postMessage).toHaveBeenCalledWith( + { + name: __DOTCMS_UVE_EVENT__.UVE_SCROLL_INSIDE_IFRAME, + direction: 'up' + }, + '*' + ); + + service.scrollInsideIframe('down'); + expect(mockIframeWindow.postMessage).toHaveBeenCalledWith( + { + name: __DOTCMS_UVE_EVENT__.UVE_SCROLL_INSIDE_IFRAME, + direction: 'down' + }, + '*' + ); + }); + }); + + describe('copyContentletInlineEditingSuccess', () => { + it('should send copy contentlet success message to iframe', () => { + service.setIframeWindow(mockIframeWindow); + const payload = { contentletId: '123' }; + + service.copyContentletInlineEditingSuccess(payload); + + expect(mockIframeWindow.postMessage).toHaveBeenCalledWith( + { + name: __DOTCMS_UVE_EVENT__.UVE_COPY_CONTENTLET_INLINE_EDITING_SUCCESS, + payload + }, + '*' + ); + }); + }); +}); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/uve-iframe-messenger.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/iframe-messenger/uve-iframe-messenger.service.ts similarity index 99% rename from core-web/libs/portlets/edit-ema/portlet/src/lib/services/uve-iframe-messenger.service.ts rename to core-web/libs/portlets/edit-ema/portlet/src/lib/services/iframe-messenger/uve-iframe-messenger.service.ts index 6b867f30039a..2dcfb6dfb90f 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/uve-iframe-messenger.service.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/iframe-messenger/uve-iframe-messenger.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@angular/core'; + import { __DOTCMS_UVE_EVENT__ } from '@dotcms/types/internal'; export interface IframeMessage { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts index 166927243470..046ca99cc0c8 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts @@ -6,6 +6,8 @@ import { CommonErrors, DialogStatus, FormStatus } from './enums'; import { DotPageApiParams } from '../services/dot-page-api.service'; +export type StyleEditorProperties = Record; + export interface MessagePipeOptions { message: string; args: string[]; @@ -101,8 +103,7 @@ export interface SaveStylePropertiesPayload { containerIdentifier: string; containerUUID: string; contentledIdentifier: string; - // TODO: Centralize this later - styleProperties: Record; + styleProperties: StyleEditorProperties; } export interface NavigationBarItem { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts index 2779a008a495..a439d1d3713d 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts @@ -10,7 +10,7 @@ import { catchError, map, switchMap, tap } from 'rxjs/operators'; import { DotCMSPageAsset } from '@dotcms/types'; import { DotPageApiService } from '../../../../services/dot-page-api.service'; -import { UveIframeMessengerService } from '../../../../services/uve-iframe-messenger.service'; +import { UveIframeMessengerService } from '../../../../services/iframe-messenger/uve-iframe-messenger.service'; import { UVE_STATUS } from '../../../../shared/enums'; import { PageContainer, SaveStylePropertiesPayload } from '../../../../shared/models'; import { UVEState } from '../../../models'; @@ -100,31 +100,10 @@ export function withSave() { saveStyleEditor: rxMethod( pipe( switchMap((payload) => { - // TEST ONLY: Random failure mechanism (30% chance of failure) - const shouldFail = Math.random() < 0.5; - - const apiCall$ = dotPageApiService.saveStyleProperties(payload); - - // TEST ONLY: Inject random failure for testing rollback mechanism - const testApiCall$ = shouldFail - ? timer(500).pipe( - switchMap(() => - throwError( - () => - new Error( - '[TEST] Random failure triggered for testing rollback mechanism' - ) - ) - ) - ) - : apiCall$; - - return testApiCall$.pipe( + return dotPageApiService.saveStyleProperties(payload).pipe( tapResponse({ next: () => { // Success - optimistic update remains, no rollback needed - // eslint-disable-next-line no-console - console.log('[TEST] Style properties saved successfully'); }, error: (error) => { console.error('Error saving style properties:', error); @@ -132,23 +111,22 @@ export function withSave() { // Rollback the optimistic update const rolledBack = store.rollbackGraphqlResponse(); - if (rolledBack) { - // Update iframe with rolled back state - const rolledBackResponse = - store.$customGraphqlResponse(); - if (rolledBackResponse) { - iframeMessenger.sendPageData(rolledBackResponse); - } - console.warn( - '[TEST] Rolled back optimistic style update due to save failure' - ); - } else { + if (!rolledBack) { console.error( 'Failed to rollback optimistic update - no history available' ); + + return; } - // TODO: Add error toast notification + // Update iframe with rolled back state + const rolledBackResponse = store.$customGraphqlResponse(); + if (rolledBackResponse) { + iframeMessenger.sendPageData(rolledBackResponse); + } + console.warn( + '[TEST] Rolled back optimistic style update due to save failure' + ); } }), catchError((error) => { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts index 998bfa945be5..a9f4b4cea904 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts @@ -29,6 +29,7 @@ import { } from '../../../shared/mocks'; import { getPersonalization, mapContainerStructureToArrayOfContainers } from '../../../utils'; import { UVEState } from '../../models'; +import { ActionPayload } from '../../../shared/models'; const emptyParams = {} as DotPageApiParams; @@ -256,10 +257,22 @@ describe('withEditor', () => { it('should return undefined when styleSchemas is empty', () => { patchState(store, { activeContentlet: { - identifier: 'test-id', - inode: 'test-inode', - title: 'Test', - contentType: 'testContentType' + language_id: '1', + pageContainers: [], + pageId: '123', + container: { + identifier: 'test-container-id', + uuid: 'test-container-uuid', + acceptTypes: 'test', + maxContentlets: 1, + variantId: '1' + }, + contentlet: { + identifier: 'test-contentlet-id', + inode: 'test-inode', + title: 'Test Contentlet', + contentType: 'testType' + } }, styleSchemas: [] }); @@ -275,10 +288,22 @@ describe('withEditor', () => { patchState(store, { activeContentlet: { - identifier: 'test-id', - inode: 'test-inode', - title: 'Test', - contentType: 'testContentType' + language_id: '1', + pageContainers: [], + pageId: '123', + container: { + identifier: 'test-container-id', + uuid: 'test-container-uuid', + acceptTypes: 'test', + maxContentlets: 1, + variantId: '1' + }, + contentlet: { + identifier: 'test-contentlet-id', + inode: 'test-inode', + title: 'Test Contentlet', + contentType: 'testContentType' + } }, styleSchemas: [mockSchema] }); @@ -293,10 +318,22 @@ describe('withEditor', () => { patchState(store, { activeContentlet: { - identifier: 'test-id', - inode: 'test-inode', - title: 'Test', - contentType: 'type2' + language_id: '1', + pageContainers: [], + pageId: '123', + container: { + identifier: 'test-container-id', + uuid: 'test-container-uuid', + acceptTypes: 'test', + maxContentlets: 1, + variantId: '1' + }, + contentlet: { + identifier: 'test-contentlet-id', + inode: 'test-inode', + title: 'Test Contentlet', + contentType: 'type2' + } }, styleSchemas: [schema1, schema2, schema3] }); @@ -312,10 +349,22 @@ describe('withEditor', () => { patchState(store, { activeContentlet: { - identifier: 'test-id', - inode: 'test-inode', - title: 'Test', - contentType: 'testContentType' + language_id: '1', + pageContainers: [], + pageId: '123', + container: { + identifier: 'test-container-id', + uuid: 'test-container-uuid', + acceptTypes: 'test', + maxContentlets: 1, + variantId: '1' + }, + contentlet: { + identifier: 'test-contentlet-id', + inode: 'test-inode', + title: 'Test Contentlet', + contentType: 'testType' + } }, styleSchemas: [mockSchema] }); @@ -725,11 +774,23 @@ describe('withEditor', () => { describe('setActiveContentlet', () => { it('should set the active contentlet', () => { - const mockContentlet = { - identifier: 'test-contentlet-id', - inode: 'test-inode', - title: 'Test Contentlet', - contentType: 'testType' + const mockContentlet: ActionPayload = { + language_id: '1', + pageContainers: [], + pageId: '123', + container: { + identifier: 'test-container-id', + uuid: 'test-container-uuid', + acceptTypes: 'test', + maxContentlets: 1, + variantId: '1' + }, + contentlet: { + identifier: 'test-contentlet-id', + inode: 'test-inode', + title: 'Test Contentlet', + contentType: 'testType' + } }; store.setActiveContentlet(mockContentlet); @@ -738,11 +799,23 @@ describe('withEditor', () => { }); it('should open palette and set current tab to STYLE_EDITOR', () => { - const mockContentlet = { - identifier: 'test-contentlet-id', - inode: 'test-inode', - title: 'Test Contentlet', - contentType: 'testType' + const mockContentlet: ActionPayload = { + language_id: '1', + pageContainers: [], + pageId: '123', + container: { + identifier: 'test-container-id', + uuid: 'test-container-uuid', + acceptTypes: 'test', + maxContentlets: 1, + variantId: '1' + }, + contentlet: { + identifier: 'test-contentlet-id', + inode: 'test-inode', + title: 'Test Contentlet', + contentType: 'testType' + } }; store.setActiveContentlet(mockContentlet); @@ -754,11 +827,23 @@ describe('withEditor', () => { }); it('should switch to STYLE_EDITOR tab even if palette was on different tab', () => { - const mockContentlet = { - identifier: 'test-contentlet-id', - inode: 'test-inode', - title: 'Test Contentlet', - contentType: 'testType' + const mockContentlet: ActionPayload = { + language_id: '1', + pageContainers: [], + pageId: '123', + container: { + identifier: 'test-container-id', + uuid: 'test-container-uuid', + acceptTypes: 'test', + maxContentlets: 1, + variantId: '1' + }, + contentlet: { + identifier: 'test-contentlet-id', + inode: 'test-inode', + title: 'Test Contentlet', + contentType: 'testType' + } }; // Set palette to a different tab first diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/timeMachine/withTimeMachine.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/timeMachine/withTimeMachine.spec.ts new file mode 100644 index 000000000000..a183ad8caf95 --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/timeMachine/withTimeMachine.spec.ts @@ -0,0 +1,300 @@ +import { describe, expect, it } from '@jest/globals'; +import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; +import { signalStore, withState } from '@ngrx/signals'; + +import { withTimeMachine } from './withTimeMachine'; + +interface TestState { + count: number; + items: string[]; +} + +const initialState: TestState = { + count: 0, + items: [] +}; + +export const testStoreMock = signalStore( + { protectedState: false }, + withState(initialState), + withTimeMachine() +); + +const storeNoClone = signalStore( + { protectedState: false }, + withState(initialState), + withTimeMachine({ deepClone: false }) +); + +describe('withTimeMachine', () => { + let spectator: SpectatorService>; + let store: InstanceType; + + const createService = createServiceFactory({ + service: testStoreMock + }); + + const createServiceNoClone = createServiceFactory({ + service: storeNoClone + }); + + beforeEach(() => { + spectator = createService(); + store = spectator.service; + }); + + describe('Initial state', () => { + it('should have empty history and pointer at -1', () => { + expect(store.historyLength()).toBe(0); + expect(store.currentIndex()).toBe(-1); + expect(store.haveHistory()).toBe(false); + expect(store.canUndo()).toBe(false); + expect(store.canRedo()).toBe(false); + expect(store.current()).toBeUndefined(); + }); + }); + + describe('addHistory', () => { + it('should add state to history', () => { + const state1: TestState = { count: 1, items: ['apple'] }; + store.addHistory(state1); + + expect(store.historyLength()).toBe(1); + expect(store.currentIndex()).toBe(0); + expect(store.haveHistory()).toBe(true); + expect(store.current()).toEqual(state1); + }); + + it('should update pointer when adding new history', () => { + const state1: TestState = { count: 1, items: ['apple'] }; + const state2: TestState = { count: 2, items: ['banana'] }; + + store.addHistory(state1); + expect(store.currentIndex()).toBe(0); + + store.addHistory(state2); + expect(store.currentIndex()).toBe(1); + expect(store.current()).toEqual(state2); + }); + + it('should discard future states when adding history in the middle', () => { + const state1: TestState = { count: 1, items: ['apple'] }; + const state2: TestState = { count: 2, items: ['banana'] }; + const state3: TestState = { count: 3, items: ['cherry'] }; + + store.addHistory(state1); + store.addHistory(state2); + store.undo(); // Go back to state1 (index 0) + + store.addHistory(state3); // Should discard state2 + + expect(store.historyLength()).toBe(2); + expect(store.currentIndex()).toBe(1); + expect(store.current()).toEqual(state3); + expect(store.getStateAt(0)).toEqual(state1); + expect(store.getStateAt(1)).toEqual(state3); + }); + + it('should deep clone state to prevent mutations', () => { + const state: TestState = { count: 1, items: ['apple'] }; + store.addHistory(state); + + // Mutate the original state + state.items.push('banana'); + state.count = 999; + + // History should not be affected + expect(store.current()?.items).toEqual(['apple']); + expect(store.current()?.count).toBe(1); + }); + }); + + describe('undo', () => { + it('should return null if no history', () => { + expect(store.undo()).toBeNull(); + }); + + it('should return null if only one history item', () => { + store.addHistory({ count: 1, items: ['apple'] }); + expect(store.undo()).toBeNull(); + }); + + it('should move pointer back and return previous state', () => { + const state1: TestState = { count: 1, items: ['apple'] }; + const state2: TestState = { count: 2, items: ['banana'] }; + + store.addHistory(state1); + store.addHistory(state2); + + expect(store.currentIndex()).toBe(1); + const previousState = store.undo(); + + expect(previousState).toEqual(state1); + expect(store.currentIndex()).toBe(0); + expect(store.current()).toEqual(state1); + }); + + it('should return null when at the beginning', () => { + const state1: TestState = { count: 1, items: ['apple'] }; + const state2: TestState = { count: 2, items: ['banana'] }; + + store.addHistory(state1); + store.addHistory(state2); + store.undo(); // Now at index 0 + + expect(store.undo()).toBeNull(); + expect(store.currentIndex()).toBe(0); + }); + }); + + describe('redo', () => { + it('should return null if at the end', () => { + const state1: TestState = { count: 1, items: ['apple'] }; + store.addHistory(state1); + expect(store.redo()).toBeNull(); + }); + + it('should move pointer forward and return next state', () => { + const state1: TestState = { count: 1, items: ['apple'] }; + const state2: TestState = { count: 2, items: ['banana'] }; + + store.addHistory(state1); + store.addHistory(state2); + store.undo(); // Go back to state1 + + const nextState = store.redo(); + + expect(nextState).toEqual(state2); + expect(store.currentIndex()).toBe(1); + expect(store.current()).toEqual(state2); + }); + }); + + describe('goTo', () => { + it('should navigate to specific index', () => { + store.addHistory({ count: 1, items: ['apple'] }); + store.addHistory({ count: 2, items: ['banana'] }); + store.addHistory({ count: 3, items: ['cherry'] }); + + const state = store.goTo(0); + expect(store.currentIndex()).toBe(0); + expect(state?.count).toBe(1); + + const state2 = store.goTo(2); + expect(store.currentIndex()).toBe(2); + expect(state2?.count).toBe(3); + }); + + it('should clamp index to valid range', () => { + store.addHistory({ count: 1, items: ['apple'] }); + store.addHistory({ count: 2, items: ['banana'] }); + + store.goTo(-1); + expect(store.currentIndex()).toBe(0); + + store.goTo(100); + expect(store.currentIndex()).toBe(1); + }); + + it('should return null for invalid index', () => { + expect(store.goTo(0)).toBeNull(); + }); + }); + + describe('getStateAt', () => { + it('should return state at specific index without navigating', () => { + const state1: TestState = { count: 1, items: ['apple'] }; + const state2: TestState = { count: 2, items: ['banana'] }; + + store.addHistory(state1); + store.addHistory(state2); + + expect(store.currentIndex()).toBe(1); + expect(store.getStateAt(0)).toEqual(state1); + expect(store.getStateAt(1)).toEqual(state2); + expect(store.currentIndex()).toBe(1); // Should not change + }); + + it('should return null for invalid index', () => { + expect(store.getStateAt(0)).toBeNull(); + expect(store.getStateAt(-1)).toBeNull(); + }); + }); + + describe('clearHistory', () => { + it('should clear all history and reset pointer', () => { + store.addHistory({ count: 1, items: ['apple'] }); + store.addHistory({ count: 2, items: ['banana'] }); + + store.clearHistory(); + + expect(store.historyLength()).toBe(0); + expect(store.currentIndex()).toBe(-1); + expect(store.haveHistory()).toBe(false); + }); + }); + + describe('Computed properties', () => { + it('should correctly compute canUndo', () => { + expect(store.canUndo()).toBe(false); + + store.addHistory({ count: 1, items: ['apple'] }); + expect(store.canUndo()).toBe(false); // Need at least 2 items + + store.addHistory({ count: 2, items: ['banana'] }); + expect(store.canUndo()).toBe(true); + + store.undo(); + expect(store.canUndo()).toBe(false); // At start + }); + + it('should correctly compute canRedo', () => { + expect(store.canRedo()).toBe(false); + + store.addHistory({ count: 1, items: ['apple'] }); + store.addHistory({ count: 2, items: ['banana'] }); + expect(store.canRedo()).toBe(false); // At end + + store.undo(); + expect(store.canRedo()).toBe(true); + }); + + it('should correctly compute isAtStart and isAtEnd', () => { + store.addHistory({ count: 1, items: ['apple'] }); + store.addHistory({ count: 2, items: ['banana'] }); + + expect(store.isAtStart()).toBe(false); + expect(store.isAtEnd()).toBe(true); + + store.undo(); + expect(store.isAtStart()).toBe(true); + expect(store.isAtEnd()).toBe(false); + }); + }); + + describe('Deep cloning', () => { + it('should deep clone by default', () => { + const state: TestState = { count: 1, items: ['apple'] }; + store.addHistory(state); + + state.items.push('banana'); + state.count = 999; + + expect(store.current()?.items).toEqual(['apple']); + expect(store.current()?.count).toBe(1); + }); + + it('should allow disabling deep cloning', () => { + const spectatorNoClone = createServiceNoClone(); + const storeNoCloneInstance = spectatorNoClone.service; + + const state: TestState = { count: 1, items: ['apple'] }; + storeNoCloneInstance.addHistory(state); + + state.items.push('banana'); + + // Without deep cloning, mutations affect history + expect(storeNoCloneInstance.current()?.items).toEqual(['apple', 'banana']); + }); + }); +}); diff --git a/core-web/libs/sdk/types/src/lib/page/public.ts b/core-web/libs/sdk/types/src/lib/page/public.ts index 4ea73d2f8dd8..45cf4ad07def 100644 --- a/core-web/libs/sdk/types/src/lib/page/public.ts +++ b/core-web/libs/sdk/types/src/lib/page/public.ts @@ -378,7 +378,7 @@ export interface DotCMSBasicContentlet { widgetTitle?: string; onNumberOfPages?: string; __icon__?: string; - style_properties?: Record; + styleProperties?: Record; _map?: Record; } From 3e04b4603ab80f56adb209aa5ba1a8c2ce6d5ac1 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 30 Dec 2025 13:32:40 -0500 Subject: [PATCH 5/9] refactor(edit-ema): Clean up imports and enhance code organization - Removed unused imports from the edit-ema editor component and style editor form builder service for improved clarity. - Updated import paths for consistency across components, ensuring better maintainability. - Streamlined the GraphQL utility functions by adjusting import statements, enhancing code readability. These changes contribute to a cleaner codebase and facilitate easier future modifications. --- .../services/style-editor-form-builder.service.ts | 4 ++-- .../utils/style-editor-graphql.utils.ts | 4 ++-- .../src/lib/edit-ema-editor/edit-ema-editor.component.ts | 3 +-- .../portlet/src/lib/store/features/editor/save/withSave.ts | 2 +- .../portlet/src/lib/store/features/editor/withEditor.spec.ts | 2 +- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/services/style-editor-form-builder.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/services/style-editor-form-builder.service.ts index b095160fddf7..0b9ae26827ac 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/services/style-editor-form-builder.service.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/services/style-editor-form-builder.service.ts @@ -1,5 +1,3 @@ -import { STYLE_EDITOR_FIELD_TYPES } from 'libs/portlets/edit-ema/portlet/src/lib/shared/consts'; - import { Injectable, inject } from '@angular/core'; import { FormBuilder, FormGroup, FormControl, AbstractControl } from '@angular/forms'; @@ -10,6 +8,8 @@ import { StyleEditorCheckboxDefaultValue } from '@dotcms/uve'; +import { STYLE_EDITOR_FIELD_TYPES } from '../../../../../../shared/consts'; + /** * Service responsible for building reactive forms from style editor schemas. * Handles form control creation and default value extraction for different field types. diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/utils/style-editor-graphql.utils.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/utils/style-editor-graphql.utils.ts index 376923db6e39..2cad72501ab3 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/utils/style-editor-graphql.utils.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/utils/style-editor-graphql.utils.ts @@ -1,7 +1,7 @@ -import { ActionPayload } from 'libs/portlets/edit-ema/portlet/src/lib/shared/models'; - import { DotCMSBasicContentlet, DotCMSPageAsset } from '@dotcms/types'; +import { ActionPayload } from '../../../../../../shared/models'; + /** * Type representing a GraphQL response that can be either: * - Direct DotCMSPageAsset diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts index 10a67ac08074..2d44801351b9 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts @@ -52,7 +52,6 @@ import { DotCMSInlineEditingPayload, DotCMSInlineEditingType, DotCMSPage, - DotCMSPageAsset, DotCMSURLContentMap, DotCMSUVEAction } from '@dotcms/types'; @@ -77,8 +76,8 @@ import { import { DotBlockEditorSidebarComponent } from '../components/dot-block-editor-sidebar/dot-block-editor-sidebar.component'; import { DotEmaDialogComponent } from '../components/dot-ema-dialog/dot-ema-dialog.component'; import { DotPageApiService } from '../services/dot-page-api.service'; -import { InlineEditService } from '../services/inline-edit/inline-edit.service'; import { UveIframeMessengerService } from '../services/iframe-messenger/uve-iframe-messenger.service'; +import { InlineEditService } from '../services/inline-edit/inline-edit.service'; import { DEFAULT_PERSONA, IFRAME_SCROLL_ZONE, PERSONA_KEY } from '../shared/consts'; import { CONTAINER_INSERT_ERROR, diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts index a439d1d3713d..1d0fc216de9d 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts @@ -1,7 +1,7 @@ import { tapResponse } from '@ngrx/operators'; import { patchState, signalStoreFeature, type, withMethods } from '@ngrx/signals'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; -import { EMPTY, pipe, throwError, timer } from 'rxjs'; +import { EMPTY, pipe } from 'rxjs'; import { inject } from '@angular/core'; diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts index a9f4b4cea904..ed9d3bf817d9 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts @@ -27,9 +27,9 @@ import { MOCK_RESPONSE_HEADLESS, MOCK_RESPONSE_VTL } from '../../../shared/mocks'; +import { ActionPayload } from '../../../shared/models'; import { getPersonalization, mapContainerStructureToArrayOfContainers } from '../../../utils'; import { UVEState } from '../../models'; -import { ActionPayload } from '../../../shared/models'; const emptyParams = {} as DotPageApiParams; From 3dc67161903eb32861dcb15d21336fbedcbef4e8 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 30 Dec 2025 21:36:55 -0500 Subject: [PATCH 6/9] Added jsdoc --- .../portlets/edit-ema/portlet/src/lib/shared/models.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts index 046ca99cc0c8..75cd62ad19a2 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts @@ -6,6 +6,13 @@ import { CommonErrors, DialogStatus, FormStatus } from './enums'; import { DotPageApiParams } from '../services/dot-page-api.service'; +/** + * Represents a map of style property keys and their corresponding values + * for use in the style editor. + * + * Key is a string representing the CSS property name, + * value can be any type, allowing flexibility for different style values. + */ export type StyleEditorProperties = Record; export interface MessagePipeOptions { From 11617c2709775961976302aadc42d36a872292dc Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 2 Jan 2026 22:52:27 -0500 Subject: [PATCH 7/9] refactor(style-editor): Enhance style editor form with message service and debounce functionality - Integrated MessageService for user notifications on style property saves, improving user feedback. - Implemented a debounce mechanism for form value changes to optimize performance and reduce unnecessary API calls. - Updated variable names for consistency and clarity, ensuring better maintainability. - Cleaned up code and imports across the style editor component and related files. These changes enhance the user experience and performance of the style editor, providing timely feedback and efficient state management. --- ...ot-uve-style-editor-form.component.spec.ts | 6 +- .../dot-uve-style-editor-form.component.ts | 75 +++++++++++------- .../utils/style-editor-graphql.utils.ts | 2 +- .../lib/services/dot-page-api.service.spec.ts | 2 +- .../src/lib/services/dot-page-api.service.ts | 6 +- .../edit-ema/portlet/src/lib/shared/consts.ts | 2 + .../edit-ema/portlet/src/lib/shared/models.ts | 4 +- .../store/features/editor/save/withSave.ts | 77 +++++++++---------- .../features/timeMachine/withTimeMachine.ts | 67 +++++++++++----- 9 files changed, 144 insertions(+), 97 deletions(-) diff --git a/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 b/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 index f2777a2c2c09..76c9014e5b4e 100644 --- a/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 +++ b/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 @@ -6,9 +6,10 @@ import { signal } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { Accordion, AccordionModule } from 'primeng/accordion'; +import { MessageService } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; -import { DotWorkflowsActionsService } from '@dotcms/data-access'; +import { DotMessageService, DotWorkflowsActionsService } from '@dotcms/data-access'; import { StyleEditorFormSchema } from '@dotcms/uve'; import { DotUveStyleEditorFormComponent } from './dot-uve-style-editor-form.component'; @@ -87,10 +88,11 @@ describe('DotUveStyleEditorFormComponent', () => { component: DotUveStyleEditorFormComponent, imports: [AccordionModule, ButtonModule], providers: [ - // mockProvider(UVEStore), mockProvider(DotWorkflowsActionsService), mockProvider(DotPageApiService), mockProvider(HttpClient), + mockProvider(DotMessageService), + mockProvider(MessageService), { provide: UVEStore, useValue: { diff --git a/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.ts b/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.ts index 42d34ad55343..cc1504ff53f7 100644 --- a/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.ts +++ b/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.ts @@ -13,10 +13,12 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { AccordionModule } from 'primeng/accordion'; +import { MessageService } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; -import { debounceTime, distinctUntilChanged, share } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, share, tap } from 'rxjs/operators'; +import { DotMessageService } from '@dotcms/data-access'; import { StyleEditorFormSchema } from '@dotcms/uve'; import { UveStyleEditorFieldCheckboxGroupComponent } from './components/uve-style-editor-field-checkbox-group/uve-style-editor-field-checkbox-group.component'; @@ -30,7 +32,7 @@ import { } from './utils/style-editor-graphql.utils'; import { UveIframeMessengerService } from '../../../../../services/iframe-messenger/uve-iframe-messenger.service'; -import { STYLE_EDITOR_FIELD_TYPES } from '../../../../../shared/consts'; +import { STYLE_EDITOR_DEBOUNCE_TIME, STYLE_EDITOR_FIELD_TYPES } from '../../../../../shared/consts'; import { UVEStore } from '../../../../../store/dot-uve.store'; @Component({ @@ -56,6 +58,8 @@ export class DotUveStyleEditorFormComponent { readonly #uveStore = inject(UVEStore); readonly #iframeMessenger = inject(UveIframeMessengerService); readonly #destroyRef = inject(DestroyRef); + readonly #messageService = inject(MessageService); + readonly #dotMessageService = inject(DotMessageService); $sections = computed(() => this.$schema().sections); $form = computed(() => this.#form()); @@ -149,24 +153,15 @@ export class DotUveStyleEditorFormComponent { takeUntilDestroyed(this.#destroyRef) ); - // Immediate subscription: Update iframe without debounce formValueChanges$ .pipe( - distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)) + distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)), + tap((formValues) => this.#updateIframeImmediately(formValues)), + debounceTime(STYLE_EDITOR_DEBOUNCE_TIME) ) - .subscribe((formValues) => { - this.#updateIframeImmediately(formValues); - }); - - // Debounced subscription: Save to API - formValueChanges$ - .pipe( - debounceTime(3000), - distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)) - ) - .subscribe((formValues) => { - this.#saveStyleProperties(formValues); - }); + .subscribe((formValues: Record) => + this.#saveStyleProperties(formValues) + ); } /** @@ -175,14 +170,13 @@ export class DotUveStyleEditorFormComponent { */ #updateIframeImmediately(formValues: Record): void { const activeContentlet = this.#uveStore.activeContentlet(); - const customGraphqlResponse = this.#uveStore.$customGraphqlResponse(); - if (!activeContentlet || !customGraphqlResponse) { + if (!activeContentlet) { return; } try { - // Get the internal graphqlResponse (always wrapped format) for optimistic update + // Get the internal graphqlResponse for optimistic update const internalGraphqlResponse = this.#uveStore.graphqlResponse(); if (!internalGraphqlResponse) { return; @@ -232,12 +226,39 @@ export class DotUveStyleEditorFormComponent { } // Use the store's saveStyleEditor method which handles API call and rollback on failure - this.#uveStore.saveStyleEditor({ - containerIdentifier: activeContentlet.container.identifier, - contentledIdentifier: activeContentlet.contentlet.identifier, - styleProperties: formValues, - pageId: activeContentlet.pageId, - containerUUID: activeContentlet.container.uuid - }); + // Subscribe to handle success/error and show toast notifications + this.#uveStore + .saveStyleEditor({ + containerIdentifier: activeContentlet.container.identifier, + contentletIdentifier: activeContentlet.contentlet.identifier, + styleProperties: formValues, + pageId: activeContentlet.pageId, + containerUUID: activeContentlet.container.uuid + }) + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe({ + next: () => { + // Success toast - style properties saved successfully + this.#messageService.add({ + severity: 'success', + summary: this.#dotMessageService.get('message.content.saved'), + detail: this.#dotMessageService.get( + 'message.content.note.already.published' + ), + life: 2000 + }); + }, + error: (error) => { + // Error toast - rollback already handled in store + this.#messageService.add({ + severity: 'error', + summary: this.#dotMessageService.get( + 'editpage.content.update.contentlet.error' + ), + detail: error?.message || '', + life: 2000 + }); + } + }); } } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/utils/style-editor-graphql.utils.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/utils/style-editor-graphql.utils.ts index 2cad72501ab3..67a07fc9570c 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/utils/style-editor-graphql.utils.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-palette/components/dot-uve-style-editor-form/utils/style-editor-graphql.utils.ts @@ -10,7 +10,7 @@ import { ActionPayload } from '../../../../../../shared/models'; export type GraphQLResponse = | DotCMSPageAsset | { - grapql?: { + graphql?: { query: string; variables: Record; }; diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.spec.ts index d5352c84a265..7aaf4cdbc898 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.spec.ts @@ -216,7 +216,7 @@ describe('DotPageApiService', () => { pageId: 'test-page-123', containerIdentifier: 'container-id-456', containerUUID: 'container-uuid-789', - contentledIdentifier: 'contentlet-id-abc', + contentletIdentifier: 'contentlet-id-abc', styleProperties: { 'font-size': '16px', color: '#000000' diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.ts index 2e5b5c7e1608..6ff70c9be241 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.ts @@ -104,7 +104,7 @@ export class DotPageApiService { */ saveStyleProperties({ containerIdentifier, - contentledIdentifier, + contentletIdentifier, styleProperties, pageId, containerUUID @@ -112,9 +112,9 @@ export class DotPageApiService { const payload = { identifier: containerIdentifier, uuid: containerUUID, - contentletsId: [contentledIdentifier], + contentletsId: [contentletIdentifier], styleProperties: { - [contentledIdentifier]: styleProperties + [contentletIdentifier]: styleProperties } }; diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/consts.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/consts.ts index 8bd0cf7b008d..a351df7a99b5 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/consts.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/consts.ts @@ -23,6 +23,8 @@ export const CONTENTLET_CONTROLS_DRAG_ORIGIN = 'contentlet-controls'; export const BASE_IFRAME_MEASURE_UNIT = 'px'; +export const STYLE_EDITOR_DEBOUNCE_TIME = 2000; + export const COMMON_ERRORS: CommonErrorsInfo = { [CommonErrors.NOT_FOUND]: { icon: 'compass', diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts index 75cd62ad19a2..d2ab221f6fea 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts @@ -10,7 +10,7 @@ import { DotPageApiParams } from '../services/dot-page-api.service'; * Represents a map of style property keys and their corresponding values * for use in the style editor. * - * Key is a string representing the CSS property name, + * Key is a string representing the property name, * value can be any type, allowing flexibility for different style values. */ export type StyleEditorProperties = Record; @@ -109,7 +109,7 @@ export interface SaveStylePropertiesPayload { pageId: string; containerIdentifier: string; containerUUID: string; - contentledIdentifier: string; + contentletIdentifier: string; styleProperties: StyleEditorProperties; } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts index 1d0fc216de9d..637bbf4ba19f 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts @@ -1,7 +1,7 @@ import { tapResponse } from '@ngrx/operators'; import { patchState, signalStoreFeature, type, withMethods } from '@ngrx/signals'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; -import { EMPTY, pipe } from 'rxjs'; +import { EMPTY, pipe, throwError } from 'rxjs'; import { inject } from '@angular/core'; @@ -91,53 +91,50 @@ export function withSave() { ), /** * Saves style properties optimistically with automatic rollback on failure. - * The optimistic update should be done before calling this method using - * setGraphqlResponseOptimistic. This method handles the API call and - * rolls back the state if the save fails. + * Returns an observable that can be subscribed to for handling success/error. + * The optimistic update should be done before calling this method. + * This method handles the API call and rolls back the state if the save fails. * * @param payload - Style properties save payload + * @returns Observable that emits on success or error */ - saveStyleEditor: rxMethod( - pipe( - switchMap((payload) => { - return dotPageApiService.saveStyleProperties(payload).pipe( - tapResponse({ - next: () => { - // Success - optimistic update remains, no rollback needed - }, - error: (error) => { - console.error('Error saving style properties:', error); + saveStyleEditor: (payload: SaveStylePropertiesPayload) => { + return dotPageApiService.saveStyleProperties(payload).pipe( + tapResponse({ + next: () => { + // Success - optimistic update remains, no rollback needed + }, + error: (error) => { + console.error('Error saving style properties:', error); - // Rollback the optimistic update - const rolledBack = store.rollbackGraphqlResponse(); + // Rollback the optimistic update + const rolledBack = store.rollbackGraphqlResponse(); - if (!rolledBack) { - console.error( - 'Failed to rollback optimistic update - no history available' - ); + if (!rolledBack) { + console.error( + 'Failed to rollback optimistic update - no history available' + ); - return; - } + return; + } - // Update iframe with rolled back state - const rolledBackResponse = store.$customGraphqlResponse(); - if (rolledBackResponse) { - iframeMessenger.sendPageData(rolledBackResponse); - } - console.warn( - '[TEST] Rolled back optimistic style update due to save failure' - ); - } - }), - catchError((error) => { - // Additional error handling if needed - console.error('Error in saveStyleEditor:', error); - return EMPTY; - }) - ); + // Update iframe with rolled back state + const rolledBackResponse = store.$customGraphqlResponse(); + if (rolledBackResponse) { + iframeMessenger.sendPageData(rolledBackResponse); + } + console.warn( + 'Rolled back optimistic style update due to save failure' + ); + } + }), + catchError((error) => { + // Re-throw error so component can handle it (show toast, etc.) + // Rollback is already handled in tapResponse error callback + return throwError(() => error); }) - ) - ) + ); + } }; }) ); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/timeMachine/withTimeMachine.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/timeMachine/withTimeMachine.ts index 0b2bbab87f6f..4ec9f46a5c52 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/timeMachine/withTimeMachine.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/timeMachine/withTimeMachine.ts @@ -43,64 +43,89 @@ const safeClone: StructuredCloneFunc = : (v: T) => JSON.parse(JSON.stringify(v)); /** - * Internal state added by withOtherTimeMachine + * Represents the state of the time machine history feature. + * + * @template T - The type of state snapshots being managed. + * @property {T[]} history - An array of state snapshots representing the history stack. + * @property {number} pointer - The current index within the history array (0-based). */ -interface OtherTimeMachineState { +interface TimeMachineState { history: T[]; pointer: number; } /** - * Methods added by withOtherTimeMachine + * Interface defining methods for managing undo/redo history in a time machine pattern. + * + * @template T The type of the state being stored in the time machine. */ export interface TimeMachineMethods { /** - * Add a new state snapshot to history - * If pointer is not at the end, discards future states (rebase) + * Add a new state snapshot to the history. + * If the pointer is not at the end, discards future states ("rebase"). + * + * @param {T} state - The state snapshot to add to history. + * @returns {void} */ addHistory(state: T): void; /** - * Navigate to a specific index in history - * @param index - The index to navigate to (0-based) - * @returns The state at that index, or null if invalid + * Navigate directly to a specific index in the history. + * + * @param {number} index - The index to navigate to (0-based). + * @returns {(T | null)} The state at that index, or null if invalid. */ goTo(index: number): T | null; /** - * Move back one step in history - * @returns The previous state, or null if at the beginning or only one history item + * Move back one step in the history (undo). + * + * @returns {(T | null)} The previous state, or null if at the beginning or only one history item. */ undo(): T | null; /** - * Move forward one step in history - * @returns The next state, or null if at the end + * Move forward one step in the history (redo). + * + * @returns {(T | null)} The next state, or null if at the end. */ redo(): T | null; /** - * Get state at a specific index without navigating - * @param index - The index to get state from - * @returns The state at that index, or null if invalid + * Retrieve the state at a specific index without navigating. + * + * @param {number} index - The index to get the state from. + * @returns {(T | null)} The state at that index, or null if invalid. */ getStateAt(index: number): T | null; /** - * Clear all history and reset pointer + * Clear all history and reset the pointer. + * + * @returns {void} */ clearHistory(): void; /** - * Get the full history array (read-only) + * Get the full history array (read-only). + * + * @returns {readonly T[]} The history array. */ getHistory(): readonly T[]; } /** - * Computed properties added by withOtherTimeMachine + * Represents the computed properties added by withTimeMachine. + * + * @template T - The type of state snapshots being managed. + * @property {Signal} haveHistory - Whether there is any history. + * @property {Signal} canUndo - Whether undo is possible. + * @property {Signal} canRedo - Whether redo is possible. + * @property {Signal} current - The current state at the pointer position. + * @property {Signal} currentIndex - The current pointer index. + * @property {Signal} historyLength - The total number of states in history. */ -export interface OtherTimeMachineComputed { +export interface TimeMachineComputed { /** * Whether there is any history */ @@ -177,7 +202,7 @@ export interface TimeMachineOptions { * ```typescript * export const myStore = signalStore( * withState({ count: 0 }), - * withOtherTimeMachine<{ count: number }>({ maxHistory: 50 }), + * withTimeMachine<{ count: number }>({ maxHistory: 50 }), * withMethods(store => ({ * increment() { * patchState(store, { count: store.count() + 1 }); @@ -193,7 +218,7 @@ export function withTimeMachine(options?: TimeMachineOptions) { const shouldDeepClone = options?.deepClone !== false; // default true return signalStoreFeature( - withState>({ + withState>({ history: [], pointer: -1 }), From 1f98ac33bbd0b6c26246ffd4f33e50af2eefae73 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 2 Jan 2026 23:38:28 -0500 Subject: [PATCH 8/9] Moved model order to trigger pipeline --- .../libs/portlets/edit-ema/portlet/src/lib/shared/models.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts index d2ab221f6fea..9f4d67bf732d 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/models.ts @@ -109,8 +109,8 @@ export interface SaveStylePropertiesPayload { pageId: string; containerIdentifier: string; containerUUID: string; - contentletIdentifier: string; styleProperties: StyleEditorProperties; + contentletIdentifier: string; } export interface NavigationBarItem { From f6f07ab0f40bc202dbd2bea27eed381088539668 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 2 Jan 2026 23:48:09 -0500 Subject: [PATCH 9/9] Fixed typo --- .../edit-ema/portlet/src/lib/services/dot-page-api.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.ts index 6ff70c9be241..280fe664f182 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.ts @@ -95,7 +95,7 @@ export class DotPageApiService { * * @param {SaveStylePropertiesPayload} payload - The payload for saving style properties. * @param {string} payload.containerIdentifier - Identifier of the container. - * @param {string} payload.contentledIdentifier - Identifier of the contentlet. + * @param {string} payload.contentletIdentifier - Identifier of the contentlet. * @param {Record} payload.styleProperties - Style properties to apply. * @param {string} payload.pageId - The page ID where styles are being saved. * @param {string} payload.containerUUID - UUID of the container.