diff --git a/.github/workflows/cicd_manual_publish-starter.yml b/.github/workflows/cicd_manual_publish-starter.yml index d98a717c02a4..d6292a25e276 100644 --- a/.github/workflows/cicd_manual_publish-starter.yml +++ b/.github/workflows/cicd_manual_publish-starter.yml @@ -28,13 +28,13 @@ env: EMPTY_STARTER_TOKEN: ${{ secrets.DOT_EMPTY_STARTER_ACCESS_TOKEN }} FULL_STARTER_URL: ${{ vars.DOT_STARTER_URL }} FULL_STARTER_TOKEN: ${{ secrets.DOT_STARTER_ACCESS_TOKEN }} - DOWNLOAD_ENDPOINT: api/v1/maintenance/_downloadStarterWithAssets?oldAssets=true + DOWNLOAD_ENDPOINT: api/v1/maintenance/_downloadStarterWithAssets?oldAssets=false jobs: get-starter: runs-on: macos-13 if: github.repository == 'dotcms/core' - environment: trunk + environment: starter steps: - name: 'Github context' run: | diff --git a/README.md b/README.md index 12f698e7f2d9..4e4597a0cd97 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - + [![Merge Queue](https://github.com/dotCMS/core/actions/workflows/cicd_2-merge-queue.yml/badge.svg)](https://github.com/dotCMS/core/actions/workflows/cicd_2-merge-queue.yml) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index dff2a3a7481c..16b6750564ac 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -1154,6 +1154,19 @@ 3.1.9.Final + + org.glassfish.jersey.ext.cdi + jersey-cdi1x + ${jersey.version} + + + + org.glassfish.jersey.ext.cdi + jersey-cdi1x-servlet + ${jersey.version} + + + org.glassfish.hk2.external bean-validator diff --git a/core-web/libs/dotcms-models/src/lib/dot-contentlet.model.ts b/core-web/libs/dotcms-models/src/lib/dot-contentlet.model.ts index bbaaabda9188..77939313c4f8 100644 --- a/core-web/libs/dotcms-models/src/lib/dot-contentlet.model.ts +++ b/core-web/libs/dotcms-models/src/lib/dot-contentlet.model.ts @@ -1,3 +1,5 @@ +// Beware while using this type, since we have a [key: string]: any; it can be used to store any kind of data and you can write wrong properties and it will not fail +// Maybe we need to refactor this to a generic type that extends from unknown when missing the generic type export interface DotCMSContentlet { archived: boolean; baseType: string; @@ -36,6 +38,7 @@ export interface DotCMSContentlet { contentTypeIcon?: string; variant?: string; __icon__?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any; } diff --git a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_splitter.scss b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_splitter.scss new file mode 100644 index 000000000000..c4566182e1de --- /dev/null +++ b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/_splitter.scss @@ -0,0 +1,22 @@ +@use "variables" as *; + +.p-splitter { + border: 1px solid $color-palette-gray-400; + background: $white; + border-radius: $border-radius-sm; + + .p-splitter-gutter, + .p-splitter-gutter-resizing { + background: $color-palette-gray-100; + + .p-splitter-gutter-handle { + background: $color-palette-gray-300; + + &:focus-visible { + outline: none; + outline-offset: 0; + box-shadow: $shadow-xs; + } + } + } +} diff --git a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/form/_iconfield.scss b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/form/_iconfield.scss new file mode 100644 index 000000000000..6dfc8f2fa600 --- /dev/null +++ b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/form/_iconfield.scss @@ -0,0 +1,35 @@ +@use "variables" as *; + +.p-icon-field { + .p-input-icon { + position: absolute; + top: 50%; + transform: translateY(-50%); + } + + &-left { + .p-input-icon:first-of-type { + left: $spacing-2; + color: $color-palette-gray-600; + } + + > .p-inputtext { + padding-left: $spacing-6; + } + + &.p-float-label > label { + left: $spacing-6; + } + } + + &-right { + .p-input-icon:last-of-type { + right: $spacing-2; + color: $color-palette-gray-600; + } + + > .p-inputtext { + padding-right: $spacing-6; + } + } +} diff --git a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/form/index.scss b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/form/index.scss index 5a11c254b228..3e266321dcd6 100644 --- a/core-web/libs/dotcms-scss/angular/dotcms-theme/components/form/index.scss +++ b/core-web/libs/dotcms-scss/angular/dotcms-theme/components/form/index.scss @@ -12,3 +12,4 @@ @use "selectbutton"; @use "slider"; @use "treeselect"; +@use "iconfield"; diff --git a/core-web/libs/dotcms-scss/angular/dotcms-theme/theme.scss b/core-web/libs/dotcms-scss/angular/dotcms-theme/theme.scss index 2e38f07a86a9..bac1c2e44541 100644 --- a/core-web/libs/dotcms-scss/angular/dotcms-theme/theme.scss +++ b/core-web/libs/dotcms-scss/angular/dotcms-theme/theme.scss @@ -36,6 +36,7 @@ @use "components/tooltip"; @use "components/tree"; @use "components/table"; +@use "components/splitter"; @use "utils/validation"; @use "utils/password"; diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts index 4dbc05c58831..9b8ab39beb1d 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts @@ -5,7 +5,7 @@ import { MockComponent } from 'ng-mocks'; import { of } from 'rxjs'; import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { Provider, Type } from '@angular/core'; +import { Provider, signal, Type } from '@angular/core'; import { ControlContainer, FormGroupDirective } from '@angular/forms'; import { By } from '@angular/platform-browser'; @@ -21,6 +21,7 @@ import { DotKeyValueComponent } from '@dotcms/ui'; import { DotEditContentFieldComponent } from './dot-edit-content-field.component'; +import { DotEditContentStore } from '../../feature/edit-content/store/edit-content.store'; import { DotEditContentBinaryFieldComponent } from '../../fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component'; import { DotEditContentCalendarFieldComponent } from '../../fields/dot-edit-content-calendar-field/dot-edit-content-calendar-field.component'; import { DotEditContentCategoryFieldComponent } from '../../fields/dot-edit-content-category-field/dot-edit-content-category-field.component'; @@ -168,6 +169,12 @@ const FIELD_TYPES_COMPONENTS: Record | DotEditFieldTe { provide: DotWorkflowActionsFireService, useValue: {} + }, + { + provide: DotEditContentStore, + useValue: { + showSidebar: signal(false) + } } ], declarations: [MockComponent(EditorComponent)] @@ -239,11 +246,11 @@ describe.each([...FIELDS_TO_BE_RENDER])('DotEditContentFieldComponent all fields it('should render the correct field type', () => { spectator.detectChanges(); - const field = spectator.debugElement.query( - By.css(`[data-testId="field-${fieldMock.variable}"]`) - ); const FIELD_TYPE = fieldTestBed.component ? fieldTestBed.component : fieldTestBed; - expect(field.componentInstance instanceof FIELD_TYPE).toBeTruthy(); + const component = spectator.query(FIELD_TYPE); + + expect(component).toBeTruthy(); + expect(component instanceof FIELD_TYPE).toBeTruthy(); }); if (fieldTestBed.outsideFormControl) { diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.html new file mode 100644 index 000000000000..aff434f107ef --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.html @@ -0,0 +1,60 @@ +
+ @if (store.allowFileNameEdit()) { +
+ + +
+ @let error = store.error(); + @if (error) { + + {{ error | dm: [store.allowFiles()] }} + + } @else { + + } +
+
+ } +
+ + + @let file = store.file(); +
+ + Mime Type: {{ file.mimeType }} +
+
+
+ + + +
+
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.scss new file mode 100644 index 000000000000..ea37a78b38f9 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.scss @@ -0,0 +1,93 @@ +@use "variables" as *; + +:host ::ng-deep { + .editor-container { + position: absolute !important; + top: 0; + left: 0; + right: 0; + bottom: 0; + } +} + +.file-field__editor-container { + display: flex; + justify-content: center; + align-items: flex-start; + flex-direction: column; + flex: 1; + width: 100%; + gap: $spacing-1; +} + +.file-field__code-editor { + border: 1px solid $color-palette-gray-400; // Input + display: block; + flex-grow: 1; + width: 100%; + min-height: 30rem; + border-radius: $border-radius-md; + overflow: auto; + position: relative; +} + +.file-field__code-editor--disabled { + background-color: $color-palette-gray-200; + opacity: 0.5; + + &::ng-deep { + .monaco-mouse-cursor-text, + .overflow-guard { + cursor: not-allowed; + } + } +} + +.editor-mode__form { + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; +} + +.editor-mode__input-container { + width: 100%; + display: flex; + gap: $spacing-1; + flex-direction: column; +} + +.editor-mode__input { + width: 100%; + display: flex; + flex-direction: column; +} + +.editor-mode__actions { + width: 100%; + display: flex; + gap: $spacing-1; + align-items: center; + justify-content: flex-end; +} + +.editor-mode__helper { + display: flex; + justify-content: flex-start; + align-items: center; + gap: $spacing-1; + color: $color-palette-gray-700; + font-weight: $font-size-sm; + visibility: hidden; +} + +.editor-mode__helper--visible { + visibility: visible; +} + +.error-message { + min-height: $spacing-4; // Fix height to avoid jumping + justify-content: flex-start; + display: flex; +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.ts new file mode 100644 index 000000000000..edc56360c0ed --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.component.ts @@ -0,0 +1,275 @@ +import { MonacoEditorConstructionOptions, MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; + +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + OnInit, + untracked +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog'; +import { InputTextModule } from 'primeng/inputtext'; + +import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators'; + +import { DotMessagePipe, DotFieldValidationMessageComponent } from '@dotcms/ui'; + +import { FormFileEditorStore } from './store/form-file-editor.store'; + +import { UploadedFile } from '../../models'; + +type DialogProps = { + allowFileNameEdit: boolean; + userMonacoOptions: Partial; + uploadedFile: UploadedFile | null; +}; + +@Component({ + selector: 'dot-form-file-editor', + standalone: true, + imports: [ + DotMessagePipe, + ReactiveFormsModule, + DotFieldValidationMessageComponent, + ButtonModule, + InputTextModule, + MonacoEditorModule + ], + templateUrl: './dot-form-file-editor.component.html', + styleUrls: ['./dot-form-file-editor.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [FormFileEditorStore] +}) +export class DotFormFileEditorComponent implements OnInit { + /** + * Injects the FormFileEditorStore into the component. + * + * @readonly + * @type {FormFileEditorStore} + */ + readonly store = inject(FormFileEditorStore); + /** + * A private and readonly instance of FormBuilder injected into the component. + * This instance is used to create and manage forms within the component. + */ + readonly #formBuilder = inject(FormBuilder); + /** + * A reference to the dynamic dialog instance. + * This is a read-only property that is injected using the `DynamicDialogRef` token. + */ + readonly #dialogRef = inject(DynamicDialogRef); + /** + * A read-only private property that holds the configuration for the dynamic dialog. + * This configuration is injected using the `DynamicDialogConfig` class with a generic type of `DialogProps`. + */ + readonly #dialogConfig = inject(DynamicDialogConfig); + + /** + * Form group for the file editor component. + * + * This form contains the following controls: + * - `name`: A required string field that must match the pattern of a valid file name (e.g., "filename.extension"). + * - `content`: An optional string field for the file content. + * + * @readonly + */ + readonly form = this.#formBuilder.nonNullable.group({ + name: ['', [Validators.required, Validators.pattern(/^[^.]+\.[^.]+$/)]], + content: [''] + }); + + /** + * Reference to the MonacoEditorComponent instance within the view. + * This is used to interact with the Monaco Editor component in the template. + * + * @type {MonacoEditorComponent} + */ + #editorRef: monaco.editor.IStandaloneCodeEditor | null = null; + + constructor() { + effect(() => { + const isUploading = this.store.isUploading(); + + if (isUploading) { + this.#disableEditor(); + } else { + this.#enableEditor(); + } + }); + + effect( + () => { + const isDone = this.store.isDone(); + const uploadedFile = this.store.uploadedFile(); + + untracked(() => { + if (isDone) { + this.#dialogRef.close(uploadedFile); + } + }); + }, + { + allowSignalWrites: true + } + ); + + this.nameField.valueChanges + .pipe( + debounceTime(350), + distinctUntilChanged(), + filter((value) => value.length > 0), + takeUntilDestroyed() + ) + .subscribe((value) => { + this.store.setFileName(value); + }); + } + + /** + * Initializes the component by extracting data from the dialog configuration + * and setting up the form and store with the provided values. + * + * @returns {void} + * + * @memberof DotFormFileEditorComponent + * + * @method ngOnInit + * + * @description + * This method is called once the component is initialized. It retrieves the + * dialog configuration data and initializes the form with the uploaded file + * if available. It also sets up the store with the provided Monaco editor + * options, file name edit permission, uploaded file, accepted files, and + * upload type. + */ + ngOnInit(): void { + const data = this.#dialogConfig?.data as DialogProps; + if (!data) { + return; + } + + const { uploadedFile, userMonacoOptions, allowFileNameEdit } = data; + + if (uploadedFile) { + this.#initValuesForm(uploadedFile); + } + + this.store.initLoad({ + monacoOptions: userMonacoOptions || {}, + allowFileNameEdit: allowFileNameEdit || true, + uploadedFile, + acceptedFiles: [], + uploadType: 'dotasset' + }); + } + + /** + * Handles the form submission event. + * + * This method performs the following actions: + * 1. Checks if the form is invalid. If so, marks the form as dirty and updates its validity status. + * 2. If the form is valid, retrieves the raw values from the form and triggers the file upload process via the store. + * + * @returns {void} + */ + onSubmit(): void { + if (this.form.invalid) { + this.form.markAsDirty(); + this.form.updateValueAndValidity(); + + return; + } + + const values = this.form.getRawValue(); + this.store.uploadFile(values); + } + + /** + * Getter for the 'name' field control from the form. + * + * @returns The form control associated with the 'name' field. + */ + get nameField() { + return this.form.controls.name; + } + + /** + * Getter for the 'content' form control. + * + * @returns The 'content' form control from the form group. + */ + get contentField() { + return this.form.controls.content; + } + + /** + * Disables the form and sets the editor to read-only mode. + * + * This method disables the form associated with the component and updates the editor's options + * to make it read-only. It is useful for preventing further user interaction with the form and editor. + * + * @private + */ + #disableEditor() { + if (!this.#editorRef) { + return; + } + + this.form.disable(); + this.#editorRef.updateOptions({ readOnly: true }); + } + + /** + * Enables the form and sets the editor to be editable. + * + * This method performs the following actions: + * 1. Enables the form associated with this component. + * 2. Retrieves the editor instance from the `$editorRef` method. + * 3. Updates the editor options to make it writable (readOnly: false). + */ + #enableEditor() { + if (!this.#editorRef) { + return; + } + + this.form.enable(); + this.#editorRef.updateOptions({ readOnly: false }); + } + + /** + * Initializes the form values with the provided uploaded file data. + * + * @param {UploadedFile} param0 - The uploaded file object containing source and file information. + * @param {string} param0.source - The source of the file, which can be 'temp' or another value. + * @param {File} param0.file - The file object containing file details. + * @param {string} param0.file.fileName - The name of the file if the source is 'temp'. + * @param {string} param0.file.title - The title of the file if the source is not 'temp'. + * @param {string} param0.file.content - The content of the file. + */ + #initValuesForm({ source, file }: UploadedFile): void { + this.form.patchValue({ + name: source === 'temp' ? file.fileName : file.title, + content: file.content + }); + } + + /** + * 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. + */ + cancelUpload(): void { + this.#dialogRef.close(); + } + + onEditorInit(editor: monaco.editor.IStandaloneCodeEditor) { + this.#editorRef = editor; + } +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.conts.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.conts.ts new file mode 100644 index 000000000000..db6cdf79463b --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/dot-form-file-editor.conts.ts @@ -0,0 +1,17 @@ +import { MonacoEditorConstructionOptions } from '@materia-ui/ngx-monaco-editor'; + +export const DEFAULT_MONACO_CONFIG: MonacoEditorConstructionOptions = { + theme: 'vs', + minimap: { + enabled: false + }, + cursorBlinking: 'solid', + overviewRulerBorder: false, + mouseWheelZoom: false, + lineNumbers: 'on', + roundedSelection: false, + automaticLayout: true, + fixedOverflowWidgets: true, + language: 'text', + fontSize: 14 +}; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/store/form-file-editor.store.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/store/form-file-editor.store.ts new file mode 100644 index 000000000000..22dacea3f112 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-file-editor/store/form-file-editor.store.ts @@ -0,0 +1,207 @@ +import { MonacoEditorConstructionOptions } from '@materia-ui/ngx-monaco-editor'; +import { tapResponse } from '@ngrx/operators'; +import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { pipe } from 'rxjs'; + +import { computed, inject } from '@angular/core'; + +import { switchMap, tap } from 'rxjs/operators'; + +import { ComponentStatus, DotHttpErrorResponse } from '@dotcms/dotcms-models'; + +import { UPLOAD_TYPE, UploadedFile } from '../../../models'; +import { DotFileFieldUploadService } from '../../../services/upload-file/upload-file.service'; +import { extractFileExtension, getInfoByLang } from '../../../utils/editor'; +import { DEFAULT_MONACO_CONFIG } from '../dot-form-file-editor.conts'; + +type FileInfo = { + name: string; + content: string; + mimeType: string; + extension: string; + language: string; +}; + +export interface FormFileEditorState { + file: FileInfo; + allowFileNameEdit: boolean; + status: ComponentStatus; + error: string | null; + monacoOptions: MonacoEditorConstructionOptions; + uploadedFile: UploadedFile | null; + uploadType: UPLOAD_TYPE; + acceptedFiles: string[]; +} + +const initialState: FormFileEditorState = { + file: { + name: '', + content: '', + mimeType: 'plain/text', + extension: '.txt', + language: 'text' + }, + allowFileNameEdit: false, + status: ComponentStatus.INIT, + error: null, + monacoOptions: DEFAULT_MONACO_CONFIG, + uploadedFile: null, + uploadType: 'dotasset', + acceptedFiles: [] +}; + +export const FormFileEditorStore = signalStore( + withState(initialState), + withComputed((state) => ({ + isUploading: computed(() => state.status() === ComponentStatus.LOADING), + isDone: computed(() => state.status() === ComponentStatus.LOADED && state.uploadedFile), + allowFiles: computed(() => state.acceptedFiles().join(', ')), + monacoConfig: computed(() => { + const monacoOptions = state.monacoOptions(); + const { language } = state.file(); + + return { + ...monacoOptions, + language: language + }; + }) + })), + withMethods((store) => { + const uploadService = inject(DotFileFieldUploadService); + + return { + /** + * Sets the file name and updates the file's metadata in the store. + * + * @param name - The new name of the file. + * + * This method performs the following actions: + * 1. Extracts the file extension from the provided name. + * 2. Retrieves file information based on the extracted extension. + * 3. Updates the store with the new file name and its associated metadata, including MIME type, extension, and language. + */ + setFileName(name: string) { + const file = store.file(); + + const extension = extractFileExtension(name); + const info = getInfoByLang(extension); + + patchState(store, { + file: { + ...file, + name, + mimeType: info.mimeType, + extension: info.extension, + language: info.lang + } + }); + }, + /** + * Initializes the file editor state with the provided options. + * + * @param params - The parameters for initializing the file editor. + * @param params.monacoOptions - Partial options for configuring the Monaco editor. + * @param params.allowFileNameEdit - Flag indicating if the file name can be edited. + * @param params.uploadedFile - The uploaded file information, or null if no file is uploaded. + * @param params.acceptedFiles - Array of accepted file types. + * @param params.uploadType - The type of upload being performed. + */ + initLoad({ + monacoOptions, + allowFileNameEdit, + uploadedFile, + acceptedFiles, + uploadType + }: { + monacoOptions: Partial; + allowFileNameEdit: boolean; + uploadedFile: UploadedFile | null; + acceptedFiles: string[]; + uploadType: UPLOAD_TYPE; + }) { + const prevState = store.monacoOptions(); + + const state: Partial = { + monacoOptions: { + ...prevState, + ...monacoOptions + }, + allowFileNameEdit, + acceptedFiles, + uploadType + }; + + if (uploadedFile) { + const { file, source } = uploadedFile; + + const name = source === 'contentlet' ? file.title : file.fileName; + const extension = extractFileExtension(name); + const info = getInfoByLang(extension); + + state.file = { + name, + content: file.content || '', + mimeType: info.mimeType, + extension: info.extension, + language: info.lang + }; + } + + patchState(store, state); + }, + /** + * Uploads the file content to the server. + * + */ + uploadFile: rxMethod<{ + name: string; + content: string; + }>( + pipe( + tap(() => patchState(store, { status: ComponentStatus.LOADING })), + switchMap(({ name, content }) => { + const { mimeType: type } = store.file(); + const uploadType = store.uploadType(); + const acceptedFiles = store.acceptedFiles(); + + const file = new File([content], name, { type }); + + return uploadService + .uploadFile({ + file, + uploadType, + acceptedFiles, + maxSize: null + }) + .pipe( + tapResponse({ + next: (uploadedFile) => { + patchState(store, { + uploadedFile, + status: ComponentStatus.LOADED + }); + }, + error: (error: DotHttpErrorResponse) => { + let errorMessage = error?.message || ''; + + if (error instanceof Error) { + if (errorMessage === 'Invalid file type') { + errorMessage = + 'dot.file.field.error.type.file.not.supported.message'; + } + } + + patchState(store, { + error: errorMessage, + status: ComponentStatus.ERROR + }); + } + }) + ); + }) + ) + ) + }; + }) +); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.html index d72d4c9817e4..1a996fa390d7 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.html @@ -19,7 +19,7 @@ } @else { } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.spec.ts index c5d122b79d38..278f73bdad29 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.spec.ts @@ -8,6 +8,7 @@ import { ReactiveFormsModule } from '@angular/forms'; import { DialogService, DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { DotMessageService } from '@dotcms/data-access'; +import { ComponentStatus } from '@dotcms/dotcms-models'; import { DotFormImportUrlComponent } from './dot-form-import-url.component'; import { FormImportUrlStore } from './store/form-import-url.store'; @@ -64,7 +65,7 @@ describe('DotFormImportUrlComponent', () => { patchState(store, { file: mockPreviewFile, - status: 'done' + status: ComponentStatus.LOADED }); spectator.flushEffects(); @@ -78,7 +79,7 @@ describe('DotFormImportUrlComponent', () => { const enableSpy = jest.spyOn(spectator.component.form, 'enable'); patchState(store, { - status: 'uploading' + status: ComponentStatus.LOADING }); spectator.flushEffects(); @@ -86,7 +87,7 @@ describe('DotFormImportUrlComponent', () => { expect(disableSpy).toHaveBeenCalled(); patchState(store, { - status: 'done' + status: ComponentStatus.LOADED }); spectator.flushEffects(); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.ts index 1e38d552e515..a97121b6daac 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/dot-form-import-url.component.ts @@ -1,4 +1,11 @@ -import { ChangeDetectionStrategy, Component, effect, inject, OnInit } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + OnInit, + untracked +} from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ButtonModule } from 'primeng/button'; @@ -35,7 +42,7 @@ export class DotFormImportUrlComponent implements OnInit { ); #abortController: AbortController | null = null; - readonly form = this.#formBuilder.group({ + readonly form = this.#formBuilder.nonNullable.group({ url: ['', [Validators.required, DotValidators.url]] }); @@ -49,9 +56,11 @@ export class DotFormImportUrlComponent implements OnInit { const file = this.store.file(); const isDone = this.store.isDone(); - if (file && isDone) { - this.#dialogRef.close(file); - } + untracked(() => { + if (isDone) { + this.#dialogRef.close(file); + } + }); }, { allowSignalWrites: true diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/store/form-import-url.store.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/store/form-import-url.store.spec.ts index 821f0ded9f53..64be5196b51b 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/store/form-import-url.store.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/store/form-import-url.store.spec.ts @@ -3,6 +3,8 @@ import { of, throwError } from 'rxjs'; import { TestBed } from '@angular/core/testing'; +import { ComponentStatus } from '@dotcms/dotcms-models'; + import { NEW_FILE_MOCK } from './../../../../../utils/mocks'; import { FormImportUrlStore, FormImportUrlState } from './form-import-url.store'; @@ -29,7 +31,7 @@ describe('FormImportUrlStore', () => { it('should initialize with the correct state', () => { expect(store.file()).toBeNull(); - expect(store.status()).toBe('init'); + expect(store.status()).toBe(ComponentStatus.INIT); expect(store.error()).toBeNull(); expect(store.uploadType()).toBe('temp'); expect(store.acceptedFiles()).toEqual([]); @@ -61,9 +63,9 @@ describe('FormImportUrlStore', () => { store.uploadFileByUrl({ fileUrl, abortSignal: abortController.signal }); - expect(store.file().file).toEqual(mockContentlet); - expect(store.file().source).toEqual('contentlet'); - expect(store.status()).toBe('done'); + expect(store.file()?.file).toEqual(mockContentlet); + expect(store.file()?.source).toEqual('contentlet'); + expect(store.status()).toBe(ComponentStatus.LOADED); }); it('should handle upload file by URL error', () => { @@ -77,7 +79,7 @@ describe('FormImportUrlStore', () => { expect(store.error()).toBe( 'dot.file.field.import.from.url.error.file.not.supported.message' ); - expect(store.status()).toBe('error'); + expect(store.status()).toBe(ComponentStatus.ERROR); }); }); }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/store/form-import-url.store.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/store/form-import-url.store.ts index 6677b0b6c897..de1dad6995a7 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/store/form-import-url.store.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-form-import-url/store/form-import-url.store.ts @@ -7,14 +7,14 @@ import { computed, inject } from '@angular/core'; import { switchMap, tap } from 'rxjs/operators'; -import { DotHttpErrorResponse } from '@dotcms/dotcms-models'; +import { ComponentStatus, DotHttpErrorResponse } from '@dotcms/dotcms-models'; import { UploadedFile, UPLOAD_TYPE } from '../../../models'; import { DotFileFieldUploadService } from '../../../services/upload-file/upload-file.service'; export interface FormImportUrlState { file: UploadedFile | null; - status: 'init' | 'uploading' | 'done' | 'error'; + status: ComponentStatus; error: string | null; uploadType: UPLOAD_TYPE; acceptedFiles: string[]; @@ -22,7 +22,7 @@ export interface FormImportUrlState { const initialState: FormImportUrlState = { file: null, - status: 'init', + status: ComponentStatus.INIT, error: null, uploadType: 'temp', acceptedFiles: [] @@ -31,8 +31,8 @@ const initialState: FormImportUrlState = { export const FormImportUrlStore = signalStore( withState(initialState), withComputed((state) => ({ - isLoading: computed(() => state.status() === 'uploading'), - isDone: computed(() => state.status() === 'done'), + isLoading: computed(() => state.status() === ComponentStatus.LOADING), + isDone: computed(() => state.status() === ComponentStatus.LOADED && state.file), allowFiles: computed(() => state.acceptedFiles().join(', ')) })), withMethods((store, uploadService = inject(DotFileFieldUploadService)) => ({ @@ -45,7 +45,7 @@ export const FormImportUrlStore = signalStore( abortSignal: AbortSignal; }>( pipe( - tap(() => patchState(store, { status: 'uploading' })), + tap(() => patchState(store, { status: ComponentStatus.LOADING })), switchMap(({ fileUrl, abortSignal }) => { return uploadService .uploadFile({ @@ -58,7 +58,7 @@ export const FormImportUrlStore = signalStore( .pipe( tapResponse({ next: (file) => { - patchState(store, { file, status: 'done' }); + patchState(store, { file, status: ComponentStatus.LOADED }); }, error: (error: DotHttpErrorResponse) => { let errorMessage = error?.message || ''; @@ -72,7 +72,7 @@ export const FormImportUrlStore = signalStore( patchState(store, { error: errorMessage, - status: 'error' + status: ComponentStatus.ERROR }); } }) 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.html 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.html new file mode 100644 index 000000000000..6fc9ea595e40 --- /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.html @@ -0,0 +1,54 @@ + + +
+ + + + +
+
+ + + {{ 'dot.file.field.dialog.select.existing.file.table.title' | dm }} + + {{ 'dot.file.field.dialog.select.existing.file.table.modified.by' | dm }} + + + {{ 'dot.file.field.dialog.select.existing.file.table.last.modified' | dm }} + + + + + + +
+ + +

{{ content.title }}

+
+ + {{ content.modifiedBy }} + {{ content.lastModified | date }} + +
+ + + @for (col of columns; track $index) { + + + + } + + +
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.external bean-validator @@ -1429,7 +1440,6 @@ jandex 3.0.5 - org.apache.tomcat tomcat-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 request, - OutputStream output); + AIResponseData applyStrategy(AIClient client, + AIResponseEvaluator handler, + AIRequest 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 request, - final OutputStream output) { - client.sendRequest(request, output); + public AIResponseData applyStrategy(final AIClient client, + final AIResponseEvaluator handler, + final AIRequest 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 request, - final OutputStream output) { + public AIResponseData applyStrategy(final AIClient client, + final AIResponseEvaluator handler, + final AIRequest 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 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 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 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> uniqueFieldOptional = uniqueFieldDataBaseUtil.get(contentlet); + + try { + if (uniqueFieldOptional.isPresent()) { + final Map uniqueFields = uniqueFieldOptional.get(); + + final String hash = uniqueFields.get("unique_key_val").toString(); + final PGobject supportingValues = (PGobject) uniqueFields.get("supporting_values"); + final Map supportingValuesMap = JsonUtil.getJsonFromString(supportingValues.getValue()); + final List contentletsId = (List) supportingValuesMap.get("contentletsId"); + + if (contentletsId.size() == 1) { + uniqueFieldDataBaseUtil.delete(hash, field.variable()); + } else { + contentletsId.remove(contentlet.getIdentifier()); + uniqueFieldDataBaseUtil.updateContentLists(hash, contentletsId); + } + } + } catch (IOException e){ + throw new DotDataException(e); + } + } + + //@Override + public void afterSaved(final Contentlet contentlet, final boolean isNew) throws DotDataException, DotSecurityException { + if (isNew) { + final ContentType contentType = APILocator.getContentTypeAPI(APILocator.systemUser()) + .find(contentlet.getContentTypeId()); + + final User systemUser = APILocator.systemUser(); + final Host host = APILocator.getHostAPI().find(contentlet.getHost(), systemUser, false); + final Language language = APILocator.getLanguageAPI().getLanguage(contentlet.getLanguageId()); + + final List uniqueFields = contentType.fields().stream() + .filter(Field::unique) + .collect(Collectors.toList()); + + if (uniqueFields.isEmpty()) { + throw new IllegalArgumentException("The ContentType must contains at least one Unique Field"); + } + + for (final Field uniqueField : uniqueFields) { + final Object fieldValue = contentlet.get(uniqueField.variable()); + + UniqueFieldCriteria uniqueFieldCriteria = new UniqueFieldCriteria.Builder() + .setSite(host) + .setLanguage(language) + .setField(uniqueField) + .setContentType(contentType) + .setValue(fieldValue) + .build(); + + uniqueFieldDataBaseUtil.updateContentList(uniqueFieldCriteria.hash(), contentlet.getIdentifier()); + } + } + } + + /** + * Insert a new unique field value, if the value is duplicated then a {@link java.sql.SQLException} is thrown. + * + * @param uniqueFieldCriteria + * @param contentletId + * + * @throws UniqueFieldValueDuplicatedException when the Value is duplicated + * @throws DotDataException when a DotDataException is throws + */ + private void checkUnique(UniqueFieldCriteria uniqueFieldCriteria, String contentletId) throws UniqueFieldValueDuplicatedException { + final boolean uniqueForSite = uniqueFieldCriteria.field().fieldVariableValue(ESContentletAPIImpl.UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) + .map(Boolean::valueOf).orElse(false); + + final Map supportingValues = new HashMap<>(uniqueFieldCriteria.toMap()); + supportingValues.put("contentletsId", CollectionsUtils.list(contentletId)); + supportingValues.put("uniquePerSite", uniqueForSite); + + try { + Logger.debug(DBUniqueFieldValidationStrategy.class, "Including value in the unique_fields table"); + uniqueFieldDataBaseUtil.insert(uniqueFieldCriteria.hash(), supportingValues); + } catch (DotDataException e) { + + if (isDuplicatedKeyError(e)) { + final String duplicatedValueMessage = String.format("The value %s for the field %s in the Content type %s is duplicated", + uniqueFieldCriteria.value(), uniqueFieldCriteria.field().variable(), + uniqueFieldCriteria.contentType().variable()); + + Logger.error(DBUniqueFieldValidationStrategy.class, duplicatedValueMessage); + throw new UniqueFieldValueDuplicatedException(duplicatedValueMessage); + } + } + } + + + private static boolean isDuplicatedKeyError(final Exception exception) { + final String originalMessage = exception.getMessage(); + + return originalMessage != null && originalMessage.startsWith( + "ERROR: duplicate key value violates unique constraint \"unique_fields_pkey\""); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldCriteria.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldCriteria.java new file mode 100644 index 000000000000..a98261653da3 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldCriteria.java @@ -0,0 +1,166 @@ +package com.dotcms.contenttype.business.uniquefields.extratable; + +import com.dotcms.content.elasticsearch.business.ESContentletAPIImpl; +import com.dotcms.contenttype.model.field.Field; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.variant.model.Variant; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotRuntimeException; +import com.dotmarketing.portlets.languagesmanager.model.Language; +import com.dotmarketing.util.StringUtils; +import com.liferay.util.StringPool; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Represent the criteria used to determine if a value is unique or not + */ +class UniqueFieldCriteria { + private final ContentType contentType; + private final Field field; + private final Object value; + private final Language language; + private final Host site; + private String variantName; + + public UniqueFieldCriteria(final Builder builder) { + this.contentType = builder.contentType; + this.field = builder.field; + this.value = builder.value; + this.language = builder.language; + this.site = builder.site; + this.variantName = builder.variantName; + } + + + /** + * Return a Map with the values in this Unique Field Criteria + * @return + */ + public Map toMap(){ + final Map map = new HashMap<>(Map.of( + "contentTypeID", Objects.requireNonNull(contentType.id()), + "fieldVariableName", Objects.requireNonNull(field.variable()), + "fieldValue", value.toString(), + "languageId", language.getId(), + "uniquePerSite", isUniqueForSite(contentType.id(), field.variable()), + "variant", variantName + )); + + if (site != null) { + map.put("hostId", site.getIdentifier()); + } + + return map; + } + + /** + * return true if the uniquePerSite Field Variable is set to true. + * + * @param contentTypeId + * @param fieldVariableName + * @return + */ + private static boolean isUniqueForSite(String contentTypeId, String fieldVariableName) { + try { + final Field uniqueField = APILocator.getContentTypeFieldAPI().byContentTypeIdAndVar(contentTypeId, fieldVariableName); + + return uniqueField.fieldVariableValue(ESContentletAPIImpl.UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) + .map(Boolean::valueOf).orElse(false); + } catch (DotDataException e) { + throw new DotRuntimeException( + String.format("Impossible to get FieldVariable from Field: %s, Content Type: %s", + fieldVariableName, contentTypeId), e); + } + } + + /** + * Return a hash calculated as follow: + * + * - If the uniquePerSite Field Variable is set to true then concat the: + * Content Type' id + Field Variable Name + Language's Id + Field Value + * + * - If the uniquePerSite Field Variable is set to false then concat the: + * Content Type' id + Field Variable Name + Language's Id + Field Value + Site's id + * + * @return + */ + public String hash(){ + return StringUtils.hashText(contentType.id() + field.variable() + language.getId() + value + + ((isUniqueForSite(contentType.id(), field.variable())) ? site.getIdentifier() : StringPool.BLANK)); + } + + public Field field() { + return field; + } + + public Object value() { + return value; + } + + public ContentType contentType() { + return contentType; + } + + public Language language() { + return language; + } + + + public static class Builder { + private ContentType contentType; + private Field field; + private Object value; + private Language language; + private Host site; + private String variantName; + + public Builder setVariantName(final String variantName) { + this.variantName = variantName; + return this; + } + + public Builder setContentType(final ContentType contentType) { + this.contentType = contentType; + return this; + } + + public Builder setField(final Field field) { + this.field = field; + return this; + } + + public Builder setValue(final Object value) { + this.value = value; + return this; + } + + public Builder setLanguage(final Language language) { + this.language = language; + return this; + } + + public Builder setSite(final Host site) { + this.site = site; + return this; + } + + public UniqueFieldCriteria build(){ + Objects.requireNonNull(contentType); + Objects.requireNonNull(field); + Objects.requireNonNull(value); + Objects.requireNonNull(language); + + if (isUniqueForSite(contentType.id(), field.variable())) { + Objects.requireNonNull(site); + } + + return new UniqueFieldCriteria(this); + } + } +} diff --git a/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldDataBaseUtil.java b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldDataBaseUtil.java new file mode 100644 index 000000000000..098c70f4c5ff --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldDataBaseUtil.java @@ -0,0 +1,74 @@ +package com.dotcms.contenttype.business.uniquefields.extratable; + +import com.dotmarketing.common.db.DotConnect; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.portlets.contentlet.model.Contentlet; + +import javax.enterprise.context.ApplicationScoped; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static com.dotcms.util.CollectionsUtils.list; + +/** + * Util class to handle QL statement related with the unique_fiedls table + */ +@ApplicationScoped +public class UniqueFieldDataBaseUtil { + + private final static String INSERT_SQL = "INSERT INTO unique_fields (unique_key_val, supporting_values) VALUES (?, ?)"; + private final static String UPDATE_CONTENT_LIST ="UPDATE unique_fields " + + "SET supporting_values = jsonb_set(supporting_values, '{contentletsId}', ?::jsonb) " + + "WHERE unique_key_val = ?"; + + private final static String GET_UNIQUE_FIELDS_BY_CONTENTLET = "SELECT * FROM unique_fields " + + "WHERE supporting_values->'contentletsId' @> ?::jsonb AND supporting_values->>'variant' = ?"; + + private final String DELETE_UNIQUE_FIELDS = "DELETE FROM unique_fields WHERE unique_key_val = ? " + + "AND supporting_values->>'fieldVariableName' = ?"; + + /** + * Insert a new register into the unique_fields table, if already exists another register with the same + * 'unique_key_val' then a {@link java.sql.SQLException} is thrown. + * + * @param key + * @param supportingValues + */ + public void insert(final String key, final Map supportingValues) throws DotDataException { + new DotConnect().setSQL(INSERT_SQL).addParam(key).addJSONParam(supportingValues).loadObjectResults(); + } + + /** + * Update the contentList attribute in the supportingValues field of the unique_fields table. + * + * @param hash + * @param contentletId + */ + public void updateContentList(final String hash, final String contentletId) throws DotDataException { + updateContentLists(hash, list(contentletId)); + } + + public void updateContentLists(final String hash, final List contentletIds) throws DotDataException { + new DotConnect().setSQL(UPDATE_CONTENT_LIST) + .addJSONParam(contentletIds) + .addParam(hash) + .loadObjectResults(); + } + + public Optional> get(final Contentlet contentlet) throws DotDataException { + final List> results = new DotConnect().setSQL(GET_UNIQUE_FIELDS_BY_CONTENTLET) + .addParam("\"" + contentlet.getIdentifier() + "\"") + .addParam(contentlet.getVariantId()) + .loadObjectResults(); + + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } + + public void delete(final String hash, String fiedVariable) throws DotDataException { + new DotConnect().setSQL(DELETE_UNIQUE_FIELDS) + .addParam(hash) + .addParam(fiedVariable) + .loadObjectResults(); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/cube/CubeJSClientFactoryImpl.java b/dotCMS/src/main/java/com/dotcms/cube/CubeJSClientFactoryImpl.java index c0adf31d92c6..830ca460c8c9 100644 --- a/dotCMS/src/main/java/com/dotcms/cube/CubeJSClientFactoryImpl.java +++ b/dotCMS/src/main/java/com/dotcms/cube/CubeJSClientFactoryImpl.java @@ -11,12 +11,14 @@ import com.dotmarketing.exception.DotSecurityException; import com.google.common.annotations.VisibleForTesting; import com.liferay.portal.model.User; +import javax.enterprise.context.ApplicationScoped; /** * Factory to create {@link CubeJSClient} instances. * * @author vico */ +@ApplicationScoped public class CubeJSClientFactoryImpl implements CubeJSClientFactory { private static AnalyticsHelper analyticsHelper = AnalyticsHelper.get(); diff --git a/dotCMS/src/main/java/com/dotcms/jitsu/EventLogRunnable.java b/dotCMS/src/main/java/com/dotcms/jitsu/EventLogRunnable.java index 68246893593f..e56d4352162f 100644 --- a/dotCMS/src/main/java/com/dotcms/jitsu/EventLogRunnable.java +++ b/dotCMS/src/main/java/com/dotcms/jitsu/EventLogRunnable.java @@ -72,7 +72,16 @@ public EventLogRunnable(final Host site, final Supplier convertToEventPayload(payloadSupplier.get()); } - private EventsPayload convertToEventPayload(final List> listStringSerializableMap) { + /** + * Returns the generated event payload as an {@link Optional} object. + * + * @return {@link Optional} of {@link EventsPayload}. + */ + public Optional getEventPayload() { + return Optional.ofNullable(eventPayload.get()); + } + + protected EventsPayload convertToEventPayload(final List> listStringSerializableMap) { return new AnalyticsEventsPayload(listStringSerializableMap); } diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueManagerAPI.java b/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueManagerAPI.java index d7e0311f1240..86f5b24ca77d 100644 --- a/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueManagerAPI.java +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueManagerAPI.java @@ -61,9 +61,10 @@ public interface JobQueueManagerAPI { /** * Retrieves the job processors for all registered queues. + * * @return A map of queue names to job processors */ - Map> getQueueNames(); + Map> getQueueNames(); /** * Creates a new job in the specified queue. @@ -86,6 +87,18 @@ String createJob(String queueName, Map parameters) */ Job getJob(String jobId) throws DotDataException; + /** + * Retrieves a list of active jobs for a specific queue. + * + * @param queueName The name of the queue + * @param page The page number + * @param pageSize The number of jobs per page + * @return A result object containing the list of active jobs and pagination information. + * @throws JobQueueDataException if there's an error fetching the jobs + */ + JobPaginatedResult getActiveJobs(String queueName, int page, int pageSize) + throws JobQueueDataException; + /** * Retrieves a list of jobs. * @@ -97,21 +110,42 @@ String createJob(String queueName, Map parameters) JobPaginatedResult getJobs(int page, int pageSize) throws DotDataException; /** - * Retrieves a list of active jobs for a specific queue. - * @param queueName The name of the queue - * @param page The page number - * @param pageSize The number of jobs per page + * Retrieves a list of active jobs, meaning jobs that are currently being processed. + * + * @param page The page number + * @param pageSize The number of jobs per page * @return A result object containing the list of active jobs and pagination information. * @throws JobQueueDataException if there's an error fetching the jobs */ - JobPaginatedResult getActiveJobs(String queueName, int page, int pageSize) throws JobQueueDataException; + JobPaginatedResult getActiveJobs(int page, int pageSize) throws JobQueueDataException; /** - * Retrieves a list of completed jobs for a specific queue within a date range. + * Retrieves a list of completed jobs + * * @param page The page number * @param pageSize The number of jobs per page * @return A result object containing the list of completed jobs and pagination information. - * @throws JobQueueDataException + * @throws JobQueueDataException if there's an error fetching the jobs + */ + JobPaginatedResult getCompletedJobs(int page, int pageSize) throws JobQueueDataException; + + /** + * Retrieves a list of canceled jobs + * + * @param page The page number + * @param pageSize The number of jobs per page + * @return A result object containing the list of canceled jobs and pagination information. + * @throws JobQueueDataException if there's an error fetching the jobs + */ + JobPaginatedResult getCanceledJobs(int page, int pageSize) throws JobQueueDataException; + + /** + * Retrieves a list of failed jobs + * + * @param page The page number + * @param pageSize The number of jobs per page + * @return A result object containing the list of failed jobs and pagination information. + * @throws JobQueueDataException if there's an error fetching the jobs */ JobPaginatedResult getFailedJobs(int page, int pageSize) throws JobQueueDataException; @@ -141,6 +175,7 @@ String createJob(String queueName, Map parameters) /** * Retrieves the retry strategy for a specific queue. + * * @param jobId The ID of the job * @return The processor instance, or an empty optional if not found */ diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueManagerAPIImpl.java b/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueManagerAPIImpl.java index d45b26b8b476..ffb777cf8791 100644 --- a/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueManagerAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/api/JobQueueManagerAPIImpl.java @@ -15,6 +15,7 @@ import com.dotcms.jobs.business.error.CircuitBreaker; import com.dotcms.jobs.business.error.ErrorDetail; import com.dotcms.jobs.business.error.JobProcessorNotFoundException; +import com.dotcms.jobs.business.error.RetryPolicyProcessor; import com.dotcms.jobs.business.error.RetryStrategy; import com.dotcms.jobs.business.job.Job; import com.dotcms.jobs.business.job.JobPaginatedResult; @@ -22,6 +23,7 @@ import com.dotcms.jobs.business.job.JobState; import com.dotcms.jobs.business.processor.Cancellable; import com.dotcms.jobs.business.processor.DefaultProgressTracker; +import com.dotcms.jobs.business.processor.DefaultRetryStrategy; import com.dotcms.jobs.business.processor.JobProcessor; import com.dotcms.jobs.business.processor.ProgressTracker; import com.dotcms.jobs.business.queue.JobQueue; @@ -50,6 +52,7 @@ import java.util.function.Consumer; import javax.enterprise.context.ApplicationScoped; import javax.inject.Inject; +import javax.inject.Named; /** * Manages the processing of jobs in a distributed job queue system. This class is responsible for @@ -106,6 +109,7 @@ public class JobQueueManagerAPIImpl implements JobQueueManagerAPI { private ExecutorService executorService; private final Map retryStrategies; private final RetryStrategy defaultRetryStrategy; + private final RetryPolicyProcessor retryPolicyProcessor; private final ScheduledExecutorService pollJobUpdatesScheduler; private LocalDateTime lastPollJobUpdateTime = LocalDateTime.now(); @@ -138,13 +142,14 @@ public class JobQueueManagerAPIImpl implements JobQueueManagerAPI { * - Initializes event handlers for various job state changes. */ @Inject - public JobQueueManagerAPIImpl(JobQueue jobQueue, + public JobQueueManagerAPIImpl(@Named("queueProducer") JobQueue jobQueue, JobQueueConfig jobQueueConfig, CircuitBreaker circuitBreaker, - RetryStrategy defaultRetryStrategy, + @DefaultRetryStrategy RetryStrategy defaultRetryStrategy, RealTimeJobMonitor realTimeJobMonitor, EventProducer eventProducer, - JobProcessorFactory jobProcessorFactory) { + JobProcessorFactory jobProcessorFactory, + RetryPolicyProcessor retryPolicyProcessor) { this.jobQueue = jobQueue; this.threadPoolSize = jobQueueConfig.getThreadPoolSize(); @@ -153,6 +158,8 @@ public JobQueueManagerAPIImpl(JobQueue jobQueue, this.retryStrategies = new ConcurrentHashMap<>(); this.defaultRetryStrategy = defaultRetryStrategy; this.circuitBreaker = circuitBreaker; + this.jobProcessorFactory = jobProcessorFactory; + this.retryPolicyProcessor = retryPolicyProcessor; this.pollJobUpdatesScheduler = Executors.newSingleThreadScheduledExecutor(); pollJobUpdatesScheduler.scheduleAtFixedRate( @@ -163,7 +170,6 @@ public JobQueueManagerAPIImpl(JobQueue jobQueue, // Events this.realTimeJobMonitor = realTimeJobMonitor; this.eventProducer = eventProducer; - this.jobProcessorFactory = jobProcessorFactory; } @Override @@ -243,6 +249,12 @@ public void registerProcessor(final String queueName, final Class watcher(); + + /** + * Returns a predicate that can be used to filter jobs based on custom criteria. + * + * @return a Predicate object to filter Job instances + */ + Predicate filter(); + +} diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/api/events/RealTimeJobMonitor.java b/dotCMS/src/main/java/com/dotcms/jobs/business/api/events/RealTimeJobMonitor.java index c55466f1f62a..29c9eab2e0b7 100644 --- a/dotCMS/src/main/java/com/dotcms/jobs/business/api/events/RealTimeJobMonitor.java +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/api/events/RealTimeJobMonitor.java @@ -1,38 +1,121 @@ package com.dotcms.jobs.business.api.events; import com.dotcms.jobs.business.job.Job; +import com.dotcms.jobs.business.job.JobState; +import com.dotmarketing.util.Logger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; +import java.util.function.Predicate; import javax.enterprise.context.ApplicationScoped; import javax.enterprise.event.Observes; /** - * Manages real-time monitoring of jobs in the system. This class handles registration of job - * watchers, updates watchers on job changes, and processes various job-related events. + * Manages real-time monitoring of jobs in the system. This class provides functionality to register + * watchers for specific jobs and receive notifications about job state changes and progress updates. + * + *

Thread safety is ensured through a combination of {@link ConcurrentHashMap} for storing watchers + * and synchronized {@link List}s for managing multiple watchers per job. This allows concurrent + * registration and notification of watchers without compromising data consistency.

+ * + *

The monitor supports filtered watching through predicates, allowing clients to receive only + * the updates they're interested in. Common predicates are provided through the inner + * {@link Predicates} class.

+ * + *

Usage Examples:

+ * + *

Watch all job updates:

+ *
{@code
+ * monitor.registerWatcher(jobId, job -> System.out.println("Job updated: " + job.id()));
+ * }
+ * + *

Watch only completed jobs:

+ *
{@code
+ * monitor.registerWatcher(jobId,
+ *     job -> handleCompletion(job),
+ *     Predicates.isCompleted()
+ * );
+ * }
+ * + *

Watch progress changes with threshold:

+ *
{@code
+ * monitor.registerWatcher(jobId,
+ *     job -> updateProgress(job),
+ *     Predicates.progressChanged(0.1f) // Updates every 10% progress
+ * );
+ * }
+ * + *

Combine multiple conditions:

+ *
{@code
+ * monitor.registerWatcher(jobId,
+ *     job -> handleUpdate(job),
+ *     Predicates.hasState(JobState.RUNNING)
+ *         .and(Predicates.progressChanged(0.05f))
+ * );
+ * }
+ * + * @see JobWatcher + * @see Predicates */ @ApplicationScoped public class RealTimeJobMonitor { - private final Map>> jobWatchers = new ConcurrentHashMap<>(); + private final Map> jobWatchers = new ConcurrentHashMap<>(); /** - * Registers a watcher for a specific job. + * Registers a watcher for a specific job with optional filtering of updates. The watcher will + * be notified of job updates that match the provided filter predicate. If no filter is provided + * (null), the watcher receives all updates for the job. * - * @param jobId The ID of the job to watch. - * @param watcher The consumer to be notified of job updates. + *

Multiple watchers can be registered for the same job, and each watcher can have + * its own filter predicate. Watchers are automatically removed when a job reaches a final state + * (completed, cancelled, or removed).

+ * + * @param jobId The ID of the job to watch + * @param watcher The consumer to be notified of job updates + * @param filter Optional predicate to filter job updates (null means receive all updates) + * @throws IllegalArgumentException if jobId or watcher is null + * @see Predicates for common filter predicates + */ + public void registerWatcher(String jobId, Consumer watcher, Predicate filter) { + jobWatchers.compute(jobId, (key, existingWatchers) -> { + List watchers = Objects.requireNonNullElseGet( + existingWatchers, + () -> Collections.synchronizedList(new ArrayList<>()) + ); + + final var jobWatcher = JobWatcher.builder() + .watcher(watcher) + .filter(filter != null ? filter : job -> true).build(); + + watchers.add(jobWatcher); + return watchers; + }); + } + + /** + * Registers a watcher for a specific job that receives all updates. + * This is a convenience method equivalent to calling {@code registerWatcher(jobId, watcher, null)}. + * + * @param jobId The ID of the job to watch + * @param watcher The consumer to be notified of job updates + * @throws IllegalArgumentException if jobId or watcher is null */ public void registerWatcher(String jobId, Consumer watcher) { - jobWatchers.computeIfAbsent(jobId, k -> new CopyOnWriteArrayList<>()).add(watcher); + registerWatcher(jobId, watcher, null); } /** * Retrieves the set of job IDs currently being watched. + * The returned set is a snapshot and may not reflect concurrent modifications. * - * @return A set of job IDs. + * @return An unmodifiable set of job IDs with active watchers */ public Set getWatchedJobIds() { return jobWatchers.keySet(); @@ -40,8 +123,10 @@ public Set getWatchedJobIds() { /** * Updates watchers for a list of jobs. + * Each job's watchers are notified according to their filter predicates. * - * @param updatedJobs List of jobs that have been updated. + * @param updatedJobs List of jobs that have been updated + * @throws IllegalArgumentException if updatedJobs is null */ public void updateWatchers(List updatedJobs) { for (Job job : updatedJobs) { @@ -56,9 +141,18 @@ public void updateWatchers(List updatedJobs) { */ private void updateWatchers(Job job) { - List> watchers = jobWatchers.get(job.id()); + List watchers = jobWatchers.get(job.id()); if (watchers != null) { - watchers.forEach(watcher -> watcher.accept(job)); + watchers.forEach(jobWatcher -> { + try { + if (jobWatcher.filter().test(job)) { + jobWatcher.watcher().accept(job); + } + } catch (Exception e) { + Logger.error(this, "Error notifying job watcher for job " + job.id(), e); + watchers.remove(jobWatcher); + } + }); } } @@ -136,4 +230,85 @@ public void onJobProgressUpdated(@Observes JobProgressUpdatedEvent event) { updateWatchers(event.getJob()); } + /** + * Common predicates for filtering job updates. These predicates can be used individually or + * combined using {@link Predicate#and(Predicate)} and {@link Predicate#or(Predicate)} to create + * more complex filtering conditions. + */ + public static class Predicates { + + private Predicates() { + // Prevent instantiation + } + + /** + * Creates a predicate that matches jobs with any of the specified states. + * + * @param states One or more job states to match + * @return A predicate that returns true if the job's state matches any of the specified + * states + * @throws IllegalArgumentException if states is null or empty + */ + public static Predicate hasState(JobState... states) { + return job -> Arrays.asList(states).contains(job.state()); + } + + /** + * Creates a predicate that matches jobs whose progress has changed by at least the + * specified threshold since the last notification. + * + * @param threshold The minimum progress change (0.0 to 1.0) required to match + * @return A predicate that tracks and matches significant progress changes + * @throws IllegalArgumentException if threshold is not between 0.0 and 1.0 + */ + public static Predicate progressChanged(float threshold) { + return new Predicate<>() { + private float lastProgress = 0; + + @Override + public boolean test(Job job) { + float currentProgress = job.progress(); + if (Math.abs(currentProgress - lastProgress) >= threshold) { + lastProgress = currentProgress; + return true; + } + return false; + } + }; + } + + /** + * Creates a predicate that matches failed jobs with error details. The predicate only + * matches if the job is in FAILED state and has error details available. + * + * @return A predicate for matching failed jobs + */ + public static Predicate hasFailed() { + return job -> job.state() == JobState.FAILED + && job.result().isPresent() + && job.result().get().errorDetail().isPresent(); + } + + /** + * Creates a predicate that matches completed jobs. The predicate matches any job in the + * COMPLETED state. + * + * @return A predicate for matching completed jobs + */ + public static Predicate isCompleted() { + return job -> job.state() == JobState.COMPLETED; + } + + /** + * Creates a predicate that matches canceled jobs. The predicate matches any job in the + * CANCELED state. + * + * @return A predicate for matching canceled jobs + */ + public static Predicate isCanceled() { + return job -> job.state() == JobState.CANCELED; + } + + } + } diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/error/ExponentialBackoffRetryStrategy.java b/dotCMS/src/main/java/com/dotcms/jobs/business/error/ExponentialBackoffRetryStrategy.java index 8b16953d763f..d754d6e9890d 100644 --- a/dotCMS/src/main/java/com/dotcms/jobs/business/error/ExponentialBackoffRetryStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/error/ExponentialBackoffRetryStrategy.java @@ -16,7 +16,7 @@ public class ExponentialBackoffRetryStrategy implements RetryStrategy { private final long maxDelay; private final double backoffFactor; private final int maxRetries; - private final Set> retryableExceptions; + private final Set> nonRetryableExceptions; private final SecureRandom random = new SecureRandom(); /** @@ -36,14 +36,14 @@ public ExponentialBackoffRetryStrategy(long initialDelay, long maxDelay, double * Constructs an ExponentialBackoffRetryStrategy with the specified parameters and retryable * exceptions. * - * @param initialDelay The initial delay between retries in milliseconds. - * @param maxDelay The maximum delay between retries in milliseconds. - * @param backoffFactor The factor by which the delay increases with each retry. - * @param maxRetries The maximum number of retry attempts allowed. - * @param retryableExceptions A set of exception classes that are considered retryable. + * @param initialDelay The initial delay between retries in milliseconds. + * @param maxDelay The maximum delay between retries in milliseconds. + * @param backoffFactor The factor by which the delay increases with each retry. + * @param maxRetries The maximum number of retry attempts allowed. + * @param nonRetryableExceptions A set of exception classes that are considered non retryable. */ public ExponentialBackoffRetryStrategy(long initialDelay, long maxDelay, double backoffFactor, - int maxRetries, Set> retryableExceptions) { + int maxRetries, Set> nonRetryableExceptions) { if (initialDelay <= 0 || maxDelay <= 0 || backoffFactor <= 1) { throw new IllegalArgumentException("Invalid retry strategy parameters"); @@ -53,7 +53,7 @@ public ExponentialBackoffRetryStrategy(long initialDelay, long maxDelay, double this.maxDelay = maxDelay; this.backoffFactor = backoffFactor; this.maxRetries = maxRetries; - this.retryableExceptions = new HashSet<>(retryableExceptions); + this.nonRetryableExceptions = new HashSet<>(nonRetryableExceptions); } /** @@ -65,7 +65,7 @@ public ExponentialBackoffRetryStrategy(long initialDelay, long maxDelay, double */ @Override public boolean shouldRetry(final Job job, final Class exceptionClass) { - return job.retryCount() < maxRetries && isRetryableException(exceptionClass); + return job.retryCount() < maxRetries && !isNonRetryableException(exceptionClass); } /** @@ -93,25 +93,22 @@ public int maxRetries() { } @Override - public boolean isRetryableException(final Class exceptionClass) { + public boolean isNonRetryableException(final Class exceptionClass) { if (exceptionClass == null) { return false; } - if (retryableExceptions.isEmpty()) { - return true; // If no specific exceptions are set, all are retryable - } - return retryableExceptions.stream() + return nonRetryableExceptions.stream() .anyMatch(clazz -> clazz.isAssignableFrom(exceptionClass)); } @Override - public void addRetryableException(final Class exceptionClass) { - retryableExceptions.add(exceptionClass); + public void addNonRetryableException(final Class exceptionClass) { + nonRetryableExceptions.add(exceptionClass); } @Override - public Set> getRetryableExceptions() { - return Set.copyOf(retryableExceptions); + public Set> getNonRetryableExceptions() { + return Set.copyOf(nonRetryableExceptions); } } \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/error/JobProcessingException.java b/dotCMS/src/main/java/com/dotcms/jobs/business/error/JobProcessingException.java index ef4d9148ffb0..b656c8f5b79b 100644 --- a/dotCMS/src/main/java/com/dotcms/jobs/business/error/JobProcessingException.java +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/error/JobProcessingException.java @@ -17,4 +17,15 @@ public class JobProcessingException extends RuntimeException { public JobProcessingException(String jobId, String reason, Throwable cause) { super("Error processing job " + jobId + ". Reason: " + reason, cause); } + + /** + * Constructs a new JobProcessingException with the specified job ID, reason, and cause. + * + * @param jobId The ID of the job that encountered an error during processing + * @param reason A description of why the error occurred + */ + public JobProcessingException(String jobId, String reason) { + super("Error processing job " + jobId + ". Reason: " + reason); + } + } \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/error/JobValidationException.java b/dotCMS/src/main/java/com/dotcms/jobs/business/error/JobValidationException.java new file mode 100644 index 000000000000..00dca669a109 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/error/JobValidationException.java @@ -0,0 +1,31 @@ +package com.dotcms.jobs.business.error; + +/** + * Exception thrown when a job fails validation before or during processing. This exception provides + * information about which job failed validation, the reason for the validation failure, and the + * underlying cause (if available). + */ +public class JobValidationException extends RuntimeException { + + /** + * Constructs a new JobValidationException with the specified job ID, reason, and cause. + * + * @param jobId The ID of the job that failed validation + * @param reason A description of why the validation failed + * @param cause The underlying cause of the validation failure (can be null) + */ + public JobValidationException(String jobId, String reason, Throwable cause) { + super("Error processing job " + jobId + ". Reason: " + reason, cause); + } + + /** + * Constructs a new JobValidationException with the specified job ID and reason. + * + * @param jobId The ID of the job that failed validation + * @param reason A description of why the validation failed + */ + public JobValidationException(String jobId, String reason) { + super("Error processing job " + jobId + ". Reason: " + reason); + } + +} \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/error/NoRetryStrategy.java b/dotCMS/src/main/java/com/dotcms/jobs/business/error/NoRetryStrategy.java new file mode 100644 index 000000000000..72b51c445e2a --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/error/NoRetryStrategy.java @@ -0,0 +1,46 @@ +package com.dotcms.jobs.business.error; + +import com.dotcms.jobs.business.job.Job; +import java.util.Collections; +import java.util.Set; +import javax.enterprise.context.ApplicationScoped; + +/** + * Implements a no-retry strategy for job processors that should never retry failed jobs. This + * strategy always returns false for shouldRetry and maintains an empty set of non-retryable + * exceptions since retries are never attempted. + */ +@ApplicationScoped +public class NoRetryStrategy implements RetryStrategy { + + @Override + public boolean shouldRetry(Job job, Class exceptionClass) { + return false; // Never retry + } + + @Override + public long nextRetryDelay(Job job) { + return 0; // Not used since retries never occur + } + + @Override + public int maxRetries() { + return 0; + } + + @Override + public boolean isNonRetryableException(Class exceptionClass) { + return true; // All exceptions are considered non-retryable + } + + @Override + public void addNonRetryableException(Class exceptionClass) { + // No-op since all exceptions are already non-retryable + } + + @Override + public Set> getNonRetryableExceptions() { + return Collections.emptySet(); // No need to track specific exceptions + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/error/RetryPolicyProcessor.java b/dotCMS/src/main/java/com/dotcms/jobs/business/error/RetryPolicyProcessor.java new file mode 100644 index 000000000000..1775684c8f33 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/error/RetryPolicyProcessor.java @@ -0,0 +1,93 @@ +package com.dotcms.jobs.business.error; + +import com.dotcms.jobs.business.processor.ExponentialBackoffRetryPolicy; +import com.dotcms.jobs.business.processor.JobProcessor; +import com.dotcms.jobs.business.processor.NoRetryPolicy; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; + +/** + * Processes retry policies for job processors. This class is responsible for interpreting retry + * policy annotations on job processor classes and creating appropriate RetryStrategy instances. + */ +@ApplicationScoped +public class RetryPolicyProcessor { + + private NoRetryStrategy noRetryStrategy; + + /** + * Default constructor required for CDI proxy creation. + */ + public RetryPolicyProcessor() { + // Default constructor for CDI + } + + @Inject + public RetryPolicyProcessor(NoRetryStrategy noRetryStrategy) { + this.noRetryStrategy = noRetryStrategy; + } + + /** + * Processes the retry policy for a given job processor class. + *

+ * Currently supports ExponentialBackoffRetryPolicy and NoRetryPolicy. + * + * @param processorClass The class of the job processor to process. + * @return A RetryStrategy based on the annotation present on the processor class, or null if no + * supported annotation is found. + * + * @see ExponentialBackoffRetryPolicy + * @see NoRetryPolicy + */ + public RetryStrategy processRetryPolicy(Class processorClass) { + + // Check for NoRetryPolicy + if (processorClass.isAnnotationPresent(NoRetryPolicy.class)) { + return noRetryStrategy; + } + + // Check for ExponentialBackoffRetryPolicy + if (processorClass.isAnnotationPresent(ExponentialBackoffRetryPolicy.class)) { + return processExponentialBackoffPolicy( + processorClass.getAnnotation(ExponentialBackoffRetryPolicy.class) + ); + } + + return null; + } + + /** + * Processes an ExponentialBackoffRetryPolicy annotation and creates an + * ExponentialBackoffRetryStrategy based on its parameters. + * + * @param policy The ExponentialBackoffRetryPolicy annotation to process + * @return An ExponentialBackoffRetryStrategy configured based on the annotation + */ + private RetryStrategy processExponentialBackoffPolicy(ExponentialBackoffRetryPolicy policy) { + + final long initialDelay = policy.initialDelay() != -1 ? policy.initialDelay() + : RetryStrategyProducer.DEFAULT_RETRY_STRATEGY_INITIAL_DELAY; + final long maxDelay = policy.maxDelay() != -1 ? policy.maxDelay() : + RetryStrategyProducer.DEFAULT_RETRY_STRATEGY_MAX_DELAY; + final double backoffFactor = policy.backoffFactor() != -1 ? policy.backoffFactor() + : RetryStrategyProducer.DEFAULT_RETRY_STRATEGY_BACK0FF_FACTOR; + final int maxRetries = policy.maxRetries() != -1 ? policy.maxRetries() + : RetryStrategyProducer.DEFAULT_RETRY_STRATEGY_MAX_RETRIES; + + Set> nonRetryableExceptions = new HashSet<>( + Arrays.asList(policy.nonRetryableExceptions()) + ); + + return new ExponentialBackoffRetryStrategy( + initialDelay, + maxDelay, + backoffFactor, + maxRetries, + nonRetryableExceptions + ); + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/error/RetryStrategy.java b/dotCMS/src/main/java/com/dotcms/jobs/business/error/RetryStrategy.java index 23f54600de02..256172478224 100644 --- a/dotCMS/src/main/java/com/dotcms/jobs/business/error/RetryStrategy.java +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/error/RetryStrategy.java @@ -35,25 +35,25 @@ public interface RetryStrategy { int maxRetries(); /** - * Determines whether a given exception is retryable according to this strategy. + * Determines whether a given exception is not retryable according to this strategy. * * @param exceptionClass The class of the exception to check. - * @return true if the exception is retryable, false otherwise. + * @return true if the exception is not retryable, false otherwise. */ - boolean isRetryableException(Class exceptionClass); + boolean isNonRetryableException(Class exceptionClass); /** - * Adds an exception class to the set of retryable exceptions. + * Adds an exception class to the set of non retryable exceptions. * - * @param exceptionClass The exception class to be considered retryable. + * @param exceptionClass The exception class to be considered non retryable. */ - void addRetryableException(Class exceptionClass); + void addNonRetryableException(Class exceptionClass); /** - * Returns an unmodifiable set of the currently registered retryable exceptions. + * Returns an unmodifiable set of the currently registered non retryable exceptions. * - * @return An unmodifiable set of retryable exception classes. + * @return An unmodifiable set of non retryable exception classes. */ - Set> getRetryableExceptions(); + Set> getNonRetryableExceptions(); } \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/error/RetryStrategyProducer.java b/dotCMS/src/main/java/com/dotcms/jobs/business/error/RetryStrategyProducer.java index 2b9452d66713..f91b9a6755c9 100644 --- a/dotCMS/src/main/java/com/dotcms/jobs/business/error/RetryStrategyProducer.java +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/error/RetryStrategyProducer.java @@ -1,5 +1,6 @@ package com.dotcms.jobs.business.error; +import com.dotcms.jobs.business.processor.DefaultRetryStrategy; import com.dotmarketing.util.Config; import javax.enterprise.context.ApplicationScoped; import javax.enterprise.inject.Produces; @@ -38,6 +39,7 @@ public class RetryStrategyProducer { * @return An ExponentialBackoffRetryStrategy instance configured with the default values. */ @Produces + @DefaultRetryStrategy public RetryStrategy produceDefaultRetryStrategy() { return new ExponentialBackoffRetryStrategy( DEFAULT_RETRY_STRATEGY_INITIAL_DELAY, diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/processor/DefaultRetryStrategy.java b/dotCMS/src/main/java/com/dotcms/jobs/business/processor/DefaultRetryStrategy.java new file mode 100644 index 000000000000..82802b95c6b9 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/processor/DefaultRetryStrategy.java @@ -0,0 +1,23 @@ +package com.dotcms.jobs.business.processor; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import javax.inject.Qualifier; + +/** + * Qualifier annotation to identify the default retry strategy implementation. + */ +@Qualifier +@Retention(RUNTIME) +@Target({TYPE, METHOD, FIELD, PARAMETER}) +@Documented +public @interface DefaultRetryStrategy { + +} diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/processor/ExponentialBackoffRetryPolicy.java b/dotCMS/src/main/java/com/dotcms/jobs/business/processor/ExponentialBackoffRetryPolicy.java new file mode 100644 index 000000000000..4e31497fda55 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/processor/ExponentialBackoffRetryPolicy.java @@ -0,0 +1,66 @@ +package com.dotcms.jobs.business.processor; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to specify an exponential backoff retry policy for job processors. This annotation + *

+ * should be applied at the class level to define the retry behavior for the entire job processor. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +public @interface ExponentialBackoffRetryPolicy { + + /** + * Specifies the maximum number of retry attempts. + *

+ * If set to -1, the value will be taken from the configuration property + * 'DEFAULT_RETRY_STRATEGY_MAX_RETRIES'. + * + * @return the maximum number of retries, or -1 to use the config value + */ + int maxRetries() default -1; + + /** + * Specifies the initial delay between retry attempts in milliseconds. + *

+ * If set to -1, the value will be taken from the configuration property + * 'DEFAULT_RETRY_STRATEGY_INITIAL_DELAY'. + * + * @return the initial delay in milliseconds, or -1 to use the config value + */ + long initialDelay() default -1; + + /** + * Specifies the maximum delay between retry attempts in milliseconds. + *

+ * If set to -1, the value will be taken from the configuration property + * 'DEFAULT_RETRY_STRATEGY_MAX_DELAY'. + * + * @return the maximum delay in milliseconds, or -1 to use the config value + */ + long maxDelay() default -1; + + /** + * Specifies the factor by which the delay increases with each retry attempt. + *

+ * If set to -1, the value will be taken from the configuration property + * 'DEFAULT_RETRY_STRATEGY_BACK0FF_FACTOR'. + * + * @return the backoff factor, or -1 to use the config value + */ + double backoffFactor() default -1; + + /** + * Specifies the exception types that should not be retried. If an empty array is provided, all + * exceptions will be considered retryable. + * + * @return an array of Throwable classes representing non-retryable exceptions + */ + Class[] nonRetryableExceptions() default {}; +} diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/processor/NoRetryPolicy.java b/dotCMS/src/main/java/com/dotcms/jobs/business/processor/NoRetryPolicy.java new file mode 100644 index 000000000000..527805fba76f --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/processor/NoRetryPolicy.java @@ -0,0 +1,28 @@ +package com.dotcms.jobs.business.processor; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to explicitly specify that a job processor should not retry failed jobs. + * This provides a more semantic way to indicate no-retry behavior compared to setting + * maxRetries=0 in ExponentialBackoffRetryPolicy. + * + *

Usage example:

+ *
+ * {@literal @}NoRetryPolicy
+ * {@literal @}Queue("myQueue")
+ * public class MyJobProcessor implements JobProcessor {
+ *     // Implementation
+ * }
+ * 
+ */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +public @interface NoRetryPolicy { + // No elements needed - presence of annotation is sufficient +} diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/processor/impl/FailJob.java b/dotCMS/src/main/java/com/dotcms/jobs/business/processor/impl/FailSuccessJob.java similarity index 69% rename from dotCMS/src/main/java/com/dotcms/jobs/business/processor/impl/FailJob.java rename to dotCMS/src/main/java/com/dotcms/jobs/business/processor/impl/FailSuccessJob.java index a70a4a4103b6..ae34091b2bbd 100644 --- a/dotCMS/src/main/java/com/dotcms/jobs/business/processor/impl/FailJob.java +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/processor/impl/FailSuccessJob.java @@ -6,13 +6,14 @@ import com.dotmarketing.exception.DotRuntimeException; import java.util.Map; -@Queue("fail") -public class FailJob implements JobProcessor { +@Queue("failSuccess") +public class FailSuccessJob implements JobProcessor { @Override public void process(Job job) { - - throw new DotRuntimeException( "Failed job !"); + if (job.parameters().containsKey("fail")) { + throw new DotRuntimeException("Failed job !"); + } } @Override diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/processor/impl/ImportContentletsProcessor.java b/dotCMS/src/main/java/com/dotcms/jobs/business/processor/impl/ImportContentletsProcessor.java new file mode 100644 index 000000000000..5c586ee62a59 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/processor/impl/ImportContentletsProcessor.java @@ -0,0 +1,728 @@ +package com.dotcms.jobs.business.processor.impl; + +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.jobs.business.error.JobCancellationException; +import com.dotcms.jobs.business.error.JobProcessingException; +import com.dotcms.jobs.business.error.JobValidationException; +import com.dotcms.jobs.business.job.Job; +import com.dotcms.jobs.business.processor.Cancellable; +import com.dotcms.jobs.business.processor.ExponentialBackoffRetryPolicy; +import com.dotcms.jobs.business.processor.JobProcessor; +import com.dotcms.jobs.business.processor.NoRetryPolicy; +import com.dotcms.jobs.business.processor.Queue; +import com.dotcms.jobs.business.util.JobUtil; +import com.dotcms.repackage.com.csvreader.CsvReader; +import com.dotcms.rest.api.v1.temp.DotTempFile; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.db.HibernateUtil; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotHibernateException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.portlets.contentlet.action.ImportAuditUtil; +import com.dotmarketing.util.AdminLogger; +import com.dotmarketing.util.FileUtil; +import com.dotmarketing.util.ImportUtil; +import com.dotmarketing.util.Logger; +import com.google.common.hash.Hashing; +import com.liferay.portal.model.User; +import com.liferay.portal.util.Constants; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Calendar; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.LongConsumer; + +/** + * Processor implementation for handling content import operations in dotCMS. This class provides + * functionality to import content from CSV files, with support for both preview and publish + * operations, as well as multilingual content handling. + * + *

The processor implements both {@link JobProcessor} and {@link Cancellable} interfaces to + * provide job processing and cancellation capabilities. It's annotated with {@link Queue} to + * specify the queue name and {@link ExponentialBackoffRetryPolicy} to define retry behavior.

+ * + *

Key features:

+ *
    + *
  • Support for both preview and publish operations
  • + *
  • Multilingual content import capabilities
  • + *
  • Progress tracking during import
  • + *
  • Cancellation support
  • + *
  • Validation of import parameters and content
  • + *
+ * + * @see JobProcessor + * @see Cancellable + * @see Queue + * @see ExponentialBackoffRetryPolicy + */ +@Queue("importContentlets") +@NoRetryPolicy +public class ImportContentletsProcessor implements JobProcessor, Cancellable { + + private static final String PARAMETER_LANGUAGE = "language"; + private static final String PARAMETER_FIELDS = "fields"; + private static final String PARAMETER_USER_ID = "userId"; + private static final String PARAMETER_SITE_IDENTIFIER = "siteIdentifier"; + private static final String PARAMETER_SITE_NAME = "siteName"; + private static final String PARAMETER_CONTENT_TYPE = "contentType"; + private static final String PARAMETER_WORKFLOW_ACTION_ID = "workflowActionId"; + private static final String PARAMETER_CMD = Constants.CMD; + private static final String CMD_PREVIEW = com.dotmarketing.util.Constants.PREVIEW; + private static final String CMD_PUBLISH = com.dotmarketing.util.Constants.PUBLISH; + + private static final String LANGUAGE_CODE_HEADER = "languageCode"; + private static final String COUNTRY_CODE_HEADER = "countryCode"; + + /** + * Flag to track cancellation requests for the current import operation. + */ + private final AtomicBoolean cancellationRequested = new AtomicBoolean(false); + + /** + * Storage for metadata about the import operation results. + */ + private Map resultMetadata = new HashMap<>(); + + /** + * Processes a content import job. This method serves as the main entry point for the import + * operation and handles both preview and publish modes. + * + *

The method performs the following steps:

+ *
    + *
  1. Validates the input parameters and retrieves the necessary user information
  2. + *
  3. Retrieves and validates the import file
  4. + *
  5. Sets up progress tracking
  6. + *
  7. Executes either preview or publish operation based on the command
  8. + *
  9. Ensures proper progress updates throughout the process
  10. + *
+ * + * @param job The job containing import parameters and configuration + * @throws JobProcessingException if any error occurs during processing + */ + @Override + public void process(final Job job) throws JobProcessingException { + + final String command = getCommand(job); + + final User user; + try { + user = getUser(job); + } catch (Exception e) { + Logger.error(this, "Error retrieving user", e); + throw new JobProcessingException(job.id(), "Error retrieving user", e); + } + + Logger.info(this, String.format("Processing import contentlets job [%s], " + + "with command [%s] and user [%s]", job.id(), command, user.getUserId())); + + // Retrieving the import file + Optional tempFile = JobUtil.retrieveTempFile(job); + if (tempFile.isEmpty()) { + Logger.error(this.getClass(), "Unable to retrieve the import file. Quitting the job."); + throw new JobValidationException(job.id(), "Unable to retrieve the import file."); + } + + // Validate the job has the required data + validate(job); + + final var language = getLanguage(job); + final var fileToImport = tempFile.get().file; + final long totalLines = totalLines(job, fileToImport); + final Charset charset = language == -1 ? + Charset.defaultCharset() : FileUtil.detectEncodeType(fileToImport); + + // Create a progress callback function + final var progressTracker = job.progressTracker().orElseThrow( + () -> new JobProcessingException(job.id(), "Progress tracker not found") + ); + final LongConsumer progressCallback = processedLines -> { + float progressPercentage = (float) processedLines / totalLines; + // This ensures the progress is between 0.0 and 1.0 + progressTracker.updateProgress(Math.min(1.0f, Math.max(0.0f, progressPercentage))); + }; + + if (CMD_PREVIEW.equals(command)) { + handlePreview(job, language, fileToImport, charset, user, progressCallback); + } else if (CMD_PUBLISH.equals(command)) { + handlePublish(job, language, fileToImport, charset, user, progressCallback); + } + + if (!cancellationRequested.get()) { + // Ensure the progress is at 100% when the job is done + progressTracker.updateProgress(1.0f); + } + } + + /** + * Handles cancellation requests for the import operation. When called, it marks the operation + * for cancellation. + * + * @param job The job to be cancelled + * @throws JobCancellationException if any error occurs during cancellation + */ + @Override + public void cancel(Job job) throws JobCancellationException { + + Logger.info(this.getClass(), "Job cancellation requested: " + job.id()); + cancellationRequested.set(true); + + final var importId = jobIdToLong(job.id()); + ImportAuditUtil.cancelledImports.put(importId, Calendar.getInstance().getTime()); + } + + /** + * Retrieves metadata about the import operation results. + * + * @param job The job whose metadata is being requested + * @return A map containing result metadata, or an empty map if no metadata is available + */ + @Override + public Map getResultMetadata(Job job) { + + if (resultMetadata.isEmpty()) { + return Collections.emptyMap(); + } + + return resultMetadata; + } + + /** + * Handles the preview phase of content import. This method analyzes the CSV file and provides + * information about potential issues without actually importing the content. + * + * @param job The import job configuration + * @param language The target language for import + * @param fileToImport The CSV file to be imported + * @param charset The character encoding of the import file + * @param user The user performing the import + * @param progressCallback Callback for tracking import progress + */ + private void handlePreview(final Job job, long language, final File fileToImport, + final Charset charset, final User user, final LongConsumer progressCallback) { + + try { + try (Reader reader = new BufferedReader( + new InputStreamReader(new FileInputStream(fileToImport), charset))) { + + CsvReader csvReader = createCsvReader(reader); + CsvHeaderInfo headerInfo = processHeadersBasedOnLanguage(job, language, csvReader); + + final var previewResult = generatePreview(job, user, + headerInfo.headers, csvReader, headerInfo.languageCodeColumn, + headerInfo.countryCodeColumn, progressCallback); + resultMetadata = new HashMap<>(previewResult); + } + } catch (Exception e) { + + try { + HibernateUtil.rollbackTransaction(); + } catch (DotHibernateException he) { + Logger.error(this, he.getMessage(), he); + } + + final var errorMessage = "An error occurred when analyzing the CSV file."; + Logger.error(this, errorMessage, e); + throw new JobProcessingException(job.id(), errorMessage, e); + } + } + + /** + * Handles the publish phase of content import. This method performs the actual content import + * operation, creating or updating content based on the CSV file. + * + * @param job The import job configuration + * @param language The target language for import + * @param fileToImport The CSV file to be imported + * @param charset The character encoding of the import file + * @param user The user performing the import + * @param progressCallback Callback for tracking import progress + */ + private void handlePublish(final Job job, long language, final File fileToImport, + final Charset charset, final User user, final LongConsumer progressCallback) { + + AdminLogger.log( + ImportContentletsProcessor.class, "process", + "Importing Contentlets", user + ); + + try { + try (Reader reader = new BufferedReader( + new InputStreamReader(new FileInputStream(fileToImport), charset))) { + + CsvReader csvReader = createCsvReader(reader); + CsvHeaderInfo headerInfo = readPublishHeaders(language, csvReader); + + final var importResults = processFile(job, user, headerInfo.headers, csvReader, + headerInfo.languageCodeColumn, headerInfo.countryCodeColumn, + progressCallback); + resultMetadata = new HashMap<>(importResults); + } + } catch (Exception e) { + + try { + HibernateUtil.rollbackTransaction(); + } catch (DotHibernateException he) { + Logger.error(this, he.getMessage(), he); + } + + final var errorMessage = "An error occurred when importing the CSV file."; + Logger.error(this, errorMessage, e); + throw new JobProcessingException(job.id(), errorMessage, e); + } finally { + final var importId = jobIdToLong(job.id()); + ImportAuditUtil.cancelledImports.remove(importId); + } + } + + /** + * Reads and analyzes the content of the CSV import file to determine potential errors, + * inconsistencies or warnings, and provide the user with useful information regarding the + * contents of the file. + * + * @param job - The {@link Job} being processed. + * @param user - The {@link User} performing this action. + * @param csvHeaders - The headers that make up the CSV file. + * @param csvReader - The actual data contained in the CSV file. + * @param languageCodeHeaderColumn - The column name containing the language code. + * @param countryCodeHeaderColumn - The column name containing the country code. + * @param progressCallback - The callback function to update the progress of the job. + * @throws DotDataException An error occurred when analyzing the CSV file. + */ + private Map> generatePreview(final Job job, final User user, + final String[] csvHeaders, final CsvReader csvReader, + final int languageCodeHeaderColumn, int countryCodeHeaderColumn, + final LongConsumer progressCallback) throws DotDataException { + + final var currentSiteId = getSiteIdentifier(job); + final var currentSiteName = getSiteName(job); + final var contentType = getContentType(job); + final var fields = getFields(job); + final var language = getLanguage(job); + final var workflowActionId = getWorkflowActionId(job); + final var httpReq = JobUtil.generateMockRequest(user, currentSiteName); + + Logger.info(this, "-------- Starting Content Import Preview -------- "); + Logger.info(this, String.format("-> Content Type ID: %s", contentType)); + + return ImportUtil.importFile(0L, currentSiteId, contentType, fields, true, + (language == -1), user, language, csvHeaders, csvReader, languageCodeHeaderColumn, + countryCodeHeaderColumn, workflowActionId, httpReq, progressCallback); + } + + /** + * Executes the content import process after the review process has been run and displayed to + * the user. + * + * @param job - The {@link Job} being processed. + * @param user - The {@link User} performing this action. + * @param csvHeaders - The headers that make up the CSV file. + * @param csvReader - The actual data contained in the CSV file. + * @param languageCodeHeaderColumn - The column name containing the language code. + * @param countryCodeHeaderColumn - The column name containing the country code. + * @param progressCallback - The callback function to update the progress of the job. + * @return The status of the content import performed by dotCMS. This provides information + * regarding inconsistencies, errors, warnings and/or precautions to the user. + * @throws DotDataException An error occurred when importing the CSV file. + */ + private Map> processFile(final Job job, final User user, + final String[] csvHeaders, final CsvReader csvReader, + final int languageCodeHeaderColumn, final int countryCodeHeaderColumn, + final LongConsumer progressCallback) throws DotDataException { + + final var currentSiteId = getSiteIdentifier(job); + final var currentSiteName = getSiteName(job); + final var contentType = getContentType(job); + final var fields = getFields(job); + final var language = getLanguage(job); + final var workflowActionId = getWorkflowActionId(job); + final var httpReq = JobUtil.generateMockRequest(user, currentSiteName); + final var importId = jobIdToLong(job.id()); + + Logger.info(this, "-------- Starting Content Import Process -------- "); + Logger.info(this, String.format("-> Content Type ID: %s", contentType)); + + return ImportUtil.importFile(importId, currentSiteId, contentType, fields, false, + (language == -1), user, language, csvHeaders, csvReader, languageCodeHeaderColumn, + countryCodeHeaderColumn, workflowActionId, httpReq, progressCallback); + } + + /** + * Retrieve the command from the job parameters + * + * @param job input job + * @return the command from the job parameters, if not found, return the default value "preview" + */ + private String getCommand(final Job job) { + + if (!job.parameters().containsKey(PARAMETER_CMD)) { + return CMD_PREVIEW; + } + + return (String) job.parameters().get(PARAMETER_CMD); + } + + /** + * Retrieve the user from the job parameters + * + * @param job input job + * @return the user from the job parameters + * @throws DotDataException if an error occurs during the user retrieval + * @throws DotSecurityException if we don't have the necessary permissions to retrieve the user + */ + private User getUser(final Job job) throws DotDataException, DotSecurityException { + final var userId = (String) job.parameters().get(PARAMETER_USER_ID); + return APILocator.getUserAPI().loadUserById(userId); + } + + /** + * Retrieves the site identifier from the job parameters. + * + * @param job The job containing the parameters + * @return The site identifier string, or null if not present in parameters + */ + private String getSiteIdentifier(final Job job) { + return (String) job.parameters().get(PARAMETER_SITE_IDENTIFIER); + } + + /** + * Retrieves the site name from the job parameters. + * + * @param job The job containing the parameters + * @return The site name string, or null if not present in parameters + */ + private String getSiteName(final Job job) { + return (String) job.parameters().get(PARAMETER_SITE_NAME); + } + + /** + * Retrieves the content type from the job parameters. + * + * @param job The job containing the parameters + * @return The content type string, or null if not present in parameters + */ + private String getContentType(final Job job) { + return (String) job.parameters().get(PARAMETER_CONTENT_TYPE); + } + + /** + * Retrieves the workflow action ID from the job parameters. + * + * @param job The job containing the parameters + * @return The workflow action ID string, or null if not present in parameters + */ + private String getWorkflowActionId(final Job job) { + return (String) job.parameters().get(PARAMETER_WORKFLOW_ACTION_ID); + } + + /** + * Retrieves the language setting from the job parameters. Handles both string and long + * parameter types. + * + * @param job The job containing the parameters + * @return The language ID as a long, or -1 if not specified + */ + private long getLanguage(final Job job) { + + if (!job.parameters().containsKey(PARAMETER_LANGUAGE) + || job.parameters().get(PARAMETER_LANGUAGE) == null) { + return -1; + } + + final Object language = job.parameters().get(PARAMETER_LANGUAGE); + + if (language instanceof String) { + return Long.parseLong((String) language); + } + + return (long) language; + } + + /** + * Retrieves the fields array from the job parameters. + * + * @param job The job containing the parameters + * @return An array of field strings, or an empty array if no fields are specified + */ + public String[] getFields(final Job job) { + + if (!job.parameters().containsKey(PARAMETER_FIELDS) + || job.parameters().get(PARAMETER_FIELDS) == null) { + return new String[0]; + } + + final var fields = job.parameters().get(PARAMETER_FIELDS); + if (fields instanceof List) { + return ((List) fields).toArray(new String[0]); + } + + return (String[]) fields; + } + + /** + * Validates the job parameters and content type. Performs security checks to prevent + * unauthorized host imports. + * + * @param job The job to validate + * @throws JobValidationException if validation fails + * @throws JobProcessingException if an error occurs during content type validation + */ + private void validate(final Job job) { + + if (getContentType(job) != null && getContentType(job).isEmpty()) { + Logger.error(this.getClass(), "A Content Type is required"); + throw new JobValidationException(job.id(), "A Content Type is required"); + } else if (getWorkflowActionId(job) != null && getWorkflowActionId(job).isEmpty()) { + Logger.error(this.getClass(), "Workflow action type is required"); + throw new JobValidationException(job.id(), "Workflow action type is required"); + } + + // Security measure to prevent invalid attempts to import a host. + try { + final ContentType hostContentType = APILocator.getContentTypeAPI( + APILocator.systemUser()).find(Host.HOST_VELOCITY_VAR_NAME + ); + final boolean isHost = (hostContentType.id().equals(getContentType(job))); + if (isHost) { + Logger.error(this, "Invalid attempt to import a host."); + throw new JobValidationException(job.id(), "Invalid attempt to import a host."); + } + } catch (DotSecurityException | DotDataException e) { + throw new JobProcessingException(job.id(), "Error validating content type", e); + } + } + + /** + * Utility method to convert a job ID to a long value for internal processing. Uses FarmHash for + * efficient hash generation and distribution. + * + * @param jobId The string job identifier + * @return A long value representing the job ID + */ + public static long jobIdToLong(final String jobId) { + + // Use FarmHash for good distribution and speed + long hashValue = Hashing.farmHashFingerprint64() + .hashString(jobId, StandardCharsets.UTF_8).asLong(); + + // Ensure the value is positive (in the upper half of the bigint range) + return Math.abs(hashValue); + } + + /** + * Count the number of lines in the file + * + * @param dotTempFile temporary file + * @return the number of lines in the file + */ + private Long totalLines(final Job job, final File dotTempFile) { + + long totalCount; + try (BufferedReader reader = new BufferedReader(new FileReader(dotTempFile))) { + totalCount = reader.lines().count(); + if (totalCount == 0) { + Logger.info(this.getClass(), + "No lines in CSV import file: " + dotTempFile.getName()); + } + } catch (Exception e) { + Logger.error(this.getClass(), + "Error calculating total lines in CSV import file: " + e.getMessage()); + throw new JobProcessingException(job.id(), + "Error calculating total lines in CSV import file", e); + } + + return totalCount; + } + + /** + * Reads and processes headers for publishing operation. + * + * @param language The target language for import + * @param csvreader The CSV reader containing the file data + * @return CsvHeaderInfo containing processed header information + * @throws IOException if an error occurs reading the CSV file + */ + private CsvHeaderInfo readPublishHeaders(long language, CsvReader csvreader) + throws IOException { + if (language == -1 && csvreader.readHeaders()) { + return findLanguageColumnsInHeaders(csvreader.getHeaders()); + } + return new CsvHeaderInfo(null, -1, -1); + } + + /** + * Locates language-related columns in CSV headers. + * + * @param headers Array of CSV header strings + * @return CsvHeaderInfo containing the positions of language and country code columns + */ + private CsvHeaderInfo findLanguageColumnsInHeaders(String[] headers) { + + int languageCodeColumn = -1; + int countryCodeColumn = -1; + + for (int column = 0; column < headers.length; ++column) { + if (headers[column].equals(LANGUAGE_CODE_HEADER)) { + languageCodeColumn = column; + } + if (headers[column].equals(COUNTRY_CODE_HEADER)) { + countryCodeColumn = column; + } + if (languageCodeColumn != -1 && countryCodeColumn != -1) { + break; + } + } + + return new CsvHeaderInfo(headers, languageCodeColumn, countryCodeColumn); + } + + /** + * Creates a CSV reader with appropriate configuration for import operations. + * + * @param reader The source reader for CSV content + * @return A configured CsvReader instance + */ + private CsvReader createCsvReader(final Reader reader) { + CsvReader csvreader = new CsvReader(reader); + csvreader.setSafetySwitch(false); + return csvreader; + } + + /** + * Processes CSV headers based on the specified language configuration. + * + * @param job The current import job + * @param language The target language for import + * @param csvReader The CSV reader to process headers from + * @return CsvHeaderInfo containing processed header information + * @throws IOException if an error occurs reading the CSV file + */ + private CsvHeaderInfo processHeadersBasedOnLanguage(final Job job, final long language, + final CsvReader csvReader) throws IOException { + if (language != -1) { + validateLanguage(job, language); + return new CsvHeaderInfo(null, -1, -1); + } + + return processMultilingualHeaders(job, csvReader); + } + + /** + * Validates the language configuration for import operations. + * + * @param job The current import job + * @param language The language identifier to validate + */ + private void validateLanguage(Job job, long language) { + if (language == 0) { + final var errorMessage = "Please select a valid Language."; + Logger.error(this, errorMessage); + throw new JobValidationException(job.id(), errorMessage); + } + } + + /** + * Processes headers for multilingual content imports. + * + * @param job The current import job + * @param csvReader The CSV reader to process headers from + * @return CsvHeaderInfo containing processed multilingual header information + * @throws IOException if an error occurs reading the CSV file + */ + private CsvHeaderInfo processMultilingualHeaders(final Job job, final CsvReader csvReader) + throws IOException { + + if (getFields(job).length == 0) { + final var errorMessage = + "A key identifying the different Language versions of the same " + + "content must be defined when importing multilingual files."; + Logger.error(this, errorMessage); + throw new JobValidationException(job.id(), errorMessage); + } + + if (!csvReader.readHeaders()) { + final var errorMessage = "An error occurred when attempting to read the CSV file headers."; + Logger.error(this, errorMessage); + throw new JobProcessingException(job.id(), errorMessage); + } + + String[] headers = csvReader.getHeaders(); + return findLanguageColumns(job, headers); + } + + /** + * Locates language-related columns in CSV headers. + * + * @param headers Array of CSV header strings + * @return CsvHeaderInfo containing the positions of language and country code columns + */ + private CsvHeaderInfo findLanguageColumns(Job job, String[] headers) + throws JobProcessingException { + + int languageCodeColumn = -1; + int countryCodeColumn = -1; + + for (int column = 0; column < headers.length; ++column) { + if (headers[column].equals(LANGUAGE_CODE_HEADER)) { + languageCodeColumn = column; + } + if (headers[column].equals(COUNTRY_CODE_HEADER)) { + countryCodeColumn = column; + } + if (languageCodeColumn != -1 && countryCodeColumn != -1) { + break; + } + } + + validateLanguageColumns(job, languageCodeColumn, countryCodeColumn); + return new CsvHeaderInfo(headers, languageCodeColumn, countryCodeColumn); + } + + /** + * Performs validation of language columns for multilingual imports. + * + * @param job The current import job + * @param languageCodeColumn The index of the language code column + * @param countryCodeColumn The index of the country code column + * @throws JobValidationException if the required language columns are not found + */ + private void validateLanguageColumns(Job job, int languageCodeColumn, int countryCodeColumn) + throws JobProcessingException { + if (languageCodeColumn == -1 || countryCodeColumn == -1) { + final var errorMessage = "languageCode and countryCode fields are mandatory in the CSV " + + "file when importing multilingual content."; + Logger.error(this, errorMessage); + throw new JobValidationException(job.id(), errorMessage); + } + } + + /** + * Container class for CSV header information, particularly for handling language-related + * columns in multilingual imports. + */ + private static class CsvHeaderInfo { + + final String[] headers; + final int languageCodeColumn; + final int countryCodeColumn; + + CsvHeaderInfo(String[] headers, int languageCodeColumn, int countryCodeColumn) { + this.headers = headers; + this.languageCodeColumn = languageCodeColumn; + this.countryCodeColumn = countryCodeColumn; + } + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/processor/impl/LargeFileReader.java b/dotCMS/src/main/java/com/dotcms/jobs/business/processor/impl/LargeFileReader.java index a0f4025f5f66..36245a0c4ba6 100644 --- a/dotCMS/src/main/java/com/dotcms/jobs/business/processor/impl/LargeFileReader.java +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/processor/impl/LargeFileReader.java @@ -6,15 +6,13 @@ import com.dotcms.jobs.business.processor.JobProcessor; import com.dotcms.jobs.business.processor.ProgressTracker; import com.dotcms.jobs.business.processor.Queue; +import com.dotcms.jobs.business.util.JobUtil; import com.dotcms.rest.api.v1.temp.DotTempFile; -import com.dotcms.rest.api.v1.temp.TempFileAPI; -import com.dotmarketing.business.APILocator; import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.util.Logger; import io.vavr.control.Try; import java.io.BufferedReader; import java.io.FileReader; -import java.util.List; import java.util.Map; import java.util.Optional; @@ -36,7 +34,7 @@ public void process(Job job) { Logger.info(this.getClass(), "Processing job: " + job.id()); Map params = job.parameters(); - Optional tempFile = tempFile(params); + Optional tempFile = JobUtil.retrieveTempFile(job); if (tempFile.isEmpty()) { Logger.error(this.getClass(), "Unable to retrieve the temporary file. Quitting the job."); throw new DotRuntimeException("Unable to retrieve the temporary file."); @@ -181,36 +179,6 @@ Optional linesParam(Map params) { return Optional.of(nLines); } - /** - * Retrieve the temporary file from the parameters - * - * @param params input parameters - * @return the temporary file - */ - Optional tempFile(Map params) { - // Extract parameters - String tempFileId = (String) params.get("tempFileId"); - - final Object requestFingerPrintRaw = params.get("requestFingerPrint"); - if (!(requestFingerPrintRaw instanceof String)) { - Logger.error(this.getClass(), - "Parameter 'requestFingerPrint' is required and must be a string."); - return Optional.empty(); - } - final String requestFingerPrint = (String) requestFingerPrintRaw; - - // Retrieve the temporary file - final TempFileAPI tempFileAPI = APILocator.getTempFileAPI(); - final Optional tempFile = tempFileAPI.getTempFile(List.of(requestFingerPrint), - tempFileId); - if (tempFile.isEmpty()) { - Logger.error(this.getClass(), "Temporary file not found: " + tempFileId); - return Optional.empty(); - } - - return tempFile; - } - /** * Provide metadata for the job result. * @param job The job for which to provide metadata. diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/queue/JobQueue.java b/dotCMS/src/main/java/com/dotcms/jobs/business/queue/JobQueue.java index 45cdebfabcf9..af9d434bfcb7 100644 --- a/dotCMS/src/main/java/com/dotcms/jobs/business/queue/JobQueue.java +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/queue/JobQueue.java @@ -76,6 +76,36 @@ JobPaginatedResult getCompletedJobs(String queueName, LocalDateTime startDate, */ JobPaginatedResult getJobs(int page, int pageSize) throws JobQueueDataException; + /** + * Retrieves a list of active jobs, meaning jobs that are currently being processed. + * + * @param page The page number (for pagination). + * @param pageSize The number of items per page. + * @return A result object containing the list of active jobs and pagination information. + * @throws JobQueueDataException if there's a data storage error while fetching the jobs + */ + JobPaginatedResult getActiveJobs(int page, int pageSize) throws JobQueueDataException; + + /** + * Retrieves a list of completed jobs. + * + * @param page The page number (for pagination). + * @param pageSize The number of items per page. + * @return A result object containing the list of completed jobs and pagination information. + * @throws JobQueueDataException if there's a data storage error while fetching the jobs + */ + JobPaginatedResult getCompletedJobs(int page, int pageSize) throws JobQueueDataException; + + /** + * Retrieves a list of canceled jobs. + * + * @param page The page number (for pagination). + * @param pageSize The number of items per page. + * @return A result object containing the list of canceled jobs and pagination information. + * @throws JobQueueDataException if there's a data storage error while fetching the jobs + */ + JobPaginatedResult getCanceledJobs(int page, int pageSize) throws JobQueueDataException; + /** * Retrieves a list of failed jobs. * diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/queue/JobQueueProducer.java b/dotCMS/src/main/java/com/dotcms/jobs/business/queue/JobQueueProducer.java index 1596e9fb17d6..431b40fc33e5 100644 --- a/dotCMS/src/main/java/com/dotcms/jobs/business/queue/JobQueueProducer.java +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/queue/JobQueueProducer.java @@ -3,6 +3,7 @@ import com.dotmarketing.util.Config; import javax.enterprise.context.ApplicationScoped; import javax.enterprise.inject.Produces; +import javax.inject.Named; /** * This class is responsible for producing the JobQueue implementation used in the application. It @@ -22,17 +23,16 @@ public class JobQueueProducer { * * @return A JobQueue instance */ + @Named("queueProducer") @Produces - @ApplicationScoped public JobQueue produceJobQueue() { if (JOB_QUEUE_IMPLEMENTATION_TYPE.equals("postgres")) { return new PostgresJobQueue(); } - throw new IllegalStateException( - "Unknown job queue implementation type: " + JOB_QUEUE_IMPLEMENTATION_TYPE - ); + throw new IllegalStateException("Unknown job queue implementation type: " + JOB_QUEUE_IMPLEMENTATION_TYPE); + } } \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/queue/PostgresJobQueue.java b/dotCMS/src/main/java/com/dotcms/jobs/business/queue/PostgresJobQueue.java index 37bb0fa9ac1e..dfc179106563 100644 --- a/dotCMS/src/main/java/com/dotcms/jobs/business/queue/PostgresJobQueue.java +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/queue/PostgresJobQueue.java @@ -75,7 +75,7 @@ public class PostgresJobQueue implements JobQueue { + "ORDER BY priority DESC, created_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED) " + "RETURNING *"; - private static final String GET_ACTIVE_JOBS_QUERY = + private static final String GET_ACTIVE_JOBS_QUERY_FOR_QUEUE = "WITH total AS (SELECT COUNT(*) AS total_count " + " FROM job WHERE queue_name = ? AND state IN (?, ?) " + "), " + @@ -85,7 +85,7 @@ public class PostgresJobQueue implements JobQueue { ") " + "SELECT p.*, t.total_count FROM total t LEFT JOIN paginated_data p ON true"; - private static final String GET_COMPLETED_JOBS_QUERY = + private static final String GET_COMPLETED_JOBS_QUERY_FOR_QUEUE = "WITH total AS (SELECT COUNT(*) AS total_count " + " FROM job WHERE queue_name = ? AND state = ? AND completed_at BETWEEN ? AND ? " + "), " + @@ -95,15 +95,15 @@ public class PostgresJobQueue implements JobQueue { ") " + "SELECT p.*, t.total_count FROM total t LEFT JOIN paginated_data p ON true"; - private static final String GET_FAILED_JOBS_QUERY = + private static final String GET_JOBS_QUERY_BY_STATE = "WITH total AS (" + " SELECT COUNT(*) AS total_count FROM job " + - " WHERE state = ? " + + " WHERE state IN $??$ " + "), " + "paginated_data AS (" + " SELECT * " + - " FROM job WHERE state = ? " + - " ORDER BY updated_at DESC " + + " FROM job WHERE state IN $??$ " + + " ORDER BY $ORDER_BY$ DESC " + " LIMIT ? OFFSET ? " + ") " + "SELECT p.*, t.total_count " + @@ -152,6 +152,9 @@ public class PostgresJobQueue implements JobQueue { private static final String COLUMN_TOTAL_COUNT = "total_count"; + private static final String REPLACE_TOKEN_PARAMETERS = "$??$"; + private static final String REPLACE_TOKEN_ORDER_BY = "$ORDER_BY$"; + /** * Jackson mapper configuration and lazy initialized instance. */ @@ -246,7 +249,7 @@ public JobPaginatedResult getActiveJobs(final String queueName, final int page, try { DotConnect dc = new DotConnect(); - dc.setSQL(GET_ACTIVE_JOBS_QUERY); + dc.setSQL(GET_ACTIVE_JOBS_QUERY_FOR_QUEUE); dc.addParam(queueName); dc.addParam(JobState.PENDING.name()); dc.addParam(JobState.RUNNING.name()); @@ -258,8 +261,10 @@ public JobPaginatedResult getActiveJobs(final String queueName, final int page, return jobPaginatedResult(page, pageSize, dc); } catch (DotDataException e) { - Logger.error(this, "Database error while fetching active jobs", e); - throw new JobQueueDataException("Database error while fetching active jobs", e); + Logger.error(this, + "Database error while fetching active jobs by queue", e); + throw new JobQueueDataException( + "Database error while fetching active jobs by queue", e); } } @@ -271,7 +276,7 @@ public JobPaginatedResult getCompletedJobs(final String queueName, try { DotConnect dc = new DotConnect(); - dc.setSQL(GET_COMPLETED_JOBS_QUERY); + dc.setSQL(GET_COMPLETED_JOBS_QUERY_FOR_QUEUE); dc.addParam(queueName); dc.addParam(JobState.COMPLETED.name()); dc.addParam(Timestamp.valueOf(startDate)); @@ -285,8 +290,10 @@ public JobPaginatedResult getCompletedJobs(final String queueName, return jobPaginatedResult(page, pageSize, dc); } catch (DotDataException e) { - Logger.error(this, "Database error while fetching completed jobs", e); - throw new JobQueueDataException("Database error while fetching completed jobs", e); + Logger.error(this, + "Database error while fetching completed jobs by queue", e); + throw new JobQueueDataException( + "Database error while fetching completed jobs by queue", e); } } @@ -307,13 +314,92 @@ public JobPaginatedResult getJobs(final int page, final int pageSize) } } + @Override + public JobPaginatedResult getActiveJobs(final int page, final int pageSize) + throws JobQueueDataException { + + try { + + var query = GET_JOBS_QUERY_BY_STATE + .replace(REPLACE_TOKEN_PARAMETERS, "(?, ?)") + .replace(REPLACE_TOKEN_ORDER_BY, "created_at"); + + DotConnect dc = new DotConnect(); + dc.setSQL(query); + dc.addParam(JobState.PENDING.name()); + dc.addParam(JobState.RUNNING.name()); + dc.addParam(JobState.PENDING.name()); // Repeated for paginated_data CTE + dc.addParam(JobState.RUNNING.name()); + dc.addParam(pageSize); + dc.addParam((page - 1) * pageSize); + + return jobPaginatedResult(page, pageSize, dc); + } catch (DotDataException e) { + Logger.error(this, "Database error while fetching active jobs", e); + throw new JobQueueDataException("Database error while fetching active jobs", e); + } + } + + @Override + public JobPaginatedResult getCompletedJobs(final int page, final int pageSize) + throws JobQueueDataException { + + try { + + var query = GET_JOBS_QUERY_BY_STATE + .replace(REPLACE_TOKEN_PARAMETERS, "(?)") + .replace(REPLACE_TOKEN_ORDER_BY, "completed_at"); + + DotConnect dc = new DotConnect(); + dc.setSQL(query); + dc.addParam(JobState.COMPLETED.name()); + dc.addParam(JobState.COMPLETED.name()); // Repeated for paginated_data CTE + dc.addParam(pageSize); + dc.addParam((page - 1) * pageSize); + + return jobPaginatedResult(page, pageSize, dc); + } catch (DotDataException e) { + Logger.error(this, "Database error while fetching completed jobs", e); + throw new JobQueueDataException("Database error while fetching completed jobs", e); + } + } + + @Override + public JobPaginatedResult getCanceledJobs(final int page, final int pageSize) + throws JobQueueDataException { + + try { + + var query = GET_JOBS_QUERY_BY_STATE + .replace(REPLACE_TOKEN_PARAMETERS, "(?)") + .replace(REPLACE_TOKEN_ORDER_BY, "completed_at"); + + DotConnect dc = new DotConnect(); + dc.setSQL(query); + dc.addParam(JobState.CANCELED.name()); + dc.addParam(JobState.CANCELED.name()); // Repeated for paginated_data CTE + dc.addParam(pageSize); + dc.addParam((page - 1) * pageSize); + + return jobPaginatedResult(page, pageSize, dc); + } catch (DotDataException e) { + Logger.error(this, "Database error while fetching cancelled jobs", e); + throw new JobQueueDataException("Database error while fetching cancelled jobs", e); + } + } + @Override public JobPaginatedResult getFailedJobs(final int page, final int pageSize) throws JobQueueDataException { try { + + var query = GET_JOBS_QUERY_BY_STATE + .replace(REPLACE_TOKEN_PARAMETERS, "(?)") + .replace(REPLACE_TOKEN_ORDER_BY, "updated_at"); + DotConnect dc = new DotConnect(); - dc.setSQL(GET_FAILED_JOBS_QUERY); + dc.setSQL(query); dc.addParam(JobState.FAILED.name()); dc.addParam(JobState.FAILED.name()); // Repeated for paginated_data CTE dc.addParam(pageSize); diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/util/JobUtil.java b/dotCMS/src/main/java/com/dotcms/jobs/business/util/JobUtil.java new file mode 100644 index 000000000000..6a6ec3650e4e --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/util/JobUtil.java @@ -0,0 +1,105 @@ +package com.dotcms.jobs.business.util; + +import com.dotcms.api.web.HttpServletRequestThreadLocal; +import com.dotcms.jobs.business.job.Job; +import com.dotcms.mock.request.FakeHttpRequest; +import com.dotcms.mock.request.MockHeaderRequest; +import com.dotcms.mock.request.MockSessionRequest; +import com.dotcms.rest.api.v1.temp.DotTempFile; +import com.dotcms.rest.api.v1.temp.TempFileAPI; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; +import com.dotmarketing.util.WebKeys; +import com.liferay.portal.model.User; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import javax.servlet.http.HttpServletRequest; + +/** + * Utility class for job-related operations. + */ +public class JobUtil { + + private JobUtil() { + throw new IllegalStateException("Utility class"); + } + + /** + * Retrieves the temporary file associated with the given job. + * + * @param job The job containing the temporary file information in its parameters. + * @return An Optional containing the DotTempFile if found, or an empty Optional if not found or + * if an error occurs. + */ + public static Optional retrieveTempFile(final Job job) { + + if (job == null) { + Logger.error(JobUtil.class, "Job cannot be null"); + return Optional.empty(); + } + + Map params = job.parameters(); + if (params == null) { + Logger.error(JobUtil.class, "Job parameters cannot be null"); + return Optional.empty(); + } + + // Extract parameters + String tempFileId = (String) params.get("tempFileId"); + if (tempFileId == null) { + Logger.error(JobUtil.class, "Parameter 'tempFileId' is required"); + return Optional.empty(); + } + + final Object requestFingerPrintRaw = params.get("requestFingerPrint"); + if (!(requestFingerPrintRaw instanceof String)) { + Logger.error(JobUtil.class, + "Parameter 'requestFingerPrint' is required and must be a string."); + return Optional.empty(); + } + final String requestFingerPrint = (String) requestFingerPrintRaw; + + // Retrieve the temporary file + final TempFileAPI tempFileAPI = APILocator.getTempFileAPI(); + final Optional tempFile = tempFileAPI.getTempFile( + List.of(requestFingerPrint), tempFileId + ); + if (tempFile.isEmpty()) { + Logger.error(JobUtil.class, "Temporary file not found: " + tempFileId); + } + + return tempFile; + } + + /** + * Utility method to create or retrieve an HttpServletRequest when needed from job processors. + * Uses thread-local request if available, otherwise creates a mock request with the specified + * user and site information. + * + * @param user The user performing the import + * @param siteName The name of the site for the import + * @return An HttpServletRequest instance configured for the import operation + */ + public static HttpServletRequest generateMockRequest(final User user, final String siteName) { + + if (null != HttpServletRequestThreadLocal.INSTANCE.getRequest()) { + return HttpServletRequestThreadLocal.INSTANCE.getRequest(); + } + + final HttpServletRequest requestProxy = new MockSessionRequest( + new MockHeaderRequest( + new FakeHttpRequest(siteName, "/").request(), + "referer", + "https://" + siteName + "/fakeRefer") + .request()); + requestProxy.setAttribute(WebKeys.CMS_USER, user); + requestProxy.getSession().setAttribute(WebKeys.CMS_USER, user); + requestProxy.setAttribute(com.liferay.portal.util.WebKeys.USER_ID, + UtilMethods.extractUserIdOrNull(user)); + + return requestProxy; + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/BinaryMap.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/BinaryMap.java index ea132e8392e3..68148e147cd4 100644 --- a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/BinaryMap.java +++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/content/BinaryMap.java @@ -1,5 +1,6 @@ package com.dotcms.rendering.velocity.viewtools.content; +import com.fasterxml.jackson.annotation.JsonIgnore; import java.io.File; import java.io.Serializable; import java.util.Map; @@ -254,9 +255,10 @@ public String getThumbnailUri(Integer width, Integer height, String background){ } /** - * This is the underneath Java File. Becareful when working with this object as you can manipulate it. + * This is the underneath Java File. Be careful when working with this object as you can manipulate it. * @return the file */ + @JsonIgnore public File getFile() { return Sneaky.sneak(()->content.getBinary(field.variable())); } diff --git a/dotCMS/src/main/java/com/dotcms/rest/CountView.java b/dotCMS/src/main/java/com/dotcms/rest/CountView.java new file mode 100644 index 000000000000..7ae65cf1ebc8 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/CountView.java @@ -0,0 +1,18 @@ +package com.dotcms.rest; + +/** + * This class encapsulates a single count + * @author jsanca + */ +public class CountView { + + private int count; + + public CountView(final int count) { + this.count = count; + } + + public int getCount() { + return count; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/ResponseEntityCountView.java b/dotCMS/src/main/java/com/dotcms/rest/ResponseEntityCountView.java new file mode 100644 index 000000000000..b12a8ecf7b90 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/ResponseEntityCountView.java @@ -0,0 +1,11 @@ +package com.dotcms.rest; + +/** + * This class encapsulates the {@link javax.ws.rs.core.Response} object to include the expected Integer and related + * @author jsanca + */ +public class ResponseEntityCountView extends ResponseEntityView { + public ResponseEntityCountView(final CountView entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java index f966d2653433..ca36973aad2c 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/analytics/content/ContentAnalyticsResource.java @@ -5,13 +5,11 @@ import com.dotcms.analytics.model.ResultSetItem; import com.dotcms.analytics.track.collectors.WebEventsCollectorServiceFactory; import com.dotcms.analytics.track.matchers.UserCustomDefinedRequestMatcher; -import com.dotcms.cdi.CDIUtils; import com.dotcms.rest.InitDataObject; import com.dotcms.rest.ResponseEntityStringView; import com.dotcms.rest.WebResource; import com.dotcms.rest.annotation.NoCache; import com.dotcms.util.DotPreconditions; -import com.dotmarketing.business.APILocator; import com.dotmarketing.util.Logger; import com.dotmarketing.util.UUIDUtil; import com.google.common.annotations.VisibleForTesting; @@ -21,8 +19,11 @@ import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import org.glassfish.jersey.server.JSONP; - +import java.io.Serializable; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import javax.inject.Inject; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.Consumes; @@ -31,12 +32,7 @@ import javax.ws.rs.Produces; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; -import java.io.Serializable; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.stream.Collectors; +import org.glassfish.jersey.server.JSONP; /** * Resource class that exposes endpoints to query content analytics data. @@ -47,6 +43,7 @@ * @author Jose Castro * @since Sep 13th, 2024 */ + @Path("/v1/analytics/content") @Tag(name = "Content Analytics", description = "Endpoints that exposes information related to how dotCMS content is accessed and interacted with by users.") @@ -57,13 +54,7 @@ public class ContentAnalyticsResource { private final WebResource webResource; private final ContentAnalyticsAPI contentAnalyticsAPI; - @SuppressWarnings("unused") - public ContentAnalyticsResource() { - this(CDIUtils.getBean(ContentAnalyticsAPI.class).orElseGet(APILocator::getContentAnalyticsAPI)); - } - - //@Inject - @VisibleForTesting + @Inject public ContentAnalyticsResource(final ContentAnalyticsAPI contentAnalyticsAPI) { this(new WebResource(), contentAnalyticsAPI); } @@ -98,7 +89,7 @@ public ContentAnalyticsResource(final WebResource webResource, " \"measures\": [\n" + " \"request.count\"\n" + " ],\n" + - " \"orders\": \"request.count DESC\",\n" + + " \"order\": \"request.count DESC\",\n" + " \"dimensions\": [\n" + " \"request.url\",\n" + " \"request.pageId\",\n" + diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/ContentReferenceView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/ContentReferenceView.java new file mode 100644 index 000000000000..8b7c0e4d99b5 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/ContentReferenceView.java @@ -0,0 +1,36 @@ +package com.dotcms.rest.api.v1.content; + +import com.dotmarketing.portlets.containers.model.Container; +import com.dotmarketing.portlets.containers.model.ContainerView; +import com.dotmarketing.portlets.htmlpageasset.model.IHTMLPage; + +/** + * This class is used to represent a reference to a content item. + * @author + */ +public class ContentReferenceView { + + private final IHTMLPage page; + private final ContainerView container; + private final String personaName; + + public ContentReferenceView(final IHTMLPage page, + final ContainerView container, + final String personaName) { + this.page = page; + this.container = container; + this.personaName = personaName; + } + + public IHTMLPage getPage() { + return page; + } + + public ContainerView getContainer() { + return container; + } + + public String getPersonaName() { + return personaName; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/ContentResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/ContentResource.java index 4c32c168a307..7a237dedf344 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/ContentResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/ContentResource.java @@ -6,9 +6,11 @@ import com.dotcms.mock.response.MockHttpResponse; import com.dotcms.rendering.velocity.viewtools.content.util.ContentUtils; import com.dotcms.rest.AnonymousAccess; +import com.dotcms.rest.CountView; import com.dotcms.rest.InitDataObject; import com.dotcms.rest.MapToContentletPopulator; import com.dotcms.rest.ResponseEntityContentletView; +import com.dotcms.rest.ResponseEntityCountView; import com.dotcms.rest.ResponseEntityView; import com.dotcms.rest.WebResource; import com.dotcms.rest.annotation.NoCache; @@ -26,12 +28,15 @@ import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotSecurityException; import com.dotmarketing.portlets.categories.model.Category; +import com.dotmarketing.portlets.containers.model.Container; +import com.dotmarketing.portlets.containers.model.ContainerView; import com.dotmarketing.portlets.contentlet.business.ContentletAPI; import com.dotmarketing.portlets.contentlet.business.DotContentletValidationException; import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.portlets.contentlet.model.ContentletVersionInfo; import com.dotmarketing.portlets.contentlet.model.IndexPolicy; import com.dotmarketing.portlets.contentlet.transform.DotTransformerBuilder; +import com.dotmarketing.portlets.htmlpageasset.model.IHTMLPage; import com.dotmarketing.portlets.languagesmanager.model.Language; import com.dotmarketing.portlets.structure.model.ContentletRelationships; import com.dotmarketing.portlets.structure.model.Relationship; @@ -50,14 +55,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.vavr.Lazy; import io.vavr.control.Try; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.function.LongSupplier; -import java.util.function.Supplier; -import java.util.stream.Collectors; +import org.glassfish.jersey.server.JSONP; + import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.Consumes; @@ -72,7 +71,15 @@ import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; -import org.glassfish.jersey.server.JSONP; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.LongSupplier; +import java.util.function.Supplier; +import java.util.stream.Collectors; /** * Version 1 of the Content resource, to interact and retrieve contentlets @@ -84,6 +91,7 @@ public class ContentResource { private final WebResource webResource; private final ContentletAPI contentletAPI; private final IdentifierAPI identifierAPI; + private final LanguageWebAPI languageWebAPI; private final Lazy isDefaultContentToDefaultLanguageEnabled = Lazy.of( () -> Config.getBooleanProperty("DEFAULT_CONTENT_TO_DEFAULT_LANGUAGE", false)); @@ -93,17 +101,20 @@ public ContentResource() { this(new WebResource(), APILocator.getContentletAPI(), - APILocator.getIdentifierAPI()); + APILocator.getIdentifierAPI(), + WebAPILocator.getLanguageWebAPI()); } @VisibleForTesting public ContentResource(final WebResource webResource, final ContentletAPI contentletAPI, - final IdentifierAPI identifierAPI) { + final IdentifierAPI identifierAPI, + final LanguageWebAPI languageWebAPI) { this.webResource = webResource; this.contentletAPI = contentletAPI; this.identifierAPI = identifierAPI; + this.languageWebAPI = languageWebAPI; } /** @@ -139,7 +150,7 @@ public final ResponseEntityContentletView saveDraft(@Context final HttpServletRe final long languageId = LanguageUtil.getLanguageId(language); final PageMode mode = PageMode.get(request); final Contentlet contentlet = this.getContentlet(inode, identifier, languageId, - () -> WebAPILocator.getLanguageWebAPI().getLanguage(request).getId(), contentForm, + () -> this.languageWebAPI.getLanguage(request).getId(), contentForm, initDataObject.getUser(), mode); if (UtilMethods.isSet(indexPolicy)) { contentlet.setIndexPolicy(IndexPolicy.parseIndexPolicy(indexPolicy)); @@ -284,7 +295,6 @@ public Response getContent(@Context HttpServletRequest request, Logger.debug(this, () -> "Finding the contentlet: " + inodeOrIdentifier); - final LanguageWebAPI languageWebAPI = WebAPILocator.getLanguageWebAPI(); final LongSupplier sessionLanguageSupplier = ()-> languageWebAPI.getLanguage(request).getId(); final PageMode mode = PageMode.get(request); final long testLangId = LanguageUtil.getLanguageId(language); @@ -301,6 +311,84 @@ public Response getContent(@Context HttpServletRequest request, WorkflowHelper.getInstance().contentletToMap(contentlet))).build(); } + /** + * Retrieves the count of a Contentlet based on the identifier + * If the contentlet exist or not, does not matter, the count will be returned + * + * @param request The current {@link HttpServletRequest} instance. + * @param response The current {@link HttpServletResponse} instance. + * + * @return The {@link ResponseEntityCountView} + */ + @GET + @Path("/{identifier}/references/count") + @Produces(MediaType.APPLICATION_JSON) + public ResponseEntityCountView getAllContentletReferencesCount( + @Context HttpServletRequest request, + @Context final HttpServletResponse response, + @PathParam("identifier") final String identifier + ) throws DotDataException { + + new WebResource.InitBuilder(this.webResource) + .requestAndResponse(request, response) + .requiredAnonAccess(AnonymousAccess.READ) + .init(); + + Logger.debug(this, () -> "Finding the counts for contentlet id: " + identifier); + final Optional count = this.contentletAPI.getAllContentletReferencesCount(identifier); + + if (!count.isPresent()) { + throw new DoesNotExistException("The contentlet with identifier " + identifier + " does not exist"); + } + + return new ResponseEntityCountView(new CountView(count.get())); + } + + /** + * Retrieves the references of a Contentlet based on the identifier + * If the contentlet does not exist, 404 + * + * @param request The current {@link HttpServletRequest} instance. + * @param response The current {@link HttpServletResponse} instance. + * + * @return The {@link ResponseEntityView>} + */ + @GET + @Path("/{inodeOrIdentifier}/references") + @Produces(MediaType.APPLICATION_JSON) + public ResponseEntityView> getContentletReferences( + @Context HttpServletRequest request, + @Context final HttpServletResponse response, + @PathParam("inodeOrIdentifier") final String inodeOrIdentifier, + @DefaultValue("") @QueryParam("language") final String language + ) throws DotDataException, DotSecurityException { + + final User user = new WebResource.InitBuilder(this.webResource) + .requestAndResponse(request, response) + .requiredAnonAccess(AnonymousAccess.READ) + .init().getUser(); + + Logger.debug(this, () -> "Finding the references for contentlet id: " + inodeOrIdentifier); + + final LongSupplier sessionLanguageSupplier = ()-> languageWebAPI.getLanguage(request).getId(); + final PageMode mode = PageMode.get(request); + final long testLangId = LanguageUtil.getLanguageId(language); + final long languageId = testLangId <=0 ? sessionLanguageSupplier.getAsLong() : testLangId; + + final Contentlet contentlet = this.resolveContentletOrFallBack(inodeOrIdentifier, mode, languageId, user); + + final List> references = this.contentletAPI.getContentletReferences(contentlet, user, mode.respectAnonPerms); + final List contentReferenceViews = Objects.nonNull(references)? + references.stream() + .map(reference -> new ContentReferenceView( + (IHTMLPage) reference.get("page"), + new ContainerView((Container) reference.get("container")), + (String) reference.get("personaName"))) + .collect(Collectors.toList()): + List.of(); + return new ResponseEntityView<>(contentReferenceViews); + } + @GET @Path("/_canlock/{inodeOrIdentifier}") @Produces(MediaType.APPLICATION_JSON) @@ -317,7 +405,7 @@ public Response canLockContent(@Context HttpServletRequest request, @Context fin final PageMode mode = PageMode.get(request); final long testLangId = LanguageUtil.getLanguageId(language); - final long languageId = testLangId <=0 ? WebAPILocator.getLanguageWebAPI().getLanguage(request).getId() : testLangId; + final long languageId = testLangId <=0 ? this.languageWebAPI.getLanguage(request).getId() : testLangId; final Contentlet contentlet = this.resolveContentletOrFallBack(inodeOrIdentifier, mode, languageId, user); @@ -463,7 +551,7 @@ public Response pullRelated(@Context final HttpServletRequest request, ", limit: " + pullRelatedForm.getLimit() + ", offset: " + pullRelatedForm.getOffset() + ", orderby: " + pullRelatedForm.getOrderBy()); final User user = initData.getUser(); - final Language language = WebAPILocator.getLanguageWebAPI().getLanguage(request); + final Language language = this.languageWebAPI.getLanguage(request); final long langId = UtilMethods.isSet(pullRelatedForm.getCondition()) && pullRelatedForm.getCondition().contains("languageId") ? -1 : language.getId(); final String tmDate = request.getSession (false) != null? @@ -500,4 +588,7 @@ public Response pullRelated(@Context final HttpServletRequest request, Logger.debug(this, ()-> "The field:" + pullRelatedForm.getFieldVariable() + " is not a relationship"); throw new IllegalArgumentException("The field:" + pullRelatedForm.getFieldVariable() + " is not a relationship"); } + + + } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/job/JobParams.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/job/JobParams.java index 16737ceae606..f95c7e606373 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/job/JobParams.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/job/JobParams.java @@ -25,7 +25,7 @@ public class JobParams { private String jsonParams; @FormDataParam("params") - private Map params; + private Map params; public InputStream getFileInputStream() { return fileInputStream; @@ -47,17 +47,14 @@ public void setJsonParams(String jsonParams) { this.jsonParams = jsonParams; } - public Map getParams() throws JsonProcessingException { - if (null == params) { - if (null == jsonParams){ - throw new IllegalArgumentException("Job Params must be passed as a json object in the params field."); - } + public Map getParams() throws JsonProcessingException { + if (null == params && (null != jsonParams && !jsonParams.isEmpty())) { params = new ObjectMapper().readValue(jsonParams, Map.class); } return params; } - public void setParams(Map params) { + public void setParams(Map params) { this.params = params; } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/job/JobQueueHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/job/JobQueueHelper.java index cf990ad0fac9..1902207ce186 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/job/JobQueueHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/job/JobQueueHelper.java @@ -12,11 +12,13 @@ import com.dotcms.rest.api.v1.temp.DotTempFile; import com.dotcms.rest.api.v1.temp.TempFileAPI; import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.web.WebAPILocator; import com.dotmarketing.exception.DoesNotExistException; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.util.Logger; import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.annotations.VisibleForTesting; +import com.liferay.portal.model.User; import java.io.InputStream; import java.lang.reflect.Constructor; import java.time.format.DateTimeFormatter; @@ -114,24 +116,81 @@ public JobQueueHelper(JobQueueManagerAPI jobQueueManagerAPI, JobProcessorScanner */ @VisibleForTesting void registerProcessor(final String queueName, final Class processor){ - jobQueueManagerAPI.registerProcessor(queueName.toLowerCase(), processor); + jobQueueManagerAPI.registerProcessor(queueName, processor); } /** - * creates a job - * @param queueName - * @param form - * @return jobId - * @throws JsonProcessingException - * @throws DotDataException + * Creates a job + * + * @param queueName The name of the queue + * @param form The request form with the job parameters + * @param user The user requesting to create the job + * @param request The request object + * @return jobId The ID of the created job + * @throws JsonProcessingException If there is an error processing the request form parameters + * @throws DotDataException If there is an error creating the job */ - String createJob(String queueName, JobParams form, HttpServletRequest request) - throws JsonProcessingException, DotDataException { + String createJob(final String queueName, final JobParams form, final User user, + final HttpServletRequest request) throws JsonProcessingException, DotDataException { + + final HashMap in; + + final var parameters = form.getParams(); + if (parameters != null) { + in = new HashMap<>(parameters); + } else { + in = new HashMap<>(); + } - final HashMap in = new HashMap<>(form.getParams()); handleUploadIfPresent(form, in, request); + return createJob(queueName, in, user, request); + } + + /** + * Creates a job with the given parameters + * + * @param queueName The name of the queue + * @param parameters The job parameters + * @param user The user requesting to create the job + * @param request The request object + * @return jobId The ID of the created job + * @throws DotDataException If there is an error creating the job + */ + String createJob(final String queueName, final Map parameters, final User user, + final HttpServletRequest request) throws DotDataException { + + final HashMap in; + if (parameters != null) { + in = new HashMap<>(parameters); + } else { + in = new HashMap<>(); + } + return createJob(queueName, in, user, request); + } + + /** + * Creates a job with the given parameters + * + * @param queueName The name of the queue + * @param parameters The job parameters + * @param user The user requesting to create the job + * @param request The request object + * @return jobId The ID of the created job + * @throws DotDataException If there is an error creating the job + */ + private String createJob(final String queueName, final HashMap parameters, + final User user, final HttpServletRequest request) throws DotDataException { + + // Get the current host and include it in the job params + final var currentHost = WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request); + parameters.put("siteName", currentHost.getHostname()); + parameters.put("siteIdentifier", currentHost.getIdentifier()); + + // Including the user id in the job params + parameters.put("userId", user.getUserId()); + try { - return jobQueueManagerAPI.createJob(queueName.toLowerCase(), Map.copyOf(in)); + return jobQueueManagerAPI.createJob(queueName, Map.copyOf(parameters)); } catch (JobProcessorNotFoundException e) { Logger.error(this.getClass(), "Error creating job", e); throw new DoesNotExistException(e.getMessage()); @@ -179,25 +238,41 @@ void watchJob(String jobId, Consumer watcher) { * @return JobPaginatedResult * @throws DotDataException if there's an error fetching the jobs */ + JobPaginatedResult getActiveJobs(String queueName, int page, int pageSize) { + try { + return jobQueueManagerAPI.getActiveJobs(queueName, page, pageSize); + } catch (JobQueueDataException e) { + Logger.error(this.getClass(), "Error fetching active jobs", e); + } + return JobPaginatedResult.builder().build(); + } + + /** + * Retrieves a list of jobs. + * + * @param page The page number + * @param pageSize The number of jobs per page + * @return A result object containing the list of jobs and pagination information. + */ JobPaginatedResult getJobs(int page, int pageSize) { try { return jobQueueManagerAPI.getJobs(page, pageSize); - } catch (DotDataException e){ + } catch (DotDataException e) { Logger.error(this.getClass(), "Error fetching jobs", e); } return JobPaginatedResult.builder().build(); } /** - * Retrieves a list of jobs. - * @param page The page number + * Retrieves a list of active jobs, meaning jobs that are currently being processed. + * + * @param page The page number * @param pageSize The number of jobs per page - * @return JobPaginatedResult - * @throws DotDataException if there's an error fetching the jobs + * @return A result object containing the list of active jobs and pagination information. */ - JobPaginatedResult getActiveJobs(String queueName, int page, int pageSize) { + JobPaginatedResult getActiveJobs(int page, int pageSize) { try { - return jobQueueManagerAPI.getActiveJobs(queueName.toLowerCase(), page, pageSize); + return jobQueueManagerAPI.getActiveJobs(page, pageSize); } catch (JobQueueDataException e) { Logger.error(this.getClass(), "Error fetching active jobs", e); } @@ -205,11 +280,43 @@ JobPaginatedResult getActiveJobs(String queueName, int page, int pageSize) { } /** - * Retrieves a list of completed jobs for a specific queue within a date range. + * Retrieves a list of completed jobs + * + * @param page The page number + * @param pageSize The number of jobs per page + * @return A result object containing the list of completed jobs and pagination information. + */ + JobPaginatedResult getCompletedJobs(int page, int pageSize) { + try { + return jobQueueManagerAPI.getCompletedJobs(page, pageSize); + } catch (JobQueueDataException e) { + Logger.error(this.getClass(), "Error fetching completed jobs", e); + } + return JobPaginatedResult.builder().build(); + } + + /** + * Retrieves a list of canceled jobs + * + * @param page The page number + * @param pageSize The number of jobs per page + * @return A result object containing the list of canceled jobs and pagination information. + */ + JobPaginatedResult getCanceledJobs(int page, int pageSize) { + try { + return jobQueueManagerAPI.getCanceledJobs(page, pageSize); + } catch (JobQueueDataException e) { + Logger.error(this.getClass(), "Error fetching canceled jobs", e); + } + return JobPaginatedResult.builder().build(); + } + + /** + * Retrieves a list of failed jobs + * * @param page The page number * @param pageSize The number of jobs per page * @return A result object containing the list of completed jobs and pagination information. - * @throws JobQueueDataException if there's an error fetching the jobs */ JobPaginatedResult getFailedJobs(int page, int pageSize) { try { @@ -217,7 +324,7 @@ JobPaginatedResult getFailedJobs(int page, int pageSize) { } catch (JobQueueDataException e) { Logger.error(this.getClass(), "Error fetching failed jobs", e); } - return JobPaginatedResult.builder().build(); + return JobPaginatedResult.builder().build(); } /** diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/job/JobQueueResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/job/JobQueueResource.java index fc15b93c9714..c298d24d50a6 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/job/JobQueueResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/job/JobQueueResource.java @@ -1,6 +1,5 @@ package com.dotcms.rest.api.v1.job; -import com.dotcms.cdi.CDIUtils; import com.dotcms.jobs.business.job.Job; import com.dotcms.jobs.business.job.JobPaginatedResult; import com.dotcms.rest.ResponseEntityView; @@ -14,6 +13,7 @@ import java.io.IOException; import java.util.Map; import java.util.Set; +import javax.inject.Inject; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.BeanParam; import javax.ws.rs.Consumes; @@ -26,7 +26,6 @@ import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; import org.glassfish.jersey.media.sse.EventOutput; import org.glassfish.jersey.media.sse.OutboundEvent; import org.glassfish.jersey.media.sse.SseFeature; @@ -38,8 +37,9 @@ public class JobQueueResource { private final JobQueueHelper helper; - public JobQueueResource() { - this(new WebResource(), CDIUtils.getBean(JobQueueHelper.class).orElseThrow(()->new IllegalStateException("JobQueueHelper Bean not found"))); + @Inject + public JobQueueResource(final JobQueueHelper helper) { + this(new WebResource(), helper); } @VisibleForTesting @@ -52,118 +52,108 @@ public JobQueueResource(WebResource webResource, JobQueueHelper helper) { @Path("/{queueName}") @Consumes(MediaType.MULTIPART_FORM_DATA) @Produces(MediaType.APPLICATION_JSON) - public Response createJob( + public ResponseEntityView createJob( @Context HttpServletRequest request, @PathParam("queueName") String queueName, @BeanParam JobParams form) throws JsonProcessingException, DotDataException { - new WebResource.InitBuilder(webResource) + final var initDataObject = new WebResource.InitBuilder(webResource) .requiredBackendUser(true) .requiredFrontendUser(false) .requestAndResponse(request, null) .rejectWhenNoUser(true) .init(); - final String jobId = helper.createJob(queueName, form, request); - return Response.ok(new ResponseEntityView<>(jobId)).build(); + final String jobId = helper.createJob(queueName, form, initDataObject.getUser(), request); + return new ResponseEntityView<>(jobId); + } + + @POST + @Path("/{queueName}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public ResponseEntityView createJob( + @Context HttpServletRequest request, + @PathParam("queueName") String queueName, + Map parameters) throws DotDataException { + final var initDataObject = new WebResource.InitBuilder(webResource) + .requiredBackendUser(true) + .requiredFrontendUser(false) + .requestAndResponse(request, null) + .rejectWhenNoUser(true) + .init(); + final String jobId = helper.createJob( + queueName, parameters, initDataObject.getUser(), request); + return new ResponseEntityView<>(jobId); } @GET @Path("/queues") @Produces(MediaType.APPLICATION_JSON) public ResponseEntityView> getQueues(@Context HttpServletRequest request) { - new WebResource.InitBuilder(webResource) + new WebResource.InitBuilder(webResource) .requiredBackendUser(true) .requiredFrontendUser(false) .requestAndResponse(request, null) .rejectWhenNoUser(true) .init(); - return new ResponseEntityView<>(helper.getQueueNames()); + return new ResponseEntityView<>(helper.getQueueNames()); } @GET @Path("/{jobId}/status") @Produces(MediaType.APPLICATION_JSON) - public ResponseEntityView getJobStatus(@Context HttpServletRequest request, @PathParam("jobId") String jobId) + public ResponseEntityView getJobStatus(@Context HttpServletRequest request, + @PathParam("jobId") String jobId) throws DotDataException { - new WebResource.InitBuilder(webResource) + new WebResource.InitBuilder(webResource) .requiredBackendUser(true) .requiredFrontendUser(false) .requestAndResponse(request, null) .rejectWhenNoUser(true) .init(); - Job job = helper.getJob(jobId); - return new ResponseEntityView<>(job); + Job job = helper.getJob(jobId); + return new ResponseEntityView<>(job); } @POST @Path("/{jobId}/cancel") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.WILDCARD) - public ResponseEntityView cancelJob(@Context HttpServletRequest request, @PathParam("jobId") String jobId) - throws DotDataException { - new WebResource.InitBuilder(webResource) - .requiredBackendUser(true) - .requiredFrontendUser(false) - .requestAndResponse(request, null) - .rejectWhenNoUser(true) - .init(); - helper.cancelJob(jobId); - return new ResponseEntityView<>("Job cancelled successfully"); - } - - @GET - @Produces(MediaType.APPLICATION_JSON) - public ResponseEntityView listJobs(@Context HttpServletRequest request, - @QueryParam("page") @DefaultValue("1") int page, - @QueryParam("pageSize") @DefaultValue("20") int pageSize) { - new WebResource.InitBuilder(webResource) + public ResponseEntityView cancelJob(@Context HttpServletRequest request, + @PathParam("jobId") String jobId) throws DotDataException { + new WebResource.InitBuilder(webResource) .requiredBackendUser(true) .requiredFrontendUser(false) .requestAndResponse(request, null) .rejectWhenNoUser(true) .init(); - final JobPaginatedResult result = helper.getJobs(page, pageSize); - return new ResponseEntityView<>(result); + helper.cancelJob(jobId); + return new ResponseEntityView<>("Cancellation request successfully sent to job " + jobId); } @GET @Path("/{queueName}/active") @Produces(MediaType.APPLICATION_JSON) - public ResponseEntityView activeJobs(@Context HttpServletRequest request, @PathParam("queueName") String queueName, - @QueryParam("page") @DefaultValue("1") int page, - @QueryParam("pageSize") @DefaultValue("20") int pageSize) { - new WebResource.InitBuilder(webResource) - .requiredBackendUser(true) - .requiredFrontendUser(false) - .requestAndResponse(request, null) - .rejectWhenNoUser(true) - .init(); - final JobPaginatedResult result = helper.getActiveJobs(queueName, page, pageSize); - return new ResponseEntityView<>(result); - } - - @GET - @Path("/failed") - @Produces(MediaType.APPLICATION_JSON) - public ResponseEntityView failedJobs(@Context HttpServletRequest request, + public ResponseEntityView activeJobs(@Context HttpServletRequest request, + @PathParam("queueName") String queueName, @QueryParam("page") @DefaultValue("1") int page, @QueryParam("pageSize") @DefaultValue("20") int pageSize) { - new WebResource.InitBuilder(webResource) + new WebResource.InitBuilder(webResource) .requiredBackendUser(true) .requiredFrontendUser(false) .requestAndResponse(request, null) .rejectWhenNoUser(true) .init(); - final JobPaginatedResult result = helper.getFailedJobs(page, pageSize); - return new ResponseEntityView<>(result); + final JobPaginatedResult result = helper.getActiveJobs(queueName, page, pageSize); + return new ResponseEntityView<>(result); } - @GET @Path("/{jobId}/monitor") @Produces(SseFeature.SERVER_SENT_EVENTS) - public EventOutput monitorJob(@Context HttpServletRequest request, @PathParam("jobId") String jobId) { + public EventOutput monitorJob(@Context HttpServletRequest request, + @PathParam("jobId") String jobId) { new WebResource.InitBuilder(webResource) .requiredBackendUser(true) @@ -193,7 +183,7 @@ public EventOutput monitorJob(@Context HttpServletRequest request, @PathParam("j } catch (IOException e) { Logger.error(this, "Error closing SSE connection", e); } - } else { + } else { // Callback for watching job updates and sending them to the client helper.watchJob(job.id(), watched -> { if (!eventOutput.isClosed()) { @@ -213,4 +203,84 @@ public EventOutput monitorJob(@Context HttpServletRequest request, @PathParam("j } return eventOutput; } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public ResponseEntityView listJobs(@Context HttpServletRequest request, + @QueryParam("page") @DefaultValue("1") int page, + @QueryParam("pageSize") @DefaultValue("20") int pageSize) { + new WebResource.InitBuilder(webResource) + .requiredBackendUser(true) + .requiredFrontendUser(false) + .requestAndResponse(request, null) + .rejectWhenNoUser(true) + .init(); + final JobPaginatedResult result = helper.getJobs(page, pageSize); + return new ResponseEntityView<>(result); + } + + @GET + @Path("/active") + @Produces(MediaType.APPLICATION_JSON) + public ResponseEntityView activeJobs(@Context HttpServletRequest request, + @QueryParam("page") @DefaultValue("1") int page, + @QueryParam("pageSize") @DefaultValue("20") int pageSize) { + new WebResource.InitBuilder(webResource) + .requiredBackendUser(true) + .requiredFrontendUser(false) + .requestAndResponse(request, null) + .rejectWhenNoUser(true) + .init(); + final JobPaginatedResult result = helper.getActiveJobs(page, pageSize); + return new ResponseEntityView<>(result); + } + + @GET + @Path("/completed") + @Produces(MediaType.APPLICATION_JSON) + public ResponseEntityView completedJobs(@Context HttpServletRequest request, + @QueryParam("page") @DefaultValue("1") int page, + @QueryParam("pageSize") @DefaultValue("20") int pageSize) { + new WebResource.InitBuilder(webResource) + .requiredBackendUser(true) + .requiredFrontendUser(false) + .requestAndResponse(request, null) + .rejectWhenNoUser(true) + .init(); + final JobPaginatedResult result = helper.getCompletedJobs(page, pageSize); + return new ResponseEntityView<>(result); + } + + @GET + @Path("/canceled") + @Produces(MediaType.APPLICATION_JSON) + public ResponseEntityView canceledJobs(@Context HttpServletRequest request, + @QueryParam("page") @DefaultValue("1") int page, + @QueryParam("pageSize") @DefaultValue("20") int pageSize) { + new WebResource.InitBuilder(webResource) + .requiredBackendUser(true) + .requiredFrontendUser(false) + .requestAndResponse(request, null) + .rejectWhenNoUser(true) + .init(); + final JobPaginatedResult result = helper.getCanceledJobs(page, pageSize); + return new ResponseEntityView<>(result); + } + + @GET + @Path("/failed") + @Produces(MediaType.APPLICATION_JSON) + public ResponseEntityView failedJobs(@Context HttpServletRequest request, + @QueryParam("page") @DefaultValue("1") int page, + @QueryParam("pageSize") @DefaultValue("20") int pageSize) { + new WebResource.InitBuilder(webResource) + .requiredBackendUser(true) + .requiredFrontendUser(false) + .requestAndResponse(request, null) + .rejectWhenNoUser(true) + .init(); + final JobPaginatedResult result = helper.getFailedJobs(page, pageSize); + return new ResponseEntityView<>(result); + } + } \ No newline at end of file diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/portlet/PortletResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/portlet/PortletResource.java index cd2d13f88441..3f51ac3786f0 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/portlet/PortletResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/portlet/PortletResource.java @@ -24,6 +24,7 @@ import com.dotmarketing.util.UtilMethods; import com.liferay.portal.model.Portlet; import com.liferay.portal.model.User; +import javax.ws.rs.QueryParam; import org.glassfish.jersey.server.JSONP; import javax.servlet.http.HttpServletRequest; @@ -459,6 +460,7 @@ public final Response doesUserHaveAccessToPortlet(@Context final HttpServletRequ * @param request * @param httpResponse * @param contentTypeVariable - content type variable name + * @param languageId - The language to be used for the search. If not set, the user's language Id will be used * @return * @throws DotDataException * @throws DotSecurityException @@ -470,7 +472,8 @@ public final Response doesUserHaveAccessToPortlet(@Context final HttpServletRequ @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) public final Response getCreateContentURL(@Context final HttpServletRequest request, @Context final HttpServletResponse httpResponse, - @PathParam("contentTypeVariable") String contentTypeVariable) + @PathParam("contentTypeVariable") String contentTypeVariable, + @QueryParam("language_id") String languageId) throws DotDataException, DotSecurityException { final InitDataObject initData = new WebResource.InitBuilder(webResource) .requiredBackendUser(true) @@ -485,8 +488,8 @@ public final Response getCreateContentURL(@Context final HttpServletRequest requ "/ext/contentlet/edit_contentlet"; return Response.ok( - new ResponseEntityView(( - ContentTypeUtil.getInstance().getActionUrl(request,contentTypeId,user,strutsAction)))) - .build(); + new ResponseEntityView(( + ContentTypeUtil.getInstance().getActionUrl(request,contentTypeId,user,strutsAction, languageId)))) + .build(); } } diff --git a/dotCMS/src/main/java/com/dotcms/rest/config/ContainerReloader.java b/dotCMS/src/main/java/com/dotcms/rest/config/ContainerReloader.java index 969420976ad0..7c5695dfaf7c 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/config/ContainerReloader.java +++ b/dotCMS/src/main/java/com/dotcms/rest/config/ContainerReloader.java @@ -9,7 +9,7 @@ import org.glassfish.jersey.server.spi.Container; /** - * A new Reloader will get created on each reload there can only be one container at a time + * A new Re-loader will get created on each reload there can only be one container at a time */ @Provider @ApplicationScoped diff --git a/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java b/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java index 38af8db95716..f8163c40798b 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java +++ b/dotCMS/src/main/java/com/dotcms/rest/config/DotRestApplication.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.jaxrs.json.JacksonJaxbJsonProvider; import io.swagger.v3.jaxrs2.integration.resources.AcceptHeaderOpenApiResource; import io.swagger.v3.jaxrs2.integration.resources.OpenApiResource; +import org.glassfish.jersey.ext.cdi1x.internal.CdiComponentProvider; import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.info.Info; import io.swagger.v3.oas.annotations.servers.Server; @@ -57,7 +58,7 @@ public DotRestApplication() { "com.dotcms.rendering.js", "com.dotcms.ai.rest", "io.swagger.v3.jaxrs2" - ); + ).register(CdiComponentProvider.class); } /** diff --git a/dotCMS/src/main/java/com/dotcms/util/ContentTypeUtil.java b/dotCMS/src/main/java/com/dotcms/util/ContentTypeUtil.java index f5f7eb716704..b9f5a40cca84 100644 --- a/dotCMS/src/main/java/com/dotcms/util/ContentTypeUtil.java +++ b/dotCMS/src/main/java/com/dotcms/util/ContentTypeUtil.java @@ -21,11 +21,7 @@ import org.apache.commons.lang.StringUtils; import javax.servlet.http.HttpServletRequest; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; +import java.util.*; /** * Utility class for the {@link ContentTypeResource} end-point and other Content @@ -151,6 +147,27 @@ public String getActionUrl( HttpServletRequest request, final String contentType return getActionUrl(request,contentTypeInode,user,"/ext/contentlet/edit_contentlet"); } + /** + * Returns the action URL for the specified Content Type. Valid layouts must + * be returned by the {@link User} requesting this data; otherwise, the URL + * will not be returned. This means that a layout must contain at least one + * portlet. The user's language will be considered to return results + * + * @param request + * - The {@link HttpServletRequest} object. + * @param contentTypeInode + * - The Inode of the Content Type whose action URL will be + * returned. + * @param user + * - The user performing this action. + * @param strutsAction - struts action url to execute + * @return The action URL associated to the specified Content Type. + */ + public String getActionUrl( HttpServletRequest request, final String contentTypeInode, final User user, final String strutsAction){ + return getActionUrl(request, contentTypeInode, user, strutsAction, null); + + } + /** * Returns the action URL for the specified Content Type. Valid layouts must * be returned by the {@link User} requesting this data; otherwise, the URL @@ -165,17 +182,19 @@ public String getActionUrl( HttpServletRequest request, final String contentType * @param user * - The user performing this action. * @param strutsAction - struts action url to execute + * @param languageId - The language to be used for the search * @return The action URL associated to the specified Content Type. */ - public String getActionUrl( HttpServletRequest request, final String contentTypeInode, final User user, final String strutsAction) { + public String getActionUrl( HttpServletRequest request, final String contentTypeInode, final User user, final String strutsAction, final String languageId) { final List layouts; + final String resolvedLanguageId = Objects.isNull(languageId) ? this.getLanguageId(user.getLanguageId()).toString() : languageId; String actionUrl = StringUtils.EMPTY; - String referrer = StringUtils.EMPTY; + String referrer; try { layouts = this.layoutAPI.loadLayoutsForUser(user); if (UtilMethods.isSet(layouts)) { final Layout contentLayout = getContentPortletLayout(layouts); - referrer = generateReferrerUrl(request, contentLayout, contentTypeInode, user); + referrer = generateReferrerUrl(request, contentLayout, contentTypeInode, resolvedLanguageId); final PortletURL portletURL = new PortletURLImpl(request, PortletID.CONTENT.toString(), contentLayout.getId(), true); portletURL.setWindowState(WindowState.MAXIMIZED); @@ -185,7 +204,7 @@ public String getActionUrl( HttpServletRequest request, final String contentType "referer", new String[] {referrer}, "inode", new String[] {""}, "selectedStructure", new String[] {contentTypeInode}, - "lang", new String[] {this.getLanguageId(user.getLanguageId()).toString()}))); + "lang", new String[] {resolvedLanguageId}))); actionUrl = portletURL.toString(); } else { Logger.info(this, "Layouts are empty for the user: " + user.getUserId()); @@ -205,17 +224,17 @@ public String getActionUrl( HttpServletRequest request, final String contentType * redirected to after performing an operation in the back-end. For example, this can be used by * the "+" sign component that adds different types of content to the system, as it indicates * where to return after adding new content. - * + * * @param request - The {@link HttpServletRequest} object. * @param layout - The layout where the user will be redirected after performing his task. * @param contentTypeInode - The Inode of the content type used by the new content. - * @param user - The user performing this action. + * @param languageId - The languageId. * @return The referrer URL. * @throws WindowStateException If the portlet does not support the * {@link WindowState.MAXIMIZED} state. */ private String generateReferrerUrl(final HttpServletRequest request, final Layout layout, final String contentTypeInode, - final User user) throws WindowStateException { + final String languageId) throws WindowStateException { final PortletURL portletURL = new PortletURLImpl(request, PortletID.CONTENT.toString(), layout.getId(), true); portletURL.setWindowState(WindowState.MAXIMIZED); portletURL.setParameters(new HashMap<>(Map.of( @@ -223,7 +242,7 @@ private String generateReferrerUrl(final HttpServletRequest request, final Layou "cmd", new String[] {"new"}, "inode", new String[] {""}, "structure_id", new String[] {contentTypeInode}, - "lang", new String[] {this.getLanguageId(user.getLanguageId()).toString()}))); + "lang", new String[] {languageId}))); return portletURL.toString(); } diff --git a/dotCMS/src/main/java/com/dotcms/vanityurl/filters/VanityUrlRequestWrapper.java b/dotCMS/src/main/java/com/dotcms/vanityurl/filters/VanityUrlRequestWrapper.java index 9a40b5ccf8bb..de8d5cb7f97d 100644 --- a/dotCMS/src/main/java/com/dotcms/vanityurl/filters/VanityUrlRequestWrapper.java +++ b/dotCMS/src/main/java/com/dotcms/vanityurl/filters/VanityUrlRequestWrapper.java @@ -4,7 +4,6 @@ import static com.dotmarketing.filters.Constants.CMS_FILTER_URI_OVERRIDE; import com.dotcms.vanityurl.model.VanityUrlResult; -import com.dotmarketing.util.Logger; import com.dotmarketing.util.UtilMethods; import com.google.common.collect.ImmutableMap; import java.nio.charset.StandardCharsets; @@ -36,12 +35,23 @@ public VanityUrlRequestWrapper(final HttpServletRequest request, final VanityUrl super(request); final boolean vanityHasQueryString = UtilMethods.isSet(vanityUrlResult.getQueryString()); - - this.newQueryString = vanityHasQueryString && UtilMethods.isSet(request.getQueryString()) - ? request.getQueryString() + "&" + vanityUrlResult.getQueryString() - : vanityHasQueryString - ? vanityUrlResult.getQueryString() - : request.getQueryString(); + + final StringBuilder params = new StringBuilder(); + params.append(request.getQueryString()); + final Map vanityParams = convertURLParamsStringToMap(vanityUrlResult.getQueryString()); + final Map requestParams = convertURLParamsStringToMap(request.getQueryString()); + if(vanityHasQueryString){ + for (final Map.Entry entry : vanityParams.entrySet()) { + final String key = entry.getKey(); + final String value = entry.getValue(); + //add to the request.getQueryString() the vanity parameters that are not already present, the key and value must not be the same + if(!requestParams.containsKey(key) || !requestParams.get(key).equals(value)){ + params.append("&" + key + "=" + value); + } + } + } + this.newQueryString = params.toString(); + // we create a new map here because it merges the @@ -64,6 +74,32 @@ public VanityUrlRequestWrapper(final HttpServletRequest request, final VanityUrl } + /** + * Converts a URL parameters string to a map of key-value pairs + * @param input URL parameters string + * @return Map of key-value pairs + */ + private Map convertURLParamsStringToMap(final String input) { + final Map map = new HashMap<>(); + + if(UtilMethods.isSet(input)) { + // Split the input string by '&' to get key-value pairs + final String[] pairs = input.split("&"); + + for (final String pair : pairs) { + // Split each pair by '=' to get the key and value + final String[] keyValue = pair.split("="); + if (keyValue.length == 2) { + map.put(keyValue[0], keyValue[1]); + } else if (keyValue.length == 1) { + map.put(keyValue[0], ""); // Handle case where there is a key with no value + } + } + } + + return map; + } + @Override public String getQueryString() { return this.newQueryString; diff --git a/dotCMS/src/main/java/com/dotmarketing/business/APILocator.java b/dotCMS/src/main/java/com/dotmarketing/business/APILocator.java index 07921109e3fb..2acb7cb49e27 100644 --- a/dotCMS/src/main/java/com/dotmarketing/business/APILocator.java +++ b/dotCMS/src/main/java/com/dotmarketing/business/APILocator.java @@ -1443,8 +1443,8 @@ Object create() { case SYSTEM_API: return new SystemAPIImpl(); case ARTIFICIAL_INTELLIGENCE_API: return new DotAIAPIFacadeImpl(); case ACHECKER_API: return new ACheckerAPIImpl(); - case CONTENT_ANALYTICS_API: CDIUtils.getBean(ContentAnalyticsAPI.class).orElseThrow(() -> new DotRuntimeException("Content Analytics API not found")); - case JOB_QUEUE_MANAGER_API: return CDIUtils.getBean(JobQueueManagerAPI.class).orElseThrow(() -> new DotRuntimeException("JobQueueManagerAPI not found")); + case CONTENT_ANALYTICS_API: return CDIUtils.getBeanThrows(ContentAnalyticsAPI.class); + case JOB_QUEUE_MANAGER_API: return CDIUtils.getBeanThrows(JobQueueManagerAPI.class); } throw new AssertionError("Unknown API index: " + this); } diff --git a/dotCMS/src/main/java/com/dotmarketing/business/FactoryLocator.java b/dotCMS/src/main/java/com/dotmarketing/business/FactoryLocator.java index cb3d99958d92..7e055bb3e2aa 100644 --- a/dotCMS/src/main/java/com/dotmarketing/business/FactoryLocator.java +++ b/dotCMS/src/main/java/com/dotmarketing/business/FactoryLocator.java @@ -271,7 +271,7 @@ public static SystemTableFactory getSystemTableFactory() { } public static CubeJSClientFactory getCubeJSClientFactory() { - return (CubeJSClientFactory) getInstance(FactoryIndex.CUBEJS_CLIENT_FACTORY); + return CDIUtils.getBeanThrows(CubeJSClientFactory.class); } /** @@ -289,7 +289,7 @@ public static LanguageVariableFactory getLanguageVariableFactory() { * @return An instance of the {@link ContentAnalyticsFactory} object. */ public static ContentAnalyticsFactory getContentAnalyticsFactory() { - return (ContentAnalyticsFactory) getInstance(FactoryIndex.CONTENT_ANALYTICS_FACTORY); + return CDIUtils.getBeanThrows(ContentAnalyticsFactory.class); } /** @@ -378,9 +378,7 @@ enum FactoryIndex VARIANT_FACTORY, EXPERIMENTS_FACTORY, SYSTEM_TABLE_FACTORY, - CUBEJS_CLIENT_FACTORY, LANGUAGE_VARIABLE_FACTORY, - CONTENT_ANALYTICS_FACTORY, PORTLET_FACTORY; Object create() { @@ -423,9 +421,7 @@ Object create() { case VARIANT_FACTORY : return new VariantFactoryImpl(); case EXPERIMENTS_FACTORY: return new ExperimentsFactoryImpl(); case SYSTEM_TABLE_FACTORY: return new SystemTableFactoryImpl(); - case CUBEJS_CLIENT_FACTORY: return new CubeJSClientFactoryImpl(); case LANGUAGE_VARIABLE_FACTORY: return new LanguageVariableFactoryImpl(); - case CONTENT_ANALYTICS_FACTORY: CDIUtils.getBean(ContentAnalyticsFactory.class).orElseThrow(() -> new DotRuntimeException("ContentAnalyticsFactory not found")); case PORTLET_FACTORY: return new PortletFactoryImpl(); } throw new AssertionError("Unknown Factory Index: " + this); diff --git a/dotCMS/src/main/java/com/dotmarketing/business/ThemeAPIImpl.java b/dotCMS/src/main/java/com/dotmarketing/business/ThemeAPIImpl.java index 8aee122e1e19..9038590893ef 100644 --- a/dotCMS/src/main/java/com/dotmarketing/business/ThemeAPIImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/business/ThemeAPIImpl.java @@ -132,8 +132,7 @@ public List findThemes(final String themeId, final User user, final int l final int offset, final String hostId, final OrderDirection direction, final String searchParams, final boolean respectFrontendRoles) throws DotDataException, DotSecurityException { - - final PaginatedArrayList result = new PaginatedArrayList(); + final PaginatedArrayList result = new PaginatedArrayList<>(); //If themeId is sent, it's because a specific theme is wanted, so call the findThemeById if (UtilMethods.isSet(themeId)) { @@ -143,7 +142,7 @@ public List findThemes(final String themeId, final User user, final int l //TODO: If we modify that the hostId is not required we need to add system theme is hostId is not set. //This is because themes can not live under system_host - if (hostId.equals(this.systemTheme.getHostId())) { + if (UtilMethods.isSet(hostId) && hostId.equals(this.systemTheme.getHostId())) { result.add(this.systemTheme()); return result; } @@ -175,17 +174,17 @@ public List findThemes(final String themeId, final User user, final int l } //Don't include archived themes (template.vtl archived) - sqlQuery.append(" and cvi.deleted = " + DbConnectionFactory.getDBFalse()); + sqlQuery.append(" and cvi.deleted = ").append(DbConnectionFactory.getDBFalse()); final String sortBy = String.format("id.parent_path %s", direction.toString().toLowerCase()); - sqlQuery.append(" order by " + sortBy); + sqlQuery.append(" order by ").append(sortBy); final DotConnect dc = new DotConnect().setSQL(sqlQuery.toString()) .setMaxRows(limit).setStartRow(offset); - parameters.forEach(param -> dc.addParam(param)); + parameters.forEach(dc::addParam); //List of inodes of the template.vtl files found - final List> inodesMapList = dc.loadResults(); + final List> inodesMapList = dc.loadResults(); final List inodes = new ArrayList<>(); for (final Map versionInfoMap : inodesMapList) { @@ -201,6 +200,6 @@ public List findThemes(final String themeId, final User user, final int l } return result; - } + } diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DefaultTransformStrategy.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DefaultTransformStrategy.java index a33ac6fa79ec..ccbae86f61cf 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DefaultTransformStrategy.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DefaultTransformStrategy.java @@ -78,6 +78,7 @@ public class DefaultTransformStrategy extends AbstractTransformStrategy { private static final String FILE_ASSET = FileAssetAPI.BINARY_FIELD; + public static final String SHORTY_ID = "shortyId"; /** * Main constructor @@ -127,6 +128,7 @@ private void addCommonProperties(final Contentlet contentlet, final Map "Getting the file by path: " + uri + " for host: " + site.getHostname()); try { - final Identifier identifier = APILocator.getIdentifierAPI().find(site, uri); - final Optional cinfo = APILocator.getVersionableAPI() + final Optional versionInfoOpt = APILocator.getVersionableAPI() .getContentletVersionInfo(identifier.getId(), languageId); - - if (cinfo.isPresent()) { - - final ContentletVersionInfo versionInfo = cinfo.get(); + if (versionInfoOpt.isPresent()) { + final ContentletVersionInfo versionInfo = versionInfoOpt.get(); final Contentlet contentlet = APILocator.getContentletAPI() .find(live ? versionInfo.getLiveInode() : versionInfo.getWorkingInode(), APILocator.systemUser(), false); + if (null == contentlet) { + Logger.warn(this, String.format("%s version of File '%s' under Site " + + "'%s' was not found. You may try to publish it first", live ? "Live" : "Working", uri, site)); + return null; + } if (contentlet.getContentType().baseType() == BaseContentType.FILEASSET) { - fileAsset = fromContentlet(contentlet); } } - } catch (DotDataException | DotSecurityException e) { - - Logger.error(this, "Error getting the fileasset for the path: " - + uri + " for host: " + site.getHostname() + ", msg: " + e.getMessage(), e); - throw new DotRuntimeException(e.getMessage(), e); + } catch (final DotDataException | DotSecurityException e) { + final String errorMsg = String.format("Failed to retrieve %s version of File '%s' with Language ID " + + "'%s' under Site '%s': %s", live ? "live" : "working", uri, languageId, site, + ExceptionUtil.getErrorMessage(e)); + Logger.error(this, errorMsg, e); + throw new DotRuntimeException(errorMsg, e); } } - return fileAsset; } + } diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/files/action/UploadMultipleFilesAction.java b/dotCMS/src/main/java/com/dotmarketing/portlets/files/action/UploadMultipleFilesAction.java index 1b48672233f8..8ccbcd1e672a 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/files/action/UploadMultipleFilesAction.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/files/action/UploadMultipleFilesAction.java @@ -212,7 +212,13 @@ public void _saveFileAsset(ActionRequest req, ActionResponse res,PortletConfig c if(!UtilMethods.isSet(fileNamesStr)) throw new ActionException(LanguageUtil.get(user, "message.file_asset.alert.please.upload")); - String selectedStructureInode = ParamUtil.getString(req, "selectedStructure"); + String selectedStructureInode; + + if (config.getPortletName().contains("site-browser")){ + selectedStructureInode = ParamUtil.getString(req, "selectedStructure"); + } else { + selectedStructureInode = folder.getDefaultFileType(); + } if(!UtilMethods.isSet(selectedStructureInode)) selectedStructureInode = CacheLocator.getContentTypeCache().getStructureByVelocityVarName(FileAssetAPI.DEFAULT_FILE_ASSET_STRUCTURE_VELOCITY_VAR_NAME).getInode(); diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/workflows/actionlet/AnalyticsFireUserEventActionlet.java b/dotCMS/src/main/java/com/dotmarketing/portlets/workflows/actionlet/AnalyticsFireUserEventActionlet.java index 0fe840b9bc82..384a9a8ebe09 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/workflows/actionlet/AnalyticsFireUserEventActionlet.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/workflows/actionlet/AnalyticsFireUserEventActionlet.java @@ -12,6 +12,7 @@ import com.dotmarketing.portlets.workflows.model.WorkflowProcessor; import com.dotmarketing.util.Logger; import com.dotmarketing.util.UUIDUtil; +import com.dotmarketing.util.UtilMethods; import com.liferay.util.StringPool; import javax.servlet.http.HttpServletRequest; @@ -94,12 +95,12 @@ public void executeAction(final WorkflowProcessor processor, final HashMap objectDetail = new HashMap<>(); final Map userEventPayload = new HashMap<>(); - userEventPayload.put(ID, Objects.nonNull(objectId) ? objectId : identifier); + userEventPayload.put(ID, UtilMethods.isSet(objectId) ? objectId.trim() : identifier); objectDetail.put(ID, identifier); - objectDetail.put(OBJECT_CONTENT_TYPE_VAR_NAME, Objects.nonNull(objectType) ? objectType : CONTENT); + objectDetail.put(OBJECT_CONTENT_TYPE_VAR_NAME, UtilMethods.isSet(objectType) ? objectType.trim() : CONTENT); userEventPayload.put(OBJECT, objectDetail); - userEventPayload.put(EVENT_TYPE1, eventType); + userEventPayload.put(EVENT_TYPE1, UtilMethods.isSet(eventType)? eventType.trim(): eventType); webEventsCollectorService.fireCollectorsAndEmitEvent(request, response, USER_CUSTOM_DEFINED_REQUEST_MATCHER, userEventPayload); } else { Logger.warn(this, "The request or response is null, can't send the event for the contentlet: " + identifier); diff --git a/dotCMS/src/main/java/com/dotmarketing/util/ImportUtil.java b/dotCMS/src/main/java/com/dotmarketing/util/ImportUtil.java index cd3ea121d7c7..9f9c41203d89 100644 --- a/dotCMS/src/main/java/com/dotmarketing/util/ImportUtil.java +++ b/dotCMS/src/main/java/com/dotmarketing/util/ImportUtil.java @@ -53,20 +53,27 @@ import com.liferay.portal.language.LanguageUtil; import com.liferay.portal.model.User; import com.liferay.util.StringPool; - import java.io.File; -import java.io.IOException; import java.io.Reader; import java.net.URL; import java.sql.Timestamp; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.function.Function; +import java.util.function.LongConsumer; import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; - import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.ImmutablePair; @@ -109,6 +116,78 @@ public class ImportUtil { private static final SimpleDateFormat DATE_FIELD_FORMAT = new SimpleDateFormat("yyyyMMdd"); + /** + * Imports the data contained in a CSV file into dotCMS. The data can be + * either new or an update for existing content. The {@code preview} + * parameter determines the behavior of this method: + *
    + *
  • {@code preview == true}: This is the ideal approach. The data + * contained in the CSV file is previously analyzed and evaluated + * BEFORE actually committing any changes to existing contentlets or + * adding new ones. This way, users can perform the appropriate corrections + * (if needed) before submitting the new contents.
  • + *
  • {@code preview == false}: Setting the parameter this way will make + * the system try to import the contents right away. The method will also + * return a summary with the status of the operation.
  • + *
+ * + * @param importId + * - The ID of this data import. + * @param currentSiteId + * - The ID of the Site where the content will be added/updated. + * @param contentTypeInode + * - The Inode of the Content Type that the content is associated + * to. + * @param keyfields + * - The Inodes of the fields used to associated existing dotCMS + * contentlets with the information in this file. Can be empty. + * @param preview + * - Set to {@code true} if an analysis and evaluation of the + * imported data will be generated before actually + * importing the data. Otherwise, set to {@code false}. + * @param isMultilingual + * - If set to {@code true}, the CSV file will import contents in + * more than one language. Otherwise, set to {@code false}. + * @param user + * - The {@link User} performing this action. + * @param language + * - The language ID for the contents. If the ID equals -1, the + * columns for language code and country code will be used to + * infer the language ID. + * @param csvHeaders + * - The headers for each column in the CSV file. + * @param csvreader + * - The actual data contained in the CSV file. + * @param languageCodeHeaderColumn + * - The column name containing the language code. + * @param countryCodeHeaderColumn + * - The column name containing the country code. + * @param reader + * - The character streams reader. + * @param wfActionId + * - The workflow Action Id to execute on the import + * @param request + * - The request object. + * @return The resulting analysis performed on the CSV file. This provides + * information regarding inconsistencies, errors, warnings and/or + * precautions to the user. + * @throws DotRuntimeException + * An error occurred when analyzing the CSV file. + * @throws DotDataException + * An error occurred when analyzing the CSV file. + */ + public static HashMap> importFile( + Long importId, String currentSiteId, String contentTypeInode, String[] keyfields, + boolean preview, boolean isMultilingual, User user, long language, + String[] csvHeaders, CsvReader csvreader, int languageCodeHeaderColumn, + int countryCodeHeaderColumn, Reader reader, String wfActionId, + final HttpServletRequest request) throws DotRuntimeException, DotDataException { + + return importFile(importId, currentSiteId, contentTypeInode, keyfields, preview, + isMultilingual, user, language, csvHeaders, csvreader, languageCodeHeaderColumn, + countryCodeHeaderColumn, wfActionId, request, null); + } + /** * Imports the data contained in a CSV file into dotCMS. The data can be * either new or an update for existing content. The {@code preview} @@ -155,10 +234,12 @@ public class ImportUtil { * - The column name containing the language code. * @param countryCodeHeaderColumn * - The column name containing the country code. - * @param reader - * - The character streams reader. * @param wfActionId * - The workflow Action Id to execute on the import + * @param request + * - The request object. + * @param progressCallback + * - A callback function to report progress. * @return The resulting analysis performed on the CSV file. This provides * information regarding inconsistencies, errors, warnings and/or * precautions to the user. @@ -167,7 +248,11 @@ public class ImportUtil { * @throws DotDataException * An error occurred when analyzing the CSV file. */ - public static HashMap> importFile(Long importId, String currentSiteId, String contentTypeInode, String[] keyfields, boolean preview, boolean isMultilingual, User user, long language, String[] csvHeaders, CsvReader csvreader, int languageCodeHeaderColumn, int countryCodeHeaderColumn, Reader reader, String wfActionId, final HttpServletRequest request) + public static HashMap> importFile(Long importId, String currentSiteId, + String contentTypeInode, String[] keyfields, boolean preview, boolean isMultilingual, + User user, long language, String[] csvHeaders, CsvReader csvreader, + int languageCodeHeaderColumn, int countryCodeHeaderColumn, String wfActionId, + final HttpServletRequest request, final LongConsumer progressCallback) throws DotRuntimeException, DotDataException { HashMap> results = new HashMap<>(); @@ -293,6 +378,11 @@ public static HashMap> importFile(Long importId, String cur errors++; Logger.warn(ImportUtil.class, "Error line: " + lines + " (" + csvreader.getRawRecord() + "). Line Ignored."); + } finally { + // Progress callback + if (progressCallback != null) { + progressCallback.accept(lines); + } } } @@ -331,14 +421,6 @@ public static HashMap> importFile(Long importId, String cur } catch (final Exception e) { Logger.error(ImportContentletsAction.class, String.format("An error occurred when parsing CSV file in " + "line #%s: %s", lineNumber, e.getMessage()), e); - } finally { - if (reader != null) { - try { - reader.close(); - } catch (IOException e) { - // Reader could not be closed. Continue - } - } } final String action = preview ? "Content preview" : "Content import"; String statusMsg = String.format("%s has finished, %d lines were read correctly.", action, lines); diff --git a/dotCMS/src/main/java/com/dotmarketing/util/URLUtils.java b/dotCMS/src/main/java/com/dotmarketing/util/URLUtils.java index e792d53ad51f..510649cb464f 100644 --- a/dotCMS/src/main/java/com/dotmarketing/util/URLUtils.java +++ b/dotCMS/src/main/java/com/dotmarketing/util/URLUtils.java @@ -2,6 +2,7 @@ import java.io.UnsupportedEncodingException; import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -16,7 +17,7 @@ private URLUtils() { } public static class ParsedURL { - + private String protocol; private String host; private int port; @@ -26,7 +27,7 @@ public static class ParsedURL { private String queryString; private Map parameters; private String fragment; - + public String getProtocol() { return protocol; } @@ -81,59 +82,77 @@ public String getFragment() { public void setFragment(String fragment) { this.fragment = fragment; } - } + } - private static final Pattern regexPattern = Pattern.compile( - "((\\w+)://([^/\\p{Cntrl}:]+)(?::(\\d+))?)?(((?:/[^/\\p{Cntrl}]+)*)(?:/([^/\\p{Cntrl}?#]+)?))?\\??([^#]*)?(?:#(.*))?"); - - public static ParsedURL parseURL(String url) throws IllegalArgumentException { - - Matcher matcher = regexPattern.matcher(url); + private static final Pattern protocolHostPortPattern = Pattern.compile( + "^(?[^:/?#]+)://(?[^/\\p{Cntrl}:]+)(?::(?\\d+))?"); - if(!matcher.find()) + private static final Pattern pathQueryFragmentPattern = Pattern.compile( + "(?(?/[^?#]*)?/(?[^/?#]*))?(?:\\?(?[^#]*))?(?:#(?.*))?"); + + public static ParsedURL parseURL(final String url) throws IllegalArgumentException { + final ParsedURL parsedUrl = new ParsedURL(); + + final int pathIndex = parseProtocolHostPort(url, parsedUrl); + final boolean foundMatch = parsePathQueryFragment(url, parsedUrl, pathIndex); + if (!foundMatch) { return null; - - ParsedURL parsedUrl = new ParsedURL(); - parsedUrl.setProtocol(matcher.group(2)); - parsedUrl.setHost(matcher.group(3)); - parsedUrl.setPort(matcher.group(4) != null?Integer.parseInt(matcher.group(4)):0); - parsedUrl.setURI(matcher.group(5)); - parsedUrl.setPath(matcher.group(6)); - parsedUrl.setResource(matcher.group(7)); - parsedUrl.setQueryString(matcher.group(8)); - parsedUrl.setFragment(matcher.group(9)); - - Map> parameters = new HashMap<>(); - String[] queryStringSplitted = parsedUrl.queryString.split("&"); - - for (int i = 0; i < queryStringSplitted.length; i++) { - try { - String[] queryParamTuple = queryStringSplitted[i].split("="); - String parameterKey = URLDecoder.decode(queryParamTuple[0], "UTF8"); - if(!UtilMethods.isSet(parameterKey)) - continue; - String parameterValue = queryParamTuple.length > 1?URLDecoder.decode(queryParamTuple[1], "UTF8"):null; - List parameterValues = parameters.get(parameterKey); - if(parameterValues == null) { - parameterValues = new ArrayList<>(1); - parameters.put(parameterKey, parameterValues); - } - if(parameterValue != null) - parameterValues.add(parameterValue); - } catch (UnsupportedEncodingException e) { - Logger.error(URLUtils.class, e.getMessage(), e); - throw new IllegalArgumentException(e.getMessage(), e); - } - } - - Map parametersToRet = new HashMap<>(); - for(Map.Entry> parameterEntry : parameters.entrySet()) { - String[] values = parameterEntry.getValue().toArray(new String[0]); - parametersToRet.put(parameterEntry.getKey(), values); - } - - parsedUrl.setParameters(parametersToRet); - + } + processQueryString(parsedUrl); + return parsedUrl; } + + private static int parseProtocolHostPort(final String url, final ParsedURL parsedUrl) { + final Matcher matcher = protocolHostPortPattern.matcher(url); + if (matcher.find()) { + parsedUrl.setProtocol(matcher.group("protocol")); + parsedUrl.setHost(matcher.group("host")); + parsedUrl.setPort(matcher.group("port") != null ? + Integer.parseInt(matcher.group("port")) : 0); + return matcher.end(); + } else { + return 0; + } + } + + private static boolean parsePathQueryFragment(final String url, + final ParsedURL parsedUrl, final int pathIndex) { + final Matcher matcher = pathQueryFragmentPattern.matcher(url); + if (matcher.find(pathIndex)) { + parsedUrl.setURI(matcher.group("uri")); + parsedUrl.setPath(matcher.group("path")); + parsedUrl.setResource(matcher.group("resource")); + parsedUrl.setQueryString(matcher.group("query")); + parsedUrl.setFragment(matcher.group("fragment")); + return true; + } else { + return false; + } + } + + private static void processQueryString(final ParsedURL parsedUrl) { + final Map> parameters = new HashMap<>(); + if (parsedUrl.getQueryString() != null) { + final String[] queryStringSplitted = parsedUrl.getQueryString().split("&"); + for (String queryParam : queryStringSplitted) { + final String[] queryParamTuple = queryParam.split("="); + final String parameterKey = URLDecoder.decode( + queryParamTuple[0], StandardCharsets.UTF_8); + if (!UtilMethods.isSet(parameterKey)) continue; + final String parameterValue = queryParamTuple.length > 1 ? + URLDecoder.decode(queryParamTuple[1], StandardCharsets.UTF_8) : null; + parameters.computeIfAbsent(parameterKey, + k -> new ArrayList<>()).add(parameterValue); + } + } + + final Map parametersToRet = new HashMap<>(); + for (Map.Entry> parameterEntry : parameters.entrySet()) { + parametersToRet.put(parameterEntry.getKey(), + parameterEntry.getValue().toArray(new String[0])); + } + parsedUrl.setParameters(parametersToRet); + } + } diff --git a/dotCMS/src/main/resources/META-INF/beans.xml b/dotCMS/src/main/resources/META-INF/beans.xml new file mode 100644 index 000000000000..55e20058e932 --- /dev/null +++ b/dotCMS/src/main/resources/META-INF/beans.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/dotCMS/src/main/webapp/WEB-INF/beans.xml b/dotCMS/src/main/webapp/WEB-INF/beans.xml deleted file mode 100644 index 1675ad7ab74c..000000000000 --- a/dotCMS/src/main/webapp/WEB-INF/beans.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 7482843066bd..58f5a8d9a657 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -1190,6 +1190,13 @@ dot.file.field.action.generate.with.tooltip=Please configure dotAI to enable thi dot.file.field.action.import.from.url=Import from URL dot.file.field.action.import.from.url.error.message=The URL you requested is not valid. Please try again. dot.file.field.action.remove=Remove +dot.file.field.dialog.select.existing.file.header=Select Existing File +dot.file.field.dialog.select.existing.file.table.title=Title +dot.file.field.dialog.select.existing.file.table.modified.by=Modified by +dot.file.field.dialog.select.existing.file.table.last.modified=Last Modified +dot.file.field.dialog.select.existing.file.actions.cancel=Cancel +dot.file.field.dialog.select.existing.file.actions.add=Add +dot.file.field.dialog.select.existing.file.actions.upload=Upload dot.file.field.dialog.create.new.file.header=File Details dot.file.field.dialog.import.from.url.header=URL dot.file.field.dialog.generate.from.ai.header=Generate AI Image @@ -5808,12 +5815,15 @@ edit.content.wysiwyg.confirm.switch-editor.message=Switching to the WYSIWYG view edit.content.wysiwyg-field.language-variable-placeholder=Language variables edit.content.wysiwyg-field.language-variable-tooltip=Start typing to see matching Language Variables. -lts.expired.message = This version of dotCMS already reached EOL. Please contact your CSM to schedule an upgrade. -lts.expires.soon.message = Your dotCMS version will reach EOL in {0} days. Please contact your CSM to schedule an upgrade. +lts.expired.message = This version of dotCMS is no longer supported. Please contact your customer success manager to schedule an upgrade. +lts.expires.soon.message = Your dotCMS version will no longer be supported in {0} days. Please contact your customer success manager to schedule an upgrade. -analytics.search.run.query=Run Query +analytics.search.execute.query=Execute Query analytics.search.query=Query analytics.search.results=Results +analytics.search.no.results=No Results +analytics.search.execute.results=Execute a query to get results +analytics.search.valid.json=The query must be a valid JSON diff --git a/dotCMS/src/main/webapp/WEB-INF/velocity/VM_global_library.vm b/dotCMS/src/main/webapp/WEB-INF/velocity/VM_global_library.vm index 0f4d7d4e2000..53c06573d61e 100644 --- a/dotCMS/src/main/webapp/WEB-INF/velocity/VM_global_library.vm +++ b/dotCMS/src/main/webapp/WEB-INF/velocity/VM_global_library.vm @@ -65,13 +65,14 @@ #set($_hasPermission = $contents.doesUserHasPermission($CONTENT_INODE, 2, $EDIT_MODE)) #if($EDIT_MODE && $_hasPermission) #set($localContent = $dotcontent.find($!{CONTENT_INODE})) + #set($jsonContent = $json.generate($localContent)) #end #end diff --git a/dotCMS/src/main/webapp/html/js/editor-js/sdk-editor.js b/dotCMS/src/main/webapp/html/js/editor-js/sdk-editor.js index 3f13c04df996..853270c30ed4 100644 --- a/dotCMS/src/main/webapp/html/js/editor-js/sdk-editor.js +++ b/dotCMS/src/main/webapp/html/js/editor-js/sdk-editor.js @@ -1 +1 @@ -function i(t){window.parent.postMessage(t,"*")}function E(t){return t.map(n=>{let e=n.getBoundingClientRect(),o=Array.from(n.querySelectorAll('[data-dot-object="contentlet"]'));return{x:e.x,y:e.y,width:e.width,height:e.height,payload:JSON.stringify({container:p(n)}),contentlets:N(e,o)}})}function N(t,n){return n.map(e=>{let o=e.getBoundingClientRect();return{x:0,y:o.y-t.y,width:o.width,height:o.height,payload:JSON.stringify({container:e.dataset?.dotContainer?JSON.parse(e.dataset?.dotContainer):a(e),contentlet:{identifier:e.dataset?.dotIdentifier,title:e.dataset?.dotTitle,inode:e.dataset?.dotInode,contentType:e.dataset?.dotType}})}})}function p(t){return{acceptTypes:t.dataset?.dotAcceptTypes||"",identifier:t.dataset?.dotIdentifier||"",maxContentlets:t.dataset?.maxContentlets||"",uuid:t.dataset?.dotUuid||""}}function a(t){let n=t.closest('[data-dot-object="container"]');return n?p(n):(console.warn("No container found for the contentlet"),null)}function s(t){return t?t?.dataset?.dotObject==="contentlet"||t?.dataset?.dotObject==="container"&&t.children.length===0?t:s(t?.parentElement):null}function u(t){let n=t.querySelectorAll('[data-dot-object="vtl-file"]');return n.length?Array.from(n).map(e=>({inode:e.dataset?.dotInode,name:e.dataset?.dotUrl})):null}function f(){let t=document.documentElement.scrollHeight,n=window.innerHeight;return window.scrollY+n>=t}var r=[];function h(){let t=Array.from(document.querySelectorAll('[data-dot-object="container"]')),n=E(t);i({action:"set-bounds",payload:n})}function l(){let t=n=>{switch(n.data){case"ema-request-bounds":{h();break}}if(n.data.name==="scroll-inside-iframe"){let e=n.data.direction;if(window.scrollY===0&&e==="up"||f()&&e==="down")return;let o=e==="up"?-120:120;window.scrollBy({left:0,top:o,behavior:"smooth"})}};window.addEventListener("message",t),r.push({type:"listener",event:"message",callback:t})}function d(){let t=n=>{let e=s(n.target);if(!e)return;let{x:o,y,width:L,height:v}=e.getBoundingClientRect(),b=e.dataset?.dotObject==="container",P={identifier:"TEMP_EMPTY_CONTENTLET",title:"TEMP_EMPTY_CONTENTLET",contentType:"TEMP_EMPTY_CONTENTLET_TYPE",inode:"TEMPY_EMPTY_CONTENTLET_INODE",widgetTitle:"TEMP_EMPTY_CONTENTLET",baseType:"TEMP_EMPTY_CONTENTLET",onNumberOfPages:1},w={identifier:e.dataset?.dotIdentifier,title:e.dataset?.dotTitle,inode:e.dataset?.dotInode,contentType:e.dataset?.dotType,baseType:e.dataset?.dotBasetype,widgetTitle:e.dataset?.dotWidgetTitle,onNumberOfPages:e.dataset?.dotOnNumberOfPages},M=u(e),D={container:e.dataset?.dotContainer?JSON.parse(e.dataset?.dotContainer):a(e),contentlet:b?P:w,vtlFiles:M};i({action:"set-contentlet",payload:{x:o,y,width:L,height:v,payload:D}})};document.addEventListener("pointermove",t),r.push({type:"listener",event:"pointermove",callback:t})}function c(){let t=()=>{i({action:"scroll"}),window.lastScrollYPosition=window.scrollY},n=()=>{i({action:"scroll-end"})};window.addEventListener("scroll",t),window.addEventListener("scrollend",n),r.push({type:"listener",event:"scroll",callback:n}),r.push({type:"listener",event:"scroll",callback:t})}function m(){let t=()=>{window.scrollTo(0,window.lastScrollYPosition)};window.addEventListener("load",t),r.push({type:"listener",event:"scroll",callback:t})}function g(){return typeof window>"u"?!1:window.parent!==window}function T(){document.querySelectorAll('[data-dot-object="contentlet"]').forEach(n=>{n.clientHeight||n.classList.add("empty-contentlet")})}g()&&(l(),c(),m(),d(),T()); +function u(t){i({action:"edit-contentlet",payload:t})}function T(){return typeof window>"u"?!1:window.parent!==window}function f(){window.dotUVE=a}function m(){document.querySelectorAll('[data-dot-object="contentlet"]').forEach(n=>{n.clientHeight||n.classList.add("empty-contentlet")})}var a={editContentlet:u,lastScrollYPosition:0};function i(t){window.parent.postMessage(t,"*")}function g(t){return t.map(n=>{let e=n.getBoundingClientRect(),o=Array.from(n.querySelectorAll('[data-dot-object="contentlet"]'));return{x:e.x,y:e.y,width:e.width,height:e.height,payload:JSON.stringify({container:v(n)}),contentlets:I(e,o)}})}function I(t,n){return n.map(e=>{let o=e.getBoundingClientRect();return{x:0,y:o.y-t.y,width:o.width,height:o.height,payload:JSON.stringify({container:e.dataset?.dotContainer?JSON.parse(e.dataset?.dotContainer):E(e),contentlet:{identifier:e.dataset?.dotIdentifier,title:e.dataset?.dotTitle,inode:e.dataset?.dotInode,contentType:e.dataset?.dotType}})}})}function v(t){return{acceptTypes:t.dataset?.dotAcceptTypes||"",identifier:t.dataset?.dotIdentifier||"",maxContentlets:t.dataset?.maxContentlets||"",uuid:t.dataset?.dotUuid||""}}function E(t){let n=t.closest('[data-dot-object="container"]');return n?v(n):(console.warn("No container found for the contentlet"),null)}function p(t){return t?t?.dataset?.dotObject==="contentlet"||t?.dataset?.dotObject==="container"&&t.children.length===0?t:p(t?.parentElement):null}function y(t){let n=t.querySelectorAll('[data-dot-object="vtl-file"]');return n.length?Array.from(n).map(e=>({inode:e.dataset?.dotInode,name:e.dataset?.dotUrl})):null}function _(){let t=document.documentElement.scrollHeight,n=window.innerHeight;return window.scrollY+n>=t}var r=[];function h(){let t=Array.from(document.querySelectorAll('[data-dot-object="container"]')),n=g(t);i({action:"set-bounds",payload:n})}function d(){let t=n=>{({"uve-reload-page":()=>{window.location.reload()},"uve-request-bounds":()=>{h()},"uve-scroll-inside-iframe":()=>{let o=n.data.direction;if(window.scrollY===0&&o==="up"||_()&&o==="down")return;let s=o==="up"?-120:120;window.scrollBy({left:0,top:s,behavior:"smooth"})}})[n.data.name]?.()};window.addEventListener("message",t),r.push({type:"listener",event:"message",callback:t})}function l(){let t=n=>{let e=p(n.target);if(!e)return;let{x:o,y:s,width:C,height:M}=e.getBoundingClientRect(),w=e.dataset?.dotObject==="container",P={identifier:"TEMP_EMPTY_CONTENTLET",title:"TEMP_EMPTY_CONTENTLET",contentType:"TEMP_EMPTY_CONTENTLET_TYPE",inode:"TEMPY_EMPTY_CONTENTLET_INODE",widgetTitle:"TEMP_EMPTY_CONTENTLET",baseType:"TEMP_EMPTY_CONTENTLET",onNumberOfPages:1},b={identifier:e.dataset?.dotIdentifier,title:e.dataset?.dotTitle,inode:e.dataset?.dotInode,contentType:e.dataset?.dotType,baseType:e.dataset?.dotBasetype,widgetTitle:e.dataset?.dotWidgetTitle,onNumberOfPages:e.dataset?.dotOnNumberOfPages},L=y(e),O={container:e.dataset?.dotContainer?JSON.parse(e.dataset?.dotContainer):E(e),contentlet:w?P:b,vtlFiles:L};i({action:"set-contentlet",payload:{x:o,y:s,width:C,height:M,payload:O}})};document.addEventListener("pointermove",t),r.push({type:"listener",event:"pointermove",callback:t})}function c(){let t=()=>{i({action:"scroll"}),window.dotUVE={...window.dotUVE??a,lastScrollYPosition:window.scrollY}},n=()=>{i({action:"scroll-end"})};window.addEventListener("scroll",t),window.addEventListener("scrollend",n),r.push({type:"listener",event:"scroll",callback:n}),r.push({type:"listener",event:"scroll",callback:t})}function D(){let t=()=>{window.scrollTo(0,window.dotUVE?.lastScrollYPosition)};window.addEventListener("load",t),r.push({type:"listener",event:"scroll",callback:t})}T()&&(f(),d(),c(),D(),l(),m()); diff --git a/dotCMS/src/main/webapp/html/portlet/ext/contentlet/edit_contentlet_js_inc.jsp b/dotCMS/src/main/webapp/html/portlet/ext/contentlet/edit_contentlet_js_inc.jsp index 457c03623601..4c3d52a6ac10 100644 --- a/dotCMS/src/main/webapp/html/portlet/ext/contentlet/edit_contentlet_js_inc.jsp +++ b/dotCMS/src/main/webapp/html/portlet/ext/contentlet/edit_contentlet_js_inc.jsp @@ -453,8 +453,15 @@ * This is beacuse we may need to redirect the user to the new URLMap contentlet. * We need to wait until the re-index process is done to avoid a 404 error. * More info: https://github.com/dotCMS/core/issues/21818 + * + * We also need to wait for the reindex when we edit in order to reload the page when editing using the + * #editContentlet() macro + * More info: https://github.com/dotCMS/core/issues/30218 + * + * Maybe we can avoid this after this is merged: https://github.com/dotCMS/core/pull/30110 + * */ - const newSaveContentCallBack = isURLMapContent ? (data) => setTimeout(() => saveContentCallback(data), 1800) : saveContentCallback; + const newSaveContentCallBack = (data) => setTimeout(() => saveContentCallback(data), 1800); ContentletAjax.saveContent(fmData, isAutoSave, isCheckin, publish, newSaveContentCallBack); } diff --git a/dotCMS/src/test/java/com/dotcms/UnitTestBase.java b/dotCMS/src/test/java/com/dotcms/UnitTestBase.java index e994d0f1f97a..4c6abfccd7d9 100644 --- a/dotCMS/src/test/java/com/dotcms/UnitTestBase.java +++ b/dotCMS/src/test/java/com/dotcms/UnitTestBase.java @@ -12,10 +12,6 @@ import com.liferay.portal.model.Company; import com.liferay.portal.model.User; import java.util.TimeZone; -import org.jboss.weld.bootstrap.api.helpers.RegistrySingletonProvider; -import org.jboss.weld.environment.se.Weld; -import org.jboss.weld.environment.se.WeldContainer; -import org.junit.AfterClass; import org.junit.BeforeClass; import org.mockito.Mockito; @@ -24,8 +20,6 @@ public abstract class UnitTestBase extends BaseMessageResources { protected static final ContentTypeAPI contentTypeAPI = mock(ContentTypeAPI.class); protected static final CompanyAPI companyAPI = mock(CompanyAPI.class); - private static WeldContainer weld; - public static class MyAPILocator extends APILocator { static { @@ -47,10 +41,6 @@ protected CompanyAPI getCompanyAPIImpl() { @BeforeClass public static void prepare () throws DotDataException, DotSecurityException, Exception { - weld = new Weld().containerId(RegistrySingletonProvider.STATIC_INSTANCE) - .initialize(); - - System.out.println("Weld :: " + weld); Config.initializeConfig(); Config.setProperty("API_LOCATOR_IMPLEMENTATION", MyAPILocator.class.getName()); @@ -64,10 +54,4 @@ public static void prepare () throws DotDataException, DotSecurityException, Exc Mockito.lenient().when(companyAPI.getDefaultCompany()).thenReturn(company); } - @AfterClass - public static void cleanup() { - if( null != weld && weld.isRunning() ){ - weld.shutdown(); - } - } } diff --git a/dotCMS/src/test/java/com/dotcms/ai/client/openai/AIProxiedClientTest.java b/dotCMS/src/test/java/com/dotcms/ai/client/openai/AIProxiedClientTest.java index 86ca35f3290a..8041094e187e 100644 --- a/dotCMS/src/test/java/com/dotcms/ai/client/openai/AIProxiedClientTest.java +++ b/dotCMS/src/test/java/com/dotcms/ai/client/openai/AIProxiedClientTest.java @@ -7,6 +7,7 @@ import com.dotcms.ai.client.AIRequest; import com.dotcms.ai.domain.AIResponse; import com.dotcms.ai.client.AIResponseEvaluator; +import com.dotcms.ai.domain.AIResponseData; import org.junit.Before; import org.junit.Test; @@ -15,6 +16,7 @@ import java.io.Serializable; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -53,10 +55,10 @@ public void setUp() { */ @Test public void testSendToAI_withValidRequest() { - AIRequest request = mock(AIRequest.class); - OutputStream output = mock(OutputStream.class); + final AIRequest request = mock(AIRequest.class); + final OutputStream output = mock(OutputStream.class); - AIResponse response = proxiedClient.sendToAI(request, output); + final AIResponse response = proxiedClient.sendToAI(request, output); verify(mockClientStrategy).applyStrategy(mockClient, mockResponseEvaluator, request, output); assertEquals(AIResponse.EMPTY, response); @@ -71,15 +73,19 @@ public void testSendToAI_withValidRequest() { */ @Test public void testSendToAI_withNullOutput() { - AIRequest request = mock(AIRequest.class); - AIResponse response = proxiedClient.sendToAI(request, null); + final AIRequest request = mock(AIRequest.class); + final AIResponseData aiResponseData = mock(AIResponseData.class); + when(mockClientStrategy + .applyStrategy( + eq(mockClient), + eq(mockResponseEvaluator), + eq(request), + any(OutputStream.class))) + .thenReturn(aiResponseData); - verify(mockClientStrategy).applyStrategy( - eq(mockClient), - eq(mockResponseEvaluator), - eq(request), - any(OutputStream.class)); - assertEquals("", response.getResponse()); + final AIResponse response = proxiedClient.sendToAI(request, new ByteArrayOutputStream()); + + assertNull(response.getResponse()); } /** @@ -92,10 +98,10 @@ public void testSendToAI_withNullOutput() { @Test public void testSendToAI_withNoopClient() { proxiedClient = AIProxiedClient.NOOP; - AIRequest request = AIRequest.builder().build(); - OutputStream output = new ByteArrayOutputStream(); + final AIRequest request = AIRequest.builder().build(); + final OutputStream output = new ByteArrayOutputStream(); - AIResponse response = proxiedClient.sendToAI(request, output); + final AIResponse response = proxiedClient.sendToAI(request, output); assertEquals(AIResponse.EMPTY, response); } diff --git a/dotCMS/src/test/java/com/dotcms/analytics/content/ContentAnalyticsAPITest.java b/dotCMS/src/test/java/com/dotcms/analytics/content/ContentAnalyticsAPITest.java index 15dd62e8aa57..908e985d02cd 100644 --- a/dotCMS/src/test/java/com/dotcms/analytics/content/ContentAnalyticsAPITest.java +++ b/dotCMS/src/test/java/com/dotcms/analytics/content/ContentAnalyticsAPITest.java @@ -61,7 +61,7 @@ public void getContentAnalyticsReport() { // ╚══════════════════╝ final AnalyticsQuery analyticsQuery = new AnalyticsQuery.Builder() .measures(Set.of("request.count")) - .orders("request.count DESC") + .order("request.count DESC") .dimensions(Set.of("request.url", "request.pageId", "request.pageTitle")) .filters("request.whatAmI = ['PAGE']") .limit(100) diff --git a/dotCMS/src/test/java/com/dotcms/analytics/content/ContentAnalyticsFactoryTest.java b/dotCMS/src/test/java/com/dotcms/analytics/content/ContentAnalyticsFactoryTest.java index 6a9698cf3cbf..c969130ec436 100644 --- a/dotCMS/src/test/java/com/dotcms/analytics/content/ContentAnalyticsFactoryTest.java +++ b/dotCMS/src/test/java/com/dotcms/analytics/content/ContentAnalyticsFactoryTest.java @@ -87,7 +87,7 @@ public void getContentAnalyticsReport() throws DotDataException, DotSecurityExce final AnalyticsQuery analyticsQuery = new AnalyticsQuery.Builder() .measures(Set.of("request.count")) - .orders("request.count DESC") + .order("request.count DESC") .dimensions(Set.of("request.url", "request.pageId", "request.pageTitle")) .filters("request.whatAmI = ['PAGE']") .limit(100) diff --git a/dotCMS/src/test/java/com/dotcms/analytics/query/AnalyticsQueryParserTest.java b/dotCMS/src/test/java/com/dotcms/analytics/query/AnalyticsQueryParserTest.java index 3a50a0deb821..fe80e3c68215 100644 --- a/dotCMS/src/test/java/com/dotcms/analytics/query/AnalyticsQueryParserTest.java +++ b/dotCMS/src/test/java/com/dotcms/analytics/query/AnalyticsQueryParserTest.java @@ -25,7 +25,7 @@ public class AnalyticsQueryParserTest { * "limit":100, * "offset":1, * "timeDimensions":"Events.day day", - * "orders":"Events.day ASC" + * "order":"Events.day ASC" * } * * 1) parseJsonToQuery should return a valid query based on the parsed @@ -114,7 +114,7 @@ private static AnalyticsQuery testAnalyticsQueryAndReturn(AnalyticsQueryParser a "\t\"limit\":100,\n" + "\t\"offset\":1,\n" + "\t\"timeDimensions\":\"Events.day day\",\n" + - "\t\"orders\":\"Events.day ASC\"\n" + + "\t\"order\":\"Events.day ASC\"\n" + "}"); Assert.assertNotNull("Query can not be null", query); Assert.assertTrue("Dimensions should have Events.experiment:", query.getDimensions().contains("Events.experiment")); @@ -130,7 +130,7 @@ private static AnalyticsQuery testAnalyticsQueryAndReturn(AnalyticsQueryParser a Assert.assertEquals("Time dimensions is wrong", "Events.day day", query.getTimeDimensions()); - Assert.assertEquals("Orders is wrong", "Events.day ASC", query.getOrders()); + Assert.assertEquals("Orders is wrong", "Events.day ASC", query.getOrder()); return query; } @@ -143,7 +143,7 @@ private static AnalyticsQuery testAnalyticsQueryAndReturn(AnalyticsQueryParser a * "limit":100, * "offset":1, * "timeDimensions":Events.day day", - * "orders":"Events.day ASC" + * "order":"Events.day ASC" * } * * should throw an DotRuntimeException @@ -161,7 +161,7 @@ public void test_parsing_a_bad_sintax_query() throws Exception { "\t\"limit\":100,\n" + "\t\"offset\":1,\n" + "\t\"timeDimensions\":Events.day day\",\n" + - "\t\"orders\":\"Events.day ASC\"\n" + + "\t\"order\":\"Events.day ASC\"\n" + "}"); } @@ -175,7 +175,7 @@ public void test_parsing_a_bad_sintax_query() throws Exception { * "limit":100, * "offset":1, * "timeDimensions":"Events.day day", - * "orders":"Events.day XXX" + * "order":"Events.day XXX" * } * * should throw an DotRuntimeException @@ -193,7 +193,7 @@ public void test_parsing_a_bad_sintax_on_query() throws Exception { "\t\"limit\":100,\n" + "\t\"offset\":1,\n" + "\t\"timeDimensions\":\"Events.day day\",\n" + - "\t\"orders\":\"Events.day XXX\"\n" + + "\t\"order\":\"Events.day XXX\"\n" + "}"); } diff --git a/dotCMS/src/test/java/com/dotcms/analytics/viewtool/AnalyticsToolTest.java b/dotCMS/src/test/java/com/dotcms/analytics/viewtool/AnalyticsToolTest.java index a7b87dfe6e83..ef534e71e505 100644 --- a/dotCMS/src/test/java/com/dotcms/analytics/viewtool/AnalyticsToolTest.java +++ b/dotCMS/src/test/java/com/dotcms/analytics/viewtool/AnalyticsToolTest.java @@ -80,7 +80,7 @@ public void test_run_report_from_json_bad_json() { "\t\"limit\":100,\n" + "\t\"offset\":1,\n" + "\t\"timeDimensions\":Events.day day\",\n" + // here is a sintax error - "\t\"orders\":\"Events.day ASC\"\n" + + "\t\"order\":\"Events.day ASC\"\n" + "}"); } @@ -115,7 +115,7 @@ public void test_run_report_from_json_good_json() { "\t\"limit\":100,\n" + "\t\"offset\":1,\n" + "\t\"timeDimensions\":\"Events.day day\",\n" + - "\t\"orders\":\"Events.day ASC\"\n" + + "\t\"order\":\"Events.day ASC\"\n" + "}"); Assert.assertNotNull(reportResponse); @@ -181,7 +181,7 @@ public void test_run_report_from_map_good_map() { queryMap.put("limit", 100); queryMap.put("offset", 1); queryMap.put("timeDimensions", "Events.day day"); - queryMap.put("orders", "Events.day ASC"); + queryMap.put("order", "Events.day ASC"); final ReportResponse reportResponse = analyticsTool.runReportFromMap(queryMap); Assert.assertNotNull(reportResponse); diff --git a/dotCMS/src/test/java/com/dotcms/util/FunctionUtilsTest.java b/dotCMS/src/test/java/com/dotcms/util/FunctionUtilsTest.java index e44757833f30..dc0c81123289 100644 --- a/dotCMS/src/test/java/com/dotcms/util/FunctionUtilsTest.java +++ b/dotCMS/src/test/java/com/dotcms/util/FunctionUtilsTest.java @@ -1,12 +1,17 @@ package com.dotcms.util; import com.dotcms.UnitTestBase; +import com.dotmarketing.portlets.contentlet.business.HostAPI; import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.liferay.portal.model.User; import org.junit.Test; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -66,4 +71,21 @@ public void ifElseTestWithValidOptional() { assertTrue( atomicBoolean.get() ); } + /** + *
    + *
  • Method to test: {@link FunctionUtils#getOrDefault(Supplier, Supplier)}
  • + *
  • Given Scenario: Returns the expected {@code Supplier} based on the result + * of the boolean condition.
  • + *
  • Expected Result: The condition evaluates to {@code true}, so the {@code + * trueSupplier} is returned.
  • + *
+ */ + @Test + public void getOrDefault() { + final Supplier trueSupplier = () -> System.currentTimeMillis() - 50000L; + final Supplier falseSupplier = System::currentTimeMillis; + assertEquals("The value of 'time' should be returned", trueSupplier.get(), + FunctionUtils.getOrDefault(trueSupplier.get() < falseSupplier.get(), trueSupplier, falseSupplier)); + } + } diff --git a/dotCMS/src/test/java/com/dotmarketing/filters/VanityUrlRequestWrapperTest.java b/dotCMS/src/test/java/com/dotmarketing/filters/VanityUrlRequestWrapperTest.java index 51e7748d6c16..55bd6852a4a8 100644 --- a/dotCMS/src/test/java/com/dotmarketing/filters/VanityUrlRequestWrapperTest.java +++ b/dotCMS/src/test/java/com/dotmarketing/filters/VanityUrlRequestWrapperTest.java @@ -13,6 +13,8 @@ import com.dotcms.vanityurl.model.VanityUrlResult; import com.google.common.collect.ImmutableMap; +import static org.junit.Assert.assertEquals; + public class VanityUrlRequestWrapperTest { final String URL = "URL"; @@ -98,4 +100,28 @@ public void test_that_query_string_has_all_the_parameters() { } + + @Test + public void test_that_vanityUrlParams_requestParams_Are_Same_Should_Not_Be_Duped() { + + + final HttpServletRequest baseRequest = new MockHttpRequestUnitTest("testing", "/test?param1=" + URL + "¶m2=" + URL).request(); + + final VanityUrlResult vanityUrlResult = new VanityUrlResult("/newUrl", "param1=" + URL + "¶m2=" + URL, false); + + final HttpServletRequest request = new VanityUrlRequestWrapper(baseRequest, vanityUrlResult); + + final String queryString= request.getQueryString(); + assert(queryString!=null); + assert(!queryString.startsWith("&")); + assert(!queryString.endsWith("&")); + assert(queryString.contains("param1=" + URL)); + assert(queryString.contains("param2=" + URL)); + List queryParams = URLEncodedUtils.parse(queryString, Charset.forName("UTF-8")); + assertEquals("Should be only 2 params since all are the same. Params: " + queryParams,2,queryParams.size()); + + + + + } } diff --git a/dotCMS/src/test/java/com/dotmarketing/util/ContentTypeUtilTest.java b/dotCMS/src/test/java/com/dotmarketing/util/ContentTypeUtilTest.java index 4eba82b8abbf..8292ce143a2b 100644 --- a/dotCMS/src/test/java/com/dotmarketing/util/ContentTypeUtilTest.java +++ b/dotCMS/src/test/java/com/dotmarketing/util/ContentTypeUtilTest.java @@ -1,6 +1,5 @@ package com.dotmarketing.util; -import static com.dotcms.util.CollectionsUtils.list; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -26,6 +25,8 @@ import java.util.stream.Stream; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; + +import org.junit.Before; import org.junit.Test; /** @@ -34,55 +35,139 @@ * @author Freddy Rodriguez */ public class ContentTypeUtilTest extends UnitTestBase { + private static final String CONTENT_TYPE_INODE = "38a3f133-85e1-4b07-b55e-179f38303b90"; + private static final String LAYOUT_ID = "71b8a1ca-37b6-4b6e-a43b-c7482f28db6c"; + private static final String CTX_PATH = "/ctx"; + private static final String ACTION_PARAM = "&p_p_action=1&p_p_state=maximized"; - @Test - public void testGetActionUrl() throws DotDataException { - HttpServletRequest request = mock(HttpServletRequest.class); + private ContentTypeUtil contentTypeUtil; + private HttpServletRequest request; + private LayoutAPI layoutAPI; + private LanguageAPI languageAPI; + private User user; + private Language language; + + @Before + public void setUp() throws DotDataException { + request = mock(HttpServletRequest.class); HttpSession session = mock(HttpSession.class); - LayoutAPI layoutAPI = mock(LayoutAPI.class); + layoutAPI = mock(LayoutAPI.class); LoginServiceAPI loginService = mock(LoginServiceAPI.class); - LanguageAPI languageAPI = mock(LanguageAPI.class); - User user = mock(User.class); - Structure structure = mock(Structure.class); - Language language = mock(Language.class); + languageAPI = mock(LanguageAPI.class); + user = mock(User.class); + language = mock(Language.class); HttpServletRequestThreadLocal httpServletRequestThreadLocal = mock(HttpServletRequestThreadLocal.class); - - ContentTypeUtil contentTypeUtil = new ContentTypeUtil(layoutAPI, languageAPI, - httpServletRequestThreadLocal, loginService); + contentTypeUtil = new ContentTypeUtil(layoutAPI, languageAPI, httpServletRequestThreadLocal, loginService); Layout layout = new Layout(); - layout.setPortletIds(list(PortletID.CONTENT.toString())); - final String layoutId = "71b8a1ca-37b6-4b6e-a43b-c7482f28db6c"; - layout.setId(layoutId); - List layouts = list(layout); + layout.setPortletIds(List.of(PortletID.CONTENT.toString())); + layout.setId(LAYOUT_ID); - when(structure.getStructureType()).thenReturn(1); - final String contentTypeInode = "38a3f133-85e1-4b07-b55e-179f38303b90"; - when(structure.getInode()).thenReturn(contentTypeInode); - when(structure.getModDate()).thenReturn(new Date()); - when(structure.getIDate()).thenReturn(new Date()); - when(structure.getName()).thenReturn("testSt"); - when(structure.getVelocityVarName()).thenReturn("testSt"); - when(layoutAPI.loadLayoutsForUser(user)).thenReturn(layouts); + when(layoutAPI.loadLayoutsForUser(user)).thenReturn(List.of(layout)); when(request.getSession()).thenReturn(session); - when(request.getServerName()).thenReturn("localhost"); - when(session.getAttribute(WebKeys.CTX_PATH)).thenReturn("/ctx"); + when(session.getAttribute(WebKeys.CTX_PATH)).thenReturn(CTX_PATH); when(session.getAttribute(com.dotmarketing.util.WebKeys.CMS_USER)).thenReturn(user); - when(languageAPI.getLanguage(any(), any())).thenReturn(language); - final long languageId = 1L; - when(language.getId()).thenReturn(languageId); when(httpServletRequestThreadLocal.getRequest()).thenReturn(request); when(loginService.getLoggedInUser(request)).thenReturn(user); + } - String expected = "/ctx/portal_public/layout?p_l_id=" + layoutId + "&p_p_id=" + PortletID.CONTENT.toString() + "&p_p_action=1&p_p_state=maximized&_content_inode=&_content_referer=%2Fctx%2Fportal_public%2Flayout%3Fp_l_id%3D" + layout.getId() + "%26p_p_id%3D" + PortletID.CONTENT.toString() + "%26p_p_action%3D1%26p_p_state%3Dmaximized%26_content_inode%3D%26_content_structure_id%3D" + contentTypeInode + "%26_content_cmd%3Dnew%26_content_lang%3D" + languageId + "%26_content_struts_action%3D%252Fext%252Fcontentlet%252Fview_contentlets&_content_selectedStructure=" + contentTypeInode + "&_content_cmd=new&_content_lang=" + languageId + "&_content_struts_action=%2Fext%2Fcontentlet%2Fedit_contentlet"; + /** + * Tests the behavior of {@link ContentTypeUtil#getActionUrl(com.dotcms.contenttype.model.type.ContentType)} method.
+ *

+ * Given: A structure with a specified language ID.
+ * Expected result: The generated action URL matches the expected URL format based on the provided language ID. + *

+ */ + @Test + public void testGetActionUrl() { + final long providedLanguageId = 2L; + + when(languageAPI.getLanguage(any(), any())).thenReturn(language); + when(language.getId()).thenReturn(providedLanguageId); + + Structure structure = createMockStructure(); + String expectedUrl = generateExpectedUrl(LAYOUT_ID, CONTENT_TYPE_INODE, String.valueOf(providedLanguageId)); String actionUrl = contentTypeUtil.getActionUrl(new StructureTransformer(structure).from()); - System.out.println("actionUrl = " + actionUrl); - // Parse the URLs into maps of query parameters - Map expectedParams = parseQueryParams(expected); - Map actualParams = parseQueryParams(actionUrl); + assertUrlMatchesExpected(actionUrl, expectedUrl); + } + + /** + * Tests the behavior of {@link ContentTypeUtil#getActionUrl(HttpServletRequest, String, User, String, String)} method + * when a language ID is provided.
+ *

+ * Given: A request, user, content type inode, and an action path with a specified language ID.
+ * Expected result: The generated action URL matches the expected URL format based on the provided language ID. + *

+ */ + @Test + public void testGetActionUrl_withLanguageId() { + final long providedLanguageId = 2L; + + String expectedUrl = generateExpectedUrl(LAYOUT_ID, CONTENT_TYPE_INODE, String.valueOf(providedLanguageId)); + String actionUrl = contentTypeUtil.getActionUrl(request, CONTENT_TYPE_INODE, user, "/ext/contentlet/edit_contentlet", String.valueOf(providedLanguageId)); + + assertUrlMatchesExpected(actionUrl, expectedUrl); + } + + /** + * Tests the behavior of {@link ContentTypeUtil#getActionUrl(HttpServletRequest, String, User, String, String)} method + * when no language ID is provided, falling back to the default language.
+ *

+ * Given: A request, user, content type inode, and an action path with no specified language ID.
+ * Expected result: The generated action URL matches the expected URL format based on the default language ID. + *

+ */ + @Test + public void testGetActionUrl_withoutLanguageId() { + final long fallbackLanguageId = 1L; + when(languageAPI.getDefaultLanguage()).thenReturn(language); + when(language.getId()).thenReturn(fallbackLanguageId); + + String expectedUrl = generateExpectedUrl(LAYOUT_ID, CONTENT_TYPE_INODE, String.valueOf(fallbackLanguageId)); + String actionUrl = contentTypeUtil.getActionUrl(request, CONTENT_TYPE_INODE, user, "/ext/contentlet/edit_contentlet", null); + + assertUrlMatchesExpected(actionUrl, expectedUrl); + } + + private String generateExpectedUrl(String layoutId, String contentTypeInode, String languageId) { + return "/ctx/portal_public/layout" + + "?p_l_id=" + layoutId + + "&p_p_id=" + PortletID.CONTENT + + "&p_p_action=1" + + "&p_p_state=maximized" + + "&_content_inode=" + + "&_content_referer=%2Fctx%2Fportal_public%2Flayout%3Fp_l_id%3D" + layoutId + + "%26p_p_id%3D" + PortletID.CONTENT + + "%26p_p_action%3D1" + + "%26p_p_state%3Dmaximized" + + "%26_content_inode%3D" + + "%26_content_structure_id%3D" + contentTypeInode + + "%26_content_cmd%3Dnew" + + "%26_content_lang%3D" + languageId + + "%26_content_struts_action%3D%252Fext%252Fcontentlet%252Fview_contentlets" + + "&_content_selectedStructure=" + contentTypeInode + + "&_content_cmd=new" + + "&_content_lang=" + languageId + + "&_content_struts_action=%2Fext%2Fcontentlet%2Fedit_contentlet"; + } + + + private Structure createMockStructure() { + Structure structure = mock(Structure.class); + when(structure.getStructureType()).thenReturn(1); + when(structure.getInode()).thenReturn(CONTENT_TYPE_INODE); + when(structure.getModDate()).thenReturn(new Date()); + when(structure.getIDate()).thenReturn(new Date()); + when(structure.getName()).thenReturn("testSt"); + when(structure.getVelocityVarName()).thenReturn("testSt"); + return structure; + } + private void assertUrlMatchesExpected(String actualUrl, String expectedUrl) { + Map expectedParams = parseQueryParams(expectedUrl); + Map actualParams = parseQueryParams(actualUrl); assertEquals("The expected actionUrl parameters are not the same as the ones generated by the Util.", expectedParams, actualParams); } diff --git a/dotCMS/src/test/resources/META-INF/beans.xml b/dotCMS/src/test/resources/META-INF/beans.xml deleted file mode 100644 index 1675ad7ab74c..000000000000 --- a/dotCMS/src/test/resources/META-INF/beans.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/dotcms-integration/src/test/java/com/dotcms/DataProviderWeldRunner.java b/dotcms-integration/src/test/java/com/dotcms/DataProviderWeldRunner.java new file mode 100644 index 000000000000..ddfe15f5a0af --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/DataProviderWeldRunner.java @@ -0,0 +1,41 @@ +package com.dotcms; + +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import org.jboss.weld.environment.se.Weld; +import org.jboss.weld.environment.se.WeldContainer; +import org.junit.runners.model.InitializationError; + +/** + * Annotate your JUnit4 test using {@code @DataProviderRunner} class with {@code @RunWith(DataProviderWeldRunner.class)} to run it with Weld container. + */ +public class DataProviderWeldRunner extends DataProviderRunner { + + private static final Weld WELD; + private static final WeldContainer CONTAINER; + + static { + WELD = new Weld("DataProviderWeldRunner"); + CONTAINER = WELD.initialize(); + } + + /** + * Creates a DataProviderRunner to run supplied {@code clazz}. + * + * @param clazz the test {@link Class} to run + * @throws InitializationError if the test {@link Class} is malformed. + */ + public DataProviderWeldRunner(Class clazz) throws InitializationError { + super(clazz); + } + + /** + * Create the test instance using Weld container. + * @return the test instance + * @throws Exception if something goes wrong + */ + @Override + protected Object createTest() throws Exception { + return CONTAINER.instance().select(getTestClass().getJavaClass()).get(); + } + +} diff --git a/dotcms-integration/src/test/java/com/dotcms/IntegrationTestBase.java b/dotcms-integration/src/test/java/com/dotcms/IntegrationTestBase.java index 01fc89e82008..427dee4fd2ea 100644 --- a/dotcms-integration/src/test/java/com/dotcms/IntegrationTestBase.java +++ b/dotcms-integration/src/test/java/com/dotcms/IntegrationTestBase.java @@ -31,8 +31,6 @@ import java.io.UnsupportedEncodingException; import java.util.List; import org.apache.commons.io.FileUtils; -import org.jboss.weld.environment.se.Weld; -import org.jboss.weld.environment.se.WeldContainer; import org.junit.After; import org.junit.AfterClass; import org.junit.Assert; @@ -56,8 +54,6 @@ public abstract class IntegrationTestBase extends BaseMessageResources { private final static PrintStream stdout = System.out; private final static ByteArrayOutputStream output = new ByteArrayOutputStream(); - private static WeldContainer weld; - @Rule public TestName name = new TestName(); @@ -281,15 +277,4 @@ protected T wrapOnReadOnlyConn(final ReturnableDelegate supplier) throw } } - @BeforeClass - public static void initWeld() { - weld = new Weld().containerId("IntegrationTestBase").initialize(); - } - - @AfterClass - public static void cleanupWeld() { - if( null != weld && weld.isRunning() ){ - weld.shutdown(); - } - } } diff --git a/dotcms-integration/src/test/java/com/dotcms/JUnit4WeldRunner.java b/dotcms-integration/src/test/java/com/dotcms/JUnit4WeldRunner.java new file mode 100644 index 000000000000..cbe54dad21fc --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/JUnit4WeldRunner.java @@ -0,0 +1,40 @@ +package com.dotcms; + +import org.jboss.weld.environment.se.Weld; +import org.jboss.weld.environment.se.WeldContainer; +import org.junit.runners.BlockJUnit4ClassRunner; +import org.junit.runners.model.InitializationError; + +/** + * Annotate your JUnit4 test class with {@code @RunWith(JUnit4WeldRunner.class)} to run it with Weld container. + */ +public class JUnit4WeldRunner extends BlockJUnit4ClassRunner { + + private static final Weld WELD; + private static final WeldContainer CONTAINER; + + static { + WELD = new Weld("JUnit4WeldRunner"); + CONTAINER = WELD.initialize(); + } + + /** + * Creates a DataProviderRunner to run supplied {@code clazz}. + * + * @param clazz the test {@link Class} to run + * @throws InitializationError if the test {@link Class} is malformed. + */ + public JUnit4WeldRunner(Class clazz) throws InitializationError { + super(clazz); + } + + /** + * Create the test instance using Weld container. + * @return the test instance + * @throws Exception if something goes wrong + */ + @Override + protected Object createTest() throws Exception { + return CONTAINER.instance().select(getTestClass().getJavaClass()).get(); + } +} diff --git a/dotcms-integration/src/test/java/com/dotcms/Junit5Suite1.java b/dotcms-integration/src/test/java/com/dotcms/Junit5Suite1.java index a0003e96d56d..4055eca82377 100644 --- a/dotcms-integration/src/test/java/com/dotcms/Junit5Suite1.java +++ b/dotcms-integration/src/test/java/com/dotcms/Junit5Suite1.java @@ -2,6 +2,7 @@ import com.dotcms.jobs.business.api.JobQueueManagerAPICDITest; import com.dotcms.jobs.business.api.JobQueueManagerAPIIntegrationTest; +import com.dotcms.jobs.business.processor.impl.ImportContentletsProcessorIntegrationTest; import com.dotcms.jobs.business.queue.PostgresJobQueueIntegrationTest; import com.dotcms.rest.api.v1.job.JobQueueHelperIntegrationTest; import org.junit.platform.suite.api.SelectClasses; @@ -12,7 +13,8 @@ JobQueueManagerAPICDITest.class, PostgresJobQueueIntegrationTest.class, JobQueueManagerAPIIntegrationTest.class, - JobQueueHelperIntegrationTest.class + JobQueueHelperIntegrationTest.class, + ImportContentletsProcessorIntegrationTest.class }) public class Junit5Suite1 { diff --git a/dotcms-integration/src/test/java/com/dotcms/Junit5WeldBaseTest.java b/dotcms-integration/src/test/java/com/dotcms/Junit5WeldBaseTest.java new file mode 100644 index 000000000000..93a7326368a8 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/Junit5WeldBaseTest.java @@ -0,0 +1,16 @@ +package com.dotcms; + +import org.jboss.weld.junit5.WeldInitiator; +import org.jboss.weld.junit5.WeldSetup; + + +public abstract class Junit5WeldBaseTest { + + @WeldSetup + public static WeldInitiator weldInitiator = WeldInitiator.of( + WeldInitiator.createWeld() + .containerId("Junit5WeldBaseTest") + .enableDiscovery() + ); + +} diff --git a/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java b/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java index 86b17f60063f..4b716d43c119 100644 --- a/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java +++ b/dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java @@ -10,6 +10,13 @@ import com.dotcms.ai.viewtool.EmbeddingsToolTest; import com.dotcms.ai.viewtool.SearchToolTest; import com.dotcms.ai.workflow.OpenAIContentPromptActionletTest; +import com.dotcms.analytics.track.collectors.AsyncVanitiesCollectorTest; +import com.dotcms.analytics.track.collectors.BasicProfileCollectorTest; +import com.dotcms.analytics.track.collectors.FilesCollectorTest; +import com.dotcms.analytics.track.collectors.PageDetailCollectorTest; +import com.dotcms.analytics.track.collectors.PagesCollectorTest; +import com.dotcms.analytics.track.collectors.SyncVanitiesCollectorTest; +import com.dotcms.analytics.track.collectors.WebEventsCollectorServiceImplTest; import com.dotcms.auth.providers.saml.v1.DotSamlResourceTest; import com.dotcms.auth.providers.saml.v1.SAMLHelperTest; import com.dotcms.bayesian.BayesianAPIImplIT; @@ -18,15 +25,17 @@ import com.dotcms.cache.lettuce.DotObjectCodecTest; import com.dotcms.cache.lettuce.LettuceCacheTest; import com.dotcms.cache.lettuce.RedisClientTest; +import com.dotcms.cdi.SimpleDataProviderWeldRunnerInjectionIT; import com.dotcms.cdi.SimpleInjectionIT; +import com.dotcms.cdi.SimpleJUnit4InjectionIT; import com.dotcms.content.business.ObjectMapperTest; import com.dotcms.content.business.json.ContentletJsonAPITest; import com.dotcms.content.business.json.LegacyJSONObjectRenderTest; import com.dotcms.content.elasticsearch.business.ESIndexAPITest; import com.dotcms.content.model.hydration.MetadataDelegateTest; -import com.dotcms.contenttype.business.ContentTypeDestroyAPIImplTest; -import com.dotcms.contenttype.business.ContentTypeInitializerTest; -import com.dotcms.contenttype.business.StoryBlockAPITest; +import com.dotcms.contenttype.business.*; +import com.dotcms.contenttype.business.uniquefields.extratable.DBUniqueFieldValidationStrategyTest; +import com.dotcms.contenttype.business.uniquefields.extratable.UniqueFieldDataBaseUtilTest; import com.dotcms.csspreproc.CSSCacheTest; import com.dotcms.csspreproc.CSSPreProcessServletTest; import com.dotcms.dotpubsub.RedisPubSubImplTest; @@ -102,7 +111,7 @@ import com.dotmarketing.common.db.DBTimeZoneCheckTest; import com.dotmarketing.filters.AutoLoginFilterTest; import com.dotmarketing.filters.CMSUrlUtilIntegrationTest; -import com.dotmarketing.osgi.GenericBundleActivatorTest; +import com.dotmarketing.osgi.GenericBundleActivatorIntegrationTest; import com.dotmarketing.portlets.browser.BrowserUtilTest; import com.dotmarketing.portlets.browser.ajax.BrowserAjaxTest; import com.dotmarketing.portlets.categories.business.CategoryFactoryTest; @@ -226,7 +235,7 @@ Task201102UpdateColumnSitelicTableTest.class, DependencyManagerTest.class, com.dotcms.rest.api.v1.versionable.VersionableResourceTest.class, - GenericBundleActivatorTest.class, + GenericBundleActivatorIntegrationTest.class, SAMLHelperTest.class, PermissionHelperTest.class, ResetPasswordTokenUtilTest.class, @@ -385,12 +394,25 @@ JobQueueManagerAPITest.class, ConfigUtilsTest.class, SimpleInjectionIT.class, + SimpleDataProviderWeldRunnerInjectionIT.class, + SimpleJUnit4InjectionIT.class, LegacyJSONObjectRenderTest.class, Task241013RemoveFullPathLcColumnFromIdentifierTest.class, Task241009CreatePostgresJobQueueTablesTest.class, + + UniqueFieldDataBaseUtilTest.class, + DBUniqueFieldValidationStrategyTest.class, + Task241013RemoveFullPathLcColumnFromIdentifierTest.class, Task241013RemoveFullPathLcColumnFromIdentifierTest.class, Task241015ReplaceLanguagesWithLocalesPortletTest.class, - Task241016AddCustomLanguageVariablesPortletToLayoutTest.class + Task241016AddCustomLanguageVariablesPortletToLayoutTest.class, + WebEventsCollectorServiceImplTest.class, + BasicProfileCollectorTest.class, + PagesCollectorTest.class, + PageDetailCollectorTest.class, + FilesCollectorTest.class, + SyncVanitiesCollectorTest.class, + AsyncVanitiesCollectorTest.class }) public class MainSuite2b { diff --git a/dotcms-integration/src/test/java/com/dotcms/TestBaseJunit5WeldInitiator.java b/dotcms-integration/src/test/java/com/dotcms/TestBaseJunit5WeldInitiator.java deleted file mode 100644 index b65843c090dc..000000000000 --- a/dotcms-integration/src/test/java/com/dotcms/TestBaseJunit5WeldInitiator.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.dotcms; - -import com.dotcms.jobs.business.api.JobProcessorFactory; -import com.dotcms.jobs.business.api.JobProcessorScanner; -import com.dotcms.jobs.business.api.JobQueueConfig; -import com.dotcms.jobs.business.api.JobQueueConfigProducer; -import com.dotcms.jobs.business.api.JobQueueManagerAPIImpl; -import com.dotcms.jobs.business.api.events.EventProducer; -import com.dotcms.jobs.business.api.events.RealTimeJobMonitor; -import com.dotcms.jobs.business.error.CircuitBreaker; -import com.dotcms.jobs.business.error.RetryStrategy; -import com.dotcms.jobs.business.error.RetryStrategyProducer; -import com.dotcms.jobs.business.queue.JobQueue; -import com.dotcms.jobs.business.queue.JobQueueProducer; -import com.dotcms.rest.api.v1.job.JobQueueHelper; -import org.jboss.weld.bootstrap.api.helpers.RegistrySingletonProvider; -import org.jboss.weld.junit5.WeldInitiator; -import org.jboss.weld.junit5.WeldJunit5Extension; -import org.jboss.weld.junit5.WeldSetup; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.extension.ExtendWith; - -@ExtendWith(WeldJunit5Extension.class) -public class TestBaseJunit5WeldInitiator { - - @WeldSetup - public static WeldInitiator weld = WeldInitiator.of( - WeldInitiator.createWeld() - .containerId(RegistrySingletonProvider.STATIC_INSTANCE) - .beanClasses(JobQueueManagerAPIImpl.class, JobQueueConfig.class, - JobQueue.class, RetryStrategy.class, CircuitBreaker.class, - JobQueueProducer.class, JobQueueConfigProducer.class, - RetryStrategyProducer.class, RealTimeJobMonitor.class, - EventProducer.class, JobProcessorFactory.class, JobQueueHelper.class, - JobProcessorScanner.class - ) - ); - - @AfterAll - public static void tearDown() { - if (weld != null && weld.isRunning()) { - weld.shutdown(); - weld = null; - } - } - -} diff --git a/dotcms-integration/src/test/java/com/dotcms/ai/client/AIProxyClientTest.java b/dotcms-integration/src/test/java/com/dotcms/ai/client/AIProxyClientTest.java index af100d725f2f..5fa1d0f05213 100644 --- a/dotcms-integration/src/test/java/com/dotcms/ai/client/AIProxyClientTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/ai/client/AIProxyClientTest.java @@ -238,6 +238,7 @@ public void test_callToAI_withProvidedOutput() throws Exception { final JSONObjectAIRequest request = textRequest( model, "What are the major achievements of the Apollo space program?"); + request.getPayload().put(AiKeys.STREAM, true); final AIResponse aiResponse = aiProxyClient.callToAI( request, diff --git a/dotcms-integration/src/test/java/com/dotcms/ai/validator/AIAppValidatorTest.java b/dotcms-integration/src/test/java/com/dotcms/ai/validator/AIAppValidatorTest.java index 8c7379ab6313..a429c08c415f 100644 --- a/dotcms-integration/src/test/java/com/dotcms/ai/validator/AIAppValidatorTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/ai/validator/AIAppValidatorTest.java @@ -3,6 +3,7 @@ import com.dotcms.ai.AiTest; import com.dotcms.ai.app.AppConfig; import com.dotcms.ai.app.ConfigService; +import com.dotcms.ai.client.JSONObjectAIRequest; import com.dotcms.api.system.event.message.SystemMessageEventUtil; import com.dotcms.api.system.event.message.builder.SystemMessage; import com.dotcms.datagen.SiteDataGen; @@ -101,7 +102,8 @@ public void test_validateModelsUsage() throws Exception { AiTest.aiAppSecrets(host, invalidModels, "dall-e-3", "text-embedding-ada-002"); appConfig = ConfigService.INSTANCE.config(host); - validator.validateModelsUsage(appConfig.getModel(), user.getUserId()); + final JSONObjectAIRequest request = JSONObjectAIRequest.builder().withUserId("jon.snow").build(); + validator.validateModelsUsage(appConfig.getModel(), request); verify(systemMessageEventUtil, atLeast(2)) .pushMessage(any(SystemMessage.class), anyList()); diff --git a/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/AsyncVanitiesCollectorTest.java b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/AsyncVanitiesCollectorTest.java new file mode 100644 index 000000000000..41634a2fcf9a --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/AsyncVanitiesCollectorTest.java @@ -0,0 +1,116 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.IntegrationTestBase; +import com.dotcms.LicenseTestUtil; +import com.dotcms.datagen.ContentletDataGen; +import com.dotcms.datagen.HTMLPageDataGen; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.util.FiltersUtil; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.vanityurl.model.CachedVanityUrl; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.filters.Constants; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.htmlpageasset.model.HTMLPageAsset; +import com.dotmarketing.portlets.languagesmanager.model.Language; +import com.dotmarketing.portlets.templates.model.Template; +import com.dotmarketing.util.UUIDUtil; +import com.dotmarketing.util.UtilMethods; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Map; +import java.util.Optional; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Verifies that the {@link AsyncVanitiesCollector} is able to collect the necessary data. + * + * @author Jose Castro + * @since Oct 21st, 2024 + */ +public class AsyncVanitiesCollectorTest extends IntegrationTestBase { + + private static final String TEST_PAGE_NAME = "index"; + private static final String TEST_PAGE_URL = "/" + TEST_PAGE_NAME; + private static final String URI = "/my-test/vanity-url"; + + private static Host testSite = null; + + @BeforeClass + public static void prepare() throws Exception { + // Setting web app environment + IntegrationTestInitService.getInstance().init(); + LicenseTestUtil.getLicense(); + + final long millis = System.currentTimeMillis(); + final String siteName = "www.myTestSite-" + millis + ".com"; + testSite = new SiteDataGen().name(siteName).nextPersisted(); + } + + /** + *
    + *
  • Method to test: + * {@link AsyncVanitiesCollector#collect(CollectorContextMap, CollectorPayloadBean)} + *
  • + *
  • Given Scenario: Calls the collector for a Vanity URL asynchronously.
  • + *
  • Expected Result: The returned data must be equal to the expected one.
  • + *
+ */ + @Test + public void collectAsyncVanityData() throws DotDataException, IOException, + DotSecurityException { + final Language defaultLanguage = APILocator.getLanguageAPI().getDefaultLanguage(); + final HttpServletResponse response = mock(HttpServletResponse.class); + final String requestId = UUIDUtil.uuid(); + final Map requestAttrs = Map.of( + Constants.CMS_FILTER_URI_OVERRIDE, TEST_PAGE_URL + ); + final HttpServletRequest request = Util.mockHttpRequestObj(response, URI, requestId, + APILocator.getUserAPI().getAnonymousUser(), requestAttrs, null); + + // Vanity URL will point to this HTML Page + final HTMLPageAsset testHTMLPage = Util.createTestHTMLPage(testSite, TEST_PAGE_NAME); + + assertNotNull("Test HTML Page cannot be null", testHTMLPage); + + final Optional resolvedVanity = Util.createAndResolveVanityURL(testSite, + "My Test Vanity", URI, TEST_PAGE_URL); + + assertTrue("Resolved vanity url must be present", resolvedVanity.isPresent()); + + final Map expectedDataMap = Map.of( + "event_type", EventType.PAGE_REQUEST.getType(), + "host", testSite.getIdentifier(), + "comeFromVanityURL", true, + "language", defaultLanguage.getIsoCode(), + "url", TEST_PAGE_URL, + "object", Map.of( + "id", testHTMLPage.getIdentifier(), + "title", TEST_PAGE_NAME, + "url", TEST_PAGE_URL) + ); + + final Collector collector = new AsyncVanitiesCollector(); + final Map contextMap = Map.of( + "uri", URI, + "currentHost", testSite, + Constants.VANITY_URL_OBJECT, resolvedVanity.get()); + final CollectorPayloadBean collectedData = + Util.getRequestCharacterCollectorPayloadBean(request, collector, contextMap); + + assertTrue("Collected data map cannot be null or empty", UtilMethods.isSet(collectedData)); + + Util.validateExpectedEntries(expectedDataMap, collectedData); + } + +} diff --git a/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/BasicProfileCollectorTest.java b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/BasicProfileCollectorTest.java new file mode 100644 index 000000000000..50ba30bfebbf --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/BasicProfileCollectorTest.java @@ -0,0 +1,87 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.IntegrationTestBase; +import com.dotcms.LicenseTestUtil; +import com.dotcms.analytics.track.matchers.PagesAndUrlMapsRequestMatcher; +import com.dotcms.enterprise.cluster.ClusterFactory; +import com.dotcms.util.IntegrationTestInitService; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.util.UUIDUtil; +import com.dotmarketing.util.UtilMethods; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.net.UnknownHostException; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * + * @author Jose Castro + * @since Oct 9th, 2024 + */ +public class BasicProfileCollectorTest extends IntegrationTestBase { + + private static String CLUSTER_ID = null; + private static String SERVER_ID = null; + + @BeforeClass + public static void prepare() throws Exception { + // Setting web app environment + IntegrationTestInitService.getInstance().init(); + LicenseTestUtil.getLicense(); + + CLUSTER_ID = APILocator.getShortyAPI().shortify(ClusterFactory.getClusterId()); + SERVER_ID = APILocator.getShortyAPI().shortify(APILocator.getServerAPI().readServerId()); + } + + /** + *
    + *
  • Method to test: {@link }
  • + *
  • Given Scenario:
  • + *
  • Expected Result:
  • + *
+ */ + @Test + public void collectBasicProfileData() throws DotDataException, UnknownHostException { + final HttpServletResponse response = mock(HttpServletResponse.class); + final String requestId = UUIDUtil.uuid(); + final HttpServletRequest request = Util.mockHttpRequestObj(response, "/", requestId, + APILocator.getUserAPI().getAnonymousUser()); + final Map expectedDataMap = Map.of( + "renderMode", "LIVE", + "cluster", CLUSTER_ID, + "server", SERVER_ID, + "persona", "dot:default", + "utc_time", "2024-10-09T00:00:00.000000Z", + "sessionNew", true, + "userAgent", Util.USER_AGENT, + "sessionId", "DAA3339CD687D9ABD4101CF9EDDD42DB", + "request_id", requestId + ); + final Collector collector = new BasicProfileCollector(); + final CollectorPayloadBean collectedData = Util.getCollectorPayloadBean(request, collector, new PagesAndUrlMapsRequestMatcher(), null); + + assertTrue("Collected data map cannot be null or empty", UtilMethods.isSet(collectedData)); + + int counter = 0; + for (final String key : expectedDataMap.keySet()) { + if (collectedData.toMap().containsKey(key)) { + final Object expectedValue = expectedDataMap.get(key); + final Object collectedValue = collectedData.toMap().get(key); + if (!"utc_time".equalsIgnoreCase(key)) { + assertEquals("Collected value must be equal to expected value for key: " + key, expectedValue, collectedValue); + } + counter++; + } + } + assertEquals("Number of returned expected properties doesn't match", counter, expectedDataMap.size()); + } + +} diff --git a/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/FilesCollectorTest.java b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/FilesCollectorTest.java new file mode 100644 index 000000000000..99db1867f220 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/FilesCollectorTest.java @@ -0,0 +1,98 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.IntegrationTestBase; +import com.dotcms.LicenseTestUtil; +import com.dotcms.analytics.track.matchers.FilesRequestMatcher; +import com.dotcms.datagen.ContentletDataGen; +import com.dotcms.datagen.FileAssetDataGen; +import com.dotcms.datagen.FolderDataGen; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.util.IntegrationTestInitService; +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.fileassets.business.FileAsset; +import com.dotmarketing.portlets.folders.model.Folder; +import com.dotmarketing.util.PageMode; +import com.dotmarketing.util.UUIDUtil; +import com.dotmarketing.util.UtilMethods; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Verifies that the {@link FilesCollector} is able to collect the necessary data. + * + * @author Jose Castro + * @since Oct 16th, 2024 + */ +public class FilesCollectorTest extends IntegrationTestBase { + + private static final String PARENT_FOLDER_1_NAME = "parent-folder"; + + private static Host testSite = null; + + @BeforeClass + public static void prepare() throws Exception { + // Setting web app environment + IntegrationTestInitService.getInstance().init(); + LicenseTestUtil.getLicense(); + + final long millis = System.currentTimeMillis(); + final String siteName = "www.myTestSite-" + millis + ".com"; + testSite = new SiteDataGen().name(siteName).nextPersisted(); + } + + /** + *
    + *
  • Method to test: {@link }
  • + *
  • Given Scenario:
  • + *
  • Expected Result:
  • + *
+ */ + @Test + public void collectFileData() throws DotDataException, IOException, DotSecurityException { + final FileAsset testFileAsset = Util.createTestFileAsset("my-test-file_" + System.currentTimeMillis(), + ".txt","Sample content for my test file", PARENT_FOLDER_1_NAME, testSite); + + final HttpServletResponse response = mock(HttpServletResponse.class); + final String requestId = UUIDUtil.uuid(); + final HttpServletRequest request = Util.mockHttpRequestObj(response, testFileAsset.getURI(), requestId, + APILocator.getUserAPI().getAnonymousUser()); + + final Map expectedDataMap = Map.of( + "host", "localhost:8080", + "site", testSite.getIdentifier(), + "language", APILocator.getLanguageAPI().getDefaultLanguage().getIsoCode(), + "event_type", EventType.FILE_REQUEST.getType(), + "url", testFileAsset.getURI(), + "object", Map.of( + "id", testFileAsset.getIdentifier(), + "title", testFileAsset.getTitle(), + "url", testFileAsset.getURI()) + ); + + final Collector collector = new FilesCollector(); + final Map contextMap = Map.of( + "uri", testFileAsset.getURI(), + "pageMode", PageMode.LIVE, + "currentHost", testSite, + "requestId", requestId); + final CollectorPayloadBean collectedData = Util.getCollectorPayloadBean(request, collector, new FilesRequestMatcher(), contextMap); + + assertTrue("Collected data map cannot be null or empty", UtilMethods.isSet(collectedData)); + + Util.validateExpectedEntries(expectedDataMap, collectedData); + } + +} diff --git a/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/PageDetailCollectorTest.java b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/PageDetailCollectorTest.java new file mode 100644 index 000000000000..17b67a72dbf2 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/PageDetailCollectorTest.java @@ -0,0 +1,130 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.IntegrationTestBase; +import com.dotcms.LicenseTestUtil; +import com.dotcms.analytics.track.matchers.PagesAndUrlMapsRequestMatcher; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.datagen.ContentletDataGen; +import com.dotcms.datagen.FolderDataGen; +import com.dotcms.datagen.HTMLPageDataGen; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.visitor.filter.characteristics.CharacterWebAPI; +import com.dotcms.visitor.filter.characteristics.GDPRCharacter; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.contentlet.model.IndexPolicy; +import com.dotmarketing.portlets.folders.model.Folder; +import com.dotmarketing.portlets.htmlpageasset.model.HTMLPageAsset; +import com.dotmarketing.portlets.languagesmanager.model.Language; +import com.dotmarketing.portlets.templates.model.Template; +import com.dotmarketing.util.PageMode; +import com.dotmarketing.util.UUIDUtil; +import com.dotmarketing.util.UtilMethods; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.net.UnknownHostException; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Verifies that the {@link PageDetailCollector} is able to collect the necessary data. + * + * @author Jose Castro + * @since Oct 14th, 2024 + */ +public class PageDetailCollectorTest extends IntegrationTestBase { + + private static final String PARENT_FOLDER_1_NAME = "news"; + private static final String TEST_URL_MAP_PAGE_NAME = "news-detail"; + private static final String TEST_PATTERN = "/testpattern/"; + private static final String TEST_URL_MAP_DETAIL_PAGE_URL = TEST_PATTERN + "mynews"; + + private static Host testSite = null; + + @BeforeClass + public static void prepare() throws Exception { + // Setting web app environment + IntegrationTestInitService.getInstance().init(); + LicenseTestUtil.getLicense(); + + final long millis = System.currentTimeMillis(); + final String siteName = "www.myTestSite-" + millis + ".com"; + testSite = new SiteDataGen().name(siteName).nextPersisted(); + } + + /** + *
    + *
  • Method to test: + * {@link PageDetailCollector#collect(CollectorContextMap, CollectorPayloadBean)}
  • + *
  • Given Scenario: Calls the collector for a URL Mapped HTML Page.
  • + *
  • Expected Result: The collected data for a URL Mapped page must be equal to + * the expected attributes.
  • + *
+ */ + @Test + public void testPageDetailCollector() throws DotDataException, UnknownHostException { + final HttpServletResponse response = mock(HttpServletResponse.class); + final String requestId = UUIDUtil.uuid(); + final HttpServletRequest request = Util.mockHttpRequestObj(response, + TEST_URL_MAP_DETAIL_PAGE_URL, requestId, + APILocator.getUserAPI().getAnonymousUser()); + + final HTMLPageAsset testDetailPage = Util.createTestHTMLPage(testSite, + TEST_URL_MAP_PAGE_NAME, PARENT_FOLDER_1_NAME); + + final String urlTitle = "mynews"; + final String urlMapPatternToUse = TEST_PATTERN + "{urlTitle}"; + final Language language = APILocator.getLanguageAPI().getDefaultLanguage(); + final long langId = language.getId(); + + final ContentType urlMappedContentType = Util.getUrlMapLikeContentType( + "News_" + System.currentTimeMillis(), + testSite, + testDetailPage.getIdentifier(), + urlMapPatternToUse); + final ContentletDataGen contentletDataGen = new ContentletDataGen(urlMappedContentType.id()) + .languageId(langId) + .host(testSite) + .setProperty("hostfolder", testSite) + .setProperty("urlTitle", urlTitle) + .setPolicy(IndexPolicy.WAIT_FOR); + final Contentlet newsTestContent = contentletDataGen.nextPersisted(); + ContentletDataGen.publish(newsTestContent); + + final Map expectedDataMap = Map.of( + "event_type", EventType.PAGE_REQUEST.getType(), + "host", testSite.getIdentifier(), + "language", language.getIsoCode(), + "url", TEST_URL_MAP_DETAIL_PAGE_URL, + "object", Map.of( + "id", testDetailPage.getIdentifier(), + "title", testDetailPage.getTitle(), + "url", TEST_URL_MAP_DETAIL_PAGE_URL, + "detail_page_url", testDetailPage.getURI()) + ); + + final Collector collector = new PageDetailCollector(); + final Map contextMap = Map.of( + "uri", testDetailPage.getURI(), + "pageMode", PageMode.LIVE, + "currentHost", testSite, + "requestId", requestId); + final CollectorPayloadBean collectedData = Util.getCollectorPayloadBean(request, + collector, new PagesAndUrlMapsRequestMatcher(), contextMap); + + assertTrue("Collected data map cannot be null or empty", UtilMethods.isSet(collectedData)); + + Util.validateExpectedEntries(expectedDataMap, collectedData); + } + +} diff --git a/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/PagesCollectorTest.java b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/PagesCollectorTest.java new file mode 100644 index 000000000000..245b4a54d28d --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/PagesCollectorTest.java @@ -0,0 +1,181 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.IntegrationTestBase; +import com.dotcms.LicenseTestUtil; +import com.dotcms.analytics.track.matchers.PagesAndUrlMapsRequestMatcher; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.datagen.ContentletDataGen; +import com.dotcms.datagen.FolderDataGen; +import com.dotcms.datagen.HTMLPageDataGen; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.visitor.filter.characteristics.CharacterWebAPI; +import com.dotcms.visitor.filter.characteristics.GDPRCharacter; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.contentlet.model.IndexPolicy; +import com.dotmarketing.portlets.folders.model.Folder; +import com.dotmarketing.portlets.htmlpageasset.model.HTMLPageAsset; +import com.dotmarketing.portlets.templates.model.Template; +import com.dotmarketing.util.PageMode; +import com.dotmarketing.util.UUIDUtil; +import com.dotmarketing.util.UtilMethods; +import io.vavr.API; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.net.UnknownHostException; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Verifies that the {@link PagesCollector} is able to collect the necessary data. + * + * @author Jose Castro + * @since Oct 9th, 2024 + */ +public class PagesCollectorTest extends IntegrationTestBase { + + private static final String TEST_PAGE_NAME = "index"; + private static final String TEST_PAGE_URL = "/" + TEST_PAGE_NAME; + + private static final String PARENT_FOLDER_1_NAME = "news"; + private static final String TEST_URL_MAP_PAGE_NAME = "news-detail"; + private static final String URL_MAP_PATTERN = "/testpattern/"; + private static final String TEST_URL_MAP_DETAIL_PAGE_URL = URL_MAP_PATTERN + "mynews"; + + private static Host testSite = null; + + @BeforeClass + public static void prepare() throws Exception { + // Setting web app environment + IntegrationTestInitService.getInstance().init(); + LicenseTestUtil.getLicense(); + + final long millis = System.currentTimeMillis(); + final String siteName = "www.myTestSite-" + millis + ".com"; + testSite = new SiteDataGen().name(siteName).nextPersisted(); + } + + /** + *
    + *
  • Method to test: + * {@link PagesCollector#collect(CollectorContextMap, CollectorPayloadBean)}
  • + *
  • Given Scenario: Calls the collector for an HTML Page.
  • + *
  • Expected Result: The returned data must be equal to the expected + * parameters.
  • + *
+ */ + @Test + public void collectPageData() throws DotDataException, UnknownHostException { + final HttpServletResponse response = mock(HttpServletResponse.class); + final String requestId = UUIDUtil.uuid(); + final HttpServletRequest request = Util.mockHttpRequestObj(response, TEST_PAGE_URL, + requestId, APILocator.getUserAPI().getAnonymousUser()); + + final HTMLPageAsset testPage = Util.createTestHTMLPage(testSite, TEST_PAGE_NAME); + + final Map expectedDataMap = Map.of( + "event_type", EventType.PAGE_REQUEST.getType(), + "host", testSite.getIdentifier(), + "language", APILocator.getLanguageAPI().getDefaultLanguage().getIsoCode(), + "url", TEST_PAGE_URL, + "object", Map.of( + "id", testPage.getIdentifier(), + "title", testPage.getTitle(), + "url", testPage.getURI()) + ); + + final Collector collector = new PagesCollector(); + final Map contextMap = Map.of( + "uri", testPage.getURI(), + "pageMode", PageMode.LIVE, + "currentHost", testSite, + "requestId", requestId); + final CollectorPayloadBean collectedData = Util.getCollectorPayloadBean(request, + collector, new PagesAndUrlMapsRequestMatcher(), contextMap); + + assertTrue("Collected data map cannot be null or empty", UtilMethods.isSet(collectedData)); + + Util.validateExpectedEntries(expectedDataMap, collectedData); + } + + /** + *
    + *
  • Method to test: + * {@link PagesCollector#collect(CollectorContextMap, CollectorPayloadBean)}
  • + *
  • Given Scenario: Calls the collector for a URL Mapped HTML Page.
  • + *
  • Expected Result: The collected data for a URL Mapped page must be equal to + * the expected attributes.
  • + *
+ */ + @Test + public void collectUrlMapPageData() throws DotDataException, UnknownHostException { + final HttpServletResponse response = mock(HttpServletResponse.class); + final String requestId = UUIDUtil.uuid(); + final HttpServletRequest request = Util.mockHttpRequestObj(response, + TEST_URL_MAP_DETAIL_PAGE_URL, requestId, + APILocator.getUserAPI().getAnonymousUser()); + + final HTMLPageAsset testDetailPage = Util.createTestHTMLPage(testSite, + TEST_URL_MAP_PAGE_NAME, PARENT_FOLDER_1_NAME); + + final String urlTitle = "mynews"; + final String urlMapPatternToUse = URL_MAP_PATTERN + "{urlTitle}"; + final long langId = APILocator.getLanguageAPI().getDefaultLanguage().getId(); + + final ContentType urlMappedContentType = Util.getUrlMapLikeContentType( + "News_" + System.currentTimeMillis(), + testSite, + testDetailPage.getIdentifier(), + urlMapPatternToUse); + + assertNotNull("The test URL Map Content Type cannot be null", urlMappedContentType); + + final ContentletDataGen contentletDataGen = new ContentletDataGen(urlMappedContentType.id()) + .languageId(langId) + .host(testSite) + .setProperty("hostfolder", testSite) + .setProperty("urlTitle", urlTitle) + .setPolicy(IndexPolicy.WAIT_FOR); + final Contentlet newsTestContent = contentletDataGen.nextPersisted(); + ContentletDataGen.publish(newsTestContent); + + final Map expectedDataMap = Map.of( + "event_type", EventType.URL_MAP.getType(), + "host", testSite.getIdentifier(), + "language", APILocator.getLanguageAPI().getDefaultLanguage().getIsoCode(), + "url", TEST_URL_MAP_DETAIL_PAGE_URL, + "object", Map.of( + "content_type_var_name", urlMappedContentType.variable(), + "content_type_id", urlMappedContentType.id(), + "id", newsTestContent.getIdentifier(), + "content_type_name", urlMappedContentType.name(), + "title", urlTitle, + "url", TEST_URL_MAP_DETAIL_PAGE_URL) + ); + + final Collector collector = new PagesCollector(); + final Map contextMap = new HashMap<>(Map.of( + "uri", testDetailPage.getURI(), + "pageMode", PageMode.LIVE, + "currentHost", testSite, + "requestId", requestId)); + final CollectorPayloadBean collectedData = Util.getCollectorPayloadBean(request, + collector, new PagesAndUrlMapsRequestMatcher(), contextMap); + + assertTrue("Collected data map cannot be null or empty", UtilMethods.isSet(collectedData)); + + Util.validateExpectedEntries(expectedDataMap, collectedData); + } + +} diff --git a/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/SyncVanitiesCollectorTest.java b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/SyncVanitiesCollectorTest.java new file mode 100644 index 000000000000..a5626f6398dd --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/SyncVanitiesCollectorTest.java @@ -0,0 +1,122 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.IntegrationTestBase; +import com.dotcms.LicenseTestUtil; +import com.dotcms.datagen.ContentletDataGen; +import com.dotcms.datagen.FileAssetDataGen; +import com.dotcms.datagen.FolderDataGen; +import com.dotcms.datagen.HTMLPageDataGen; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.util.FiltersUtil; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.vanityurl.model.CachedVanityUrl; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.filters.Constants; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.fileassets.business.FileAsset; +import com.dotmarketing.portlets.folders.model.Folder; +import com.dotmarketing.portlets.htmlpageasset.model.HTMLPageAsset; +import com.dotmarketing.portlets.languagesmanager.model.Language; +import com.dotmarketing.portlets.templates.model.Template; +import com.dotmarketing.util.PageMode; +import com.dotmarketing.util.UUIDUtil; +import com.dotmarketing.util.UtilMethods; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Verifies that the {@link SyncVanitiesCollector} is able to collect the necessary data. + * + * @author Jose Castro + * @since Oct 21st, 2024 + */ +public class SyncVanitiesCollectorTest extends IntegrationTestBase { + + private static final String TEST_PAGE_NAME = "index"; + private static final String TEST_PAGE_URL = "/" + TEST_PAGE_NAME; + private static final String URI = "/my-test/vanity-url"; + + private static Host testSite = null; + + @BeforeClass + public static void prepare() throws Exception { + // Setting web app environment + IntegrationTestInitService.getInstance().init(); + LicenseTestUtil.getLicense(); + + final long millis = System.currentTimeMillis(); + final String siteName = "www.myTestSite-" + millis + ".com"; + testSite = new SiteDataGen().name(siteName).nextPersisted(); + } + + /** + *
    + *
  • Method to test: + * {@link SyncVanitiesCollector#collect(CollectorContextMap, CollectorPayloadBean)}
  • + *
  • Given Scenario: Calls the collector for a Vanity URL.
  • + *
  • Expected Result: The returned data must be equal to the expected one.
  • + *
+ */ + @Test + public void collectSyncVanityData() throws DotDataException, IOException, DotSecurityException { + final Language defaultLanguage = APILocator.getLanguageAPI().getDefaultLanguage(); + final HttpServletResponse response = mock(HttpServletResponse.class); + final String requestId = UUIDUtil.uuid(); + final Map requestAttrs = Map.of( + Constants.CMS_FILTER_URI_OVERRIDE, TEST_PAGE_URL + ); + final HttpServletRequest request = Util.mockHttpRequestObj(response, URI, requestId, + APILocator.getUserAPI().getAnonymousUser(), requestAttrs, null); + + // Vanity URL will point to this HTML Page + final HTMLPageAsset testHTMLPage = Util.createTestHTMLPage(testSite, TEST_PAGE_NAME); + + assertNotNull("Test HTML Page cannot be null", testHTMLPage); + + final Optional resolvedVanity = Util.createAndResolveVanityURL(testSite, + "My Test Vanity", URI, TEST_PAGE_URL); + + assertTrue("Resolved vanity url must be present", resolvedVanity.isPresent()); + + final Map expectedDataMap = Map.of( + "site", testSite.getIdentifier(), + "event_type", EventType.VANITY_REQUEST.getType(), + "language", defaultLanguage.getIsoCode(), + "vanity_url", TEST_PAGE_URL, + "language_id", defaultLanguage.getId(), + "url", URI, + "object", Map.of( + "forward_to", TEST_PAGE_URL, + "response", "200", + "id", resolvedVanity.get().vanityUrlId, + "url", URI) + ); + + final Collector collector = new SyncVanitiesCollector(); + final Map contextMap = Map.of( + "uri", URI, + "currentHost", testSite, + Constants.VANITY_URL_OBJECT, resolvedVanity.get()); + final CollectorPayloadBean collectedData = + Util.getRequestCharacterCollectorPayloadBean(request, collector, contextMap); + + assertTrue("Collected data map cannot be null or empty", UtilMethods.isSet(collectedData)); + + Util.validateExpectedEntries(expectedDataMap, collectedData); + } + +} diff --git a/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/Util.java b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/Util.java new file mode 100644 index 000000000000..82a73bc488bd --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/Util.java @@ -0,0 +1,426 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.analytics.track.matchers.PagesAndUrlMapsRequestMatcher; +import com.dotcms.analytics.track.matchers.RequestMatcher; +import com.dotcms.analytics.track.matchers.VanitiesRequestMatcher; +import com.dotcms.business.WrapInTransaction; +import com.dotcms.contenttype.exception.NotFoundInDbException; +import com.dotcms.contenttype.model.field.Field; +import com.dotcms.contenttype.model.field.HostFolderField; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.datagen.ContentTypeDataGen; +import com.dotcms.datagen.ContentletDataGen; +import com.dotcms.datagen.FieldDataGen; +import com.dotcms.datagen.FileAssetDataGen; +import com.dotcms.datagen.FolderDataGen; +import com.dotcms.datagen.HTMLPageDataGen; +import com.dotcms.util.FiltersUtil; +import com.dotcms.util.HttpRequestDataUtil; +import com.dotcms.vanityurl.model.CachedVanityUrl; +import com.dotcms.visitor.domain.Visitor; +import com.dotcms.visitor.filter.characteristics.BaseCharacter; +import com.dotcms.visitor.filter.characteristics.Character; +import com.dotcms.visitor.filter.characteristics.CharacterWebAPI; +import com.dotcms.visitor.filter.characteristics.GDPRCharacter; +import com.dotcms.visitor.filter.servlet.VisitorFilter; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotRuntimeException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.fileassets.business.FileAsset; +import com.dotmarketing.portlets.folders.model.Folder; +import com.dotmarketing.portlets.htmlpageasset.model.HTMLPageAsset; +import com.dotmarketing.portlets.languagesmanager.model.Language; +import com.dotmarketing.util.PageMode; +import com.dotmarketing.util.UtilMethods; +import com.dotmarketing.util.WebKeys; +import com.liferay.portal.model.User; +import io.vavr.control.Try; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.io.IOException; +import java.io.Serializable; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Simple utility class that helps with the execution of Content Analytics-related tests. It + * provides methods for mocking specific objects, creating test data, compare expected results with + * the returned results, and so on. + * + * @author Jose Castro + * @since Oct 9th, 2024 + */ +public class Util { + + public static final String USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:131" + + ".0) Gecko/20100101 Firefox/131.0"; + + /** + * Creates a mock {@link HttpServletRequest} object with the given parameters. + * + * @param response The {@link HttpServletResponse} object. + * @param url The URL of the request. + * @param requestId The request ID. + * @param cmsUser The {@link User} object. + * + * @return The mock {@link HttpServletRequest} object. + * + * @throws UnknownHostException + */ + public static HttpServletRequest mockHttpRequestObj(final HttpServletResponse response, + final String url, final String requestId, + final User cmsUser) throws UnknownHostException { + return mockHttpRequestObj(response, url, requestId, cmsUser, null, null); + } + + /** + * Creates a mock {@link HttpServletRequest} object with the given parameters. + * + * @param response The {@link HttpServletResponse} object. + * @param url The URL of the request. + * @param requestId The request ID. + * @param cmsUser The {@link User} object. + * @param requestAttrs A Map with the request attributes. + * @param requestParams A Map with the request parameters. + * + * @return The mock {@link HttpServletRequest} object. + * + * @throws UnknownHostException + */ + public static HttpServletRequest mockHttpRequestObj(final HttpServletResponse response, + final String url, final String requestId, + final User cmsUser, final Map requestAttrs, + final Map requestParams) throws UnknownHostException { + return mockHttpRequestObj(response, url, requestId, cmsUser, requestAttrs, requestParams, + null); + } + + /** + * Creates a mock {@link HttpServletRequest} object with the given parameters. + * + * @param response The {@link HttpServletResponse} object. + * @param url The URL of the request. + * @param requestId The request ID. + * @param cmsUser The {@link User} object. + * @param requestAttrs A Map with the request attributes. + * @param requestParams A Map with the request parameters. + * @param characterParams A Map with parameters for the {@link Character} object. + * + * @return The mock {@link HttpServletRequest} object. + * + * @throws UnknownHostException + */ + public static HttpServletRequest mockHttpRequestObj(final HttpServletResponse response, + final String url, final String requestId, + final User cmsUser, final Map requestAttrs, + final Map requestParams, + final Map characterParams) throws UnknownHostException { + final HttpServletRequest request = mock(HttpServletRequest.class); + final HttpSession session = mock(HttpSession.class); + + when(request.getSession()).thenReturn(session); + when(session.getId()).thenReturn("DAA3339CD687D9ABD4101CF9EDDD42DB"); + when(session.isNew()).thenReturn(true); + when(request.getRequestURI()).thenReturn(url); + when(request.getAttribute(VisitorFilter.DOTPAGE_PROCESSING_TIME)).thenReturn(1000L); + when(request.getAttribute("requestId")).thenReturn(requestId); + when(request.getHeader("X-Forwarded-For")).thenReturn("127.0.0.1"); + when(request.getHeader("user-agent")).thenReturn(USER_AGENT); + when(request.getHeader("host")).thenReturn("localhost:8080"); + if (UtilMethods.isSet(requestAttrs)) { + for (final Map.Entry entry : requestAttrs.entrySet()) { + when(request.getAttribute(entry.getKey())).thenReturn(entry.getValue()); + } + } + + if (UtilMethods.isSet(requestParams)) { + for (final Map.Entry entry : requestParams.entrySet()) { + when(request.getParameter(entry.getKey())).thenReturn((String) entry.getValue()); + } + } + + final Visitor visitor = new Visitor(); + visitor.setIpAddress(HttpRequestDataUtil.getIpAddress(request)); + when(session.getAttribute(WebKeys.VISITOR)).thenReturn(visitor); + when(session.getAttribute(WebKeys.CMS_USER)).thenReturn(cmsUser); + + final GDPRCharacter gdprCharacter = new GDPRCharacter(new BaseCharacter(request, response)); + if (UtilMethods.isSet(characterParams)) { + for (final Map.Entry entry : characterParams.entrySet()) { + gdprCharacter.getMap().put(entry.getKey(), (Serializable) entry.getValue()); + } + } + when(request.getAttribute(CharacterWebAPI.DOT_CHARACTER)).thenReturn(gdprCharacter); + + when(request.getParameter(WebKeys.PAGE_MODE_PARAMETER)).thenReturn(PageMode.LIVE.name()); + + return request; + } + + /** + * Retrieves the {@link CollectorPayloadBean} object with specific test parameters from the + * given collector. + * + * @param request The {@link HttpServletRequest} object. + * @param collector The {@link Collector} that will be called. + * @param requestMatcher The {@link RequestMatcher} object. + * @param contextMap A Map with the context parameters. + * + * @return The {@link CollectorPayloadBean} object. + */ + public static CollectorPayloadBean getCollectorPayloadBean(final HttpServletRequest request, + final Collector collector, + final RequestMatcher requestMatcher, + final Map contextMap) { + final Character character = (Character) request.getAttribute(CharacterWebAPI.DOT_CHARACTER); + final CollectorContextMap syncCollectorContextMap = + null == contextMap + ? new RequestCharacterCollectorContextMap(request, character, + requestMatcher) + : new CharacterCollectorContextMap(character, requestMatcher, contextMap); + final CollectorPayloadBean collectorPayloadBean = new ConcurrentCollectorPayloadBean(); + return collector.collect(syncCollectorContextMap, collectorPayloadBean); + } + + /** + * Retrieves the {@link CollectorPayloadBean} object with specific test parameters from the + * given collector. This one is specific For Vanity URL Collectors. + * + * @param request The {@link HttpServletRequest} object. + * @param collector The {@link Collector} that will be called. + * @param contextMap A Map with the context parameters. + * + * @return The {@link CollectorPayloadBean} object. + */ + public static CollectorPayloadBean getRequestCharacterCollectorPayloadBean(final HttpServletRequest request, + final Collector collector, + final Map contextMap) { + final Character character = (Character) request.getAttribute(CharacterWebAPI.DOT_CHARACTER); + if (UtilMethods.isSet(contextMap)) { + for (final Map.Entry entry : contextMap.entrySet()) { + character.getMap().put(entry.getKey(), (Serializable) entry.getValue()); + } + } + final RequestMatcher requestMatcher = new VanitiesRequestMatcher(); + final CollectorContextMap syncCollectorContextMap = + new RequestCharacterCollectorContextMap(request, character, requestMatcher); + final CollectorPayloadBean collectorPayloadBean = new ConcurrentCollectorPayloadBean(); + return collector.collect(syncCollectorContextMap, collectorPayloadBean); + } + + /** + * Creates a {@link ContentType} object that can be used for URL Mapped content. + * + * @param contentTypeName The name of the content type. + * @param site The {@link Host} object where the Content Type will live. + * @param detailPageIdentifier The identifier of the detail page that will displayed the mapped + * content. + * @param urlMapPattern The URL map pattern for the URL Map. + * + * @return The {@link ContentType} object. + */ + @WrapInTransaction + public static ContentType getUrlMapLikeContentType(final String contentTypeName, + final Host site, + final String detailPageIdentifier, + final String urlMapPattern) { + ContentType newsType = Try.of(() -> + APILocator.getContentTypeAPI(APILocator.systemUser()).find(contentTypeName)).getOrNull(); + if (newsType == null) { + final List fields = new ArrayList<>(); + fields.add(new FieldDataGen() + .name("Site or Folder") + .velocityVarName("hostfolder") + .required(Boolean.TRUE) + .type(HostFolderField.class) + .next() + ); + fields.add(new FieldDataGen() + .name("urlTitle") + .velocityVarName("urlTitle") + .searchable(false) + .indexed(true) + .listed(true) + .next() + ); + + try { + final ContentTypeDataGen contentTypeDataGen = new ContentTypeDataGen() + .name(contentTypeName) + .velocityVarName(contentTypeName) + .workflowId(APILocator.getWorkflowAPI().findSystemWorkflowScheme().getId()) + .fields(fields); + if (null != site) { + contentTypeDataGen.host(site); + } + if (null != detailPageIdentifier) { + contentTypeDataGen.detailPage(detailPageIdentifier); + } + if (null != urlMapPattern) { + contentTypeDataGen.urlMapPattern(urlMapPattern); + } + newsType = contentTypeDataGen.nextPersisted(); + } catch (final Exception e) { + throw new DotRuntimeException(e); + } + } + return newsType; + } + + /** + * Validates the expected data that should be returned by a given Collector with the actual data + * that was returned. + * + * @param expectedDataMap The expected data. + * @param collectedData The actual data. + */ + public static void validateExpectedEntries(final Map expectedDataMap, + final CollectorPayloadBean collectedData) { + final Map collectedDataMap = collectedData.toMap(); + validateExpectedEntries(expectedDataMap, collectedDataMap); + } + + /** + * Validates the expected data that should be returned by a given Collector with the actual data + * that was returned. + * + * @param expectedDataMap The expected data. + * @param collectedData The actual data. + */ + public static void validateExpectedEntries(final Map expectedDataMap, + final Map collectedData) { + assertEquals("Number of returned expected properties doesn't match", expectedDataMap.size(), + collectedData.size()); + for (final String key : expectedDataMap.keySet()) { + if (collectedData.containsKey(key)) { + final Object expectedValue = expectedDataMap.get(key); + final Object collectedValue = collectedData.get(key); + if (expectedValue instanceof Map) { + final Map expectedMap = (Map) expectedValue; + final Map collectedMap = (Map) collectedValue; + assertEquals("Number of returned expected properties in 'object' entry " + + "doesn't match", + expectedMap.size(), collectedMap.size()); + for (final String mapKey : expectedMap.keySet()) { + assertEquals("Collected value in 'object' entry must be equal to expected" + + " value for key: " + + mapKey, expectedMap.get(mapKey), collectedMap.get(mapKey)); + } + } + assertEquals("Collected value must be equal to expected value for key: " + key, + expectedValue, collectedValue); + } + } + } + + /** + * Creates a test HTML Page with the given name. + * + * @param site The {@link Host} object where the HTML Page will live. + * @param pageName The name of the HTML Page. + * + * @return The {@link HTMLPageAsset} object. + */ + public static HTMLPageAsset createTestHTMLPage(final Host site, final String pageName) { + final HTMLPageAsset testPage = new HTMLPageDataGen(site, + APILocator.getTemplateAPI().systemTemplate()) + .pageURL(pageName) + .title(pageName) + .nextPersisted(); + ContentletDataGen.publish(testPage); + return testPage; + } + + /** + * Creates a test HTML Page with the given name and folder name. + * + * @param site The {@link Host} object where the HTML Page will live. + * @param pageName The name of the HTML Page. + * @param folderName The name of the folder where the HTML Page will live. + * + * @return The {@link HTMLPageAsset} object. + */ + public static HTMLPageAsset createTestHTMLPage(final Host site, final String pageName, + final String folderName) { + final Folder parentFolder = + new FolderDataGen().name(folderName).title(folderName).site(site) + .nextPersisted(); + final HTMLPageAsset testPage = new HTMLPageDataGen(parentFolder, + APILocator.getTemplateAPI().systemTemplate()) + .pageURL(pageName) + .title(pageName) + .nextPersisted(); + ContentletDataGen.publish(testPage); + return testPage; + } + + /** + * Creates a test File Asset with the given parameters. + * + * @param fileName The name of the file. + * @param suffix The file's extension. + * @param content The file's content, if necessary. + * @param folderName The name of the folder where the file will live. + * @param site The {@link Host} object where the file will live. + * + * @return The {@link FileAsset} object. + * + * @throws DotDataException + * @throws IOException + * @throws DotSecurityException + */ + public static FileAsset createTestFileAsset(final String fileName, final String suffix, + final String content, final String folderName, + final Host site) throws DotDataException, + IOException, DotSecurityException { + final Folder parentFolder = + new FolderDataGen().name(folderName).title(folderName).site(site).nextPersisted(); + final Contentlet testFileAsContent = FileAssetDataGen.createFileAssetDataGen(parentFolder, + fileName, suffix, content).nextPersisted(); + ContentletDataGen.publish(testFileAsContent); + return APILocator.getFileAssetAPI().fromContentlet(testFileAsContent); + } + + /** + * Creates a test Vanity URL contentlet. + * + * @param site The {@link Host} object where the Vanity URL will live. + * @param title The title of the Vanity URL. + * @param uri The URI of the HTML Page that the Vanity will point to. + * @param forwardTo The URL to forward to. + * + * @return The {@link Contentlet} object representing the Vanity URL. + * + * @throws DotDataException + * @throws DotSecurityException + */ + public static Optional createAndResolveVanityURL(final Host site, + final String title, + final String uri, + final String forwardTo) throws DotDataException, DotSecurityException { + final FiltersUtil filtersUtil = FiltersUtil.getInstance(); + final Language defaultLanguage = APILocator.getLanguageAPI().getDefaultLanguage(); + + final Contentlet vanity = filtersUtil.createVanityUrl(title, site, uri, forwardTo, + 200, 1, defaultLanguage.getId()); + filtersUtil.publishVanityUrl(vanity); + + return APILocator.getVanityUrlAPI().resolveVanityUrl(uri, site, defaultLanguage); + } + +} diff --git a/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/WebEventsCollectorServiceImplTest.java b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/WebEventsCollectorServiceImplTest.java new file mode 100644 index 000000000000..e0953235c0a1 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/analytics/track/collectors/WebEventsCollectorServiceImplTest.java @@ -0,0 +1,462 @@ +package com.dotcms.analytics.track.collectors; + +import com.dotcms.IntegrationTestBase; +import com.dotcms.LicenseTestUtil; +import com.dotcms.analytics.app.AnalyticsApp; +import com.dotcms.analytics.model.AnalyticsAppProperty; +import com.dotcms.analytics.track.matchers.FilesRequestMatcher; +import com.dotcms.analytics.track.matchers.PagesAndUrlMapsRequestMatcher; +import com.dotcms.analytics.track.matchers.RequestMatcher; +import com.dotcms.analytics.track.matchers.VanitiesRequestMatcher; +import com.dotcms.concurrent.DotConcurrentFactory; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.datagen.ContentletDataGen; +import com.dotcms.datagen.FileAssetDataGen; +import com.dotcms.datagen.FolderDataGen; +import com.dotcms.datagen.HTMLPageDataGen; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.enterprise.cluster.ClusterFactory; +import com.dotcms.jitsu.AnalyticsEventsPayload; +import com.dotcms.jitsu.EventLogRunnable; +import com.dotcms.jitsu.EventLogSubmitter; +import com.dotcms.jitsu.EventsPayload; +import com.dotcms.security.apps.AppSecrets; +import com.dotcms.util.FiltersUtil; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.util.JsonUtil; +import com.dotcms.vanityurl.model.CachedVanityUrl; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.filters.Constants; +import com.dotmarketing.init.DotInitScheduler; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.contentlet.model.IndexPolicy; +import com.dotmarketing.portlets.fileassets.business.FileAsset; +import com.dotmarketing.portlets.folders.model.Folder; +import com.dotmarketing.portlets.htmlpageasset.model.HTMLPageAsset; +import com.dotmarketing.portlets.languagesmanager.model.Language; +import com.dotmarketing.portlets.templates.model.Template; +import com.dotmarketing.util.Config; +import com.dotmarketing.util.PageMode; +import com.dotmarketing.util.UUIDUtil; +import com.dotmarketing.util.UtilMethods; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.runner.RunWith; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.Serializable; +import java.net.UnknownHostException; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static com.dotcms.analytics.app.AnalyticsApp.ANALYTICS_APP_CONFIG_URL_KEY; +import static com.dotcms.analytics.app.AnalyticsApp.ANALYTICS_APP_READ_URL_KEY; +import static com.dotcms.analytics.app.AnalyticsApp.ANALYTICS_APP_WRITE_URL_KEY; +import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies that the {@link WebEventsCollectorService} class is working as expected. + * + * @author Jose Castro + * @since Oct 3rd, 2024 + */ +@RunWith(DataProviderRunner.class) +public class WebEventsCollectorServiceImplTest extends IntegrationTestBase { + + private static final String PARENT_FOLDER_1_NAME = "parent-folder"; + private static final String TEST_PAGE_NAME = "index"; + private static final String TEST_PAGE_URL = "/" + TEST_PAGE_NAME; + private static final String TEST_URL_MAP_PAGE_NAME = "news-detail"; + private static final String TEST_PATTERN = "/testpattern/"; + private static final String TEST_URL_MAP_DETAIL_PAGE_URL = TEST_PATTERN + "mynews"; + private static final String URI = "/my-test/vanity-url"; + + private static final String CLIENT_ID = "analytics-customer-customer1"; + private static final String CLIENT_SECRET = "testsecret"; + + private static Host testSite = null; + private static HTMLPageAsset testPage = null; + private static HTMLPageAsset testDetailPage = null; + private static Optional resolvedVanity = Optional.empty(); + + @BeforeClass + public static void prepare() throws Exception { + // Setting web app environment + IntegrationTestInitService.getInstance().init(); + LicenseTestUtil.getLicense(); + DotInitScheduler.start(); + + final long millis = System.currentTimeMillis(); + final String siteName = "www.myTestSite-" + millis + ".com"; + testSite = new SiteDataGen().name(siteName).nextPersisted(); + + final AppSecrets appSecrets = new AppSecrets.Builder() + .withKey(AnalyticsApp.ANALYTICS_APP_KEY) + .withSecret(AnalyticsAppProperty.CLIENT_ID.getPropertyName(), CLIENT_ID) + .withHiddenSecret(AnalyticsAppProperty.CLIENT_SECRET.getPropertyName(), CLIENT_SECRET) + .withSecret( + AnalyticsAppProperty.ANALYTICS_CONFIG_URL.getPropertyName(), + Config.getStringProperty( + ANALYTICS_APP_CONFIG_URL_KEY, + "http://localhost:8080/c/customer1/cluster1/keys")) + .withSecret( + AnalyticsAppProperty.ANALYTICS_WRITE_URL.getPropertyName(), + Config.getStringProperty(ANALYTICS_APP_WRITE_URL_KEY, "http://localhost")) + .withSecret( + AnalyticsAppProperty.ANALYTICS_READ_URL.getPropertyName(), + Config.getStringProperty(ANALYTICS_APP_READ_URL_KEY, "http://localhost")) + .withSecret(AnalyticsAppProperty.ANALYTICS_KEY.getPropertyName(), "123") + .build(); + APILocator.getAppsAPI().saveSecrets(appSecrets, testSite, APILocator.systemUser()); + } + + /** + * This version of the {@link EventLogSubmitter} was created with the only purpose of to + * testing the {@link WebEventsCollectorService} class by overriding the behavior of the + * logEvent method to run it synchronously. + */ + static class TestEventLogSubmitter extends EventLogSubmitter { + + public EventsPayload analyticsEventsPayload; + + public void logEvent(final EventLogRunnable eventLogRunnable) { + analyticsEventsPayload = eventLogRunnable.getEventPayload().orElse(null); + } + + } + + /** + *
    + *
  • Method to test: {@link }
  • + *
  • Given Scenario:
  • + *
  • Expected Result:
  • + *
+ */ + @Test + public void testBasicProfileCollector() throws DotDataException, IOException { + testPage = null != testPage ? testPage : Util.createTestHTMLPage(testSite, TEST_PAGE_NAME); + + final Map expectedDataMap = Map.of( + "event_type", EventType.PAGE_REQUEST.getType(), + "host", testSite.getIdentifier(), + "url", TEST_PAGE_URL, + "language", APILocator.getLanguageAPI().getDefaultLanguage().getIsoCode(), + "object", Map.of( + "id", testPage.getIdentifier(), + "title", testPage.getTitle(), + "url", testPage.getURI()) + ); + + final TestEventLogSubmitter submitter = new TestEventLogSubmitter(); + + final WebEventsCollectorService webEventsCollectorService = + new WebEventsCollectorServiceFactory.WebEventsCollectorServiceImpl(submitter); + webEventsCollectorService.addCollector(new BasicProfileCollector()); + + final HttpServletResponse response = mock(HttpServletResponse.class); + final Map requestParams = Map.of( + "host_id", testSite.getIdentifier() + ); + final HttpServletRequest request = Util.mockHttpRequestObj(response, TEST_PAGE_URL, + UUIDUtil.uuid(), APILocator.getUserAPI().getAnonymousUser(), null, requestParams); + + final RequestMatcher requestMatcher = new PagesAndUrlMapsRequestMatcher(); + webEventsCollectorService.fireCollectors(request, response, requestMatcher); + + assertNotNull(submitter.analyticsEventsPayload, ""); + for (EventsPayload.EventPayload payload : submitter.analyticsEventsPayload.payloads()) { + final Map payloadData = JsonUtil.getJsonFromString(payload.toString()); + Util.validateExpectedEntries(expectedDataMap, payloadData); + } + } + + /** + *
    + *
  • Method to test: {@link }
  • + *
  • Given Scenario:
  • + *
  • Expected Result:
  • + *
+ */ + @Test + public void testPagesCollector() throws DotDataException, IOException { + testPage = null != testPage ? testPage : Util.createTestHTMLPage(testSite, TEST_PAGE_NAME); + + final Map expectedDataMap = Map.of( + "event_type", EventType.PAGE_REQUEST.getType(), + "host", testSite.getIdentifier(), + "url", TEST_PAGE_URL, + "language", APILocator.getLanguageAPI().getDefaultLanguage().getIsoCode(), + "object", Map.of( + "id", testPage.getIdentifier(), + "title", testPage.getTitle(), + "url", testPage.getURI()) + ); + + final TestEventLogSubmitter submitter = new TestEventLogSubmitter(); + + final WebEventsCollectorService webEventsCollectorService = + new WebEventsCollectorServiceFactory.WebEventsCollectorServiceImpl(submitter); + webEventsCollectorService.addCollector(new PagesCollector()); + + final HttpServletResponse response = mock(HttpServletResponse.class); + final Map requestParams = Map.of( + "host_id", testSite.getIdentifier() + ); + final HttpServletRequest request = Util.mockHttpRequestObj(response, TEST_PAGE_URL, + UUIDUtil.uuid(), APILocator.getUserAPI().getAnonymousUser(), null, requestParams); + + final RequestMatcher requestMatcher = new PagesAndUrlMapsRequestMatcher(); + webEventsCollectorService.fireCollectors(request, response, requestMatcher); + + assertNotNull(submitter.analyticsEventsPayload, ""); + for (EventsPayload.EventPayload payload : submitter.analyticsEventsPayload.payloads()) { + final Map payloadData = JsonUtil.getJsonFromString(payload.toString()); + Util.validateExpectedEntries(expectedDataMap, payloadData); + } + } + + /** + *
    + *
  • Method to test: {@link }
  • + *
  • Given Scenario:
  • + *
  • Expected Result:
  • + *
+ */ + @Test + public void testPageDetailCollector() throws DotDataException, IOException { + testDetailPage = null != testDetailPage ? testDetailPage : Util.createTestHTMLPage(testSite, TEST_URL_MAP_PAGE_NAME, PARENT_FOLDER_1_NAME); + + final String urlTitle = "mynews"; + final String urlMapPatternToUse = TEST_PATTERN + "{urlTitle}"; + final Language language = APILocator.getLanguageAPI().getDefaultLanguage(); + final long langId = language.getId(); + + final ContentType urlMappedContentType = Util.getUrlMapLikeContentType( + "News_" + System.currentTimeMillis(), + testSite, + testDetailPage.getIdentifier(), + urlMapPatternToUse); + final ContentletDataGen contentletDataGen = new ContentletDataGen(urlMappedContentType.id()) + .languageId(langId) + .host(testSite) + .setProperty("hostfolder", testSite) + .setProperty("urlTitle", urlTitle) + .setPolicy(IndexPolicy.WAIT_FOR); + + final Contentlet newsTestContent = contentletDataGen.nextPersisted(); + ContentletDataGen.publish(newsTestContent); + final Map expectedDataMap = Map.of( + "event_type", EventType.PAGE_REQUEST.getType(), + "host", testSite.getIdentifier(), + "language", language.getIsoCode(), + "url", TEST_URL_MAP_DETAIL_PAGE_URL, + "object", Map.of( + "id", testDetailPage.getIdentifier(), + "title", testDetailPage.getTitle(), + "url", TEST_URL_MAP_DETAIL_PAGE_URL, + "detail_page_url", testDetailPage.getURI()) + ); + + final TestEventLogSubmitter submitter = new TestEventLogSubmitter(); + + final WebEventsCollectorService webEventsCollectorService = + new WebEventsCollectorServiceFactory.WebEventsCollectorServiceImpl(submitter); + webEventsCollectorService.addCollector(new PageDetailCollector()); + + final HttpServletResponse response = mock(HttpServletResponse.class); + final Map requestParams = Map.of( + "host_id", testSite.getIdentifier() + ); + final HttpServletRequest request = Util.mockHttpRequestObj(response, TEST_URL_MAP_DETAIL_PAGE_URL, + UUIDUtil.uuid(), APILocator.getUserAPI().getAnonymousUser(), null, requestParams); + + final RequestMatcher requestMatcher = new PagesAndUrlMapsRequestMatcher(); + webEventsCollectorService.fireCollectors(request, response, requestMatcher); + + assertNotNull(submitter.analyticsEventsPayload, ""); + for (EventsPayload.EventPayload payload : submitter.analyticsEventsPayload.payloads()) { + final Map payloadData = JsonUtil.getJsonFromString(payload.toString()); + Util.validateExpectedEntries(expectedDataMap, payloadData); + } + } + + /** + *
    + *
  • Method to test: {@link }
  • + *
  • Given Scenario:
  • + *
  • Expected Result:
  • + *
+ */ + @Test + public void testSyncVanitiesCollector() throws DotDataException, IOException, DotSecurityException { + final Language defaultLanguage = APILocator.getLanguageAPI().getDefaultLanguage(); + testPage = null != testPage ? testPage : Util.createTestHTMLPage(testSite, TEST_PAGE_NAME); + resolvedVanity = Util.createAndResolveVanityURL(testSite, "My Test Vanity", URI, TEST_PAGE_URL); + + assertTrue(resolvedVanity.isPresent(), "Test resolved vanity url must be present"); + + final Map expectedDataMap = Map.of( + "site", testSite.getIdentifier(), + "event_type", EventType.VANITY_REQUEST.getType(), + "language", defaultLanguage.getIsoCode(), + "vanity_url", TEST_PAGE_URL, + "language_id", (int) defaultLanguage.getId(), + "url", URI, + "object", Map.of( + "forward_to", TEST_PAGE_URL, + "response", "200", + "id", resolvedVanity.get().vanityUrlId, + "url", URI) + ); + + final TestEventLogSubmitter submitter = new TestEventLogSubmitter(); + + final WebEventsCollectorService webEventsCollectorService = + new WebEventsCollectorServiceFactory.WebEventsCollectorServiceImpl(submitter); + webEventsCollectorService.addCollector(new SyncVanitiesCollector()); + + final HttpServletResponse response = mock(HttpServletResponse.class); + final Map requestParams = Map.of( + "host_id", testSite.getIdentifier() + ); + final Map requestAttrs = Map.of( + Constants.CMS_FILTER_URI_OVERRIDE, TEST_PAGE_URL + ); + final Map characterMap = Map.of( + "uri", URI, + "currentHost", testSite, + Constants.VANITY_URL_OBJECT, resolvedVanity.get()); + final HttpServletRequest request = Util.mockHttpRequestObj(response, URI, + UUIDUtil.uuid(), APILocator.getUserAPI().getAnonymousUser(), requestAttrs, requestParams, characterMap); + + final RequestMatcher requestMatcher = new VanitiesRequestMatcher(); + webEventsCollectorService.fireCollectors(request, response, requestMatcher); + + assertNotNull(submitter.analyticsEventsPayload, ""); + for (EventsPayload.EventPayload payload : submitter.analyticsEventsPayload.payloads()) { + final Map payloadData = JsonUtil.getJsonFromString(payload.toString()); + Util.validateExpectedEntries(expectedDataMap, payloadData); + } + } + + /** + *
    + *
  • Method to test: {@link }
  • + *
  • Given Scenario:
  • + *
  • Expected Result:
  • + *
+ */ + @Test + public void testAsyncVanitiesCollector() throws DotDataException, IOException, DotSecurityException { + final Language defaultLanguage = APILocator.getLanguageAPI().getDefaultLanguage(); + testPage = null != testPage ? testPage : Util.createTestHTMLPage(testSite, TEST_PAGE_NAME); + resolvedVanity = Util.createAndResolveVanityURL(testSite, "My Test Vanity", URI, TEST_PAGE_URL); + + assertTrue(resolvedVanity.isPresent(), "Test resolved vanity url must be present"); + + final Map expectedDataMap = Map.of( + "event_type", EventType.PAGE_REQUEST.getType(), + "host", testSite.getIdentifier(), + "comeFromVanityURL", true, + "language", defaultLanguage.getIsoCode(), + "url", TEST_PAGE_URL, + "object", Map.of( + "id", testPage.getIdentifier(), + "title", TEST_PAGE_NAME, + "url", TEST_PAGE_URL) + ); + + final TestEventLogSubmitter submitter = new TestEventLogSubmitter(); + + final WebEventsCollectorService webEventsCollectorService = + new WebEventsCollectorServiceFactory.WebEventsCollectorServiceImpl(submitter); + webEventsCollectorService.addCollector(new AsyncVanitiesCollector()); + + final HttpServletResponse response = mock(HttpServletResponse.class); + final Map requestParams = Map.of( + "host_id", testSite.getIdentifier() + ); + final Map requestAttrs = Map.of( + Constants.CMS_FILTER_URI_OVERRIDE, TEST_PAGE_URL + ); + final Map characterMap = Map.of( + "uri", URI, + "currentHost", testSite, + Constants.VANITY_URL_OBJECT, resolvedVanity.get()); + final HttpServletRequest request = Util.mockHttpRequestObj(response, URI, + UUIDUtil.uuid(), APILocator.getUserAPI().getAnonymousUser(), requestAttrs, requestParams, characterMap); + + final RequestMatcher requestMatcher = new VanitiesRequestMatcher(); + webEventsCollectorService.fireCollectors(request, response, requestMatcher); + + assertNotNull(submitter.analyticsEventsPayload, ""); + for (EventsPayload.EventPayload payload : submitter.analyticsEventsPayload.payloads()) { + final Map payloadData = JsonUtil.getJsonFromString(payload.toString()); + Util.validateExpectedEntries(expectedDataMap, payloadData); + } + } + + /** + *
    + *
  • Method to test: {@link }
  • + *
  • Given Scenario:
  • + *
  • Expected Result:
  • + *
+ */ + @Test + public void testFilesCollector() throws DotDataException, IOException, DotSecurityException { + final FileAsset testFileAsset = Util.createTestFileAsset("my-test-file_" + System.currentTimeMillis(), + ".txt","Sample content for my test file", "parent-folder-for-file", testSite); + + final Map expectedDataMap = Map.of( + "host", "localhost:8080", + "site", testSite.getIdentifier(), + "language", APILocator.getLanguageAPI().getDefaultLanguage().getIsoCode(), + "event_type", EventType.FILE_REQUEST.getType(), + "url", testFileAsset.getURI(), + "object", Map.of( + "id", testFileAsset.getIdentifier(), + "title", testFileAsset.getTitle(), + "url", testFileAsset.getURI()) + ); + + final TestEventLogSubmitter submitter = new TestEventLogSubmitter(); + + final WebEventsCollectorService webEventsCollectorService = + new WebEventsCollectorServiceFactory.WebEventsCollectorServiceImpl(submitter); + webEventsCollectorService.addCollector(new FilesCollector()); + + final HttpServletResponse response = mock(HttpServletResponse.class); + final Map requestParams = Map.of( + "host_id", testSite.getIdentifier() + ); + final HttpServletRequest request = Util.mockHttpRequestObj(response, testFileAsset.getURI(), UUIDUtil.uuid(), + APILocator.getUserAPI().getAnonymousUser(), null, requestParams); + + final RequestMatcher requestMatcher = new FilesRequestMatcher(); + webEventsCollectorService.fireCollectors(request, response, requestMatcher); + + assertNotNull(submitter.analyticsEventsPayload, ""); + for (EventsPayload.EventPayload payload : submitter.analyticsEventsPayload.payloads()) { + final Map payloadData = JsonUtil.getJsonFromString(payload.toString()); + Util.validateExpectedEntries(expectedDataMap, payloadData); + } + } + +} diff --git a/dotcms-integration/src/test/java/com/dotcms/cdi/SimpleDataProviderWeldRunnerInjectionIT.java b/dotcms-integration/src/test/java/com/dotcms/cdi/SimpleDataProviderWeldRunnerInjectionIT.java new file mode 100644 index 000000000000..b6b9bb3f327d --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/cdi/SimpleDataProviderWeldRunnerInjectionIT.java @@ -0,0 +1,51 @@ +package com.dotcms.cdi; + +import static org.junit.Assert.assertEquals; + +import com.dotcms.DataProviderWeldRunner; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.UseDataProvider; +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Integration test for simple CDI injection using the Runner DataProviderWeldRunner + */ +@ApplicationScoped +@RunWith(DataProviderWeldRunner.class) +public class SimpleDataProviderWeldRunnerInjectionIT { + + @Inject + GreetingBean greetingBean; + + /** + * Test that DataProviderWeldRunner can inject a bean and receive a value from a data provider + * @param testCase the test case + */ + @UseDataProvider("testCases") + @Test + public void testInjection(TestCase testCase) { + assertEquals("lol", testCase.getValue()); + assertEquals ("Hello World", greetingBean.greet()); + } + + @DataProvider + public static Object[] testCases() { + return new Object[]{ + new TestCase("lol"), + }; + } + + public static class TestCase { + private final String value; + public TestCase(String value){ + this.value = value; + } + public String getValue(){ + return value; + } + } + +} diff --git a/dotcms-integration/src/test/java/com/dotcms/cdi/SimpleInjectionIT.java b/dotcms-integration/src/test/java/com/dotcms/cdi/SimpleInjectionIT.java index 30e3112da561..a9724435b2fe 100644 --- a/dotcms-integration/src/test/java/com/dotcms/cdi/SimpleInjectionIT.java +++ b/dotcms-integration/src/test/java/com/dotcms/cdi/SimpleInjectionIT.java @@ -5,13 +5,19 @@ import static org.junit.Assert.assertTrue; import com.dotcms.IntegrationTestBase; +import com.dotcms.JUnit4WeldRunner; import java.util.Optional; +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.inject.spi.CDI; import org.junit.Test; +import org.junit.runner.RunWith; /** * Integration test for simple CDI injection */ -public class SimpleInjectionIT extends IntegrationTestBase { +@ApplicationScoped +@RunWith(JUnit4WeldRunner.class) +public class SimpleInjectionIT { /** * Test CDI injection @@ -20,12 +26,8 @@ public class SimpleInjectionIT extends IntegrationTestBase { */ @Test public void testInjection() { - - Optional optional = CDIUtils.getBean(GreetingBean.class); - assertTrue(optional.isPresent()); - final GreetingBean greetingBean = optional.get(); + final GreetingBean greetingBean = CDIUtils.getBeanThrows(GreetingBean.class); assertEquals("Hello World", greetingBean.greet()); - } } diff --git a/dotcms-integration/src/test/java/com/dotcms/cdi/SimpleJUnit4InjectionIT.java b/dotcms-integration/src/test/java/com/dotcms/cdi/SimpleJUnit4InjectionIT.java new file mode 100644 index 000000000000..007a240a4d6b --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/cdi/SimpleJUnit4InjectionIT.java @@ -0,0 +1,26 @@ +package com.dotcms.cdi; + +import static org.junit.Assert.assertEquals; + +import com.dotcms.JUnit4WeldRunner; +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import org.junit.Test; +import org.junit.runner.RunWith; + +@ApplicationScoped +@RunWith(JUnit4WeldRunner.class) +public class SimpleJUnit4InjectionIT { + + @Inject + GreetingBean greetingBean; + + /** + * Test CDI injection using the Runner JUnit4WeldRunner + */ + @Test + public void testInjection() { + final String greet = greetingBean.greet(); + assertEquals("Hello World", greet); + } +} diff --git a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImplTest.java b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImplTest.java index 15e215ab21ee..837d61a86980 100644 --- a/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImplTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImplTest.java @@ -3,17 +3,8 @@ import com.dotcms.IntegrationTestBase; import com.dotcms.business.WrapInTransaction; import com.dotcms.content.elasticsearch.util.RestHighLevelClientProvider; -import com.dotcms.contenttype.business.ContentTypeAPI; -import com.dotcms.contenttype.business.CopyContentTypeBean; -import com.dotcms.contenttype.business.FieldAPI; -import com.dotcms.contenttype.model.field.BinaryField; -import com.dotcms.contenttype.model.field.DataTypes; -import com.dotcms.contenttype.model.field.DateTimeField; -import com.dotcms.contenttype.model.field.Field; -import com.dotcms.contenttype.model.field.FieldBuilder; -import com.dotcms.contenttype.model.field.HostFolderField; -import com.dotcms.contenttype.model.field.RelationshipField; -import com.dotcms.contenttype.model.field.TextField; +import com.dotcms.contenttype.business.*; +import com.dotcms.contenttype.model.field.*; import com.dotcms.contenttype.model.type.ContentType; import com.dotcms.contenttype.model.type.ContentTypeBuilder; import com.dotcms.contenttype.model.type.SimpleContentType; @@ -42,6 +33,7 @@ import com.dotcms.test.util.FileTestUtil; import com.dotcms.util.CollectionsUtils; import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.util.JsonUtil; import com.dotcms.vanityurl.filters.VanityURLFilter; import com.dotcms.vanityurl.model.DefaultVanityUrl; import com.dotcms.vanityurl.model.VanityUrl; @@ -83,6 +75,7 @@ import com.dotmarketing.portlets.structure.model.Structure; import com.dotmarketing.portlets.templates.model.Template; import com.dotmarketing.util.Logger; +import com.dotmarketing.util.StringUtils; import com.dotmarketing.util.UtilMethods; import com.dotmarketing.util.WebKeys.Relationship.RELATIONSHIP_CARDINALITY; import com.fasterxml.jackson.core.JsonProcessingException; @@ -92,6 +85,9 @@ import com.liferay.util.FileUtil; import com.liferay.util.StringPool; import com.rainerhahnekamp.sneakythrow.Sneaky; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; import org.apache.http.HttpStatus; import org.elasticsearch.action.admin.cluster.settings.ClusterUpdateSettingsRequest; import org.elasticsearch.action.support.master.AcknowledgedResponse; @@ -101,7 +97,9 @@ import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Test; +import org.junit.runner.RunWith; import org.mockito.Mockito; +import org.postgresql.util.PGobject; import javax.servlet.FilterChain; import javax.servlet.ServletException; @@ -111,16 +109,9 @@ import javax.servlet.http.HttpServletResponse; import java.io.File; import java.io.IOException; +import java.io.Serializable; import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Calendar; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; import static com.dotcms.content.elasticsearch.business.ESContentletAPIImpl.UNIQUE_PER_SITE_FIELD_VARIABLE_NAME; @@ -144,6 +135,7 @@ * * @author nollymar */ +@RunWith(DataProviderRunner.class) public class ESContentletAPIImplTest extends IntegrationTestBase { private static ContentTypeAPI contentTypeAPI; @@ -167,6 +159,21 @@ public static void prepare () throws Exception { relationshipAPI = APILocator.getRelationshipAPI(); contentletAPI = APILocator.getContentletAPI(); fieldAPI = APILocator.getContentTypeFieldAPI(); + + //TODO: Remove this when the whole change is done + try { + new DotConnect().setSQL("CREATE TABLE IF NOT EXISTS unique_fields (" + + "unique_key_val VARCHAR(64) PRIMARY KEY," + + "supporting_values JSONB" + + " )").loadObjectResults(); + } catch (DotDataException e) { + throw new RuntimeException(e); + } + } + + @DataProvider + public static Object[] enabledUniqueFieldDatabaseValidation() { + return new Boolean[] {true, false}; } @Test @@ -1079,45 +1086,100 @@ public void selfRelatedContents() throws DotDataException { * @throws DotSecurityException */ @Test - public void savingFieldWithUniqueFieldInTheSameHost() throws DotDataException, DotSecurityException { + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void savingFieldWithUniqueFieldInTheSameHost(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { - final ContentType contentType = new ContentTypeDataGen() - .nextPersisted(); + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); - final Field uniqueTextField = new FieldDataGen() - .contentTypeId(contentType.id()) - .unique(true) - .type(TextField.class) - .nextPersisted(); + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + final ContentType contentType = new ContentTypeDataGen() + .nextPersisted(); - new FieldVariableDataGen() - .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) - .value("true") - .field(uniqueTextField) - .nextPersisted(); + final Field uniqueTextField = new FieldDataGen() + .contentTypeId(contentType.id()) + .unique(true) + .type(TextField.class) + .nextPersisted(); - final Host host = new SiteDataGen().nextPersisted(); + new FieldVariableDataGen() + .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) + .value("true") + .field(uniqueTextField) + .nextPersisted(); - final Contentlet contentlet_1 = new ContentletDataGen(contentType) - .host(host) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final Host host = new SiteDataGen().nextPersisted(); - final Contentlet contentlet_2 = new ContentletDataGen(contentType) - .host(host) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - try { - APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); - throw new AssertionError("DotRuntimeException Expected"); - }catch (final DotRuntimeException e) { - final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." - + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); - assertEquals(expectedMessage, e.getMessage()); + try { + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + throw new AssertionError("DotRuntimeException Expected"); + } catch (final DotRuntimeException e) { + final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(true, contentType, uniqueTextField, contentlet_1); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + private static void checkUniqueFieldsTable(final boolean uniquePerSite, final ContentType contentType, + final Field uniqueField, Contentlet... contentlets) + throws DotDataException { + List> results = new DotConnect().setSQL("SELECT * FROM unique_fields WHERE supporting_values->>'contentTypeID' = ?") + .addParam(contentType.id()) + .loadObjectResults(); + + assertEquals(contentlets.length, results.size()); + + for (Map result : results) { + try { + final Map supportingValues = JsonUtil.getJsonFromString(result.get("supporting_values").toString()); + final List contentletsId = (List) supportingValues.get("contentletsId"); + final String variant = supportingValues.get("variant").toString(); + + assertEquals(1, contentletsId.size()); + + assertEquals(uniqueField.variable(), supportingValues.get("fieldVariableName")); + assertEquals(uniquePerSite, supportingValues.get("uniquePerSite")); + + Contentlet contentletFound = null; + + for (Contentlet contentlet : contentlets) { + if (contentletsId.get(0).equals(contentlet.getIdentifier()) && variant.equals(contentlet.getVariantId())) { + contentletFound = contentlet; + break; + } + } + + if (contentletFound == null) { + throw new AssertionError("Contentley does not expected"); + } + + assertEquals(contentletFound.get(uniqueField.variable()), supportingValues.get("fieldValue")); + assertEquals(contentletFound.getLanguageId(), Long.parseLong(supportingValues.get("languageId").toString())); + assertEquals(contentletFound.getHost(), supportingValues.get("hostId")); + } catch (IOException e) { + throw new RuntimeException(e); + } } } @@ -1135,45 +1197,58 @@ public void savingFieldWithUniqueFieldInTheSameHost() throws DotDataException, D * @throws DotSecurityException */ @Test - public void savingFieldWithUniqueFieldInTheSameHostUniquePerSiteToFalse() throws DotDataException, DotSecurityException { + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void savingFieldWithUniqueFieldInTheSameHostUniquePerSiteToFalse(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { - final ContentType contentType = new ContentTypeDataGen() - .nextPersisted(); + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); - final Field uniqueTextField = new FieldDataGen() - .contentTypeId(contentType.id()) - .unique(true) - .type(TextField.class) - .nextPersisted(); + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + final ContentType contentType = new ContentTypeDataGen() + .nextPersisted(); - new FieldVariableDataGen() - .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) - .value("true") - .field(uniqueTextField) - .nextPersisted(); + final Field uniqueTextField = new FieldDataGen() + .contentTypeId(contentType.id()) + .unique(true) + .type(TextField.class) + .nextPersisted(); - final Host host = new SiteDataGen().nextPersisted(); + new FieldVariableDataGen() + .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) + .value("true") + .field(uniqueTextField) + .nextPersisted(); - final Contentlet contentlet_1 = new ContentletDataGen(contentType) - .host(host) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final Host host = new SiteDataGen().nextPersisted(); - final Contentlet contentlet_2 = new ContentletDataGen(contentType) - .host(host) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - try { - APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); - throw new AssertionError("DotRuntimeException Expected"); - } catch (final DotRuntimeException e) { - final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." - + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); - assertEquals(expectedMessage, e.getMessage()); + try { + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + throw new AssertionError("DotRuntimeException Expected"); + } catch (final DotRuntimeException e) { + final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(true, contentType, uniqueTextField, contentlet_1); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); } } @@ -1191,48 +1266,63 @@ public void savingFieldWithUniqueFieldInTheSameHostUniquePerSiteToFalse() throws * @throws DotSecurityException */ @Test - public void savingFieldWithUniqueFieldInDifferentHost() throws DotDataException, DotSecurityException { - final ContentType contentType = new ContentTypeDataGen() - .nextPersisted(); + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void savingFieldWithUniqueFieldInDifferentHost(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { - final Field uniqueTextField = new FieldDataGen() - .contentTypeId(contentType.id()) - .unique(true) - .type(TextField.class) - .nextPersisted(); - new FieldVariableDataGen() - .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) - .value("true") - .field(uniqueTextField) - .nextPersisted(); + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); - final Host host1 = new SiteDataGen().nextPersisted(); - final Host host2 = new SiteDataGen().nextPersisted(); + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); - final Contentlet contentlet_1 = new ContentletDataGen(contentType) - .host(host1) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final ContentType contentType = new ContentTypeDataGen() + .nextPersisted(); - final Contentlet contentlet_2 = new ContentletDataGen(contentType) - .host(host2) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final Field uniqueTextField = new FieldDataGen() + .contentTypeId(contentType.id()) + .unique(true) + .type(TextField.class) + .nextPersisted(); + + new FieldVariableDataGen() + .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) + .value("true") + .field(uniqueTextField) + .nextPersisted(); - APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); - APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + final Host host1 = new SiteDataGen().nextPersisted(); + final Host host2 = new SiteDataGen().nextPersisted(); - final Optional contentlet1FromDB = APILocator.getContentletAPI() - .findInDb(contentlet_1.getInode()); + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host1) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host2) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - assertTrue(contentlet1FromDB.isPresent()); + APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); - final Optional contentlet2FromDB = APILocator.getContentletAPI() - .findInDb(contentlet_2.getInode()); + final Optional contentlet1FromDB = APILocator.getContentletAPI() + .findInDb(contentlet_1.getInode()); - assertTrue(contentlet2FromDB.isPresent()); + assertTrue(contentlet1FromDB.isPresent()); + final Optional contentlet2FromDB = APILocator.getContentletAPI() + .findInDb(contentlet_2.getInode()); + + assertTrue(contentlet2FromDB.isPresent()); + + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(true, contentType, uniqueTextField, contentlet_1, contentlet_2); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } } /** @@ -1249,46 +1339,58 @@ public void savingFieldWithUniqueFieldInDifferentHost() throws DotDataException, * @throws DotSecurityException */ @Test - public void savingFieldWithUniqueFieldInDifferentHostUniquePerSiteToFalse() throws DotDataException, DotSecurityException { + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void savingFieldWithUniqueFieldInDifferentHostUniquePerSiteToFalse(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { - final ContentType contentType = new ContentTypeDataGen() - .nextPersisted(); + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); - final Field uniqueTextField = new FieldDataGen() - .contentTypeId(contentType.id()) - .unique(true) - .type(TextField.class) - .nextPersisted(); + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + final ContentType contentType = new ContentTypeDataGen() + .nextPersisted(); - new FieldVariableDataGen() - .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) - .value("false") - .field(uniqueTextField) - .nextPersisted(); + final Field uniqueTextField = new FieldDataGen() + .contentTypeId(contentType.id()) + .unique(true) + .type(TextField.class) + .nextPersisted(); - final Host host1 = new SiteDataGen().nextPersisted(); - final Host host2 = new SiteDataGen().nextPersisted(); + new FieldVariableDataGen() + .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) + .value("false") + .field(uniqueTextField) + .nextPersisted(); - final Contentlet contentlet_1 = new ContentletDataGen(contentType) - .host(host1) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final Host host1 = new SiteDataGen().nextPersisted(); + final Host host2 = new SiteDataGen().nextPersisted(); - final Contentlet contentlet_2 = new ContentletDataGen(contentType) - .host(host2) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host1) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host2) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - try { - APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); - throw new AssertionError("DotRuntimeException Expected"); - } catch (final DotRuntimeException e) { - final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." - + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); - assertEquals(expectedMessage, e.getMessage()); + try { + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + throw new AssertionError("DotRuntimeException Expected"); + } catch (final DotRuntimeException e) { + final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(false, contentType, uniqueTextField, contentlet_1); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); } } @@ -1635,50 +1737,61 @@ private void checkFilter(final Host host, final VanityUrl vanityURL, final int s * @throws DotSecurityException */ @Test - public void savingFieldWithUniqueFieldInDifferentHostUsingContentTypeHost() throws DotDataException, DotSecurityException { - final ContentType contentType = new ContentTypeDataGen() - .nextPersisted(); + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void savingFieldWithUniqueFieldInDifferentHostUsingContentTypeHost(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); - final Field uniqueTextField = new FieldDataGen() - .contentTypeId(contentType.id()) - .unique(true) - .type(TextField.class) - .nextPersisted(); + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); - new FieldVariableDataGen() - .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) - .value("true") - .field(uniqueTextField) - .nextPersisted(); + final ContentType contentType = new ContentTypeDataGen() + .nextPersisted(); - final Host host1 = new SiteDataGen().nextPersisted(); - final Host host2 = new SiteDataGen().nextPersisted(); + final Field uniqueTextField = new FieldDataGen() + .contentTypeId(contentType.id()) + .unique(true) + .type(TextField.class) + .nextPersisted(); - final Contentlet contentlet_1 = new ContentletDataGen(contentType) - .host(host1) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + new FieldVariableDataGen() + .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) + .value("true") + .field(uniqueTextField) + .nextPersisted(); - final Contentlet contentlet_2 = new ContentletDataGen(contentType) - .host(host2) - .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + final Host host1 = new SiteDataGen().nextPersisted(); + final Host host2 = new SiteDataGen().nextPersisted(); + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host1) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - contentlet_2.setHost(null); - contentlet_2.setFolder(null); + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host2) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); - APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); - APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); - final Optional contentlet1FromDB = APILocator.getContentletAPI() - .findInDb(contentlet_1.getInode()); + final Optional contentlet1FromDB = APILocator.getContentletAPI() + .findInDb(contentlet_1.getInode()); - assertTrue(contentlet1FromDB.isPresent()); + assertTrue(contentlet1FromDB.isPresent()); - final Optional contentlet2FromDB = APILocator.getContentletAPI() - .findInDb(contentlet_2.getInode()); + final Optional contentlet2FromDB = APILocator.getContentletAPI() + .findInDb(contentlet_2.getInode()); - assertTrue(contentlet2FromDB.isPresent()); + assertTrue(contentlet2FromDB.isPresent()); + + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(true, contentType, uniqueTextField, contentlet_1, contentlet_2); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } } @@ -1693,34 +1806,710 @@ public void savingFieldWithUniqueFieldInDifferentHostUsingContentTypeHost() thro * @throws DotSecurityException */ @Test - public void savingFieldWithUniqueFieldInTheSameHostTakingContentTypeHost() throws DotDataException, DotSecurityException { + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void savingFieldWithUniqueFieldInTheSameHostTakingContentTypeHost(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { + + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + + final Field uniqueTextField = new FieldDataGen() + .unique(true) + .type(TextField.class) + .next(); + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .field(uniqueTextField) + .nextPersisted(); + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); + + APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); + + try { + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + throw new AssertionError("DotRuntimeException Expected"); + } catch (final DotRuntimeException e) { + final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(false, contentType, uniqueTextField, contentlet_1); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with a unique field + * - Create a {@link Contentlet} and later try to update it + * Should: Works and create the new version + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void updateContentletWithUniqueFields(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { + + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + + final Field uniqueTextField = new FieldDataGen() + .unique(true) + .type(TextField.class) + .next(); + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .fields(list(uniqueTextField)) + .nextPersisted(); + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "unique-value") + .nextPersisted(); + + Contentlet checkout = ContentletDataGen.checkout(contentlet_1); + Contentlet checkin = ContentletDataGen.checkin(checkout); + + List allVersions = APILocator.getContentletAPI().findAllVersions( + APILocator.getIdentifierAPI().find(contentlet_1.getIdentifier()), + APILocator.systemUser(), false); + + assertEquals(2, allVersions.size()); + + final List inodes = allVersions.stream().map(Contentlet::getInode).collect(Collectors.toList()); + assertTrue(inodes.contains(contentlet_1.getInode())); + assertTrue(inodes.contains(checkin.getInode())); + + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(false, contentType, uniqueTextField, contentlet_1); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with a unique field + * - Create a {@link Contentlet} and later create a new version of it with a different value in the unique field + * - Update the Variant value should update the unique_fields table + * Should: Works and create the new version + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + public void updateUniqueFieldVariantValue() + throws DotDataException, DotSecurityException { + + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(true); + + final Variant specificVariant = new VariantDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .unique(true) + .type(TextField.class) + .next(); + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .fields(list(uniqueTextField)) + .nextPersisted(); + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "default-unique-value") + .nextPersisted(); + + Contentlet contentletVariant = ContentletDataGen.checkout(contentlet_1); + contentletVariant.setVariantId(specificVariant.name()); + contentletVariant.setProperty(uniqueTextField.variable(), "variant-unique-value"); + + Contentlet checkin_1 = ContentletDataGen.checkin(contentletVariant); + + checkUniqueFieldsTable(false, contentType, uniqueTextField, contentlet_1, checkin_1); + + contentletVariant = ContentletDataGen.checkout(checkin_1); + contentletVariant.setVariantId(specificVariant.name()); + contentletVariant.setProperty(uniqueTextField.variable(), "variant-unique-value-2"); + + Contentlet checkin_2 = ContentletDataGen.checkin(contentletVariant); + + checkUniqueFieldsTable(false, contentType, uniqueTextField, contentlet_1, checkin_2); + + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with a unique field + * - Create 2 {@link Contentlet} in different languages with the same unique value + * Should: Works and create both {@link Contentlet} + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void savingDifferentLanguageContentletWithUniqueFields(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { + + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + + final Language language_1 = new LanguageDataGen().nextPersisted(); + final Language language_2 = new LanguageDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .unique(true) + .type(TextField.class) + .next(); + + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .fields(list(uniqueTextField)) + .nextPersisted(); + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .languageId(language_1.getId()) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host) + .languageId(language_2.getId()) + .setProperty(uniqueTextField.variable(), "unique-value") + .next(); + + APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + + final Contentlet contentlet_1FromDB = APILocator.getContentletAPI().find(contentlet_1.getInode(), + APILocator.systemUser(), false); + + assertNotNull(contentlet_1FromDB.getIdentifier()); + + final Contentlet contentlet_2FromDB = APILocator.getContentletAPI().find(contentlet_2.getInode(), + APILocator.systemUser(), false); + assertNotNull(contentlet_2FromDB.getIdentifier()); + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(false, contentType, uniqueTextField, contentlet_1, contentlet_2); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with a unique field + * - Create a {@link Contentlet} and later try to create a version in another Variant but change the Unique Field Value + * - Try to create a new Contentlet in DEFAULT value with the unique value in the Variant Version + * Should: Throw a RuntimeException with the message: "Contentlet with id:`Unknown/New` and title:`` has invalid / missing field(s)." + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void createVersionInAnotherVarianttWithUniqueFieldsAndDifferentValue(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException, InterruptedException { + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + final Variant variant = new VariantDataGen().nextPersisted(); + final Language language = new LanguageDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .unique(true) + .type(TextField.class) + .next(); + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .fields(list(uniqueTextField)) + .nextPersisted(); + + final String defaultVersionValue = "default-unique-value"; + final String variantVersionValue = "variant-unique-value"; + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), defaultVersionValue) + .languageId(language.getId()) + .nextPersisted(); + + final Contentlet contentletVariantVersion = ContentletDataGen.checkout(contentlet_1); + contentletVariantVersion.setProperty(uniqueTextField.variable(), variantVersionValue); + contentletVariantVersion.setVariantId(variant.name()); + + APILocator.getContentletAPI().checkin(contentletVariantVersion, APILocator.systemUser(), false); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), defaultVersionValue) + .languageId(language.getId()) + .next(); + + try { + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + throw new AssertionError("DotRuntimeException Expected"); + } catch (final DotRuntimeException e) { + final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + + final Contentlet contentlet_3 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), variantVersionValue) + .variant(variant) + .languageId(language.getId()) + .next(); + + try { + APILocator.getContentletAPI().checkin(contentlet_3, APILocator.systemUser(), false); + throw new AssertionError("DotRuntimeException Expected"); + } catch (final DotRuntimeException e) { + final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with a unique field + * - Create a {@link Contentlet}, set unique-value as value to the unique field. + * - Try to Create a new {@link Contentlet} using the unique-value should throw a Duplicated Exception. + * - Update the {@link Contentlet} and change the value of the unique-field for 'new-unique-value' + * - Try to create the new {@link Contentlet} again using the 'unique-value', should create the Contentlet + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void reUseUniqueValues(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException, InterruptedException { + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + final Language language = new LanguageDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .unique(true) + .type(TextField.class) + .next(); + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .fields(list(uniqueTextField)) + .nextPersisted(); + + final String uniqueValue = "unique-value"; + final String newUniqueValue = "new-unique-value"; + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), uniqueValue) + .languageId(language.getId()) + .nextPersisted(); + + Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), uniqueValue) + .languageId(language.getId()) + .next(); + + try { + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + throw new AssertionError("DotRuntimeException Expected"); + } catch (final DotRuntimeException e) { + final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + + final Contentlet checkout = ContentletDataGen.checkout(contentlet_1); + checkout.setProperty(uniqueTextField.variable(), newUniqueValue); + final Contentlet contentletUpdate = ContentletDataGen.checkin(checkout); + + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + + Contentlet contentlet_2FromDB = APILocator.getContentletAPI().find(contentlet_2.getInode(), + APILocator.systemUser(), false); + + assertNotNull(contentlet_2FromDB); + assertEquals(contentlet_2.getIdentifier(), contentlet_2FromDB.getIdentifier()); + + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(false, contentType, uniqueTextField, contentletUpdate, + contentlet_2FromDB); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with a unique field + * - Create a {@link Contentlet}, set unique-value as value to the unique field. + * - Archive the {@link Contentlet}, created in the previous step. + * - Try to Create a new {@link Contentlet} using the unique-value + * Should: throw a Duplicated Exception + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void uniqueFieldWithArchiveContentlet(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException, InterruptedException { + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + final Language language = new LanguageDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .unique(true) + .type(TextField.class) + .next(); + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .fields(list(uniqueTextField)) + .nextPersisted(); + + final String uniqueValue = "unique-value"; + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), uniqueValue) + .languageId(language.getId()) + .nextPersisted(); + + ContentletDataGen.archive(contentlet_1); + + Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), uniqueValue) + .languageId(language.getId()) + .next(); + + try { + APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); + throw new AssertionError("DotRuntimeException Expected"); + } catch (final DotRuntimeException e) { + final String expectedMessage = String.format("Contentlet with ID 'Unknown/New' [''] has invalid/missing field(s)." + + " - Fields: [UNIQUE]: %s (%s)", uniqueTextField.name(), uniqueTextField.variable()); + + assertEquals(expectedMessage, e.getMessage()); + } + + if (enabledDataBaseValidation) { + checkUniqueFieldsTable(false, contentType, uniqueTextField, contentlet_1); + } + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with Text Fields + * - Create a couple of Contentlet with the same value in this ETxt Field + * - Change the field to be unique + * - Populate manually the unique_fields table + * - Update one of the Contentlet and the unique_fields table should be updated too, but the register + * is not going to be removed because we have another COntentlet with the same value + * Should: Update the Contentlet and uodate the unique_fields table right + * + * This can happen if the Contentlets with the duplicated values exists before the Upgrade than contains the new Database validation + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + public void updateContentletWithDuplicateValuesInUniqueFields() + throws DotDataException, DotSecurityException, InterruptedException, IOException { + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(true); + + final Language language = new LanguageDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .type(TextField.class) + .next(); + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .fields(list(uniqueTextField)) + .nextPersisted(); + + final String uniqueVersionValue = "unique-value"; + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), uniqueVersionValue) + .languageId(language.getId()) + .nextPersisted(); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), uniqueVersionValue) + .languageId(language.getId()) + .nextPersisted(); + + final Field uniqueTextFieldFromDB = APILocator.getContentTypeFieldAPI() + .byContentTypeAndVar(contentType, uniqueTextField.variable()); + + final ImmutableTextField uniqueFieldUpdated = ImmutableTextField.builder() + .from(uniqueTextField) + .contentTypeId(contentType.id()) + .unique(true) + .build(); + + APILocator.getContentTypeFieldAPI().save(uniqueFieldUpdated, APILocator.systemUser()); + + Map uniqueFieldCriteriaMap = Map.of( + "contentTypeID", contentType.id(), + "fieldVariableName", uniqueTextFieldFromDB.variable(), + "fieldValue", uniqueVersionValue.toString(), + "languageId", language.getId(), + "hostId", host.getIdentifier(), + "uniquePerSite", true, + "variant", VariantAPI.DEFAULT_VARIANT.name() + ); + + final Map supportingValues = new HashMap<>(uniqueFieldCriteriaMap); + supportingValues.put("contentletsId", CollectionsUtils.list(contentlet_1.getIdentifier(), + contentlet_2.getIdentifier())); + supportingValues.put("uniquePerSite", false); + + final String hash = StringUtils.hashText(contentType.id() + uniqueTextFieldFromDB.variable() + + language.getId() + uniqueVersionValue + host.getIdentifier()); + + new DotConnect().setSQL("INSERT INTO unique_fields (unique_key_val, supporting_values) VALUES(?, ?)") + .addParam(hash) + .addJSONParam(supportingValues) + .loadObjectResults(); + + final Contentlet checkout = ContentletDataGen.checkout(contentlet_1); + checkout.setProperty(uniqueTextField.variable(), "another-value"); + + APILocator.getContentletAPI().checkin(checkout, APILocator.systemUser(), false); + + checkContentletInUniqueFieldsTable(contentlet_1); + checkContentletInUniqueFieldsTable(contentlet_2); + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + private static void checkContentletInUniqueFieldsTable(final Contentlet contentlet) throws DotDataException, IOException { + final List> result_1 = new DotConnect() + .setSQL("SELECT * FROM unique_fields WHERE supporting_values->'contentletsId' @> ?::jsonb") + .addParam("\"" + contentlet.getIdentifier() + "\"") + .loadObjectResults(); + + assertEquals(1, result_1.size()); + + final PGobject supportingValues = (PGobject) result_1.get(0).get("supporting_values"); + final Map supportingValuesMap = JsonUtil.getJsonFromString(supportingValues.getValue()); + final List contentletsId = (List) supportingValuesMap.get("contentletsId"); + + assertEquals(1, contentletsId.size()); + assertEquals(contentlet.getIdentifier(), contentletsId.get(0)); + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with a unique field + * - Create a {@link Contentlet} and later try to create a version in another Variant + * Should: Works and create the new version + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + @UseDataProvider("enabledUniqueFieldDatabaseValidation") + public void createVersionInAnotherVarianttWithUniqueFields(final Boolean enabledDataBaseValidation) + throws DotDataException, DotSecurityException { + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(enabledDataBaseValidation); + + + final Variant variant = new VariantDataGen().nextPersisted(); + final Language language = new LanguageDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .unique(true) + .type(TextField.class) + .next(); + + final Field titleTextField = new FieldDataGen() + .type(TextField.class) + .next(); + + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .fields(list(uniqueTextField, titleTextField)) + .nextPersisted(); + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .host(host) + .setProperty(uniqueTextField.variable(), "default-unique-value") + .setProperty(titleTextField.variable(), "Title") + .languageId(language.getId()) + .nextPersisted(); + + Contentlet checkout = ContentletDataGen.checkout(contentlet_1); + checkout.setProperty(uniqueTextField.variable(), "variant-unique-value"); + checkout.setProperty(titleTextField.variable(), "Title2"); + checkout.setVariantId(variant.name()); + + final Contentlet contentletVariantVersion = ContentletDataGen.checkin(checkout); + + List allVersions = APILocator.getContentletAPI().findAllVersions( + APILocator.getIdentifierAPI().find(contentlet_1.getIdentifier()), + APILocator.systemUser(), false); + + assertEquals(2, allVersions.size()); + + final Contentlet contentlet_1FromDB = allVersions.stream() + .filter(contentlet -> contentlet.getInode().equals(contentlet_1.getInode())) + .findFirst() + .orElseThrow(); + + assertEquals(contentlet_1.getVariantId(), contentlet_1FromDB.getVariantId()); + + final Contentlet contentlet_2FromDB = allVersions.stream() + .filter(contentlet -> contentlet.getInode().equals(contentletVariantVersion.getInode())) + .findFirst() + .orElseThrow(); + + assertEquals(contentletVariantVersion.getVariantId(), contentlet_2FromDB.getVariantId()); + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: + * - Create a {@link ContentType} with a unique field + * - Create 2 {@link Contentlet} in different Variants with the same unique value + * Should: throws Exception + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + public void savingDifferentVariantContentletWithUniqueFields() throws DotDataException, DotSecurityException, InterruptedException { + + final Language language = new LanguageDataGen().nextPersisted(); + + final Variant variant_1 = new VariantDataGen().nextPersisted(); + final Variant variant_2 = new VariantDataGen().nextPersisted(); + final Field uniqueTextField = new FieldDataGen() .unique(true) .type(TextField.class) .next(); + final Host host = new SiteDataGen().nextPersisted(); final ContentType contentType = new ContentTypeDataGen() .host(host) - .field(uniqueTextField) + .fields(list(uniqueTextField)) .nextPersisted(); final Contentlet contentlet_1 = new ContentletDataGen(contentType) .host(host) + .languageId(language.getId()) + .variant(variant_1) .setProperty(uniqueTextField.variable(), "unique-value") - .next(); + .nextPersisted(); final Contentlet contentlet_2 = new ContentletDataGen(contentType) .host(host) + .languageId(language.getId()) + .variant(variant_2) .setProperty(uniqueTextField.variable(), "unique-value") .next(); - contentlet_2.setHost(null); - contentlet_2.setFolder(null); - - APILocator.getContentletAPI().checkin(contentlet_1, APILocator.systemUser(), false); - try { APILocator.getContentletAPI().checkin(contentlet_2, APILocator.systemUser(), false); throw new AssertionError("DotRuntimeException Expected"); diff --git a/dotcms-integration/src/test/java/com/dotcms/contenttype/business/uniquefields/extratable/DBUniqueFieldValidationStrategyTest.java b/dotcms-integration/src/test/java/com/dotcms/contenttype/business/uniquefields/extratable/DBUniqueFieldValidationStrategyTest.java new file mode 100644 index 000000000000..59e25307cbe3 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/contenttype/business/uniquefields/extratable/DBUniqueFieldValidationStrategyTest.java @@ -0,0 +1,845 @@ +package com.dotcms.contenttype.business.uniquefields.extratable; + +import com.dotcms.contenttype.business.UniqueFieldValueDuplicatedException; +import com.dotcms.contenttype.model.field.Field; +import com.dotcms.contenttype.model.field.TextField; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.datagen.*; +import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.util.JsonUtil; +import com.dotcms.variant.VariantAPI; +import com.dotmarketing.beans.Host; +import com.dotmarketing.common.db.DotConnect; +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.dotmarketing.util.StringUtils; +import com.dotmarketing.util.UUIDGenerator; +import com.liferay.util.StringPool; +import net.bytebuddy.utility.RandomString; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.dotcms.content.elasticsearch.business.ESContentletAPIImpl.UNIQUE_PER_SITE_FIELD_VARIABLE_NAME; +import static com.dotcms.util.CollectionsUtils.list; +import static com.dotmarketing.portlets.contentlet.model.Contentlet.IDENTIFIER_KEY; +import static com.dotmarketing.portlets.contentlet.model.Contentlet.INODE_KEY; +import static org.junit.Assert.*; + +public class DBUniqueFieldValidationStrategyTest { + + static UniqueFieldDataBaseUtil uniqueFieldDataBaseUtil; + + @BeforeClass + public static void prepare() throws Exception { + IntegrationTestInitService.getInstance().init(); + uniqueFieldDataBaseUtil = new UniqueFieldDataBaseUtil(); + + //TODO: Remove this when the whole change is done + try { + new DotConnect().setSQL("CREATE TABLE IF NOT EXISTS unique_fields (" + + "unique_key_val VARCHAR(64) PRIMARY KEY," + + "supporting_values JSONB" + + " )").loadObjectResults(); + } catch (DotDataException e) { + throw new RuntimeException(e); + } + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Called the method with the right parameters + * Should: Insert a register in the unique_fields table + */ + @Test + public void insert() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException { + final Field field = new FieldDataGen().type(TextField.class).unique(true).next(); + final ContentType contentType = new ContentTypeDataGen().field(field).nextPersisted(); + final Object value = new RandomString().nextString(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(field.variable(), value) + .host(site) + .languageId(language.getId()) + .next(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, field); + + final UniqueFieldCriteria uniqueFieldCriteria = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(value) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + validateAfterInsert(uniqueFieldCriteria, contentlet); + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Called the method with a 'unique_key_val' duplicated + * Should: Throw a {@link UniqueFieldValueDuplicatedException} + */ + @Test + public void tryToInsertDuplicated() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException { + final Field uniqueField = new FieldDataGen().type(TextField.class).unique(true).next(); + final ContentType contentType = new ContentTypeDataGen().field(uniqueField).nextPersisted(); + final Object value = "UniqueValue" + System.currentTimeMillis(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(uniqueField.variable(), value) + .host(site) + .languageId(language.getId()) + .next(); + + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, uniqueField); + + final String hash = StringUtils.hashText(contentType.id() + uniqueField.variable() + language.getId() + value); + + final int countBefore = Integer.parseInt(new DotConnect() + .setSQL("SELECT COUNT(*) as count FROM unique_fields WHERE unique_key_val = ?") + .addParam(hash).loadObjectResults().get(0).get("count").toString()); + try { + + extraTableUniqueFieldValidationStrategy.validate(contentlet, uniqueField); + throw new AssertionError("UniqueFieldValueDupliacatedException expected"); + } catch (UniqueFieldValueDuplicatedException e) { + + final int countAfter = Integer.parseInt(new DotConnect() + .setSQL("SELECT COUNT(*) as count FROM unique_fields WHERE unique_key_val = ?") + .addParam(hash).loadObjectResults().get(0).get("count").toString()); + + assertEquals(countBefore, countAfter); + } + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Called the method with a field with uniquePerSite set to true + * Should: Allow insert the same values in different Host + */ + @Test + public void insertWithUniquePerSiteSetToTrue() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException { + final Field uniqueField = new FieldDataGen().type(TextField.class).unique(true).next(); + final ContentType contentType = new ContentTypeDataGen().field(uniqueField).nextPersisted(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + final Host site_2 = new SiteDataGen().nextPersisted(); + final String uniqueFieldVariable = uniqueField.variable(); + final String uniqueValue = "UniqueValue" + System.currentTimeMillis(); + + new FieldVariableDataGen() + .key(UNIQUE_PER_SITE_FIELD_VARIABLE_NAME) + .value("true") + .field(contentType.fields().stream() + .filter(field -> field.variable().equals(uniqueFieldVariable)) + .limit(1) + .findFirst() + .orElseThrow()) + .nextPersisted(); + + final UniqueFieldCriteria uniqueFieldCriteria_1 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(uniqueField) + .setValue(uniqueValue) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + final Contentlet contentlet_1 = new ContentletDataGen(contentType) + .setProperty(uniqueFieldVariable, uniqueValue) + .host(site) + .languageId(language.getId()) + .next(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet_1, uniqueField); + + final UniqueFieldCriteria uniqueFieldCriteria_2 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(uniqueField) + .setValue(uniqueValue) + .setLanguage(language) + .setSite(site_2) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .setProperty(uniqueFieldVariable, uniqueValue) + .host(site_2) + .languageId(language.getId()) + .next(); + + extraTableUniqueFieldValidationStrategy.validate(contentlet_2, uniqueField); + + final List> results = new DotConnect() + .setSQL("SELECT * FROM unique_fields WHERE supporting_values->>'contentTypeID' = ?") + .addParam(contentType.id()) + .loadObjectResults(); + + assertEquals(2, results.size()); + + final UniqueFieldCriteria[] uniqueFieldCriterias = new UniqueFieldCriteria[]{uniqueFieldCriteria_1, uniqueFieldCriteria_2}; + final Contentlet[] contentlets = new Contentlet[]{contentlet_1, contentlet_2}; + final Host[] sites = new Host[]{site, site_2}; + + for (int i =0; i < results.size(); i++) { + Map result = results.get(i); + final Map mapExpected = new HashMap<>(uniqueFieldCriterias[i].toMap()); + mapExpected.put("contentletsId", list(contentlets[i].getIdentifier())); + mapExpected.put("uniquePerSite", true); + + final String valueToHash = contentType.id() + uniqueField.variable() + language.getId() + uniqueValue + + sites[i].getIdentifier(); + assertEquals(StringUtils.hashText(valueToHash), result.get("unique_key_val")); + } + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Called the method with a Not Unique Field + * Should: thrown an {@link IllegalArgumentException} + */ + @Test + public void insertNotUniqueField() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException { + final Field notUniqueField = new FieldDataGen().type(TextField.class).next(); + final ContentType contentType = new ContentTypeDataGen().field(notUniqueField).nextPersisted(); + final Object value = "UniqueValue" + System.currentTimeMillis(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(notUniqueField.variable(), value) + .languageId(language.getId()) + .host(site) + .next(); + + try { + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, notUniqueField); + throw new AssertionError("IllegalArgumentException Expected"); + } catch (IllegalArgumentException e) { + //expected + assertEquals("The Field " + notUniqueField.variable() + " is not unique", e.getMessage()); + } + } + + private static void validateAfterInsert(UniqueFieldCriteria uniqueFieldCriteria, + Contentlet... contentlets) throws DotDataException { + + final ContentType contentType = uniqueFieldCriteria.contentType(); + final Field field =uniqueFieldCriteria.field(); + final Language language = uniqueFieldCriteria.language(); + final Object value = uniqueFieldCriteria.value(); + + final List> results = new DotConnect() + .setSQL("SELECT * FROM unique_fields WHERE supporting_values->>'contentTypeID' = ? AND " + + "supporting_values->>'fieldVariableName' = ? AND supporting_values->>'fieldValue' = ? AND " + + "(supporting_values->>'languageId')::numeric = ?") + .addParam(contentType.id()) + .addParam(field.variable()) + .addParam(value) + .addParam(language.getId()) + .loadObjectResults(); + + assertEquals(contentlets.length, results.size()); + + for (Contentlet contentlet : contentlets) { + final Map mapExpected = new HashMap<>(uniqueFieldCriteria.toMap()); + + mapExpected.put("contentletsId", list(contentlet.getIdentifier())); + mapExpected.put("uniquePerSite", false); + + final String valueToHash = contentType.id() + field.variable() + language.getId() + value; + assertEquals(StringUtils.hashText(valueToHash), results.get(0).get("unique_key_val")); + } + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Called the method twice with different Content Type + * Should: Insert a register in the unique_fields table + */ + @Test + public void insertWithDifferentContentType() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException { + final Field field_1 = new FieldDataGen().type(TextField.class).velocityVarName("unique").unique(true).next(); + final ContentType contentType_1 = new ContentTypeDataGen().field(field_1).nextPersisted(); + final Object value = "UniqueValue" + System.currentTimeMillis(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final UniqueFieldCriteria uniqueFieldCriteria_1 = new UniqueFieldCriteria.Builder() + .setContentType(contentType_1) + .setField(field_1) + .setValue(value) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + final Contentlet contentlet_1 = new ContentletDataGen(contentType_1) + .setProperty(field_1.variable(), value) + .host(site) + .languageId(language.getId()) + .next(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet_1, field_1); + + validateAfterInsert(uniqueFieldCriteria_1, contentlet_1); + + final Field field_2 = new FieldDataGen().type(TextField.class).velocityVarName("unique").unique(true).next(); + final ContentType contentType_2 = new ContentTypeDataGen().field(field_2).nextPersisted(); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType_2) + .setProperty(field_1.variable(), value) + .host(site) + .languageId(language.getId()) + .next(); + + final UniqueFieldCriteria uniqueFieldCriteria_2 = new UniqueFieldCriteria.Builder() + .setContentType(contentType_2) + .setField(field_2) + .setValue(value) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + extraTableUniqueFieldValidationStrategy.validate(contentlet_2, field_1); + validateAfterInsert(uniqueFieldCriteria_2, contentlet_2); + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Called the method twice with different Field + * Should: Insert a register in the unique_fields table + */ + @Test + public void insertWithDifferentField() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException { + final Field field_1 = new FieldDataGen().type(TextField.class).unique(true) + .velocityVarName("field1" + System.currentTimeMillis()).next(); + final Field field_2 = new FieldDataGen().type(TextField.class).unique(true) + .velocityVarName("field2" + System.currentTimeMillis()).next(); + final ContentType contentType = new ContentTypeDataGen().field(field_1).field(field_2).nextPersisted(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final String uniqueValue = "UniqueValue" + System.currentTimeMillis(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(field_1.variable(), uniqueValue) + .setProperty(field_2.variable(), uniqueValue) + .languageId(language.getId()) + .host(site) + .next(); + + final UniqueFieldCriteria uniqueFieldCriteria_1 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field_1) + .setValue(uniqueValue) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, field_1); + + final UniqueFieldCriteria uniqueFieldCriteria_2 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field_2) + .setValue(uniqueValue) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + extraTableUniqueFieldValidationStrategy.validate(contentlet, field_2); + + validateAfterInsert(uniqueFieldCriteria_1, contentlet); + validateAfterInsert(uniqueFieldCriteria_2, contentlet); + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Called the method twice with different Value + * Should: Insert a register in the unique_fields table + */ + @Test + public void insertWithDifferentValue() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException { + final Field field = new FieldDataGen().type(TextField.class).unique(true) + .velocityVarName("field1" + System.currentTimeMillis()).next(); + final ContentType contentType = new ContentTypeDataGen().field(field).nextPersisted(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final String uniqueValue_1 = "UniqueValue1" + System.currentTimeMillis(); + final String uniqueValue_2 = "UniqueValue2" + System.currentTimeMillis(); + + final String id = UUIDGenerator.generateUuid(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(field.variable(), uniqueValue_1) + .setProperty(IDENTIFIER_KEY, id) + .host(site) + .languageId(language.getId()) + .next(); + + final UniqueFieldCriteria uniqueFieldCriteria_1 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(uniqueValue_1) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, field); + + final UniqueFieldCriteria uniqueFieldCriteria_2 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(uniqueValue_2) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .setProperty(field.variable(), uniqueValue_2) + .setProperty(IDENTIFIER_KEY, id) + .host(site) + .languageId(language.getId()) + .next(); + + extraTableUniqueFieldValidationStrategy.validate(contentlet_2, field); + + validateDoesNotExists(uniqueFieldCriteria_1); + validateAfterInsert(uniqueFieldCriteria_2, contentlet_2); + } + + private static void validateDoesNotExists(final UniqueFieldCriteria uniqueFieldCriteria_1) throws DotDataException { + final List> results = new DotConnect() + .setSQL("SELECT * FROM unique_fields WHERE supporting_values->>'contentTypeID' = ? AND " + + "supporting_values->>'fieldVariableName' = ? AND supporting_values->>'fieldValue' = ? AND " + + "(supporting_values->>'languageId')::numeric = ?") + .addParam(uniqueFieldCriteria_1.contentType().id()) + .addParam(uniqueFieldCriteria_1.field().variable()) + .addParam(uniqueFieldCriteria_1.value()) + .addParam(uniqueFieldCriteria_1.language().getId()) + .loadObjectResults(); + + assertTrue(results.isEmpty()); + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Called the method twice with different Language + * Should: Insert a register in the unique_fields table + */ + @Test + public void insertWithDifferentLanguage() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException { + final Field field = new FieldDataGen().type(TextField.class).unique(true) + .velocityVarName("field1" + System.currentTimeMillis()).next(); + final ContentType contentType = new ContentTypeDataGen().field(field).nextPersisted(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + final Language otherLanguage = new LanguageDataGen().nextPersisted(); + + final String uniqueValue = "UniqueValue1" + System.currentTimeMillis(); + final String id = UUIDGenerator.generateUuid(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(field.variable(), uniqueValue) + .setProperty(IDENTIFIER_KEY, id) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + final UniqueFieldCriteria uniqueFieldCriteria_1 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(uniqueValue) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, field); + + + final UniqueFieldCriteria uniqueFieldCriteria_2 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(uniqueValue) + .setLanguage(otherLanguage) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + final Contentlet contentlet_2 = new ContentletDataGen(contentType) + .setProperty(field.variable(), uniqueValue) + .setProperty(IDENTIFIER_KEY, id) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(otherLanguage.getId()) + .next(); + + + extraTableUniqueFieldValidationStrategy.validate(contentlet_2, field); + + validateDoesNotExists(uniqueFieldCriteria_1); + validateAfterInsert(uniqueFieldCriteria_2, contentlet_2); + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Pretend that we are calling the afterSaved method after saved a new Contentlet + * Should: Update the unique_fields register created before to add the Id in the contentlet list + */ + @Test + public void afterSaved() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException, IOException { + final Field field = new FieldDataGen().type(TextField.class).unique(true).next(); + final ContentType contentType = new ContentTypeDataGen().field(field).nextPersisted(); + final Object value = new RandomString().nextString(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(field.variable(), value) + .host(site) + .languageId(language.getId()) + .next(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, field); + + final UniqueFieldCriteria uniqueFieldCriteria = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(value) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + checkContentIds(uniqueFieldCriteria, list(StringPool.BLANK)); + + final Contentlet contentletSaved = new ContentletDataGen(contentType) + .setProperty(field.variable(), value) + .setProperty(IDENTIFIER_KEY, UUIDGenerator.generateUuid()) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + extraTableUniqueFieldValidationStrategy.afterSaved(contentletSaved, true); + + checkContentIds(uniqueFieldCriteria, list(contentletSaved.getIdentifier())); + } + + private static void checkContentIds(final UniqueFieldCriteria uniqueFieldCriteria, + final Collection compareWith) throws DotDataException, IOException { + final List> results = new DotConnect().setSQL("SELECT * FROM unique_fields WHERE unique_key_val = ?") + .addParam(uniqueFieldCriteria.hash()) + .loadObjectResults(); + + assertEquals(1, results.size()); + + final Map supportingValues = JsonUtil.getJsonFromString( + results.get(0).get("supporting_values").toString()); + + assertEquals(compareWith, supportingValues.get("contentletsId")); + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#afterSaved(Contentlet, boolean)} + * When: Pretend that we are calling the afterSaved method after updated Contentlet + * Should: Update the unique_fields register created before to add the Id in the contentlet list + */ + @Test + public void afterUpdated() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException, IOException { + final Field field = new FieldDataGen().type(TextField.class).unique(true).next(); + final ContentType contentType = new ContentTypeDataGen().field(field).nextPersisted(); + final Object value = new RandomString().nextString(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final String contentletId = UUIDGenerator.generateUuid(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(field.variable(), value) + .setProperty(IDENTIFIER_KEY, contentletId) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, field); + + final UniqueFieldCriteria uniqueFieldCriteria = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(value) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + checkContentIds(uniqueFieldCriteria, list(contentlet.getIdentifier())); + + final Contentlet contentletSaved = new ContentletDataGen(contentType) + .setProperty(field.variable(), value) + .setProperty(IDENTIFIER_KEY, contentletId) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + extraTableUniqueFieldValidationStrategy.afterSaved(contentletSaved, false); + + checkContentIds(uniqueFieldCriteria, list(contentletSaved.getIdentifier())); + } + + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#afterSaved(Contentlet, boolean)} + * When: Pretend that we are calling the afterSaved method after saved a new Contentlet with 2 unique fields + * Should: Update the unique_fields register created before to add the Id in the contentlet list + */ + @Test + public void savingWithContentTypeWithMoreThanOneUniqueField() + throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException, IOException { + final Field uniquefield_1 = new FieldDataGen().type(TextField.class).unique(true) + .velocityVarName("field1" + System.currentTimeMillis()).next(); + final Field uniquefield_2 = new FieldDataGen().type(TextField.class).unique(true) + .velocityVarName("field2" + System.currentTimeMillis()).next(); + + final ContentType contentType = new ContentTypeDataGen().fields(list(uniquefield_1, uniquefield_2)).nextPersisted(); + final Object value_1 = new RandomString().nextString(); + final Object value_2 = new RandomString().nextString(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(uniquefield_1.variable(), value_1) + .setProperty(uniquefield_2.variable(), value_2) + .host(site) + .languageId(language.getId()) + .next(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, uniquefield_1); + extraTableUniqueFieldValidationStrategy.validate(contentlet, uniquefield_2); + + final UniqueFieldCriteria uniqueFieldCriteria_1 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(uniquefield_1) + .setValue(value_1) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + checkContentIds(uniqueFieldCriteria_1, list(StringPool.BLANK)); + + final UniqueFieldCriteria uniqueFieldCriteria_2 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(uniquefield_2) + .setValue(value_2) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + checkContentIds(uniqueFieldCriteria_2, list(StringPool.BLANK)); + + final Contentlet contentletSaved = new ContentletDataGen(contentType) + .setProperty(uniquefield_1.variable(), value_1) + .setProperty(uniquefield_2.variable(), value_2) + .setProperty(IDENTIFIER_KEY, UUIDGenerator.generateUuid()) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + extraTableUniqueFieldValidationStrategy.afterSaved(contentletSaved, true); + + checkContentIds(uniqueFieldCriteria_1, list(contentletSaved.getIdentifier())); + checkContentIds(uniqueFieldCriteria_2, list(contentletSaved.getIdentifier())); + } + + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#afterSaved(Contentlet, boolean)} + * When: Pretend that we are calling the afterSaved method after update a Contentlet with 2 unique fields + * Should: Update the unique_fields register created before to add the Id in the contentlet list + */ + @Test + public void updatingWithContentTypeWithMoreThanOneUniqueField() + throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException, IOException { + final Field uniquefield_1 = new FieldDataGen().type(TextField.class).unique(true) + .velocityVarName("field1" + System.currentTimeMillis()).next(); + final Field uniquefield_2 = new FieldDataGen().type(TextField.class).unique(true) + .velocityVarName("field2" + System.currentTimeMillis()).next(); + + final ContentType contentType = new ContentTypeDataGen().fields(list(uniquefield_1, uniquefield_2)).nextPersisted(); + final Object value_1 = new RandomString().nextString(); + final Object value_2 = new RandomString().nextString(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final String contentletId = UUIDGenerator.generateUuid(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(uniquefield_1.variable(), value_1) + .setProperty(uniquefield_2.variable(), value_2) + .setProperty(IDENTIFIER_KEY, contentletId) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, uniquefield_1); + extraTableUniqueFieldValidationStrategy.validate(contentlet, uniquefield_2); + + final UniqueFieldCriteria uniqueFieldCriteria_1 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(uniquefield_1) + .setValue(value_1) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + checkContentIds(uniqueFieldCriteria_1, list(contentlet.getIdentifier())); + + final UniqueFieldCriteria uniqueFieldCriteria_2 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(uniquefield_2) + .setValue(value_2) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + checkContentIds(uniqueFieldCriteria_2, list(contentlet.getIdentifier())); + + final Contentlet contentletSaved = new ContentletDataGen(contentType) + .setProperty(uniquefield_1.variable(), value_1) + .setProperty(uniquefield_2.variable(), value_2) + .setProperty(IDENTIFIER_KEY, contentletId) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + extraTableUniqueFieldValidationStrategy.afterSaved(contentletSaved, false); + + checkContentIds(uniqueFieldCriteria_1, list(contentletSaved.getIdentifier())); + checkContentIds(uniqueFieldCriteria_2, list(contentletSaved.getIdentifier())); + } + + /** + * Method to test: {@link DBUniqueFieldValidationStrategy#validate(Contentlet, Field)} + * When: Pretend that we are calling the validate method after updated Contentlet, and the unique value is the changed + * Should: Update the unique_fields register + */ + @Test + public void validateUpdating() throws DotDataException, UniqueFieldValueDuplicatedException, DotSecurityException, IOException { + final Field field = new FieldDataGen().type(TextField.class).unique(true).next(); + final ContentType contentType = new ContentTypeDataGen().field(field).nextPersisted(); + final Object value_1 = new RandomString().nextString(); + final Object value_2 = new RandomString().nextString(); + final Language language = new LanguageDataGen().nextPersisted(); + final Host site = new SiteDataGen().nextPersisted(); + + final String contentletId = UUIDGenerator.generateUuid(); + + final Contentlet contentlet = new ContentletDataGen(contentType) + .setProperty(field.variable(), value_1) + .setProperty(IDENTIFIER_KEY, contentletId) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + final DBUniqueFieldValidationStrategy extraTableUniqueFieldValidationStrategy = + new DBUniqueFieldValidationStrategy(uniqueFieldDataBaseUtil); + extraTableUniqueFieldValidationStrategy.validate(contentlet, field); + + final UniqueFieldCriteria uniqueFieldCriteria_1 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(value_1) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + checkContentIds(uniqueFieldCriteria_1, list(contentlet.getIdentifier())); + + final Contentlet contentletSaved = new ContentletDataGen(contentType) + .setProperty(field.variable(), value_2) + .setProperty(IDENTIFIER_KEY, contentletId) + .setProperty(INODE_KEY, UUIDGenerator.generateUuid()) + .host(site) + .languageId(language.getId()) + .next(); + + extraTableUniqueFieldValidationStrategy.validate(contentletSaved, field); + + final UniqueFieldCriteria uniqueFieldCriteria_2 = new UniqueFieldCriteria.Builder() + .setContentType(contentType) + .setField(field) + .setValue(value_2) + .setLanguage(language) + .setSite(site) + .setVariantName(VariantAPI.DEFAULT_VARIANT.name()) + .build(); + + checkContentIds(uniqueFieldCriteria_2, list(contentletSaved.getIdentifier())); + + List> results = new DotConnect().setSQL("SELECT * FROM unique_fields WHERE supporting_values->>'contentTypeID' = ?") + .addParam(contentType.id()) + .loadObjectResults(); + + assertEquals(1, results.size()); + } +} diff --git a/dotcms-integration/src/test/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldDataBaseUtilTest.java b/dotcms-integration/src/test/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldDataBaseUtilTest.java new file mode 100644 index 000000000000..e7f34a9f5097 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/contenttype/business/uniquefields/extratable/UniqueFieldDataBaseUtilTest.java @@ -0,0 +1,117 @@ +package com.dotcms.contenttype.business.uniquefields.extratable; + +import com.dotcms.util.CollectionsUtils; +import com.dotcms.util.JsonUtil; +import com.dotmarketing.business.FactoryLocator; +import com.dotmarketing.common.db.DotConnect; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.util.StringUtils; +import net.bytebuddy.utility.RandomString; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; + +public class UniqueFieldDataBaseUtilTest { + + @BeforeClass + //TODO: Remove this when the whole change is done + public static void init (){ + try { + new DotConnect().setSQL("CREATE TABLE IF NOT EXISTS unique_fields (" + + "unique_key_val VARCHAR(64) PRIMARY KEY," + + "supporting_values JSONB" + + " )").loadObjectResults(); + } catch (DotDataException e) { + throw new RuntimeException(e); + } + } + + /** + * Method to test: {@link UniqueFieldDataBaseUtil#insert(String, Map)} + * When: Called the method with the right parameters + * Should: Insert a register in the unique_fields table + */ + @Test + public void insert() throws DotDataException, IOException { + final RandomString randomStringGenerator = new RandomString(); + + final String hash = StringUtils.hashText("This is a test " + System.currentTimeMillis()); + + final Map supportingValues = Map.of( + "contentTypeID", randomStringGenerator.nextString(), + "fieldVariableName", randomStringGenerator.nextString(), + "fieldValue", randomStringGenerator.nextString(), + "languageId", randomStringGenerator.nextString(), + "hostId", randomStringGenerator.nextString(), + "uniquePerSite", true, + "contentletsId", CollectionsUtils.list( randomStringGenerator.nextString() ) + ); + + final UniqueFieldDataBaseUtil uniqueFieldDataBaseUtil = new UniqueFieldDataBaseUtil(); + + uniqueFieldDataBaseUtil.insert(hash, supportingValues); + + final List> results = new DotConnect().setSQL("SELECT * FROM unique_fields WHERE unique_key_val = ?") + .addParam(hash).loadObjectResults(); + + assertFalse(results.isEmpty()); + + final List> hashResults = results.stream() + .filter(result -> result.get("unique_key_val").equals(hash)) + .collect(Collectors.toList()); + + assertEquals(1, hashResults.size()); + assertEquals(supportingValues, JsonUtil.getJsonFromString(hashResults.get(0).get("supporting_values").toString())); + } + + /** + * Method to test: {@link UniqueFieldDataBaseUtil#insert(String, Map)} + * When: Called the method with a 'unique_key_val' duplicated + * Should: Throw a {@link java.sql.SQLException} + */ + @Test + public void tryToInsertDuplicated() throws DotDataException { + final RandomString randomStringGenerator = new RandomString(); + + final String hash = StringUtils.hashText("This is a test " + System.currentTimeMillis()); + + final Map supportingValues_1 = Map.of( + "contentTypeID", randomStringGenerator.nextString(), + "fieldVariableName", randomStringGenerator.nextString(), + "fieldValue", randomStringGenerator.nextString(), + "languageId", randomStringGenerator.nextString(), + "hostId", randomStringGenerator.nextString(), + "uniquePerSite", true, + "contentletsId", "['" + randomStringGenerator.nextString() + "']" + ); + + final UniqueFieldDataBaseUtil uniqueFieldDataBaseUtil = new UniqueFieldDataBaseUtil(); + uniqueFieldDataBaseUtil.insert(hash, supportingValues_1); + + final Map supportingValues_2 = Map.of( + "contentTypeID", randomStringGenerator.nextString(), + "fieldVariableName", randomStringGenerator.nextString(), + "fieldValue", randomStringGenerator.nextString(), + "languageId", randomStringGenerator.nextString(), + "hostId", randomStringGenerator.nextString(), + "uniquePerSite", true, + "contentletsId", CollectionsUtils.list( randomStringGenerator.nextString()) + ); + + try { + uniqueFieldDataBaseUtil.insert(hash, supportingValues_2); + + throw new AssertionError("Exception expected"); + } catch (DotDataException e) { + assertTrue(e.getMessage().startsWith("ERROR: duplicate key value violates unique constraint \"unique_fields_pkey\"")); + } + } + + +} diff --git a/dotcms-integration/src/test/java/com/dotcms/filters/VanityUrlFilterTest.java b/dotcms-integration/src/test/java/com/dotcms/filters/VanityUrlFilterTest.java index 88e60551278e..09008cec8ecb 100644 --- a/dotcms-integration/src/test/java/com/dotcms/filters/VanityUrlFilterTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/filters/VanityUrlFilterTest.java @@ -239,7 +239,7 @@ public void test_that_vanity_url_filter_handles_redirects() throws Exception { filtersUtil.publishVanityUrl(contentlet1); final String resource = "/test redirect 301".replaceAll(" ", "%20"); - final String queryWithFragment = "?param1=value 1¶m2=value 2#test-fragment" + final String queryWithFragment = "?param1=value 1¶m2=value 2#test/fragment" .replaceAll(" ", "+"); final String testURI = baseURI + resource + queryWithFragment; final HttpServletRequest request = new MockHttpRequestIntegrationTest(defaultHost.getHostname(), testURI).request(); diff --git a/dotcms-integration/src/test/java/com/dotcms/jobs/business/api/JobQueueManagerAPICDITest.java b/dotcms-integration/src/test/java/com/dotcms/jobs/business/api/JobQueueManagerAPICDITest.java index d4e6d85c2865..58a18fd7712a 100644 --- a/dotcms-integration/src/test/java/com/dotcms/jobs/business/api/JobQueueManagerAPICDITest.java +++ b/dotcms-integration/src/test/java/com/dotcms/jobs/business/api/JobQueueManagerAPICDITest.java @@ -5,24 +5,25 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; -import com.dotcms.TestBaseJunit5WeldInitiator; +import com.dotcms.Junit5WeldBaseTest; import com.dotcms.jobs.business.error.ExponentialBackoffRetryStrategy; import com.dotcms.jobs.business.queue.JobQueue; import javax.inject.Inject; +import org.jboss.weld.junit5.EnableWeld; import org.junit.jupiter.api.Test; /** * Test class for verifying the CDI (Contexts and Dependency Injection) functionality of the * JobQueueManagerAPI implementation. */ - -public class JobQueueManagerAPICDITest extends TestBaseJunit5WeldInitiator { +@EnableWeld +public class JobQueueManagerAPICDITest extends Junit5WeldBaseTest { @Inject - private JobQueueManagerAPI jobQueueManagerAPI; + JobQueueManagerAPI jobQueueManagerAPI; @Inject - private JobQueueManagerAPI jobQueueManagerAPI2; + JobQueueManagerAPI jobQueueManagerAPI2; /** * Method to test: Multiple injections of JobQueueManagerAPI Given Scenario: Two separate diff --git a/dotcms-integration/src/test/java/com/dotcms/jobs/business/api/JobQueueManagerAPIIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/jobs/business/api/JobQueueManagerAPIIntegrationTest.java index d9b5f0046be1..210028bdee69 100644 --- a/dotcms-integration/src/test/java/com/dotcms/jobs/business/api/JobQueueManagerAPIIntegrationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/jobs/business/api/JobQueueManagerAPIIntegrationTest.java @@ -13,7 +13,6 @@ import com.dotcms.jobs.business.processor.JobProcessor; import com.dotcms.jobs.business.processor.ProgressTracker; import com.dotcms.util.IntegrationTestInitService; -import com.dotmarketing.business.APILocator; import com.dotmarketing.common.db.DotConnect; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.util.Logger; @@ -26,24 +25,33 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import javax.inject.Inject; import org.awaitility.Awaitility; +import org.jboss.weld.junit5.EnableWeld; +import org.jboss.weld.junit5.WeldJunit5Extension; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.ExtendWith; /** * Integration tests for the JobQueueManagerAPI. * These tests verify the functionality of the job queue system in a real environment, * including job creation, processing, cancellation, retrying, and progress tracking. */ +@EnableWeld @TestMethodOrder(MethodOrderer.OrderAnnotation.class) -public class JobQueueManagerAPIIntegrationTest { +@TestInstance(Lifecycle.PER_CLASS) +public class JobQueueManagerAPIIntegrationTest extends com.dotcms.Junit5WeldBaseTest { - private static JobQueueManagerAPI jobQueueManagerAPI; + @Inject + JobQueueManagerAPI jobQueueManagerAPI; /** * Sets up the test environment before all tests are run. @@ -55,8 +63,6 @@ public class JobQueueManagerAPIIntegrationTest { static void setUp() throws Exception { // Initialize the test environment IntegrationTestInitService.getInstance().init(); - - jobQueueManagerAPI = APILocator.getJobQueueManagerAPI(); } /** @@ -66,7 +72,7 @@ static void setUp() throws Exception { * @throws Exception if there's an error during cleanup */ @AfterAll - static void cleanUp() throws Exception { + void cleanUp() throws Exception { if(null != jobQueueManagerAPI) { jobQueueManagerAPI.close(); } diff --git a/dotcms-integration/src/test/java/com/dotcms/jobs/business/api/JobQueueManagerAPITest.java b/dotcms-integration/src/test/java/com/dotcms/jobs/business/api/JobQueueManagerAPITest.java index 1ea8589cc6c4..8525440886d3 100644 --- a/dotcms-integration/src/test/java/com/dotcms/jobs/business/api/JobQueueManagerAPITest.java +++ b/dotcms-integration/src/test/java/com/dotcms/jobs/business/api/JobQueueManagerAPITest.java @@ -28,6 +28,7 @@ import com.dotcms.jobs.business.error.ErrorDetail; import com.dotcms.jobs.business.error.JobCancellationException; import com.dotcms.jobs.business.error.JobProcessingException; +import com.dotcms.jobs.business.error.RetryPolicyProcessor; import com.dotcms.jobs.business.error.RetryStrategy; import com.dotcms.jobs.business.job.Job; import com.dotcms.jobs.business.job.JobPaginatedResult; @@ -144,6 +145,8 @@ public boolean awaitProcessingCompleted(long timeout, TimeUnit unit) private EventProducer eventProducer; + private RetryPolicyProcessor retryPolicyProcessor; + /** * Factory to create mock JobProcessor instances for testing. * This is how we instruct the JobQueueManagerAPI to use our mock processors. @@ -173,10 +176,11 @@ public void setUp() { mockRetryStrategy = mock(RetryStrategy.class); mockCircuitBreaker = mock(CircuitBreaker.class); eventProducer = mock(EventProducer.class); + retryPolicyProcessor = mock(RetryPolicyProcessor.class); jobQueueManagerAPI = newJobQueueManagerAPI( mockJobQueue, mockCircuitBreaker, mockRetryStrategy, eventProducer, jobProcessorFactory, - 1, 10 + retryPolicyProcessor, 1, 10 ); jobQueueManagerAPI.registerProcessor("testQueue", JobProcessor.class); @@ -995,7 +999,7 @@ public void test_CircuitBreaker_Opens() throws Exception { // Create JobQueueManagerAPIImpl with the real CircuitBreaker JobQueueManagerAPI jobQueueManagerAPI = newJobQueueManagerAPI( mockJobQueue, circuitBreaker, mockRetryStrategy, eventProducer, jobProcessorFactory, - 1, 1000 + retryPolicyProcessor, 1, 1000 ); jobQueueManagerAPI.registerProcessor("testQueue", JobProcessor.class); @@ -1080,7 +1084,7 @@ public void test_CircuitBreaker_Closes() throws Exception { // Create JobQueueManagerAPIImpl with the real CircuitBreaker JobQueueManagerAPI jobQueueManagerAPI = newJobQueueManagerAPI( mockJobQueue, circuitBreaker, mockRetryStrategy, eventProducer, jobProcessorFactory, - 1, 1000 + retryPolicyProcessor, 1, 1000 ); jobQueueManagerAPI.registerProcessor("testQueue", JobProcessor.class); @@ -1143,7 +1147,7 @@ public void test_CircuitBreaker_Reset() throws Exception { // Create JobQueueManagerAPIImpl with the real CircuitBreaker JobQueueManagerAPI jobQueueManagerAPI = newJobQueueManagerAPI( mockJobQueue, circuitBreaker, mockRetryStrategy, eventProducer, jobProcessorFactory, - 1, 1000 + retryPolicyProcessor, 1, 1000 ); jobQueueManagerAPI.registerProcessor("testQueue", JobProcessor.class); @@ -1327,13 +1331,15 @@ private JobQueueManagerAPI newJobQueueManagerAPI(JobQueue jobQueue, RetryStrategy retryStrategy, EventProducer eventProducer, JobProcessorFactory jobProcessorFactory, + RetryPolicyProcessor retryPolicyProcessor, int threadPoolSize, int pollJobUpdatesIntervalMilliseconds) { final var realTimeJobMonitor = new RealTimeJobMonitor(); return new JobQueueManagerAPIImpl( jobQueue, new JobQueueConfig(threadPoolSize, pollJobUpdatesIntervalMilliseconds), - circuitBreaker, retryStrategy, realTimeJobMonitor, eventProducer, jobProcessorFactory + circuitBreaker, retryStrategy, realTimeJobMonitor, eventProducer, + jobProcessorFactory, retryPolicyProcessor ); } diff --git a/dotcms-integration/src/test/java/com/dotcms/jobs/business/processor/impl/ImportContentletsProcessorIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/jobs/business/processor/impl/ImportContentletsProcessorIntegrationTest.java new file mode 100644 index 000000000000..e2889dc18f46 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/jobs/business/processor/impl/ImportContentletsProcessorIntegrationTest.java @@ -0,0 +1,282 @@ +package com.dotcms.jobs.business.processor.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.datagen.TestDataUtils; +import com.dotcms.jobs.business.job.Job; +import com.dotcms.jobs.business.job.JobState; +import com.dotcms.jobs.business.processor.DefaultProgressTracker; +import com.dotcms.jobs.business.util.JobUtil; +import com.dotcms.rest.api.v1.temp.DotTempFile; +import com.dotcms.rest.api.v1.temp.TempFileAPI; +import com.dotcms.util.IntegrationTestInitService; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.liferay.portal.model.User; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.servlet.http.HttpServletRequest; +import org.jboss.weld.junit5.EnableWeld; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for the {@link ImportContentletsProcessor} class. These tests verify the + * functionality of content import operations in a real database environment. The tests cover both + * preview and publish modes, testing the complete workflow of content import including content type + * creation, CSV file processing, and content verification. + * + *

The test suite creates temporary content types and files for testing, + * and includes cleanup operations to maintain database integrity. + */ +@EnableWeld +public class ImportContentletsProcessorIntegrationTest extends com.dotcms.Junit5WeldBaseTest { + + private static Host defaultSite; + private static User systemUser; + private static HttpServletRequest request; + + /** + * Sets up the test environment before all tests are run. This method: + *

    + *
  • Initializes the dotCMS test environment
  • + *
  • Retrieves the system user for test operations
  • + *
  • Sets up the default site
  • + *
  • Creates a mock HTTP request for the import process
  • + *
+ * + * @throws Exception if there's an error during setup + */ + @BeforeAll + static void setUp() throws Exception { + + // Initialize the test environment + IntegrationTestInitService.getInstance().init(); + + // Get system user + systemUser = APILocator.getUserAPI().getSystemUser(); + + // Get the default site + defaultSite = APILocator.getHostAPI().findDefaultHost(systemUser, false); + + // Create a mock request + request = JobUtil.generateMockRequest(systemUser, defaultSite.getHostname()); + } + + /** + * Tests the preview mode of the content import process. This test: + *
    + *
  • Creates a test content type
  • + *
  • Generates a test CSV file with sample content
  • + *
  • Processes the import in preview mode
  • + *
  • Verifies the preview results and metadata
  • + *
  • Verifies there is no content creation in the database
  • + *
+ * + *

The test ensures that preview mode properly validates the content + * without actually creating it in the system. + * + * @throws Exception if there's an error during the test execution + */ + @Test + void test_process_preview() throws Exception { + + ContentType testContentType = null; + + try { + // Initialize processor + final var processor = new ImportContentletsProcessor(); + + // Create test content type + testContentType = createTestContentType(); + + // Create test CSV file + File csvFile = createTestCsvFile(); + + // Create test job + final var testJob = createTestJob( + csvFile, "preview", testContentType.id(), "b9d89c80-3d88-4311-8365-187323c96436" + ); + + // Process the job in preview mode + processor.process(testJob); + + // Verify preview results + Map metadata = processor.getResultMetadata(testJob); + assertNotNull(metadata, "Preview metadata should not be null"); + assertNotNull(metadata.get("errors"), "Preview metadata errors should not be null"); + assertNotNull(metadata.get("results"), "Preview metadata results should not be null"); + assertEquals(0, ((ArrayList) metadata.get("errors")).size(), + "Preview metadata errors should be empty"); + + // Verify no content was created + final var importedContent = findImportedContent(testContentType.id()); + assertNotNull(importedContent, "Imported content should not be null"); + assertEquals(0, importedContent.size(), "Imported content should have no items"); + + } finally { + if (testContentType != null) { + // Clean up test content type + APILocator.getContentTypeAPI(systemUser).delete(testContentType); + } + } + } + + /** + * Tests the publish mode of the content import process. This test: + *

    + *
  • Creates a test content type
  • + *
  • Generates a test CSV file with sample content
  • + *
  • Processes the import in publish mode
  • + *
  • Verifies the actual content creation in the database
  • + *
+ * + *

The test confirms that content is properly created in the system + * and matches the data provided in the CSV file. + * + * @throws Exception if there's an error during the test execution + */ + @Test + void test_process_publish() throws Exception { + + ContentType testContentType = null; + + try { + // Initialize processor + final var processor = new ImportContentletsProcessor(); + + // Create test content type + testContentType = createTestContentType(); + + // Create test CSV file + File csvFile = createTestCsvFile(); + + // Create test job + final var testJob = createTestJob( + csvFile, "publish", testContentType.id(), "b9d89c80-3d88-4311-8365-187323c96436" + ); + + // Process the job in preview mode + processor.process(testJob); + + // Verify preview results + Map metadata = processor.getResultMetadata(testJob); + assertNotNull(metadata, "Publish metadata should not be null"); + assertNotNull(metadata.get("errors"), "Publish metadata errors should not be null"); + assertNotNull(metadata.get("results"), "Publish metadata results should not be null"); + assertEquals(0, ((ArrayList) metadata.get("errors")).size(), + "Publish metadata errors should be empty"); + + // Verify the content was actually created + final var importedContent = findImportedContent(testContentType.id()); + assertNotNull(importedContent, "Imported content should not be null"); + assertEquals(2, importedContent.size(), "Imported content should have 2 items"); + + } finally { + if (testContentType != null) { + // Clean up test content type + APILocator.getContentTypeAPI(systemUser).delete(testContentType); + } + } + } + + /** + * Creates a test content type for import operations. The content type is designed to support + * rich text content and is suitable for testing import functionality. + * + * @return A newly created {@link ContentType} instance + */ + private ContentType createTestContentType() { + return TestDataUtils.getRichTextLikeContentType(); + } + + /** + * Creates a test job for the import process. + * + * @param csvFile The CSV file containing the content to be imported + * @param cmd The command to execute ('preview' or 'publish') + * @param contentTypeId The ID of the content type for the imported content + * @param workflowActionId The ID of the workflow action to be applied + * @return A configured {@link Job} instance ready for processing + * @throws IOException if there's an error reading the CSV file + * @throws DotSecurityException if there's a security violation during job creation + */ + private Job createTestJob(final File csvFile, final String cmd, final String contentTypeId, + final String workflowActionId) throws IOException, DotSecurityException { + + final Map jobParameters = new HashMap<>(); + + // Setup basic job parameters + jobParameters.put("cmd", cmd); + jobParameters.put("userId", systemUser.getUserId()); + jobParameters.put("siteName", defaultSite.getHostname()); + jobParameters.put("siteIdentifier", defaultSite.getIdentifier()); + jobParameters.put("contentType", contentTypeId); + jobParameters.put("workflowActionId", workflowActionId); + jobParameters.put("language", "1"); + + final TempFileAPI tempFileAPI = APILocator.getTempFileAPI(); + try (final var fileInputStream = new FileInputStream(csvFile)) { + + final DotTempFile tempFile = tempFileAPI.createTempFile( + csvFile.getName(), request, fileInputStream + ); + + jobParameters.put("tempFileId", tempFile.id); + jobParameters.put("requestFingerPrint", tempFileAPI.getRequestFingerprint(request)); + } + + return Job.builder() + .id("test-job-id") + .queueName("Test Job") + .state(JobState.RUNNING) + .parameters(jobParameters) + .progressTracker(new DefaultProgressTracker()) + .build(); + } + + /** + * Creates a test CSV file with sample content. The file includes a header row and two content + * rows with title and body fields. + * + * @return A temporary {@link File} containing the CSV data + * @throws IOException if there's an error creating or writing to the file + */ + private File createTestCsvFile() throws IOException { + + // Create a CSV file that matches your content type structure + StringBuilder csv = new StringBuilder(); + csv.append("title,body\n"); + csv.append("Test Title 1,Test Body 1\n"); + csv.append("Test Title 2,Test Body 2\n"); + + File csvFile = File.createTempFile("test", ".csv"); + Files.write(csvFile.toPath(), csv.toString().getBytes()); + + return csvFile; + } + + /** + * Retrieves the list of content that was imported during the test. + * + * @param contentTypeId The ID of the content type to search for + * @return A list of {@link Contentlet} objects that were imported + * @throws Exception if there's an error retrieving the content + */ + private List findImportedContent(final String contentTypeId) throws Exception { + return APILocator.getContentletAPI().findByStructure( + contentTypeId, systemUser, false, -1, 0 + ); + } + +} \ No newline at end of file diff --git a/dotcms-integration/src/test/java/com/dotcms/jobs/business/queue/PostgresJobQueueIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/jobs/business/queue/PostgresJobQueueIntegrationTest.java index 890354fd0285..62d180e3088f 100644 --- a/dotcms-integration/src/test/java/com/dotcms/jobs/business/queue/PostgresJobQueueIntegrationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/jobs/business/queue/PostgresJobQueueIntegrationTest.java @@ -80,12 +80,12 @@ void test_createJob_and_getJob() throws JobQueueException { } /** - * Method to test: getActiveJobs in PostgresJobQueue + * Method to test: getActiveJobs in PostgresJobQueue for queue * Given Scenario: Multiple active jobs are created * ExpectedResult: All active jobs are retrieved correctly */ @Test - void test_getActiveJobs() throws JobQueueException { + void test_getActiveJobsForQueue() throws JobQueueException { String queueName = "testQueue"; for (int i = 0; i < 5; i++) { @@ -98,12 +98,29 @@ void test_getActiveJobs() throws JobQueueException { } /** - * Method to test: getCompletedJobs in PostgresJobQueue + * Method to test: getActiveJobs in PostgresJobQueue Given Scenario: Multiple active jobs are + * created ExpectedResult: All active jobs are retrieved correctly + */ + @Test + void test_getActiveJobs() throws JobQueueException { + + String queueName = "testQueue"; + for (int i = 0; i < 5; i++) { + jobQueue.createJob(queueName, new HashMap<>()); + } + + JobPaginatedResult result = jobQueue.getActiveJobs(1, 10); + assertEquals(5, result.jobs().size()); + assertEquals(5, result.total()); + } + + /** + * Method to test: getCompletedJobs in PostgresJobQueue for queue * Given Scenario: Multiple jobs are created and completed * ExpectedResult: All completed jobs within the given time range are retrieved */ @Test - void testGetCompletedJobs() throws JobQueueException { + void testGetCompletedJobsForQueue() throws JobQueueException { String queueName = "testQueue"; LocalDateTime startDate = LocalDateTime.now().minusDays(1); @@ -123,6 +140,54 @@ void testGetCompletedJobs() throws JobQueueException { result.jobs().forEach(job -> assertEquals(JobState.COMPLETED, job.state())); } + /** + * Method to test: getCompletedJobs in PostgresJobQueue + * Given Scenario: Multiple jobs are created and completed + * ExpectedResult: All completed jobs are retrieved + */ + @Test + void testGetCompletedJobs() throws JobQueueException { + + String queueName = "testQueue"; + + // Create and complete some jobs + for (int i = 0; i < 3; i++) { + String jobId = jobQueue.createJob(queueName, new HashMap<>()); + Job job = jobQueue.getJob(jobId); + Job completedJob = job.markAsCompleted(null); + jobQueue.updateJobStatus(completedJob); + } + + JobPaginatedResult result = jobQueue.getCompletedJobs(1, 10); + assertEquals(3, result.jobs().size()); + assertEquals(3, result.total()); + result.jobs().forEach(job -> assertEquals(JobState.COMPLETED, job.state())); + } + + /** + * Method to test: getCanceledJobs in PostgresJobQueue + * Given Scenario: Multiple jobs are created and canceled + * ExpectedResult: All canceled jobs are retrieved + */ + @Test + void testGetCanceledJobs() throws JobQueueException { + + String queueName = "testQueue"; + + // Create and complete some jobs + for (int i = 0; i < 3; i++) { + String jobId = jobQueue.createJob(queueName, new HashMap<>()); + Job job = jobQueue.getJob(jobId); + Job completedJob = job.markAsCanceled(null); + jobQueue.updateJobStatus(completedJob); + } + + JobPaginatedResult result = jobQueue.getCanceledJobs(1, 10); + assertEquals(3, result.jobs().size()); + assertEquals(3, result.total()); + result.jobs().forEach(job -> assertEquals(JobState.CANCELED, job.state())); + } + /** * Method to test: getFailedJobs in PostgresJobQueue * Given Scenario: Multiple jobs are created and set to failed state diff --git a/dotcms-integration/src/test/java/com/dotcms/junit/CustomDataProviderRunner.java b/dotcms-integration/src/test/java/com/dotcms/junit/CustomDataProviderRunner.java index f98199bfd452..4bdf0d2daae8 100644 --- a/dotcms-integration/src/test/java/com/dotcms/junit/CustomDataProviderRunner.java +++ b/dotcms-integration/src/test/java/com/dotcms/junit/CustomDataProviderRunner.java @@ -1,23 +1,55 @@ package com.dotcms.junit; +import com.dotcms.DataProviderWeldRunner; +import com.dotcms.JUnit4WeldRunner; import com.dotmarketing.util.Logger; import com.tngtech.java.junit.dataprovider.DataProviderRunner; import com.tngtech.java.junit.dataprovider.internal.DataConverter; import com.tngtech.java.junit.dataprovider.internal.TestGenerator; import com.tngtech.java.junit.dataprovider.internal.TestValidator; +import java.util.Optional; +import org.jboss.weld.environment.se.Weld; +import org.jboss.weld.environment.se.WeldContainer; import org.junit.Ignore; import org.junit.rules.RunRules; import org.junit.runner.Description; +import org.junit.runner.RunWith; import org.junit.runner.notification.RunNotifier; import org.junit.runners.model.FrameworkMethod; import org.junit.runners.model.InitializationError; - import java.util.List; public class CustomDataProviderRunner extends DataProviderRunner { + // We assume that any test annotated with any of the following runners is meant to be run with Weld + static final List> weldRunners = List.of(JUnit4WeldRunner.class, DataProviderWeldRunner.class); + + /** + * Check if the given class is annotated with any of the Weld runners + * @param clazz the class to check + * @return true if the class is annotated with any of the Weld runners + */ + static boolean isWeldRunnerPresent(Class clazz) { + return Optional.ofNullable(clazz.getAnnotation(RunWith.class)) + .map(RunWith::value) + .map(runnerClass -> weldRunners.stream() + .anyMatch(weldRunner -> weldRunner.equals(runnerClass))) + .orElse(false); + } + + private static final Weld WELD; + private static final WeldContainer CONTAINER; + + static { + WELD = new Weld("CustomDataProviderRunner"); + CONTAINER = WELD.initialize(); + } + + private final boolean instantiateWithWeld; + public CustomDataProviderRunner(Class clazz) throws InitializationError { super(clazz); + instantiateWithWeld = isWeldRunnerPresent(clazz); } @Override @@ -61,4 +93,13 @@ public Object invokeExplosively(Object target, Object... params) throws Throwabl testValidator = new TestValidator(dataConverter); } + @Override + protected Object createTest() throws Exception { + if (instantiateWithWeld) { + final Class javaClass = getTestClass().getJavaClass(); + Logger.debug(this, String.format("Instantiating [%s] with Weld", javaClass)); + return CONTAINER.instance().select(javaClass).get(); + } + return super.createTest(); + } } diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/job/JobQueueHelperIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/job/JobQueueHelperIntegrationTest.java index 566363fc2ab3..144f22a8822b 100644 --- a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/job/JobQueueHelperIntegrationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/job/JobQueueHelperIntegrationTest.java @@ -5,7 +5,6 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import com.dotcms.TestBaseJunit5WeldInitiator; import com.dotcms.jobs.business.job.Job; import com.dotcms.jobs.business.processor.JobProcessor; import com.dotmarketing.exception.DoesNotExistException; @@ -22,6 +21,7 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; import org.glassfish.jersey.media.multipart.FormDataContentDisposition; +import org.jboss.weld.junit5.EnableWeld; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -30,24 +30,77 @@ * Helper add functionality to consume JobQueueManagerAPI * Here we test those functionalities, methods that simply call the JobQueueManagerAPI are not tested */ -public class JobQueueHelperIntegrationTest extends TestBaseJunit5WeldInitiator { +@EnableWeld +public class JobQueueHelperIntegrationTest extends com.dotcms.Junit5WeldBaseTest { @Inject JobQueueHelper jobQueueHelper; + /** + * Test with no parameters in the JobParams creating a job + * Given scenario: create a job with no parameters and valid queue name + * Expected result: the job is created + * + * @throws DotDataException if there's an error creating the job + */ @Test - void testEmptyParams(){ - assertThrows(IllegalArgumentException.class, () -> { - jobQueueHelper.createJob("any", new JobParams(), mock(HttpServletRequest.class)); - }); + void testEmptyParams() throws DotDataException, JsonProcessingException { + + jobQueueHelper.registerProcessor("demoQueue", DemoJobProcessor.class); + + final var jobParams = new JobParams(); + final var user = mock(User.class); + final var request = mock(HttpServletRequest.class); + + when(user.getUserId()).thenReturn("dotcms.org.1"); + + final String jobId = jobQueueHelper.createJob( + "demoQueue", jobParams, user, request + ); + + Assertions.assertNotNull(jobId); + final Job job = jobQueueHelper.getJob(jobId); + Assertions.assertNotNull(job); + Assertions.assertEquals(jobId, job.id()); + } + + /** + * Test with null parameters creating a job + * Given scenario: create a job with null parameters and valid queue name + * Expected result: the job is created + * + * @throws DotDataException if there's an error creating the job + */ + @Test + void testCreateJobWithNoParameters() throws DotDataException { + + jobQueueHelper.registerProcessor("demoQueue", DemoJobProcessor.class); + + final var user = mock(User.class); + when(user.getUserId()).thenReturn("dotcms.org.1"); + + final String jobId = jobQueueHelper.createJob( + "demoQueue", (Map) null, user, mock(HttpServletRequest.class) + ); + + Assertions.assertNotNull(jobId); + final Job job = jobQueueHelper.getJob(jobId); + Assertions.assertNotNull(job); + Assertions.assertEquals(jobId, job.id()); } @Test void testWithValidParamsButInvalidQueueName(){ final JobParams jobParams = new JobParams(); jobParams.setJsonParams("{}"); + + final var user = mock(User.class); + when(user.getUserId()).thenReturn("dotcms.org.1"); + assertThrows(DoesNotExistException.class, () -> { - jobQueueHelper.createJob("nonExisting", jobParams, mock(HttpServletRequest.class)); + jobQueueHelper.createJob( + "nonExisting", jobParams, user, mock(HttpServletRequest.class) + ); }); } @@ -82,14 +135,18 @@ void testWithValidParamsAndQueueName() throws DotDataException, JsonProcessingEx final JobParams jobParams = new JobParams(); jobParams.setJsonParams("{}"); - final String jobId = jobQueueHelper.createJob("demoQueue", jobParams, - mock(HttpServletRequest.class)); + final var user = mock(User.class); + when(user.getUserId()).thenReturn("dotcms.org.1"); + + final String jobId = jobQueueHelper.createJob( + "demoQueue", jobParams, user, mock(HttpServletRequest.class) + ); Assertions.assertNotNull(jobId); final Job job = jobQueueHelper.getJob(jobId); Assertions.assertNotNull(job); Assertions.assertEquals(jobId, job.id()); - Assertions.assertTrue(jobQueueHelper.getQueueNames().contains("demoQueue".toLowerCase())); + Assertions.assertTrue(jobQueueHelper.getQueueNames().contains("demoQueue")); } /** @@ -103,8 +160,13 @@ void testIsWatchable() throws DotDataException, JsonProcessingException { jobQueueHelper.registerProcessor("testQueue", DemoJobProcessor.class); final JobParams jobParams = new JobParams(); jobParams.setJsonParams("{}"); - final String jobId = jobQueueHelper.createJob("testQueue", jobParams, - mock(HttpServletRequest.class)); + + final var user = mock(User.class); + when(user.getUserId()).thenReturn("dotcms.org.1"); + + final String jobId = jobQueueHelper.createJob( + "testQueue", jobParams, user, mock(HttpServletRequest.class) + ); Assertions.assertNotNull(jobId); final Job job = jobQueueHelper.getJob(jobId); assertFalse(jobQueueHelper.isNotWatchable(job)); @@ -122,8 +184,13 @@ void testGetStatusInfo() throws DotDataException, JsonProcessingException { jobQueueHelper.registerProcessor("testQueue", DemoJobProcessor.class); final JobParams jobParams = new JobParams(); jobParams.setJsonParams("{}"); - final String jobId = jobQueueHelper.createJob("testQueue", jobParams, - mock(HttpServletRequest.class)); + + final var user = mock(User.class); + when(user.getUserId()).thenReturn("dotcms.org.1"); + + final String jobId = jobQueueHelper.createJob( + "testQueue", jobParams, user, mock(HttpServletRequest.class) + ); Assertions.assertNotNull(jobId); final Job job = jobQueueHelper.getJob(jobId); final Map info = jobQueueHelper.getJobStatusInfo(job); @@ -137,7 +204,7 @@ void testGetStatusInfo() throws DotDataException, JsonProcessingException { * Given scenario: call cancel Job with an invalid job id * Expected result: we should get a DoesNotExistException */ - @Test + @Test void testCancelNonExistingJob(){ assertThrows(DoesNotExistException.class, () -> { jobQueueHelper.cancelJob("nonExisting" ); diff --git a/dotcms-integration/src/test/java/com/dotcms/util/IntegrationTestInitService.java b/dotcms-integration/src/test/java/com/dotcms/util/IntegrationTestInitService.java index 9711a2000a10..5eab72b64c47 100644 --- a/dotcms-integration/src/test/java/com/dotcms/util/IntegrationTestInitService.java +++ b/dotcms-integration/src/test/java/com/dotcms/util/IntegrationTestInitService.java @@ -2,22 +2,9 @@ import com.dotcms.business.bytebuddy.ByteBuddyFactory; import com.dotcms.config.DotInitializationService; -import com.dotcms.jobs.business.api.JobProcessorFactory; -import com.dotcms.jobs.business.api.JobProcessorScanner; -import com.dotcms.jobs.business.api.JobQueueConfig; -import com.dotcms.jobs.business.api.JobQueueConfigProducer; -import com.dotcms.jobs.business.api.JobQueueManagerAPIImpl; -import com.dotcms.jobs.business.api.events.EventProducer; -import com.dotcms.jobs.business.api.events.RealTimeJobMonitor; -import com.dotcms.jobs.business.error.CircuitBreaker; -import com.dotcms.jobs.business.error.RetryStrategy; -import com.dotcms.jobs.business.error.RetryStrategyProducer; -import com.dotcms.jobs.business.queue.JobQueue; -import com.dotcms.jobs.business.queue.JobQueueProducer; import com.dotcms.repackage.org.apache.struts.Globals; import com.dotcms.repackage.org.apache.struts.config.ModuleConfig; import com.dotcms.repackage.org.apache.struts.config.ModuleConfigFactory; -import com.dotcms.rest.api.v1.job.JobQueueHelper; import com.dotcms.test.TestUtil; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.CacheLocator; @@ -29,9 +16,6 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import org.awaitility.Awaitility; -import org.jboss.weld.bootstrap.api.helpers.RegistrySingletonProvider; -import org.jboss.weld.environment.se.Weld; -import org.jboss.weld.environment.se.WeldContainer; import org.mockito.Mockito; /** @@ -44,8 +28,6 @@ public class IntegrationTestInitService { private static final AtomicBoolean initCompleted = new AtomicBoolean(false); - private static WeldContainer weld; - static { SystemProperties.getProperties(); } @@ -62,23 +44,6 @@ public void init() throws Exception { try { if (initCompleted.compareAndSet(false, true)) { - weld = new Weld().containerId(RegistrySingletonProvider.STATIC_INSTANCE) - .beanClasses( - JobQueueManagerAPIImpl.class, - JobQueueConfig.class, - JobQueue.class, - RetryStrategy.class, - CircuitBreaker.class, - JobQueueProducer.class, - JobQueueConfigProducer.class, - RetryStrategyProducer.class, - RealTimeJobMonitor.class, - JobProcessorFactory.class, - EventProducer.class, - JobProcessorScanner.class, - JobQueueHelper.class) - .initialize(); - System.setProperty(TestUtil.DOTCMS_INTEGRATION_TEST, TestUtil.DOTCMS_INTEGRATION_TEST); Awaitility.setDefaultPollInterval(10, TimeUnit.MILLISECONDS); @@ -105,6 +70,7 @@ public void init() throws Exception { DotInitializationService.getInstance().initialize(); APILocator.getDotAIAPI().getEmbeddingsAPI().initEmbeddingsTable(); + Logger.info(this, "Integration Test Init Service initialized"); } } catch (Exception e) { Logger.error(this, "Error initializing Integration Test Init Service", e); diff --git a/dotcms-integration/src/test/java/com/dotmarketing/osgi/GenericBundleActivatorTest.java b/dotcms-integration/src/test/java/com/dotmarketing/osgi/GenericBundleActivatorIntegrationTest.java similarity index 98% rename from dotcms-integration/src/test/java/com/dotmarketing/osgi/GenericBundleActivatorTest.java rename to dotcms-integration/src/test/java/com/dotmarketing/osgi/GenericBundleActivatorIntegrationTest.java index 2a8af5a199c3..711d2c4a2cc7 100644 --- a/dotcms-integration/src/test/java/com/dotmarketing/osgi/GenericBundleActivatorTest.java +++ b/dotcms-integration/src/test/java/com/dotmarketing/osgi/GenericBundleActivatorIntegrationTest.java @@ -5,7 +5,6 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -import com.dotcms.LicenseTestUtil; import com.dotcms.util.IntegrationTestInitService; import java.lang.reflect.Method; import java.net.URL; @@ -23,7 +22,7 @@ import org.osgi.framework.BundleContext; import org.osgi.framework.launch.Framework; -public class GenericBundleActivatorTest { +public class GenericBundleActivatorIntegrationTest { /** * Sets up OSGI and makes sure the framework has started diff --git a/dotcms-integration/src/test/java/com/dotmarketing/portlets/contentlet/business/ContentletAPITest.java b/dotcms-integration/src/test/java/com/dotmarketing/portlets/contentlet/business/ContentletAPITest.java index 73bcc22e18a4..386420475414 100644 --- a/dotcms-integration/src/test/java/com/dotmarketing/portlets/contentlet/business/ContentletAPITest.java +++ b/dotcms-integration/src/test/java/com/dotmarketing/portlets/contentlet/business/ContentletAPITest.java @@ -207,7 +207,6 @@ public class ContentletAPITest extends ContentletBaseTest { @Test public void testDotAsset_Checkin() throws DotDataException, DotSecurityException, IOException { - // 1) creates a dotasset for test final String variable = "testDotAsset" + System.currentTimeMillis(); final ContentTypeAPI contentTypeAPI = APILocator.getContentTypeAPI(APILocator.systemUser()); diff --git a/dotcms-integration/src/test/java/com/dotmarketing/portlets/contentlet/transform/ContentletTransformerTest.java b/dotcms-integration/src/test/java/com/dotmarketing/portlets/contentlet/transform/ContentletTransformerTest.java index c0f7fd56885c..19129d16b479 100644 --- a/dotcms-integration/src/test/java/com/dotmarketing/portlets/contentlet/transform/ContentletTransformerTest.java +++ b/dotcms-integration/src/test/java/com/dotmarketing/portlets/contentlet/transform/ContentletTransformerTest.java @@ -239,6 +239,7 @@ public void Test_Hydrate_Contentlet_WithUrl() throws DotDataException { assertFalse(newContentlet.getMap().containsKey(Contentlet.NULL_PROPERTIES)); assertEquals(newContentlet.getMap().get(ContentletForm.IDENTIFIER_KEY), identifier); assertEquals(newContentlet.getMap().get(HTMLPageAssetAPI.URL_FIELD), urlExpected); + assertTrue(newContentlet.getMap().containsKey(DefaultTransformStrategy.SHORTY_ID)); } @Test @@ -313,6 +314,7 @@ public String getUrl(Contentlet contentlet) { assertTrue(newContentlet.getMap().containsKey(HTMLPageAssetAPI.URL_FIELD)); assertEquals(urlExpected, newContentlet.getMap().get(HTMLPageAssetAPI.URL_FIELD)); assertFalse(newContentlet.getMap().containsKey(Contentlet.NULL_PROPERTIES)); + assertTrue(newContentlet.getMap().containsKey(DefaultTransformStrategy.SHORTY_ID)); } diff --git a/dotcms-integration/src/test/java/com/dotmarketing/portlets/fileassets/business/FileAssetAPITest.java b/dotcms-integration/src/test/java/com/dotmarketing/portlets/fileassets/business/FileAssetAPITest.java index a79cb5d0f2df..d108f719af51 100644 --- a/dotcms-integration/src/test/java/com/dotmarketing/portlets/fileassets/business/FileAssetAPITest.java +++ b/dotcms-integration/src/test/java/com/dotmarketing/portlets/fileassets/business/FileAssetAPITest.java @@ -5,16 +5,28 @@ import com.dotcms.datagen.FileAssetDataGen; import com.dotcms.datagen.FolderDataGen; import com.dotcms.datagen.SiteDataGen; +import com.dotcms.datagen.TestDataUtils; +import com.dotcms.datagen.TestDataUtils.TestFile; +import com.dotcms.rendering.velocity.viewtools.content.FileAssetMap; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; import com.dotcms.util.IntegrationTestInitService; import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.CacheLocator; +import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotRuntimeException; +import com.dotmarketing.exception.DotSecurityException; import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.portlets.contentlet.model.IndexPolicy; import com.dotmarketing.portlets.folders.model.Folder; +import com.dotmarketing.util.json.JSONObject; +import com.fasterxml.jackson.databind.ObjectMapper; import com.liferay.portal.model.User; import com.liferay.util.FileUtil; +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Test; @@ -35,6 +47,36 @@ public static void prepare() throws Exception { IntegrationTestInitService.getInstance().init(); } + /** + * This tests that a file asset wrapped in FileAssetMap can be serialized to a JSON string see 30464 + * For security reasons I'm also removing the file from the rendered contentlet to make sure it is not serialized giving away the file path. + * Given scenario: A file asset is created and persisted then wrapped in a FileAssetMap + * Expected result: The FileAssetMap can be serialized to a JSON string + * @throws Exception + */ + @Test + public void Test_FileAssetMap_Can_Be_Serialized() + throws Exception { + final File file = TestDataUtils.nextBinaryFile(TestFile.JPG); + final Folder parentFolder = new FolderDataGen().nextPersisted(); + final FileAssetDataGen fileAssetDataGen = new FileAssetDataGen(parentFolder, file); + final Contentlet fileAssetContentlet = fileAssetDataGen.nextPersisted(); + final FileAssetMap fileAssetMap = FileAssetMap.of(fileAssetContentlet); + // First test using a jackson mapper + final ObjectMapper defaultMapper = DotObjectMapperProvider.createDefaultMapper(); + String asString = defaultMapper.writeValueAsString(fileAssetMap); + Assert.assertNotNull(asString); + Assert.assertTrue(asString.startsWith("{")); + Assert.assertTrue(asString.endsWith("}")); + //Test no file attribute is present + Assert.assertFalse(asString.contains("\"file\":")); + // Now test the old-fashioned way + asString = new JSONObject(fileAssetMap).toString(); + Assert.assertTrue(asString.startsWith("{")); + Assert.assertTrue(asString.endsWith("}")); + //Test no file attribute is present + Assert.assertFalse(asString.contains("\"file\":")); + } @Test public void Test_Modify_Identifier_File_Name_Then_Recover_File_Then_Expect_Mismatch() @@ -86,9 +128,9 @@ public void Test_Modify_Identifier_File_Name_Then_Recover_File_Then_Expect_Misma } - @Test + @Test public void Test_Rename_File_Asset_Then_Recover_File_Then_Expect_Match() - throws Exception { + throws Exception { final User user = APILocator.systemUser(); final Folder parentFolder = new FolderDataGen().nextPersisted(); diff --git a/dotcms-integration/src/test/resources/META-INF/beans.xml b/dotcms-integration/src/test/resources/META-INF/beans.xml index 1675ad7ab74c..12fe991ddcca 100644 --- a/dotcms-integration/src/test/resources/META-INF/beans.xml +++ b/dotcms-integration/src/test/resources/META-INF/beans.xml @@ -1,7 +1,6 @@ - diff --git a/dotcms-postman/src/main/resources/postman/ContentResourceV1.postman_collection.json b/dotcms-postman/src/main/resources/postman/ContentResourceV1.postman_collection.json index 6fc4fac28a45..9737cf41313f 100644 --- a/dotcms-postman/src/main/resources/postman/ContentResourceV1.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/ContentResourceV1.postman_collection.json @@ -1,10 +1,10 @@ { "info": { - "_postman_id": "432048a5-eb89-43a5-9828-e4d69ff200dc", + "_postman_id": "05e460a0-406c-42b4-a8e7-0d61a8c6f794", "name": "ContentResourceV1", "description": "This folder contains a comprehensive set of API requests related to the `ContentResourceV1` API endpoints. These requests cover various operations such as creating, retrieving and updating content. The folder is organized to help developers and testers efficiently validate and interact with the content resource endpoints in the system.\n\n#### Objectives:\n\n1. **Create Content**:\n \n - Test the creation of new content items with valid and invalid data.\n \n - Ensure that the response includes all necessary details for the created content.\n \n2. **Retrieve Content**:\n \n - Validate the retrieval of content items by ID.\n \n - Ensure the response contains accurate and complete content details.\n \n3. **Update Content**:\n \n - Test updating existing content items with valid and invalid data.\n \n - Verify that the response reflects the updated content accurately.\n \n - Ensure that only authorized users can update content.\n \n4. **Error Handling**:\n \n - Verify that the API returns appropriate error messages for invalid requests.\n \n - Ensure the correct HTTP status codes are used for different error scenarios.\n \n5. **Security**:\n \n - Validate that only authorized users can perform operations on the content.\n \n - Ensure that all security protocols are enforced during API interactions.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "5403727" + "_exporter_id": "781456" }, "item": [ { @@ -2457,6 +2457,334 @@ } ], "description": "This folder contains a comprehensive set of requests designed to handle the creation, relationship management, and updating of content within our application. These requests are essential for ensuring that content operations are performed correctly and efficiently. \n \nThis will include both possitive and negative testing." + }, + { + "name": "References", + "item": [ + { + "name": "CreatePage", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const pagePost = Math.floor(Math.random()*100+1)", + "pm.globals.set(\"pageRefPost\", pagePost)" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"HTTP Status code must be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Test Page created successfully\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors.length).to.eql(0);", + " pm.collectionVariables.set(\"pageIdentifier\", jsonData.entity.identifier);", + " pm.collectionVariables.set(\"host_id\", jsonData.entity.host);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{jwt}}", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n\t\n\t\"contentlet\": {\n\t\t\"contentType\":\"htmlpageasset\",\n \"title\":\"testPageCopy{{pageRefPost}}\",\n \"url\":\"testPageCopy{{pageRefPost}}\",\n \"hostFolder\":\"default\",\n \"template\":\"SYSTEM_TEMPLATE\",\n \"friendlyName\":\"testPageCopy{{pageRefPost}}\",\n \"cachettl\":0\n\t\t\n\t}\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH?indexPolicy=WAIT_FOR", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "workflow", + "actions", + "default", + "fire", + "PUBLISH" + ], + "query": [ + { + "key": "indexPolicy", + "value": "WAIT_FOR" + } + ] + } + }, + "response": [] + }, + { + "name": "CreateContentRich", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"HTTP Status code must be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Test Page created successfully\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors.length).to.eql(0);", + " pm.collectionVariables.set(\"contentIdentifier\", jsonData.entity.identifier);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{jwt}}", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n\t\n\t\"contentlet\": {\n\t\t\"contentType\":\"webPageContent\",\n \"title\":\"test\",\n\t\t\"body\":\"Test body\",\n \"contentHost\":\"default\"\n\t}\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH?indexPolicy=WAIT_FOR", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "workflow", + "actions", + "default", + "fire", + "PUBLISH" + ], + "query": [ + { + "key": "indexPolicy", + "value": "WAIT_FOR" + } + ] + } + }, + "response": [] + }, + { + "name": "AddContentToPage", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"HTTP Status code must be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{jwt}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "[\n {\n \"identifier\": \"SYSTEM_CONTAINER\",\n \"uuid\": \"1\",\n \"contentletsId\": [\n \"{{contentIdentifier}}\"\n ]\n }\n]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/page/{{pageIdentifier}}/content", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "page", + "{{pageIdentifier}}", + "content" + ] + } + }, + "response": [] + }, + { + "name": "CheckCount", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"HTTP Status code must be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"count successfully\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.entity.count).to.gte(1);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{jwt}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/api/v1/content/{{contentIdentifier}}/references/count", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "v1", + "content", + "{{contentIdentifier}}", + "references", + "count" + ] + } + }, + "response": [] + }, + { + "name": "CheckReferences", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"HTTP Status code must be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"References successfully\", function () {", + " var jsonData = pm.response.json();", + " var pageIdCreated = pm.collectionVariables.get(\"pageIdentifier\");", + " console.log(\"pageIdCreated\", pageIdCreated);", + " console.log(\"pageIdCreated\", pageIdCreated, jsonData.entity[0].page.identifier);", + " pm.expect(jsonData.entity[0].page.identifier).to.eql(pageIdCreated);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{jwt}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/api/v1/content/{{contentIdentifier}}/references", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "v1", + "content", + "{{contentIdentifier}}", + "references" + ] + } + }, + "response": [] + } + ] } ], "auth": { @@ -2530,5 +2858,19 @@ ] } } + ], + "variable": [ + { + "key": "pageIdentifier", + "value": "" + }, + { + "key": "host_id", + "value": "" + }, + { + "key": "contentIdentifier", + "value": "" + } ] } \ No newline at end of file diff --git a/dotcms-postman/src/main/resources/postman/JobQueueResourceAPITests.postman_collection.json b/dotcms-postman/src/main/resources/postman/JobQueueResourceAPITests.postman_collection.json index f7aa1934dadc..cbc8bc3971d6 100644 --- a/dotcms-postman/src/main/resources/postman/JobQueueResourceAPITests.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/JobQueueResourceAPITests.postman_collection.json @@ -1,11 +1,10 @@ { "info": { - "_postman_id": "a12c5acf-e63e-4357-9642-07ca2795b509", + "_postman_id": "cc2de2d8-aecf-4063-a97c-089965ba573d", "name": "JobQueueResource API Tests", "description": "Postman collection for testing the JobQueueResource API endpoints.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "10041132", - "_collection_link": "https://speeding-firefly-555540.postman.co/workspace/blank~a8ffdb2b-2b56-46fa-ae3e-f4b3b0f8204a/collection/10041132-a12c5acf-e63e-4357-9642-07ca2795b509?action=share&source=collection_link&creator=10041132" + "_exporter_id": "31066048" }, "item": [ { @@ -79,7 +78,7 @@ "});", "", "pm.test(\"Response has a demo queue in it\", function () {", - " pm.expect(jsonData.entity).to.include.members(['demo', 'fail']);", + " pm.expect(jsonData.entity).to.include.members(['demo', 'failSuccess']);", "});" ], "type": "text/javascript", @@ -107,63 +106,7 @@ "response": [] }, { - "name": "Create Job No Params Expect Bad Request", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code is 400\", function () {", - " pm.response.to.have.status(400);", - "});", - "" - ], - "type": "text/javascript", - "packages": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "multipart/form-data" - } - ], - "body": { - "mode": "formdata", - "formdata": [ - { - "key": "file", - "type": "file", - "src": [] - }, - { - "key": "params", - "value": "", - "type": "text" - } - ] - }, - "url": { - "raw": "{{baseUrl}}/api/v1/jobs/{{queueName}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "v1", - "jobs", - "{{queueName}}" - ] - }, - "description": "Creates a new job in the specified queue." - }, - "response": [] - }, - { - "name": "Create Job Expect Success", + "name": "Create Multipart Job Expect Success", "event": [ { "listen": "test", @@ -176,9 +119,7 @@ "var jsonData = pm.response.json();", "pm.expect(jsonData.entity).to.be.a('String');", "// Save jobId to environment variable", - "pm.collectionVariables.set(\"jobId\", jsonData.entity);", - "let jId = pm.collectionVariables.get(\"jobId\");", - "console.log(jId);" + "pm.collectionVariables.set(\"jobId\", jsonData.entity);" ], "type": "text/javascript", "packages": {} @@ -220,7 +161,7 @@ "{{queueName}}" ] }, - "description": "Creates a new job in the specified queue." + "description": "Creates a new job with a multipart content type in the specified queue." }, "response": [] }, @@ -376,123 +317,7 @@ "response": [] }, { - "name": "Cancel Job", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code is 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "// Check if cancellation message is returned", - "var jsonData = pm.response.json();", - "pm.test(\"Job cancelled successfully\", function () {", - " pm.expect(jsonData.entity).to.equal('Job cancelled successfully');", - "});", - "", - "var jobId = pm.collectionVariables.get(\"jobId\");", - "console.log(\" At the time this request was sent \" + jobId);", - "pm.collectionVariables.set(\"cancelledJobId\",jobId);" - ], - "type": "text/javascript", - "packages": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [ - "" - ], - "type": "text/javascript", - "packages": {} - } - } - ], - "request": { - "method": "POST", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/v1/jobs/{{jobId}}/cancel", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "v1", - "jobs", - "{{jobId}}", - "cancel" - ] - }, - "description": "Cancels a specific job." - }, - "response": [] - }, - { - "name": "Create Second Job Expect Success", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code is 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "var jsonData = pm.response.json();", - "pm.expect(jsonData.entity).to.be.a('String');", - "// Save jobId to environment variable", - "pm.collectionVariables.set(\"jobId\", jsonData.entity);" - ], - "type": "text/javascript", - "packages": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "multipart/form-data" - } - ], - "body": { - "mode": "formdata", - "formdata": [ - { - "key": "file", - "type": "file", - "src": "resources/JobQueue/odyssey.txt" - }, - { - "key": "params", - "value": "{\n \"nLines\":\"1\"\n}", - "type": "text" - } - ] - }, - "url": { - "raw": "{{baseUrl}}/api/v1/jobs/{{queueName}}", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "v1", - "jobs", - "{{queueName}}" - ] - }, - "description": "Creates a new job in the specified queue." - }, - "response": [] - }, - { - "name": "Active Jobs", + "name": "Get all active Jobs", "event": [ { "listen": "test", @@ -520,6 +345,11 @@ " pm.expect(entity).to.have.property(\"jobs\").that.is.an(\"array\").with.lengthOf(entity.total);", "});", "", + "// Check 'jobs' array length", + "pm.test(\"'jobs' is an array with the correct length\", function () {", + " pm.expect(entity).to.have.property(\"jobs\").that.is.an(\"array\").with.lengthOf(1);", + "});", + "", "// Iterate over each job in the 'jobs' array", "entity.jobs.forEach((job, index) => {", " pm.test(`Job ${index + 1}: 'completedAt' is null or a valid date`, function () {", @@ -638,7 +468,7 @@ "method": "GET", "header": [], "url": { - "raw": "{{baseUrl}}/api/v1/jobs/{{queueName}}/active?page={{page}}&pageSize={{pageSize}}", + "raw": "{{baseUrl}}/api/v1/jobs/active?page={{page}}&pageSize={{pageSize}}", "host": [ "{{baseUrl}}" ], @@ -646,7 +476,6 @@ "api", "v1", "jobs", - "{{queueName}}", "active" ], "query": [ @@ -662,98 +491,563 @@ } ] }, - "description": "Lists active jobs for a specific queue with pagination." + "description": "Lists active jobs with pagination." }, "response": [] }, { - "name": "Create Failing Job", + "name": "Active Jobs by queue", "event": [ { "listen": "test", "script": { "exec": [ - "pm.test(\"Status code is 200\", function () {", + "", + "// Store the response in a variable", + "let response = pm.response.json();", + "", + "// Validate that the response status is 200 OK", + "pm.test(\"Response status is 200\", function () {", " pm.response.to.have.status(200);", "});", "", - "var jsonData = pm.response.json();", - "pm.expect(jsonData.entity).to.be.a('String');", - "// Save jobId to environment variable", - "pm.environment.set(\"failingJobId\", jsonData.entity);", - "" - ], - "type": "text/javascript", - "packages": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "multipart/form-data" - } - ], - "body": { - "mode": "formdata", - "formdata": [ - { - "key": "file", - "type": "file", - "src": [], - "disabled": true - }, - { - "key": "params", - "value": "{\n\n}", - "type": "text" - } - ] - }, - "url": { - "raw": "{{baseUrl}}/api/v1/jobs/fail", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "v1", - "jobs", - "fail" - ] - }, - "description": "Creates a new job in the specified queue (Create Failing Job)" - }, - "response": [] - }, - { - "name": "Monitor Non Existing Job", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code is 200\", function () {", - " pm.response.to.have.status(200);", + "// Check if the 'entity' object exists", + "pm.test(\"'entity' object exists\", function () {", + " pm.expect(response).to.have.property(\"entity\");", "});", "", - "pm.test(\"Response contains job-not-found event and 404 data\", function () {", - " const responseText = pm.response.text();", - " pm.expect(responseText).to.include(\"event: job-not-found\");", - " pm.expect(responseText).to.include(\"data: 404\");", + "// Validate the fields within `entity`", + "let entity = response.entity;", + "", + "// Check that 'jobs' is an array and validate its length", + "pm.test(\"'jobs' is an array with the correct length\", function () {", + " pm.expect(entity).to.have.property(\"jobs\").that.is.an(\"array\").with.lengthOf(entity.total);", "});", - "" - ], - "type": "text/javascript", - "packages": {} - } - } - ], - "request": { - "method": "GET", - "header": [ + "", + "// Iterate over each job in the 'jobs' array", + "entity.jobs.forEach((job, index) => {", + " pm.test(`Job ${index + 1}: 'completedAt' is null or a valid date`, function () {", + " pm.expect(job.completedAt).to.satisfy(function(val) {", + " return val === null || new Date(val).toString() !== \"Invalid Date\";", + " });", + " });", + "", + " pm.test(`Job ${index + 1}: 'createdAt' is a valid date string`, function () {", + " pm.expect(job.createdAt).to.be.a(\"string\");", + " pm.expect(new Date(job.createdAt)).to.not.equal(\"Invalid Date\");", + " });", + "", + " pm.test(`Job ${index + 1}: 'executionNode' is a valid UUID`, function () {", + " pm.expect(job.executionNode).to.match(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/);", + " });", + "", + " pm.test(`Job ${index + 1}: 'id' is a valid UUID`, function () {", + " pm.expect(job.id).to.match(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/);", + " });", + "", + " // Validate the `parameters` object", + " let parameters = job.parameters;", + "", + " pm.test(`Job ${index + 1}: 'parameters' contains expected keys with valid values`, function () {", + " pm.expect(parameters).to.have.property(\"nLines\").that.is.a(\"string\");", + " pm.expect(parameters).to.have.property(\"requestFingerPrint\").that.is.a(\"string\");", + " pm.expect(parameters.requestFingerPrint).to.have.lengthOf(44); // Typical length for SHA-256 in Base64", + " pm.expect(parameters).to.have.property(\"tempFileId\").that.is.a(\"string\");", + " });", + "", + " pm.test(`Job ${index + 1}: 'progress' is a number between 0 and 1`, function () {", + " pm.expect(job.progress).to.be.a(\"number\").within(0, 1);", + " });", + "", + " pm.test(`Job ${index + 1}: 'queueName' is a non-empty string`, function () {", + " pm.expect(job.queueName).to.be.a(\"string\").that.is.not.empty;", + " });", + "", + " pm.test(`Job ${index + 1}: 'result' is null or an object`, function () {", + " pm.expect(job.result === null || typeof job.result === \"object\").to.be.true;", + " });", + "", + " pm.test(`Job ${index + 1}: 'retryCount' is a non-negative integer`, function () {", + " pm.expect(job.retryCount).to.be.a(\"number\").that.is.at.least(0);", + " });", + "", + " pm.test(`Job ${index + 1}: 'startedAt' is null or a valid date`, function () {", + " pm.expect(job.startedAt).to.satisfy(function(val) {", + " return val === null || new Date(val).toString() !== \"Invalid Date\";", + " });", + " });", + "", + " pm.test(`Job ${index + 1}: 'state' is a non-empty string`, function () {", + " pm.expect(job.state).to.be.a(\"string\").that.is.not.empty;", + " });", + "", + " pm.test(`Job ${index + 1}: 'updatedAt' is a valid date string`, function () {", + " pm.expect(job.updatedAt).to.be.a(\"string\");", + " pm.expect(new Date(job.updatedAt)).to.not.equal(\"Invalid Date\");", + " });", + "});", + "", + "//Look for the last created job ", + "let jobsArray = entity.jobs;", + "", + "var jobId = pm.collectionVariables.get(\"jobId\");", + "pm.test(\"jobId is present in the response\", function () {", + " var jobFound = jobsArray.some(function(job) {", + " return job.id === jobId;", + " });", + " pm.expect(jobFound).to.be.true;", + "});", + "", + "// Validate pagination fields within `entity`", + "pm.test(\"'page' is a positive integer\", function () {", + " pm.expect(entity.page).to.be.a(\"number\").that.is.at.least(1);", + "});", + "", + "pm.test(\"'pageSize' is a positive integer\", function () {", + " pm.expect(entity.pageSize).to.be.a(\"number\").that.is.at.least(1);", + "});", + "", + "pm.test(\"'total' matches the length of 'jobs' array\", function () {", + " pm.expect(entity.total).to.equal(entity.jobs.length);", + "});", + "", + "// Validate other top-level objects in the response", + "pm.test(\"'errors' is an empty array\", function () {", + " pm.expect(response.errors).to.be.an(\"array\").that.is.empty;", + "});", + "", + "pm.test(\"'i18nMessagesMap' is an empty object\", function () {", + " pm.expect(response.i18nMessagesMap).to.be.an(\"object\").that.is.empty;", + "});", + "", + "pm.test(\"'messages' is an empty array\", function () {", + " pm.expect(response.messages).to.be.an(\"array\").that.is.empty;", + "});", + "", + "pm.test(\"'pagination' is null\", function () {", + " pm.expect(response.pagination).to.be.null;", + "});", + "", + "pm.test(\"'permissions' is an empty array\", function () {", + " pm.expect(response.permissions).to.be.an(\"array\").that.is.empty;", + "});", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/v1/jobs/{{queueName}}/active?page={{page}}&pageSize={{pageSize}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "jobs", + "{{queueName}}", + "active" + ], + "query": [ + { + "key": "page", + "value": "{{page}}", + "description": "Page number" + }, + { + "key": "pageSize", + "value": "{{pageSize}}", + "description": "Number of items per page" + } + ] + }, + "description": "Lists active jobs for a specific queue with pagination." + }, + "response": [] + }, + { + "name": "Waiting Job to start execution", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const maxTimeout = 30000; // 10 seconds", + "const maxRetries = 10; // Maximum number of retry attempts", + "const startTime = parseInt(pm.environment.get(\"startTime\"));", + "const retryCount = parseInt(pm.environment.get(\"retryCount\"));", + "const elapsedTime = Date.now() - startTime;", + "", + "console.log(`Attempt ${retryCount + 1}, Elapsed time: ${elapsedTime}ms`);", + "", + "var response = pm.response.json();", + "console.log(\"Current job state:\", response.entity.state);", + " ", + "// Check if job status is not \"PENDING\"", + "if (response.entity.state !== \"PENDING\") {", + "", + " console.log(`Job transitioned to ${response.entity.state}`);", + " pm.test(`Job transitioned out of PENDING state to ${response.entity.state}`, function() {", + " pm.expect(response.entity.state).to.not.equal(\"PENDING\");", + " });", + "", + " // Clear environment variables once done", + " pm.environment.unset(\"startTime\");", + " pm.environment.unset(\"retryCount\");", + "} else if (elapsedTime < maxTimeout && retryCount < maxRetries) {", + " // Increment retry count", + " pm.environment.set(\"retryCount\", retryCount + 1);", + " ", + " setTimeout(function(){", + " console.log(\"Sleeping for 3 seconds before next request.\");", + " }, 3000);", + " postman.setNextRequest(\"Waiting Job to start execution\");", + " console.log(`Job still in PENDING state, retrying... (${maxTimeout - elapsedTime}ms remaining)`);", + "} else {", + " // If we exceed the max timeout or max retries, fail the test", + " const timeoutReason = elapsedTime >= maxTimeout ? \"timeout\" : \"max retries\";", + " pm.environment.unset(\"startTime\");", + " pm.environment.unset(\"retryCount\");", + " pm.test(`Job state check failed due to ${timeoutReason}`, function () {", + " pm.expect.fail(`${timeoutReason} reached after ${elapsedTime}ms. Job still in PENDING state after ${retryCount} attempts`);", + " });", + "}", + "", + "// Add response validation", + "pm.test(\"Response is successful\", function () {", + " pm.response.to.be.success;", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response has the correct structure\", function () {", + " const response = pm.response.json();", + " pm.expect(response).to.have.property('entity');", + " pm.expect(response.entity).to.have.property('state');", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "if (!pm.environment.get(\"startTime\")) {", + " pm.environment.set(\"startTime\", Date.now());", + "}", + "", + "if (!pm.environment.get(\"retryCount\")) {", + " pm.environment.set(\"retryCount\", 0);", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/v1/jobs/{{jobId}}/status", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "jobs", + "{{jobId}}", + "status" + ] + }, + "description": "Retrieves the status of a specific job." + }, + "response": [] + }, + { + "name": "Cancel Job", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "// Check if cancellation message is returned", + "var jsonData = pm.response.json();", + "pm.test(\"Job cancelled successfully\", function () {", + " pm.expect(jsonData.entity).to.include('Cancellation request successfully sent to job');", + "});", + "", + "var jobId = pm.collectionVariables.get(\"jobId\");", + "console.log(\" At the time this request was sent \" + jobId);", + "pm.collectionVariables.set(\"cancelledJobId\",jobId);" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/v1/jobs/{{jobId}}/cancel", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "jobs", + "{{jobId}}", + "cancel" + ] + }, + "description": "Cancels a specific job." + }, + "response": [] + }, + { + "name": "Create Failing Job", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var jsonData = pm.response.json();", + "pm.expect(jsonData.entity).to.be.a('String');", + "// Save jobId to environment variable", + "pm.environment.set(\"failingJobId\", jsonData.entity);", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"fail\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/v1/jobs/failSuccess", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "jobs", + "failSuccess" + ] + }, + "description": "Creates a new job in the specified queue (Create Failing Job)" + }, + "response": [] + }, + { + "name": "Create Success Job", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var jsonData = pm.response.json();", + "pm.expect(jsonData.entity).to.be.a('String');", + "// Save jobId to environment variable", + "pm.environment.set(\"successJobId\", jsonData.entity);", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/api/v1/jobs/failSuccess", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "jobs", + "failSuccess" + ] + }, + "description": "Creates a new job in the specified queue (Create a job that will finish sucessfully)" + }, + "response": [] + }, + { + "name": "Waiting Job to complete", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "const maxTimeout = 30000; // 10 seconds", + "const maxRetries = 10; // Maximum number of retry attempts", + "const startTime = parseInt(pm.environment.get(\"startTime\"));", + "const retryCount = parseInt(pm.environment.get(\"retryCount\"));", + "const elapsedTime = Date.now() - startTime;", + "", + "console.log(`Attempt ${retryCount + 1}, Elapsed time: ${elapsedTime}ms`);", + "", + "var response = pm.response.json();", + "console.log(\"Current job state:\", response.entity.state);", + " ", + "// Check if job status is \"COMPLETED\"", + "if (response.entity.state === \"COMPLETED\") {", + " // Clear environment variables once done", + " pm.environment.unset(\"startTime\");", + " pm.environment.unset(\"retryCount\");", + "} else if (elapsedTime < maxTimeout && retryCount < maxRetries) {", + " // Increment retry count", + " pm.environment.set(\"retryCount\", retryCount + 1);", + " ", + " setTimeout(function(){", + " console.log(\"Sleeping for 3 seconds before next request.\");", + " }, 3000);", + " postman.setNextRequest(\"Waiting Job to complete\");", + " console.log(`Job still processing, retrying... (${maxTimeout - elapsedTime}ms remaining)`);", + "} else {", + " // If we exceed the max timeout or max retries, fail the test", + " const timeoutReason = elapsedTime >= maxTimeout ? \"timeout\" : \"max retries\";", + " pm.environment.unset(\"startTime\");", + " pm.environment.unset(\"retryCount\");", + " pm.test(`Job state check failed due to ${timeoutReason}`, function () {", + " pm.expect.fail(`${timeoutReason} reached after ${elapsedTime}ms. Job still in processing state after ${retryCount} attempts`);", + " });", + "}", + "", + "// Add response validation", + "pm.test(\"Response is successful\", function () {", + " pm.response.to.be.success;", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response has the correct structure\", function () {", + " const response = pm.response.json();", + " pm.expect(response).to.have.property('entity');", + " pm.expect(response.entity).to.have.property('state');", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "if (!pm.environment.get(\"startTime\")) {", + " pm.environment.set(\"startTime\", Date.now());", + "}", + "", + "if (!pm.environment.get(\"retryCount\")) {", + " pm.environment.set(\"retryCount\", 0);", + "}" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/v1/jobs/{{successJobId}}/status", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "jobs", + "{{successJobId}}", + "status" + ] + }, + "description": "Retrieves the status of a specific job." + }, + "response": [] + }, + { + "name": "Monitor Non Existing Job", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response contains job-not-found event and 404 data\", function () {", + " const responseText = pm.response.text();", + " pm.expect(responseText).to.include(\"event: job-not-found\");", + " pm.expect(responseText).to.include(\"data: 404\");", + "});", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [ { "key": "Accept", "value": "text/event-stream" @@ -906,7 +1200,160 @@ "response": [] }, { - "name": "Failed Jobs", + "name": "Get all canceled Jobs", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Get the expected job ID from collection variables", + "const jobId = pm.collectionVariables.get('cancelledJobId');", + "", + "// Parse the response JSON", + "const response = pm.response.json();", + "", + "// Validate that the response status is 200 OK", + "pm.test(\"Response status is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "// Validate that the response contains an \"entity.jobs\" array", + "pm.test(\"Response should contain jobs array\", function () {", + " pm.expect(response.entity).to.have.property(\"jobs\");", + " pm.expect(response.entity.jobs).to.be.an(\"array\");", + "});", + "", + "// Validate that the jobs array contains only one job", + "pm.test(\"Jobs array should contain only one job\", function () {", + " pm.expect(response.entity.jobs.length).to.eql(1);", + "});", + "", + "// Validate that the job ID in the response matches the expected job ID", + "pm.test(\"Job ID should match expected job ID\", function () {", + " pm.expect(response.entity.jobs[0].id).to.eql(jobId);", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/v1/jobs/canceled?page={{page}}&pageSize={{pageSize}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "jobs", + "canceled" + ], + "query": [ + { + "key": "page", + "value": "{{page}}" + }, + { + "key": "pageSize", + "value": "{{pageSize}}" + } + ] + }, + "description": "Lists canceled jobs with pagination." + }, + "response": [] + }, + { + "name": "Get all completed Jobs", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Parse the response JSON", + "const response = pm.response.json();", + "", + "// Validate that the response status is 200 OK", + "pm.test(\"Response status is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "// Validate that the response contains an \"entity.jobs\" array", + "pm.test(\"Response should contain jobs array\", function () {", + " pm.expect(response.entity).to.have.property(\"jobs\");", + " pm.expect(response.entity.jobs).to.be.an(\"array\");", + "});", + "", + "// Validate that the jobs array contains only one job", + "pm.test(\"Jobs array should contain only one job\", function () {", + " pm.expect(response.entity.jobs.length).to.eql(1);", + "});", + "", + "// Validate that the job ID in the response matches the expected job ID", + "pm.test(\"Job ID should match expected job ID\", function () {", + " pm.expect(response.entity.jobs[0].id).to.eql(pm.environment.get('successJobId'));", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/v1/jobs/completed?page={{page}}&pageSize={{pageSize}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "jobs", + "completed" + ], + "query": [ + { + "key": "page", + "value": "{{page}}" + }, + { + "key": "pageSize", + "value": "{{pageSize}}" + } + ] + }, + "description": "Lists completed jobs with pagination." + }, + "response": [] + }, + { + "name": "Get all failed Jobs", "event": [ { "listen": "test", diff --git a/dotcms-postman/src/main/resources/postman/PortletResource.json b/dotcms-postman/src/main/resources/postman/PortletResource.json index 72beb5ac8907..0b2d0092e3ea 100644 --- a/dotcms-postman/src/main/resources/postman/PortletResource.json +++ b/dotcms-postman/src/main/resources/postman/PortletResource.json @@ -406,6 +406,212 @@ } }, "response": [] + }, + { + "name": "Get with language_id query param", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "// Parse the response JSON", + "var jsonData = pm.response.json();", + "", + "// Check if the `entity` property is present", + "pm.test(\"Check entity is present in the response\", function () {", + " pm.expect(jsonData).to.have.property(\"entity\");", + "});", + "", + "// Extract the URL from the entity", + "var entityURL = jsonData.entity;", + "", + "// Check if the entity URL contains \"_content_lang\"", + "pm.test(\"Check _content_lang is present in the entity URL\", function () {", + " pm.expect(entityURL).to.include(\"_content_lang\");", + "});", + "", + "// Manually parse the query parameters from the entity URL", + "var entityQueryString = entityURL.split('?')[1];", + "var entityParams = {};", + "if (entityQueryString) {", + " entityQueryString.split('&').forEach(function(param) {", + " var pair = param.split('=');", + " entityParams[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || '');", + " });", + "}", + "", + "// Validate that the extracted language_id from the entity is \"2\"", + "pm.test(\"Check language_id is '2' from entity URL\", function () {", + " pm.expect(entityParams['_content_lang']).to.equal(\"2\");", + "});", + "", + "// Extract the referrer URL from the entity", + "var referrerParam = entityURL.split(\"&_content_referer=\")[1];", + "", + "// Check if the referrer URL exists", + "pm.test(\"Check referrer URL is present\", function () {", + " pm.expect(referrerParam).to.not.be.undefined;", + "});", + "", + "// Decode the referrer URL and extract parameters", + "if (referrerParam) {", + " referrerParam = decodeURIComponent(referrerParam.split('&')[0]); // Decode and get the actual URL", + "", + " // Check if the referrer URL contains \"_content_lang\"", + " pm.test(\"Check _content_lang is present in the referrer URL\", function () {", + " pm.expect(referrerParam).to.include(\"_content_lang\");", + " });", + "", + " // Manually parse the query parameters from the decoded referrer URL", + " var referrerQueryString = referrerParam.split('?')[1];", + " var referrerParams = {};", + " if (referrerQueryString) {", + " referrerQueryString.split('&').forEach(function(param) {", + " var pair = param.split('=');", + " referrerParams[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || '');", + " });", + " }", + "", + " // Validate that the extracted language_id from the referrer is \"2\"", + " pm.test(\"Check language_id is '2' from referrer URL\", function () {", + " pm.expect(referrerParams['_content_lang']).to.equal(\"2\");", + " });", + "}", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/portlet/_actionurl/webpagecontent?language_id=2", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "portlet", + "_actionurl", + "webpagecontent" + ], + "query": [ + { + "key": "language_id", + "value": "2" + } + ] + } + }, + "response": [] + }, + { + "name": "Get without language_id query param", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "// Parse the response JSON", + "var jsonData = pm.response.json();", + "", + "// Check if the `entity` property is present", + "pm.test(\"Check entity is present in the response\", function () {", + " pm.expect(jsonData).to.have.property(\"entity\");", + "});", + "", + "// Extract the URL from the entity", + "var entityURL = jsonData.entity;", + "", + "// Check if the entity URL contains \"_content_lang\"", + "pm.test(\"Check _content_lang is present in the entity URL\", function () {", + " pm.expect(entityURL).to.include(\"_content_lang\");", + "});", + "", + "// Manually parse the query parameters from the entity URL", + "var entityQueryString = entityURL.split('?')[1];", + "var entityParams = {};", + "if (entityQueryString) {", + " entityQueryString.split('&').forEach(function(param) {", + " var pair = param.split('=');", + " entityParams[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || '');", + " });", + "}", + "", + "// Validate that the extracted language_id from the entity is \"1\"", + "pm.test(\"Check language_id is '1' from entity URL\", function () {", + " pm.expect(entityParams['_content_lang']).to.equal(\"1\");", + "});", + "", + "// Extract the referrer URL from the entity", + "var referrerParam = entityURL.split(\"&_content_referer=\")[1];", + "", + "// Check if the referrer URL exists", + "pm.test(\"Check referrer URL is present\", function () {", + " pm.expect(referrerParam).to.not.be.undefined;", + "});", + "", + "// Decode the referrer URL and extract parameters", + "if (referrerParam) {", + " referrerParam = decodeURIComponent(referrerParam.split('&')[0]); // Decode and get the actual URL", + "", + " // Check if the referrer URL contains \"_content_lang\"", + " pm.test(\"Check _content_lang is present in the referrer URL\", function () {", + " pm.expect(referrerParam).to.include(\"_content_lang\");", + " });", + "", + " // Manually parse the query parameters from the decoded referrer URL", + " var referrerQueryString = referrerParam.split('?')[1];", + " var referrerParams = {};", + " if (referrerQueryString) {", + " referrerQueryString.split('&').forEach(function(param) {", + " var pair = param.split('=');", + " referrerParams[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || '');", + " });", + " }", + "", + " // Validate that the extracted language_id from the referrer is \"1\"", + " pm.test(\"Check language_id is '1' from referrer URL\", function () {", + " pm.expect(referrerParams['_content_lang']).to.equal(\"1\");", + " });", + "}", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/portlet/_actionurl/webpagecontent", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "portlet", + "_actionurl", + "webpagecontent" + ] + } + }, + "response": [] } ], "description": "This suite verifies that the `{{serverURL}}/api/v1/portlet/_actionurl/{{contentTypeVarName}}` works as expected.\n\nIt returns the Action URL used by the legacy Liferay layer to render a Portlet inside dotCMS. It includes several query String parameters such as IDs for the language, Content Type, Struts Actions, and so on." diff --git a/examples/angular/src/app/pages/pages.component.ts b/examples/angular/src/app/pages/pages.component.ts index b35d30b517f9..45fbf51a3dfb 100644 --- a/examples/angular/src/app/pages/pages.component.ts +++ b/examples/angular/src/app/pages/pages.component.ts @@ -1,10 +1,4 @@ -import { - Component, - DestroyRef, - OnInit, - inject, - signal, -} from '@angular/core'; +import { Component, DestroyRef, OnInit, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { NavigationEnd } from '@angular/router'; @@ -38,7 +32,6 @@ type PageRender = { status: 'idle' | 'success' | 'error' | 'loading'; }; - @Component({ selector: 'app-dotcms-page', standalone: true, @@ -72,40 +65,41 @@ export class DotCMSPagesComponent implements OnInit { ngOnInit() { this.#router.events .pipe( - filter((event): event is NavigationEnd => event instanceof NavigationEnd), + filter( + (event): event is NavigationEnd => event instanceof NavigationEnd + ), startWith(null), // Trigger initial load tap(() => this.#setLoading()), switchMap(() => this.#pageService.getPageAndNavigation(this.#route, this.editorCofig)), takeUntilDestroyed(this.#destroyRef) ) - .subscribe( - ({ page, nav }: { - page: DotCMSPageAsset | { error: PageError }; - nav: DotcmsNavigationItem | null; - }) => { - if ('error' in page) { - this.#setError(page.error); - } else { - const { vanityUrl } = page; + .subscribe(({ page = {}, nav, error }) => { + if (error) { + this.#setError(error); + return; + } - if (vanityUrl?.permanentRedirect || vanityUrl?.temporaryRedirect) { - this.#router.navigate([vanityUrl.forwardTo]); - return; - } + const { vanityUrl } = page || {}; - this.#setPageContent(page, nav); - } + if (vanityUrl?.permanentRedirect || vanityUrl?.temporaryRedirect) { + this.#router.navigate([vanityUrl.forwardTo]); + return; } - ); + + this.#setPageContent(page as DotCMSPageAsset, nav); + }); } - #setPageContent(page: DotCMSPageAsset, nav: DotcmsNavigationItem | null) { - this.$context.update((state) => ({ + #setPageContent( + page: DotCMSPageAsset, + nav: DotcmsNavigationItem | null + ) { + this.$context.set({ status: 'success', page, nav, error: null, - })); + }); } #setLoading() { diff --git a/examples/angular/src/app/pages/services/page.service.ts b/examples/angular/src/app/pages/services/page.service.ts index fbf5e724bb04..6efbd088e1aa 100644 --- a/examples/angular/src/app/pages/services/page.service.ts +++ b/examples/angular/src/app/pages/services/page.service.ts @@ -1,14 +1,23 @@ import { inject, Injectable } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { from, Observable, shareReplay } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; +import { forkJoin, from, Observable, of, shareReplay } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; -import { getPageRequestParams } from '@dotcms/client'; +import { getPageRequestParams, isInsideEditor } from '@dotcms/client'; import { DotcmsNavigationItem, DotCMSPageAsset } from '@dotcms/angular'; import { PageError } from '../pages.component'; import { DOTCMS_CLIENT_TOKEN } from '../../app.config'; +export interface PageResponse { + page: DotCMSPageAsset | null; + error?: PageError; +} + +export interface PageAndNavResponse extends PageResponse { + nav: DotcmsNavigationItem | null; +} + @Injectable({ providedIn: 'root', }) @@ -27,20 +36,20 @@ export class PageService { getPageAndNavigation( route: ActivatedRoute, config: any - ): Observable<{ - page: DotCMSPageAsset | { error: PageError }; - nav: DotcmsNavigationItem | null; - }> { + ): Observable { if (!this.navObservable) { this.navObservable = this.fetchNavigation(route); } - return this.fetchPage(route, config).pipe( - switchMap((page) => - this.navObservable.pipe( - map((nav) => ({ page, nav })) - ) - ) + return forkJoin({ + nav: this.navObservable, + pageAsset: this.fetchPage(route, config), + }).pipe( + map(({ nav, pageAsset }) => { + const { page, error } = pageAsset; + + return { nav, page, error }; + }) ); } @@ -65,7 +74,7 @@ export class PageService { private fetchPage( route: ActivatedRoute, config: any - ): Observable { + ): Observable { const queryParams = route.snapshot.queryParamMap; const url = route.snapshot.url.map((segment) => segment.path).join('/'); const path = url || '/'; @@ -75,24 +84,30 @@ export class PageService { params: queryParams, }); - return from( - this.client.page - .get({ ...pageParams, ...config.params }) - .then((response) => { - if (!(response as any).layout) { - return { error: { message: 'You might be using an advanced template, or your dotCMS instance might lack an enterprise license.', status: 'Page without layout' } }; - } - - return response as DotCMSPageAsset - }) - .catch((e) => { - console.error(`Error fetching page: ${e.message}`); - const error: PageError = { - message: e.message, - status: e.status, + return from(this.client.page.get({ ...pageParams, ...config.params })).pipe( + map((page: any) => { + if (!page?.layout) { + return { + page: null, + error: { + message: + 'You might be using an advanced template, or your dotCMS instance might lack an enterprise license.', + status: 'Page without layout', + }, }; - return { error }; - }) + } + + return { page, error: null }; + }), + catchError((error) => { + // If the page is not found and we are inside the editor, return an empty object + // The editor will get the working/unpublished page + if (error.status === 404 && isInsideEditor()) { + return of({ page: {}, error: null } as any); + } + + return of({ page: null, error }); + }) ); } } diff --git a/examples/astro/src/components/Error.astro b/examples/astro/src/components/Error.astro index 67a228aae495..e47dd7bff5c0 100644 --- a/examples/astro/src/components/Error.astro +++ b/examples/astro/src/components/Error.astro @@ -1,7 +1,5 @@ --- const { error } = Astro.props; - -const currentQueryParams = Astro.url.search; --- { @@ -10,32 +8,10 @@ const currentQueryParams = Astro.url.search;

- {error.status === 404 ? ( - <> -

- 404 -

-

- Something's missing. -

-

- Sorry, we can't find that page. You'll find lots to - explore on the home page. -

- -
- Return Home -
-
- - ) : ( - <> -

- {error.status} -

-

{error.message}

- - )} +

+ {error.status} +

+

{error.message}

diff --git a/examples/astro/src/react/components/notFound.tsx b/examples/astro/src/react/components/notFound.tsx new file mode 100644 index 000000000000..70663f34ddff --- /dev/null +++ b/examples/astro/src/react/components/notFound.tsx @@ -0,0 +1,27 @@ +export default function NotFound() { + return ( +
+
+
+
+

+ 404 +

+

+ Something's missing. +

+

+ Sorry, we can't find that page. You'll find lots to + explore on the home page. +

+ +
+ Return Home +
+
+
+
+
+
+ ); +} diff --git a/examples/astro/src/react/hooks/usePageAsset.ts b/examples/astro/src/react/hooks/usePageAsset.ts index 8e37c1d49d56..d0cd3242a32a 100644 --- a/examples/astro/src/react/hooks/usePageAsset.ts +++ b/examples/astro/src/react/hooks/usePageAsset.ts @@ -1,11 +1,20 @@ +import { useEffect, useState } from "react"; +import { + CUSTOMER_ACTIONS, + isInsideEditor, + postMessageToEditor, +} from "@dotcms/client"; import type { DotCMSPageAsset } from "@dotcms/types"; import { client } from "@utils/client"; -import { useEffect, useState } from "react"; export const usePageAsset = (currentPageAsset: DotCMSPageAsset | undefined) => { const [pageAsset, setPageAsset] = useState(); useEffect(() => { + if (!isInsideEditor()) { + return; + } + client.editor.on("changes", (page) => { if (!page) { return; @@ -13,6 +22,13 @@ export const usePageAsset = (currentPageAsset: DotCMSPageAsset | undefined) => { setPageAsset(page as DotCMSPageAsset); }); + // If the page is not found, let the editor know + if (!currentPageAsset) { + postMessageToEditor({ action: CUSTOMER_ACTIONS.CLIENT_READY }); + + return; + } + return () => { client.editor.off("changes"); }; diff --git a/examples/astro/src/react/myPage.tsx b/examples/astro/src/react/myPage.tsx index 0c15be0d3ee6..252cf9f8850b 100644 --- a/examples/astro/src/react/myPage.tsx +++ b/examples/astro/src/react/myPage.tsx @@ -8,6 +8,8 @@ import type { FC } from "react"; import { Navigation } from "./layout/navigation"; import { Footer } from "./layout/footer/footer"; +import NotFound from "./components/notFound"; + import type { DotcmsNavigationItem, DotCMSPageAsset } from "@dotcms/types"; export type MyPageProps = { @@ -18,6 +20,10 @@ export type MyPageProps = { export const MyPage: FC = ({ pageAsset, nav }) => { pageAsset = usePageAsset(pageAsset); + if(!pageAsset) { + return + } + return (
{pageAsset?.layout.header && ( diff --git a/examples/astro/src/utils/client.ts b/examples/astro/src/utils/client.ts index e5dae4b6475c..5d0f3c87d06d 100644 --- a/examples/astro/src/utils/client.ts +++ b/examples/astro/src/utils/client.ts @@ -20,28 +20,50 @@ export type GetPageDataResponse = { export const getPageData = async ( slug: string | undefined, params: URLSearchParams, -): Promise => { - try { - const path = slug || "/"; +) => { + const path = slug || "/"; + const pageParams = getPageRequestParams({ + path, + params, + }); + + const { pageAsset, error: pageError } = await fetchPageData(pageParams); + const { nav, error: navError } = await fetchNavData(pageParams.language_id); - const pageRequestParams = getPageRequestParams({ - path, - params, - }); + return { + nav, + pageAsset, + error: pageError || navError, + }; +}; +const fetchPageData = async (params: any) => { + try { const pageAsset = (await client.page.get({ - ...pageRequestParams, + ...params, depth: 3, })) as DotCMSPageAsset; + return { pageAsset }; + } catch (error: any) { + if (error?.status === 404) { + return { pageAsset: null, error: null }; + } + + return { pageAsset: null, error }; + } +}; + +const fetchNavData = async (languageId = 1) => { + try { const { entity } = (await client.nav.get({ path: "/", depth: 2, - languageId: pageRequestParams.languageId as number, + languageId, })) as { entity: DotcmsNavigationItem }; - return { pageAsset, nav: entity }; + return { nav: entity }; } catch (error) { - return { error }; + return { nav: null, error }; } }; diff --git a/examples/astro/tsconfig.json b/examples/astro/tsconfig.json index acef73989ca5..7effa155f432 100644 --- a/examples/astro/tsconfig.json +++ b/examples/astro/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "react", + "module": "es2020", "baseUrl": "./", "paths": { "@utils/*": ["src/utils/*"], diff --git a/examples/nextjs/src/app/[[...slug]]/page.js b/examples/nextjs/src/app/[[...slug]]/page.js index a62110805d0f..7fa93580b9dc 100644 --- a/examples/nextjs/src/app/[[...slug]]/page.js +++ b/examples/nextjs/src/app/[[...slug]]/page.js @@ -4,6 +4,7 @@ import { ErrorPage } from "@/components/error"; import { handleVanityUrlRedirect } from "@/utils/vanityUrlHandler"; import { client } from "@/utils/dotcmsClient"; import { getPageRequestParams } from "@dotcms/client"; +import { fetchNavData, fetchPageData } from "@/utils/page.utils"; /** * Generate metadata @@ -36,38 +37,31 @@ export async function generateMetadata({ params, searchParams }) { export default async function Home({ searchParams, params }) { const getPageData = async () => { - try { - const path = params?.slug?.join("/") || "/"; - const pageRequestParams = getPageRequestParams({ - path, - params: searchParams, - }); - const pageAsset = await client.page.get({ - ...pageRequestParams, - depth: 3, - }); - const nav = await client.nav.get({ - path: "/", - depth: 2, - languageId: searchParams.language_id, - }); + const path = params?.slug?.join("/") || "/"; + const pageParams = getPageRequestParams({ + path, + params: searchParams, + }); - return { pageAsset, nav }; - } catch (error) { - return { pageAsset: null, nav: null, error }; - } + const { pageAsset, error: pageError } = await fetchPageData(pageParams); + const { nav, error: navError } = await fetchNavData(pageParams.language_id); + + return { + nav, + pageAsset, + error: pageError || navError, + }; }; const { pageAsset, nav, error } = await getPageData(); + // Move this to MyPage if (error) { return ; } - const { vanityUrl } = pageAsset; - - if (vanityUrl) { - handleVanityUrlRedirect(vanityUrl); + if (pageAsset?.vanityUrl) { + handleVanityUrlRedirect(pageAsset?.vanityUrl); } - return ; + return ; } diff --git a/examples/nextjs/src/components/layout/footer/components/blogs.js b/examples/nextjs/src/components/layout/footer/components/blogs.js index c69db89bcf6c..3b4fdf97784b 100644 --- a/examples/nextjs/src/components/layout/footer/components/blogs.js +++ b/examples/nextjs/src/components/layout/footer/components/blogs.js @@ -1,18 +1,18 @@ -import { useEffect, useState } from "react"; -import Contentlets from "@/components/shared/contentlets"; -import { client } from "@/utils/dotcmsClient"; +import { useEffect, useState } from 'react'; +import Contentlets from '@/components/shared/contentlets'; +import { client } from '@/utils/dotcmsClient'; export default function Blogs() { const [blogs, setBlogs] = useState([]); useEffect(() => { client.content - .getCollection("Blog") + .getCollection('Blog') .sortBy([ { - field: "modDate", - order: "desc", - }, + field: 'modDate', + order: 'desc' + } ]) .limit(3) .then((response) => { @@ -25,9 +25,7 @@ export default function Blogs() { return (
-

- Latest Blog Posts -

+

Latest Blog Posts

{!!blogs.length && }
); diff --git a/examples/nextjs/src/components/layout/footer/components/destinations.js b/examples/nextjs/src/components/layout/footer/components/destinations.js index abe53e24c8c6..f1f14ddcc505 100644 --- a/examples/nextjs/src/components/layout/footer/components/destinations.js +++ b/examples/nextjs/src/components/layout/footer/components/destinations.js @@ -1,18 +1,18 @@ -import { useEffect, useState } from "react"; -import Contentlets from "@/components/shared/contentlets"; -import { client } from "@/utils/dotcmsClient"; +import { useEffect, useState } from 'react'; +import Contentlets from '@/components/shared/contentlets'; +import { client } from '@/utils/dotcmsClient'; export default function Destinations() { const [destinations, setDestinations] = useState([]); useEffect(() => { client.content - .getCollection("Destination") + .getCollection('Destination') .sortBy([ { - field: "modDate", - order: "desc", - }, + field: 'modDate', + order: 'desc' + } ]) .limit(3) .then((response) => { @@ -25,12 +25,8 @@ export default function Destinations() { return (
-

- Popular Destinations -

- {!!destinations.length && ( - - )} +

Popular Destinations

+ {!!destinations.length && }
); } diff --git a/examples/nextjs/src/components/my-page.js b/examples/nextjs/src/components/my-page.js index f5b19c71e85a..e9f0d33ce5f8 100644 --- a/examples/nextjs/src/components/my-page.js +++ b/examples/nextjs/src/components/my-page.js @@ -18,7 +18,7 @@ import { CustomNoComponent } from "./content-types/empty"; import { usePageAsset } from "../hooks/usePageAsset"; import BlogWithBlockEditor from "./content-types/blog"; -import { DotCmsClient } from "@dotcms/client"; +import NotFound from "@/app/not-found"; /** * Configure experiment settings below. If you are not using experiments, @@ -62,32 +62,34 @@ export function MyPage({ pageAsset, nav }) { pageAsset = usePageAsset(pageAsset); + if (!pageAsset) { + return ; + } + return (
- {pageAsset.layout.header && ( -
- -
+ {pageAsset?.layout.header && ( +
{!!nav && }
)}
- {pageAsset.layout.footer &&
} + {pageAsset?.layout.footer &&
}
); } diff --git a/examples/nextjs/src/components/shared/contentlet.js b/examples/nextjs/src/components/shared/contentlet.js deleted file mode 100644 index c46ea034ded7..000000000000 --- a/examples/nextjs/src/components/shared/contentlet.js +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; -import { useMemo } from "react"; -import { isInsideEditor } from "@dotcms/client"; - -function Contentlet({ contentlet, children }) { - const insideEditor = useMemo(isInsideEditor, []); - - return insideEditor ? ( -
- {children} -
- ) : ( - children - ); -} - -export default Contentlet; diff --git a/examples/nextjs/src/components/shared/contentlets.js b/examples/nextjs/src/components/shared/contentlets.js index 7582f77e822f..1ea66565e9fa 100644 --- a/examples/nextjs/src/components/shared/contentlets.js +++ b/examples/nextjs/src/components/shared/contentlets.js @@ -1,54 +1,60 @@ -"use client"; -import React from "react"; -import Image from "next/image"; -import Contentlet from "./contentlet"; +'use client'; +import React from 'react'; +import Image from 'next/image'; +import { editContentlet } from '@dotcms/client'; const dateFormatOptions = { - year: "numeric", - month: "long", - day: "numeric", + year: 'numeric', + month: 'long', + day: 'numeric' }; function Contentlets({ contentlets }) { return (
+ ))} ); diff --git a/examples/nextjs/src/hooks/usePageAsset.js b/examples/nextjs/src/hooks/usePageAsset.js index cbda64b4191e..dca69d7e7565 100644 --- a/examples/nextjs/src/hooks/usePageAsset.js +++ b/examples/nextjs/src/hooks/usePageAsset.js @@ -1,17 +1,35 @@ -import { client } from "@/utils/dotcmsClient"; import { useEffect, useState } from "react"; +import { client } from "@/utils/dotcmsClient"; +import { + CUSTOMER_ACTIONS, + isInsideEditor, + postMessageToEditor, +} from "@dotcms/client"; + export const usePageAsset = (currentPageAsset) => { const [pageAsset, setPageAsset] = useState(null); useEffect(() => { + if (!isInsideEditor()) { + return; + } + client.editor.on("changes", (page) => { if (!page) { return; } + setPageAsset(page); }); + // If the page is not found, let the editor know + if (!currentPageAsset) { + postMessageToEditor({ action: CUSTOMER_ACTIONS.CLIENT_READY }); + + return; + } + return () => { client.editor.off("changes"); }; diff --git a/examples/nextjs/src/utils/page.utils.js b/examples/nextjs/src/utils/page.utils.js new file mode 100644 index 000000000000..a8854a2d01f1 --- /dev/null +++ b/examples/nextjs/src/utils/page.utils.js @@ -0,0 +1,32 @@ +import { client } from "./dotcmsClient"; + +export const fetchPageData = async (params) => { + try { + const pageAsset = await client.page.get({ + ...params, + depth: 3, + }); + + return { pageAsset }; + } catch (error) { + if (error?.status === 404) { + return { pageAsset: null, error: null }; + } + + return { pageAsset: null, error }; + } +}; + +export const fetchNavData = async (languageId = 1) => { + try { + const nav = await client.nav.get({ + path: "/", + depth: 2, + languageId, + }); + + return { nav }; + } catch (error) { + return { nav: null, error }; + } +}; diff --git a/justfile b/justfile index 378842ddb929..de5b8380e59a 100644 --- a/justfile +++ b/justfile @@ -39,6 +39,9 @@ build-test: build-quick: ./mvnw -DskipTests install +build-quicker: + ./mvnw -pl :dotcms-core -DskipTests install + # Builds the project for production, skipping tests build-prod: ./mvnw -DskipTests clean install -Pprod @@ -85,7 +88,7 @@ dev-stop: dev-clean-volumes: ./mvnw -pl :dotcms-core -Pdocker-clean-volumes -# Starts the dotCMS application in a Tomcat container on port 8080, running in the foreground +# Starts the dotCMS application in a Tomcat container on port 8087, running in the foreground dev-tomcat-run port="8087": ./mvnw -pl :dotcms-core -Ptomcat-run -Pdebug -Dservlet.port={{ port }}