+
+
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-dataview/dot-dataview.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-dataview/dot-dataview.component.scss
new file mode 100644
index 000000000000..8acfaa4d306e
--- /dev/null
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-dataview/dot-dataview.component.scss
@@ -0,0 +1,27 @@
+@use "variables" as *;
+
+:host {
+ height: 100%;
+ display: block;
+}
+::ng-deep {
+ p-table {
+ .p-datatable .p-datatable-header {
+ background-color: $color-palette-gray-100;
+ }
+ }
+}
+
+.file-selector__table_header {
+ th {
+ font-weight: $font-weight-bold;
+ background-color: $color-palette-gray-100;
+ }
+}
+.img {
+ max-inline-size: 100%;
+ block-size: auto;
+ aspect-ratio: 2/1;
+ object-fit: cover;
+ object-position: top center;
+}
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-dataview/dot-dataview.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-dataview/dot-dataview.component.ts
new file mode 100644
index 000000000000..f86bbe7e649f
--- /dev/null
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-dataview/dot-dataview.component.ts
@@ -0,0 +1,55 @@
+import { DatePipe, NgOptimizedImage } from '@angular/common';
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+
+import { ButtonModule } from 'primeng/button';
+import { DataViewModule } from 'primeng/dataview';
+import { IconFieldModule } from 'primeng/iconfield';
+import { ImageModule } from 'primeng/image';
+import { InputIconModule } from 'primeng/inputicon';
+import { InputTextModule } from 'primeng/inputtext';
+import { SkeletonModule } from 'primeng/skeleton';
+import { TableModule } from 'primeng/table';
+import { TagModule } from 'primeng/tag';
+
+import { DotMessagePipe } from '@dotcms/ui';
+
+import { Content } from '../../store/select-existing-file.store';
+
+@Component({
+ selector: 'dot-dataview',
+ standalone: true,
+ imports: [
+ DataViewModule,
+ TagModule,
+ ButtonModule,
+ TableModule,
+ IconFieldModule,
+ InputIconModule,
+ InputTextModule,
+ SkeletonModule,
+ ImageModule,
+ NgOptimizedImage,
+ DatePipe,
+ DotMessagePipe
+ ],
+ templateUrl: './dot-dataview.component.html',
+ styleUrls: ['./dot-dataview.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class DotDataViewComponent {
+ /**
+ * Represents an observable stream of content data.
+ *
+ * @type {Observable}
+ * @alias data
+ * @required
+ */
+ $data = input.required({ alias: 'data' });
+ /**
+ * A boolean observable that indicates the loading state.
+ * This is typically used to show or hide a loading indicator in the UI.
+ *
+ * @type {boolean}
+ */
+ $loading = input.required({ alias: 'loading' });
+}
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-sidebar/dot-sidebar.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-sidebar/dot-sidebar.component.html
new file mode 100644
index 000000000000..690560328112
--- /dev/null
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-sidebar/dot-sidebar.component.html
@@ -0,0 +1 @@
+
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-sidebar/dot-sidebar.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-sidebar/dot-sidebar.component.scss
new file mode 100644
index 000000000000..9c94b8ec9f4d
--- /dev/null
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-sidebar/dot-sidebar.component.scss
@@ -0,0 +1,4 @@
+:host {
+ height: 100%;
+ display: flex;
+}
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-sidebar/dot-sidebar.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-sidebar/dot-sidebar.component.ts
new file mode 100644
index 000000000000..95baf9ce298e
--- /dev/null
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-sidebar/dot-sidebar.component.ts
@@ -0,0 +1,29 @@
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+
+import { TreeNode } from 'primeng/api';
+import { TreeModule } from 'primeng/tree';
+
+@Component({
+ selector: 'dot-sidebar',
+ standalone: true,
+ imports: [TreeModule],
+ templateUrl: './dot-sidebar.component.html',
+ styleUrls: ['./dot-sidebar.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class DotSideBarComponent {
+ /**
+ * An observable that emits an array of TreeNode objects representing folders.
+ *
+ * @type {Observable}
+ * @alias folders
+ */
+ $folders = input.required({ alias: 'folders' });
+ /**
+ * Represents a loading state for the component.
+ *
+ * @type {boolean}
+ * @alias loading
+ */
+ $loading = input.required({ alias: 'loading' });
+}
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/dot-select-existing-file.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/dot-select-existing-file.component.html
new file mode 100644
index 000000000000..68c1387397dc
--- /dev/null
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/dot-select-existing-file.component.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/dot-select-existing-file.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/dot-select-existing-file.component.scss
new file mode 100644
index 000000000000..674ba19f4eed
--- /dev/null
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/dot-select-existing-file.component.scss
@@ -0,0 +1,18 @@
+@use "variables" as *;
+
+.file-selector {
+ height: 43rem;
+ .file-selector__sidebar {
+ border-right: $field-border-size solid $color-palette-gray-400;
+ ::ng-deep {
+ .p-tree {
+ border: 0px;
+ }
+ }
+ }
+ .file-selector__main {
+ margin-top: 1px;
+ border-radius: $border-radius-md;
+ border: $field-border-size solid $color-palette-gray-400;
+ }
+}
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/dot-select-existing-file.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/dot-select-existing-file.component.ts
new file mode 100644
index 000000000000..b10f9dfa2f3b
--- /dev/null
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/dot-select-existing-file.component.ts
@@ -0,0 +1,55 @@
+import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
+
+import { ButtonModule } from 'primeng/button';
+import { DynamicDialogRef } from 'primeng/dynamicdialog';
+
+import { DotMessagePipe } from '@dotcms/ui';
+
+import { DotDataViewComponent } from './components/dot-dataview/dot-dataview.component';
+import { DotSideBarComponent } from './components/dot-sidebar/dot-sidebar.component';
+import { SelectExisingFileStore } from './store/select-existing-file.store';
+
+@Component({
+ selector: 'dot-select-existing-file',
+ standalone: true,
+ imports: [DotSideBarComponent, DotDataViewComponent, ButtonModule, DotMessagePipe],
+ templateUrl: './dot-select-existing-file.component.html',
+ styleUrls: ['./dot-select-existing-file.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [SelectExisingFileStore]
+})
+export class DotSelectExistingFileComponent implements OnInit {
+ /**
+ * Injects the SelectExistingFileStore into the component.
+ *
+ * @readonly
+ * @type {SelectExistingFileStore}
+ */
+ /**
+ * A readonly property that injects the `SelectExisingFileStore` service.
+ * This store is used to manage the state and actions related to selecting existing files.
+ */
+ readonly store = inject(SelectExisingFileStore);
+ /**
+ * A reference to the dynamic dialog instance.
+ * This is a read-only property that is injected using Angular's dependency injection.
+ * It provides access to the dialog's methods and properties.
+ */
+ readonly #dialogRef = inject(DynamicDialogRef);
+
+ ngOnInit() {
+ this.store.loadContent();
+ this.store.loadFolders();
+ }
+
+ /**
+ * Cancels the current file upload and closes the dialog.
+ *
+ * @remarks
+ * This method is used to terminate the ongoing file upload process and
+ * close the associated dialog reference.
+ */
+ closeDialog(): void {
+ this.#dialogRef.close();
+ }
+}
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/store/select-existing-file.store.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/store/select-existing-file.store.ts
new file mode 100644
index 000000000000..ea6e91dbc56d
--- /dev/null
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/store/select-existing-file.store.ts
@@ -0,0 +1,115 @@
+import { faker } from '@faker-js/faker';
+import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals';
+
+import { computed } from '@angular/core';
+
+import { TreeNode } from 'primeng/api';
+
+import { ComponentStatus, DotCMSContentlet } from '@dotcms/dotcms-models';
+
+export interface Content {
+ id: string;
+ image: string;
+ title: string;
+ modifiedBy: string;
+ lastModified: Date;
+}
+
+export interface SelectExisingFileState {
+ folders: {
+ data: TreeNode[];
+ status: ComponentStatus;
+ };
+ content: {
+ data: Content[];
+ status: ComponentStatus;
+ };
+ selectedFolder: TreeNode | null;
+ selectedFile: DotCMSContentlet | null;
+ searchQuery: string;
+ viewMode: 'list' | 'grid';
+}
+
+const initialState: SelectExisingFileState = {
+ folders: {
+ data: [],
+ status: ComponentStatus.INIT
+ },
+ content: {
+ data: [],
+ status: ComponentStatus.INIT
+ },
+ selectedFolder: null,
+ selectedFile: null,
+ searchQuery: '',
+ viewMode: 'list'
+};
+
+export const SelectExisingFileStore = signalStore(
+ withState(initialState),
+ withComputed((state) => ({
+ foldersIsLoading: computed(() => state.folders().status === ComponentStatus.LOADING),
+ contentIsLoading: computed(() => state.content().status === ComponentStatus.LOADING)
+ })),
+ withMethods((store) => {
+ return {
+ loadContent: () => {
+ const mockContent = faker.helpers.multiple(
+ () => ({
+ id: faker.string.uuid(),
+ image: faker.image.url(),
+ title: faker.commerce.productName(),
+ modifiedBy: faker.internet.displayName(),
+ lastModified: faker.date.recent()
+ }),
+ { count: 100 }
+ );
+
+ patchState(store, {
+ content: {
+ data: mockContent,
+ status: ComponentStatus.LOADED
+ }
+ });
+ },
+ loadFolders: () => {
+ const mockFolders = [
+ {
+ label: 'demo.dotcms.com',
+ expandedIcon: 'pi pi-folder-open',
+ collapsedIcon: 'pi pi-folder',
+ children: [
+ {
+ label: 'demo.dotcms.com',
+ expandedIcon: 'pi pi-folder-open',
+ collapsedIcon: 'pi pi-folder',
+ children: [
+ {
+ label: 'documents'
+ }
+ ]
+ },
+ {
+ label: 'demo.dotcms.com',
+ expandedIcon: 'pi pi-folder-open',
+ collapsedIcon: 'pi pi-folder'
+ }
+ ]
+ },
+ {
+ label: 'nico.dotcms.com',
+ expandedIcon: 'pi pi-folder-open',
+ collapsedIcon: 'pi pi-folder'
+ }
+ ];
+
+ patchState(store, {
+ folders: {
+ data: mockFolders,
+ status: ComponentStatus.LOADED
+ }
+ });
+ }
+ };
+ })
+);
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.html
index 39c6954bc2a0..d80bdfcb1e4a 100644
--- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.html
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.html
@@ -44,6 +44,7 @@
}
@if (store.allowExistingFile()) {
}
@case ('preview') {
- @if (store.uploadedFile()) {
+ @let uploadedFile = store.uploadedFile();
+ @if (uploadedFile) {
+ [previewFile]="uploadedFile" />
}
}
}
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.ts
index e7a3bdab3743..6f7d14061d31 100644
--- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.ts
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.ts
@@ -32,7 +32,9 @@ import {
import { DotFileFieldPreviewComponent } from './components/dot-file-field-preview/dot-file-field-preview.component';
import { DotFileFieldUiMessageComponent } from './components/dot-file-field-ui-message/dot-file-field-ui-message.component';
+import { DotFormFileEditorComponent } from './components/dot-form-file-editor/dot-form-file-editor.component';
import { DotFormImportUrlComponent } from './components/dot-form-import-url/dot-form-import-url.component';
+import { DotSelectExistingFileComponent } from './components/dot-select-existing-file/dot-select-existing-file.component';
import { INPUT_TYPES, UploadedFile } from './models';
import { DotFileFieldUploadService } from './services/upload-file/upload-file.service';
import { FileFieldStore } from './store/file-field.store';
@@ -132,21 +134,19 @@ export class DotEditContentFileFieldComponent implements ControlValueAccessor, O
return this.#dotMessageService.get('dot.file.field.action.generate.with.tooltip');
}
- return null;
+ return '';
});
- private onChange: (value: string) => void;
- private onTouched: () => void;
+ private onChange: ((value: string) => void) | null = null;
+ private onTouched: (() => void) | null = null;
constructor() {
effect(() => {
- if (!this.onChange && !this.onTouched) {
- return;
+ if (this.onChange && this.onTouched) {
+ const value = this.store.value();
+ this.onChange(value);
+ this.onTouched();
}
-
- const value = this.store.value();
- this.onChange(value);
- this.onTouched();
});
}
@@ -234,7 +234,11 @@ export class DotEditContentFileFieldComponent implements ControlValueAccessor, O
*
* @return {void}
*/
- fileSelected(files: FileList) {
+ fileSelected(files: FileList | null) {
+ if (!files || files.length === 0) {
+ return;
+ }
+
const file = files[0];
if (!file) {
@@ -346,6 +350,75 @@ export class DotEditContentFileFieldComponent implements ControlValueAccessor, O
});
}
+ /**
+ * Opens the file editor dialog with specific configurations and handles the file upload process.
+ *
+ * This method performs the following actions:
+ * - Retrieves the header message for the dialog.
+ * - Opens the `DotFormFileEditorComponent` dialog with various options such as header, appendTo, closeOnEscape, draggable, keepInViewport, maskStyleClass, resizable, modal, width, and style.
+ * - Passes data to the dialog, including the uploaded file and a flag to allow file name editing.
+ * - Subscribes to the dialog's onClose event to handle the uploaded file and update the store with the preview file.
+ *
+ */
+ showFileEditorDialog() {
+ const header = this.#dotMessageService.get('dot.file.field.dialog.create.new.file.header');
+
+ this.#dialogRef = this.#dialogService.open(DotFormFileEditorComponent, {
+ header,
+ appendTo: 'body',
+ closeOnEscape: false,
+ draggable: false,
+ keepInViewport: false,
+ maskStyleClass: 'p-dialog-mask-transparent-ai',
+ resizable: false,
+ modal: true,
+ width: '90%',
+ style: { 'max-width': '1040px' },
+ data: {
+ uploadedFile: this.store.uploadedFile(),
+ allowFileNameEdit: true
+ }
+ });
+
+ this.#dialogRef.onClose
+ .pipe(
+ filter((file) => !!file),
+ takeUntilDestroyed(this.#destroyRef)
+ )
+ .subscribe((file) => {
+ this.store.setPreviewFile(file);
+ });
+ }
+
+ showSelectExistingFileDialog() {
+ const header = this.#dotMessageService.get(
+ 'dot.file.field.dialog.select.existing.file.header'
+ );
+
+ this.#dialogRef = this.#dialogService.open(DotSelectExistingFileComponent, {
+ header,
+ appendTo: 'body',
+ closeOnEscape: false,
+ draggable: false,
+ keepInViewport: false,
+ maskStyleClass: 'p-dialog-mask-transparent-ai',
+ resizable: false,
+ modal: true,
+ width: '90%',
+ style: { 'max-width': '1040px' },
+ data: {}
+ });
+
+ this.#dialogRef.onClose
+ .pipe(
+ filter((file) => !!file),
+ takeUntilDestroyed(this.#destroyRef)
+ )
+ .subscribe((file) => {
+ this.store.setPreviewFile(file);
+ });
+ }
+
/**
* Cleanup method.
*
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.const.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.const.ts
index 796aa5997f6f..2cd8dcccd076 100644
--- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.const.ts
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.const.ts
@@ -6,7 +6,7 @@ type Actions = {
allowCreateFile: boolean;
allowGenerateImg: boolean;
acceptedFiles: string[];
- maxFileSize: number;
+ maxFileSize: number | null;
};
type ConfigActions = Record;
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.ts
index 62c65593b450..5ec332eecd08 100644
--- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.ts
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.ts
@@ -76,7 +76,7 @@ export const FileFieldStore = signalStore(
* @param initState
*/
initLoad: (initState: {
- inputType: FileFieldState['inputType'];
+ inputType: INPUT_TYPES;
fieldVariable: FileFieldState['fieldVariable'];
isAIPluginInstalled?: boolean;
}) => {
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/editor.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/editor.ts
new file mode 100644
index 000000000000..b1e189198080
--- /dev/null
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/editor.ts
@@ -0,0 +1,53 @@
+/**
+ * Extracts the file extension from a given file name.
+ *
+ * @param fileName - The name of the file from which to extract the extension.
+ * @returns The file extension if present, otherwise an empty string.
+ */
+export function extractFileExtension(fileName: string): string {
+ const includesDot = fileName.includes('.');
+
+ if (!includesDot) {
+ return '';
+ }
+
+ return fileName.split('.').pop() || '';
+}
+
+/**
+ * Retrieves language information based on the provided file extension.
+ *
+ * @param extension - The file extension to get the language information for.
+ * @returns An object containing the language id, MIME type, and extension.
+ *
+ * @example
+ * ```typescript
+ * const info = getInfoByLang('vtl');
+ * console.log(info);
+ * // Output: { lang: 'html', mimeType: 'text/x-velocity', extension: '.vtl' }
+ * ```
+ *
+ * @remarks
+ * If the extension is 'vtl', it returns a predefined set of values.
+ * Otherwise, it searches through the Monaco Editor languages to find a match.
+ * If no match is found, it defaults to 'text' for the language id, 'text/plain' for the MIME type, and '.txt' for the extension.
+ */
+export function getInfoByLang(extension: string) {
+ if (extension === 'vtl') {
+ return {
+ lang: 'html',
+ mimeType: 'text/x-velocity',
+ extension: '.vtl'
+ };
+ }
+
+ const language = monaco.languages
+ .getLanguages()
+ .find((language) => language.extensions?.includes(`.${extension}`));
+
+ return {
+ lang: language?.id || 'text',
+ mimeType: language?.mimetypes?.[0] || 'text/plain',
+ extension: language?.extensions?.[0] || '.txt'
+ };
+}
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.html
index 96d22c077ade..f1b9c20589ff 100644
--- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.html
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.html
@@ -8,20 +8,25 @@
- {{ variable.key }} - {{ variable.value }}
+ {{ variable.key }}
+ {{ variable.value }}
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.scss
index 4cc0d6d16af6..45e836ab6b05 100644
--- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.scss
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.scss
@@ -20,7 +20,7 @@
.dot-wysiwyg__language-selector {
position: relative;
- min-width: 12rem;
+ min-width: 16rem;
}
.dot-wysiwyg__search-icon {
@@ -32,6 +32,25 @@
color: $color-palette-primary-500;
}
+ .dot-wysiwyg__language-item {
+ display: flex;
+ gap: $spacing-1;
+ max-width: 100%;
+
+ .dot-wysiwyg__language-key {
+ flex: 0 1 auto;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ direction: rtl;
+ }
+
+ .dot-wysiwyg__language-value {
+ flex: 0 0 auto;
+ font-weight: $font-weight-medium-bold;
+ }
+ }
+
::ng-deep {
// Hide the promotion button
// This button redirect to the tinyMCE premium page
@@ -52,10 +71,17 @@
}
p-dropdown {
- min-width: 12rem;
+ min-width: 10rem;
}
+
p-autocomplete .p-inputwrapper {
padding-right: 35px;
}
+
+ .p-autocomplete-panel {
+ z-index: 1000;
+ position: fixed;
+ max-width: 16rem;
+ }
}
}
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.spec.ts
index 043d3cd9a6a3..8542d26679d5 100644
--- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.spec.ts
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.spec.ts
@@ -12,16 +12,21 @@ import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { ControlContainer, FormsModule } from '@angular/forms';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { ActivatedRoute } from '@angular/router';
import { ConfirmationService } from 'primeng/api';
import { ConfirmDialogModule } from 'primeng/confirmdialog';
import { DropdownModule } from 'primeng/dropdown';
import {
+ DotContentTypeService,
+ DotHttpErrorManagerService,
DotLanguagesService,
DotLanguageVariableEntry,
DotPropertiesService,
- DotUploadFileService
+ DotUploadFileService,
+ DotWorkflowActionsFireService,
+ DotWorkflowsActionsService
} from '@dotcms/data-access';
import { DotMessagePipe, mockMatchMedia, monacoMock } from '@dotcms/utils-testing';
@@ -41,6 +46,8 @@ import {
WYSIWYG_MOCK
} from './mocks/dot-edit-content-wysiwyg-field.mock';
+import { DotEditContentStore } from '../../feature/edit-content/store/edit-content.store';
+import { DotEditContentService } from '../../services/dot-edit-content.service';
import { createFormGroupDirectiveMock } from '../../utils/mocks';
const mockLanguageVariables: Record = {
@@ -124,9 +131,16 @@ describe('DotEditContentWYSIWYGFieldComponent', () => {
providers: [
mockProvider(DotLanguagesService),
mockProvider(DotUploadFileService),
+ mockProvider(DotWorkflowsActionsService),
+ mockProvider(DotWorkflowActionsFireService),
+ mockProvider(DotContentTypeService),
+ mockProvider(DotEditContentService),
+ mockProvider(DotHttpErrorManagerService),
+ mockProvider(ActivatedRoute),
provideHttpClient(),
provideHttpClientTesting(),
- ConfirmationService
+ ConfirmationService,
+ DotEditContentStore
]
});
@@ -200,4 +214,19 @@ describe('DotEditContentWYSIWYGFieldComponent', () => {
expect(spectator.query(byTestId('language-variable-selector'))).toBeTruthy();
});
});
+
+ describe('sidebar closed state', () => {
+ it('should add sidebar-closed class when sidebar is closed', () => {
+ const store = spectator.inject(DotEditContentStore);
+
+ spectator.detectChanges();
+ const element = spectator.query(byTestId('language-variable-selector'));
+ expect(element.classList).not.toContain('dot-wysiwyg__sidebar-closed');
+
+ store.toggleSidebar();
+ spectator.detectChanges();
+
+ expect(element.classList).toContain('dot-wysiwyg__sidebar-closed');
+ });
+ });
});
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.ts
index bc76e9127399..f8a04d520cf1 100644
--- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.ts
+++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component.ts
@@ -15,11 +15,7 @@ import {
import { FormsModule } from '@angular/forms';
import { ConfirmationService } from 'primeng/api';
-import {
- AutoCompleteCompleteEvent,
- AutoCompleteModule,
- AutoCompleteSelectEvent
-} from 'primeng/autocomplete';
+import { AutoComplete, AutoCompleteModule, AutoCompleteSelectEvent } from 'primeng/autocomplete';
import { ConfirmDialogModule } from 'primeng/confirmdialog';
import { DropdownModule } from 'primeng/dropdown';
import { InputGroupModule } from 'primeng/inputgroup';
@@ -42,11 +38,16 @@ import {
} from './dot-edit-content-wysiwyg-field.constant';
import { shouldUseDefaultEditor } from './dot-edit-content-wysiwyg-field.utils';
+import { DotEditContentStore } from '../../feature/edit-content/store/edit-content.store';
+
interface LanguageVariable {
key: string;
value: string;
}
+// Quantity of language variables to show in the autocomplete
+const MAX_LANGUAGES_SUGGESTIONS = 20;
+
/**
* Component representing a WYSIWYG (What You See Is What You Get) editor field for editing content in DotCMS.
* Allows users to edit content using either the TinyMCE or Monaco editor, based on the content type and properties.
@@ -75,15 +76,41 @@ interface LanguageVariable {
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DotEditContentWYSIWYGFieldComponent implements AfterViewInit {
+ /**
+ * Clear the autocomplete when the overlay is hidden.
+ */
+ onHideOverlay() {
+ this.$autoComplete()?.clear();
+ }
+
+ /**
+ * Signal to get the TinyMCE component.
+ */
$tinyMCEComponent: Signal = viewChild(
DotWysiwygTinymceComponent
);
+
+ /**
+ * Signal to get the Monaco component.
+ */
$monacoComponent: Signal =
viewChild(DotWysiwygMonacoComponent);
+ /**
+ * Signal to get the autocomplete component.
+ */
+ $autoComplete = viewChild(AutoComplete);
+
#confirmationService = inject(ConfirmationService);
#dotMessageService = inject(DotMessageService);
#dotLanguagesService = inject(DotLanguagesService);
+ #store = inject(DotEditContentStore);
+
+ /**
+ * This variable represents if the sidebar is closed.
+ */
+ $sidebarClosed = computed(() => !this.#store.showSidebar());
+
/**
* This variable represents a required content type field in DotCMS.
*/
@@ -159,21 +186,26 @@ export class DotEditContentWYSIWYGFieldComponent implements AfterViewInit {
*/
$languageVariables = signal([]);
- /**
- * Signal to track if the user has interacted with the autocomplete.
- * This is used to determine if the language variables should be loaded, and to avoid unnecessary loading.
- */
- $hasInteracted = signal(false);
-
/**
* Signal to store the selected item from the autocomplete.
*/
- $selectedItem = signal(null);
+ $selectedItem = model('');
/**
- * Signal to store the search query from the autocomplete.
+ * Computed property to filter the language variables based on the search query.
*/
- $searchQuery = signal('');
+ $filteredSuggestions = computed(() => {
+ const term = this.$selectedItem()?.toLowerCase();
+ const languageVariables = this.$languageVariables();
+
+ if (!term) {
+ return [];
+ }
+
+ return languageVariables
+ .filter((variable) => variable.key.toLowerCase().includes(term))
+ .slice(0, MAX_LANGUAGES_SUGGESTIONS);
+ });
ngAfterViewInit(): void {
// Assign the selected editor value
@@ -219,30 +251,28 @@ export class DotEditContentWYSIWYGFieldComponent implements AfterViewInit {
/**
* Search for language variables.
*
- * @param {AutoCompleteCompleteEvent} event - The event object containing the search query.
* @return {void}
*/
- search(event: AutoCompleteCompleteEvent) {
- if (event.query) {
- this.$searchQuery.set(event.query);
- }
-
+ loadSuggestions() {
if (this.$languageVariables().length === 0) {
this.getLanguageVariables();
+ } else {
+ /**
+ * Set the autocomplete loading to false and show the overlay if there are suggestions.
+ * this for handle the bug in the autocomplete and show the loading icon when there are suggestions
+ */
+ const autocomplete = this.$autoComplete();
+ if (autocomplete) {
+ autocomplete.loading = false;
+ if (this.$filteredSuggestions().length > 0) {
+ autocomplete.overlayVisible = true;
+ }
+
+ autocomplete.cd.markForCheck();
+ }
}
}
- /**
- * Computed property to filter the language variables based on the search query.
- */
- $filteredSuggestions = computed(() => {
- const term = this.$searchQuery().toLowerCase();
-
- return this.$languageVariables()
- .filter((variable) => variable.key.toLowerCase().includes(term))
- .slice(0, 10);
- });
-
/**
* Handles the selection of a language variable from the autocomplete.
*
@@ -250,29 +280,38 @@ export class DotEditContentWYSIWYGFieldComponent implements AfterViewInit {
* @return {void}
*/
onSelectLanguageVariable($event: AutoCompleteSelectEvent) {
+ const { value } = $event;
+
if (this.$displayedEditor() === AvailableEditor.TinyMCE) {
const tinyMCE = this.$tinyMCEComponent();
if (tinyMCE) {
- tinyMCE.insertContent(`$text.get('${$event.value.key}')`);
+ tinyMCE.insertContent(`$text.get('${value.key}')`);
+ this.resetAutocomplete();
} else {
console.warn('TinyMCE component is not available');
}
} else if (this.$displayedEditor() === AvailableEditor.Monaco) {
const monaco = this.$monacoComponent();
if (monaco) {
- monaco.insertContent(`$text.get('${$event.value.key}')`);
+ monaco.insertContent(`$text.get('${value.key}')`);
+ this.resetAutocomplete();
} else {
console.warn('Monaco component is not available');
}
}
+ }
- this.$selectedItem.set(null);
+ /**
+ * Resets the autocomplete state.
+ */
+ private resetAutocomplete() {
+ this.$autoComplete()?.clear();
}
/**
* Fetches language variables from the DotCMS Languages API and formats them for use in the autocomplete.
*/
- private getLanguageVariables() {
+ getLanguageVariables() {
// TODO: This is a temporary solution to get the language variables from the DotCMS Languages API.
// We need a way to get the current language from the contentlet.
this.#dotLanguagesService
diff --git a/core-web/libs/edit-content/tsconfig.json b/core-web/libs/edit-content/tsconfig.json
index 56e37bfd9172..2c3af383b4f6 100644
--- a/core-web/libs/edit-content/tsconfig.json
+++ b/core-web/libs/edit-content/tsconfig.json
@@ -5,7 +5,8 @@
"forceConsistentCasingInFileNames": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
- "noFallthroughCasesInSwitch": true
+ "noFallthroughCasesInSwitch": true,
+ "strict": false
},
"files": [],
"include": [],
diff --git a/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/dot-analytics-search/dot-analytics-search.component.html b/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/dot-analytics-search/dot-analytics-search.component.html
index c8bf35ad6e6c..4662fefafe90 100644
--- a/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/dot-analytics-search/dot-analytics-search.component.html
+++ b/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/dot-analytics-search/dot-analytics-search.component.html
@@ -1,27 +1,44 @@
-
-
-
{{ 'analytics.search.query' | dm }}
-
-
-
-
-
-
-
-
-
{{ 'analytics.search.results' | dm }}
-
-
-
+
+
+
+
+
{{ 'analytics.search.query' | dm }}
+
+
+
+
+
+
+
+
+
+
+
+ @if ($results() === null) {
+
+ } @else {
+
+
+
+ }
+
+
diff --git a/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/dot-analytics-search/dot-analytics-search.component.scss b/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/dot-analytics-search/dot-analytics-search.component.scss
index 943ebe1039ce..660d1c4d0d71 100644
--- a/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/dot-analytics-search/dot-analytics-search.component.scss
+++ b/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/dot-analytics-search/dot-analytics-search.component.scss
@@ -6,40 +6,56 @@
overflow: auto;
background: $white;
display: flex;
- flex-direction: row;
+ flex-direction: column;
padding: $spacing-3 $spacing-4;
gap: $spacing-3;
+ section {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding: $spacing-3;
+ gap: $spacing-3;
+ }
+
+ dot-empty-container {
+ flex-grow: 1;
+ }
+
+ p-splitter {
+ height: 100%;
+
+ ::ng-deep .p-splitter {
+ height: 100%;
+ }
+ }
+
ngx-monaco-editor {
- height: 300px;
- width: 100%;
+ flex-grow: 1;
border: $field-border-size solid $color-palette-gray-400;
border-radius: $border-radius-md;
- display: flex;
- flex-grow: 1;
}
- .content-analytics__header {
+ .content-analytics__query-header {
display: flex;
align-items: center;
justify-content: space-between;
- margin-bottom: $spacing-3;
h4 {
- font-size: $font-size-lmd;
- margin: $spacing-3 0;
+ font-size: $font-size-md;
+ margin: 0;
+ }
+
+ button {
+ color: $color-palette-gray-500;
+ visibility: hidden;
}
}
.content-analytics__actions {
display: flex;
gap: $spacing-3;
- }
-
- section {
- flex: 1;
- display: flex;
- flex-direction: column;
+ justify-content: flex-end;
}
.content-analytics__results {
diff --git a/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/dot-analytics-search/dot-analytics-search.component.spec.ts b/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/dot-analytics-search/dot-analytics-search.component.spec.ts
index 0d44d1d2ba2f..5ca4d3de00a1 100644
--- a/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/dot-analytics-search/dot-analytics-search.component.spec.ts
+++ b/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/dot-analytics-search/dot-analytics-search.component.spec.ts
@@ -53,8 +53,11 @@ describe('DotAnalyticsSearchComponent', () => {
it('should call getResults with valid JSON', () => {
const getResultsSpy = jest.spyOn(store, 'getResults');
+
spectator.component.queryEditor = '{"measures": ["request.count"]}';
+ spectator.component.handleQueryChange('{"measures": ["request.count"]}');
spectator.detectChanges();
+
const button = spectator.query(byTestId('run-query')) as HTMLButtonElement;
spectator.click(button);
@@ -62,12 +65,13 @@ describe('DotAnalyticsSearchComponent', () => {
});
it('should not call getResults with invalid JSON', () => {
- const getResultsSpy = jest.spyOn(store, 'getResults');
spectator.component.queryEditor = 'invalid json';
+ spectator.component.handleQueryChange('invalid json');
spectator.detectChanges();
+
const button = spectator.query(byTestId('run-query')) as HTMLButtonElement;
spectator.click(button);
- expect(getResultsSpy).not.toHaveBeenCalled();
+ expect(button).toBeDisabled();
});
});
diff --git a/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/dot-analytics-search/dot-analytics-search.component.ts b/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/dot-analytics-search/dot-analytics-search.component.ts
index 8371e69d3cfb..ce3349a5ebb8 100644
--- a/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/dot-analytics-search/dot-analytics-search.component.ts
+++ b/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/dot-analytics-search/dot-analytics-search.component.ts
@@ -2,14 +2,17 @@ import { JsonObject } from '@angular-devkit/core';
import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor';
import { CommonModule } from '@angular/common';
-import { Component, computed, inject } from '@angular/core';
+import { Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { ButtonDirective } from 'primeng/button';
+import { DropdownModule } from 'primeng/dropdown';
+import { SplitterModule } from 'primeng/splitter';
+import { TooltipModule } from 'primeng/tooltip';
-import { DotAnalyticsSearchService } from '@dotcms/data-access';
-import { DotMessagePipe } from '@dotcms/ui';
+import { DotAnalyticsSearchService, DotMessageService } from '@dotcms/data-access';
+import { DotEmptyContainerComponent, DotMessagePipe, PrincipalConfiguration } from '@dotcms/ui';
import {
ANALYTICS_MONACO_EDITOR_OPTIONS,
@@ -22,7 +25,17 @@ import { DotAnalyticsSearchStore } from '../store/dot-analytics-search.store';
@Component({
selector: 'lib-dot-analytics-search',
standalone: true,
- imports: [CommonModule, DotMessagePipe, ButtonDirective, MonacoEditorModule, FormsModule],
+ imports: [
+ CommonModule,
+ DotMessagePipe,
+ ButtonDirective,
+ MonacoEditorModule,
+ FormsModule,
+ SplitterModule,
+ DropdownModule,
+ DotEmptyContainerComponent,
+ TooltipModule
+ ],
providers: [DotAnalyticsSearchStore, DotAnalyticsSearchService],
templateUrl: './dot-analytics-search.component.html',
styleUrl: './dot-analytics-search.component.scss'
@@ -34,12 +47,34 @@ export class DotAnalyticsSearchComponent {
readonly store = inject(DotAnalyticsSearchStore);
+ /**
+ * Represents the DotMessageService instance.
+ */
+ readonly #dotMessageService = inject(DotMessageService);
+
+ /**
+ * The content of the query editor.
+ */
queryEditor = '';
+ /**
+ * Signal representing the empty state configuration.
+ */
+ $emptyState = signal({
+ title: this.#dotMessageService.get('analytics.search.no.results'),
+ icon: 'pi-search',
+ subtitle: this.#dotMessageService.get('analytics.search.execute.results')
+ });
+
+ /**
+ * Signal representing whether the query editor content is valid JSON.
+ */
+ $isValidJson = signal(false);
+
/**
* Computed property to get the results from the store and format them as a JSON string.
*/
- results$ = computed(() => {
+ $results = computed(() => {
const results = this.store.results();
return results ? JSON.stringify(results, null, 2) : null;
@@ -59,8 +94,16 @@ export class DotAnalyticsSearchComponent {
const value = isValidJson(this.queryEditor);
if (value) {
this.store.getResults(value as JsonObject);
- } else {
- //TODO: handle query error.
}
}
+
+ /**
+ * Handles changes to the query editor content.
+ * Updates the $isValidJson signal based on the validity of the JSON.
+ *
+ * @param value - The new content of the query editor.
+ */
+ handleQueryChange(value: string) {
+ this.$isValidJson.set(!!isValidJson(value));
+ }
}
diff --git a/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/store/dot-analytics-search.store.spec.ts b/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/store/dot-analytics-search.store.spec.ts
index 8cba098c245a..77bb6427e3d9 100644
--- a/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/store/dot-analytics-search.store.spec.ts
+++ b/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/store/dot-analytics-search.store.spec.ts
@@ -56,7 +56,7 @@ describe('DotAnalyticsSearchStore', () => {
it('should initialize with default state', () => {
expect(store.isEnterprise()).toEqual(false);
expect(store.results()).toEqual(null);
- expect(store.query()).toEqual({ value: null, type: AnalyticsQueryType.DEFAULT });
+ expect(store.query()).toEqual({ value: null, type: AnalyticsQueryType.CUBE });
expect(store.state()).toEqual(ComponentStatus.INIT);
expect(store.errorMessage()).toEqual('');
});
@@ -74,7 +74,7 @@ describe('DotAnalyticsSearchStore', () => {
expect(dotAnalyticsSearchService.get).toHaveBeenCalledWith(
{ query: 'test' },
- AnalyticsQueryType.DEFAULT
+ AnalyticsQueryType.CUBE
);
expect(store.results()).toEqual(mockResponse);
diff --git a/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/store/dot-analytics-search.store.ts b/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/store/dot-analytics-search.store.ts
index 58c15c081f4d..75339a88a8bf 100644
--- a/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/store/dot-analytics-search.store.ts
+++ b/core-web/libs/portlets/dot-analytics-search/portlet/src/lib/store/dot-analytics-search.store.ts
@@ -12,6 +12,9 @@ import { switchMap, tap } from 'rxjs/operators';
import { DotAnalyticsSearchService, DotHttpErrorManagerService } from '@dotcms/data-access';
import { AnalyticsQueryType, ComponentStatus } from '@dotcms/dotcms-models';
+/**
+ * Type definition for the state of the DotContentAnalytics.
+ */
export type DotContentAnalyticsState = {
isEnterprise: boolean;
results: JsonObject[] | null;
@@ -23,17 +26,23 @@ export type DotContentAnalyticsState = {
errorMessage: string;
};
+/**
+ * Initial state for the DotContentAnalytics.
+ */
export const initialState: DotContentAnalyticsState = {
isEnterprise: false,
results: null,
query: {
value: null,
- type: AnalyticsQueryType.DEFAULT
+ type: AnalyticsQueryType.CUBE
},
state: ComponentStatus.INIT,
errorMessage: ''
};
+/**
+ * Store for managing the state and actions related to DotAnalyticsSearch.
+ */
export const DotAnalyticsSearchStore = signalStore(
withState(initialState),
withMethods(
@@ -43,8 +52,8 @@ export const DotAnalyticsSearchStore = signalStore(
dotHttpErrorManagerService = inject(DotHttpErrorManagerService)
) => ({
/**
- * Set if initial state, including, the user is enterprise or not
- * @param isEnterprise
+ * Initializes the state with the given enterprise status.
+ * @param isEnterprise - Boolean indicating if the user is an enterprise user.
*/
initLoad: (isEnterprise: boolean) => {
patchState(store, {
@@ -52,6 +61,11 @@ export const DotAnalyticsSearchStore = signalStore(
isEnterprise
});
},
+
+ /**
+ * Fetches the results based on the current query.
+ * @param query - The query to fetch results for.
+ */
getResults: rxMethod(
pipe(
tap(() => {
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.spec.ts
index 7ba1beafda77..6cf8e165cb89 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.spec.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.spec.ts
@@ -15,6 +15,7 @@ import { By } from '@angular/platform-browser';
import { MessageService } from 'primeng/api';
import { Dialog } from 'primeng/dialog';
+import { CLIENT_ACTIONS } from '@dotcms/client';
import {
DotAlertConfirmService,
DotContentTypeService,
@@ -178,11 +179,12 @@ describe('DotEmaDialogComponent', () => {
event: expect.objectContaining({
isTrusted: false
}),
- payload: PAYLOAD_MOCK,
+ actionPayload: PAYLOAD_MOCK,
form: {
status: FormStatus.PRISTINE,
isTranslation: false
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
});
@@ -200,11 +202,12 @@ describe('DotEmaDialogComponent', () => {
name: NG_CUSTOM_EVENTS.DIALOG_CLOSED
}
}),
- payload: PAYLOAD_MOCK,
+ actionPayload: PAYLOAD_MOCK,
form: {
status: FormStatus.PRISTINE,
isTranslation: false
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
});
});
@@ -331,7 +334,7 @@ describe('DotEmaDialogComponent', () => {
containerId: PAYLOAD_MOCK.container.identifier,
acceptTypes: PAYLOAD_MOCK.container.acceptTypes,
language_id: PAYLOAD_MOCK.language_id,
- payload: PAYLOAD_MOCK
+ actionPayload: PAYLOAD_MOCK
});
});
@@ -352,7 +355,7 @@ describe('DotEmaDialogComponent', () => {
containerId: PAYLOAD_MOCK.container.identifier,
acceptTypes: DotCMSBaseTypesContentTypes.WIDGET,
language_id: PAYLOAD_MOCK.language_id,
- payload: PAYLOAD_MOCK
+ actionPayload: PAYLOAD_MOCK
});
});
@@ -379,10 +382,7 @@ describe('DotEmaDialogComponent', () => {
component.editContentlet(PAYLOAD_MOCK.contentlet);
- expect(editContentletSpy).toHaveBeenCalledWith({
- inode: PAYLOAD_MOCK.contentlet.inode,
- title: PAYLOAD_MOCK.contentlet.title
- });
+ expect(editContentletSpy).toHaveBeenCalledWith(PAYLOAD_MOCK.contentlet);
});
it('should trigger editVTLContentlet in the store', () => {
@@ -418,13 +418,13 @@ describe('DotEmaDialogComponent', () => {
component.createContentlet({
url: 'https://demo.dotcms.com/jsp.jsp',
contentType: 'test',
- payload: PAYLOAD_MOCK
+ actionPayload: PAYLOAD_MOCK
});
expect(createContentletSpy).toHaveBeenCalledWith({
contentType: 'test',
url: 'https://demo.dotcms.com/jsp.jsp',
- payload: PAYLOAD_MOCK
+ actionPayload: PAYLOAD_MOCK
});
});
@@ -437,12 +437,12 @@ describe('DotEmaDialogComponent', () => {
component.createContentletFromPalette({
variable: 'test',
name: 'test',
- payload: PAYLOAD_MOCK
+ actionPayload: PAYLOAD_MOCK
});
expect(createContentletFromPalletSpy).toHaveBeenCalledWith({
name: 'test',
- payload: PAYLOAD_MOCK,
+ actionPayload: PAYLOAD_MOCK,
variable: 'test'
});
});
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.ts
index 3ddbe62b6955..28230b862661 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/dot-ema-dialog.component.ts
@@ -40,6 +40,7 @@ import {
CreateFromPaletteAction,
DialogAction,
DotPage,
+ EditContentletPayload,
VTLFile
} from '../../shared/models';
import { EmaFormSelectorComponent } from '../ema-form-selector/ema-form-selector.component';
@@ -93,22 +94,22 @@ export class DotEmaDialogComponent {
/**
* Add contentlet
*
- * @param {ActionPayload} payload
+ * @param {ActionPayload} actionPayload
* @memberof EditEmaEditorComponent
*/
- addContentlet(payload: ActionPayload): void {
+ addContentlet(actionPayload: ActionPayload): void {
this.store.addContentlet({
- containerId: payload.container.identifier,
- acceptTypes: payload.container.acceptTypes ?? '*',
- language_id: payload.language_id,
- payload
+ containerId: actionPayload.container.identifier,
+ acceptTypes: actionPayload.container.acceptTypes ?? '*',
+ language_id: actionPayload.language_id,
+ actionPayload
});
}
/**
* Add Form
*
- * @param {ActionPayload} _payload
+ * @param {ActionPayload} actionPayload
* @memberof EditEmaEditorComponent
*/
addForm(payload: ActionPayload): void {
@@ -118,29 +119,26 @@ export class DotEmaDialogComponent {
/**
* Add Widget
*
- * @param {ActionPayload} payload
+ * @param {ActionPayload} actionPayload
* @memberof EditEmaEditorComponent
*/
- addWidget(payload: ActionPayload): void {
+ addWidget(actionPayload: ActionPayload): void {
this.store.addContentlet({
- containerId: payload.container.identifier,
+ containerId: actionPayload.container.identifier,
acceptTypes: DotCMSBaseTypesContentTypes.WIDGET,
- language_id: payload.language_id,
- payload
+ language_id: actionPayload.language_id,
+ actionPayload
});
}
/**
* Edit contentlet
*
- * @param {Partial} contentlet
+ * @param {EditContentletPayload} contentlet
* @memberof DotEmaDialogComponent
*/
- editContentlet(contentlet: Partial) {
- this.store.editContentlet({
- inode: contentlet.inode,
- title: contentlet.title
- });
+ editContentlet(payload: EditContentletPayload) {
+ this.store.editContentlet(payload);
}
/**
@@ -185,11 +183,11 @@ export class DotEmaDialogComponent {
* @param {CreateContentletAction} { url, contentType, payload }
* @memberof DotEmaDialogComponent
*/
- createContentlet({ url, contentType, payload }: CreateContentletAction) {
+ createContentlet({ url, contentType, actionPayload }: CreateContentletAction) {
this.store.createContentlet({
url,
contentType,
- payload
+ actionPayload
});
}
@@ -199,11 +197,17 @@ export class DotEmaDialogComponent {
* @param {CreateFromPaletteAction} { variable, name, payload }
* @memberof DotEmaDialogComponent
*/
- createContentletFromPalette({ variable, name, payload }: CreateFromPaletteAction) {
+ createContentletFromPalette({
+ variable,
+ name,
+ actionPayload,
+ language_id
+ }: CreateFromPaletteAction) {
this.store.createContentletFromPalette({
variable,
name,
- payload
+ actionPayload,
+ language_id
});
}
@@ -333,7 +337,7 @@ export class DotEmaDialogComponent {
case NG_CUSTOM_EVENTS.EDIT_CONTENTLET_UPDATED: {
// The edit content emits this for savings when translating a page and does not emit anything when changing the content
- if (this.dialogState().editContentForm.isTranslation) {
+ if (this.dialogState().form.isTranslation) {
this.store.setSaved();
if (event.detail.payload.isMoveAction) {
@@ -405,8 +409,8 @@ export class DotEmaDialogComponent {
}
private emitAction(event: CustomEvent) {
- const { payload, editContentForm } = this.dialogState();
+ const { actionPayload, form, clientAction } = this.dialogState();
- this.action.emit({ event, payload, form: editContentForm });
+ this.action.emit({ event, actionPayload, form, clientAction });
}
}
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/store/dot-ema-dialog.store.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/store/dot-ema-dialog.store.spec.ts
index e7d6d8868e9f..84966322f09e 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/store/dot-ema-dialog.store.spec.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/store/dot-ema-dialog.store.spec.ts
@@ -2,6 +2,7 @@ import { expect, it, describe } from '@jest/globals';
import { SpectatorService, createServiceFactory } from '@ngneat/spectator/jest';
import { of } from 'rxjs';
+import { CLIENT_ACTIONS } from '@dotcms/client';
import { DotMessageService } from '@dotcms/data-access';
import { MockDotMessageService } from '@dotcms/utils-testing';
@@ -44,10 +45,11 @@ describe('DotEmaDialogStoreService', () => {
header: '',
type: null,
status: DialogStatus.LOADING,
- editContentForm: {
+ form: {
status: FormStatus.PRISTINE,
isTranslation: false
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
done();
});
@@ -57,7 +59,7 @@ describe('DotEmaDialogStoreService', () => {
spectator.service.setDirty();
spectator.service.dialogState$.subscribe((state) => {
- expect(state.editContentForm.status).toBe(FormStatus.DIRTY);
+ expect(state.form.status).toBe(FormStatus.DIRTY);
done();
});
});
@@ -66,7 +68,7 @@ describe('DotEmaDialogStoreService', () => {
spectator.service.setSaved();
spectator.service.dialogState$.subscribe((state) => {
- expect(state.editContentForm.status).toBe(FormStatus.SAVED);
+ expect(state.form.status).toBe(FormStatus.SAVED);
done();
});
});
@@ -82,11 +84,12 @@ describe('DotEmaDialogStoreService', () => {
status: DialogStatus.IDLE,
header: '',
type: null,
- payload: undefined,
- editContentForm: {
+ actionPayload: undefined,
+ form: {
status: FormStatus.PRISTINE,
isTranslation: false
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
done();
});
@@ -114,10 +117,44 @@ describe('DotEmaDialogStoreService', () => {
status: DialogStatus.LOADING,
header: 'test',
type: 'content',
- editContentForm: {
+ form: {
status: FormStatus.PRISTINE,
isTranslation: false
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
+ });
+ done();
+ });
+ });
+
+ it('should initialize with edit iframe properties and with clientAction', (done) => {
+ spectator.service.editContentlet({
+ inode: '123',
+ title: 'test',
+ clientAction: CLIENT_ACTIONS.EDIT_CONTENTLET
+ });
+
+ const queryParams = new URLSearchParams({
+ p_p_id: 'content',
+ p_p_action: '1',
+ p_p_state: 'maximized',
+ p_p_mode: 'view',
+ _content_struts_action: '/ext/contentlet/edit_contentlet',
+ _content_cmd: 'edit',
+ inode: '123'
+ });
+
+ spectator.service.dialogState$.subscribe((state) => {
+ expect(state).toEqual({
+ url: LAYOUT_URL + '?' + queryParams.toString(),
+ status: DialogStatus.LOADING,
+ header: 'test',
+ type: 'content',
+ form: {
+ status: FormStatus.PRISTINE,
+ isTranslation: false
+ },
+ clientAction: CLIENT_ACTIONS.EDIT_CONTENTLET
});
done();
});
@@ -145,10 +182,11 @@ describe('DotEmaDialogStoreService', () => {
status: DialogStatus.LOADING,
header: 'test',
type: 'content',
- editContentForm: {
+ form: {
status: FormStatus.PRISTINE,
isTranslation: false
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
done();
});
@@ -159,7 +197,7 @@ describe('DotEmaDialogStoreService', () => {
containerId: '1234',
acceptTypes: 'test',
language_id: '1',
- payload: PAYLOAD_MOCK
+ actionPayload: PAYLOAD_MOCK
});
spectator.service.dialogState$.subscribe((state) => {
@@ -168,11 +206,12 @@ describe('DotEmaDialogStoreService', () => {
header: 'Search Content',
type: 'content',
status: DialogStatus.LOADING,
- payload: PAYLOAD_MOCK,
- editContentForm: {
+ actionPayload: PAYLOAD_MOCK,
+ form: {
status: FormStatus.PRISTINE,
isTranslation: false
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
done();
});
@@ -187,11 +226,12 @@ describe('DotEmaDialogStoreService', () => {
status: DialogStatus.LOADING,
url: null,
type: 'form',
- payload: PAYLOAD_MOCK,
- editContentForm: {
+ actionPayload: PAYLOAD_MOCK,
+ form: {
status: FormStatus.PRISTINE,
isTranslation: false
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
done();
});
@@ -201,7 +241,7 @@ describe('DotEmaDialogStoreService', () => {
spectator.service.createContentlet({
contentType: 'test',
url: 'some/really/long/url',
- payload: PAYLOAD_MOCK
+ actionPayload: PAYLOAD_MOCK
});
spectator.service.dialogState$.subscribe((state) => {
@@ -210,11 +250,12 @@ describe('DotEmaDialogStoreService', () => {
status: DialogStatus.LOADING,
header: 'Create test',
type: 'content',
- payload: PAYLOAD_MOCK,
- editContentForm: {
+ actionPayload: PAYLOAD_MOCK,
+ form: {
status: FormStatus.PRISTINE,
isTranslation: false
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
done();
});
@@ -224,7 +265,7 @@ describe('DotEmaDialogStoreService', () => {
spectator.service.createContentlet({
url: 'some/really/long/url',
contentType: 'Blog Posts',
- payload: PAYLOAD_MOCK
+ actionPayload: PAYLOAD_MOCK
});
spectator.service.dialogState$.subscribe((state) => {
@@ -232,7 +273,7 @@ describe('DotEmaDialogStoreService', () => {
expect(state.status).toBe(DialogStatus.LOADING);
expect(state.url).toBe('some/really/long/url');
expect(state.type).toBe('content');
- expect(state.payload).toEqual(PAYLOAD_MOCK);
+ expect(state.actionPayload).toEqual(PAYLOAD_MOCK);
done();
});
});
@@ -245,7 +286,8 @@ describe('DotEmaDialogStoreService', () => {
spectator.service.createContentletFromPalette({
variable: 'blogPost',
name: 'Blog',
- payload: PAYLOAD_MOCK
+ actionPayload: PAYLOAD_MOCK,
+ language_id: 2
});
spectator.service.dialogState$.subscribe((state) => {
@@ -254,11 +296,11 @@ describe('DotEmaDialogStoreService', () => {
expect(state.url).toBe('https://demo.dotcms.com/jsp.jsp');
expect(state.type).toBe('content');
- expect(state.payload).toEqual(PAYLOAD_MOCK);
+ expect(state.actionPayload).toEqual(PAYLOAD_MOCK);
done();
});
- expect(dotActionUrlService.getCreateContentletUrl).toHaveBeenCalledWith('blogPost');
+ expect(dotActionUrlService.getCreateContentletUrl).toHaveBeenCalledWith('blogPost', 2);
});
it('should initialize with loading iframe properties', (done) => {
@@ -270,10 +312,11 @@ describe('DotEmaDialogStoreService', () => {
status: DialogStatus.LOADING,
header: 'test',
type: 'content',
- editContentForm: {
+ form: {
status: FormStatus.PRISTINE,
isTranslation: false
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
done();
});
@@ -291,10 +334,11 @@ describe('DotEmaDialogStoreService', () => {
status: DialogStatus.LOADING,
header: 'test',
type: 'content',
- editContentForm: {
+ form: {
status: FormStatus.PRISTINE,
isTranslation: false
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
done();
});
@@ -335,10 +379,11 @@ describe('DotEmaDialogStoreService', () => {
status: DialogStatus.LOADING,
header: 'test',
type: 'content',
- editContentForm: {
+ form: {
status: FormStatus.PRISTINE,
isTranslation: true
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
});
});
@@ -379,10 +424,11 @@ describe('DotEmaDialogStoreService', () => {
status: DialogStatus.LOADING,
header: 'test',
type: 'content',
- editContentForm: {
+ form: {
status: FormStatus.PRISTINE,
isTranslation: true
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
});
});
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/store/dot-ema-dialog.store.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/store/dot-ema-dialog.store.ts
index 3c2353cd9109..b0a2eb001a9e 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/store/dot-ema-dialog.store.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/components/dot-ema-dialog/store/dot-ema-dialog.store.ts
@@ -5,6 +5,7 @@ import { Injectable, inject } from '@angular/core';
import { switchMap } from 'rxjs/operators';
+import { CLIENT_ACTIONS } from '@dotcms/client';
import { DotMessageService } from '@dotcms/data-access';
import { DotActionUrlService } from '../../../services/dot-action-url/dot-action-url.service';
@@ -12,6 +13,7 @@ import { LAYOUT_URL, CONTENTLET_SELECTOR_URL } from '../../../shared/consts';
import { DialogStatus, FormStatus } from '../../../shared/enums';
import {
ActionPayload,
+ AddContentletAction,
CreateContentletAction,
CreateFromPaletteAction,
DotPage,
@@ -27,10 +29,11 @@ export class DotEmaDialogStore extends ComponentStore {
url: '',
type: null,
status: DialogStatus.IDLE,
- editContentForm: {
+ form: {
status: FormStatus.PRISTINE,
isTranslation: false
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
}
@@ -48,21 +51,23 @@ export class DotEmaDialogStore extends ComponentStore {
readonly createContentletFromPalette = this.effect(
(contentTypeVariable$: Observable) => {
return contentTypeVariable$.pipe(
- switchMap(({ name, variable, payload }) => {
- return this.dotActionUrlService.getCreateContentletUrl(variable).pipe(
- tapResponse(
- (url) => {
- this.createContentlet({
- url,
- contentType: name,
- payload
- });
- },
- (e) => {
- console.error(e);
- }
- )
- );
+ switchMap(({ name, variable, actionPayload, language_id = 1 }) => {
+ return this.dotActionUrlService
+ .getCreateContentletUrl(variable, language_id)
+ .pipe(
+ tapResponse(
+ (url) => {
+ this.createContentlet({
+ url,
+ contentType: name,
+ actionPayload
+ });
+ },
+ (e) => {
+ console.error(e);
+ }
+ )
+ );
})
);
}
@@ -91,17 +96,17 @@ export class DotEmaDialogStore extends ComponentStore {
* @memberof DotEmaDialogStore
*/
readonly createContentlet = this.updater(
- (state, { url, contentType, payload }: CreateContentletAction) => {
+ (state, { url, contentType, actionPayload }: CreateContentletAction) => {
return {
...state,
- url: url,
+ url,
+ actionPayload,
header: this.dotMessageService.get(
'contenttypes.content.create.contenttype',
contentType
),
status: DialogStatus.LOADING,
- type: 'content',
- payload
+ type: 'content'
};
}
);
@@ -126,15 +131,18 @@ export class DotEmaDialogStore extends ComponentStore {
*
* @memberof DotEmaDialogStore
*/
- readonly editContentlet = this.updater((state, { inode, title }: EditContentletPayload) => {
- return {
- ...state,
- header: title,
- status: DialogStatus.LOADING,
- type: 'content',
- url: this.createEditContentletUrl(inode)
- };
- });
+ readonly editContentlet = this.updater(
+ (state, { inode, title, clientAction = CLIENT_ACTIONS.NOOP }: EditContentletPayload) => {
+ return {
+ ...state,
+ clientAction, //In case it is undefined we set it to "noop"
+ header: title,
+ status: DialogStatus.LOADING,
+ type: 'content',
+ url: this.createEditContentletUrl(inode)
+ };
+ }
+ );
/**
* This method is called when the user clicks on the edit URL Content Map button
@@ -168,7 +176,7 @@ export class DotEmaDialogStore extends ComponentStore {
status: DialogStatus.LOADING,
type: 'content',
url: this.createTranslatePageUrl(page, newLanguage),
- editContentForm: {
+ form: {
status: FormStatus.PRISTINE,
isTranslation: true
}
@@ -181,26 +189,16 @@ export class DotEmaDialogStore extends ComponentStore {
*
* @memberof DotEmaDialogStore
*/
- readonly addContentlet = this.updater(
- (
- state,
- data: {
- containerId: string;
- acceptTypes: string;
- language_id: string;
- payload: ActionPayload;
- }
- ) => {
- return {
- ...state,
- header: this.dotMessageService.get('edit.ema.page.dialog.header.search.content'),
- status: DialogStatus.LOADING,
- url: this.createAddContentletUrl(data),
- type: 'content',
- payload: data.payload
- };
- }
- );
+ readonly addContentlet = this.updater((state, data: AddContentletAction) => {
+ return {
+ ...state,
+ header: this.dotMessageService.get('edit.ema.page.dialog.header.search.content'),
+ status: DialogStatus.LOADING,
+ url: this.createAddContentletUrl(data),
+ type: 'content',
+ actionPayload: data.actionPayload
+ };
+ });
/**
* This method is called when the user make changes in the form
@@ -210,8 +208,8 @@ export class DotEmaDialogStore extends ComponentStore {
readonly setDirty = this.updater((state) => {
return {
...state,
- editContentForm: {
- ...state.editContentForm,
+ form: {
+ ...state.form,
status: FormStatus.DIRTY
}
};
@@ -225,8 +223,8 @@ export class DotEmaDialogStore extends ComponentStore {
readonly setSaved = this.updater((state) => {
return {
...state,
- editContentForm: {
- ...state.editContentForm,
+ form: {
+ ...state.form,
status: FormStatus.SAVED
}
};
@@ -244,7 +242,7 @@ export class DotEmaDialogStore extends ComponentStore {
status: DialogStatus.LOADING,
url: null,
type: 'form',
- payload
+ actionPayload: payload
};
});
@@ -260,11 +258,12 @@ export class DotEmaDialogStore extends ComponentStore {
header: '',
status: DialogStatus.IDLE,
type: null,
- payload: undefined,
- editContentForm: {
+ actionPayload: undefined,
+ form: {
status: FormStatus.PRISTINE,
isTranslation: false
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
};
});
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.spec.ts
index f932e1506a06..d7542cab4a7a 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.spec.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.spec.ts
@@ -20,6 +20,7 @@ import { ConfirmDialogModule } from 'primeng/confirmdialog';
import { DialogService } from 'primeng/dynamicdialog';
import { ToastModule } from 'primeng/toast';
+import { CLIENT_ACTIONS } from '@dotcms/client';
import {
DotContentletLockerService,
DotExperimentsService,
@@ -829,11 +830,12 @@ describe('DotEmaShellComponent', () => {
name: NG_CUSTOM_EVENTS.DIALOG_CLOSED
}
}),
- payload: PAYLOAD_MOCK,
+ actionPayload: PAYLOAD_MOCK,
form: {
status: FormStatus.DIRTY,
isTranslation: true
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
expect(router.navigate).toHaveBeenCalledWith([], {
@@ -871,11 +873,12 @@ describe('DotEmaShellComponent', () => {
name: NG_CUSTOM_EVENTS.DIALOG_CLOSED
}
}),
- payload: PAYLOAD_MOCK,
+ actionPayload: PAYLOAD_MOCK,
form: {
status: FormStatus.PRISTINE,
isTranslation: true
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
expect(router.navigate).toHaveBeenCalledWith([], {
@@ -914,11 +917,12 @@ describe('DotEmaShellComponent', () => {
name: NG_CUSTOM_EVENTS.DIALOG_CLOSED
}
}),
- payload: PAYLOAD_MOCK,
+ actionPayload: PAYLOAD_MOCK,
form: {
isTranslation: true,
status: FormStatus.SAVED
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
spectator.detectChanges();
@@ -955,11 +959,12 @@ describe('DotEmaShellComponent', () => {
}
}
}),
- payload: PAYLOAD_MOCK,
+ actionPayload: PAYLOAD_MOCK,
form: {
status: FormStatus.SAVED,
isTranslation: false
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
spectator.detectChanges();
@@ -985,11 +990,12 @@ describe('DotEmaShellComponent', () => {
}
}
}),
- payload: PAYLOAD_MOCK,
+ actionPayload: PAYLOAD_MOCK,
form: {
status: FormStatus.SAVED,
isTranslation: false
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
spectator.detectChanges();
@@ -1026,11 +1032,12 @@ describe('DotEmaShellComponent', () => {
}
}
}),
- payload: PAYLOAD_MOCK,
+ actionPayload: PAYLOAD_MOCK,
form: {
status: FormStatus.SAVED,
isTranslation: false
- }
+ },
+ clientAction: CLIENT_ACTIONS.NOOP
});
spectator.detectChanges();
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.spec.ts
index bf4f48df15cf..63e3a32cb453 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.spec.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.spec.ts
@@ -18,7 +18,7 @@ import { ConfirmationService, MessageService } from 'primeng/api';
import { ConfirmDialogModule } from 'primeng/confirmdialog';
import { DialogService } from 'primeng/dynamicdialog';
-import { CUSTOMER_ACTIONS } from '@dotcms/client';
+import { CLIENT_ACTIONS } from '@dotcms/client';
import {
DotAlertConfirmService,
DotContentTypeService,
@@ -458,6 +458,14 @@ describe('EditEmaEditorComponent', () => {
).not.toBeNull();
});
});
+
+ it('should set the client is ready to false when the component is destroyed', () => {
+ store.setIsClientReady(true);
+
+ spectator.component.ngOnDestroy();
+
+ expect(store.isClientReady()).toBe(false);
+ });
});
describe('customer actions', () => {
@@ -592,13 +600,56 @@ describe('EditEmaEditorComponent', () => {
);
});
+ it('should open a dialog to edit contentlet using custom action and trigger reload after saving', (done) => {
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ origin: HOST,
+ data: {
+ action: CLIENT_ACTIONS.EDIT_CONTENTLET,
+ payload: CONTENTLETS_MOCK[0]
+ }
+ })
+ );
+
+ spectator.detectComponentChanges();
+
+ const dialog = spectator.debugElement.query(
+ By.css("[data-testId='ema-dialog']")
+ );
+
+ const pDialog = dialog.query(By.css('p-dialog'));
+
+ expect(pDialog.attributes['ng-reflect-visible']).toBe('true');
+
+ const iframe = spectator.debugElement.query(By.css('[data-testId="iframe"]'));
+
+ iframe.nativeElement.contentWindow.addEventListener(
+ 'message',
+ (event: MessageEvent) => {
+ expect(event).toBeTruthy();
+ done();
+ }
+ );
+
+ triggerCustomEvent(dialog, 'action', {
+ event: new CustomEvent('ng-event', {
+ detail: {
+ name: NG_CUSTOM_EVENTS.SAVE_PAGE,
+ payload: {}
+ }
+ })
+ });
+
+ spectator.detectChanges();
+ });
+
describe('reorder navigation', () => {
it('should open a dialog to reorder the navigation', () => {
window.dispatchEvent(
new MessageEvent('message', {
origin: HOST,
data: {
- action: CUSTOMER_ACTIONS.REORDER_MENU,
+ action: CLIENT_ACTIONS.REORDER_MENU,
payload: {
reorderUrl: 'http://localhost:3000/reorder-menu'
}
@@ -673,7 +724,7 @@ describe('EditEmaEditorComponent', () => {
new MessageEvent('message', {
origin: HOST,
data: {
- action: CUSTOMER_ACTIONS.REORDER_MENU,
+ action: CLIENT_ACTIONS.REORDER_MENU,
payload: {
reorderUrl: 'http://localhost:3000/reorder-menu'
}
@@ -946,7 +997,7 @@ describe('EditEmaEditorComponent', () => {
new MessageEvent('message', {
origin: HOST,
data: {
- action: CUSTOMER_ACTIONS.COPY_CONTENTLET_INLINE_EDITING,
+ action: CLIENT_ACTIONS.COPY_CONTENTLET_INLINE_EDITING,
payload: {
inode: '123'
}
@@ -1018,7 +1069,7 @@ describe('EditEmaEditorComponent', () => {
}
}
}),
- payload: PAYLOAD_MOCK
+ actionPayload: PAYLOAD_MOCK
});
spectator.detectChanges();
@@ -1078,7 +1129,7 @@ describe('EditEmaEditorComponent', () => {
}
}
}),
- payload
+ actionPayload: payload
});
spectator.detectChanges();
@@ -1156,7 +1207,7 @@ describe('EditEmaEditorComponent', () => {
}
}
}),
- payload
+ actionPayload: payload
});
spectator.detectChanges();
@@ -1237,7 +1288,7 @@ describe('EditEmaEditorComponent', () => {
}
}
}),
- payload
+ actionPayload: payload
});
spectator.detectChanges();
@@ -1315,7 +1366,7 @@ describe('EditEmaEditorComponent', () => {
}
}
}),
- payload
+ actionPayload: payload
});
spectator.detectChanges();
@@ -1396,7 +1447,7 @@ describe('EditEmaEditorComponent', () => {
}
}
}),
- payload
+ actionPayload: payload
});
spectator.detectChanges();
@@ -2711,7 +2762,7 @@ describe('EditEmaEditorComponent', () => {
new MessageEvent('message', {
origin: HOST,
data: {
- action: CUSTOMER_ACTIONS.UPDATE_CONTENTLET_INLINE_EDITING,
+ action: CLIENT_ACTIONS.UPDATE_CONTENTLET_INLINE_EDITING,
payload: {
dataset: {
inode: '123',
@@ -2747,7 +2798,7 @@ describe('EditEmaEditorComponent', () => {
new MessageEvent('message', {
origin: HOST,
data: {
- action: CUSTOMER_ACTIONS.UPDATE_CONTENTLET_INLINE_EDITING,
+ action: CLIENT_ACTIONS.UPDATE_CONTENTLET_INLINE_EDITING,
payload: null
}
})
@@ -2767,7 +2818,7 @@ describe('EditEmaEditorComponent', () => {
new MessageEvent('message', {
origin: HOST,
data: {
- action: CUSTOMER_ACTIONS.CLIENT_READY
+ action: CLIENT_ACTIONS.CLIENT_READY
}
})
);
@@ -2791,7 +2842,7 @@ describe('EditEmaEditorComponent', () => {
new MessageEvent('message', {
origin: HOST,
data: {
- action: CUSTOMER_ACTIONS.CLIENT_READY,
+ action: CLIENT_ACTIONS.CLIENT_READY,
payload: config
}
})
@@ -2819,7 +2870,7 @@ describe('EditEmaEditorComponent', () => {
new MessageEvent('message', {
origin: HOST,
data: {
- action: CUSTOMER_ACTIONS.CLIENT_READY,
+ action: CLIENT_ACTIONS.CLIENT_READY,
payload: config
}
})
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 27bcc6d9a7e5..db7df5131ed4 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
@@ -25,7 +25,7 @@ import { ProgressBarModule } from 'primeng/progressbar';
import { takeUntil, catchError, filter, map, switchMap, tap, take } from 'rxjs/operators';
-import { CUSTOMER_ACTIONS } from '@dotcms/client';
+import { CLIENT_ACTIONS, NOTIFY_CLIENT } from '@dotcms/client';
import {
DotMessageService,
DotCopyContentService,
@@ -69,7 +69,7 @@ import { DotEmaDialogComponent } from '../components/dot-ema-dialog/dot-ema-dial
import { DotPageApiService } from '../services/dot-page-api.service';
import { InlineEditService } from '../services/inline-edit/inline-edit.service';
import { DEFAULT_PERSONA, IFRAME_SCROLL_ZONE, WINDOW } from '../shared/consts';
-import { EDITOR_STATE, NG_CUSTOM_EVENTS, NOTIFY_CUSTOMER, UVE_STATUS } from '../shared/enums';
+import { EDITOR_STATE, NG_CUSTOM_EVENTS, UVE_STATUS } from '../shared/enums';
import {
ActionPayload,
PositionPayload,
@@ -78,7 +78,9 @@ import {
VTLFile,
DeletePayload,
InsertPayloadFromDelete,
- ReorderPayload
+ ReorderPayload,
+ DialogAction,
+ PostMessage
} from '../shared/models';
import { UVEStore } from '../store/dot-uve.store';
import { ClientRequestProps } from '../store/features/client/withClient';
@@ -200,7 +202,12 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
const isDragging = this.uveStore.$editorIsInDraggingState();
if (isDragging) {
- this.contentWindow?.postMessage(NOTIFY_CUSTOMER.EMA_REQUEST_BOUNDS, this.host);
+ this.contentWindow?.postMessage(
+ {
+ name: NOTIFY_CLIENT.UVE_REQUEST_BOUNDS
+ },
+ this.host
+ );
}
});
@@ -306,7 +313,12 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
}
this.uveStore.setEditorState(EDITOR_STATE.DRAGGING);
- this.contentWindow?.postMessage(NOTIFY_CUSTOMER.EMA_REQUEST_BOUNDS, this.host);
+ this.contentWindow?.postMessage(
+ {
+ name: NOTIFY_CLIENT.UVE_REQUEST_BOUNDS
+ },
+ this.host
+ );
if (dragItem) {
return;
@@ -370,7 +382,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
this.uveStore.updateEditorScrollDragState();
this.contentWindow?.postMessage(
- { name: NOTIFY_CUSTOMER.EMA_SCROLL_INSIDE_IFRAME, direction },
+ { name: NOTIFY_CLIENT.UVE_SCROLL_INSIDE_IFRAME, direction },
this.host
);
});
@@ -527,16 +539,17 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.destroy$.next(true);
this.destroy$.complete();
+ this.uveStore.setIsClientReady(false);
}
/**
* Handle the custom event
*
- * @param {{ event: CustomEvent; payload: ActionPayload }} { event, payload }
+ * @param {DialogAction}
* @memberof EditEmaEditorComponent
*/
- onCustomEvent({ event, payload }: { event: CustomEvent; payload: ActionPayload }) {
- this.handleNgEvent({ event, payload })?.();
+ onCustomEvent(dialogAction: DialogAction) {
+ this.handleNgEvent(dialogAction)?.();
}
/**
@@ -608,7 +621,11 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
} else if (dragItem.draggedPayload.type === 'content-type') {
this.uveStore.resetEditorProperties(); // In case the user cancels the creation of the contentlet, we already have the editor in idle state
- this.dialog.createContentletFromPalette({ ...dragItem.draggedPayload.item, payload });
+ this.dialog.createContentletFromPalette({
+ ...dragItem.draggedPayload.item,
+ actionPayload: payload,
+ language_id: this.uveStore.$languageId()
+ });
} else if (dragItem.draggedPayload.type === 'temp') {
const { pageContainers, didInsert } = insertContentletInContainer({
...payload,
@@ -673,7 +690,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
});
}
- protected handleNgEvent({ event, payload }: { event: CustomEvent; payload: ActionPayload }) {
+ protected handleNgEvent({ event, actionPayload, clientAction }: DialogAction) {
const { detail } = event;
return ( void>>{
@@ -682,10 +699,9 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
},
[NG_CUSTOM_EVENTS.CONTENT_SEARCH_SELECT]: () => {
const { pageContainers, didInsert } = insertContentletInContainer({
- ...payload,
+ ...actionPayload,
newContentletId: detail.data.identifier
});
-
if (!didInsert) {
this.handleDuplicatedContentlet();
@@ -695,7 +711,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
this.uveStore.savePage(pageContainers);
},
[NG_CUSTOM_EVENTS.SAVE_PAGE]: () => {
- const { shouldReloadPage, contentletIdentifier } = detail.payload;
+ const { shouldReloadPage, contentletIdentifier } = detail.payload ?? {};
if (shouldReloadPage) {
this.reloadURLContentMapPage(contentletIdentifier);
@@ -703,14 +719,23 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
return;
}
- if (!payload) {
+ if (clientAction === CLIENT_ACTIONS.EDIT_CONTENTLET) {
+ this.contentWindow?.postMessage(
+ {
+ name: NOTIFY_CLIENT.UVE_RELOAD_PAGE
+ },
+ this.host
+ );
+ }
+
+ if (!actionPayload) {
this.uveStore.reload();
return;
}
const { pageContainers, didInsert } = insertContentletInContainer({
- ...payload,
+ ...actionPayload,
newContentletId: contentletIdentifier
});
@@ -726,7 +751,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
this.dialog.createContentlet({
contentType: detail.data.contentType,
url: detail.data.url,
- payload
+ actionPayload
});
this.cd.detectChanges();
},
@@ -734,14 +759,14 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
const formId = detail.data.identifier;
this.dotPageApiService
- .getFormIndetifier(payload.container.identifier, formId)
+ .getFormIndetifier(actionPayload.container.identifier, formId)
.pipe(
tap(() => {
this.uveStore.setUveStatus(UVE_STATUS.LOADING);
}),
map((newFormId: string) => {
return {
- ...payload,
+ ...actionPayload,
newContentletId: newFormId
};
}),
@@ -795,13 +820,13 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
* Handle the post message event
*
* @private
- * @param {{ action: CUSTOMER_ACTIONS; payload: DotCMSContentlet }} data
+ * @param {{ action: CLIENT_ACTIONS; payload: DotCMSContentlet }} data
* @return {*}
* @memberof DotEmaComponent
*/
- private handlePostMessage({ action, payload }: { action: string; payload: unknown }): void {
- const CUSTOMER_ACTIONS_FUNC_MAP = {
- [CUSTOMER_ACTIONS.NAVIGATION_UPDATE]: (payload: SetUrlPayload) => {
+ private handlePostMessage({ action, payload }: PostMessage): void {
+ const CLIENT_ACTIONS_FUNC_MAP = {
+ [CLIENT_ACTIONS.NAVIGATION_UPDATE]: (payload: SetUrlPayload) => {
// When we set the url, we trigger in the shell component a load to get the new state of the page
// This triggers a rerender that makes nextjs to send the set_url again
// But this time the params are the same so the shell component wont trigger a load and there we know that the page is loaded
@@ -816,10 +841,10 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
});
}
},
- [CUSTOMER_ACTIONS.SET_BOUNDS]: (payload: Container[]) => {
+ [CLIENT_ACTIONS.SET_BOUNDS]: (payload: Container[]) => {
this.uveStore.setEditorBounds(payload);
},
- [CUSTOMER_ACTIONS.SET_CONTENTLET]: (contentletArea: ClientContentletArea) => {
+ [CLIENT_ACTIONS.SET_CONTENTLET]: (contentletArea: ClientContentletArea) => {
const payload = this.uveStore.getPageSavePayload(contentletArea.payload);
this.uveStore.setEditorContentletArea({
@@ -827,19 +852,19 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
payload
});
},
- [CUSTOMER_ACTIONS.IFRAME_SCROLL]: () => {
+ [CLIENT_ACTIONS.IFRAME_SCROLL]: () => {
this.uveStore.updateEditorScrollState();
},
- [CUSTOMER_ACTIONS.IFRAME_SCROLL_END]: () => {
+ [CLIENT_ACTIONS.IFRAME_SCROLL_END]: () => {
this.uveStore.updateEditorOnScrollEnd();
},
- [CUSTOMER_ACTIONS.INIT_INLINE_EDITING]: () => {
+ [CLIENT_ACTIONS.INIT_INLINE_EDITING]: () => {
// The iframe says that the editor is ready to start inline editing
// The dataset of the inline-editing contentlet is ready inside the service.
this.inlineEditingService.initEditor();
this.uveStore.setEditorState(EDITOR_STATE.INLINE_EDITING);
},
- [CUSTOMER_ACTIONS.COPY_CONTENTLET_INLINE_EDITING]: (payload: {
+ [CLIENT_ACTIONS.COPY_CONTENTLET_INLINE_EDITING]: (payload: {
dataset: InlineEditingContentletDataset;
}) => {
// The iframe say the contentlet that the content is queue to be inline edited is in multiple pages
@@ -882,7 +907,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
if (!this.uveStore.isTraditionalPage()) {
const message = {
- name: NOTIFY_CUSTOMER.COPY_CONTENTLET_INLINE_EDITING_SUCCESS,
+ name: NOTIFY_CLIENT.UVE_COPY_CONTENTLET_INLINE_EDITING_SUCCESS,
payload: data
};
@@ -898,7 +923,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
}
});
},
- [CUSTOMER_ACTIONS.UPDATE_CONTENTLET_INLINE_EDITING]: (payload: UpdatedContentlet) => {
+ [CLIENT_ACTIONS.UPDATE_CONTENTLET_INLINE_EDITING]: (payload: UpdatedContentlet) => {
this.uveStore.setEditorState(EDITOR_STATE.IDLE);
// If there is no payload, we don't need to do anything
@@ -942,13 +967,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
)
.subscribe(() => this.uveStore.reload());
},
- [CUSTOMER_ACTIONS.REORDER_MENU]: ({ reorderUrl }: ReorderPayload) => {
- this.dialog.openDialogOnUrl(
- reorderUrl,
- this.dotMessageService.get('editpage.content.contentlet.menu.reorder.title')
- );
- },
- [CUSTOMER_ACTIONS.CLIENT_READY]: (clientConfig: ClientRequestProps) => {
+ [CLIENT_ACTIONS.CLIENT_READY]: (clientConfig: ClientRequestProps) => {
const { query, params } = clientConfig || {};
const isClientReady = this.uveStore.isClientReady();
@@ -968,11 +987,20 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
this.uveStore.setClientConfiguration({ query, params });
this.uveStore.reload();
},
- [CUSTOMER_ACTIONS.NOOP]: () => {
+ [CLIENT_ACTIONS.EDIT_CONTENTLET]: (contentlet: DotCMSContentlet) => {
+ this.dialog.editContentlet({ ...contentlet, clientAction: action });
+ },
+ [CLIENT_ACTIONS.REORDER_MENU]: ({ reorderUrl }: ReorderPayload) => {
+ this.dialog.openDialogOnUrl(
+ reorderUrl,
+ this.dotMessageService.get('editpage.content.contentlet.menu.reorder.title')
+ );
+ },
+ [CLIENT_ACTIONS.NOOP]: () => {
/* Do Nothing because is not the origin we are expecting */
}
};
- const actionToExecute = CUSTOMER_ACTIONS_FUNC_MAP[action];
+ const actionToExecute = CLIENT_ACTIONS_FUNC_MAP[action];
actionToExecute?.(payload);
}
@@ -984,7 +1012,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy {
*/
reloadIframeContent() {
this.iframe?.nativeElement?.contentWindow?.postMessage(
- { name: NOTIFY_CUSTOMER.SET_PAGE_DATA, payload: this.uveStore.pageAPIResponse() },
+ { name: NOTIFY_CLIENT.UVE_SET_PAGE_DATA, payload: this.uveStore.pageAPIResponse() },
this.host
);
}
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-action-url/dot-action-url.service.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-action-url/dot-action-url.service.spec.ts
index aa35644b39b0..149b21a213a3 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-action-url/dot-action-url.service.spec.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-action-url/dot-action-url.service.spec.ts
@@ -26,7 +26,22 @@ describe('DotActionUrlService', () => {
expect(url).toEqual('testUrl');
});
- expect(httpClientMock.get).toHaveBeenCalledWith('/api/v1/portlet/_actionurl/testType');
+ expect(httpClientMock.get).toHaveBeenCalledWith(
+ '/api/v1/portlet/_actionurl/testType?language_id=1'
+ );
+ });
+
+ it('should get the URL to create a contentlet with a specify language id', () => {
+ const mockResponse = { entity: 'testUrl' };
+ httpClientMock.get.mockReturnValue(of(mockResponse));
+
+ spectator.service.getCreateContentletUrl('testType', 2).subscribe((url) => {
+ expect(url).toEqual('testUrl');
+ });
+
+ expect(httpClientMock.get).toHaveBeenCalledWith(
+ '/api/v1/portlet/_actionurl/testType?language_id=2'
+ );
});
it('should return EMPTY when the request fails', () => {
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-action-url/dot-action-url.service.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-action-url/dot-action-url.service.ts
index c8bf2507d8fd..b6da934e881f 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-action-url/dot-action-url.service.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-action-url/dot-action-url.service.ts
@@ -16,9 +16,14 @@ export class DotActionUrlService {
* @return {*} {Observable}
* @memberof DotActionUrlService
*/
- getCreateContentletUrl(contentTypeVariable: string): Observable {
+ getCreateContentletUrl(
+ contentTypeVariable: string,
+ language_id: string | number = 1
+ ): Observable {
return this.http
- .get<{ entity: string }>(`/api/v1/portlet/_actionurl/${contentTypeVariable}`)
+ .get<{
+ entity: string;
+ }>(`/api/v1/portlet/_actionurl/${contentTypeVariable}?language_id=${language_id}`)
.pipe(
pluck('entity'),
catchError(() => {
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/enums.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/enums.ts
index 053be21eff33..a060bbb8ac68 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/enums.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/enums.ts
@@ -1,12 +1,3 @@
-export enum NOTIFY_CUSTOMER {
- EMA_RELOAD_PAGE = 'ema-reload-page', // We need to reload the ema page
- EMA_REQUEST_BOUNDS = 'ema-request-bounds',
- EMA_EDITOR_PONG = 'ema-editor-pong',
- EMA_SCROLL_INSIDE_IFRAME = 'scroll-inside-iframe',
- SET_PAGE_DATA = 'SET_PAGE_DATA',
- COPY_CONTENTLET_INLINE_EDITING_SUCCESS = 'COPY_CONTENTLET_INLINE_EDITING_SUCCESS'
-}
-
// All the custom events that come from the JSP Iframe
export enum NG_CUSTOM_EVENTS {
EDIT_CONTENTLET_LOADED = 'edit-contentlet-loaded',
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 c2306250ee5a..721a2451c10b 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
@@ -1,4 +1,5 @@
-import { DotDevice } from '@dotcms/dotcms-models';
+import { CLIENT_ACTIONS } from '@dotcms/client';
+import { DotCMSContentlet, DotDevice } from '@dotcms/dotcms-models';
import { InfoPage } from '@dotcms/ui';
import { CommonErrors, DialogStatus, FormStatus } from './enums';
@@ -194,12 +195,6 @@ export interface DialogForm {
isTranslation: boolean;
}
-export interface DialogAction {
- event: CustomEvent;
- payload: ActionPayload;
- form: DialogForm;
-}
-
export type DialogType = 'content' | 'form' | 'widget' | null;
export interface EditEmaDialogState {
@@ -207,24 +202,41 @@ export interface EditEmaDialogState {
status: DialogStatus;
url: string;
type: DialogType;
- payload?: ActionPayload;
- editContentForm: DialogForm;
+ actionPayload?: ActionPayload;
+ form: DialogForm;
+ clientAction: CLIENT_ACTIONS;
+}
+
+export type DialogActionPayload = Pick;
+
+export interface DialogAction
+ extends Pick {
+ event: CustomEvent;
}
// We can modify this if we add more events, for now I think is enough
-export interface CreateFromPaletteAction {
+export interface CreateFromPaletteAction extends DialogActionPayload {
variable: string;
name: string;
- payload: ActionPayload;
+ language_id?: string | number;
}
-export interface EditContentletPayload {
- inode: string;
- title: string;
-}
+export type EditContentletPayload = Partial<
+ DotCMSContentlet & Pick
+>;
-export interface CreateContentletAction {
+export interface CreateContentletAction extends DialogActionPayload {
url: string;
contentType: string;
- payload: ActionPayload;
+}
+
+export interface AddContentletAction extends DialogActionPayload {
+ containerId: string;
+ acceptTypes: string;
+ language_id: string;
+}
+
+export interface PostMessage {
+ action: CLIENT_ACTIONS;
+ payload: unknown;
}
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.spec.ts
index c6e5f810a45d..c7297b633cd6 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.spec.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.spec.ts
@@ -8,7 +8,7 @@ import {
import { patchState } from '@ngrx/signals';
import { of } from 'rxjs';
-import { ActivatedRoute, ActivatedRouteSnapshot, ParamMap, Router } from '@angular/router';
+import { ActivatedRoute, Router } from '@angular/router';
import { MessageService } from 'primeng/api';
@@ -18,8 +18,7 @@ import {
DotLicenseService,
DotMessageService
} from '@dotcms/data-access';
-import { CurrentUser, LoginService } from '@dotcms/dotcms-js';
-import { DEFAULT_VARIANT_ID, DEFAULT_VARIANT_NAME, DotCMSContentlet } from '@dotcms/dotcms-models';
+import { LoginService } from '@dotcms/dotcms-js';
import {
MockDotMessageService,
getRunningExperimentMock,
@@ -27,38 +26,22 @@ import {
getDraftExperimentMock,
DotLanguagesServiceMock,
CurrentUserDataMock,
- mockLanguageArray,
- mockDotDevices,
- seoOGTagsMock
+ mockLanguageArray
} from '@dotcms/utils-testing';
import { UVEStore } from './dot-uve.store';
-import { DotPageApiResponse, DotPageApiService } from '../services/dot-page-api.service';
-import { BASE_IFRAME_MEASURE_UNIT, COMMON_ERRORS, DEFAULT_PERSONA } from '../shared/consts';
-import { EDITOR_STATE, UVE_STATUS } from '../shared/enums';
+import { DotPageApiService } from '../services/dot-page-api.service';
+import { COMMON_ERRORS } from '../shared/consts';
+import { UVE_STATUS } from '../shared/enums';
import {
- ACTION_MOCK,
- ACTION_PAYLOAD_MOCK,
BASE_SHELL_ITEMS,
BASE_SHELL_PROPS_RESPONSE,
- EMA_DRAG_ITEM_CONTENTLET_MOCK,
- getBoundsMock,
- getVanityUrl,
HEADLESS_BASE_QUERY_PARAMS,
- MOCK_CONTENTLET_AREA,
MOCK_RESPONSE_HEADLESS,
MOCK_RESPONSE_VTL,
- PERMANENT_REDIRECT_VANITY_URL,
- TEMPORARY_REDIRECT_VANITY_URL,
VTL_BASE_QUERY_PARAMS
} from '../shared/mocks';
-import { DotDeviceWithIcon } from '../shared/models';
-import {
- getPersonalization,
- mapContainerStructureToArrayOfContainers,
- mapContainerStructureToDotContainerMap
-} from '../utils';
const buildPageAPIResponseFromMock =
(mock) =>
@@ -70,28 +53,11 @@ const buildPageAPIResponseFromMock =
pageURI: url
}
});
-const mockCurrentUser: CurrentUser = {
- email: 'admin@dotcms.com',
- givenName: 'Admin',
- loginAs: true,
- roleId: 'e7d4e34e-5127-45fc-8123-d48b62d510e3',
- surname: 'User',
- userId: 'dotcms.org.1'
-};
-const mockOtherUser: CurrentUser = {
- email: 'admin2@dotcms.com',
- givenName: 'Admin2',
- loginAs: true,
- roleId: '73ec980e-d74f-4cec-a4d0-e319061e20b9',
- surname: 'User',
- userId: 'dotcms.org.2808'
-};
+
describe('UVEStore', () => {
let spectator: SpectatorService>;
let store: InstanceType;
let dotPageApiService: SpyObject;
- let activatedRoute: SpyObject;
- let router: SpyObject;
const createService = createServiceFactory({
service: UVEStore,
@@ -153,8 +119,6 @@ describe('UVEStore', () => {
store = spectator.service;
dotPageApiService = spectator.inject(DotPageApiService);
- router = spectator.inject(Router);
- activatedRoute = spectator.inject(ActivatedRoute);
jest.spyOn(dotPageApiService, 'get').mockImplementation(
buildPageAPIResponseFromMock(MOCK_RESPONSE_HEADLESS)
);
@@ -172,6 +136,12 @@ describe('UVEStore', () => {
});
});
+ describe('$languageId', () => {
+ it('should return the languageId', () => {
+ expect(store.$languageId()).toBe(MOCK_RESPONSE_HEADLESS.viewAs.language.id);
+ });
+ });
+
describe('$shellProps', () => {
it('should return the shell props for Headless Pages', () => {
expect(store.$shellProps()).toEqual(BASE_SHELL_PROPS_RESPONSE);
@@ -393,1213 +363,4 @@ describe('UVEStore', () => {
});
});
});
-
- describe('withLoad', () => {
- describe('withMethods', () => {
- it('should load the store with the base data', () => {
- expect(store.pageAPIResponse()).toEqual(MOCK_RESPONSE_HEADLESS);
- expect(store.isEnterprise()).toBe(true);
- expect(store.currentUser()).toEqual(CurrentUserDataMock);
- expect(store.experiment()).toBe(undefined);
- expect(store.languages()).toBe(mockLanguageArray);
- expect(store.params()).toEqual(HEADLESS_BASE_QUERY_PARAMS);
- expect(store.canEditPage()).toBe(true);
- expect(store.pageIsLocked()).toBe(false);
- expect(store.status()).toBe(UVE_STATUS.LOADED);
- expect(store.isTraditionalPage()).toBe(false);
- expect(store.isClientReady()).toBe(false);
- });
-
- it('should load the store with the base data for traditional page', () => {
- jest.spyOn(dotPageApiService, 'get').mockImplementation(
- buildPageAPIResponseFromMock(MOCK_RESPONSE_VTL)
- );
-
- store.init(VTL_BASE_QUERY_PARAMS);
-
- expect(store.pageAPIResponse()).toEqual(MOCK_RESPONSE_VTL);
- expect(store.isEnterprise()).toBe(true);
- expect(store.currentUser()).toEqual(CurrentUserDataMock);
- expect(store.experiment()).toBe(undefined);
- expect(store.languages()).toBe(mockLanguageArray);
- expect(store.params()).toEqual(VTL_BASE_QUERY_PARAMS);
- expect(store.canEditPage()).toBe(true);
- expect(store.pageIsLocked()).toBe(false);
- expect(store.status()).toBe(UVE_STATUS.LOADED);
- expect(store.isTraditionalPage()).toBe(true);
- expect(store.isClientReady()).toBe(true);
- });
-
- it('should navigate when the page is a vanityUrl permanent redirect', () => {
- const permanentRedirect = getVanityUrl(
- VTL_BASE_QUERY_PARAMS.url,
- PERMANENT_REDIRECT_VANITY_URL
- ) as unknown as DotPageApiResponse;
-
- const forwardTo = PERMANENT_REDIRECT_VANITY_URL.forwardTo;
-
- jest.spyOn(dotPageApiService, 'get').mockImplementation(() =>
- of(permanentRedirect)
- );
-
- store.init(VTL_BASE_QUERY_PARAMS);
-
- expect(router.navigate).toHaveBeenCalledWith([], {
- queryParams: {
- ...VTL_BASE_QUERY_PARAMS,
- url: forwardTo
- },
- queryParamsHandling: 'merge'
- });
- });
-
- it('should navigate when the page is a vanityUrl temporary redirect', () => {
- const temporaryRedirect = getVanityUrl(
- VTL_BASE_QUERY_PARAMS.url,
- TEMPORARY_REDIRECT_VANITY_URL
- ) as unknown as DotPageApiResponse;
-
- const forwardTo = TEMPORARY_REDIRECT_VANITY_URL.forwardTo;
-
- jest.spyOn(dotPageApiService, 'get').mockImplementation(() =>
- of(temporaryRedirect)
- );
-
- store.init(VTL_BASE_QUERY_PARAMS);
-
- expect(router.navigate).toHaveBeenCalledWith([], {
- queryParams: {
- ...VTL_BASE_QUERY_PARAMS,
- url: forwardTo
- },
- queryParamsHandling: 'merge'
- });
- });
-
- it('should navigate to content when the layout is disable by page.canEdit and current route is layout', () => {
- jest.spyOn(dotPageApiService, 'get').mockImplementation(() =>
- of({
- ...MOCK_RESPONSE_VTL,
- page: {
- ...MOCK_RESPONSE_VTL.page,
- canEdit: false
- }
- })
- );
-
- jest.spyOn(activatedRoute, 'firstChild', 'get').mockReturnValue({
- snapshot: {
- url: [
- {
- path: 'layout',
- parameters: {},
- parameterMap: {} as unknown as ParamMap
- }
- ]
- } as unknown as ActivatedRouteSnapshot
- } as unknown as ActivatedRoute);
-
- store.init(VTL_BASE_QUERY_PARAMS);
-
- expect(router.navigate).toHaveBeenCalledWith(['edit-page/content'], {
- queryParamsHandling: 'merge'
- });
- });
-
- it('should navigate to content when the layout is disable by template.drawed and current route is layout', () => {
- jest.spyOn(dotPageApiService, 'get').mockImplementation(() =>
- of({
- ...MOCK_RESPONSE_VTL,
- template: {
- ...MOCK_RESPONSE_VTL.template,
- drawed: false
- }
- })
- );
-
- jest.spyOn(activatedRoute, 'firstChild', 'get').mockReturnValue({
- snapshot: {
- url: [
- {
- path: 'layout',
- parameters: {},
- parameterMap: {} as unknown as ParamMap
- }
- ]
- } as unknown as ActivatedRouteSnapshot
- } as unknown as ActivatedRoute);
-
- store.init(VTL_BASE_QUERY_PARAMS);
-
- expect(router.navigate).toHaveBeenCalledWith(['edit-page/content'], {
- queryParamsHandling: 'merge'
- });
- });
-
- it('should not navigate to content when the layout is disable by template.drawed and current route is not layout', () => {
- jest.spyOn(dotPageApiService, 'get').mockImplementation(() =>
- of({
- ...MOCK_RESPONSE_VTL,
- template: {
- ...MOCK_RESPONSE_VTL.template,
- drawed: false
- }
- })
- );
-
- jest.spyOn(activatedRoute, 'firstChild', 'get').mockReturnValue({
- snapshot: {
- url: [
- {
- path: 'rules',
- parameters: {},
- parameterMap: {} as unknown as ParamMap
- }
- ]
- } as unknown as ActivatedRouteSnapshot
- } as unknown as ActivatedRoute);
-
- store.init(VTL_BASE_QUERY_PARAMS);
-
- expect(router.navigate).not.toHaveBeenCalled();
- });
-
- it('should not navigate to content when the layout is disable by page.canEdit and current route is not layout', () => {
- jest.spyOn(dotPageApiService, 'get').mockImplementation(() =>
- of({
- ...MOCK_RESPONSE_VTL,
- page: {
- ...MOCK_RESPONSE_VTL.page,
- canEdit: false
- }
- })
- );
-
- jest.spyOn(activatedRoute, 'firstChild', 'get').mockReturnValue({
- snapshot: {
- url: [
- {
- path: 'rules',
- parameters: {},
- parameterMap: {} as unknown as ParamMap
- }
- ]
- } as unknown as ActivatedRouteSnapshot
- } as unknown as ActivatedRoute);
-
- store.init(VTL_BASE_QUERY_PARAMS);
-
- expect(router.navigate).not.toHaveBeenCalled();
- });
-
- it('should reload the store with the same queryParams', () => {
- const getPageSpy = jest.spyOn(dotPageApiService, 'get');
-
- store.reload();
-
- expect(getPageSpy).toHaveBeenCalledWith(store.params());
- });
- });
- });
-
- describe('withLayout', () => {
- describe('withComputed', () => {
- describe('$layoutProps', () => {
- it('should return the layout props', () => {
- expect(store.$layoutProps()).toEqual({
- containersMap: mapContainerStructureToDotContainerMap(
- MOCK_RESPONSE_HEADLESS.containers
- ),
- layout: MOCK_RESPONSE_HEADLESS.layout,
- template: {
- identifier: MOCK_RESPONSE_HEADLESS.template.identifier,
- themeId: MOCK_RESPONSE_HEADLESS.template.theme,
- anonymous: false
- },
- pageId: MOCK_RESPONSE_HEADLESS.page.identifier
- });
- });
- });
- });
-
- describe('withMethods', () => {
- it('should update the layout', () => {
- const layout = {
- ...MOCK_RESPONSE_HEADLESS.layout,
- title: 'New layout'
- };
-
- store.updateLayout(layout);
-
- expect(store.pageAPIResponse().layout).toEqual(layout);
- });
- });
- });
-
- describe('withEditor', () => {
- describe('withEditorToolbar', () => {
- describe('withComputed', () => {
- describe('$toolbarProps', () => {
- it('should return the base info', () => {
- expect(store.$toolbarProps()).toEqual({
- apiUrl: '/api/v1/page/json/test-url?language_id=1&com.dotmarketing.persona.id=dot%3Apersona&variantName=DEFAULT&clientHost=http%3A%2F%2Flocalhost%3A3000',
- bookmarksUrl: '/test-url?host_id=123-xyz-567-xxl&language_id=1',
- copyUrl:
- 'http://localhost:3000/test-url?language_id=1&com.dotmarketing.persona.id=dot%3Apersona&variantName=DEFAULT&host_id=123-xyz-567-xxl',
- currentLanguage: MOCK_RESPONSE_HEADLESS.viewAs.language,
- deviceSelector: {
- apiLink:
- 'http://localhost:3000/api/v1/page/json/test-url?language_id=1&com.dotmarketing.persona.id=dot%3Apersona&variantName=DEFAULT&clientHost=http%3A%2F%2Flocalhost%3A3000',
- hideSocialMedia: true
- },
- personaSelector: {
- pageId: MOCK_RESPONSE_HEADLESS.page.identifier,
- value: MOCK_RESPONSE_HEADLESS.viewAs.persona ?? DEFAULT_PERSONA
- },
- runningExperiment: null,
- showInfoDisplay: false,
- unlockButton: null,
- urlContentMap: null,
- workflowActionsInode: MOCK_RESPONSE_HEADLESS.page.inode
- });
- });
-
- describe('urlContentMap', () => {
- it('should return the urlContentMap if the state is edit', () => {
- patchState(store, {
- pageAPIResponse: {
- ...MOCK_RESPONSE_HEADLESS,
- urlContentMap: {
- title: 'Title',
- inode: '123',
- contentType: 'test'
- } as unknown as DotCMSContentlet
- }
- });
-
- expect(store.$toolbarProps().urlContentMap).toEqual({
- title: 'Title',
- inode: '123',
- contentType: 'test'
- });
- });
-
- it('should not return the urlContentMap if the state is not edit', () => {
- patchState(store, { isEditState: false });
- patchState(store, {
- pageAPIResponse: {
- ...MOCK_RESPONSE_HEADLESS,
- urlContentMap: {
- title: 'Title',
- inode: '123',
- contentType: 'test'
- } as unknown as DotCMSContentlet
- }
- });
-
- expect(store.$toolbarProps().urlContentMap).toEqual(null);
- });
- });
-
- describe('runningExperiment', () => {
- it('should have a runningExperiment if the experiment is running', () => {
- patchState(store, { experiment: getRunningExperimentMock() });
-
- expect(store.$toolbarProps().runningExperiment).toEqual(
- getRunningExperimentMock()
- );
- });
- });
-
- describe('workflowActionsInode', () => {
- it("should not have an workflowActionsInode if the user can't edit the page", () => {
- patchState(store, { canEditPage: false });
-
- expect(store.$toolbarProps().workflowActionsInode).toBe(null);
- });
- });
-
- describe('unlockButton', () => {
- it('should display unlockButton if the page is locked by another user and the current user can lock the page', () => {
- patchState(store, {
- pageAPIResponse: {
- ...MOCK_RESPONSE_HEADLESS,
- page: {
- ...MOCK_RESPONSE_HEADLESS.page,
- locked: true,
- lockedBy: mockOtherUser.userId,
- canLock: true
- }
- },
- currentUser: mockCurrentUser
- });
-
- expect(store.$toolbarProps().unlockButton).toEqual({
- inode: '123-i',
- loading: false
- });
- });
-
- it('should not display unlockButton if the page is locked by the current user', () => {
- patchState(store, {
- pageAPIResponse: {
- ...MOCK_RESPONSE_HEADLESS,
- page: {
- ...MOCK_RESPONSE_HEADLESS.page,
- locked: true,
- lockedBy: mockCurrentUser.userId,
- canLock: true
- }
- },
- currentUser: mockCurrentUser
- });
-
- expect(store.$toolbarProps().unlockButton).toBeNull();
- });
-
- it('should not display unlockButton if the page is not locked', () => {
- patchState(store, {
- pageAPIResponse: {
- ...MOCK_RESPONSE_HEADLESS,
- page: {
- ...MOCK_RESPONSE_HEADLESS.page,
- locked: false,
- canLock: true
- }
- },
- currentUser: mockCurrentUser
- });
-
- expect(store.$toolbarProps().unlockButton).toBeNull();
- });
-
- it('should not display unlockButton if the user cannot lock the page', () => {
- patchState(store, {
- pageAPIResponse: {
- ...MOCK_RESPONSE_HEADLESS,
- page: {
- ...MOCK_RESPONSE_HEADLESS.page,
- locked: true,
- canLock: false
- }
- },
- currentUser: mockCurrentUser
- });
-
- expect(store.$toolbarProps().unlockButton).toBeNull();
- });
- });
-
- describe('shouldShowInfoDisplay', () => {
- it("should have shouldShowInfoDisplay as true if the user can't edit the page", () => {
- patchState(store, { canEditPage: false });
-
- expect(store.$toolbarProps().showInfoDisplay).toBe(true);
- });
-
- it('should have shouldShowInfoDisplay as true if the device is set', () => {
- patchState(store, { device: mockDotDevices[0] });
-
- expect(store.$toolbarProps().showInfoDisplay).toBe(true);
- });
-
- it('should have shouldShowInfoDisplay as true if the socialMedia is set', () => {
- patchState(store, { socialMedia: 'facebook' });
-
- expect(store.$toolbarProps().showInfoDisplay).toBe(true);
- });
-
- it('should have shouldShowInfoDisplay as true if the page is a variant different from default', () => {
- patchState(store, {
- pageAPIResponse: {
- ...MOCK_RESPONSE_HEADLESS,
- viewAs: {
- ...MOCK_RESPONSE_HEADLESS.viewAs,
- variantId: 'test'
- }
- }
- });
-
- expect(store.$toolbarProps().showInfoDisplay).toBe(true);
- });
- it('should have shouldShowInfoDisplay as true if the page is locked by another user', () => {
- patchState(store, {
- pageAPIResponse: {
- ...MOCK_RESPONSE_HEADLESS,
- page: {
- ...MOCK_RESPONSE_HEADLESS.page,
- locked: true,
- lockedBy: mockOtherUser.userId
- }
- },
- currentUser: mockCurrentUser
- });
-
- expect(store.$toolbarProps().showInfoDisplay).toBe(true);
- });
-
- it('should have shouldShowInfoDisplay as false if the page is locked by the current user and other conditions are not met', () => {
- patchState(store, {
- pageAPIResponse: {
- ...MOCK_RESPONSE_HEADLESS,
- page: {
- ...MOCK_RESPONSE_HEADLESS.page,
- locked: true,
- lockedBy: mockCurrentUser.userId
- }
- },
- currentUser: mockCurrentUser,
- canEditPage: true,
- device: null,
- socialMedia: null
- });
-
- expect(store.$toolbarProps().showInfoDisplay).toBe(false);
- });
- });
- });
-
- describe('$infoDisplayOptions', () => {
- it('should be null in regular conditions', () => {
- expect(store.$infoDisplayOptions()).toBe(null);
- });
-
- it('should return info for device', () => {
- const device = mockDotDevices[0] as DotDeviceWithIcon;
-
- patchState(store, { device });
-
- expect(store.$infoDisplayOptions()).toEqual({
- icon: device.icon,
- info: {
- message: 'iphone 200 x 100',
- args: []
- },
- id: 'device',
- actionIcon: 'pi pi-times'
- });
- });
-
- it('should return info for socialMedia', () => {
- patchState(store, { socialMedia: 'Facebook' });
-
- expect(store.$infoDisplayOptions()).toEqual({
- icon: 'pi pi-facebook',
- info: {
- message: 'Viewing Facebook social media preview',
- args: []
- },
- id: 'socialMedia',
- actionIcon: 'pi pi-times'
- });
- });
-
- it('should return info when visiting a variant and can edit', () => {
- const currentExperiment = getRunningExperimentMock();
-
- const variantID = currentExperiment.trafficProportion.variants.find(
- (variant) => variant.name !== DEFAULT_VARIANT_NAME
- ).id;
-
- patchState(store, {
- pageAPIResponse: {
- ...MOCK_RESPONSE_HEADLESS,
- viewAs: {
- ...MOCK_RESPONSE_HEADLESS.viewAs,
- variantId: variantID
- }
- },
- experiment: currentExperiment
- });
-
- expect(store.$infoDisplayOptions()).toEqual({
- icon: 'pi pi-file-edit',
- info: {
- message: 'editpage.editing.variant',
- args: ['Variant A']
- },
- id: 'variant',
- actionIcon: 'pi pi-arrow-left'
- });
- });
-
- it('should return info when visiting a variant and can not edit', () => {
- const currentExperiment = getRunningExperimentMock();
-
- const variantID = currentExperiment.trafficProportion.variants.find(
- (variant) => variant.name !== DEFAULT_VARIANT_NAME
- ).id;
-
- patchState(store, {
- pageAPIResponse: {
- ...MOCK_RESPONSE_HEADLESS,
- page: {
- ...MOCK_RESPONSE_HEADLESS.page
- },
- viewAs: {
- ...MOCK_RESPONSE_HEADLESS.viewAs,
- variantId: variantID
- }
- },
- experiment: currentExperiment,
- canEditPage: false
- });
-
- expect(store.$infoDisplayOptions()).toEqual({
- icon: 'pi pi-file-edit',
- info: {
- message: 'editpage.viewing.variant',
- args: ['Variant A']
- },
- id: 'variant',
- actionIcon: 'pi pi-arrow-left'
- });
- });
-
- it('should return info when the page is locked and can lock', () => {
- patchState(store, {
- pageAPIResponse: {
- ...MOCK_RESPONSE_HEADLESS,
- page: {
- ...MOCK_RESPONSE_HEADLESS.page,
- locked: true,
- canLock: true,
- lockedByName: 'John Doe'
- }
- }
- });
-
- expect(store.$infoDisplayOptions()).toEqual({
- icon: 'pi pi-lock',
- info: {
- message: 'editpage.locked-by',
- args: ['John Doe']
- },
- id: 'locked'
- });
- });
-
- it('should return info when the page is locked and cannot lock', () => {
- patchState(store, {
- pageAPIResponse: {
- ...MOCK_RESPONSE_HEADLESS,
- page: {
- ...MOCK_RESPONSE_HEADLESS.page,
- locked: true,
- canLock: false,
- lockedByName: 'John Doe'
- }
- }
- });
-
- expect(store.$infoDisplayOptions()).toEqual({
- icon: 'pi pi-lock',
- info: {
- message: 'editpage.locked-contact-with',
- args: ['John Doe']
- },
- id: 'locked'
- });
- });
-
- it('should return info when you cannot edit the page', () => {
- patchState(store, { canEditPage: false });
-
- expect(store.$infoDisplayOptions()).toEqual({
- icon: 'pi pi-exclamation-circle warning',
- info: { message: 'editema.dont.have.edit.permission', args: [] },
- id: 'no-permission'
- });
- });
- });
- });
-
- describe('withMethods', () => {
- it('should set the device with setDevice', () => {
- const device = {
- identifier: '123',
- cssHeight: '120',
- cssWidth: '120',
- name: 'square',
- inode: '1234',
- icon: 'icon'
- };
-
- store.setDevice(device);
-
- expect(store.device()).toEqual(device);
- expect(store.isEditState()).toBe(false);
- });
-
- it('should set the socialMedia with setSocialMedia', () => {
- const socialMedia = 'facebook';
-
- store.setSocialMedia(socialMedia);
-
- expect(store.socialMedia()).toEqual(socialMedia);
- expect(store.isEditState()).toBe(false);
- });
-
- it('should reset the state with clearDeviceAndSocialMedia', () => {
- store.clearDeviceAndSocialMedia();
-
- expect(store.device()).toBe(null);
- expect(store.socialMedia()).toBe(null);
- expect(store.isEditState()).toBe(true);
- });
- });
- });
-
- describe('withSave', () => {
- describe('withMethods', () => {
- describe('savePage', () => {
- it('should perform a save and patch the state', () => {
- const saveSpy = jest
- .spyOn(dotPageApiService, 'save')
- .mockImplementation(() => of({}));
-
- // It's impossible to get a VTL when we are in Headless
- // but I just want to check the state is being patched
- const getClientPageSpy = jest
- .spyOn(dotPageApiService, 'getClientPage')
- .mockImplementation(() => of(MOCK_RESPONSE_VTL));
-
- const payload = {
- pageContainers: ACTION_PAYLOAD_MOCK.pageContainers,
- pageId: MOCK_RESPONSE_HEADLESS.page.identifier,
- params: store.params()
- };
-
- store.savePage(ACTION_PAYLOAD_MOCK.pageContainers);
-
- expect(saveSpy).toHaveBeenCalledWith(payload);
-
- expect(getClientPageSpy).toHaveBeenCalledWith(
- store.params(),
- store.clientRequestProps()
- );
-
- expect(store.status()).toBe(UVE_STATUS.LOADED);
- expect(store.pageAPIResponse()).toEqual(MOCK_RESPONSE_VTL);
- });
- });
- });
- });
-
- describe('withComputed', () => {
- describe('$pageData', () => {
- it('should return the expected data', () => {
- expect(store.$pageData()).toEqual({
- containers: mapContainerStructureToArrayOfContainers(
- MOCK_RESPONSE_HEADLESS.containers
- ),
- id: MOCK_RESPONSE_HEADLESS.page.identifier,
- personalization: getPersonalization(MOCK_RESPONSE_HEADLESS.viewAs.persona),
- languageId: MOCK_RESPONSE_HEADLESS.viewAs.language.id,
- personaTag: MOCK_RESPONSE_HEADLESS.viewAs.persona.keyTag
- });
- });
- });
-
- describe('$reloadEditorContent', () => {
- it('should return the expected data for Headless', () => {
- expect(store.$reloadEditorContent()).toEqual({
- code: MOCK_RESPONSE_HEADLESS.page.rendered,
- isTraditionalPage: false,
- enableInlineEdit: true,
- isClientReady: false
- });
- });
- it('should return the expected data for VTL', () => {
- jest.spyOn(dotPageApiService, 'get').mockImplementation(
- buildPageAPIResponseFromMock(MOCK_RESPONSE_VTL)
- );
-
- store.init(VTL_BASE_QUERY_PARAMS);
-
- expect(store.$reloadEditorContent()).toEqual({
- code: MOCK_RESPONSE_VTL.page.rendered,
- isTraditionalPage: true,
- enableInlineEdit: true,
- isClientReady: true
- });
- });
- });
-
- describe('$editorIsInDraggingState', () => {
- it("should return the editor's dragging state", () => {
- expect(store.$editorIsInDraggingState()).toBe(false);
- });
-
- it("should return the editor's dragging state after a change", () => {
- // This will trigger a change in the dragging state
- store.setEditorDragItem(EMA_DRAG_ITEM_CONTENTLET_MOCK);
-
- expect(store.$editorIsInDraggingState()).toBe(true);
- });
- });
-
- describe('$editorProps', () => {
- it('should return the expected data on init', () => {
- expect(store.$editorProps()).toEqual({
- showDialogs: true,
- showEditorContent: true,
- iframe: {
- opacity: '0.5',
- pointerEvents: 'auto',
- src: 'http://localhost:3000/test-url?language_id=1&com.dotmarketing.persona.id=dot%3Apersona&variantName=DEFAULT&clientHost=http%3A%2F%2Flocalhost%3A3000',
- wrapper: null
- },
- progressBar: true,
- contentletTools: null,
- dropzone: null,
- palette: {
- variantId: DEFAULT_VARIANT_ID,
- languageId: MOCK_RESPONSE_HEADLESS.viewAs.language.id,
- containers: MOCK_RESPONSE_HEADLESS.containers
- },
- seoResults: null
- });
- });
-
- it('should set iframe opacity to 1 when client is Ready', () => {
- store.setIsClientReady(true);
-
- expect(store.$editorProps().iframe.opacity).toBe('1');
- });
-
- describe('showDialogs', () => {
- it('should have the value of false when we cannot edit the page', () => {
- patchState(store, { canEditPage: false });
-
- expect(store.$editorProps().showDialogs).toBe(false);
- });
-
- it('should have the value of false when we are not on edit state', () => {
- patchState(store, { isEditState: false });
-
- expect(store.$editorProps().showDialogs).toBe(false);
- });
- });
-
- describe('showEditorContent', () => {
- it('should have showEditorContent as true when there is no socialMedia', () => {
- expect(store.$editorProps().showEditorContent).toBe(true);
- });
- });
-
- describe('iframe', () => {
- it('should have an opacity of 0.5 when loading', () => {
- patchState(store, { status: UVE_STATUS.LOADING });
-
- expect(store.$editorProps().iframe.opacity).toBe('0.5');
- });
-
- it('should have pointerEvents as none when dragging', () => {
- patchState(store, { state: EDITOR_STATE.DRAGGING });
-
- expect(store.$editorProps().iframe.pointerEvents).toBe('none');
- });
-
- it('should have pointerEvents as none when scroll-drag', () => {
- patchState(store, { state: EDITOR_STATE.SCROLL_DRAG });
-
- expect(store.$editorProps().iframe.pointerEvents).toBe('none');
- });
-
- it('should have src as empty when the page is traditional', () => {
- jest.spyOn(dotPageApiService, 'get').mockImplementation(
- buildPageAPIResponseFromMock(MOCK_RESPONSE_VTL)
- );
-
- store.init(VTL_BASE_QUERY_PARAMS);
-
- expect(store.$editorProps().iframe.src).toBe('');
- });
-
- it('should have a wrapper when a device is present', () => {
- const device = mockDotDevices[0] as DotDeviceWithIcon;
-
- patchState(store, { device });
-
- expect(store.$editorProps().iframe.wrapper).toEqual({
- width: device.cssWidth + BASE_IFRAME_MEASURE_UNIT,
- height: device.cssHeight + BASE_IFRAME_MEASURE_UNIT
- });
- });
- });
-
- describe('progressBar', () => {
- it('should have progressBar as true when the status is loading', () => {
- patchState(store, { status: UVE_STATUS.LOADING });
-
- expect(store.$editorProps().progressBar).toBe(true);
- });
-
- it('should have progressBar as true when the status is loaded but client is not ready', () => {
- patchState(store, { status: UVE_STATUS.LOADED, isClientReady: false });
-
- expect(store.$editorProps().progressBar).toBe(true);
- });
-
- it('should have progressBar as false when the status is loaded and client is ready', () => {
- patchState(store, { status: UVE_STATUS.LOADED, isClientReady: true });
-
- expect(store.$editorProps().progressBar).toBe(false);
- });
- });
-
- describe('contentletTools', () => {
- it('should have contentletTools when contentletArea are present, can edit the page, is in edit state and not scrolling', () => {
- patchState(store, {
- isEditState: true,
- canEditPage: true,
- contentletArea: MOCK_CONTENTLET_AREA,
- state: EDITOR_STATE.IDLE
- });
-
- expect(store.$editorProps().contentletTools).toEqual({
- isEnterprise: true,
- contentletArea: MOCK_CONTENTLET_AREA,
- hide: false
- });
- });
-
- it('should have hide as true when dragging', () => {
- patchState(store, {
- isEditState: true,
- canEditPage: true,
- contentletArea: MOCK_CONTENTLET_AREA,
- state: EDITOR_STATE.DRAGGING
- });
-
- expect(store.$editorProps().contentletTools).toEqual({
- isEnterprise: true,
- contentletArea: MOCK_CONTENTLET_AREA,
- hide: true
- });
- });
-
- it('should be null when scrolling', () => {
- patchState(store, {
- isEditState: true,
- canEditPage: true,
- contentletArea: MOCK_CONTENTLET_AREA,
- state: EDITOR_STATE.SCROLLING
- });
-
- expect(store.$editorProps().contentletTools).toEqual(null);
- });
-
- it("should not have contentletTools when the page can't be edited", () => {
- patchState(store, {
- isEditState: true,
- canEditPage: false,
- contentletArea: MOCK_CONTENTLET_AREA,
- state: EDITOR_STATE.IDLE
- });
-
- expect(store.$editorProps().contentletTools).toBe(null);
- });
-
- it('should not have contentletTools when the contentletArea is not present', () => {
- patchState(store, {
- isEditState: true,
- canEditPage: true,
- state: EDITOR_STATE.IDLE
- });
-
- expect(store.$editorProps().contentletTools).toBe(null);
- });
-
- it('should not have contentletTools when the we are not in edit state', () => {
- patchState(store, {
- isEditState: false,
- canEditPage: true,
- contentletArea: MOCK_CONTENTLET_AREA,
- state: EDITOR_STATE.IDLE
- });
-
- expect(store.$editorProps().contentletTools).toBe(null);
- });
- });
- describe('dropzone', () => {
- const bounds = getBoundsMock(ACTION_MOCK);
-
- it('should have dropzone when the state is dragging and the page can be edited', () => {
- patchState(store, {
- state: EDITOR_STATE.DRAGGING,
- canEditPage: true,
- dragItem: EMA_DRAG_ITEM_CONTENTLET_MOCK,
- bounds
- });
-
- expect(store.$editorProps().dropzone).toEqual({
- dragItem: EMA_DRAG_ITEM_CONTENTLET_MOCK,
- bounds
- });
- });
-
- it("should not have dropzone when the page can't be edited", () => {
- patchState(store, {
- state: EDITOR_STATE.DRAGGING,
- canEditPage: false,
- dragItem: EMA_DRAG_ITEM_CONTENTLET_MOCK,
- bounds
- });
-
- expect(store.$editorProps().dropzone).toBe(null);
- });
- });
-
- describe('palette', () => {
- it('should be null if is not enterprise', () => {
- patchState(store, { isEnterprise: false });
-
- expect(store.$editorProps().palette).toBe(null);
- });
-
- it('should be null if canEditPage is false', () => {
- patchState(store, { canEditPage: false });
-
- expect(store.$editorProps().palette).toBe(null);
- });
-
- it('should be null if isEditState is false', () => {
- patchState(store, { isEditState: false });
-
- expect(store.$editorProps().palette).toBe(null);
- });
- });
-
- describe('seoResults', () => {
- it('should have the expected data when ogTags and socialMedia is present', () => {
- patchState(store, {
- ogTags: seoOGTagsMock,
- socialMedia: 'facebook'
- });
-
- expect(store.$editorProps().seoResults).toEqual({
- ogTags: seoOGTagsMock,
- socialMedia: 'facebook'
- });
- });
-
- it('should be null when ogTags is not present', () => {
- patchState(store, {
- socialMedia: 'facebook'
- });
-
- expect(store.$editorProps().seoResults).toBe(null);
- });
-
- it('should be null when socialMedia is not present', () => {
- patchState(store, {
- ogTags: seoOGTagsMock
- });
-
- expect(store.$editorProps().seoResults).toBe(null);
- });
- });
- });
- });
-
- describe('withMethods', () => {
- describe('updateEditorScrollState', () => {
- it("should update the editor's scroll state and remove bounds when there is no drag item", () => {
- store.updateEditorScrollState();
-
- expect(store.state()).toEqual(EDITOR_STATE.SCROLLING);
- expect(store.bounds()).toEqual([]);
- });
-
- it("should update the editor's scroll drag state and remove bounds when there is drag item", () => {
- store.setEditorDragItem(EMA_DRAG_ITEM_CONTENTLET_MOCK);
- store.setEditorBounds(getBoundsMock(ACTION_MOCK));
-
- store.updateEditorScrollState();
-
- expect(store.state()).toEqual(EDITOR_STATE.SCROLL_DRAG);
- expect(store.bounds()).toEqual([]);
- });
-
- it('should set the contentletArea to null when we are scrolling', () => {
- store.setEditorState(EDITOR_STATE.SCROLLING);
-
- store.updateEditorScrollState();
-
- expect(store.contentletArea()).toBe(null);
- });
- });
-
- describe('updateEditorOnScrollEnd', () => {
- it("should update the editor's drag state when there is no drag item", () => {
- store.updateEditorOnScrollEnd();
-
- expect(store.state()).toEqual(EDITOR_STATE.IDLE);
- });
-
- it("should update the editor's drag state when there is drag item", () => {
- store.setEditorDragItem(EMA_DRAG_ITEM_CONTENTLET_MOCK);
-
- store.updateEditorOnScrollEnd();
-
- expect(store.state()).toEqual(EDITOR_STATE.DRAGGING);
- });
- });
-
- describe('updateEditorScrollDragState', () => {
- it('should update the store correctly', () => {
- store.updateEditorScrollDragState();
-
- expect(store.state()).toEqual(EDITOR_STATE.SCROLL_DRAG);
- expect(store.bounds()).toEqual([]);
- });
- });
-
- describe('setEditorState', () => {
- it('should update the state correctly', () => {
- store.setEditorState(EDITOR_STATE.SCROLLING);
-
- expect(store.state()).toEqual(EDITOR_STATE.SCROLLING);
- });
- });
-
- describe('setEditorDragItem', () => {
- it('should update the store correctly', () => {
- store.setEditorDragItem(EMA_DRAG_ITEM_CONTENTLET_MOCK);
-
- expect(store.dragItem()).toEqual(EMA_DRAG_ITEM_CONTENTLET_MOCK);
- expect(store.state()).toEqual(EDITOR_STATE.DRAGGING);
- });
- });
-
- describe('setEditorContentletArea', () => {
- it("should update the store's contentlet area", () => {
- store.setEditorContentletArea(MOCK_CONTENTLET_AREA);
-
- expect(store.contentletArea()).toEqual(MOCK_CONTENTLET_AREA);
- expect(store.state()).toEqual(EDITOR_STATE.IDLE);
- });
-
- it('should not update contentletArea if it is the same', () => {
- store.setEditorContentletArea(MOCK_CONTENTLET_AREA);
-
- // We can have contentletArea and state at the same time we are inline editing
- store.setEditorState(EDITOR_STATE.INLINE_EDITING);
-
- store.setEditorContentletArea(MOCK_CONTENTLET_AREA);
-
- expect(store.contentletArea()).toEqual(MOCK_CONTENTLET_AREA);
- // State should not change
- expect(store.state()).toEqual(EDITOR_STATE.INLINE_EDITING);
- });
- });
-
- describe('setEditorBounds', () => {
- const bounds = getBoundsMock(ACTION_MOCK);
-
- it('should update the store correcly', () => {
- store.setEditorBounds(bounds);
-
- expect(store.bounds()).toEqual(bounds);
- });
- });
-
- describe('resetEditorProperties', () => {
- it('should reset the editor props corretcly', () => {
- store.setEditorDragItem(EMA_DRAG_ITEM_CONTENTLET_MOCK);
- store.setEditorState(EDITOR_STATE.SCROLLING);
- store.setEditorContentletArea(MOCK_CONTENTLET_AREA);
- store.setEditorBounds(getBoundsMock(ACTION_MOCK));
-
- store.resetEditorProperties();
-
- expect(store.dragItem()).toBe(null);
- expect(store.state()).toEqual(EDITOR_STATE.IDLE);
- expect(store.contentletArea()).toBe(null);
- expect(store.bounds()).toEqual([]);
- });
- });
- describe('getPageSavePayload', () => {
- it("should return the page's save payload", () => {
- expect(store.getPageSavePayload(ACTION_PAYLOAD_MOCK)).toEqual({
- container: {
- acceptTypes: 'test',
- contentletsId: [],
- identifier: 'container-identifier-123',
- maxContentlets: 1,
- uuid: 'uuid-123',
- variantId: '123'
- },
- contentlet: {
- contentType: 'test',
- identifier: 'contentlet-identifier-123',
- inode: 'contentlet-inode-123',
- onNumberOfPages: 1,
- title: 'Hello World'
- },
- language_id: '1',
- pageContainers: [
- {
- contentletsId: ['123', '456'],
- identifier: '5363c6c6-5ba0-4946-b7af-cf875188ac2e',
- uuid: '123'
- },
- {
- contentletsId: ['123'],
- identifier: '5363c6c6-5ba0-4946-b7af-cf875188ac2e',
- uuid: '456'
- },
- {
- contentletsId: ['123', '456'],
- identifier: '/container/path',
- uuid: '123'
- },
- {
- contentletsId: ['123'],
- identifier: '/container/path',
- uuid: '456'
- }
- ],
- pageId: '123',
- personaTag: 'dot:persona',
- position: 'after'
- });
- });
- });
-
- describe('getCurrentTreeNode', () => {
- it('should return the current TreeNode', () => {
- const { container, contentlet } = ACTION_PAYLOAD_MOCK;
-
- expect(store.getCurrentTreeNode(container, contentlet)).toEqual({
- containerId: 'container-identifier-123',
- contentId: 'contentlet-identifier-123',
- pageId: '123',
- personalization: 'dot:persona:dot:persona',
- relationType: 'uuid-123',
- treeOrder: '-1',
- variantId: '123'
- });
- });
- });
-
- describe('setOgTags', () => {
- it('should set the ogTags correctly', () => {
- const ogTags = {
- title: 'Title',
- description: 'Description',
- image: 'Image',
- type: 'Type',
- url: 'URL'
- };
-
- store.setOgTags(ogTags);
-
- expect(store.ogTags()).toEqual(ogTags);
- });
- });
- });
- });
});
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts
index 9e1f001afeba..3001d63aaa00 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts
@@ -121,6 +121,9 @@ export const UVEStore = signalStore(
}
]
};
+ }),
+ $languageId: computed(() => {
+ return pageAPIResponse()?.viewAs.language?.id || 1;
})
};
}
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
new file mode 100644
index 000000000000..eb1047a03f3c
--- /dev/null
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts
@@ -0,0 +1,1077 @@
+import { describe, expect } from '@jest/globals';
+import { SpyObject } from '@ngneat/spectator';
+import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest';
+import { signalStore, withState, patchState } from '@ngrx/signals';
+import { of } from 'rxjs';
+
+import { ActivatedRoute, Router } from '@angular/router';
+
+import { CurrentUser } from '@dotcms/dotcms-js';
+import { DEFAULT_VARIANT_ID, DEFAULT_VARIANT_NAME, DotCMSContentlet } from '@dotcms/dotcms-models';
+import { getRunningExperimentMock, mockDotDevices, seoOGTagsMock } from '@dotcms/utils-testing';
+
+import { withEditor } from './withEditor';
+
+import { DotPageApiParams, DotPageApiService } from '../../../services/dot-page-api.service';
+import { BASE_IFRAME_MEASURE_UNIT, DEFAULT_PERSONA } from '../../../shared/consts';
+import { EDITOR_STATE, UVE_STATUS } from '../../../shared/enums';
+import {
+ ACTION_MOCK,
+ ACTION_PAYLOAD_MOCK,
+ EMA_DRAG_ITEM_CONTENTLET_MOCK,
+ getBoundsMock,
+ MOCK_CONTENTLET_AREA,
+ MOCK_RESPONSE_HEADLESS,
+ MOCK_RESPONSE_VTL
+} from '../../../shared/mocks';
+import { DotDeviceWithIcon } from '../../../shared/models';
+import { getPersonalization, mapContainerStructureToArrayOfContainers } from '../../../utils';
+import { UVEState } from '../../models';
+
+const mockCurrentUser: CurrentUser = {
+ email: 'admin@dotcms.com',
+ givenName: 'Admin',
+ loginAs: true,
+ roleId: 'e7d4e34e-5127-45fc-8123-d48b62d510e3',
+ surname: 'User',
+ userId: 'dotcms.org.1'
+};
+const mockOtherUser: CurrentUser = {
+ email: 'admin2@dotcms.com',
+ givenName: 'Admin2',
+ loginAs: true,
+ roleId: '73ec980e-d74f-4cec-a4d0-e319061e20b9',
+ surname: 'User',
+ userId: 'dotcms.org.2808'
+};
+
+const emptyParams = {} as DotPageApiParams;
+
+const initialState: UVEState = {
+ isEnterprise: true,
+ languages: [],
+ pageAPIResponse: MOCK_RESPONSE_HEADLESS,
+ currentUser: null,
+ experiment: null,
+ errorCode: null,
+ params: {
+ ...emptyParams,
+ url: 'test-url',
+ language_id: '1',
+ 'com.dotmarketing.persona.id': 'dot:persona',
+ variantName: 'DEFAULT',
+ clientHost: 'http://localhost:3000'
+ },
+ status: UVE_STATUS.LOADED,
+ isTraditionalPage: false,
+ canEditPage: true,
+ pageIsLocked: true
+};
+
+export const uveStoreMock = signalStore(withState(initialState), withEditor());
+
+describe('withEditor', () => {
+ let spectator: SpectatorService>;
+ let dotPageApiService: SpyObject;
+ let store: InstanceType;
+
+ const createService = createServiceFactory({
+ service: uveStoreMock,
+ providers: [
+ mockProvider(Router),
+ mockProvider(ActivatedRoute),
+ mockProvider(Router),
+ mockProvider(ActivatedRoute),
+ {
+ provide: DotPageApiService,
+ useValue: {
+ get() {
+ return of({});
+ },
+ getClientPage() {
+ return of({});
+ },
+ save: jest.fn()
+ }
+ }
+ ]
+ });
+
+ beforeEach(() => {
+ spectator = createService();
+ store = spectator.service;
+ dotPageApiService = spectator.inject(DotPageApiService);
+ patchState(store, initialState);
+ });
+
+ describe('withEditorToolbar', () => {
+ describe('withComputed', () => {
+ describe('$toolbarProps', () => {
+ it('should return the base info', () => {
+ expect(store.$toolbarProps()).toEqual({
+ apiUrl: '/api/v1/page/json/test-url?language_id=1&com.dotmarketing.persona.id=dot%3Apersona&variantName=DEFAULT&clientHost=http%3A%2F%2Flocalhost%3A3000',
+ bookmarksUrl: '/test-url?host_id=123-xyz-567-xxl&language_id=1',
+ copyUrl:
+ 'http://localhost:3000/test-url?language_id=1&com.dotmarketing.persona.id=dot%3Apersona&variantName=DEFAULT&host_id=123-xyz-567-xxl',
+ currentLanguage: MOCK_RESPONSE_HEADLESS.viewAs.language,
+ deviceSelector: {
+ apiLink:
+ 'http://localhost:3000/api/v1/page/json/test-url?language_id=1&com.dotmarketing.persona.id=dot%3Apersona&variantName=DEFAULT&clientHost=http%3A%2F%2Flocalhost%3A3000',
+ hideSocialMedia: true
+ },
+ personaSelector: {
+ pageId: MOCK_RESPONSE_HEADLESS.page.identifier,
+ value: MOCK_RESPONSE_HEADLESS.viewAs.persona ?? DEFAULT_PERSONA
+ },
+ runningExperiment: null,
+ showInfoDisplay: false,
+ unlockButton: null,
+ urlContentMap: null,
+ workflowActionsInode: MOCK_RESPONSE_HEADLESS.page.inode
+ });
+ });
+
+ describe('urlContentMap', () => {
+ it('should return the urlContentMap if the state is edit', () => {
+ patchState(store, {
+ pageAPIResponse: {
+ ...MOCK_RESPONSE_HEADLESS,
+ urlContentMap: {
+ title: 'Title',
+ inode: '123',
+ contentType: 'test'
+ } as unknown as DotCMSContentlet
+ }
+ });
+
+ expect(store.$toolbarProps().urlContentMap).toEqual({
+ title: 'Title',
+ inode: '123',
+ contentType: 'test'
+ });
+ });
+
+ it('should not return the urlContentMap if the state is not edit', () => {
+ patchState(store, { isEditState: false });
+ patchState(store, {
+ pageAPIResponse: {
+ ...MOCK_RESPONSE_HEADLESS,
+ urlContentMap: {
+ title: 'Title',
+ inode: '123',
+ contentType: 'test'
+ } as unknown as DotCMSContentlet
+ }
+ });
+
+ expect(store.$toolbarProps().urlContentMap).toEqual(null);
+ });
+ });
+
+ describe('runningExperiment', () => {
+ it('should have a runningExperiment if the experiment is running', () => {
+ patchState(store, { experiment: getRunningExperimentMock() });
+
+ expect(store.$toolbarProps().runningExperiment).toEqual(
+ getRunningExperimentMock()
+ );
+ });
+ });
+
+ describe('workflowActionsInode', () => {
+ it("should not have an workflowActionsInode if the user can't edit the page", () => {
+ patchState(store, { canEditPage: false });
+
+ expect(store.$toolbarProps().workflowActionsInode).toBe(null);
+ });
+ });
+
+ describe('unlockButton', () => {
+ it('should display unlockButton if the page is locked by another user and the current user can lock the page', () => {
+ patchState(store, {
+ pageAPIResponse: {
+ ...MOCK_RESPONSE_HEADLESS,
+ page: {
+ ...MOCK_RESPONSE_HEADLESS.page,
+ locked: true,
+ lockedBy: mockOtherUser.userId,
+ canLock: true
+ }
+ },
+ currentUser: mockCurrentUser,
+ status: UVE_STATUS.LOADED
+ });
+
+ expect(store.$toolbarProps().unlockButton).toEqual({
+ inode: '123-i',
+ loading: false
+ });
+ });
+
+ it('should not display unlockButton if the page is locked by the current user', () => {
+ patchState(store, {
+ pageAPIResponse: {
+ ...MOCK_RESPONSE_HEADLESS,
+ page: {
+ ...MOCK_RESPONSE_HEADLESS.page,
+ locked: true,
+ lockedBy: mockCurrentUser.userId,
+ canLock: true
+ }
+ },
+ currentUser: mockCurrentUser
+ });
+
+ expect(store.$toolbarProps().unlockButton).toBeNull();
+ });
+
+ it('should not display unlockButton if the page is not locked', () => {
+ patchState(store, {
+ pageAPIResponse: {
+ ...MOCK_RESPONSE_HEADLESS,
+ page: {
+ ...MOCK_RESPONSE_HEADLESS.page,
+ locked: false,
+ canLock: true
+ }
+ },
+ currentUser: mockCurrentUser
+ });
+
+ expect(store.$toolbarProps().unlockButton).toBeNull();
+ });
+
+ it('should not display unlockButton if the user cannot lock the page', () => {
+ patchState(store, {
+ pageAPIResponse: {
+ ...MOCK_RESPONSE_HEADLESS,
+ page: {
+ ...MOCK_RESPONSE_HEADLESS.page,
+ locked: true,
+ canLock: false
+ }
+ },
+ currentUser: mockCurrentUser
+ });
+
+ expect(store.$toolbarProps().unlockButton).toBeNull();
+ });
+ });
+
+ describe('shouldShowInfoDisplay', () => {
+ it("should have shouldShowInfoDisplay as true if the user can't edit the page", () => {
+ patchState(store, { canEditPage: false });
+
+ expect(store.$toolbarProps().showInfoDisplay).toBe(true);
+ });
+
+ it('should have shouldShowInfoDisplay as true if the device is set', () => {
+ patchState(store, { device: mockDotDevices[0] });
+
+ expect(store.$toolbarProps().showInfoDisplay).toBe(true);
+ });
+
+ it('should have shouldShowInfoDisplay as true if the socialMedia is set', () => {
+ patchState(store, { socialMedia: 'facebook' });
+
+ expect(store.$toolbarProps().showInfoDisplay).toBe(true);
+ });
+
+ it('should have shouldShowInfoDisplay as true if the page is a variant different from default', () => {
+ patchState(store, {
+ pageAPIResponse: {
+ ...MOCK_RESPONSE_HEADLESS,
+ viewAs: {
+ ...MOCK_RESPONSE_HEADLESS.viewAs,
+ variantId: 'test'
+ }
+ }
+ });
+
+ expect(store.$toolbarProps().showInfoDisplay).toBe(true);
+ });
+ it('should have shouldShowInfoDisplay as true if the page is locked by another user', () => {
+ patchState(store, {
+ pageAPIResponse: {
+ ...MOCK_RESPONSE_HEADLESS,
+ page: {
+ ...MOCK_RESPONSE_HEADLESS.page,
+ locked: true,
+ lockedBy: mockOtherUser.userId
+ }
+ },
+ currentUser: mockCurrentUser
+ });
+
+ expect(store.$toolbarProps().showInfoDisplay).toBe(true);
+ });
+
+ it('should have shouldShowInfoDisplay as false if the page is locked by the current user and other conditions are not met', () => {
+ patchState(store, {
+ pageAPIResponse: {
+ ...MOCK_RESPONSE_HEADLESS,
+ page: {
+ ...MOCK_RESPONSE_HEADLESS.page,
+ locked: true,
+ lockedBy: mockCurrentUser.userId
+ }
+ },
+ currentUser: mockCurrentUser,
+ canEditPage: true,
+ device: null,
+ socialMedia: null
+ });
+
+ expect(store.$toolbarProps().showInfoDisplay).toBe(false);
+ });
+ });
+ });
+
+ describe('$infoDisplayOptions', () => {
+ it('should be null in regular conditions', () => {
+ patchState(store, { canEditPage: true, pageAPIResponse: null });
+ expect(store.$infoDisplayOptions()).toBe(null);
+ });
+
+ it('should return info for device', () => {
+ const device = mockDotDevices[0] as DotDeviceWithIcon;
+
+ patchState(store, { device });
+
+ expect(store.$infoDisplayOptions()).toEqual({
+ icon: device.icon,
+ info: {
+ message: 'iphone 200 x 100',
+ args: []
+ },
+ id: 'device',
+ actionIcon: 'pi pi-times'
+ });
+ });
+
+ it('should return info for socialMedia', () => {
+ patchState(store, { socialMedia: 'Facebook' });
+
+ expect(store.$infoDisplayOptions()).toEqual({
+ icon: 'pi pi-facebook',
+ info: {
+ message: 'Viewing Facebook social media preview',
+ args: []
+ },
+ id: 'socialMedia',
+ actionIcon: 'pi pi-times'
+ });
+ });
+
+ it('should return info when visiting a variant and can edit', () => {
+ const currentExperiment = getRunningExperimentMock();
+
+ const variantID = currentExperiment.trafficProportion.variants.find(
+ (variant) => variant.name !== DEFAULT_VARIANT_NAME
+ ).id;
+
+ patchState(store, {
+ canEditPage: true,
+ pageAPIResponse: {
+ ...MOCK_RESPONSE_HEADLESS,
+ viewAs: {
+ ...MOCK_RESPONSE_HEADLESS.viewAs,
+ variantId: variantID
+ }
+ },
+ experiment: currentExperiment
+ });
+
+ expect(store.$infoDisplayOptions()).toEqual({
+ icon: 'pi pi-file-edit',
+ info: {
+ message: 'editpage.editing.variant',
+ args: ['Variant A']
+ },
+ id: 'variant',
+ actionIcon: 'pi pi-arrow-left'
+ });
+ });
+
+ it('should return info when visiting a variant and can not edit', () => {
+ const currentExperiment = getRunningExperimentMock();
+
+ const variantID = currentExperiment.trafficProportion.variants.find(
+ (variant) => variant.name !== DEFAULT_VARIANT_NAME
+ ).id;
+
+ patchState(store, {
+ pageAPIResponse: {
+ ...MOCK_RESPONSE_HEADLESS,
+ page: {
+ ...MOCK_RESPONSE_HEADLESS.page
+ },
+ viewAs: {
+ ...MOCK_RESPONSE_HEADLESS.viewAs,
+ variantId: variantID
+ }
+ },
+ experiment: currentExperiment,
+ canEditPage: false
+ });
+
+ expect(store.$infoDisplayOptions()).toEqual({
+ icon: 'pi pi-file-edit',
+ info: {
+ message: 'editpage.viewing.variant',
+ args: ['Variant A']
+ },
+ id: 'variant',
+ actionIcon: 'pi pi-arrow-left'
+ });
+ });
+
+ it('should return info when the page is locked and can lock', () => {
+ patchState(store, {
+ pageAPIResponse: {
+ ...MOCK_RESPONSE_HEADLESS,
+ page: {
+ ...MOCK_RESPONSE_HEADLESS.page,
+ locked: true,
+ canLock: true,
+ lockedByName: 'John Doe'
+ }
+ }
+ });
+
+ expect(store.$infoDisplayOptions()).toEqual({
+ icon: 'pi pi-lock',
+ info: {
+ message: 'editpage.locked-by',
+ args: ['John Doe']
+ },
+ id: 'locked'
+ });
+ });
+
+ it('should return info when the page is locked and cannot lock', () => {
+ patchState(store, {
+ pageAPIResponse: {
+ ...MOCK_RESPONSE_HEADLESS,
+ page: {
+ ...MOCK_RESPONSE_HEADLESS.page,
+ locked: true,
+ canLock: false,
+ lockedByName: 'John Doe'
+ }
+ }
+ });
+
+ expect(store.$infoDisplayOptions()).toEqual({
+ icon: 'pi pi-lock',
+ info: {
+ message: 'editpage.locked-contact-with',
+ args: ['John Doe']
+ },
+ id: 'locked'
+ });
+ });
+
+ it('should return info when you cannot edit the page', () => {
+ patchState(store, { canEditPage: false });
+
+ expect(store.$infoDisplayOptions()).toEqual({
+ icon: 'pi pi-exclamation-circle warning',
+ info: { message: 'editema.dont.have.edit.permission', args: [] },
+ id: 'no-permission'
+ });
+ });
+ });
+ });
+
+ describe('withMethods', () => {
+ it('should set the device with setDevice', () => {
+ const device = {
+ identifier: '123',
+ cssHeight: '120',
+ cssWidth: '120',
+ name: 'square',
+ inode: '1234',
+ icon: 'icon'
+ };
+
+ store.setDevice(device);
+
+ expect(store.device()).toEqual(device);
+ expect(store.isEditState()).toBe(false);
+ });
+
+ it('should set the socialMedia with setSocialMedia', () => {
+ const socialMedia = 'facebook';
+
+ store.setSocialMedia(socialMedia);
+
+ expect(store.socialMedia()).toEqual(socialMedia);
+ expect(store.isEditState()).toBe(false);
+ });
+
+ it('should reset the state with clearDeviceAndSocialMedia', () => {
+ store.clearDeviceAndSocialMedia();
+
+ expect(store.device()).toBe(null);
+ expect(store.socialMedia()).toBe(null);
+ expect(store.isEditState()).toBe(true);
+ });
+ });
+ });
+
+ describe('withSave', () => {
+ describe('withMethods', () => {
+ describe('savePage', () => {
+ it('should perform a save and patch the state', () => {
+ const saveSpy = jest
+ .spyOn(dotPageApiService, 'save')
+ .mockImplementation(() => of({}));
+
+ // It's impossible to get a VTL when we are in Headless
+ // but I just want to check the state is being patched
+ const getClientPageSpy = jest
+ .spyOn(dotPageApiService, 'getClientPage')
+ .mockImplementation(() => of(MOCK_RESPONSE_VTL));
+
+ const payload = {
+ pageContainers: ACTION_PAYLOAD_MOCK.pageContainers,
+ pageId: MOCK_RESPONSE_HEADLESS.page.identifier,
+ params: store.params()
+ };
+
+ store.savePage(ACTION_PAYLOAD_MOCK.pageContainers);
+
+ expect(saveSpy).toHaveBeenCalledWith(payload);
+
+ expect(getClientPageSpy).toHaveBeenCalledWith(
+ store.params(),
+ store.clientRequestProps()
+ );
+
+ expect(store.status()).toBe(UVE_STATUS.LOADED);
+ expect(store.pageAPIResponse()).toEqual(MOCK_RESPONSE_VTL);
+ });
+ });
+ });
+ });
+
+ describe('withComputed', () => {
+ describe('$pageData', () => {
+ it('should return the expected data', () => {
+ expect(store.$pageData()).toEqual({
+ containers: mapContainerStructureToArrayOfContainers(
+ MOCK_RESPONSE_HEADLESS.containers
+ ),
+ id: MOCK_RESPONSE_HEADLESS.page.identifier,
+ personalization: getPersonalization(MOCK_RESPONSE_HEADLESS.viewAs.persona),
+ languageId: MOCK_RESPONSE_HEADLESS.viewAs.language.id,
+ personaTag: MOCK_RESPONSE_HEADLESS.viewAs.persona.keyTag
+ });
+ });
+ });
+
+ describe('$reloadEditorContent', () => {
+ it('should return the expected data for Headless', () => {
+ patchState(store, {
+ pageAPIResponse: MOCK_RESPONSE_HEADLESS,
+ isTraditionalPage: false
+ });
+
+ expect(store.$reloadEditorContent()).toEqual({
+ code: MOCK_RESPONSE_HEADLESS.page.rendered,
+ isTraditionalPage: false,
+ enableInlineEdit: true,
+ isClientReady: false
+ });
+ });
+ it('should return the expected data for VTL', () => {
+ patchState(store, {
+ pageAPIResponse: MOCK_RESPONSE_VTL,
+ isTraditionalPage: true,
+ isClientReady: true
+ });
+ expect(store.$reloadEditorContent()).toEqual({
+ code: MOCK_RESPONSE_VTL.page.rendered,
+ isTraditionalPage: true,
+ enableInlineEdit: true,
+ isClientReady: true
+ });
+ });
+ });
+
+ describe('$editorIsInDraggingState', () => {
+ it("should return the editor's dragging state", () => {
+ expect(store.$editorIsInDraggingState()).toBe(false);
+ });
+
+ it("should return the editor's dragging state after a change", () => {
+ // This will trigger a change in the dragging state
+ store.setEditorDragItem(EMA_DRAG_ITEM_CONTENTLET_MOCK);
+
+ expect(store.$editorIsInDraggingState()).toBe(true);
+ });
+ });
+
+ describe('$editorProps', () => {
+ it('should return the expected data on init', () => {
+ expect(store.$editorProps()).toEqual({
+ showDialogs: true,
+ showEditorContent: true,
+ iframe: {
+ opacity: '0.5',
+ pointerEvents: 'auto',
+ src: 'http://localhost:3000/test-url?language_id=1&com.dotmarketing.persona.id=dot%3Apersona&variantName=DEFAULT&clientHost=http%3A%2F%2Flocalhost%3A3000',
+ wrapper: null
+ },
+ progressBar: true,
+ contentletTools: null,
+ dropzone: null,
+ palette: {
+ variantId: DEFAULT_VARIANT_ID,
+ languageId: MOCK_RESPONSE_HEADLESS.viewAs.language.id,
+ containers: MOCK_RESPONSE_HEADLESS.containers
+ },
+ seoResults: null
+ });
+ });
+
+ it('should set iframe opacity to 1 when client is Ready', () => {
+ store.setIsClientReady(true);
+
+ expect(store.$editorProps().iframe.opacity).toBe('1');
+ });
+
+ describe('showDialogs', () => {
+ it('should have the value of false when we cannot edit the page', () => {
+ patchState(store, { canEditPage: false });
+
+ expect(store.$editorProps().showDialogs).toBe(false);
+ });
+
+ it('should have the value of false when we are not on edit state', () => {
+ patchState(store, { isEditState: false });
+
+ expect(store.$editorProps().showDialogs).toBe(false);
+ });
+ });
+
+ describe('showEditorContent', () => {
+ it('should have showEditorContent as true when there is no socialMedia', () => {
+ expect(store.$editorProps().showEditorContent).toBe(true);
+ });
+ });
+
+ describe('iframe', () => {
+ it('should have an opacity of 0.5 when loading', () => {
+ patchState(store, { status: UVE_STATUS.LOADING });
+
+ expect(store.$editorProps().iframe.opacity).toBe('0.5');
+ });
+
+ it('should have pointerEvents as none when dragging', () => {
+ patchState(store, { state: EDITOR_STATE.DRAGGING });
+
+ expect(store.$editorProps().iframe.pointerEvents).toBe('none');
+ });
+
+ it('should have pointerEvents as none when scroll-drag', () => {
+ patchState(store, { state: EDITOR_STATE.SCROLL_DRAG });
+
+ expect(store.$editorProps().iframe.pointerEvents).toBe('none');
+ });
+
+ it('should have src as empty when the page is traditional', () => {
+ patchState(store, {
+ pageAPIResponse: MOCK_RESPONSE_VTL,
+ isTraditionalPage: true
+ });
+
+ expect(store.$editorProps().iframe.src).toBe('');
+ });
+
+ it('should have a wrapper when a device is present', () => {
+ const device = mockDotDevices[0] as DotDeviceWithIcon;
+
+ patchState(store, { device });
+
+ expect(store.$editorProps().iframe.wrapper).toEqual({
+ width: device.cssWidth + BASE_IFRAME_MEASURE_UNIT,
+ height: device.cssHeight + BASE_IFRAME_MEASURE_UNIT
+ });
+ });
+ });
+
+ describe('progressBar', () => {
+ it('should have progressBar as true when the status is loading', () => {
+ patchState(store, { status: UVE_STATUS.LOADING });
+
+ expect(store.$editorProps().progressBar).toBe(true);
+ });
+
+ it('should have progressBar as true when the status is loaded but client is not ready', () => {
+ patchState(store, { status: UVE_STATUS.LOADED, isClientReady: false });
+
+ expect(store.$editorProps().progressBar).toBe(true);
+ });
+
+ it('should have progressBar as false when the status is loaded and client is ready', () => {
+ patchState(store, { status: UVE_STATUS.LOADED, isClientReady: true });
+
+ expect(store.$editorProps().progressBar).toBe(false);
+ });
+ });
+
+ describe('contentletTools', () => {
+ it('should have contentletTools when contentletArea are present, can edit the page, is in edit state and not scrolling', () => {
+ patchState(store, {
+ isEditState: true,
+ canEditPage: true,
+ contentletArea: MOCK_CONTENTLET_AREA,
+ state: EDITOR_STATE.IDLE
+ });
+
+ expect(store.$editorProps().contentletTools).toEqual({
+ isEnterprise: true,
+ contentletArea: MOCK_CONTENTLET_AREA,
+ hide: false
+ });
+ });
+
+ it('should have hide as true when dragging', () => {
+ patchState(store, {
+ isEditState: true,
+ canEditPage: true,
+ contentletArea: MOCK_CONTENTLET_AREA,
+ state: EDITOR_STATE.DRAGGING
+ });
+
+ expect(store.$editorProps().contentletTools).toEqual({
+ isEnterprise: true,
+ contentletArea: MOCK_CONTENTLET_AREA,
+ hide: true
+ });
+ });
+
+ it('should be null when scrolling', () => {
+ patchState(store, {
+ isEditState: true,
+ canEditPage: true,
+ contentletArea: MOCK_CONTENTLET_AREA,
+ state: EDITOR_STATE.SCROLLING
+ });
+
+ expect(store.$editorProps().contentletTools).toEqual(null);
+ });
+
+ it("should not have contentletTools when the page can't be edited", () => {
+ patchState(store, {
+ isEditState: true,
+ canEditPage: false,
+ contentletArea: MOCK_CONTENTLET_AREA,
+ state: EDITOR_STATE.IDLE
+ });
+
+ expect(store.$editorProps().contentletTools).toBe(null);
+ });
+
+ it('should not have contentletTools when the contentletArea is not present', () => {
+ patchState(store, {
+ isEditState: true,
+ canEditPage: true,
+ state: EDITOR_STATE.IDLE
+ });
+
+ expect(store.$editorProps().contentletTools).toBe(null);
+ });
+
+ it('should not have contentletTools when the we are not in edit state', () => {
+ patchState(store, {
+ isEditState: false,
+ canEditPage: true,
+ contentletArea: MOCK_CONTENTLET_AREA,
+ state: EDITOR_STATE.IDLE
+ });
+
+ expect(store.$editorProps().contentletTools).toBe(null);
+ });
+ });
+ describe('dropzone', () => {
+ const bounds = getBoundsMock(ACTION_MOCK);
+
+ it('should have dropzone when the state is dragging and the page can be edited', () => {
+ patchState(store, {
+ state: EDITOR_STATE.DRAGGING,
+ canEditPage: true,
+ dragItem: EMA_DRAG_ITEM_CONTENTLET_MOCK,
+ bounds
+ });
+
+ expect(store.$editorProps().dropzone).toEqual({
+ dragItem: EMA_DRAG_ITEM_CONTENTLET_MOCK,
+ bounds
+ });
+ });
+
+ it("should not have dropzone when the page can't be edited", () => {
+ patchState(store, {
+ state: EDITOR_STATE.DRAGGING,
+ canEditPage: false,
+ dragItem: EMA_DRAG_ITEM_CONTENTLET_MOCK,
+ bounds
+ });
+
+ expect(store.$editorProps().dropzone).toBe(null);
+ });
+ });
+
+ describe('palette', () => {
+ it('should be null if is not enterprise', () => {
+ patchState(store, { isEnterprise: false });
+
+ expect(store.$editorProps().palette).toBe(null);
+ });
+
+ it('should be null if canEditPage is false', () => {
+ patchState(store, { canEditPage: false });
+
+ expect(store.$editorProps().palette).toBe(null);
+ });
+
+ it('should be null if isEditState is false', () => {
+ patchState(store, { isEditState: false });
+
+ expect(store.$editorProps().palette).toBe(null);
+ });
+ });
+
+ describe('seoResults', () => {
+ it('should have the expected data when ogTags and socialMedia is present', () => {
+ patchState(store, {
+ ogTags: seoOGTagsMock,
+ socialMedia: 'facebook'
+ });
+
+ expect(store.$editorProps().seoResults).toEqual({
+ ogTags: seoOGTagsMock,
+ socialMedia: 'facebook'
+ });
+ });
+
+ it('should be null when ogTags is not present', () => {
+ patchState(store, {
+ socialMedia: 'facebook'
+ });
+
+ expect(store.$editorProps().seoResults).toBe(null);
+ });
+
+ it('should be null when socialMedia is not present', () => {
+ patchState(store, {
+ ogTags: seoOGTagsMock
+ });
+
+ expect(store.$editorProps().seoResults).toBe(null);
+ });
+ });
+ });
+ });
+
+ describe('withMethods', () => {
+ describe('updateEditorScrollState', () => {
+ it("should update the editor's scroll state and remove bounds when there is no drag item", () => {
+ store.updateEditorScrollState();
+
+ expect(store.state()).toEqual(EDITOR_STATE.SCROLLING);
+ expect(store.bounds()).toEqual([]);
+ });
+
+ it("should update the editor's scroll drag state and remove bounds when there is drag item", () => {
+ store.setEditorDragItem(EMA_DRAG_ITEM_CONTENTLET_MOCK);
+ store.setEditorBounds(getBoundsMock(ACTION_MOCK));
+
+ store.updateEditorScrollState();
+
+ expect(store.state()).toEqual(EDITOR_STATE.SCROLL_DRAG);
+ expect(store.bounds()).toEqual([]);
+ });
+
+ it('should set the contentletArea to null when we are scrolling', () => {
+ store.setEditorState(EDITOR_STATE.SCROLLING);
+
+ store.updateEditorScrollState();
+
+ expect(store.contentletArea()).toBe(null);
+ });
+ });
+
+ describe('updateEditorOnScrollEnd', () => {
+ it("should update the editor's drag state when there is no drag item", () => {
+ store.updateEditorOnScrollEnd();
+
+ expect(store.state()).toEqual(EDITOR_STATE.IDLE);
+ });
+
+ it("should update the editor's drag state when there is drag item", () => {
+ store.setEditorDragItem(EMA_DRAG_ITEM_CONTENTLET_MOCK);
+
+ store.updateEditorOnScrollEnd();
+
+ expect(store.state()).toEqual(EDITOR_STATE.DRAGGING);
+ });
+ });
+
+ describe('updateEditorScrollDragState', () => {
+ it('should update the store correctly', () => {
+ store.updateEditorScrollDragState();
+
+ expect(store.state()).toEqual(EDITOR_STATE.SCROLL_DRAG);
+ expect(store.bounds()).toEqual([]);
+ });
+ });
+
+ describe('setEditorState', () => {
+ it('should update the state correctly', () => {
+ store.setEditorState(EDITOR_STATE.SCROLLING);
+
+ expect(store.state()).toEqual(EDITOR_STATE.SCROLLING);
+ });
+ });
+
+ describe('setEditorDragItem', () => {
+ it('should update the store correctly', () => {
+ store.setEditorDragItem(EMA_DRAG_ITEM_CONTENTLET_MOCK);
+
+ expect(store.dragItem()).toEqual(EMA_DRAG_ITEM_CONTENTLET_MOCK);
+ expect(store.state()).toEqual(EDITOR_STATE.DRAGGING);
+ });
+ });
+
+ describe('setEditorContentletArea', () => {
+ it("should update the store's contentlet area", () => {
+ store.setEditorContentletArea(MOCK_CONTENTLET_AREA);
+
+ expect(store.contentletArea()).toEqual(MOCK_CONTENTLET_AREA);
+ expect(store.state()).toEqual(EDITOR_STATE.IDLE);
+ });
+
+ it('should not update contentletArea if it is the same', () => {
+ store.setEditorContentletArea(MOCK_CONTENTLET_AREA);
+
+ // We can have contentletArea and state at the same time we are inline editing
+ store.setEditorState(EDITOR_STATE.INLINE_EDITING);
+
+ store.setEditorContentletArea(MOCK_CONTENTLET_AREA);
+
+ expect(store.contentletArea()).toEqual(MOCK_CONTENTLET_AREA);
+ // State should not change
+ expect(store.state()).toEqual(EDITOR_STATE.INLINE_EDITING);
+ });
+ });
+
+ describe('setEditorBounds', () => {
+ const bounds = getBoundsMock(ACTION_MOCK);
+
+ it('should update the store correcly', () => {
+ store.setEditorBounds(bounds);
+
+ expect(store.bounds()).toEqual(bounds);
+ });
+ });
+
+ describe('resetEditorProperties', () => {
+ it('should reset the editor props corretcly', () => {
+ store.setEditorDragItem(EMA_DRAG_ITEM_CONTENTLET_MOCK);
+ store.setEditorState(EDITOR_STATE.SCROLLING);
+ store.setEditorContentletArea(MOCK_CONTENTLET_AREA);
+ store.setEditorBounds(getBoundsMock(ACTION_MOCK));
+
+ store.resetEditorProperties();
+
+ expect(store.dragItem()).toBe(null);
+ expect(store.state()).toEqual(EDITOR_STATE.IDLE);
+ expect(store.contentletArea()).toBe(null);
+ expect(store.bounds()).toEqual([]);
+ });
+ });
+ describe('getPageSavePayload', () => {
+ it("should return the page's save payload", () => {
+ expect(store.getPageSavePayload(ACTION_PAYLOAD_MOCK)).toEqual({
+ container: {
+ acceptTypes: 'test',
+ contentletsId: [],
+ identifier: 'container-identifier-123',
+ maxContentlets: 1,
+ uuid: 'uuid-123',
+ variantId: '123'
+ },
+ contentlet: {
+ contentType: 'test',
+ identifier: 'contentlet-identifier-123',
+ inode: 'contentlet-inode-123',
+ onNumberOfPages: 1,
+ title: 'Hello World'
+ },
+ language_id: '1',
+ pageContainers: [
+ {
+ contentletsId: ['123', '456'],
+ identifier: '5363c6c6-5ba0-4946-b7af-cf875188ac2e',
+ uuid: '123'
+ },
+ {
+ contentletsId: ['123'],
+ identifier: '5363c6c6-5ba0-4946-b7af-cf875188ac2e',
+ uuid: '456'
+ },
+ {
+ contentletsId: ['123', '456'],
+ identifier: '/container/path',
+ uuid: '123'
+ },
+ {
+ contentletsId: ['123'],
+ identifier: '/container/path',
+ uuid: '456'
+ }
+ ],
+ pageId: '123',
+ personaTag: 'dot:persona',
+ position: 'after'
+ });
+ });
+ });
+
+ describe('getCurrentTreeNode', () => {
+ it('should return the current TreeNode', () => {
+ const { container, contentlet } = ACTION_PAYLOAD_MOCK;
+
+ expect(store.getCurrentTreeNode(container, contentlet)).toEqual({
+ containerId: 'container-identifier-123',
+ contentId: 'contentlet-identifier-123',
+ pageId: '123',
+ personalization: 'dot:persona:dot:persona',
+ relationType: 'uuid-123',
+ treeOrder: '-1',
+ variantId: '123'
+ });
+ });
+ });
+
+ describe('setOgTags', () => {
+ it('should set the ogTags correctly', () => {
+ const ogTags = {
+ title: 'Title',
+ description: 'Description',
+ image: 'Image',
+ type: 'Type',
+ url: 'URL'
+ };
+
+ store.setOgTags(ogTags);
+
+ expect(store.ogTags()).toEqual(ogTags);
+ });
+ });
+ });
+});
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/layout/wihtLayout.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/layout/wihtLayout.spec.ts
new file mode 100644
index 000000000000..5a87bf8c2882
--- /dev/null
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/layout/wihtLayout.spec.ts
@@ -0,0 +1,77 @@
+import { describe, expect } from '@jest/globals';
+import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest';
+import { signalStore, withState } from '@ngrx/signals';
+
+import { ActivatedRoute, Router } from '@angular/router';
+
+import { withLayout } from './withLayout';
+
+import { DotPageApiParams } from '../../../services/dot-page-api.service';
+import { UVE_STATUS } from '../../../shared/enums';
+import { MOCK_RESPONSE_HEADLESS } from '../../../shared/mocks';
+import { mapContainerStructureToDotContainerMap } from '../../../utils';
+import { UVEState } from '../../models';
+
+const emptyParams = {} as DotPageApiParams;
+
+const initialState: UVEState = {
+ isEnterprise: false,
+ languages: [],
+ pageAPIResponse: MOCK_RESPONSE_HEADLESS,
+ currentUser: null,
+ experiment: null,
+ errorCode: null,
+ params: emptyParams,
+ status: UVE_STATUS.LOADING,
+ isTraditionalPage: true,
+ canEditPage: false,
+ pageIsLocked: true
+};
+
+export const uveStoreMock = signalStore(withState(initialState), withLayout());
+
+describe('withLayout', () => {
+ let spectator: SpectatorService>;
+ let store: InstanceType;
+ const createService = createServiceFactory({
+ service: uveStoreMock,
+ providers: [mockProvider(Router), mockProvider(ActivatedRoute)]
+ });
+
+ beforeEach(() => {
+ spectator = createService();
+ store = spectator.service;
+ });
+
+ describe('withComputed', () => {
+ describe('$layoutProps', () => {
+ it('should return the layout props', () => {
+ expect(store.$layoutProps()).toEqual({
+ containersMap: mapContainerStructureToDotContainerMap(
+ MOCK_RESPONSE_HEADLESS.containers
+ ),
+ layout: MOCK_RESPONSE_HEADLESS.layout,
+ template: {
+ identifier: MOCK_RESPONSE_HEADLESS.template.identifier,
+ themeId: MOCK_RESPONSE_HEADLESS.template.theme,
+ anonymous: false
+ },
+ pageId: MOCK_RESPONSE_HEADLESS.page.identifier
+ });
+ });
+ });
+ });
+
+ describe('withMethods', () => {
+ it('should update the layout', () => {
+ const layout = {
+ ...MOCK_RESPONSE_HEADLESS.layout,
+ title: 'New layout'
+ };
+
+ store.updateLayout(layout);
+
+ expect(store.pageAPIResponse().layout).toEqual(layout);
+ });
+ });
+});
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/load/withLoad.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/load/withLoad.spec.ts
new file mode 100644
index 000000000000..d753c4ffd7fe
--- /dev/null
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/load/withLoad.spec.ts
@@ -0,0 +1,354 @@
+import { describe, expect } from '@jest/globals';
+import {
+ createServiceFactory,
+ mockProvider,
+ SpectatorService,
+ SpyObject
+} from '@ngneat/spectator/jest';
+import { signalStore, withState } from '@ngrx/signals';
+import { of } from 'rxjs';
+
+import { ActivatedRoute, ActivatedRouteSnapshot, ParamMap, Router } from '@angular/router';
+
+import {
+ DotExperimentsService,
+ DotLanguagesService,
+ DotLicenseService,
+ DotMessageService
+} from '@dotcms/data-access';
+import { LoginService } from '@dotcms/dotcms-js';
+import {
+ CurrentUserDataMock,
+ DotLanguagesServiceMock,
+ getDraftExperimentMock,
+ getRunningExperimentMock,
+ getScheduleExperimentMock,
+ MockDotMessageService,
+ mockLanguageArray
+} from '@dotcms/utils-testing';
+
+import { withLoad } from './withLoad';
+
+import {
+ DotPageApiParams,
+ DotPageApiResponse,
+ DotPageApiService
+} from '../../../services/dot-page-api.service';
+import { UVE_STATUS } from '../../../shared/enums';
+import {
+ getVanityUrl,
+ HEADLESS_BASE_QUERY_PARAMS,
+ MOCK_RESPONSE_HEADLESS,
+ MOCK_RESPONSE_VTL,
+ PERMANENT_REDIRECT_VANITY_URL,
+ TEMPORARY_REDIRECT_VANITY_URL,
+ VTL_BASE_QUERY_PARAMS
+} from '../../../shared/mocks';
+import { UVEState } from '../../models';
+
+const buildPageAPIResponseFromMock =
+ (mock) =>
+ ({ url }) =>
+ of({
+ ...mock,
+ page: {
+ ...mock.page,
+ pageURI: url
+ }
+ });
+const emptyParams = {} as DotPageApiParams;
+
+const initialState: UVEState = {
+ isEnterprise: false,
+ languages: [],
+ pageAPIResponse: null,
+ currentUser: null,
+ experiment: null,
+ errorCode: null,
+ params: emptyParams,
+ status: UVE_STATUS.LOADING,
+ isTraditionalPage: true,
+ canEditPage: false,
+ pageIsLocked: true
+};
+
+export const uveStoreMock = signalStore(withState(initialState), withLoad());
+
+describe('withLoad', () => {
+ let spectator: SpectatorService>;
+ let store: InstanceType;
+ let dotPageApiService: SpyObject;
+ let activatedRoute: SpyObject;
+ let router: SpyObject;
+
+ const createService = createServiceFactory({
+ service: uveStoreMock,
+ providers: [
+ mockProvider(Router),
+ mockProvider(ActivatedRoute),
+ {
+ provide: DotPageApiService,
+ useValue: {
+ get() {
+ return of({});
+ },
+ getClientPage() {
+ return of({});
+ },
+ save: jest.fn()
+ }
+ },
+ {
+ provide: DotLicenseService,
+ useValue: {
+ isEnterprise: () => of(true)
+ }
+ },
+ {
+ provide: DotMessageService,
+ useValue: new MockDotMessageService({})
+ },
+ {
+ provide: LoginService,
+ useValue: {
+ getCurrentUser: () => of(CurrentUserDataMock)
+ }
+ },
+ {
+ provide: DotLanguagesService,
+ useValue: new DotLanguagesServiceMock()
+ },
+
+ {
+ provide: DotExperimentsService,
+ useValue: {
+ getById(experimentId: string) {
+ if (experimentId == 'i-have-a-running-experiment') {
+ return of(getRunningExperimentMock());
+ } else if (experimentId == 'i-have-a-scheduled-experiment') {
+ return of(getScheduleExperimentMock());
+ } else if (experimentId) return of(getDraftExperimentMock());
+
+ return of(undefined);
+ }
+ }
+ }
+ ]
+ });
+
+ beforeEach(() => {
+ spectator = createService();
+ store = spectator.service;
+
+ dotPageApiService = spectator.inject(DotPageApiService);
+ router = spectator.inject(Router);
+ activatedRoute = spectator.inject(ActivatedRoute);
+ jest.spyOn(dotPageApiService, 'get').mockImplementation(
+ buildPageAPIResponseFromMock(MOCK_RESPONSE_HEADLESS)
+ );
+
+ store.init(HEADLESS_BASE_QUERY_PARAMS);
+ });
+
+ describe('withMethods', () => {
+ it('should load the store with the base data', () => {
+ expect(store.pageAPIResponse()).toEqual(MOCK_RESPONSE_HEADLESS);
+ expect(store.isEnterprise()).toBe(true);
+ expect(store.currentUser()).toEqual(CurrentUserDataMock);
+ expect(store.experiment()).toBe(undefined);
+ expect(store.languages()).toBe(mockLanguageArray);
+ expect(store.params()).toEqual(HEADLESS_BASE_QUERY_PARAMS);
+ expect(store.canEditPage()).toBe(true);
+ expect(store.pageIsLocked()).toBe(false);
+ expect(store.status()).toBe(UVE_STATUS.LOADED);
+ expect(store.isTraditionalPage()).toBe(false);
+ expect(store.isClientReady()).toBe(false);
+ });
+
+ it('should load the store with the base data for traditional page', () => {
+ jest.spyOn(dotPageApiService, 'get').mockImplementation(
+ buildPageAPIResponseFromMock(MOCK_RESPONSE_VTL)
+ );
+
+ store.init(VTL_BASE_QUERY_PARAMS);
+
+ expect(store.pageAPIResponse()).toEqual(MOCK_RESPONSE_VTL);
+ expect(store.isEnterprise()).toBe(true);
+ expect(store.currentUser()).toEqual(CurrentUserDataMock);
+ expect(store.experiment()).toBe(undefined);
+ expect(store.languages()).toBe(mockLanguageArray);
+ expect(store.params()).toEqual(VTL_BASE_QUERY_PARAMS);
+ expect(store.canEditPage()).toBe(true);
+ expect(store.pageIsLocked()).toBe(false);
+ expect(store.status()).toBe(UVE_STATUS.LOADED);
+ expect(store.isTraditionalPage()).toBe(true);
+ expect(store.isClientReady()).toBe(true);
+ });
+
+ it('should navigate when the page is a vanityUrl permanent redirect', () => {
+ const permanentRedirect = getVanityUrl(
+ VTL_BASE_QUERY_PARAMS.url,
+ PERMANENT_REDIRECT_VANITY_URL
+ ) as unknown as DotPageApiResponse;
+
+ const forwardTo = PERMANENT_REDIRECT_VANITY_URL.forwardTo;
+
+ jest.spyOn(dotPageApiService, 'get').mockImplementation(() => of(permanentRedirect));
+
+ store.init(VTL_BASE_QUERY_PARAMS);
+
+ expect(router.navigate).toHaveBeenCalledWith([], {
+ queryParams: {
+ ...VTL_BASE_QUERY_PARAMS,
+ url: forwardTo
+ },
+ queryParamsHandling: 'merge'
+ });
+ });
+
+ it('should navigate when the page is a vanityUrl temporary redirect', () => {
+ const temporaryRedirect = getVanityUrl(
+ VTL_BASE_QUERY_PARAMS.url,
+ TEMPORARY_REDIRECT_VANITY_URL
+ ) as unknown as DotPageApiResponse;
+
+ const forwardTo = TEMPORARY_REDIRECT_VANITY_URL.forwardTo;
+
+ jest.spyOn(dotPageApiService, 'get').mockImplementation(() => of(temporaryRedirect));
+
+ store.init(VTL_BASE_QUERY_PARAMS);
+
+ expect(router.navigate).toHaveBeenCalledWith([], {
+ queryParams: {
+ ...VTL_BASE_QUERY_PARAMS,
+ url: forwardTo
+ },
+ queryParamsHandling: 'merge'
+ });
+ });
+
+ it('should navigate to content when the layout is disable by page.canEdit and current route is layout', () => {
+ jest.spyOn(dotPageApiService, 'get').mockImplementation(() =>
+ of({
+ ...MOCK_RESPONSE_VTL,
+ page: {
+ ...MOCK_RESPONSE_VTL.page,
+ canEdit: false
+ }
+ })
+ );
+
+ jest.spyOn(activatedRoute, 'firstChild', 'get').mockReturnValue({
+ snapshot: {
+ url: [
+ {
+ path: 'layout',
+ parameters: {},
+ parameterMap: {} as unknown as ParamMap
+ }
+ ]
+ } as unknown as ActivatedRouteSnapshot
+ } as unknown as ActivatedRoute);
+
+ store.init(VTL_BASE_QUERY_PARAMS);
+
+ expect(router.navigate).toHaveBeenCalledWith(['edit-page/content'], {
+ queryParamsHandling: 'merge'
+ });
+ });
+
+ it('should navigate to content when the layout is disable by template.drawed and current route is layout', () => {
+ jest.spyOn(dotPageApiService, 'get').mockImplementation(() =>
+ of({
+ ...MOCK_RESPONSE_VTL,
+ template: {
+ ...MOCK_RESPONSE_VTL.template,
+ drawed: false
+ }
+ })
+ );
+
+ jest.spyOn(activatedRoute, 'firstChild', 'get').mockReturnValue({
+ snapshot: {
+ url: [
+ {
+ path: 'layout',
+ parameters: {},
+ parameterMap: {} as unknown as ParamMap
+ }
+ ]
+ } as unknown as ActivatedRouteSnapshot
+ } as unknown as ActivatedRoute);
+
+ store.init(VTL_BASE_QUERY_PARAMS);
+
+ expect(router.navigate).toHaveBeenCalledWith(['edit-page/content'], {
+ queryParamsHandling: 'merge'
+ });
+ });
+
+ it('should not navigate to content when the layout is disable by template.drawed and current route is not layout', () => {
+ jest.spyOn(dotPageApiService, 'get').mockImplementation(() =>
+ of({
+ ...MOCK_RESPONSE_VTL,
+ template: {
+ ...MOCK_RESPONSE_VTL.template,
+ drawed: false
+ }
+ })
+ );
+
+ jest.spyOn(activatedRoute, 'firstChild', 'get').mockReturnValue({
+ snapshot: {
+ url: [
+ {
+ path: 'rules',
+ parameters: {},
+ parameterMap: {} as unknown as ParamMap
+ }
+ ]
+ } as unknown as ActivatedRouteSnapshot
+ } as unknown as ActivatedRoute);
+
+ store.init(VTL_BASE_QUERY_PARAMS);
+
+ expect(router.navigate).not.toHaveBeenCalled();
+ });
+
+ it('should not navigate to content when the layout is disable by page.canEdit and current route is not layout', () => {
+ jest.spyOn(dotPageApiService, 'get').mockImplementation(() =>
+ of({
+ ...MOCK_RESPONSE_VTL,
+ page: {
+ ...MOCK_RESPONSE_VTL.page,
+ canEdit: false
+ }
+ })
+ );
+
+ jest.spyOn(activatedRoute, 'firstChild', 'get').mockReturnValue({
+ snapshot: {
+ url: [
+ {
+ path: 'rules',
+ parameters: {},
+ parameterMap: {} as unknown as ParamMap
+ }
+ ]
+ } as unknown as ActivatedRouteSnapshot
+ } as unknown as ActivatedRoute);
+
+ store.init(VTL_BASE_QUERY_PARAMS);
+
+ expect(router.navigate).not.toHaveBeenCalled();
+ });
+
+ it('should reload the store with the same queryParams', () => {
+ const getPageSpy = jest.spyOn(dotPageApiService, 'get');
+
+ store.reload();
+
+ expect(getPageSpy).toHaveBeenCalledWith(store.params());
+ });
+ });
+});
diff --git a/core-web/libs/sdk/angular/README.md b/core-web/libs/sdk/angular/README.md
index 058070ea906a..e1ac4424e36d 100644
--- a/core-web/libs/sdk/angular/README.md
+++ b/core-web/libs/sdk/angular/README.md
@@ -1,6 +1,6 @@
# @dotcms/angular
-`@dotcms/angular` is the official Angular library designed to work seamlessly with dotCMS. This library simplifies the process of rendering dotCMS pages and integrating with the [Universal Visual Editor](dotcms.com/docs/latest/universal-visual-editor) in your Angular applications.
+`@dotcms/angular` is the official Angular library designed to work seamlessly with dotCMS. This library simplifies the process of rendering dotCMS pages and integrating with the [Universal Visual Editor](https://www.dotcms.com/docs/latest/universal-visual-editor) in your Angular applications.
## Table of Contents
@@ -18,7 +18,7 @@
## Features
-- A set of Angular components developer for dotCMS page rendering and editor integration.
+- A set of Angular components developed for dotCMS page rendering and editor integration.
- Enhanced development workflow with full TypeScript support.
- Optimized performance for efficient rendering of dotCMS pages in Angular applications.
- Flexible customization options to adapt to various project requirements.
@@ -37,7 +37,7 @@ Or using Yarn:
yarn add @dotcms/angular
```
-## Configutarion
+## Configuration
### Provider Setup
We need to provide the information of our dotCMS instance
@@ -51,10 +51,11 @@ const DOTCMS_CLIENT_CONFIG: ClientConfig = {
siteId: environment.siteId
};
```
-And add this config in the Angular app ApplicationConfig.
+
+Add this configuration to `ApplicationConfig` in your Angular app.
`src/app/app.config.ts`
-```javascript
+```typescript
import { InjectionToken } from '@angular/core';
import { ClientConfig, DotCmsClient } from '@dotcms/client';
@@ -70,36 +71,40 @@ export const appConfig: ApplicationConfig = {
],
};
```
+
This way, we will have access to `DOTCMS_CLIENT_TOKEN` from anywhere in our application.
### Client Usage
To interact with the client and obtain information from, for example, our pages
-```javascript
-private readonly client = inject(DOTCMS_CLIENT_TOKEN);
-
-this.client.page
- .get({ ...pageParams })
- .then((response) => {
- // Use your response
- })
- .catch((e) => {
- const error: PageError = {
- message: e.message,
- status: e.status,
- };
- // Use the error response
- })
+```typescript
+export class YourComponent {
+ private readonly client = inject(DOTCMS_CLIENT_TOKEN);
+
+ this.client.page
+ .get({ ...pageParams })
+ .then((response) => {
+ // Use your response
+ })
+ .catch((e) => {
+ const error: PageError = {
+ message: e.message,
+ status: e.status,
+ };
+ // Use the error response
+ })
+}
```
-For more information to how to use DotCms Client, you can visit the [documentation](https://github.com/dotCMS/core/blob/main/core-web/libs/sdk/client/README.md)
+
+For more information on how to use the dotCMS Client, you can visit the [documentation](https://www.github.com/dotCMS/core/blob/main/core-web/libs/sdk/client/README.md)
## DotCMS Page API
The `DotcmsLayoutComponent` requires a `DotCMSPageAsset` object to be passed in to it. This object represents a dotCMS page and can be fetched using the `@dotcms/client` library.
-- [DotCMS Official Angular Example](https://github.com/dotCMS/core/tree/main/examples/angular)
+- [DotCMS Official Angular Example](https://www.github.com/dotCMS/core/tree/main/examples/angular)
- [`@dotcms/client` documentation](https://www.npmjs.com/package/@dotcms/client)
-- [Page API documentation](https://dotcms.com/docs/latest/page-api)
+- [Page API documentation](https://www.dotcms.com/docs/latest/page-api)
## Components
@@ -167,17 +172,58 @@ This setup allows for dynamic rendering of different content types on your dotCM
## Troubleshooting
-If you encounter issues:
+If you encounter issues while using `@dotcms/angular`, here are some common problems and their solutions:
+
+1. **Dependency Issues**:
+ - Ensure that all dependencies, such as `@dotcms/client`, `@angular/core`, and `rxjs`, are correctly installed and up-to-date. You can verify installed versions by running:
+ ```bash
+ npm list @dotcms/client @angular/core rxjs
+ ```
+ - If there are any missing or incompatible versions, reinstall dependencies by running:
+ ```bash
+ npm install
+ ```
+ or
+ ```bash
+ npm install --force
+ ```
+
+2. **Configuration Errors**:
+ - **DotCMS Configuration**: Double-check that your `DOTCMS_CLIENT_CONFIG` settings (URL, auth token, site ID) are correct and aligned with the environment variables. For example:
+ ```typescript
+ const DOTCMS_CLIENT_CONFIG: ClientConfig = {
+ dotcmsUrl: environment.dotcmsUrl, // Ensure this is a valid URL
+ authToken: environment.authToken, // Ensure auth token has the correct permissions
+ siteId: environment.siteId // Ensure site ID is valid and accessible
+ };
+ ```
+ - **Injection Issues**: Ensure that `DOTCMS_CLIENT_TOKEN` is provided globally. Errors like `NullInjectorError` usually mean the token hasn’t been properly added to the `ApplicationConfig`. Verify by checking `src/app/app.config.ts`.
+
+3. **Network and API Errors**:
+ - **dotCMS API Connectivity**: If API calls are failing, check your browser’s Network tab to ensure requests to `dotcmsUrl` are successful. For CORS-related issues, ensure that your dotCMS server allows requests from your application’s domain.
+ - **Auth Token Permissions**: If you’re seeing `401 Unauthorized` errors, make sure the auth token used in `DOTCMS_CLIENT_CONFIG` has appropriate permissions in dotCMS for accessing pages and content.
+
+4. **Page and Component Rendering**:
+ - **Dynamic Imports**: If you’re encountering issues with lazy-loaded components, make sure dynamic imports are correctly set up, as in:
+ ```typescript
+ const DYNAMIC_COMPONENTS: DotCMSPageComponent = {
+ Activity: import('../pages/content-types/activity/activity.component').then(
+ (c) => c.ActivityComponent
+ )
+ };
+ ```
+ - **Invalid Page Assets**: Ensure that `pageAsset` objects are correctly formatted. Missing fields in `pageAsset` can cause errors in `DotcmsLayoutComponent`. Validate the structure by logging `pageAsset` before passing it in.
+
+5. **Common Angular Errors**:
+ - **Change Detection**: Angular sometimes fails to detect changes with dynamic content. If `DotcmsLayoutComponent` isn’t updating as expected, you may need to call `ChangeDetectorRef.detectChanges()` manually.
+ - **TypeScript Type Errors**: Ensure all types (e.g., `DotCMSPageAsset`, `DotCMSPageComponent`) are imported correctly from `@dotcms/angular`. Type mismatches can often be resolved by verifying imports.
-1. Ensure all dependencies are correctly installed and up to date.
-2. Verify that your dotCMS configuration (URL, auth token, site ID) is correct.
-3. Check the browser console for any error messages.
-4. Refer to the [dotCMS documentation](https://dotcms.com/docs/) for additional guidance.
+6. **Consult Documentation**: Refer to the official [dotCMS documentation](https://dotcms.com/docs/) and the [@dotcms/angular GitHub repository](https://github.com/dotCMS/core). These sources often provide updates and additional usage examples.
## Contributing
-GitHub pull requests are the preferred method to contribute code to dotCMS. Before any pull requests can be accepted, an automated tool will ask you to agree to the [dotCMS Contributor's Agreement](https://gist.github.com/wezell/85ef45298c48494b90d92755b583acb3).
+GitHub pull requests are the preferred method to contribute code to dotCMS. Before any pull requests can be accepted, an automated tool will ask you to agree to the [dotCMS Contributor's Agreement](https://www.gist.github.com/wezell/85ef45298c48494b90d92755b583acb3).
## Licensing
-dotCMS comes in multiple editions and as such is dual licensed. The dotCMS Community Edition is licensed under the GPL 3.0 and is freely available for download, customization and deployment for use within organizations of all stripes. dotCMS Enterprise Editions (EE) adds a number of enterprise features and is available via a supported, indemnified commercial license from dotCMS. For the differences between the editions, see [the feature page](http://dotcms.com/cms-platform/features).
+dotCMS comes in multiple editions and as such is dual licensed. The dotCMS Community Edition is licensed under the GPL 3.0 and is freely available for download, customization and deployment for use within organizations of all stripes. dotCMS Enterprise Editions (EE) adds a number of enterprise features and is available via a supported, indemnified commercial license from dotCMS. For the differences between the editions, see [the feature page](http://www.dotcms.com/cms-platform/features).
diff --git a/core-web/libs/sdk/angular/src/lib/components/dot-editable-text/dot-editable-text.component.spec.ts b/core-web/libs/sdk/angular/src/lib/components/dot-editable-text/dot-editable-text.component.spec.ts
index 940a8f860d83..20a720041788 100644
--- a/core-web/libs/sdk/angular/src/lib/components/dot-editable-text/dot-editable-text.component.spec.ts
+++ b/core-web/libs/sdk/angular/src/lib/components/dot-editable-text/dot-editable-text.component.spec.ts
@@ -14,7 +14,7 @@ import { TINYMCE_CONFIG } from './utils';
import { dotcmsContentletMock } from '../../utils/testing.utils';
-const { CUSTOMER_ACTIONS, postMessageToEditor } = dotcmsClient;
+const { CLIENT_ACTIONS, postMessageToEditor } = dotcmsClient;
// Mock @dotcms/client module
jest.mock('@dotcms/client', () => ({
@@ -248,11 +248,12 @@ describe('DotEditableTextComponent', () => {
focusSpy = jest.spyOn(spectator.component.editorComponent.editor, 'focus');
});
- it("should focus on the editor when the message is 'COPY_CONTENTLET_INLINE_EDITING_SUCCESS'", () => {
+ it("should focus on the editor when the message is 'uve-copy-contentlet-inline-editing-success'", () => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
- name: 'COPY_CONTENTLET_INLINE_EDITING_SUCCESS',
+ name: dotcmsClient.NOTIFY_CLIENT
+ .UVE_COPY_CONTENTLET_INLINE_EDITING_SUCCESS,
payload: {
oldInode: dotcmsContentletMock.inode,
inode: dotcmsContentletMock.inode
@@ -264,7 +265,7 @@ describe('DotEditableTextComponent', () => {
expect(focusSpy).toHaveBeenCalled();
});
- it("should not focus on the editor when the message is not 'COPY_CONTENTLET_INLINE_EDITING_SUCCESS'", () => {
+ it("should not focus on the editor when the message is not 'uve-copy-contentlet-inline-editing-success'", () => {
window.dispatchEvent(
new MessageEvent('message', {
data: { name: 'ANOTHER_EVENT' }
@@ -312,7 +313,7 @@ describe('DotEditableTextComponent', () => {
};
expect(postMessageToEditor).toHaveBeenCalledWith({
- action: CUSTOMER_ACTIONS.COPY_CONTENTLET_INLINE_EDITING,
+ action: CLIENT_ACTIONS.COPY_CONTENTLET_INLINE_EDITING,
payload
});
expect(event.stopPropagation).toHaveBeenCalled();
@@ -401,7 +402,7 @@ describe('DotEditableTextComponent', () => {
spectator.triggerEventHandler(editorDebugElement, 'onFocusOut', customEvent);
const postMessageData = {
- action: CUSTOMER_ACTIONS.UPDATE_CONTENTLET_INLINE_EDITING,
+ action: CLIENT_ACTIONS.UPDATE_CONTENTLET_INLINE_EDITING,
payload: {
content: 'New content',
dataset: {
diff --git a/core-web/libs/sdk/angular/src/lib/components/dot-editable-text/dot-editable-text.component.ts b/core-web/libs/sdk/angular/src/lib/components/dot-editable-text/dot-editable-text.component.ts
index 0e50404fb0f2..120db170a31f 100644
--- a/core-web/libs/sdk/angular/src/lib/components/dot-editable-text/dot-editable-text.component.ts
+++ b/core-web/libs/sdk/angular/src/lib/components/dot-editable-text/dot-editable-text.component.ts
@@ -16,9 +16,10 @@ import {
import { DomSanitizer } from '@angular/platform-browser';
import {
- CUSTOMER_ACTIONS,
+ CLIENT_ACTIONS,
DotCmsClient,
isInsideEditor,
+ NOTIFY_CLIENT,
postMessageToEditor
} from '@dotcms/client';
@@ -139,7 +140,7 @@ export class DotEditableTextComponent implements OnInit, OnChanges {
@HostListener('window:message', ['$event'])
onMessage({ data }: MessageEvent) {
const { name, payload } = data;
- if (name !== 'COPY_CONTENTLET_INLINE_EDITING_SUCCESS') {
+ if (name !== NOTIFY_CLIENT.UVE_COPY_CONTENTLET_INLINE_EDITING_SUCCESS) {
return;
}
@@ -194,7 +195,7 @@ export class DotEditableTextComponent implements OnInit, OnChanges {
try {
postMessageToEditor({
- action: CUSTOMER_ACTIONS.COPY_CONTENTLET_INLINE_EDITING,
+ action: CLIENT_ACTIONS.COPY_CONTENTLET_INLINE_EDITING,
payload: {
dataset: {
inode,
@@ -224,7 +225,7 @@ export class DotEditableTextComponent implements OnInit, OnChanges {
try {
postMessageToEditor({
- action: CUSTOMER_ACTIONS.UPDATE_CONTENTLET_INLINE_EDITING,
+ action: CLIENT_ACTIONS.UPDATE_CONTENTLET_INLINE_EDITING,
payload: {
content,
dataset: {
diff --git a/core-web/libs/sdk/angular/src/lib/layout/dotcms-layout/dotcms-layout.component.spec.ts b/core-web/libs/sdk/angular/src/lib/layout/dotcms-layout/dotcms-layout.component.spec.ts
index f66d5b427b05..2a5742a064c7 100644
--- a/core-web/libs/sdk/angular/src/lib/layout/dotcms-layout/dotcms-layout.component.spec.ts
+++ b/core-web/libs/sdk/angular/src/lib/layout/dotcms-layout/dotcms-layout.component.spec.ts
@@ -53,7 +53,7 @@ jest.mock('@dotcms/client', () => ({
}
}
},
- CUSTOMER_ACTIONS: {
+ CLIENT_ACTIONS: {
GET_PAGE_DATA: 'get-page-data'
}
}));
@@ -148,7 +148,7 @@ describe('DotcmsLayoutComponent', () => {
it('should post message to editor', () => {
spectator.detectChanges();
expect(dotcmsClient.postMessageToEditor).toHaveBeenCalledWith({
- action: dotcmsClient.CUSTOMER_ACTIONS.CLIENT_READY,
+ action: dotcmsClient.CLIENT_ACTIONS.CLIENT_READY,
payload: query
});
});
diff --git a/core-web/libs/sdk/angular/src/lib/layout/dotcms-layout/dotcms-layout.component.ts b/core-web/libs/sdk/angular/src/lib/layout/dotcms-layout/dotcms-layout.component.ts
index 51aeb6d115e5..d94dc0ec2fa4 100644
--- a/core-web/libs/sdk/angular/src/lib/layout/dotcms-layout/dotcms-layout.component.ts
+++ b/core-web/libs/sdk/angular/src/lib/layout/dotcms-layout/dotcms-layout.component.ts
@@ -11,7 +11,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import {
- CUSTOMER_ACTIONS,
+ CLIENT_ACTIONS,
DotCmsClient,
EditorConfig,
initEditor,
@@ -137,7 +137,7 @@ export class DotcmsLayoutComponent implements OnInit {
this.pageContextService.setPageAsset(data as DotCMSPageAsset);
});
- postMessageToEditor({ action: CUSTOMER_ACTIONS.CLIENT_READY, payload: this.editor });
+ postMessageToEditor({ action: CLIENT_ACTIONS.CLIENT_READY, payload: this.editor });
}
ngOnDestroy() {
diff --git a/core-web/libs/sdk/angular/src/lib/utils/index.ts b/core-web/libs/sdk/angular/src/lib/utils/index.ts
index fd9f44de7c33..902faf84e3c6 100644
--- a/core-web/libs/sdk/angular/src/lib/utils/index.ts
+++ b/core-web/libs/sdk/angular/src/lib/utils/index.ts
@@ -64,12 +64,21 @@ export const getContainersData = (
.map((structure) => structure.contentTypeVar)
.join(',');
- const contentlets = containers[identifier].contentlets[`uuid-${uuid}`];
+ // Get the contentlets for "this" container
+ const contentlets =
+ containers[identifier].contentlets[`uuid-${uuid}`] ??
+ containers[identifier].contentlets[`uuid-dotParser_${uuid}`];
+
+ if (!contentlets) {
+ console.warn(
+ `We couldn't find the contentlets for the container with the identifier ${identifier} and the uuid ${uuid} becareful by adding content to this container.\nWe recommend to change the container in the layout and add the content again.`
+ );
+ }
return {
...containers[identifier].container,
acceptTypes,
- contentlets,
+ contentlets: contentlets ?? [],
variantId
};
};
diff --git a/core-web/libs/sdk/client/jest.config.ts b/core-web/libs/sdk/client/jest.config.ts
index d82afdf4937a..aaa894ef3dfa 100644
--- a/core-web/libs/sdk/client/jest.config.ts
+++ b/core-web/libs/sdk/client/jest.config.ts
@@ -2,13 +2,13 @@
export default {
displayName: 'sdk-client',
preset: '../../../jest.preset.js',
- globals: {
- 'ts-jest': {
- tsconfig: '/tsconfig.spec.json'
- }
- },
transform: {
- '^.+\\.[tj]s$': 'ts-jest'
+ '^.+\\.[tj]s$': [
+ 'ts-jest',
+ {
+ tsconfig: '/tsconfig.spec.json'
+ }
+ ]
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/sdk/client'
diff --git a/core-web/libs/sdk/client/src/index.ts b/core-web/libs/sdk/client/src/index.ts
index 919b1f1f6981..d95a975f1375 100644
--- a/core-web/libs/sdk/client/src/index.ts
+++ b/core-web/libs/sdk/client/src/index.ts
@@ -1,12 +1,14 @@
import { ClientConfig, DotCmsClient } from './lib/client/sdk-js-client';
-import { CUSTOMER_ACTIONS, postMessageToEditor } from './lib/editor/models/client.model';
+import { CLIENT_ACTIONS, postMessageToEditor } from './lib/editor/models/client.model';
import {
CustomClientParams,
DotCMSPageEditorConfig,
EditorConfig
} from './lib/editor/models/editor.model';
+import { NOTIFY_CLIENT } from './lib/editor/models/listeners.model';
import {
destroyEditor,
+ editContentlet,
initEditor,
isInsideEditor,
updateNavigation
@@ -17,9 +19,11 @@ export {
graphqlToPageEntity,
getPageRequestParams,
isInsideEditor,
+ editContentlet,
DotCmsClient,
DotCMSPageEditorConfig,
- CUSTOMER_ACTIONS,
+ CLIENT_ACTIONS,
+ NOTIFY_CLIENT,
CustomClientParams,
postMessageToEditor,
EditorConfig,
diff --git a/core-web/libs/sdk/client/src/lib/client/sdk-js-client.spec.ts b/core-web/libs/sdk/client/src/lib/client/sdk-js-client.spec.ts
index 97c9cd2a697b..c5ab2c1f25ec 100644
--- a/core-web/libs/sdk/client/src/lib/client/sdk-js-client.spec.ts
+++ b/core-web/libs/sdk/client/src/lib/client/sdk-js-client.spec.ts
@@ -3,6 +3,7 @@
import { Content } from './content/content-api';
import { ClientConfig, DotCmsClient } from './sdk-js-client';
+import { NOTIFY_CLIENT } from '../editor/models/listeners.model';
import * as dotcmsEditor from '../editor/sdk-editor';
global.fetch = jest.fn();
@@ -272,7 +273,7 @@ describe('DotCmsClient', () => {
const mockMessageEvent = {
data: {
- name: 'SET_PAGE_DATA',
+ name: NOTIFY_CLIENT.UVE_SET_PAGE_DATA,
payload: { some: 'test' }
}
};
diff --git a/core-web/libs/sdk/client/src/lib/client/sdk-js-client.ts b/core-web/libs/sdk/client/src/lib/client/sdk-js-client.ts
index c01378310e0d..84915ed16095 100644
--- a/core-web/libs/sdk/client/src/lib/client/sdk-js-client.ts
+++ b/core-web/libs/sdk/client/src/lib/client/sdk-js-client.ts
@@ -2,6 +2,7 @@ import { Content } from './content/content-api';
import { ErrorMessages } from './models';
import { DotcmsClientListener } from './models/types';
+import { NOTIFY_CLIENT } from '../editor/models/listeners.model';
import { isInsideEditor } from '../editor/sdk-editor';
export type ClientOptions = Omit;
@@ -312,7 +313,7 @@ export class DotCmsClient {
if (action === 'changes') {
const messageCallback = (event: MessageEvent) => {
- if (event.data.name === 'SET_PAGE_DATA') {
+ if (event.data.name === NOTIFY_CLIENT.UVE_SET_PAGE_DATA) {
callbackFn(event.data.payload);
}
};
diff --git a/core-web/libs/sdk/client/src/lib/editor/listeners/listeners.spec.ts b/core-web/libs/sdk/client/src/lib/editor/listeners/listeners.spec.ts
index 4e807cf54e6a..911e6c14a5e2 100644
--- a/core-web/libs/sdk/client/src/lib/editor/listeners/listeners.spec.ts
+++ b/core-web/libs/sdk/client/src/lib/editor/listeners/listeners.spec.ts
@@ -6,19 +6,24 @@ import {
scrollHandler
} from './listeners';
-import { CUSTOMER_ACTIONS, postMessageToEditor } from '../models/client.model';
+import { CLIENT_ACTIONS, postMessageToEditor } from '../models/client.model';
jest.mock('../models/client.model', () => ({
postMessageToEditor: jest.fn(),
- CUSTOMER_ACTIONS: {
+ CLIENT_ACTIONS: {
NAVIGATION_UPDATE: 'set-url',
SET_BOUNDS: 'set-bounds',
SET_CONTENTLET: 'set-contentlet',
+ EDIT_CONTENTLET: 'edit-contentlet',
IFRAME_SCROLL: 'scroll',
PING_EDITOR: 'ping-editor',
CONTENT_CHANGE: 'content-change',
GET_PAGE_DATA: 'get-page-data',
NOOP: 'noop'
+ },
+ INITIAL_DOT_UVE: {
+ editContentlet: jest.fn(),
+ lastScrollYPosition: 0
}
}));
@@ -72,7 +77,7 @@ describe('listeners', () => {
expect(addEventListenerSpy).toHaveBeenCalledWith('pointermove', expect.any(Function));
expect(postMessageToEditor).toHaveBeenCalledWith({
- action: CUSTOMER_ACTIONS.SET_CONTENTLET,
+ action: CLIENT_ACTIONS.SET_CONTENTLET,
payload: {
x: expect.any(Number),
y: expect.any(Number),
@@ -110,7 +115,7 @@ describe('listeners', () => {
it('should get page data post message to editor', () => {
fetchPageDataFromInsideUVE('some-url');
expect(postMessageToEditor).toHaveBeenCalledWith({
- action: CUSTOMER_ACTIONS.GET_PAGE_DATA,
+ action: CLIENT_ACTIONS.GET_PAGE_DATA,
payload: {
pathname: 'some-url'
}
diff --git a/core-web/libs/sdk/client/src/lib/editor/listeners/listeners.ts b/core-web/libs/sdk/client/src/lib/editor/listeners/listeners.ts
index 3a1b7444b795..bec47fdbbd22 100644
--- a/core-web/libs/sdk/client/src/lib/editor/listeners/listeners.ts
+++ b/core-web/libs/sdk/client/src/lib/editor/listeners/listeners.ts
@@ -1,5 +1,5 @@
-import { CUSTOMER_ACTIONS, postMessageToEditor } from '../models/client.model';
-import { DotCMSPageEditorSubscription, NOTIFY_CUSTOMER } from '../models/listeners.model';
+import { CLIENT_ACTIONS, INITIAL_DOT_UVE, postMessageToEditor } from '../models/client.model';
+import { DotCMSPageEditorSubscription, NOTIFY_CLIENT } from '../models/listeners.model';
import {
findVTLData,
findDotElement,
@@ -8,12 +8,6 @@ import {
scrollIsInBottom
} from '../utils/editor.utils';
-declare global {
- interface Window {
- lastScrollYPosition: number;
- }
-}
-
/**
* Represents an array of DotCMSPageEditorSubscription objects.
* Used to store the subscriptions for the editor and unsubscribe later.
@@ -33,7 +27,7 @@ function setBounds(): void {
const positionData = getPageElementBound(containers);
postMessageToEditor({
- action: CUSTOMER_ACTIONS.SET_BOUNDS,
+ action: CLIENT_ACTIONS.SET_BOUNDS,
payload: positionData
});
}
@@ -45,29 +39,34 @@ function setBounds(): void {
* @memberof DotCMSPageEditor
*/
export function listenEditorMessages(): void {
- const messageCallback = (event: MessageEvent) => {
- switch (event.data) {
- case NOTIFY_CUSTOMER.EMA_REQUEST_BOUNDS: {
+ const messageCallback = (
+ event: MessageEvent<{ name: NOTIFY_CLIENT; direction: 'up' | 'down' }>
+ ) => {
+ const ACTIONS_NOTIFICATION: { [K in NOTIFY_CLIENT]?: () => void } = {
+ [NOTIFY_CLIENT.UVE_RELOAD_PAGE]: () => {
+ window.location.reload();
+ },
+ [NOTIFY_CLIENT.UVE_REQUEST_BOUNDS]: () => {
setBounds();
- break;
- }
- }
-
- if (event.data.name === NOTIFY_CUSTOMER.EMA_SCROLL_INSIDE_IFRAME) {
- const direction = event.data.direction;
-
- if (
- (window.scrollY === 0 && direction === 'up') ||
- (scrollIsInBottom() && direction === 'down')
- ) {
- // If the iframe scroll is at the top or bottom, do not send anything.
- // This avoids losing the scrollend event.
- return;
+ },
+ [NOTIFY_CLIENT.UVE_SCROLL_INSIDE_IFRAME]: () => {
+ const direction = event.data.direction;
+
+ if (
+ (window.scrollY === 0 && direction === 'up') ||
+ (scrollIsInBottom() && direction === 'down')
+ ) {
+ // If the iframe scroll is at the top or bottom, do not send anything.
+ // This avoids losing the scrollend event.
+ return;
+ }
+
+ const scrollY = direction === 'up' ? -120 : 120;
+ window.scrollBy({ left: 0, top: scrollY, behavior: 'smooth' });
}
+ };
- const scrollY = direction === 'up' ? -120 : 120;
- window.scrollBy({ left: 0, top: scrollY, behavior: 'smooth' });
- }
+ ACTIONS_NOTIFICATION[event.data.name]?.();
};
window.addEventListener('message', messageCallback);
@@ -128,7 +127,7 @@ export function listenHoveredContentlet(): void {
};
postMessageToEditor({
- action: CUSTOMER_ACTIONS.SET_CONTENTLET,
+ action: CLIENT_ACTIONS.SET_CONTENTLET,
payload: {
x,
y,
@@ -158,14 +157,19 @@ export function listenHoveredContentlet(): void {
export function scrollHandler(): void {
const scrollCallback = () => {
postMessageToEditor({
- action: CUSTOMER_ACTIONS.IFRAME_SCROLL
+ action: CLIENT_ACTIONS.IFRAME_SCROLL
});
- window.lastScrollYPosition = window.scrollY;
+
+ // In case it doesn't have a dotUVE object, we create it with the initial values.
+ window.dotUVE = {
+ ...(window.dotUVE ?? INITIAL_DOT_UVE),
+ lastScrollYPosition: window.scrollY
+ };
};
const scrollEndCallback = () => {
postMessageToEditor({
- action: CUSTOMER_ACTIONS.IFRAME_SCROLL_END
+ action: CLIENT_ACTIONS.IFRAME_SCROLL_END
});
};
@@ -196,7 +200,7 @@ export function scrollHandler(): void {
*/
export function preserveScrollOnIframe(): void {
const preserveScrollCallback = () => {
- window.scrollTo(0, window.lastScrollYPosition);
+ window.scrollTo(0, window.dotUVE?.lastScrollYPosition);
};
window.addEventListener('load', preserveScrollCallback);
@@ -215,7 +219,7 @@ export function preserveScrollOnIframe(): void {
*/
export function fetchPageDataFromInsideUVE(pathname: string) {
postMessageToEditor({
- action: CUSTOMER_ACTIONS.GET_PAGE_DATA,
+ action: CLIENT_ACTIONS.GET_PAGE_DATA,
payload: {
pathname
}
diff --git a/core-web/libs/sdk/client/src/lib/editor/models/client.model.ts b/core-web/libs/sdk/client/src/lib/editor/models/client.model.ts
index 0eea789477b4..a0c5fa98ea04 100644
--- a/core-web/libs/sdk/client/src/lib/editor/models/client.model.ts
+++ b/core-web/libs/sdk/client/src/lib/editor/models/client.model.ts
@@ -1,10 +1,22 @@
+import { editContentlet } from '../sdk-editor';
+declare global {
+ interface Window {
+ dotUVE: DotUVE;
+ }
+}
+
+export const INITIAL_DOT_UVE: DotUVE = {
+ editContentlet,
+ lastScrollYPosition: 0
+};
+
/**
* Actions send to the dotcms editor
*
* @export
* @enum {number}
*/
-export enum CUSTOMER_ACTIONS {
+export enum CLIENT_ACTIONS {
/**
* Tell the dotcms editor that page change
*/
@@ -54,6 +66,10 @@ export enum CUSTOMER_ACTIONS {
* Tell the editor an user send a graphql query
*/
CLIENT_READY = 'client-ready',
+ /**
+ * Tell the editor to edit a contentlet
+ */
+ EDIT_CONTENTLET = 'edit-contentlet',
/**
* Tell the editor to do nothing
*/
@@ -68,7 +84,7 @@ export enum CUSTOMER_ACTIONS {
* @interface PostMessageProps
*/
type PostMessageProps = {
- action: CUSTOMER_ACTIONS;
+ action: CLIENT_ACTIONS;
payload?: T;
};
@@ -82,3 +98,8 @@ type PostMessageProps = {
export function postMessageToEditor(message: PostMessageProps) {
window.parent.postMessage(message, '*');
}
+
+export interface DotUVE {
+ editContentlet: typeof editContentlet;
+ lastScrollYPosition: number;
+}
diff --git a/core-web/libs/sdk/client/src/lib/editor/models/listeners.model.ts b/core-web/libs/sdk/client/src/lib/editor/models/listeners.model.ts
index 55557166e01a..768980288ef8 100644
--- a/core-web/libs/sdk/client/src/lib/editor/models/listeners.model.ts
+++ b/core-web/libs/sdk/client/src/lib/editor/models/listeners.model.ts
@@ -4,23 +4,31 @@
* @export
* @enum {number}
*/
-export enum NOTIFY_CUSTOMER {
+export enum NOTIFY_CLIENT {
/**
* Request to page to reload
*/
- EMA_RELOAD_PAGE = 'ema-reload-page',
+ UVE_RELOAD_PAGE = 'uve-reload-page',
/**
* Request the bounds for the elements
*/
- EMA_REQUEST_BOUNDS = 'ema-request-bounds',
+ UVE_REQUEST_BOUNDS = 'uve-request-bounds',
/**
* Received pong from the editor
*/
- EMA_EDITOR_PONG = 'ema-editor-pong',
+ UVE_EDITOR_PONG = 'uve-editor-pong',
/**
* Received scroll event trigger from the editor
*/
- EMA_SCROLL_INSIDE_IFRAME = 'scroll-inside-iframe'
+ UVE_SCROLL_INSIDE_IFRAME = 'uve-scroll-inside-iframe',
+ /**
+ * Set the page data
+ */
+ UVE_SET_PAGE_DATA = 'uve-set-page-data',
+ /**
+ * Copy contentlet inline editing success
+ */
+ UVE_COPY_CONTENTLET_INLINE_EDITING_SUCCESS = 'uve-copy-contentlet-inline-editing-success'
}
type ListenerCallbackMessage = (event: MessageEvent) => void;
diff --git a/core-web/libs/sdk/client/src/lib/editor/sdk-editor-vtl.ts b/core-web/libs/sdk/client/src/lib/editor/sdk-editor-vtl.ts
index 71d21c6aada9..339e6aae2a43 100644
--- a/core-web/libs/sdk/client/src/lib/editor/sdk-editor-vtl.ts
+++ b/core-web/libs/sdk/client/src/lib/editor/sdk-editor-vtl.ts
@@ -4,13 +4,7 @@ import {
preserveScrollOnIframe,
scrollHandler
} from './listeners/listeners';
-import { isInsideEditor, addClassToEmptyContentlets } from './sdk-editor';
-
-declare global {
- interface Window {
- lastScrollYPosition: number;
- }
-}
+import { isInsideEditor, addClassToEmptyContentlets, initDotUVE } from './sdk-editor';
/**
* This is the main entry point for the SDK VTL.
@@ -23,6 +17,7 @@ declare global {
*
*/
if (isInsideEditor()) {
+ initDotUVE();
listenEditorMessages();
scrollHandler();
preserveScrollOnIframe();
diff --git a/core-web/libs/sdk/client/src/lib/editor/sdk-editor.spec.ts b/core-web/libs/sdk/client/src/lib/editor/sdk-editor.spec.ts
index de014aa7418e..9090ee5c5049 100644
--- a/core-web/libs/sdk/client/src/lib/editor/sdk-editor.spec.ts
+++ b/core-web/libs/sdk/client/src/lib/editor/sdk-editor.spec.ts
@@ -4,7 +4,7 @@ import {
fetchPageDataFromInsideUVE,
scrollHandler
} from './listeners/listeners';
-import { postMessageToEditor, CUSTOMER_ACTIONS } from './models/client.model';
+import { postMessageToEditor, CLIENT_ACTIONS } from './models/client.model';
import {
addClassToEmptyContentlets,
initEditor,
@@ -12,18 +12,25 @@ import {
updateNavigation
} from './sdk-editor';
-jest.mock('./models/client.model', () => ({
- postMessageToEditor: jest.fn(),
- CUSTOMER_ACTIONS: {
- NAVIGATION_UPDATE: 'set-url',
- SET_BOUNDS: 'set-bounds',
- SET_CONTENTLET: 'set-contentlet',
- IFRAME_SCROLL: 'scroll',
- PING_EDITOR: 'ping-editor',
- CONTENT_CHANGE: 'content-change',
- NOOP: 'noop'
- }
-}));
+jest.mock('./models/client.model', () => {
+ return {
+ postMessageToEditor: jest.fn(),
+ CLIENT_ACTIONS: {
+ NAVIGATION_UPDATE: 'set-url',
+ SET_BOUNDS: 'set-bounds',
+ SET_CONTENTLET: 'set-contentlet',
+ EDIT_CONTENTLET: 'edit-contentlet',
+ IFRAME_SCROLL: 'scroll',
+ PING_EDITOR: 'ping-editor',
+ CONTENT_CHANGE: 'content-change',
+ NOOP: 'noop'
+ },
+ INITIAL_DOT_UVE: {
+ editContentlet: jest.fn(),
+ lastScrollYPosition: 0
+ }
+ };
+});
jest.mock('./listeners/listeners', () => ({
pingEditor: jest.fn(),
@@ -80,7 +87,7 @@ describe('DotCMSPageEditor', () => {
it('should update navigation', () => {
updateNavigation('/');
expect(postMessageToEditor).toHaveBeenCalledWith({
- action: CUSTOMER_ACTIONS.NAVIGATION_UPDATE,
+ action: CLIENT_ACTIONS.NAVIGATION_UPDATE,
payload: {
url: 'index'
}
@@ -93,6 +100,10 @@ describe('DotCMSPageEditor', () => {
expect(listenEditorMessages).toHaveBeenCalled();
expect(listenHoveredContentlet).toHaveBeenCalled();
expect(scrollHandler).toHaveBeenCalled();
+ expect(window.dotUVE).toEqual({
+ editContentlet: expect.any(Function),
+ lastScrollYPosition: 0
+ });
});
});
diff --git a/core-web/libs/sdk/client/src/lib/editor/sdk-editor.ts b/core-web/libs/sdk/client/src/lib/editor/sdk-editor.ts
index 578fa6d58b85..02480c0fbbdb 100644
--- a/core-web/libs/sdk/client/src/lib/editor/sdk-editor.ts
+++ b/core-web/libs/sdk/client/src/lib/editor/sdk-editor.ts
@@ -5,9 +5,11 @@ import {
scrollHandler,
subscriptions
} from './listeners/listeners';
-import { CUSTOMER_ACTIONS, postMessageToEditor } from './models/client.model';
+import { CLIENT_ACTIONS, INITIAL_DOT_UVE, postMessageToEditor } from './models/client.model';
import { DotCMSPageEditorConfig } from './models/editor.model';
+import { Contentlet } from '../client/content/shared/types';
+
/**
* Updates the navigation in the editor.
*
@@ -18,13 +20,29 @@ import { DotCMSPageEditorConfig } from './models/editor.model';
*/
export function updateNavigation(pathname: string): void {
postMessageToEditor({
- action: CUSTOMER_ACTIONS.NAVIGATION_UPDATE,
+ action: CLIENT_ACTIONS.NAVIGATION_UPDATE,
payload: {
url: pathname === '/' ? 'index' : pathname?.replace('/', '')
}
});
}
+/**
+ * You can use this function to edit a contentlet in the editor.
+ *
+ * Calling this function inside the editor, will prompt the UVE to open a dialog to edit the contentlet.
+ *
+ * @export
+ * @template T
+ * @param {Contentlet} contentlet - The contentlet to edit.
+ */
+export function editContentlet(contentlet: Contentlet) {
+ postMessageToEditor({
+ action: CLIENT_ACTIONS.EDIT_CONTENTLET,
+ payload: contentlet
+ });
+}
+
/**
* Checks if the code is running inside an editor.
*
@@ -46,6 +64,10 @@ export function isInsideEditor(): boolean {
return window.parent !== window;
}
+export function initDotUVE() {
+ window.dotUVE = INITIAL_DOT_UVE;
+}
+
/**
* Initializes the DotCMS page editor.
*
@@ -57,6 +79,7 @@ export function isInsideEditor(): boolean {
* ```
*/
export function initEditor(config: DotCMSPageEditorConfig): void {
+ initDotUVE();
fetchPageDataFromInsideUVE(config.pathname);
listenEditorMessages();
listenHoveredContentlet();
diff --git a/core-web/libs/sdk/react/package.json b/core-web/libs/sdk/react/package.json
index 402108929fee..852b3da3b54e 100644
--- a/core-web/libs/sdk/react/package.json
+++ b/core-web/libs/sdk/react/package.json
@@ -4,7 +4,7 @@
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18",
- "@dotcms/client": "0.0.1-alpha.38",
+ "@dotcms/client": "latest",
"@tinymce/tinymce-react": "^5.1.1"
},
"description": "Official React Components library to render a dotCMS page.",
diff --git a/core-web/libs/sdk/react/src/lib/components/DotEditableText/DotEditableText.spec.tsx b/core-web/libs/sdk/react/src/lib/components/DotEditableText/DotEditableText.spec.tsx
index 0aaa79b19335..7ea7ab2c9c89 100644
--- a/core-web/libs/sdk/react/src/lib/components/DotEditableText/DotEditableText.spec.tsx
+++ b/core-web/libs/sdk/react/src/lib/components/DotEditableText/DotEditableText.spec.tsx
@@ -8,7 +8,7 @@ import { DotEditableText } from './DotEditableText';
import { dotcmsContentletMock } from '../../mocks/mockPageContext';
-const { CUSTOMER_ACTIONS, postMessageToEditor } = dotcmsClient;
+const { CLIENT_ACTIONS, postMessageToEditor } = dotcmsClient;
// Define mockEditor before using it in jest.mock
const TINYMCE_EDITOR_MOCK = {
@@ -106,11 +106,12 @@ describe('DotEditableText', () => {
focusSpy = jest.spyOn(TINYMCE_EDITOR_MOCK, 'focus');
});
- it("should focus on the editor when the message is 'COPY_CONTENTLET_INLINE_EDITING_SUCCESS'", () => {
+ it("should focus on the editor when the message is 'UVE_COPY_CONTENTLET_INLINE_EDITING_SUCCESS'", () => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
- name: 'COPY_CONTENTLET_INLINE_EDITING_SUCCESS',
+ name: dotcmsClient.NOTIFY_CLIENT
+ .UVE_COPY_CONTENTLET_INLINE_EDITING_SUCCESS,
payload: {
oldInode: dotcmsContentletMock['inode'],
inode: '456'
@@ -121,7 +122,7 @@ describe('DotEditableText', () => {
expect(focusSpy).toHaveBeenCalled();
});
- it("should not focus on the editor when the message is not 'COPY_CONTENTLET_INLINE_EDITING_SUCCESS'", () => {
+ it("should not focus on the editor when the message is not 'UVE_COPY_CONTENTLET_INLINE_EDITING_SUCCESS'", () => {
window.dispatchEvent(
new MessageEvent('message', {
data: { name: 'ANOTHER_EVENT' }
@@ -155,7 +156,7 @@ describe('DotEditableText', () => {
}
};
expect(postMessageToEditor).toHaveBeenCalledWith({
- action: CUSTOMER_ACTIONS.COPY_CONTENTLET_INLINE_EDITING,
+ action: CLIENT_ACTIONS.COPY_CONTENTLET_INLINE_EDITING,
payload
});
});
@@ -209,7 +210,7 @@ describe('DotEditableText', () => {
fireEvent(editorElem, event);
const postMessageData = {
- action: CUSTOMER_ACTIONS.UPDATE_CONTENTLET_INLINE_EDITING,
+ action: CLIENT_ACTIONS.UPDATE_CONTENTLET_INLINE_EDITING,
payload: {
content: 'New content',
dataset: {
diff --git a/core-web/libs/sdk/react/src/lib/components/DotEditableText/DotEditableText.tsx b/core-web/libs/sdk/react/src/lib/components/DotEditableText/DotEditableText.tsx
index 11533db0ea34..9104c7a3e6eb 100644
--- a/core-web/libs/sdk/react/src/lib/components/DotEditableText/DotEditableText.tsx
+++ b/core-web/libs/sdk/react/src/lib/components/DotEditableText/DotEditableText.tsx
@@ -4,8 +4,9 @@ import { useEffect, useRef, useState } from 'react';
import {
isInsideEditor as isInsideEditorFn,
postMessageToEditor,
- CUSTOMER_ACTIONS,
- DotCmsClient
+ CLIENT_ACTIONS,
+ DotCmsClient,
+ NOTIFY_CLIENT
} from '@dotcms/client';
import { DotEditableTextProps, TINYMCE_CONFIG } from './utils';
@@ -79,7 +80,7 @@ export function DotEditableText({
const onMessage = ({ data }: MessageEvent) => {
const { name, payload } = data;
- if (name !== 'COPY_CONTENTLET_INLINE_EDITING_SUCCESS') {
+ if (name !== NOTIFY_CLIENT.UVE_COPY_CONTENTLET_INLINE_EDITING_SUCCESS) {
return;
}
@@ -111,7 +112,7 @@ export function DotEditableText({
event.preventDefault();
postMessageToEditor({
- action: CUSTOMER_ACTIONS.COPY_CONTENTLET_INLINE_EDITING,
+ action: CLIENT_ACTIONS.COPY_CONTENTLET_INLINE_EDITING,
payload: {
dataset: {
inode,
@@ -131,7 +132,7 @@ export function DotEditableText({
}
postMessageToEditor({
- action: CUSTOMER_ACTIONS.UPDATE_CONTENTLET_INLINE_EDITING,
+ action: CLIENT_ACTIONS.UPDATE_CONTENTLET_INLINE_EDITING,
payload: {
content: editedContent,
dataset: {
diff --git a/core-web/libs/sdk/react/src/lib/hooks/useDotcmsEditor.spec.ts b/core-web/libs/sdk/react/src/lib/hooks/useDotcmsEditor.spec.ts
index 42cd0beaa285..f5585df64a44 100644
--- a/core-web/libs/sdk/react/src/lib/hooks/useDotcmsEditor.spec.ts
+++ b/core-web/libs/sdk/react/src/lib/hooks/useDotcmsEditor.spec.ts
@@ -147,7 +147,7 @@ describe('useDotcmsEditor', () => {
);
expect(sdkClient.postMessageToEditor).toHaveBeenCalledWith({
- action: sdkClient.CUSTOMER_ACTIONS.CLIENT_READY,
+ action: sdkClient.CLIENT_ACTIONS.CLIENT_READY,
payload: editor
});
});
diff --git a/core-web/libs/sdk/react/src/lib/hooks/useDotcmsEditor.ts b/core-web/libs/sdk/react/src/lib/hooks/useDotcmsEditor.ts
index 418592a9d18b..04155f8df296 100644
--- a/core-web/libs/sdk/react/src/lib/hooks/useDotcmsEditor.ts
+++ b/core-web/libs/sdk/react/src/lib/hooks/useDotcmsEditor.ts
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import {
- CUSTOMER_ACTIONS,
+ CLIENT_ACTIONS,
DotCmsClient,
destroyEditor,
initEditor,
@@ -69,7 +69,7 @@ export const useDotcmsEditor = ({ pageContext, config }: DotcmsPageProps) => {
return;
}
- postMessageToEditor({ action: CUSTOMER_ACTIONS.CLIENT_READY, payload: editor });
+ postMessageToEditor({ action: CLIENT_ACTIONS.CLIENT_READY, payload: editor });
}, [pathname, editor]);
/**
diff --git a/core-web/libs/sdk/react/src/lib/utils/utils.ts b/core-web/libs/sdk/react/src/lib/utils/utils.ts
index ae8737ed78de..3f673e386491 100644
--- a/core-web/libs/sdk/react/src/lib/utils/utils.ts
+++ b/core-web/libs/sdk/react/src/lib/utils/utils.ts
@@ -53,12 +53,20 @@ export const getContainersData = (
const acceptTypes = containerStructures.map((structure) => structure.contentTypeVar).join(',');
// Get the contentlets for "this" container
- const contentlets = containers[identifier].contentlets[`uuid-${uuid}`];
+ const contentlets =
+ containers[identifier].contentlets[`uuid-${uuid}`] ??
+ containers[identifier].contentlets[`uuid-dotParser_${uuid}`];
+
+ if (!contentlets) {
+ console.warn(
+ `We couldn't find the contentlets for the container with the identifier ${identifier} and the uuid ${uuid} becareful by adding content to this container.\nWe recommend to change the container in the layout and add the content again.`
+ );
+ }
return {
...containers[identifier].container,
acceptTypes,
- contentlets,
+ contentlets: contentlets ?? [],
variantId
};
};
diff --git a/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.ts b/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.ts
index 5292f81ac39d..53833d171948 100644
--- a/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.ts
+++ b/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.ts
@@ -44,7 +44,7 @@ export class DotDropZoneComponent {
* Max file size in bytes.
* See Docs: https://www.dotcms.com/docs/latest/binary-field#FieldVariables
*/
- @Input() maxFileSize: number;
+ @Input() maxFileSize: number | null = null;
@Input() set accept(types: string[]) {
this._accept = types
diff --git a/dotCMS/pom.xml b/dotCMS/pom.xml
index 7792bba84dc7..5cb123e632cd 100644
--- a/dotCMS/pom.xml
+++ b/dotCMS/pom.xml
@@ -1405,6 +1405,17 @@
3.1.9.Final
+
+ org.glassfish.jersey.ext.cdi
+ jersey-cdi1x
+
+
+
+
+ org.glassfish.jersey.ext.cdi
+ jersey-cdi1x-servlet
+
+
org.glassfish.hk2.externalbean-validator
@@ -1429,7 +1440,6 @@
jandex3.0.5
-
org.apache.tomcattomcat-catalina
diff --git a/dotCMS/src/main/java/com/dotcms/ai/api/CompletionsAPIImpl.java b/dotCMS/src/main/java/com/dotcms/ai/api/CompletionsAPIImpl.java
index 4b33ca0bb2b3..7decdb7c9fa8 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/api/CompletionsAPIImpl.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/api/CompletionsAPIImpl.java
@@ -122,10 +122,10 @@ public void summarizeStream(final CompletionsForm summaryRequest, final OutputSt
@Override
public JSONObject raw(final JSONObject json, final String userId) {
- AppConfig.debugLogger(this.getClass(), () -> "OpenAI request:" + json.toString(2));
+ config.debugLogger(this.getClass(), () -> "OpenAI request:" + json.toString(2));
final String response = sendRequest(config, json, userId).getResponse();
- AppConfig.debugLogger(this.getClass(), () -> "OpenAI response:" + response);
+ config.debugLogger(this.getClass(), () -> "OpenAI response:" + response);
return new JSONObject(response);
}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/api/EmbeddingsAPIImpl.java b/dotCMS/src/main/java/com/dotcms/ai/api/EmbeddingsAPIImpl.java
index a02d3e8fdf8f..36490040fa89 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/api/EmbeddingsAPIImpl.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/api/EmbeddingsAPIImpl.java
@@ -54,7 +54,6 @@
import java.util.Optional;
import java.util.stream.Collectors;
-import static com.dotcms.ai.app.AppConfig.debugLogger;
import static com.liferay.util.StringPool.BLANK;
/**
@@ -335,7 +334,7 @@ public Tuple2> pullOrGenerateEmbeddings(final String conten
.map(encoding -> encoding.encode(content))
.orElse(List.of());
if (tokens.isEmpty()) {
- debugLogger(this.getClass(), () -> String.format("No tokens for content ID '%s' were encoded: %s", contentId, content));
+ config.debugLogger(this.getClass(), () -> String.format("No tokens for content ID '%s' were encoded: %s", contentId, content));
return Tuple.of(0, List.of());
}
@@ -432,15 +431,15 @@ private List sendTokensToOpenAI(final String contentId,
final JSONObject json = new JSONObject();
json.put(AiKeys.MODEL, config.getEmbeddingsModel().getCurrentModel());
json.put(AiKeys.INPUT, tokens);
- debugLogger(this.getClass(), () -> String.format("Content tokens for content ID '%s': %s", contentId, tokens));
+ config.debugLogger(this.getClass(), () -> String.format("Content tokens for content ID '%s': %s", contentId, tokens));
final String responseString = AIProxyClient.get()
.callToAI(JSONObjectAIRequest.quickEmbeddings(config, json, userId))
.getResponse();
- debugLogger(this.getClass(), () -> String.format("OpenAI Response for content ID '%s': %s",
+ config.debugLogger(this.getClass(), () -> String.format("OpenAI Response for content ID '%s': %s",
contentId, responseString.replace("\n", BLANK)));
final JSONObject jsonResponse = Try.of(() -> new JSONObject(responseString)).getOrElseThrow(e -> {
Logger.error(this, "OpenAI Response String is not a valid JSON", e);
- debugLogger(this.getClass(), () -> String.format("Invalid JSON Response: %s", responseString));
+ config.debugLogger(this.getClass(), () -> String.format("Invalid JSON Response: %s", responseString));
return new DotCorruptedDataException(e);
});
if (jsonResponse.containsKey(AiKeys.ERROR)) {
diff --git a/dotCMS/src/main/java/com/dotcms/ai/api/EmbeddingsRunner.java b/dotCMS/src/main/java/com/dotcms/ai/api/EmbeddingsRunner.java
index 91d3e9a01723..f8a054a4ece0 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/api/EmbeddingsRunner.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/api/EmbeddingsRunner.java
@@ -1,5 +1,6 @@
package com.dotcms.ai.api;
+import com.dotcms.ai.app.AppConfig;
import com.dotcms.ai.app.AppKeys;
import com.dotcms.ai.app.ConfigService;
import com.dotcms.ai.db.EmbeddingsDTO;
@@ -17,7 +18,6 @@
import java.util.List;
import java.util.Locale;
-import static com.dotcms.ai.app.AppConfig.debugLogger;
import static com.liferay.util.StringPool.SPACE;
/**
@@ -86,9 +86,9 @@ public void run() {
}
if (buffer.toString().split("\\s+").length > 0) {
- debugLogger(this.getClass(), () -> String.format("Saving embeddings for contentlet ID '%s'", this.contentlet.getIdentifier()));
+ AppConfig.debugLogger(embeddingsAPI.config, this.getClass(), () -> String.format("Saving embeddings for contentlet ID '%s'", this.contentlet.getIdentifier()));
this.saveEmbedding(buffer.toString());
- debugLogger(this.getClass(), () -> String.format("Embeddings for contentlet ID '%s' were saved", this.contentlet.getIdentifier()));
+ AppConfig.debugLogger(embeddingsAPI.config, this.getClass(), () -> String.format("Embeddings for contentlet ID '%s' were saved", this.contentlet.getIdentifier()));
}
} catch (final Exception e) {
final String errorMsg = String.format("Failed to generate embeddings for contentlet ID " +
diff --git a/dotCMS/src/main/java/com/dotcms/ai/app/AIModels.java b/dotCMS/src/main/java/com/dotcms/ai/app/AIModels.java
index acead42c48d5..5e51779b1136 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/app/AIModels.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/app/AIModels.java
@@ -59,19 +59,19 @@ public static AIModels get() {
return INSTANCE.get();
}
- private static CircuitBreakerUrl.Response fetchOpenAIModels(final String apiKey) {
+ private static CircuitBreakerUrl.Response fetchOpenAIModels(final AppConfig appConfig) {
final CircuitBreakerUrl.Response response = CircuitBreakerUrl.builder()
.setMethod(CircuitBreakerUrl.Method.GET)
.setUrl(AI_MODELS_API_URL)
.setTimeout(AI_MODELS_FETCH_TIMEOUT)
.setTryAgainAttempts(AI_MODELS_FETCH_ATTEMPTS)
- .setHeaders(CircuitBreakerUrl.authHeaders("Bearer " + apiKey))
+ .setHeaders(CircuitBreakerUrl.authHeaders("Bearer " + appConfig.getApiKey()))
.setThrowWhenNot2xx(true)
.build()
.doResponse(OpenAIModels.class);
if (!CircuitBreakerUrl.isSuccessResponse(response)) {
- AppConfig.debugLogger(
+ appConfig.debugLogger(
AIModels.class,
() -> String.format(
"Error fetching OpenAI supported models from [%s] (status code: [%d])",
@@ -98,10 +98,11 @@ private AIModels() {
* are already loaded, this method does nothing. It also maps model names to their
* corresponding AIModel instances.
*
- * @param host the host for which the models are being loaded
+ * @param appConfig app config
* @param loading the list of AI models to load
*/
- public void loadModels(final String host, final List loading) {
+ public void loadModels(final AppConfig appConfig, final List loading) {
+ final String host = appConfig.getHost();
final List> added = internalModels.putIfAbsent(
host,
loading.stream()
@@ -112,7 +113,7 @@ public void loadModels(final String host, final List loading) {
.forEach(model -> {
final Tuple3 key = Tuple.of(host, model, aiModel.getType());
if (modelsByName.containsKey(key)) {
- AppConfig.debugLogger(
+ appConfig.debugLogger(
getClass(),
() -> String.format(
"Model [%s] already exists for host [%s], ignoring it",
@@ -230,11 +231,11 @@ public Set getOrPullSupportedModels(final AppConfig appConfig) {
}
if (!appConfig.isEnabled()) {
- AppConfig.debugLogger(getClass(), () -> "dotAI is not enabled, returning empty set of supported models");
+ appConfig.debugLogger(getClass(), () -> "dotAI is not enabled, returning empty set of supported models");
return Set.of();
}
- final CircuitBreakerUrl.Response response = fetchOpenAIModels(appConfig.getApiKey());
+ final CircuitBreakerUrl.Response response = fetchOpenAIModels(appConfig);
if (Objects.nonNull(response.getResponse().getError())) {
throw new DotRuntimeException("Found error in AI response: " + response.getResponse().getError().getMessage());
}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/app/AppConfig.java b/dotCMS/src/main/java/com/dotcms/ai/app/AppConfig.java
index 0ab3b83b4273..69cbad32958e 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/app/AppConfig.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/app/AppConfig.java
@@ -2,6 +2,7 @@
import com.dotcms.ai.domain.Model;
import com.dotcms.security.apps.Secret;
+import com.dotmarketing.util.Config;
import com.dotmarketing.util.Logger;
import com.dotmarketing.util.UtilMethods;
import com.liferay.util.StringPool;
@@ -12,9 +13,7 @@
import java.io.Serializable;
import java.util.List;
import java.util.Map;
-import java.util.Objects;
import java.util.Optional;
-import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -29,8 +28,7 @@ public class AppConfig implements Serializable {
private static final String AI_API_URL_KEY = "AI_API_URL";
private static final String AI_IMAGE_API_URL_KEY = "AI_IMAGE_API_URL";
private static final String AI_EMBEDDINGS_API_URL_KEY = "AI_EMBEDDINGS_API_URL";
- private static final String SYSTEM_HOST = "System Host";
- private static final AtomicReference SYSTEM_HOST_CONFIG = new AtomicReference<>();
+ private static final String AI_DEBUG_LOGGING_KEY = "AI_DEBUG_LOGGING";
public static final Pattern SPLITTER = Pattern.compile("\\s?,\\s?");
@@ -51,9 +49,6 @@ public class AppConfig implements Serializable {
public AppConfig(final String host, final Map secrets) {
this.host = host;
- if (SYSTEM_HOST.equalsIgnoreCase(host)) {
- setSystemHostConfig(this);
- }
final AIAppUtil aiAppUtil = AIAppUtil.get();
apiKey = aiAppUtil.discoverSecret(secrets, AppKeys.API_KEY);
@@ -63,7 +58,7 @@ public AppConfig(final String host, final Map secrets) {
if (!secrets.isEmpty() || isEnabled()) {
AIModels.get().loadModels(
- this.host,
+ this,
List.of(
aiAppUtil.createTextModel(secrets),
aiAppUtil.createImageModel(secrets),
@@ -85,35 +80,25 @@ public AppConfig(final String host, final Map secrets) {
Logger.debug(this, this::toString);
}
- /**
- * Retrieves the system host configuration.
- *
- * @return the system host configuration
- */
- public static AppConfig getSystemHostConfig() {
- if (Objects.isNull(SYSTEM_HOST_CONFIG.get())) {
- setSystemHostConfig(ConfigService.INSTANCE.config());
- }
- return SYSTEM_HOST_CONFIG.get();
- }
-
/**
* Prints a specific error message to the log, based on the {@link AppKeys#DEBUG_LOGGING}
* property instead of the usual Log4j configuration.
*
+ * @param appConfig The {#link AppConfig} to be used when logging.
* @param clazz The {@link Class} to log the message for.
* @param message The {@link Supplier} with the message to log.
*/
- public static void debugLogger(final Class> clazz, final Supplier message) {
- if (getSystemHostConfig().getConfigBoolean(AppKeys.DEBUG_LOGGING)) {
+ public static void debugLogger(final AppConfig appConfig, final Class> clazz, final Supplier message) {
+ if (appConfig == null) {
+ Logger.debug(clazz, message);
+ return;
+ }
+ if (appConfig.getConfigBoolean(AppKeys.DEBUG_LOGGING)
+ || Config.getBooleanProperty(AI_DEBUG_LOGGING_KEY, false)) {
Logger.info(clazz, message.get());
}
}
- public static void setSystemHostConfig(final AppConfig systemHostConfig) {
- AppConfig.SYSTEM_HOST_CONFIG.set(systemHostConfig);
- }
-
/**
* Retrieves the host.
*
@@ -318,6 +303,10 @@ public boolean isEnabled() {
return Stream.of(apiUrl, apiImageUrl, apiEmbeddingsUrl, apiKey).allMatch(StringUtils::isNotBlank);
}
+ public void debugLogger(final Class> clazz, final Supplier message) {
+ debugLogger(this, clazz, message);
+ }
+
@Override
public String toString() {
return "AppConfig{\n" +
diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/AIClientStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/AIClientStrategy.java
index 6ac784ef2a2a..371852569eb3 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/client/AIClientStrategy.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/client/AIClientStrategy.java
@@ -1,6 +1,8 @@
package com.dotcms.ai.client;
+import com.dotcms.ai.AiKeys;
import com.dotcms.ai.domain.AIResponse;
+import com.dotcms.ai.domain.AIResponseData;
import java.io.OutputStream;
import java.io.Serializable;
@@ -23,7 +25,10 @@
*/
public interface AIClientStrategy {
- AIClientStrategy NOOP = (client, handler, request, output) -> AIResponse.builder().build();
+ AIClientStrategy NOOP = (client, handler, request, output) -> {
+ AIResponse.builder().build();
+ return null;
+ };
/**
* Applies the strategy to the given AI client request and handles the response.
@@ -31,11 +36,51 @@ public interface AIClientStrategy {
* @param client the AI client to which the request is sent
* @param handler the response evaluator to handle the response
* @param request the AI request to be processed
- * @param output the output stream to which the response will be written
+ * @param incoming the output stream to which the response will be written
+ * @return response data object
*/
- void applyStrategy(AIClient client,
- AIResponseEvaluator handler,
- AIRequest extends Serializable> request,
- OutputStream output);
+ AIResponseData applyStrategy(AIClient client,
+ AIResponseEvaluator handler,
+ AIRequest extends Serializable> request,
+ OutputStream incoming);
+
+ /**
+ * Converts the given output stream to an AIResponseData object.
+ *
+ *
+ * This method takes an output stream, converts its content to a string, and
+ * sets it as the response in an AIResponseData object. The output stream is
+ * also set in the AIResponseData object.
+ *
+ *
+ * @param output the output stream containing the response data
+ * @param isStream is stream flag
+ * @return an AIResponseData object containing the response and the output stream
+ */
+ static AIResponseData response(final OutputStream output, boolean isStream) {
+ final AIResponseData responseData = new AIResponseData();
+ if (!isStream) {
+ responseData.setResponse(output.toString());
+ }
+ responseData.setOutput(output);
+
+ return responseData;
+ }
+
+ /**
+ * Checks if the given request is a stream request.
+ *
+ *
+ * This method examines the payload of the provided `JSONObjectAIRequest` to determine
+ * if it contains a stream flag set to true. If the stream flag is present and set to true,
+ * the method returns true, indicating that the request is a stream request.
+ *
+ *
+ * @param request the `JSONObjectAIRequest` to be checked
+ * @return true if the request is a stream request, false otherwise
+ */
+ static boolean isStream(final JSONObjectAIRequest request) {
+ return request.getPayload().optBoolean(AiKeys.STREAM, false);
+ }
}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/AIDefaultStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/AIDefaultStrategy.java
index 02149d98a7b1..37974fa9c3c6 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/client/AIDefaultStrategy.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/client/AIDefaultStrategy.java
@@ -1,7 +1,11 @@
package com.dotcms.ai.client;
+import com.dotcms.ai.domain.AIResponseData;
+
+import java.io.ByteArrayOutputStream;
import java.io.OutputStream;
import java.io.Serializable;
+import java.util.Optional;
/**
* Default implementation of the {@link AIClientStrategy} interface.
@@ -22,11 +26,17 @@
public class AIDefaultStrategy implements AIClientStrategy {
@Override
- public void applyStrategy(final AIClient client,
- final AIResponseEvaluator handler,
- final AIRequest extends Serializable> request,
- final OutputStream output) {
- client.sendRequest(request, output);
+ public AIResponseData applyStrategy(final AIClient client,
+ final AIResponseEvaluator handler,
+ final AIRequest extends Serializable> request,
+ final OutputStream incoming) {
+ final JSONObjectAIRequest jsonRequest = AIClient.useRequestOrThrow(request);
+ final boolean isStream = AIClientStrategy.isStream(jsonRequest);
+ final OutputStream output = Optional.ofNullable(incoming).orElseGet(ByteArrayOutputStream::new);
+
+ client.sendRequest(jsonRequest, output);
+
+ return AIClientStrategy.response(output, isStream);
}
}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/AIModelFallbackStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/AIModelFallbackStrategy.java
index 0553645ece58..9480faabfe3a 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/client/AIModelFallbackStrategy.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/client/AIModelFallbackStrategy.java
@@ -8,20 +8,15 @@
import com.dotcms.ai.domain.Model;
import com.dotcms.ai.exception.DotAIAllModelsExhaustedException;
import com.dotcms.ai.validator.AIAppValidator;
-import com.dotmarketing.exception.DotRuntimeException;
import com.dotmarketing.util.UtilMethods;
import io.vavr.Tuple;
import io.vavr.Tuple2;
import io.vavr.control.Try;
import org.apache.commons.io.IOUtils;
-import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
import java.io.OutputStream;
import java.io.Serializable;
-import java.nio.charset.StandardCharsets;
import java.util.Optional;
/**
@@ -55,23 +50,24 @@ public class AIModelFallbackStrategy implements AIClientStrategy {
* @param client the AI client to which the request is sent
* @param handler the response evaluator to handle the response
* @param request the AI request to be processed
- * @param output the output stream to which the response will be written
+ * @param incoming the output stream to which the response will be written
+ * @return response data object
* @throws DotAIAllModelsExhaustedException if all models are exhausted and no successful response is obtained
*/
@Override
- public void applyStrategy(final AIClient client,
- final AIResponseEvaluator handler,
- final AIRequest extends Serializable> request,
- final OutputStream output) {
+ public AIResponseData applyStrategy(final AIClient client,
+ final AIResponseEvaluator handler,
+ final AIRequest extends Serializable> request,
+ final OutputStream incoming) {
final JSONObjectAIRequest jsonRequest = AIClient.useRequestOrThrow(request);
final Tuple2 modelTuple = resolveModel(jsonRequest);
- final AIResponseData firstAttempt = sendAttempt(client, handler, jsonRequest, output, modelTuple);
+ final AIResponseData firstAttempt = sendRequest(client, handler, jsonRequest, incoming, modelTuple);
if (firstAttempt.isSuccess()) {
- return;
+ return firstAttempt;
}
- runFallbacks(client, handler, jsonRequest, output, modelTuple);
+ return runFallbacks(client, handler, jsonRequest, incoming, modelTuple);
}
private static Tuple2 resolveModel(final JSONObjectAIRequest request) {
@@ -96,16 +92,12 @@ private static Tuple2 resolveModel(final JSONObjectAIRequest req
}
private static boolean isSameAsFirst(final Model firstAttempt, final Model model) {
- if (firstAttempt.equals(model)) {
- return true;
- }
-
- return false;
+ return firstAttempt.equals(model);
}
- private static boolean isOperational(final Model model) {
+ private static boolean isOperational(final Model model, final AppConfig config) {
if (!model.isOperational()) {
- AppConfig.debugLogger(
+ config.debugLogger(
AIModelFallbackStrategy.class,
() -> String.format("Model [%s] is not operational. Skipping.", model.getName()));
return false;
@@ -114,37 +106,28 @@ private static boolean isOperational(final Model model) {
return true;
}
- private static AIResponseData doSend(final AIClient client, final AIRequest extends Serializable> request) {
- final ByteArrayOutputStream output = new ByteArrayOutputStream();
+ private static AIResponseData doSend(final AIClient client,
+ final JSONObjectAIRequest request,
+ final OutputStream incoming,
+ final boolean isStream) {
+ final OutputStream output = Optional.ofNullable(incoming).orElseGet(ByteArrayOutputStream::new);
client.sendRequest(request, output);
- final AIResponseData responseData = new AIResponseData();
- responseData.setResponse(output.toString());
- IOUtils.closeQuietly(output);
-
- return responseData;
+ return AIClientStrategy.response(output, isStream);
}
- private static void redirectOutput(final OutputStream output, final String response) {
- try (final InputStream input = new ByteArrayInputStream(response.getBytes(StandardCharsets.UTF_8))) {
- IOUtils.copy(input, output);
- } catch (IOException e) {
- throw new DotRuntimeException(e);
- }
- }
-
- private static void notifyFailure(final AIModel aiModel, final AIRequest extends Serializable> request) {
- AIAppValidator.get().validateModelsUsage(aiModel, request.getUserId());
+ private static void notifyFailure(final AIModel aiModel, final JSONObjectAIRequest request) {
+ AIAppValidator.get().validateModelsUsage(aiModel, request);
}
private static void handleFailure(final Tuple2 modelTuple,
- final AIRequest extends Serializable> request,
+ final JSONObjectAIRequest request,
final AIResponseData responseData) {
final AIModel aiModel = modelTuple._1;
final Model model = modelTuple._2;
if (!responseData.getStatus().doesNeedToThrow()) {
- AppConfig.debugLogger(
+ request.getConfig().debugLogger(
AIModelFallbackStrategy.class,
() -> String.format(
"Model [%s] failed then setting its status to [%s].",
@@ -155,7 +138,7 @@ private static void handleFailure(final Tuple2 modelTuple,
if (model.getIndex() == aiModel.getModels().size() - 1) {
aiModel.setCurrentModelIndex(AIModel.NOOP_INDEX);
- AppConfig.debugLogger(
+ request.getConfig().debugLogger(
AIModelFallbackStrategy.class,
() -> String.format(
"Model [%s] is the last one. Cannot fallback anymore.",
@@ -170,58 +153,65 @@ private static void handleFailure(final Tuple2 modelTuple,
}
}
- private static AIResponseData sendAttempt(final AIClient client,
+ private static AIResponseData sendRequest(final AIClient client,
final AIResponseEvaluator evaluator,
final JSONObjectAIRequest request,
final OutputStream output,
final Tuple2 modelTuple) {
-
+ final boolean isStream = AIClientStrategy.isStream(request);
final AIResponseData responseData = Try
- .of(() -> doSend(client, request))
+ .of(() -> doSend(client, request, output, isStream))
.getOrElseGet(exception -> fromException(evaluator, exception));
- if (!responseData.isSuccess()) {
- if (responseData.getStatus().doesNeedToThrow()) {
- if (!modelTuple._1.isOperational()) {
- AppConfig.debugLogger(
- AIModelFallbackStrategy.class,
- () -> String.format(
- "All models from type [%s] are not operational. Throwing exception.",
- modelTuple._1.getType()));
- notifyFailure(modelTuple._1, request);
+ try {
+ if (!responseData.isSuccess()) {
+ if (responseData.getStatus().doesNeedToThrow()) {
+ if (!modelTuple._1.isOperational()) {
+ request.getConfig().debugLogger(
+ AIModelFallbackStrategy.class,
+ () -> String.format(
+ "All models from type [%s] are not operational. Throwing exception.",
+ modelTuple._1.getType()));
+ notifyFailure(modelTuple._1, request);
+ }
+ throw responseData.getException();
}
- throw responseData.getException();
+ } else {
+ evaluator.fromResponse(responseData.getResponse(), responseData, !isStream);
}
- } else {
- evaluator.fromResponse(responseData.getResponse(), responseData, output instanceof ByteArrayOutputStream);
- }
- if (responseData.isSuccess()) {
- AppConfig.debugLogger(
- AIModelFallbackStrategy.class,
- () -> String.format("Model [%s] succeeded. No need to fallback.", modelTuple._2.getName()));
- redirectOutput(output, responseData.getResponse());
- } else {
- logFailure(modelTuple, responseData);
+ if (responseData.isSuccess()) {
+ request.getConfig().debugLogger(
+ AIModelFallbackStrategy.class,
+ () -> String.format("Model [%s] succeeded. No need to fallback.", modelTuple._2.getName()));
+ } else {
+ logFailure(modelTuple, request, responseData);
- handleFailure(modelTuple, request, responseData);
+ handleFailure(modelTuple, request, responseData);
+ }
+ } finally {
+ if (!isStream) {
+ IOUtils.closeQuietly(responseData.getOutput());
+ }
}
return responseData;
}
- private static void logFailure(final Tuple2 modelTuple, final AIResponseData responseData) {
+ private static void logFailure(final Tuple2 modelTuple,
+ final JSONObjectAIRequest request,
+ final AIResponseData responseData) {
Optional
.ofNullable(responseData.getResponse())
.ifPresentOrElse(
- response -> AppConfig.debugLogger(
+ response -> request.getConfig().debugLogger(
AIModelFallbackStrategy.class,
() -> String.format(
"Model [%s] failed with response:%s%sTrying next model.",
modelTuple._2.getName(),
System.lineSeparator(),
response)),
- () -> AppConfig.debugLogger(
+ () -> request.getConfig().debugLogger(
AIModelFallbackStrategy.class,
() -> String.format(
"Model [%s] failed with error: [%s]. Trying next model.",
@@ -235,27 +225,29 @@ private static AIResponseData fromException(final AIResponseEvaluator evaluator,
return metadata;
}
- private static void runFallbacks(final AIClient client,
- final AIResponseEvaluator evaluator,
- final JSONObjectAIRequest request,
- final OutputStream output,
- final Tuple2 modelTuple) {
+ private static AIResponseData runFallbacks(final AIClient client,
+ final AIResponseEvaluator evaluator,
+ final JSONObjectAIRequest request,
+ final OutputStream output,
+ final Tuple2 modelTuple) {
for(final Model model : modelTuple._1.getModels()) {
- if (isSameAsFirst(modelTuple._2, model) || !isOperational(model)) {
+ if (isSameAsFirst(modelTuple._2, model) || !isOperational(model, request.getConfig())) {
continue;
}
request.getPayload().put(AiKeys.MODEL, model.getName());
- final AIResponseData responseData = sendAttempt(
+ final AIResponseData responseData = sendRequest(
client,
evaluator,
request,
output,
Tuple.of(modelTuple._1, model));
if (responseData.isSuccess()) {
- return;
+ return responseData;
}
}
+
+ return null;
}
}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/AIProxiedClient.java b/dotCMS/src/main/java/com/dotcms/ai/client/AIProxiedClient.java
index 73d675a3b90e..151fe44e2aaa 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/client/AIProxiedClient.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/client/AIProxiedClient.java
@@ -1,8 +1,8 @@
package com.dotcms.ai.client;
import com.dotcms.ai.domain.AIResponse;
+import com.dotcms.ai.domain.AIResponseData;
-import java.io.ByteArrayOutputStream;
import java.io.OutputStream;
import java.io.Serializable;
import java.util.Optional;
@@ -73,13 +73,11 @@ public static AIProxiedClient of(final AIClient client, final AIProxyStrategy st
* @return the AI response
*/
public AIResponse sendToAI(final AIRequest request, final OutputStream output) {
- final OutputStream finalOutput = Optional.ofNullable(output).orElseGet(ByteArrayOutputStream::new);
-
- strategy.applyStrategy(client, responseEvaluator, request, finalOutput);
+ final AIResponseData responseData = strategy.applyStrategy(client, responseEvaluator, request, output);
return Optional.ofNullable(output)
.map(out -> AIResponse.EMPTY)
- .orElseGet(() -> AIResponse.builder().withResponse(finalOutput.toString()).build());
+ .orElseGet(() -> AIResponse.builder().withResponse(responseData.getOutput().toString()).build());
}
}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/openai/OpenAIClient.java b/dotCMS/src/main/java/com/dotcms/ai/client/openai/OpenAIClient.java
index ab12dbba58f3..c80eae336afc 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/client/openai/OpenAIClient.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/client/openai/OpenAIClient.java
@@ -16,7 +16,6 @@
import com.dotmarketing.util.Logger;
import com.dotmarketing.util.json.JSONObject;
import io.vavr.Lazy;
-import io.vavr.Tuple;
import io.vavr.Tuple2;
import io.vavr.control.Try;
import org.apache.http.HttpHeaders;
@@ -29,6 +28,7 @@
import org.apache.http.impl.client.HttpClients;
import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
import java.io.BufferedInputStream;
import java.io.OutputStream;
import java.io.Serializable;
@@ -82,7 +82,7 @@ public void sendRequest(final AIRequest request, fin
final JSONObjectAIRequest jsonRequest = AIClient.useRequestOrThrow(request);
final AppConfig appConfig = jsonRequest.getConfig();
- AppConfig.debugLogger(
+ request.getConfig().debugLogger(
OpenAIClient.class,
() -> String.format(
"Posting to [%s] with method [%s]%s with app config:%s%s the payload: %s",
@@ -94,7 +94,7 @@ public void sendRequest(final AIRequest request, fin
jsonRequest.payloadToString()));
if (!appConfig.isEnabled()) {
- AppConfig.debugLogger(OpenAIClient.class, () -> "App dotAI is not enabled and will not send request.");
+ request.getConfig().debugLogger(OpenAIClient.class, () -> "App dotAI is not enabled and will not send request.");
throw new DotAIAppConfigDisabledException("App dotAI config without API urls or API key");
}
@@ -106,7 +106,7 @@ public void sendRequest(final AIRequest request, fin
final AIModel aiModel = modelTuple._1;
if (!modelTuple._2.isOperational()) {
- AppConfig.debugLogger(
+ request.getConfig().debugLogger(
getClass(),
() -> String.format("Resolved model [%s] is not operational, avoiding its usage", modelName));
throw new DotAIModelNotOperationalException(String.format("Model [%s] is not operational", modelName));
@@ -129,17 +129,19 @@ public void sendRequest(final AIRequest request, fin
lastRestCall.put(aiModel, System.currentTimeMillis());
- try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
+ try (final CloseableHttpClient httpClient = HttpClients.createDefault()) {
final StringEntity jsonEntity = new StringEntity(payload.toString(), ContentType.APPLICATION_JSON);
final HttpUriRequest httpRequest = AIClient.resolveMethod(jsonRequest.getMethod(), jsonRequest.getUrl());
httpRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
httpRequest.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + appConfig.getApiKey());
if (!payload.getAsMap().isEmpty()) {
- Try.run(() -> HttpEntityEnclosingRequestBase.class.cast(httpRequest).setEntity(jsonEntity));
+ Try.run(() -> ((HttpEntityEnclosingRequestBase) httpRequest).setEntity(jsonEntity));
}
- try (CloseableHttpResponse response = httpClient.execute(httpRequest)) {
+ try (final CloseableHttpResponse response = httpClient.execute(httpRequest)) {
+ onStreamCheckFotStatusCode(modelName, payload, response);
+
final BufferedInputStream in = new BufferedInputStream(response.getEntity().getContent());
final byte[] buffer = new byte[1024];
int len;
@@ -148,6 +150,8 @@ public void sendRequest(final AIRequest request, fin
output.flush();
}
}
+ } catch (DotAIModelNotFoundException e) {
+ throw e;
} catch (Exception e) {
if (appConfig.getConfigBoolean(AppKeys.DEBUG_LOGGING)){
Logger.warn(this, "INVALID REQUEST: " + e.getMessage(), e);
@@ -161,4 +165,17 @@ public void sendRequest(final AIRequest request, fin
}
}
+ private static void onStreamCheckFotStatusCode(final String modelName,
+ final JSONObject payload,
+ final CloseableHttpResponse response) {
+ if (payload.optBoolean(AiKeys.STREAM, false)) {
+ final int statusCode = response.getStatusLine().getStatusCode();
+ if (Response.Status.Family.familyOf(statusCode) == Response.Status.Family.CLIENT_ERROR) {
+ throw new DotAIModelNotFoundException(String.format(
+ "Model used [%s] in request in stream mode is not found",
+ modelName));
+ }
+ }
+ }
+
}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/domain/AIResponse.java b/dotCMS/src/main/java/com/dotcms/ai/domain/AIResponse.java
index 8d9887b24571..dff8cacca25d 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/domain/AIResponse.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/domain/AIResponse.java
@@ -41,7 +41,6 @@ public Builder withResponse(final String response) {
return this;
}
-
public AIResponse build() {
return new AIResponse(this);
}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/domain/AIResponseData.java b/dotCMS/src/main/java/com/dotcms/ai/domain/AIResponseData.java
index 85ac2d9d0483..b8c58b17eae6 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/domain/AIResponseData.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/domain/AIResponseData.java
@@ -3,6 +3,8 @@
import com.dotmarketing.exception.DotRuntimeException;
import org.apache.commons.lang3.StringUtils;
+import java.io.OutputStream;
+
/**
* Represents the data of a response from an AI service.
*
@@ -20,6 +22,7 @@ public class AIResponseData {
private String error;
private ModelStatus status;
private DotRuntimeException exception;
+ private OutputStream output;
public String getResponse() {
return response;
@@ -53,6 +56,14 @@ public void setException(DotRuntimeException exception) {
this.exception = exception;
}
+ public OutputStream getOutput() {
+ return output;
+ }
+
+ public void setOutput(OutputStream output) {
+ this.output = output;
+ }
+
public boolean isSuccess() {
return StringUtils.isBlank(error);
}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/listener/EmbeddingContentListener.java b/dotCMS/src/main/java/com/dotcms/ai/listener/EmbeddingContentListener.java
index 5c5a7b24d5ef..0c315c7541a6 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/listener/EmbeddingContentListener.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/listener/EmbeddingContentListener.java
@@ -83,7 +83,7 @@ private AppConfig getAppConfig(final String hostId) {
final AppConfig appConfig = ConfigService.INSTANCE.config(host);
if (!appConfig.isEnabled()) {
- AppConfig.debugLogger(
+ appConfig.debugLogger(
getClass(),
() -> "dotAI is not enabled since no API urls or API key found in app config");
throw new DotAIAppConfigDisabledException("App dotAI config without API urls or API key");
diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java
index 5499de4ce660..0351ec0bb4c8 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java
@@ -56,14 +56,15 @@ public class CompletionsResource {
public final Response summarizeFromContent(@Context final HttpServletRequest request,
@Context final HttpServletResponse response,
final CompletionsForm formIn) {
+ final CompletionsForm resolvedForm = resolveForm(request, response, formIn);
return getResponse(
request,
response,
formIn,
- () -> APILocator.getDotAIAPI().getCompletionsAPI().summarize(formIn),
+ () -> APILocator.getDotAIAPI().getCompletionsAPI().summarize(resolvedForm),
output -> APILocator.getDotAIAPI()
.getCompletionsAPI()
- .summarizeStream(formIn, new LineReadingOutputStream(output)));
+ .summarizeStream(resolvedForm, new LineReadingOutputStream(output)));
}
/**
@@ -81,14 +82,15 @@ public final Response summarizeFromContent(@Context final HttpServletRequest req
public final Response rawPrompt(@Context final HttpServletRequest request,
@Context final HttpServletResponse response,
final CompletionsForm formIn) {
+ final CompletionsForm resolvedForm = resolveForm(request, response, formIn);
return getResponse(
request,
response,
formIn,
- () -> APILocator.getDotAIAPI().getCompletionsAPI().raw(formIn),
+ () -> APILocator.getDotAIAPI().getCompletionsAPI().raw(resolvedForm),
output -> APILocator.getDotAIAPI()
.getCompletionsAPI()
- .rawStream(formIn, new LineReadingOutputStream(output)));
+ .rawStream(resolvedForm, new LineReadingOutputStream(output)));
}
/**
@@ -180,6 +182,7 @@ private static Response getResponse(final HttpServletRequest request,
final JSONObject jsonResponse = noStream.get();
jsonResponse.put(AiKeys.TOTAL_TIME, System.currentTimeMillis() - startTime + "ms");
+
return Response.ok(jsonResponse.toString(), MediaType.APPLICATION_JSON).build();
}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/util/ContentToStringUtil.java b/dotCMS/src/main/java/com/dotcms/ai/util/ContentToStringUtil.java
index b08c7ed54f57..300d4fd74bd4 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/util/ContentToStringUtil.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/util/ContentToStringUtil.java
@@ -1,6 +1,7 @@
package com.dotcms.ai.util;
+import com.dotcms.ai.app.AppConfig;
import com.dotcms.ai.app.AppKeys;
import com.dotcms.ai.app.ConfigService;
import com.dotcms.contenttype.model.field.BinaryField;
@@ -40,7 +41,6 @@
import java.util.regex.Pattern;
import java.util.stream.Collectors;
-import static com.dotcms.ai.app.AppConfig.debugLogger;
import static com.liferay.util.StringPool.BLANK;
import static com.liferay.util.StringPool.SPACE;
@@ -194,7 +194,8 @@ public List guessWhatFieldsToIndex(@NotNull Contentlet contentlet) {
.filter(f ->
f.dataType().equals(DataTypes.LONG_TEXT)
).collect(Collectors.toUnmodifiableList());
- debugLogger(this.getClass(), () -> String.format("Found %d indexable field(s) for Contentlet ID '%s': %s",
+ final AppConfig config = ConfigService.INSTANCE.config();
+ config.debugLogger(this.getClass(), () -> String.format("Found %d indexable field(s) for Contentlet ID '%s': %s",
indexableFields.size(), contentlet.getIdentifier(), indexableFields.stream().map(Field::variable).collect(Collectors.toSet())));
return indexableFields;
}
@@ -253,10 +254,10 @@ public Optional parseFields(@NotNull final Contentlet contentlet, @N
parseField(contentlet, field)
.ifPresent(s -> builder.append(s).append(SPACE));
}
- final int embeddingsMinimumLength =
- ConfigService.INSTANCE.config().getConfigInteger(AppKeys.EMBEDDINGS_MINIMUM_TEXT_LENGTH_TO_INDEX);
+ final AppConfig config = ConfigService.INSTANCE.config();
+ final int embeddingsMinimumLength = config.getConfigInteger(AppKeys.EMBEDDINGS_MINIMUM_TEXT_LENGTH_TO_INDEX);
if (builder.length() < embeddingsMinimumLength) {
- debugLogger(this.getClass(), () -> String.format("Parseable fields for Contentlet ID " +
+ config.debugLogger(this.getClass(), () -> String.format("Parseable fields for Contentlet ID " +
"'%s' don't meet the minimum length requirement of %d characters. Skipping indexing.",
contentlet.getIdentifier(), embeddingsMinimumLength));
return Optional.empty();
diff --git a/dotCMS/src/main/java/com/dotcms/ai/util/EncodingUtil.java b/dotCMS/src/main/java/com/dotcms/ai/util/EncodingUtil.java
index 9aed5869213a..a427cfa76955 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/util/EncodingUtil.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/util/EncodingUtil.java
@@ -37,7 +37,7 @@ public Optional getEncoding(final AppConfig appConfig, final AIModelTy
final Model currentModel = aiModel.getCurrent();
if (Objects.isNull(currentModel)) {
- AppConfig.debugLogger(
+ appConfig.debugLogger(
getClass(),
() -> String.format(
"No current model found for type [%s], meaning the are all are exhausted",
@@ -47,16 +47,17 @@ public Optional getEncoding(final AppConfig appConfig, final AIModelTy
return registry
.getEncodingForModel(currentModel.getName())
- .or(() -> modelFallback(aiModel, currentModel));
+ .or(() -> modelFallback(appConfig, aiModel, currentModel));
}
public Optional getEncoding() {
return getEncoding(ConfigService.INSTANCE.config(), AIModelType.EMBEDDINGS);
}
- private Optional modelFallback(final AIModel aiModel,
+ private Optional modelFallback(final AppConfig appConfig,
+ final AIModel aiModel,
final Model currentModel) {
- AppConfig.debugLogger(
+ appConfig.debugLogger(
getClass(),
() -> String.format(
"Model [%s] is not suitable for encoding, marking it as invalid and falling back to other models",
@@ -74,7 +75,7 @@ private Optional modelFallback(final AIModel aiModel,
final Optional encoding = registry.getEncodingForModel(model.getName());
if (encoding.isEmpty()) {
model.setStatus(ModelStatus.INVALID);
- AppConfig.debugLogger(
+ appConfig.debugLogger(
getClass(),
() -> String.format(
"Model [%s] is not suitable for encoding, marking as invalid",
@@ -83,7 +84,7 @@ private Optional modelFallback(final AIModel aiModel,
}
aiModel.setCurrentModelIndex(model.getIndex());
- AppConfig.debugLogger(
+ appConfig.debugLogger(
getClass(),
() -> "Model [" + model.getName() + "] found, setting as current model");
return encoding.get();
diff --git a/dotCMS/src/main/java/com/dotcms/ai/validator/AIAppValidator.java b/dotCMS/src/main/java/com/dotcms/ai/validator/AIAppValidator.java
index 7c51f40c883e..14299eac93c9 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/validator/AIAppValidator.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/validator/AIAppValidator.java
@@ -3,6 +3,7 @@
import com.dotcms.ai.app.AIModel;
import com.dotcms.ai.app.AIModels;
import com.dotcms.ai.app.AppConfig;
+import com.dotcms.ai.client.JSONObjectAIRequest;
import com.dotcms.ai.domain.Model;
import com.dotcms.api.system.event.message.MessageSeverity;
import com.dotcms.api.system.event.message.SystemMessageEventUtil;
@@ -52,7 +53,7 @@ public static AIAppValidator get() {
*/
public void validateAIConfig(final AppConfig appConfig, final String userId) {
if (Objects.isNull(userId)) {
- AppConfig.debugLogger(getClass(), () -> "User Id is null, skipping AI configuration validation");
+ appConfig.debugLogger(getClass(), () -> "User Id is null, skipping AI configuration validation");
return;
}
@@ -89,11 +90,11 @@ public void validateAIConfig(final AppConfig appConfig, final String userId) {
* If any exhausted or invalid models are found, a warning message is pushed to the user.
*
* @param aiModel the AI model
- * @param userId the user ID
+ * @param request the ai request
*/
- public void validateModelsUsage(final AIModel aiModel, final String userId) {
- if (Objects.isNull(userId)) {
- AppConfig.debugLogger(getClass(), () -> "User Id is null, skipping AI models usage validation");
+ public void validateModelsUsage(final AIModel aiModel, final JSONObjectAIRequest request) {
+ if (Objects.isNull(request.getUserId())) {
+ request.getConfig().debugLogger(getClass(), () -> "User Id is null, skipping AI models usage validation");
return;
}
@@ -114,7 +115,7 @@ public void validateModelsUsage(final AIModel aiModel, final String userId) {
.setLife(DateUtil.SEVEN_SECOND_MILLIS)
.create();
- systemMessageEventUtil.pushMessage(systemMessage, Collections.singletonList(userId));
+ systemMessageEventUtil.pushMessage(systemMessage, Collections.singletonList(request.getUserId()));
}
@VisibleForTesting
diff --git a/dotCMS/src/main/java/com/dotcms/analytics/query/AnalyticsQuery.java b/dotCMS/src/main/java/com/dotcms/analytics/query/AnalyticsQuery.java
index d1a3f0720a3b..10ecae7ad6f7 100644
--- a/dotCMS/src/main/java/com/dotcms/analytics/query/AnalyticsQuery.java
+++ b/dotCMS/src/main/java/com/dotcms/analytics/query/AnalyticsQuery.java
@@ -24,7 +24,7 @@
* "limit": 100,
* "offset": 1,
* "timeDimensions": "Events.day day",
- * "orders": "Events.day ASC"
+ * "order": "Events.day ASC"
* }
* }
*
@@ -41,7 +41,7 @@ public class AnalyticsQuery implements Serializable {
private final long limit;
private final long offset;
private final String timeDimensions; // Events.day day
- private String orders; // Events.day ASC
+ private String order; // Events.day ASC
private AnalyticsQuery(final Builder builder) {
this.dimensions = builder.dimensions;
@@ -50,7 +50,7 @@ private AnalyticsQuery(final Builder builder) {
this.limit = builder.limit;
this.offset = builder.offset;
this.timeDimensions = builder.timeDimensions;
- this.orders = builder.orders;
+ this.order = builder.order;
}
public Set getDimensions() {
@@ -77,8 +77,8 @@ public String getTimeDimensions() {
return timeDimensions;
}
- public String getOrders() {
- return orders;
+ public String getOrder() {
+ return order;
}
public static class Builder {
@@ -96,7 +96,7 @@ public static class Builder {
@JsonProperty()
private String timeDimensions;
@JsonProperty()
- private String orders;
+ private String order;
public Builder dimensions(Set dimensions) {
@@ -129,8 +129,8 @@ public Builder timeDimensions(String timeDimensions) {
return this;
}
- public Builder orders(String orders) {
- this.orders = orders;
+ public Builder order(String orders) {
+ this.order = orders;
return this;
}
@@ -152,7 +152,7 @@ public String toString() {
", limit=" + limit +
", offset=" + offset +
", timeDimensions='" + timeDimensions + '\'' +
- ", orders='" + orders + '\'' +
+ ", order='" + order + '\'' +
'}';
}
}
diff --git a/dotCMS/src/main/java/com/dotcms/analytics/query/AnalyticsQueryParser.java b/dotCMS/src/main/java/com/dotcms/analytics/query/AnalyticsQueryParser.java
index fb0426549a81..eb12689c0fc9 100644
--- a/dotCMS/src/main/java/com/dotcms/analytics/query/AnalyticsQueryParser.java
+++ b/dotCMS/src/main/java/com/dotcms/analytics/query/AnalyticsQueryParser.java
@@ -44,7 +44,7 @@ public class AnalyticsQueryParser {
* "limit":100,
* "offset":1,
* "timeDimensions":"Events.day day",
- * "orders":"Events.day ASC"
+ * "order":"Events.day ASC"
* }
* @param json
* @return AnalyticsQuery
@@ -75,7 +75,7 @@ public AnalyticsQuery parseJsonToQuery(final String json) {
* "limit":100,
* "offset":1,
* "timeDimensions":"Events.day day",
- * "orders":"Events.day ASC"
+ * "order":"Events.day ASC"
* }
* @param json
* @return CubeJSQuery
@@ -117,8 +117,8 @@ public CubeJSQuery parseQueryToCubeQuery(final AnalyticsQuery query) {
builder.limit(query.getLimit()).offset(query.getOffset());
- if (UtilMethods.isSet(query.getOrders())) {
- builder.orders(parseOrders(query.getOrders()));
+ if (UtilMethods.isSet(query.getOrder())) {
+ builder.orders(parseOrders(query.getOrder()));
}
if (UtilMethods.isSet(query.getTimeDimensions())) {
diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/AsyncVanitiesCollector.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/AsyncVanitiesCollector.java
index e4bb96a20b96..3cacf16c4116 100644
--- a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/AsyncVanitiesCollector.java
+++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/AsyncVanitiesCollector.java
@@ -15,7 +15,9 @@
import java.util.Map;
/**
- * This asynchronized collector collects the page/asset information based on the vanity URL previous loaded on the
+ * This asynchronous collector collects page information based on the Vanity URL that has been
+ * processed previously.
+ *
* @author jsanca
*/
public class AsyncVanitiesCollector implements Collector {
diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/BasicProfileCollector.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/BasicProfileCollector.java
index 7b8ba7b8bb2f..9ef907a85ccb 100644
--- a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/BasicProfileCollector.java
+++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/BasicProfileCollector.java
@@ -15,11 +15,13 @@
import java.util.Objects;
public class BasicProfileCollector implements Collector {
+
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'");
+
@Override
public boolean test(CollectorContextMap collectorContextMap) {
-
- return true; // every one needs a basic profile
+ // Every collector needs a basic profile
+ return true;
}
@Override
@@ -62,22 +64,16 @@ public CollectorPayloadBean collect(final CollectorContextMap collectorContextMa
collectorPayloadBean.put("renderMode", PageMode.get(request).toString().replace("_MODE", ""));
- //Include default vakue for other Bool fiedls in the Clickhouse table
-
+ // Include default value for other boolean fields in the Clickhouse table
collectorPayloadBean.put("comeFromVanityURL", false);
collectorPayloadBean.put("isexperimentpage", false);
collectorPayloadBean.put("istargetpage", false);
-
return collectorPayloadBean;
}
- @Override
- public boolean isAsync() {
- return false;
- }
-
@Override
public boolean isEventCreator(){
return false;
}
+
}
diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/Collector.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/Collector.java
index 940f000a5c27..35a0c66196ad 100644
--- a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/Collector.java
+++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/Collector.java
@@ -1,7 +1,19 @@
package com.dotcms.analytics.track.collectors;
/**
- * A collector command basically puts information into a collector payload bean
+ * A collector command basically puts information into a collector payload bean. There are different
+ * implementations of a Collector, such as:
+ *
+ *
{@link BasicProfileCollector}
+ *
{@link PagesCollector}
+ *
{@link FilesCollector}
+ *
{@link SyncVanitiesCollector} and {@link AsyncVanitiesCollector}
+ *
And so on
+ *
+ * They all retrieve specific information from sources such as the request, the response, or related
+ * information form internal APIs, and put that information into a collector payload bean. Such a
+ * bean will be sent to the {@link com.dotcms.jitsu.EventLogSubmitter} to be persisted as an event.
+ *
* @author jsanca
*/
public interface Collector {
diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/PageDetailCollector.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/PageDetailCollector.java
index 9fd42684caaa..bb2cfc357bdb 100644
--- a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/PageDetailCollector.java
+++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/PageDetailCollector.java
@@ -77,7 +77,7 @@ public CollectorPayloadBean collect(final CollectorContextMap collectorContextMa
pageObject.put("id", detailPageContent.getIdentifier());
pageObject.put("title", detailPageContent.getTitle());
pageObject.put("url", uri);
- pageObject.put("detail_page_url", urlMapContentType.detailPage());
+ pageObject.put("detail_page_url", Try.of(detailPageContent::getURI).getOrElse(""));
collectorPayloadBean.put("object", pageObject);
}
diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/SyncVanitiesCollector.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/SyncVanitiesCollector.java
index f25cff71be0d..dbec9fe69f84 100644
--- a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/SyncVanitiesCollector.java
+++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/SyncVanitiesCollector.java
@@ -11,12 +11,12 @@
import java.util.Objects;
/**
- * This synchronized collector that collects the vanities
+ * This synchronous collector collects information from the Vanity URL that has been processed.
+ *
* @author jsanca
*/
public class SyncVanitiesCollector implements Collector {
-
public static final String VANITY_URL_KEY = "vanity_url";
public SyncVanitiesCollector() {
@@ -31,7 +31,6 @@ public boolean test(CollectorContextMap collectorContextMap) {
@Override
public CollectorPayloadBean collect(final CollectorContextMap collectorContextMap,
final CollectorPayloadBean collectorPayloadBean) {
-
if (null != collectorContextMap.get("request")) {
final HttpServletRequest request = (HttpServletRequest)collectorContextMap.get("request");
@@ -54,11 +53,11 @@ public CollectorPayloadBean collect(final CollectorContextMap collectorContextMa
final HashMap vanityObject = new HashMap<>();
if (Objects.nonNull(cachedVanityUrl)) {
-
vanityObject.put("id", cachedVanityUrl.vanityUrlId);
- vanityObject.put("forward_to",
- collectorPayloadBean.get(VANITY_URL_KEY)!=null?(String)collectorPayloadBean.get(VANITY_URL_KEY):cachedVanityUrl.forwardTo);
- vanityObject.put("url", uri);
+ vanityObject.put("forward_to", collectorPayloadBean.get(VANITY_URL_KEY) != null
+ ? (String) collectorPayloadBean.get(VANITY_URL_KEY)
+ : cachedVanityUrl.forwardTo);
+ vanityObject.put("url", cachedVanityUrl.url);
vanityObject.put("response", String.valueOf(cachedVanityUrl.response));
}
@@ -72,9 +71,4 @@ public CollectorPayloadBean collect(final CollectorContextMap collectorContextMa
return collectorPayloadBean;
}
- @Override
- public boolean isAsync() {
- return false;
- }
-
}
diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/WebEventsCollectorService.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/WebEventsCollectorService.java
index 89ad5af64ba1..6b8ef012939d 100644
--- a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/WebEventsCollectorService.java
+++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/WebEventsCollectorService.java
@@ -1,7 +1,6 @@
package com.dotcms.analytics.track.collectors;
import com.dotcms.analytics.track.matchers.RequestMatcher;
-import com.dotcms.analytics.track.matchers.UserCustomDefinedRequestMatcher;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@@ -17,7 +16,6 @@ public interface WebEventsCollectorService {
void fireCollectors (final HttpServletRequest request, final HttpServletResponse response,
final RequestMatcher requestMatcher);
-
/**
* Add a collector
* @param collectors
@@ -31,13 +29,18 @@ void fireCollectors (final HttpServletRequest request, final HttpServletResponse
void removeCollector(final String collectorId);
/**
- * Fire the collectors and emit the event
- * @param request
- * @param response
- * @param requestMatcher
- * @param userEventPayload
+ * Allows to fire the collectors and emit the event from a base payload map already built by
+ * the user
+ *
+ * @param request The current instance of the {@link HttpServletRequest}.
+ * @param response The current instance of the {@link HttpServletResponse}.
+ * @param requestMatcher The {@link RequestMatcher} that matched the dotCMS object being
+ * processed, such as: HTML Page, File Asset, URL Mapped Content,
+ * Vanity URL, etc.
+ * @param basePayloadMap A Map containing all the properties that were retrieved by a given
+ * {@link Collector}.
*/
- void fireCollectorsAndEmitEvent(HttpServletRequest request, HttpServletResponse response,
- final RequestMatcher requestMatcher, Map userEventPayload);
+ void fireCollectorsAndEmitEvent(final HttpServletRequest request, final HttpServletResponse response,
+ final RequestMatcher requestMatcher, final Map userEventPayload);
}
diff --git a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/WebEventsCollectorServiceFactory.java b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/WebEventsCollectorServiceFactory.java
index 1b70eb83f244..1134d666f98b 100644
--- a/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/WebEventsCollectorServiceFactory.java
+++ b/dotCMS/src/main/java/com/dotcms/analytics/track/collectors/WebEventsCollectorServiceFactory.java
@@ -10,6 +10,7 @@
import com.dotmarketing.util.Logger;
import com.dotmarketing.util.PageMode;
import com.dotmarketing.util.UtilMethods;
+import com.google.common.annotations.VisibleForTesting;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@@ -23,7 +24,8 @@
import java.util.stream.Stream;
/**
- * This class provides the default implementation for the WebEventsCollectorService
+ * This class provides the default implementation for the {@link WebEventsCollectorService}
+ * interface.
*
* @author jsanca
*/
@@ -53,12 +55,15 @@ public WebEventsCollectorService getWebEventsCollectorService() {
return webEventsCollectorService;
}
- private static class WebEventsCollectorServiceImpl implements WebEventsCollectorService {
+ /**
+ *
+ */
+ public static class WebEventsCollectorServiceImpl implements WebEventsCollectorService {
private final Collectors baseCollectors = new Collectors();
private final Collectors eventCreatorCollectors = new Collectors();
- private final EventLogSubmitter submitter = new EventLogSubmitter();
+ private EventLogSubmitter submitter = new EventLogSubmitter();
WebEventsCollectorServiceImpl () {
@@ -67,6 +72,11 @@ private static class WebEventsCollectorServiceImpl implements WebEventsCollector
new CustomerEventCollector());
}
+ @VisibleForTesting
+ WebEventsCollectorServiceImpl(final EventLogSubmitter submitter) {
+ this.submitter = submitter;
+ }
+
@Override
public void fireCollectors(final HttpServletRequest request,
final HttpServletResponse response,
@@ -81,7 +91,6 @@ public void fireCollectors(final HttpServletRequest request,
}
}
- @Override
/**
* Allows to fire the collections and emit the event from a base payload map already built by the user
* @param request
@@ -89,6 +98,7 @@ public void fireCollectors(final HttpServletRequest request,
* @param requestMatcher
* @param basePayloadMap
*/
+ @Override
public void fireCollectorsAndEmitEvent(final HttpServletRequest request,
final HttpServletResponse response,
final RequestMatcher requestMatcher,
diff --git a/dotCMS/src/main/java/com/dotcms/analytics/viewtool/AnalyticsTool.java b/dotCMS/src/main/java/com/dotcms/analytics/viewtool/AnalyticsTool.java
index 1890d00d802a..dcad56d98c98 100644
--- a/dotCMS/src/main/java/com/dotcms/analytics/viewtool/AnalyticsTool.java
+++ b/dotCMS/src/main/java/com/dotcms/analytics/viewtool/AnalyticsTool.java
@@ -40,19 +40,11 @@ public AnalyticsTool() {
}
private static ContentAnalyticsAPI getContentAnalyticsAPI() {
- final Optional contentAnalyticsAPI = CDIUtils.getBean(ContentAnalyticsAPI.class);
- if (!contentAnalyticsAPI.isPresent()) {
- throw new DotRuntimeException("Could not instance ContentAnalyticsAPI");
- }
- return contentAnalyticsAPI.get();
+ return CDIUtils.getBeanThrows(ContentAnalyticsAPI.class);
}
private static AnalyticsQueryParser getAnalyticsQueryParser() {
- final Optional queryParserOptional = CDIUtils.getBean(AnalyticsQueryParser.class);
- if (!queryParserOptional.isPresent()) {
- throw new DotRuntimeException("Could not instance AnalyticsQueryParser");
- }
- return queryParserOptional.get();
+ return CDIUtils.getBeanThrows(AnalyticsQueryParser.class);
}
public AnalyticsTool(final ContentAnalyticsAPI contentAnalyticsAPI,
@@ -93,7 +85,7 @@ public void init(final Object initData) {
* "limit":100,
* "offset":1,
* "timeDimensions":"Events.day day",
- * "orders":"Events.day ASC"
+ * "order":"Events.day ASC"
* }")
*
* $analytics.runReportFromJson($query)
@@ -118,7 +110,7 @@ public ReportResponse runReportFromJson(final String query) {
* $myMap.put('limit', 100)
* $myMap.put('offset', 1)
* $myMap.put('timeDimensions', "Events.day day")
- * $myMap.put('orders', "Events.day ASC")
+ * $myMap.put('order', "Events.day ASC")
*
* $analytics.runReportFromMap($myQuery)
*
diff --git a/dotCMS/src/main/java/com/dotcms/business/FactoryLocatorProducers.java b/dotCMS/src/main/java/com/dotcms/business/FactoryLocatorProducers.java
deleted file mode 100644
index 0c2d4cf8fe3a..000000000000
--- a/dotCMS/src/main/java/com/dotcms/business/FactoryLocatorProducers.java
+++ /dev/null
@@ -1,23 +0,0 @@
-package com.dotcms.business;
-
-import com.dotcms.cube.CubeJSClientFactory;
-import com.dotmarketing.business.FactoryLocator;
-
-import javax.enterprise.context.ApplicationScoped;
-import javax.enterprise.inject.Produces;
-
-/**
- * This class is useful to include classes are not into the CDI container but
- * wants to be available to be injected.
- * Most of the {@link FactoryLocator} classes will be eventually here.
- * @author jsanca
- */
-@ApplicationScoped
-public class FactoryLocatorProducers {
-
-
- @Produces
- public CubeJSClientFactory getCubeJSClientFactory() {
- return FactoryLocator.getCubeJSClientFactory();
- }
-}
diff --git a/dotCMS/src/main/java/com/dotcms/cdi/CDIUtils.java b/dotCMS/src/main/java/com/dotcms/cdi/CDIUtils.java
index 296b09eeab28..b4dead470d50 100644
--- a/dotCMS/src/main/java/com/dotcms/cdi/CDIUtils.java
+++ b/dotCMS/src/main/java/com/dotcms/cdi/CDIUtils.java
@@ -16,20 +16,34 @@ private CDIUtils() {
}
/**
- * Get a bean from CDI container
+ * Get a bean from CDI container and return an Optional with the bean if found, empty otherwise
* @param clazz the class of the bean
* @return an Optional with the bean if found, empty otherwise
*/
public static Optional getBean(Class clazz) {
+ try {
+ return Optional.of(getBeanThrows(clazz));
+ } catch (Exception e) {
+ // Exception is already logged in getBeanThrows
+ }
+ return Optional.empty();
+ }
+
+
+ /**
+ * Get a bean from CDI container but throw an exception if the bean is not found
+ * @param clazz the class of the bean
+ * @return the bean
+ * @param the type of the bean
+ */
+ public static T getBeanThrows(Class clazz) {
try {
- return Optional.of(CDI.current().select(clazz).get());
+ return CDI.current().select(clazz).get();
} catch (Exception e) {
- Logger.error(CDIUtils.class,
- String.format("Unable to find bean of class [%s] [%s]", clazz, e.getMessage())
- );
+ String errorMessage = String.format("Unable to find bean of class [%s]: %s", clazz, e.getMessage());
+ Logger.error(CDIUtils.class, errorMessage);
+ throw new IllegalStateException(errorMessage, e);
}
- return Optional.empty();
}
-
}
diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java
index 8253b98a295e..a8bc97ab39b2 100644
--- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java
+++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java
@@ -1,9 +1,11 @@
package com.dotcms.content.elasticsearch.business;
+import com.dotcms.analytics.content.ContentAnalyticsAPI;
import com.dotcms.api.system.event.ContentletSystemEventUtil;
import com.dotcms.api.web.HttpServletRequestThreadLocal;
import com.dotcms.business.CloseDBIfOpened;
import com.dotcms.business.WrapInTransaction;
+import com.dotcms.cdi.CDIUtils;
import com.dotcms.concurrent.DotConcurrentFactory;
import com.dotcms.concurrent.lock.IdentifierStripedLock;
import com.dotcms.content.elasticsearch.business.event.ContentletArchiveEvent;
@@ -12,11 +14,9 @@
import com.dotcms.content.elasticsearch.business.event.ContentletPublishEvent;
import com.dotcms.content.elasticsearch.business.field.FieldHandlerStrategyFactory;
import com.dotcms.content.elasticsearch.constants.ESMappingConstants;
-import com.dotcms.content.elasticsearch.util.ESUtils;
import com.dotcms.content.elasticsearch.util.PaginationUtil;
-import com.dotcms.contenttype.business.BaseTypeToContentTypeStrategy;
-import com.dotcms.contenttype.business.BaseTypeToContentTypeStrategyResolver;
-import com.dotcms.contenttype.business.ContentTypeAPI;
+import com.dotcms.contenttype.business.*;
+import com.dotcms.contenttype.business.uniquefields.UniqueFieldValidationStrategyResolver;
import com.dotcms.contenttype.exception.NotFoundInDbException;
import com.dotcms.contenttype.model.field.BinaryField;
import com.dotcms.contenttype.model.field.CategoryField;
@@ -236,6 +236,8 @@
*/
public class ESContentletAPIImpl implements ContentletAPI {
+ private static Lazy FEATURE_FLAG_DB_UNIQUE_FIELD_VALIDATION = Lazy.of(() ->
+ Config.getBooleanProperty("FEATURE_FLAG_DB_UNIQUE_FIELD_VALIDATION", false));
private static final String CAN_T_CHANGE_STATE_OF_CHECKED_OUT_CONTENT = "Can't change state of checked out content or where inode is not set. Use Search or Find then use method";
private static final String CANT_GET_LOCK_ON_CONTENT = "Only the CMS Admin or the user who locked the contentlet can lock/unlock it";
private static final String FAILED_TO_DELETE_UNARCHIVED_CONTENT = "Failed to delete unarchived content. Content must be archived first before it can be deleted.";
@@ -277,6 +279,9 @@ public class ESContentletAPIImpl implements ContentletAPI {
private final BaseTypeToContentTypeStrategyResolver baseTypeToContentTypeStrategyResolver =
BaseTypeToContentTypeStrategyResolver.getInstance();
+
+ private final Lazy uniqueFieldValidationStrategyResolver;
+
public enum QueryType {
search, suggest, moreLike, Facets
}
@@ -286,11 +291,21 @@ public enum QueryType {
private static final Supplier ND_SUPPLIER = () -> "N/D";
private final ElasticReadOnlyCommand elasticReadOnlyCommand;
+ public static boolean getFeatureFlagDbUniqueFieldValidation() {
+ return FEATURE_FLAG_DB_UNIQUE_FIELD_VALIDATION.get();
+ }
+
+ @VisibleForTesting
+ public static void setFeatureFlagDbUniqueFieldValidation(final boolean newValue) {
+ FEATURE_FLAG_DB_UNIQUE_FIELD_VALIDATION = Lazy.of(() -> newValue);
+ }
+
/**
* Default class constructor.
*/
@VisibleForTesting
public ESContentletAPIImpl(final ElasticReadOnlyCommand readOnlyCommand) {
+ this.uniqueFieldValidationStrategyResolver = Lazy.of( () -> getUniqueFieldValidationStrategyResolver());
indexAPI = new ContentletIndexAPIImpl();
contentFactory = new ESContentFactoryImpl();
permissionAPI = APILocator.getPermissionAPI();
@@ -307,11 +322,20 @@ public ESContentletAPIImpl(final ElasticReadOnlyCommand readOnlyCommand) {
fileMetadataAPI = APILocator.getFileMetadataAPI();
}
+ private static UniqueFieldValidationStrategyResolver getUniqueFieldValidationStrategyResolver() {
+ final Optional uniqueFieldValidationStrategyResolver =
+ CDIUtils.getBean(UniqueFieldValidationStrategyResolver.class);
+
+ if (!uniqueFieldValidationStrategyResolver.isPresent()) {
+ throw new DotRuntimeException("Could not instance UniqueFieldValidationStrategyResolver");
+ }
+ return uniqueFieldValidationStrategyResolver.get();
+ }
+
public ESContentletAPIImpl() {
this(ElasticReadOnlyCommand.getInstance());
}
-
@Override
public SearchResponse esSearchRaw(String esQuery, boolean live, User user,
boolean respectFrontendRoles) throws DotSecurityException, DotDataException {
@@ -5516,6 +5540,11 @@ private Contentlet internalCheckin(Contentlet contentlet,
contentlet = contentFactory.save(contentlet);
}
+ if (hasUniqueField(contentType)) {
+ uniqueFieldValidationStrategyResolver.get().get().afterSaved(contentlet, isNewContent);
+ }
+
+
contentlet.setIndexPolicy(indexPolicy);
contentlet.setIndexPolicyDependencies(indexPolicyDependencies);
@@ -5645,6 +5674,10 @@ private Contentlet internalCheckin(Contentlet contentlet,
return contentlet;
}
+ private static boolean hasUniqueField(ContentType contentType) {
+ return contentType.fields().stream().anyMatch(field -> field.unique());
+ }
+
private boolean shouldRemoveOldHostCache(Contentlet contentlet, String oldHostId) {
return contentlet.getBoolProperty(Contentlet.TO_BE_PUBLISH) &&
contentlet.isVanityUrl() &&
@@ -7632,98 +7665,16 @@ public void validateContentlet(final Contentlet contentlet, final List
// validate unique
if (field.isUnique()) {
- final boolean isDataTypeNumber =
- field.getDataType().contains(DataTypes.INTEGER.toString())
- || field.getDataType().contains(DataTypes.FLOAT.toString());
- try {
- final StringBuilder buffy = new StringBuilder(UUIDGenerator.generateUuid());
- buffy.append(" +structureInode:" + contentlet.getContentTypeId());
- if (UtilMethods.isSet(contentlet.getIdentifier())) {
- buffy.append(" -(identifier:" + contentlet.getIdentifier() + ")");
- }
- buffy.append(" +languageId:" + contentlet.getLanguageId());
-
- if (getUniquePerSiteConfig(field)) {
- if (!UtilMethods.isSet(contentlet.getHost())) {
- populateHost(contentlet);
- }
-
- buffy.append(" +conHost:" + contentlet.getHost());
- }
-
- buffy.append(" +").append(contentlet.getContentType().variable())
- .append(StringPool.PERIOD)
- .append(field.getVelocityVarName()).append(ESUtils.SHA_256)
- .append(StringPool.COLON)
- .append(ESUtils.sha256(contentlet.getContentType().variable()
- + StringPool.PERIOD + field.getVelocityVarName(), fieldValue,
- contentlet.getLanguageId()));
-
- final List contentlets = new ArrayList<>();
- try {
- contentlets.addAll(
- searchIndex(buffy.toString() + " +working:true", -1, 0, "inode",
- APILocator.getUserAPI().getSystemUser(), false));
- contentlets.addAll(
- searchIndex(buffy.toString() + " +live:true", -1, 0, "inode",
- APILocator.getUserAPI().getSystemUser(), false));
- } catch (final Exception e) {
- final String errorMsg =
- "Unique field [" + field.getVelocityVarName() + "] with value '" +
- fieldValue + "' could not be validated: " + e.getMessage();
- Logger.warn(this, errorMsg, e);
- throw new DotContentletValidationException(errorMsg, e);
- }
- int size = contentlets.size();
- if (size > 0 && !hasError) {
- boolean unique = true;
- for (final ContentletSearch contentletSearch : contentlets) {
- final Contentlet uniqueContent = contentFactory.find(
- contentletSearch.getInode());
- if (null == uniqueContent) {
- final String errorMsg = String.format(
- "Unique field [%s] could not be validated, as " +
- "unique content Inode '%s' was not found. ES Index might need to be reindexed.",
- field.getVelocityVarName(), contentletSearch.getInode());
- Logger.warn(this, errorMsg);
- throw new DotContentletValidationException(errorMsg);
- }
- final Map uniqueContentMap = uniqueContent.getMap();
- final Object obj = uniqueContentMap.get(field.getVelocityVarName());
- if ((isDataTypeNumber && Objects.equals(fieldValue, obj)) ||
- (!isDataTypeNumber && ((String) obj).equalsIgnoreCase(
- ((String) fieldValue)))) {
- unique = false;
- break;
- }
-
- }
- if (!unique) {
- if (UtilMethods.isSet(contentlet.getIdentifier())) {
- Iterator contentletsIter = contentlets.iterator();
- while (contentletsIter.hasNext()) {
- ContentletSearch cont = contentletsIter.next();
- if (!contentlet.getIdentifier()
- .equalsIgnoreCase(cont.getIdentifier())) {
- cve.addUniqueField(field);
- hasError = true;
- Logger.warn(this,
- getUniqueFieldErrorMessage(field, fieldValue,
- cont));
- break;
- }
- }
- } else {
- cve.addUniqueField(field);
- hasError = true;
- Logger.warn(this, getUniqueFieldErrorMessage(field, fieldValue,
- contentlets.get(0)));
- break;
- }
- }
- }
- } catch (final DotDataException | DotSecurityException e) {
+ try {
+ uniqueFieldValidationStrategyResolver.get().get().validate(contentlet,
+ LegacyFieldTransformer.from(field));
+ } catch (UniqueFieldValueDuplicatedException e) {
+ cve.addUniqueField(field);
+ hasError = true;
+ Logger.warn(this, getUniqueFieldErrorMessage(field, fieldValue,
+ UtilMethods.isSet(e.getContentlets()) ? e.getContentlets().get(0) : "Unknown"));
+ } catch (DotDataException | DotSecurityException e) {
Logger.warn(this, "Unable to get contentlets for Content Type: "
+ contentlet.getContentType().name(), e);
}
@@ -7799,11 +7750,11 @@ private boolean isIgnorableField(final com.dotcms.contenttype.model.field.Field
}
private String getUniqueFieldErrorMessage(final Field field, final Object fieldValue,
- final ContentletSearch contentletSearch) {
+ final String contentletID) {
return String.format(
"Value of Field [%s] must be unique. Contents having the same value (%s): %s",
- field.getVelocityVarName(), fieldValue, contentletSearch.getIdentifier());
+ field.getVelocityVarName(), fieldValue, contentletID);
}
private boolean getUniquePerSiteConfig(final Field field) {
diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/UniqueFieldValueDuplicatedException.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/UniqueFieldValueDuplicatedException.java
new file mode 100644
index 000000000000..b3a1e2f70046
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/UniqueFieldValueDuplicatedException.java
@@ -0,0 +1,24 @@
+package com.dotcms.contenttype.business;
+
+import java.util.List;
+
+/**
+ * Throw if try to insert a duplicated register in unique_fiedls table
+ */
+public class UniqueFieldValueDuplicatedException extends Exception{
+
+ private List contentletsIDS;
+
+ public UniqueFieldValueDuplicatedException(String message) {
+ super(message);
+ }
+
+ public UniqueFieldValueDuplicatedException(String message, List contentletsIDS) {
+ super(message);
+ this.contentletsIDS = contentletsIDS;
+ }
+
+ public List getContentlets() {
+ return contentletsIDS;
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/ESUniqueFieldValidationStrategy.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/ESUniqueFieldValidationStrategy.java
new file mode 100644
index 000000000000..bf2f32261b8f
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/ESUniqueFieldValidationStrategy.java
@@ -0,0 +1,180 @@
+package com.dotcms.contenttype.business.uniquefields;
+
+import com.dotcms.content.elasticsearch.util.ESUtils;
+import com.dotcms.contenttype.business.UniqueFieldValueDuplicatedException;
+import com.dotmarketing.portlets.contentlet.model.Contentlet;
+import com.dotcms.contenttype.model.field.DataTypes;
+import com.dotcms.contenttype.model.field.Field;
+import com.dotcms.contenttype.model.type.ContentType;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.common.model.ContentletSearch;
+import com.dotmarketing.exception.DotDataException;
+import com.dotmarketing.exception.DotSecurityException;
+import com.dotmarketing.portlets.contentlet.business.DotContentletValidationException;
+import com.dotmarketing.util.Logger;
+import com.dotmarketing.util.UUIDGenerator;
+import com.dotmarketing.util.UtilMethods;
+import com.liferay.util.StringPool;
+
+import javax.enterprise.context.ApplicationScoped;
+import javax.enterprise.inject.Default;
+import javax.inject.Inject;
+import java.util.*;
+import java.util.stream.Collectors;
+
+import static com.dotcms.content.elasticsearch.business.ESContentletAPIImpl.UNIQUE_PER_SITE_FIELD_VARIABLE_NAME;
+
+/**
+ * {@link UniqueFieldValidationStrategy} that check the unique values using ElasticSearch queries.
+ *
+ * The query that is run looks like the follow:
+ *
+ * +structureInode:[typeInode] -identifier: [contentletId] +languageId:[contentletLang] +conHost:[hostId] [typeVariable].[uniqueFieldVariable]_sha256 = sha256(fieldValue)
+ *
+ * Where:
+ * - typeInode: Inode of the Content Type
+ * - contentletId: {@link Contentlet}'s Identifier, this filter is just add if the {@link Contentlet} is not new,
+ * if it is a new {@link Contentlet} then this filter is removed because the {@link Contentlet} does not have any Id after be saved.
+ * - contentletLang: {@link Contentlet}'s language
+ * - [typeVariable].[uniqueFieldVariable]_sha256 = sha256(fieldValue): For each unique field an extra attribute is saved
+ * in ElasticSearch it is named concatenating _sha256 to the name of the unique field, so here the filter is looking for the value.
+ *
+ * If this query return any register then it means that the value was already taken so a UniqueFieldValidationStrategy is thrown.
+ *
+ * This approach has a race condition bug because if another {@link Contentlet} is saved before that the change is mirror
+ * in ES then the duplicate value is going to be allowed, remember that the {@link Contentlet} sare storage in ES in an async way.
+ */
+@ApplicationScoped
+@Default
+public class ESUniqueFieldValidationStrategy implements UniqueFieldValidationStrategy {
+
+ /**
+ * ES implementation for {@link UniqueFieldValidationStrategy#innerValidate(Contentlet, Field, Object, ContentType)}
+ *
+ * @param contentlet
+ * @param uniqueField
+ * @param fieldValue
+ * @param contentType
+ * @throws UniqueFieldValueDuplicatedException
+ * @throws DotDataException
+ * @throws DotSecurityException
+ */
+ @Override
+ public void innerValidate(final Contentlet contentlet, final Field uniqueField, final Object fieldValue,
+ final ContentType contentType)
+ throws UniqueFieldValueDuplicatedException, DotDataException, DotSecurityException {
+
+ final boolean isDataTypeNumber =
+ uniqueField.dataType().equals(DataTypes.INTEGER)
+ || uniqueField.dataType().equals(DataTypes.FLOAT);
+
+ final List contentlets = getContentletFromES(contentlet, uniqueField, fieldValue);
+ int size = contentlets.size();
+
+ if (size > 0) {
+ boolean unique = true;
+
+ for (final ContentletSearch contentletSearch : contentlets) {
+ final com.dotmarketing.portlets.contentlet.model.Contentlet uniqueContent = APILocator.getContentletAPI()
+ .find(contentletSearch.getInode(), APILocator.systemUser(), false);
+
+ if (null == uniqueContent) {
+ final String errorMsg = String.format(
+ "Unique field [%s] could not be validated, as " +
+ "unique content Inode '%s' was not found. ES Index might need to be reindexed.",
+ uniqueField.variable(), contentletSearch.getInode());
+ Logger.warn(this, errorMsg);
+ throw new DotContentletValidationException(errorMsg);
+ }
+
+ final Map uniqueContentMap = uniqueContent.getMap();
+ final Object obj = uniqueContentMap.get(uniqueField.variable());
+
+ if ((isDataTypeNumber && Objects.equals(fieldValue, obj)) ||
+ (!isDataTypeNumber && ((String) obj).equalsIgnoreCase(
+ ((String) fieldValue)))) {
+ unique = false;
+ break;
+ }
+
+ }
+
+ if (!unique) {
+ if (UtilMethods.isSet(contentlet.getIdentifier())) {
+ Iterator contentletsIter = contentlets.iterator();
+ while (contentletsIter.hasNext()) {
+ ContentletSearch cont = contentletsIter.next();
+ if (!contentlet.getIdentifier()
+ .equalsIgnoreCase(cont.getIdentifier())) {
+
+ final String duplicatedValueMessage = String.format("The value %s for the field %s in the Content type %s is duplicated",
+ fieldValue, uniqueField.variable(), contentType.variable());
+
+ throw new UniqueFieldValueDuplicatedException(duplicatedValueMessage,
+ contentlets.stream().map(ContentletSearch::getIdentifier).collect(Collectors.toList()));
+ }
+ }
+ } else {
+ final String duplicatedValueMessage = String.format("The value %s for the field %s in the Content type %s is duplicated",
+ fieldValue, uniqueField.variable(), contentType.variable());
+
+ throw new UniqueFieldValueDuplicatedException(duplicatedValueMessage,
+ contentlets.stream().map(ContentletSearch::getIdentifier).collect(Collectors.toList()));
+ }
+ }
+ }
+ }
+
+ /**
+ * Build and execute the Lucene Query to check unique fields validation in ES.
+ *
+ * @param contentlet
+ * @param uniqueField
+ * @param fieldValue
+ * @return
+ */
+ private List getContentletFromES(Contentlet contentlet, Field uniqueField, Object fieldValue) {
+ final StringBuilder buffy = new StringBuilder(UUIDGenerator.generateUuid());
+ buffy.append(" +structureInode:" + contentlet.getContentTypeId());
+ if (UtilMethods.isSet(contentlet.getIdentifier())) {
+ buffy.append(" -(identifier:" + contentlet.getIdentifier() + ")");
+ }
+
+ buffy.append(" +languageId:" + contentlet.getLanguageId());
+
+ if (getUniquePerSiteConfig(uniqueField)) {
+
+ buffy.append(" +conHost:" + contentlet.getHost());
+ }
+
+ buffy.append(" +").append(contentlet.getContentType().variable())
+ .append(StringPool.PERIOD)
+ .append(uniqueField.variable()).append(ESUtils.SHA_256)
+ .append(StringPool.COLON)
+ .append(ESUtils.sha256(contentlet.getContentType().variable()
+ + StringPool.PERIOD + uniqueField.variable(), fieldValue,
+ contentlet.getLanguageId()));
+
+ final List contentlets = new ArrayList<>();
+ try {
+ contentlets.addAll(
+ APILocator.getContentletAPI().searchIndex(buffy.toString() + " +working:true", -1, 0, "inode",
+ APILocator.getUserAPI().getSystemUser(), false));
+ contentlets.addAll(
+ APILocator.getContentletAPI().searchIndex(buffy.toString() + " +live:true", -1, 0, "inode",
+ APILocator.getUserAPI().getSystemUser(), false));
+ } catch (final Exception e) {
+ final String errorMsg =
+ "Unique field [" + uniqueField.variable() + "] with value '" +
+ fieldValue + "' could not be validated: " + e.getMessage();
+ Logger.warn(this, errorMsg, e);
+ throw new DotContentletValidationException(errorMsg, e);
+ }
+ return contentlets;
+ }
+
+ private boolean getUniquePerSiteConfig(final com.dotcms.contenttype.model.field.Field field) {
+ return field.fieldVariableValue(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME)
+ .map(value -> Boolean.valueOf(value)).orElse(false);
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldValidationStrategy.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldValidationStrategy.java
new file mode 100644
index 000000000000..5438ee97c576
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldValidationStrategy.java
@@ -0,0 +1,94 @@
+package com.dotcms.contenttype.business.uniquefields;
+
+import com.dotcms.contenttype.business.UniqueFieldValueDuplicatedException;
+import com.dotmarketing.portlets.contentlet.model.Contentlet;
+import com.dotcms.contenttype.model.field.Field;
+import com.dotcms.contenttype.model.type.ContentType;
+import com.dotcms.util.DotPreconditions;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.exception.DotDataException;
+import com.dotmarketing.exception.DotSecurityException;
+
+import java.util.Objects;
+
+/**
+ * Represent a Strategy to check if a value may violate unique field constraints.
+ */
+public interface UniqueFieldValidationStrategy {
+
+ /**
+ * This method checks if a contentlet can be saved without violating unique field constraints.
+ * If a constraint is violated, a {@link UniqueFieldValueDuplicatedException} will be thrown.
+ * For content types with multiple unique fields, this method must be called for each unique field individually.
+ *
+ * This method performs the following checks:
+ *
+ * - Ensures the {@link Contentlet}, the {@link Field}, and the value of the {@link Field} in the {@link Contentlet}
+ * are not null.
+ * - Verifies that the {@link Field} is indeed a unique field. If not, an {@link IllegalArgumentException} is thrown.
+ * - Ensures the {@link Field} is part of the {@link Contentlet}'s {@link ContentType}.
+ * If not, an {@link IllegalArgumentException} is thrown.
+ * - Calls the {@link UniqueFieldValidationStrategy#innerValidate(Contentlet, Field, Object, ContentType)} method,
+ * which must be overridden by subclasses. This method is responsible for the actual unique value validation.
+ *
+ * @param contentlet that is going to be saved
+ * @param uniqueField Unique field to check
+ * @throws UniqueFieldValueDuplicatedException If the unique field contraints is violate
+ * @throws DotDataException If it is thrown in the process
+ * @throws DotSecurityException If it is thrown in the process
+ */
+ default void validate(final Contentlet contentlet, final Field uniqueField)
+ throws UniqueFieldValueDuplicatedException, DotDataException, DotSecurityException {
+
+ if (!uniqueField.unique()) {
+ throw new IllegalArgumentException("The Field " + uniqueField.variable() + " is not unique");
+ }
+
+ Object value = contentlet.get(uniqueField.variable());
+
+ Objects.requireNonNull(contentlet);
+ Objects.requireNonNull(uniqueField);
+ Objects.requireNonNull(value);
+
+ final ContentType contentType = APILocator.getContentTypeAPI(APILocator.systemUser())
+ .find(contentlet.getContentTypeId());
+
+ DotPreconditions.isTrue(contentType.fields().stream()
+ .anyMatch(contentTypeField -> uniqueField.variable().equals(contentTypeField.variable())),
+ "Field %s must be one of the field of the ContentType");
+
+ innerValidate(contentlet, uniqueField, value, contentType);
+ }
+
+ /**
+ * Inner validation this method must be Override for each {@link UniqueFieldValidationStrategy} to implements
+ * the real validation approach for the specific strategy.
+ *
+ * This method must be called just by the {@link UniqueFieldValidationStrategy#validate(Contentlet, Field)} method.
+ *
+ * @param contentlet {@link Contentlet} to be saved
+ * @param field Field to be validated
+ * @param fieldValue Value to be set
+ * @param contentType {@link Contentlet}'s {@link ContentType}
+ *
+ * @throws UniqueFieldValueDuplicatedException If the unique field contraints is violate
+ * @throws DotDataException If it is thrown in the process
+ * @throws DotSecurityException If it is thrown in the process
+ */
+ void innerValidate(final Contentlet contentlet, final Field field, final Object fieldValue,
+ ContentType contentType)
+ throws UniqueFieldValueDuplicatedException, DotDataException, DotSecurityException;
+
+ /**
+ * This method is called after a {@link Contentlet} is saved. It allows the Strategy to perform any necessary
+ * actions to ensure it functions correctly the next time it's used. If the {@link Contentlet} is new when the validate
+ * method is called, its ID might not be set yet, so it may be necessary to assign the ID to save information
+ * for future use by the Strategy.
+ *
+// * @param contentlet {@link Contentlet} saved
+ * @param isNew if it is true then the {@link Contentlet} is new, otherwise the {@link Contentlet} was updated
+ */
+ default void afterSaved(final Contentlet contentlet, final boolean isNew) throws DotDataException, DotSecurityException, UniqueFieldValueDuplicatedException {
+
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldValidationStrategyResolver.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldValidationStrategyResolver.java
new file mode 100644
index 000000000000..2db8a7bc7e55
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/UniqueFieldValidationStrategyResolver.java
@@ -0,0 +1,42 @@
+package com.dotcms.contenttype.business.uniquefields;
+
+import com.dotcms.cdi.CDIUtils;
+import com.dotcms.content.elasticsearch.business.ESContentletAPIImpl;
+import com.dotcms.contenttype.business.uniquefields.extratable.DBUniqueFieldValidationStrategy;
+import com.dotmarketing.exception.DotRuntimeException;
+import com.google.common.annotations.VisibleForTesting;
+
+import javax.enterprise.context.ApplicationScoped;
+import javax.inject.Inject;
+import java.util.Optional;
+
+/**
+ * Utility class responsible for returning the appropriate {@link UniqueFieldValidationStrategy}
+ * based on the configuration setting of ENABLED_UNIQUE_FIELDS_DATABASE_VALIDATION. If this setting is true,
+ * an {@link DBUniqueFieldValidationStrategy} is returned; otherwise,
+ * an {@link ESUniqueFieldValidationStrategy} is used.
+ *
+ */
+@ApplicationScoped
+public class UniqueFieldValidationStrategyResolver {
+
+ @Inject
+ private ESUniqueFieldValidationStrategy esUniqueFieldValidationStrategy;
+ @Inject
+ private DBUniqueFieldValidationStrategy dbUniqueFieldValidationStrategy;
+
+ public UniqueFieldValidationStrategyResolver(){}
+
+ @VisibleForTesting
+ public UniqueFieldValidationStrategyResolver(final ESUniqueFieldValidationStrategy esUniqueFieldValidationStrategy,
+ final DBUniqueFieldValidationStrategy dbUniqueFieldValidationStrategy){
+ this.esUniqueFieldValidationStrategy = esUniqueFieldValidationStrategy;
+ this.dbUniqueFieldValidationStrategy = dbUniqueFieldValidationStrategy;
+
+ }
+
+ public UniqueFieldValidationStrategy get() {
+ return ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation() ?
+ dbUniqueFieldValidationStrategy : esUniqueFieldValidationStrategy;
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/DBUniqueFieldValidationStrategy.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/DBUniqueFieldValidationStrategy.java
new file mode 100644
index 000000000000..c8128859e5e0
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/DBUniqueFieldValidationStrategy.java
@@ -0,0 +1,191 @@
+package com.dotcms.contenttype.business.uniquefields.extratable;
+
+import com.dotcms.business.CloseDBIfOpened;
+import com.dotcms.business.WrapInTransaction;
+import com.dotcms.content.elasticsearch.business.ESContentletAPIImpl;
+import com.dotcms.contenttype.business.*;
+import com.dotcms.contenttype.business.uniquefields.UniqueFieldValidationStrategy;
+import com.dotcms.contenttype.model.type.ContentType;
+import com.dotcms.util.CollectionsUtils;
+import com.dotcms.util.JsonUtil;
+import com.dotmarketing.beans.Host;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.exception.DotDataException;
+import com.dotmarketing.exception.DotSecurityException;
+import com.dotmarketing.portlets.contentlet.model.Contentlet;
+import com.dotmarketing.portlets.languagesmanager.model.Language;
+import com.dotcms.contenttype.model.field.Field;
+import com.dotmarketing.util.Logger;
+import com.dotmarketing.util.UtilMethods;
+import com.google.common.annotations.VisibleForTesting;
+import com.liferay.portal.model.User;
+import org.postgresql.util.PGobject;
+
+import javax.enterprise.context.ApplicationScoped;
+import javax.enterprise.inject.Default;
+import javax.inject.Inject;
+
+import static com.dotcms.util.CollectionsUtils.list;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+
+/**
+ * {@link UniqueFieldValidationStrategy} that check the unique values using a SQL Query and a Extra table.
+ * This is the same extra table created here {@link com.dotmarketing.startup.runonce.Task241007CreateUniqueFieldsTable}
+ */
+@ApplicationScoped
+public class DBUniqueFieldValidationStrategy implements UniqueFieldValidationStrategy {
+
+ @Inject
+ private UniqueFieldDataBaseUtil uniqueFieldDataBaseUtil;
+
+ public DBUniqueFieldValidationStrategy(){
+ }
+
+ @VisibleForTesting
+ public DBUniqueFieldValidationStrategy(final UniqueFieldDataBaseUtil uniqueFieldDataBaseUtil){
+ this.uniqueFieldDataBaseUtil = uniqueFieldDataBaseUtil;
+ }
+
+ /**
+ *
+ * @param contentlet
+ * @param field
+ * @param fieldValue
+ * @param contentType
+ *
+ * @throws UniqueFieldValueDuplicatedException
+ * @throws DotDataException
+ * @throws DotSecurityException
+ */
+ @Override
+ @WrapInTransaction
+ @CloseDBIfOpened
+ public void innerValidate(final Contentlet contentlet, final Field field, final Object fieldValue,
+ final ContentType contentType) throws UniqueFieldValueDuplicatedException, DotDataException, DotSecurityException {
+
+ if (UtilMethods.isSet(contentlet.getIdentifier())) {
+ cleanUniqueFieldsUp(contentlet, field);
+ }
+
+ final User systemUser = APILocator.systemUser();
+ final Host host = APILocator.getHostAPI().find(contentlet.getHost(), systemUser, false);
+ final Language language = APILocator.getLanguageAPI().getLanguage(contentlet.getLanguageId());
+
+ UniqueFieldCriteria uniqueFieldCriteria = new UniqueFieldCriteria.Builder()
+ .setSite(host)
+ .setLanguage(language)
+ .setField(field)
+ .setContentType(contentType)
+ .setValue(fieldValue)
+ .setVariantName(contentlet.getVariantId())
+ .build();
+
+ checkUnique(uniqueFieldCriteria, contentlet.getIdentifier());
+ }
+
+ private void cleanUniqueFieldsUp(final Contentlet contentlet, final Field field) throws DotDataException {
+ Optional