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.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 6562884f0bb4..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
@@ -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,8 @@ export class DotUveContentletToolsComponent {
this.menu()?.hide();
this.menuVTL()?.hide();
}
+
+ setSelectedContent() {
+ 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 6d76439a5f88..1b56d7ab822d 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.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.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..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
@@ -1,15 +1,22 @@
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 { MessageService } from 'primeng/api';
import { ButtonModule } from 'primeng/button';
+import { DotMessageService, 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 +86,20 @@ describe('DotUveStyleEditorFormComponent', () => {
const createComponent = createComponentFactory({
component: DotUveStyleEditorFormComponent,
- imports: [AccordionModule, ButtonModule]
+ imports: [AccordionModule, ButtonModule],
+ providers: [
+ mockProvider(DotWorkflowsActionsService),
+ mockProvider(DotPageApiService),
+ mockProvider(HttpClient),
+ mockProvider(DotMessageService),
+ mockProvider(MessageService),
+ {
+ provide: UVEStore,
+ useValue: {
+ currentIndex: signal(0)
+ }
+ }
+ ]
});
beforeEach(() => {
@@ -210,7 +230,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 5028c276d5da..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
@@ -1,34 +1,46 @@
-import { Component, input, inject, computed, signal, effect } from '@angular/core';
+import { CommonModule } from '@angular/common';
import {
- FormBuilder,
- FormGroup,
- FormControl,
- ReactiveFormsModule,
- AbstractControl
-} from '@angular/forms';
+ Component,
+ input,
+ inject,
+ computed,
+ signal,
+ effect,
+ DestroyRef,
+ untracked
+} from '@angular/core';
+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 {
- StyleEditorFormSchema,
- StyleEditorSectionSchema,
- StyleEditorFieldSchema,
- StyleEditorCheckboxDefaultValue
-} from '@dotcms/uve';
+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';
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 { STYLE_EDITOR_FIELD_TYPES } from '../../../../../shared/consts';
+import { UveIframeMessengerService } from '../../../../../services/iframe-messenger/uve-iframe-messenger.service';
+import { STYLE_EDITOR_DEBOUNCE_TIME, STYLE_EDITOR_FIELD_TYPES } from '../../../../../shared/consts';
+import { UVEStore } from '../../../../../store/dot-uve.store';
@Component({
selector: 'dot-uve-style-editor-form',
templateUrl: './dot-uve-style-editor-form.component.html',
styleUrls: ['./dot-uve-style-editor-form.component.scss'],
imports: [
+ CommonModule,
ReactiveFormsModule,
AccordionModule,
ButtonModule,
@@ -41,110 +53,212 @@ import { STYLE_EDITOR_FIELD_TYPES } from '../../../../../shared/consts';
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 #destroyRef = inject(DestroyRef);
+ readonly #messageService = inject(MessageService);
+ readonly #dotMessageService = inject(DotMessageService);
$sections = computed(() => this.$schema().sections);
$form = computed(() => this.#form());
+ 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();
+
+ // 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) {
+ untracked(() => {
+ this.#restoreFormFromRollback();
+ });
+ }
+ this.$previousIndex.set(currentIndex);
+ });
+
$reloadSchemaEffect = effect(() => {
- const schema = this.$schema();
+ // 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);
+ this.#listenToFormChanges();
}
});
- readonly STYLE_EDITOR_FIELD_TYPES = STYLE_EDITOR_FIELD_TYPES;
-
+ /**
+ * Builds a form from the schema using the form builder service
+ */
#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));
+ const form = this.#formBuilder.buildForm(schema);
+ this.#form.set(form);
}
- #getDropdownDefaultValue(config: StyleEditorFieldSchema['config']): string {
- if (typeof config?.defaultValue === 'string') {
- return config.defaultValue.trim();
+ /**
+ * Restores form values from the rolled-back graphqlResponse state.
+ * Used when rollback occurs to sync form with restored state.
+ */
+ #restoreFormFromRollback(): void {
+ const form = this.#form();
+ const activeContentlet = this.#uveStore.activeContentlet();
+
+ if (!form || !activeContentlet) {
+ return;
}
- return null;
- }
+ try {
+ // 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;
+ }
- #getCheckboxGroupDefaultValue(
- config: StyleEditorFieldSchema['config']
- ): StyleEditorCheckboxDefaultValue {
- if (this.#isCheckboxDefaultValue(config?.defaultValue)) {
- return config.defaultValue;
+ // Extract style properties from the rolled-back state using utility function
+ const styleProperties = extractStylePropertiesFromGraphQL(
+ rolledBackGraphqlResponse,
+ 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);
}
- return {};
}
- #getRadioDefaultValue(config: StyleEditorFieldSchema['config']): string {
- if (typeof config?.defaultValue === 'string') {
- return config.defaultValue;
+ /**
+ * 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;
}
- return config?.options?.[0]?.value || '';
+
+ // Share the valueChanges observable to avoid multiple subscriptions
+ const formValueChanges$ = form.valueChanges.pipe(
+ share(),
+ takeUntilDestroyed(this.#destroyRef)
+ );
+
+ formValueChanges$
+ .pipe(
+ distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)),
+ tap((formValues) => this.#updateIframeImmediately(formValues)),
+ debounceTime(STYLE_EDITOR_DEBOUNCE_TIME)
+ )
+ .subscribe((formValues: Record) =>
+ this.#saveStyleProperties(formValues)
+ );
}
- #getInputDefaultValue(config: StyleEditorFieldSchema['config']): string | number {
- if (typeof config?.defaultValue === 'string' || typeof config?.defaultValue === 'number') {
- return config.defaultValue;
+ /**
+ * 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();
+
+ if (!activeContentlet) {
+ return;
+ }
+
+ try {
+ // Get the internal graphqlResponse 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 = updateStylePropertiesInGraphQL(
+ internalGraphqlResponse,
+ activeContentlet,
+ formValues
+ );
+
+ // 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
+ // Get the updated custom response (computed will reflect the changes)
+ const updatedCustomResponse = this.#uveStore.$customGraphqlResponse();
+ if (!updatedCustomResponse) {
+ return;
+ }
+ this.#iframeMessenger.sendPageData(updatedCustomResponse);
+ } catch (error) {
+ console.error('Error updating iframe:', error);
}
- return '';
}
- #isCheckboxDefaultValue(value: unknown): value is StyleEditorCheckboxDefaultValue {
- return (
- typeof value === 'object' &&
- value !== null &&
- !Array.isArray(value) &&
- Object.values(value).every((v) => typeof v === 'boolean')
- );
+ /**
+ * Saves style properties to API with debounce
+ * Saves current state to history before API call, so rollback can restore to this point
+ */
+ #saveStyleProperties(formValues: Record): void {
+ const activeContentlet = this.#uveStore.activeContentlet();
+
+ if (!activeContentlet) {
+ return;
+ }
+
+ // 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);
+ }
+
+ // Use the store's saveStyleEditor method which handles API call and rollback on failure
+ // 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/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..0b9ae26827ac
--- /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 { Injectable, inject } from '@angular/core';
+import { FormBuilder, FormGroup, FormControl, AbstractControl } from '@angular/forms';
+
+import {
+ StyleEditorFormSchema,
+ StyleEditorSectionSchema,
+ StyleEditorFieldSchema,
+ 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.
+ */
+@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..67a07fc9570c
--- /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 { DotCMSBasicContentlet, DotCMSPageAsset } from '@dotcms/types';
+
+import { ActionPayload } from '../../../../../../shared/models';
+
+/**
+ * Type representing a GraphQL response that can be either:
+ * - Direct DotCMSPageAsset
+ * - Wrapped response with pageAsset property
+ */
+export type GraphQLResponse =
+ | DotCMSPageAsset
+ | {
+ graphql?: {
+ 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.styleProperties = 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?.styleProperties || null;
+}
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 b2c01db354d9..d7000da5b319 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
@@ -55,7 +55,6 @@ import {
DotCMSURLContentMap,
DotCMSUVEAction
} from '@dotcms/types';
-import { __DOTCMS_UVE_EVENT__ } from '@dotcms/types/internal';
import { DotCopyContentModalService, SafeUrlPipe } from '@dotcms/ui';
import { WINDOW, isEqual } from '@dotcms/utils';
import { StyleEditorFormSchema } from '@dotcms/uve';
@@ -77,6 +76,7 @@ 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 { 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 {
@@ -89,7 +89,6 @@ import {
import {
ActionPayload,
ClientData,
- ContentletPayload,
DeletePayload,
DialogAction,
InsertPayloadFromDelete,
@@ -180,6 +179,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit
private readonly dotWorkflowActionsFireService = inject(DotWorkflowActionsFireService);
private readonly inlineEditingService = inject(InlineEditService);
private readonly dotPageApiService = inject(DotPageApiService);
+ private readonly iframeMessenger = inject(UveIframeMessengerService);
readonly #destroyRef = inject(DestroyRef);
readonly #dotAlertConfirmService = inject(DotAlertConfirmService);
#iframeResizeObserver: ResizeObserver | null = null;
@@ -244,10 +244,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit
return;
}
- this.contentWindow?.postMessage(
- { name: __DOTCMS_UVE_EVENT__.UVE_REQUEST_BOUNDS },
- this.host
- );
+ this.iframeMessenger.requestBounds();
});
ngOnInit(): void {
@@ -260,6 +257,8 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy, AfterViewInit
ngAfterViewInit(): void {
this.#setupContentletAreaReset();
+ // Initialize iframe messenger with the iframe window
+ this.iframeMessenger.setIframeWindow(this.contentWindow);
}
/**
@@ -354,12 +353,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;
@@ -427,10 +421,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')
@@ -496,6 +487,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;
}
@@ -822,12 +816,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({
@@ -905,12 +894,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({
@@ -1039,12 +1023,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;
}
@@ -1160,13 +1139,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() {
@@ -1631,8 +1604,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.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.spec.ts
index b047d14d3d40..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
@@ -209,4 +209,40 @@ describe('DotPageApiService', () => {
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',
+ contentletIdentifier: '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/dot-page-api.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-page-api.service.ts
index 7562d9190724..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
@@ -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,39 @@ 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.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.
+ * @returns {Observable} Observable that completes when properties are saved.
+ * @memberof DotPageApiService
+ */
+ saveStyleProperties({
+ containerIdentifier,
+ contentletIdentifier,
+ styleProperties,
+ pageId,
+ containerUUID
+ }: SaveStylePropertiesPayload): Observable {
+ const payload = {
+ identifier: containerIdentifier,
+ uuid: containerUUID,
+ contentletsId: [contentletIdentifier],
+ styleProperties: {
+ [contentletIdentifier]: 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/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/iframe-messenger/uve-iframe-messenger.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/iframe-messenger/uve-iframe-messenger.service.ts
new file mode 100644
index 000000000000..2dcfb6dfb90f
--- /dev/null
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/iframe-messenger/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/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 df329635b5e4..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
@@ -6,6 +6,15 @@ 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 property name,
+ * value can be any type, allowing flexibility for different style values.
+ */
+export type StyleEditorProperties = Record;
+
export interface MessagePipeOptions {
message: string;
args: string[];
@@ -53,6 +62,10 @@ export interface ActionPayload extends PositionPayload {
newContentletId?: string;
}
+export interface StyleEditorContentletPayload extends ActionPayload {
+ contentlet: ContentletPayload;
+}
+
export interface PageContainer {
personaTag?: string;
identifier: string;
@@ -92,6 +105,14 @@ export interface SavePagePayload {
whenSaved?: () => void;
}
+export interface SaveStylePropertiesPayload {
+ pageId: string;
+ containerIdentifier: string;
+ containerUUID: string;
+ styleProperties: StyleEditorProperties;
+ contentletIdentifier: string;
+}
+
export interface NavigationBarItem {
icon?: string;
iconURL?: string;
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/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/save/withSave.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/save/withSave.ts
index 9b1363a6c075..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';
@@ -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/iframe-messenger/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(
@@ -86,7 +88,53 @@ export function withSave() {
);
})
)
- )
+ ),
+ /**
+ * Saves style properties optimistically with automatic rollback on failure.
+ * 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: (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();
+
+ if (!rolledBack) {
+ console.error(
+ 'Failed to rollback optimistic update - no history available'
+ );
+
+ return;
+ }
+
+ // 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/editor/withEditor.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts
index 998bfa945be5..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,6 +27,7 @@ import {
MOCK_RESPONSE_HEADLESS,
MOCK_RESPONSE_VTL
} from '../../../shared/mocks';
+import { ActionPayload } from '../../../shared/models';
import { getPersonalization, mapContainerStructureToArrayOfContainers } from '../../../utils';
import { UVEState } from '../../models';
@@ -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/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/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.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/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..4ec9f46a5c52
--- /dev/null
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/timeMachine/withTimeMachine.ts
@@ -0,0 +1,361 @@
+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));
+
+/**
+ * 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 TimeMachineState {
+ history: T[];
+ pointer: number;
+}
+
+/**
+ * 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 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 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 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 the history (redo).
+ *
+ * @returns {(T | null)} The next state, or null if at the end.
+ */
+ redo(): T | null;
+
+ /**
+ * 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 the pointer.
+ *
+ * @returns {void}
+ */
+ clearHistory(): void;
+
+ /**
+ * Get the full history array (read-only).
+ *
+ * @returns {readonly T[]} The history array.
+ */
+ getHistory(): readonly T[];
+}
+
+/**
+ * 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 TimeMachineComputed {
+ /**
+ * 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 }),
+ * withTimeMachine<{ 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();
+ }
+ };
+ })
+ );
+}
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..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,6 +378,7 @@ export interface DotCMSBasicContentlet {
widgetTitle?: string;
onNumberOfPages?: string;
__icon__?: string;
+ styleProperties?: Record;
_map?: Record;
}