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 bab367e5d557..1c4ee37ba0ae 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 @@ -21,6 +21,7 @@ import { DotMessageService, DotPropertiesService, DotWorkflowActionsFireService, + DotWorkflowsActionsService, PushPublishService } from '@dotcms/data-access'; import { @@ -208,6 +209,12 @@ describe('DotEmaShellComponent', () => { DotWorkflowActionsFireService, Router, Location, + { + provide: DotWorkflowsActionsService, + useValue: { + getByInode: () => of([]) + } + }, { provide: DotPropertiesService, useValue: dotPropertiesServiceMock @@ -559,6 +566,23 @@ describe('DotEmaShellComponent', () => { expect(spyloadPageAsset).toHaveBeenCalledWith({ url: '/my-awesome-page' }); }); + it('should get the workflow action when an `UPDATE_WORKFLOW_ACTION` event is received', () => { + const spyGetWorkflowActions = jest.spyOn(store, 'getWorkflowActions'); + + spectator.detectChanges(); + + spectator.triggerEventHandler( + DotEmaDialogComponent, + 'action', + DIALOG_ACTION_EVENT({ + name: NG_CUSTOM_EVENTS.UPDATE_WORKFLOW_ACTION + }) + ); + spectator.detectChanges(); + + expect(spyGetWorkflowActions).toHaveBeenCalled(); + }); + it('should trigger a store reload if the url is the same', () => { const spyReload = jest.spyOn(store, 'reloadCurrentPage'); const spyLocation = jest.spyOn(location, 'go'); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.ts index c07053bcc3ee..fc10066c55c1 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.ts @@ -19,7 +19,8 @@ import { DotPageLayoutService, DotPageRenderService, DotSeoMetaTagsService, - DotSeoMetaTagsUtilService + DotSeoMetaTagsUtilService, + DotWorkflowsActionsService } from '@dotcms/data-access'; import { SiteService } from '@dotcms/dotcms-js'; import { DotPageToolsSeoComponent } from '@dotcms/portlets/dot-ema/ui'; @@ -58,6 +59,7 @@ import { DotPageRenderService, DotSeoMetaTagsService, DotSeoMetaTagsUtilService, + DotWorkflowsActionsService, { provide: WINDOW, useValue: window @@ -111,6 +113,7 @@ export class DotEmaShellComponent implements OnInit, OnDestroy { ...(pageParams ?? {}), ...(viewParams ?? {}) }; + this.#updateLocation(queryParams); }); @@ -131,6 +134,11 @@ export class DotEmaShellComponent implements OnInit, OnDestroy { handleNgEvent({ event }: DialogAction) { switch (event.detail.name) { + case NG_CUSTOM_EVENTS.UPDATE_WORKFLOW_ACTION: { + this.uveStore.getWorkflowActions(); + break; + } + case NG_CUSTOM_EVENTS.SAVE_PAGE: { this.handleSavePageEvent(event); break; diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-ema-info-display/dot-ema-info-display.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-ema-info-display/dot-ema-info-display.component.spec.ts index a0c08e9bcb7e..ae6d037f6ccf 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-ema-info-display/dot-ema-info-display.component.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-ema-info-display/dot-ema-info-display.component.spec.ts @@ -21,7 +21,8 @@ import { DotExperimentsService, DotLanguagesService, DotLicenseService, - DotMessageService + DotMessageService, + DotWorkflowsActionsService } from '@dotcms/data-access'; import { LoginService } from '@dotcms/dotcms-js'; import { DEFAULT_VARIANT_NAME } from '@dotcms/dotcms-models'; @@ -53,6 +54,12 @@ describe('DotEmaInfoDisplayComponent', () => { MessageService, mockProvider(Router), mockProvider(ActivatedRoute), + { + provide: DotWorkflowsActionsService, + useValue: { + getByInode: () => of([]) + } + }, { provide: DotLanguagesService, useValue: new DotLanguagesServiceMock() diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/dot-uve-toolbar.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/dot-uve-toolbar.component.html index 1f2c2c1b48cd..e5e086766816 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/dot-uve-toolbar.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/dot-uve-toolbar.component.html @@ -51,7 +51,7 @@ data-testId="uve-toolbar-persona-selector" /> @if (!preview) { - Workflows + } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/dot-uve-toolbar.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/dot-uve-toolbar.component.spec.ts index 3ada5bd626fd..291efdc4c758 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/dot-uve-toolbar.component.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/dot-uve-toolbar.component.spec.ts @@ -13,7 +13,8 @@ import { DotExperimentsService, DotLanguagesService, DotLicenseService, - DotPersonalizeService + DotPersonalizeService, + DotWorkflowsActionsService } from '@dotcms/data-access'; import { LoginService } from '@dotcms/dotcms-js'; import { @@ -41,6 +42,7 @@ import { } from '../../../utils'; import { DotEmaBookmarksComponent } from '../dot-ema-bookmarks/dot-ema-bookmarks.component'; import { DotEmaRunningExperimentComponent } from '../dot-ema-running-experiment/dot-ema-running-experiment.component'; +import { DotUveWorkflowActionsComponent } from '../dot-uve-workflow-actions/dot-uve-workflow-actions.component'; import { EditEmaLanguageSelectorComponent } from '../edit-ema-language-selector/edit-ema-language-selector.component'; import { EditEmaPersonaSelectorComponent } from '../edit-ema-persona-selector/edit-ema-persona-selector.component'; @@ -107,7 +109,8 @@ describe('DotUveToolbarComponent', () => { HttpClientTestingModule, MockComponent(DotEmaBookmarksComponent), MockComponent(DotEmaRunningExperimentComponent), - MockComponent(EditEmaPersonaSelectorComponent) + MockComponent(EditEmaPersonaSelectorComponent), + MockComponent(DotUveWorkflowActionsComponent) ], providers: [ UVEStore, @@ -115,6 +118,10 @@ describe('DotUveToolbarComponent', () => { mockProvider(ConfirmationService, { confirm: jest.fn() }), + + mockProvider(DotWorkflowsActionsService, { + getByInode: () => of([]) + }), { provide: DotLanguagesService, useValue: new DotLanguagesServiceMock() @@ -181,6 +188,12 @@ describe('DotUveToolbarComponent', () => { }); }); + it('should have a dot-uve-workflow-actions component', () => { + const workflowActions = spectator.query(DotUveWorkflowActionsComponent); + + expect(workflowActions).toBeTruthy(); + }); + describe('copy-url', () => { let button: DebugElement; @@ -359,10 +372,6 @@ describe('DotUveToolbarComponent', () => { it('should have persona selector', () => { expect(spectator.query(byTestId('uve-toolbar-persona-selector'))).toBeTruthy(); }); - - it('should have workflows button', () => { - expect(spectator.query(byTestId('uve-toolbar-workflow-actions'))).toBeTruthy(); - }); }); describe('preview', () => { @@ -411,8 +420,10 @@ describe('DotUveToolbarComponent', () => { expect(spectator.query(byTestId('uve-toolbar-running-experiment'))).toBeFalsy(); }); - it('should not have workflow actions', () => { - expect(spectator.query(byTestId('uve-toolbar-workflow-actions'))).toBeFalsy(); + it('should not have a dot-uve-workflow-actions component', () => { + const workflowActions = spectator.query(DotUveWorkflowActionsComponent); + + expect(workflowActions).toBeNull(); }); }); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/dot-uve-toolbar.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/dot-uve-toolbar.component.ts index bfc47d0fe26a..0f649288747d 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/dot-uve-toolbar.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/dot-uve-toolbar.component.ts @@ -27,6 +27,7 @@ import { UVEStore } from '../../../store/dot-uve.store'; import { DotEmaBookmarksComponent } from '../dot-ema-bookmarks/dot-ema-bookmarks.component'; import { DotEmaInfoDisplayComponent } from '../dot-ema-info-display/dot-ema-info-display.component'; import { DotEmaRunningExperimentComponent } from '../dot-ema-running-experiment/dot-ema-running-experiment.component'; +import { DotUveWorkflowActionsComponent } from '../dot-uve-workflow-actions/dot-uve-workflow-actions.component'; import { EditEmaLanguageSelectorComponent } from '../edit-ema-language-selector/edit-ema-language-selector.component'; import { EditEmaPersonaSelectorComponent } from '../edit-ema-persona-selector/edit-ema-persona-selector.component'; @@ -46,10 +47,10 @@ import { EditEmaPersonaSelectorComponent } from '../edit-ema-persona-selector/ed SplitButtonModule, FormsModule, ReactiveFormsModule, - ChipModule, EditEmaPersonaSelectorComponent, EditEmaLanguageSelectorComponent, - ClipboardModule + DotUveWorkflowActionsComponent, + ChipModule ], providers: [DotPersonalizeService], templateUrl: './dot-uve-toolbar.component.html', @@ -59,8 +60,10 @@ import { EditEmaPersonaSelectorComponent } from '../edit-ema-persona-selector/ed export class DotUveToolbarComponent { $personaSelector = viewChild('personaSelector'); $languageSelector = viewChild('languageSelector'); - #store = inject(UVEStore); + @Output() translatePage = new EventEmitter<{ page: DotPage; newLanguage: number }>(); + + readonly #store = inject(UVEStore); readonly #messageService = inject(MessageService); readonly #dotMessageService = inject(DotMessageService); readonly #confirmationService = inject(ConfirmationService); @@ -71,8 +74,6 @@ export class DotUveToolbarComponent { readonly $apiURL = this.#store.$apiURL; readonly $personaSelectorProps = this.#store.$personaSelector; - @Output() translatePage = new EventEmitter<{ page: DotPage; newLanguage: number }>(); - readonly $styleToolbarClass = computed(() => { if (!this.$isPreviewMode()) { return 'uve-toolbar'; @@ -81,6 +82,13 @@ export class DotUveToolbarComponent { return 'uve-toolbar uve-toolbar-preview'; }); + readonly $pageInode = computed(() => { + return this.#store.pageAPIResponse()?.page.inode; + }); + + readonly $actions = this.#store.workflowLoading; + readonly $workflowLoding = this.#store.workflowLoading; + protected readonly date = new Date(); /** diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-edit-ema-workflow-actions/dot-edit-ema-workflow-actions.component.css b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-workflow-actions/dot-uve-workflow-actions.component.css similarity index 100% rename from core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-edit-ema-workflow-actions/dot-edit-ema-workflow-actions.component.css rename to core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-workflow-actions/dot-uve-workflow-actions.component.css diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-edit-ema-workflow-actions/dot-edit-ema-workflow-actions.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-workflow-actions/dot-uve-workflow-actions.component.html similarity index 83% rename from core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-edit-ema-workflow-actions/dot-edit-ema-workflow-actions.component.html rename to core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-workflow-actions/dot-uve-workflow-actions.component.html index 5a2e1336efb4..ad072ffdf11d 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-edit-ema-workflow-actions/dot-edit-ema-workflow-actions.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-workflow-actions/dot-uve-workflow-actions.component.html @@ -2,4 +2,5 @@ (actionFired)="handleActionTrigger($event)" [size]="'small'" [loading]="loading()" + [disabled]="!canEdit()" [actions]="actions()" /> diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-edit-ema-workflow-actions/dot-edit-ema-workflow-actions.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-workflow-actions/dot-uve-workflow-actions.component.spec.ts similarity index 73% rename from core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-edit-ema-workflow-actions/dot-edit-ema-workflow-actions.component.spec.ts rename to core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-workflow-actions/dot-uve-workflow-actions.component.spec.ts index 58d02b6a3d42..2855f2b42294 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-edit-ema-workflow-actions/dot-edit-ema-workflow-actions.component.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-workflow-actions/dot-uve-workflow-actions.component.spec.ts @@ -3,6 +3,7 @@ import { Spectator, createComponentFactory, mockProvider } from '@ngneat/spectat import { Subject, of } from 'rxjs'; import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { signal } from '@angular/core'; import { MessageService } from 'primeng/api'; @@ -19,7 +20,6 @@ import { DotWizardService, DotWorkflowActionsFireService, DotWorkflowEventHandlerService, - DotWorkflowsActionsService, PushPublishService } from '@dotcms/data-access'; import { CoreWebService, LoginService } from '@dotcms/dotcms-js'; @@ -32,7 +32,10 @@ import { mockWorkflowsActions } from '@dotcms/utils-testing'; -import { DotEditEmaWorkflowActionsComponent } from './dot-edit-ema-workflow-actions.component'; +import { DotUveWorkflowActionsComponent } from './dot-uve-workflow-actions.component'; + +import { MOCK_RESPONSE_VTL } from '../../../shared/mocks'; +import { UVEStore } from '../../../store/dot-uve.store'; const DOT_WORKFLOW_PAYLOAD_MOCK: DotWorkflowPayload = { assign: '654b0931-1027-41f7-ad4d-173115ed8ec1', @@ -68,6 +71,8 @@ const workflowActionMock = { ] }; +const expectedInode = MOCK_RESPONSE_VTL.page.inode; + const messageServiceMock = new MockDotMessageService({ 'Workflow-Action': 'Workflow Action', 'edit.content.fire.action.success': 'Success', @@ -76,23 +81,40 @@ const messageServiceMock = new MockDotMessageService({ Loading: 'loading' }); -describe('DotEditEmaWorkflowActionsComponent', () => { - let spectator: Spectator; +const pageParams = { + url: 'test-url', + language_id: '1' +}; + +const uveStoreMock = { + pageAPIResponse: signal(MOCK_RESPONSE_VTL), + workflowActions: signal([]), + workflowLoading: signal(false), + canEditPage: signal(true), + pageParams: signal(pageParams), + loadPageAsset: jest.fn(), + reloadCurrentPage: jest.fn(), + setWorkflowActionLoading: jest.fn() +}; + +describe('DotUveWorkflowActionsComponent', () => { + let spectator: Spectator; let dotWizardService: DotWizardService; - let dotWorkflowsActionsService: DotWorkflowsActionsService; let dotWorkflowEventHandlerService: DotWorkflowEventHandlerService; let dotWorkflowActionsFireService: DotWorkflowActionsFireService; let messageService: MessageService; + let store: InstanceType; + const createComponent = createComponentFactory({ - component: DotEditEmaWorkflowActionsComponent, + component: DotUveWorkflowActionsComponent, imports: [HttpClientTestingModule], componentProviders: [ DotWizardService, - DotWorkflowsActionsService, DotWorkflowEventHandlerService, DotWorkflowActionsFireService, MessageService, + mockProvider(UVEStore, uveStoreMock), mockProvider(DotAlertConfirmService), mockProvider(DotMessageDisplayService), mockProvider(DotHttpErrorManagerService), @@ -116,57 +138,56 @@ describe('DotEditEmaWorkflowActionsComponent', () => { detectChanges: false }); + store = spectator.inject(UVEStore, true); dotWizardService = spectator.inject(DotWizardService, true); - dotWorkflowsActionsService = spectator.inject(DotWorkflowsActionsService, true); dotWorkflowEventHandlerService = spectator.inject(DotWorkflowEventHandlerService, true); dotWorkflowActionsFireService = spectator.inject(DotWorkflowActionsFireService, true); messageService = spectator.inject(MessageService, true); }); - it('should create', () => { - expect(spectator.component).toBeTruthy(); - }); - describe('Without Workflow Actions', () => { - beforeEach(() => { - spectator.setInput('inode', '123'); + it('should set action as an empty array and loading to true', () => { + uveStoreMock.workflowLoading.set(true); spectator.detectChanges(); - }); - it('should set action as an empty array and loading to true', () => { const dotWorkflowActionsComponent = spectator.query(DotWorkflowActionsComponent); expect(dotWorkflowActionsComponent.actions()).toEqual([]); expect(dotWorkflowActionsComponent.loading()).toBeTruthy(); expect(dotWorkflowActionsComponent.size()).toBe('small'); }); + + it("should be disabled if user can't edit", () => { + uveStoreMock.canEditPage.set(false); + spectator.detectChanges(); + + const dotWorkflowActionsComponent = spectator.query(DotWorkflowActionsComponent); + expect(dotWorkflowActionsComponent.disabled()).toBeTruthy(); + }); }); describe('With Workflow Actions', () => { beforeEach(() => { - jest.spyOn(dotWorkflowsActionsService, 'getByInode').mockReturnValue( - of(mockWorkflowsActions) - ); - - spectator.setInput('inode', '123'); + uveStoreMock.workflowLoading.set(false); + uveStoreMock.canEditPage.set(true); + uveStoreMock.workflowActions.set(mockWorkflowsActions); spectator.detectChanges(); }); it('should load workflow actions', () => { const dotWorkflowActionsComponent = spectator.query(DotWorkflowActionsComponent); - expect(dotWorkflowsActionsService.getByInode).toHaveBeenCalledWith('123'); expect(dotWorkflowActionsComponent.actions()).toEqual(mockWorkflowsActions); + expect(dotWorkflowActionsComponent.loading()).toBeFalsy(); + expect(dotWorkflowActionsComponent.disabled()).toBeFalsy(); }); - it('should fire workflow actions when it does not have inputs', () => { - jest.spyOn(dotWorkflowEventHandlerService, 'containsPushPublish').mockReturnValue( - false - ); + it('should fire workflow actions and loadPageAssets', () => { + const spySetWorkflowActionLoading = jest.spyOn(store, 'setWorkflowActionLoading'); + const spyLoadPageAsset = jest.spyOn(store, 'loadPageAsset'); const dotWorkflowActionsComponent = spectator.query(DotWorkflowActionsComponent); const spy = jest .spyOn(dotWorkflowActionsFireService, 'fireTo') .mockReturnValue(of(dotcmsContentletMock)); - const spyNewPage = jest.spyOn(spectator.component.newPage, 'emit'); const spyMessage = jest.spyOn(messageService, 'add'); dotWorkflowActionsComponent.actionFired.emit({ @@ -175,16 +196,16 @@ describe('DotEditEmaWorkflowActionsComponent', () => { }); expect(spy).toHaveBeenCalledWith({ - inode: '123', + inode: expectedInode, actionId: mockWorkflowsActions[0].id, data: undefined }); - expect(spyNewPage).toHaveBeenCalledWith(dotcmsContentletMock); - expect(dotWorkflowsActionsService.getByInode).toHaveBeenCalledWith( - dotcmsContentletMock.inode - ); - + expect(spySetWorkflowActionLoading).toHaveBeenCalledWith(true); + expect(spyLoadPageAsset).toHaveBeenCalledWith({ + language_id: dotcmsContentletMock.languageId.toString(), + url: dotcmsContentletMock.url + }); expect(spyMessage).toHaveBeenCalledTimes(2); // Check the first message @@ -203,6 +224,29 @@ describe('DotEditEmaWorkflowActionsComponent', () => { }); }); + it('should fire workflow actions and reloadPage', () => { + const spySetWorkflowActionLoading = jest.spyOn(store, 'setWorkflowActionLoading'); + const spyReloadCurrentPage = jest.spyOn(store, 'reloadCurrentPage'); + const dotWorkflowActionsComponent = spectator.query(DotWorkflowActionsComponent); + const spy = jest + .spyOn(dotWorkflowActionsFireService, 'fireTo') + .mockReturnValue(of({ ...dotcmsContentletMock, ...pageParams })); + + dotWorkflowActionsComponent.actionFired.emit({ + ...mockWorkflowsActions[0], + actionInputs: [] + }); + + expect(spy).toHaveBeenCalledWith({ + inode: expectedInode, + actionId: mockWorkflowsActions[0].id, + data: undefined + }); + + expect(spySetWorkflowActionLoading).toHaveBeenCalledWith(true); + expect(spyReloadCurrentPage).toHaveBeenCalledWith(); + }); + it('should open Wizard if it has inputs ', () => { const output$ = new Subject(); @@ -211,9 +255,6 @@ describe('DotEditEmaWorkflowActionsComponent', () => { title: 'title' }; - jest.spyOn(dotWorkflowEventHandlerService, 'containsPushPublish').mockReturnValue( - false - ); jest.spyOn(dotWorkflowEventHandlerService, 'setWizardInput').mockReturnValue( wizardInputMock ); @@ -240,7 +281,7 @@ describe('DotEditEmaWorkflowActionsComponent', () => { workflowActionMock.actionInputs ); expect(spyFireTo).toHaveBeenCalledWith({ - inode: '123', + inode: expectedInode, actionId: workflowActionMock.id, data: DOT_PROCESSED_WORKFLOW_PAYLOAD_MOCK }); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-edit-ema-workflow-actions/dot-edit-ema-workflow-actions.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-workflow-actions/dot-uve-workflow-actions.component.ts similarity index 69% rename from core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-edit-ema-workflow-actions/dot-edit-ema-workflow-actions.component.ts rename to core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-workflow-actions/dot-uve-workflow-actions.component.ts index a990fdd27543..2a23808dc504 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-edit-ema-workflow-actions/dot-edit-ema-workflow-actions.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-workflow-actions/dot-uve-workflow-actions.component.ts @@ -1,13 +1,4 @@ -import { - Component, - EventEmitter, - Input, - OnChanges, - Output, - SimpleChanges, - inject, - signal -} from '@angular/core'; +import { Component, computed, inject } from '@angular/core'; import { MessageService } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; @@ -19,39 +10,39 @@ import { DotMessageService, DotWizardService, DotWorkflowActionsFireService, - DotWorkflowsActionsService, DotWorkflowEventHandlerService } from '@dotcms/data-access'; import { DotCMSContentlet, DotCMSWorkflowAction, DotWorkflowPayload } from '@dotcms/dotcms-models'; -import { DotMessagePipe, DotWorkflowActionsComponent } from '@dotcms/ui'; +import { DotWorkflowActionsComponent } from '@dotcms/ui'; + +import { UVEStore } from '../../../store/dot-uve.store'; +import { compareUrlPaths, getPageURI } from '../../../utils'; @Component({ - selector: 'dot-edit-ema-workflow-actions', + selector: 'dot-uve-workflow-actions', standalone: true, - imports: [DotWorkflowActionsComponent, ButtonModule, DotMessagePipe], + imports: [DotWorkflowActionsComponent, ButtonModule], providers: [ DotWorkflowActionsFireService, DotWorkflowEventHandlerService, - DotWorkflowsActionsService, DotHttpErrorManagerService ], - templateUrl: './dot-edit-ema-workflow-actions.component.html', - styleUrl: './dot-edit-ema-workflow-actions.component.css' + templateUrl: './dot-uve-workflow-actions.component.html', + styleUrl: './dot-uve-workflow-actions.component.css' }) -export class DotEditEmaWorkflowActionsComponent implements OnChanges { - @Input({ required: true }) inode: string; - @Output() newPage: EventEmitter = new EventEmitter(); - - protected actions = signal([]); - protected loading = signal(true); - +export class DotUveWorkflowActionsComponent { private readonly dotWorkflowActionsFireService = inject(DotWorkflowActionsFireService); - private readonly dotWorkflowsActionsService = inject(DotWorkflowsActionsService); private readonly dotMessageService = inject(DotMessageService); private readonly httpErrorManagerService = inject(DotHttpErrorManagerService); private readonly dotWizardService = inject(DotWizardService); private readonly dotWorkflowEventHandlerService = inject(DotWorkflowEventHandlerService); private readonly messageService = inject(MessageService); + readonly #uveStore = inject(UVEStore); + + inode = computed(() => this.#uveStore.pageAPIResponse()?.page.inode); + actions = this.#uveStore.workflowActions; + loading = this.#uveStore.workflowLoading; + canEdit = this.#uveStore.canEditPage; private readonly successMessage = { severity: 'info', @@ -67,12 +58,6 @@ export class DotEditEmaWorkflowActionsComponent implements OnChanges { life: 2000 }; - ngOnChanges(changes: SimpleChanges) { - if (changes.inode) { - this.loadWorkflowActions(this.inode); - } - } - handleActionTrigger(workflow: DotCMSWorkflowAction): void { const { actionInputs = [] } = workflow; const isPushPublish = this.dotWorkflowEventHandlerService.containsPushPublish(actionInputs); @@ -99,21 +84,6 @@ export class DotEditEmaWorkflowActionsComponent implements OnChanges { }); } - private loadWorkflowActions(inode: string): void { - this.loading.set(true); - this.dotWorkflowsActionsService - .getByInode(inode) - .pipe( - map((newWorkflows: DotCMSWorkflowAction[]) => { - return newWorkflows || []; - }) - ) - .subscribe((newWorkflows: DotCMSWorkflowAction[]) => { - this.loading.set(false); - this.actions.set(newWorkflows); - }); - } - private openWizard(workflow: DotCMSWorkflowAction): void { this.dotWizardService .open( @@ -138,7 +108,7 @@ export class DotEditEmaWorkflowActionsComponent implements OnChanges { workflow: DotCMSWorkflowAction, data?: T ): void { - this.loading.set(true); + this.#uveStore.setWorkflowActionLoading(true); this.messageService.add({ ...this.successMessage, detail: this.dotMessageService.get('edit.ema.page.executing.workflow.action'), @@ -147,7 +117,7 @@ export class DotEditEmaWorkflowActionsComponent implements OnChanges { this.dotWorkflowActionsFireService .fireTo({ - inode: this.inode, + inode: this.inode(), actionId: workflow.id, data }) @@ -162,17 +132,40 @@ export class DotEditEmaWorkflowActionsComponent implements OnChanges { }) ) .subscribe((contentlet: DotCMSContentlet) => { - this.loading.set(false); - if (!contentlet) { return; } - const { inode } = contentlet; - this.newPage.emit(contentlet); - this.inode = inode; - this.loadWorkflowActions(inode); + this.handleNewContent(contentlet); this.messageService.add(this.successMessage); }); } + + /** + * Handle a new page event. This event is triggered when the page changes for a Workflow Action + * Update the query params if the url or the language id changed + * + * @param {DotCMSContentlet} page + * @memberof EditEmaToolbarComponent + */ + protected handleNewContent(pageAsset: DotCMSContentlet): void { + const currentParams = this.#uveStore.pageParams(); + + const url = getPageURI(pageAsset); + const language_id = pageAsset.languageId?.toString(); + + const urlChanged = !compareUrlPaths(url, currentParams.url); + const languageChanged = language_id !== currentParams.language_id; + + if (urlChanged || languageChanged) { + this.#uveStore.loadPageAsset({ + url, + language_id + }); + + return; + } + + this.#uveStore.reloadCurrentPage(); + } } diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/edit-ema-toolbar/edit-ema-toolbar.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/edit-ema-toolbar/edit-ema-toolbar.component.html index 252310595753..3c88816de67f 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/edit-ema-toolbar/edit-ema-toolbar.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/edit-ema-toolbar/edit-ema-toolbar.component.html @@ -64,9 +64,8 @@ #personaSelector data-testId="persona-selector" /> - @if ($toolbarProps().workflowActionsInode; as inode) { - - } + + @if ($toolbarProps().unlockButton; as unlockButton) { { let spectator: Spectator; let store: SpyObject>; let messageService: MessageService; - let router: Router; let confirmationService: ConfirmationService; const createComponent = createComponentFactory({ component: EditEmaToolbarComponent, imports: [ MockComponent(DotDeviceSelectorSeoComponent), - MockComponent(DotEditEmaWorkflowActionsComponent), + MockComponent(DotUveWorkflowActionsComponent), MockComponent(DotEmaBookmarksComponent), MockComponent(DotEmaInfoDisplayComponent), MockComponent(DotEmaRunningExperimentComponent), @@ -191,7 +189,6 @@ describe('EditEmaToolbarComponent', () => { store = spectator.inject(UVEStore); messageService = spectator.inject(MessageService); - router = spectator.inject(Router); confirmationService = spectator.inject(ConfirmationService); }); @@ -407,50 +404,11 @@ describe('EditEmaToolbarComponent', () => { rejectLabel: 'Reject' }); }); - - xit('should dpersonalize - call service', () => { - expect(true).toBe(true); - }); }); - describe('dot-edit-ema-workflow-actions', () => { - it('should have attr', () => { - const workflowActions = spectator.query(DotEditEmaWorkflowActionsComponent); - - expect(workflowActions.inode).toBe('123-i'); - }); - - it('should update page', () => { - const spyloadPageAsset = jest.spyOn(store, 'loadPageAsset'); - spectator.triggerEventHandler(DotEditEmaWorkflowActionsComponent, 'newPage', { - pageURI: '/path-and-stuff', - url: 'path', - languageId: 1 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); - - spectator.detectChanges(); - - expect(spyloadPageAsset).toHaveBeenCalledWith({ - url: '/path-and-stuff', - language_id: '1' - }); - }); - - it('should trigger a store reload if the URL from urlContentMap is the same as the current URL', () => { - jest.spyOn(store, 'pageAPIResponse').mockReturnValue(PAGE_RESPONSE_URL_CONTENT_MAP); - - spectator.triggerEventHandler(DotEditEmaWorkflowActionsComponent, 'newPage', { - pageURI: '/test-url', - url: '/test-url', - languageId: 1 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); - - spectator.detectChanges(); - expect(store.reloadCurrentPage).toHaveBeenCalled(); - expect(router.navigate).not.toHaveBeenCalled(); - }); + it('should have a dot-uve-workflow-actions component', () => { + const workflowActions = spectator.query(DotUveWorkflowActionsComponent); + expect(workflowActions).toBeTruthy(); }); describe('dot-ema-info-display', () => { @@ -501,7 +459,6 @@ describe('EditEmaToolbarComponent', () => { }); store = spectator.inject(UVEStore); messageService = spectator.inject(MessageService); - router = spectator.inject(Router); confirmationService = spectator.inject(ConfirmationService); }); it('should show when showInfoDisplay is true in the store', () => { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/edit-ema-toolbar/edit-ema-toolbar.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/edit-ema-toolbar/edit-ema-toolbar.component.ts index 62220f7221ca..6f059eb73aa1 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/edit-ema-toolbar/edit-ema-toolbar.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/edit-ema-toolbar/edit-ema-toolbar.component.ts @@ -29,10 +29,10 @@ import { DEFAULT_PERSONA } from '../../../shared/consts'; import { DotPage } from '../../../shared/models'; import { UVEStore } from '../../../store/dot-uve.store'; import { compareUrlPaths } from '../../../utils'; -import { DotEditEmaWorkflowActionsComponent } from '../dot-edit-ema-workflow-actions/dot-edit-ema-workflow-actions.component'; import { DotEmaBookmarksComponent } from '../dot-ema-bookmarks/dot-ema-bookmarks.component'; import { DotEmaInfoDisplayComponent } from '../dot-ema-info-display/dot-ema-info-display.component'; import { DotEmaRunningExperimentComponent } from '../dot-ema-running-experiment/dot-ema-running-experiment.component'; +import { DotUveWorkflowActionsComponent } from '../dot-uve-workflow-actions/dot-uve-workflow-actions.component'; import { EditEmaLanguageSelectorComponent } from '../edit-ema-language-selector/edit-ema-language-selector.component'; import { EditEmaPersonaSelectorComponent } from '../edit-ema-persona-selector/edit-ema-persona-selector.component'; @@ -50,7 +50,7 @@ import { EditEmaPersonaSelectorComponent } from '../edit-ema-persona-selector/ed EditEmaPersonaSelectorComponent, EditEmaLanguageSelectorComponent, DotEmaInfoDisplayComponent, - DotEditEmaWorkflowActionsComponent, + DotUveWorkflowActionsComponent, ClipboardModule ], providers: [DotPersonalizeService], 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 450bc1f9e114..d40d3308e568 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 @@ -1,5 +1,10 @@ import { describe, expect, it } from '@jest/globals'; -import { SpectatorRouting, createRoutingFactory, byTestId } from '@ngneat/spectator/jest'; +import { + SpectatorRouting, + createRoutingFactory, + byTestId, + mockProvider +} from '@ngneat/spectator/jest'; import { MockComponent } from 'ng-mocks'; import { Observable, of, throwError } from 'rxjs'; @@ -25,18 +30,22 @@ import { DotESContentService, DotExperimentsService, DotFavoritePageService, + DotGlobalMessageService, DotHttpErrorManagerService, DotIframeService, DotLanguagesService, DotLicenseService, + DotMessageDisplayService, DotMessageService, DotPersonalizeService, DotPropertiesService, + DotRouterService, DotSeoMetaTagsService, DotSeoMetaTagsUtilService, DotSessionStorageService, DotTempFileUploadService, DotWorkflowActionsFireService, + DotWorkflowsActionsService, PushPublishService } from '@dotcms/data-access'; import { @@ -67,9 +76,9 @@ import { MockDotHttpErrorManagerService } from '@dotcms/utils-testing'; -import { DotEditEmaWorkflowActionsComponent } from './components/dot-edit-ema-workflow-actions/dot-edit-ema-workflow-actions.component'; import { DotEmaRunningExperimentComponent } from './components/dot-ema-running-experiment/dot-ema-running-experiment.component'; import { DotUveToolbarComponent } from './components/dot-uve-toolbar/dot-uve-toolbar.component'; +import { DotUveWorkflowActionsComponent } from './components/dot-uve-workflow-actions/dot-uve-workflow-actions.component'; import { CONTENT_TYPE_MOCK } from './components/edit-ema-palette/components/edit-ema-palette-content-type/edit-ema-palette-content-type.component.spec'; import { CONTENTLETS_MOCK } from './components/edit-ema-palette/edit-ema-palette.component.spec'; import { EditEmaToolbarComponent } from './components/edit-ema-toolbar/edit-ema-toolbar.component'; @@ -136,7 +145,7 @@ const createRouting = () => component: EditEmaEditorComponent, imports: [RouterTestingModule, HttpClientTestingModule, SafeUrlPipe, ConfirmDialogModule], declarations: [ - MockComponent(DotEditEmaWorkflowActionsComponent), + MockComponent(DotUveWorkflowActionsComponent), MockComponent(DotResultsSeoToolComponent), MockComponent(DotEmaRunningExperimentComponent), MockComponent(EditEmaToolbarComponent) @@ -149,6 +158,15 @@ const createRouting = () => DotFavoritePageService, DotESContentService, DotSessionStorageService, + mockProvider(DotMessageDisplayService), + mockProvider(DotRouterService), + mockProvider(DotGlobalMessageService), + { + provide: DotWorkflowsActionsService, + useValue: { + getByInode: () => of([]) + } + }, { provide: DotPropertiesService, useValue: { @@ -440,6 +458,7 @@ describe('EditEmaEditorComponent', () => { store.setFlags({ FEATURE_FLAG_UVE_PREVIEW_MODE: true }); + spectator.detectChanges(); const toolbar = spectator.query(DotUveToolbarComponent); 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 4d817ba4af5a..a26c028c30cf 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 @@ -159,7 +159,7 @@ export class EditEmaEditorComponent implements OnInit, OnDestroy { readonly host = '*'; readonly $ogTags: WritableSignal = signal(undefined); readonly $editorProps = this.uveStore.$editorProps; - // This on is the FF + readonly $previewMode = this.uveStore.$previewMode; readonly $isPreviewMode = this.uveStore.$isPreviewMode; diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-layout/edit-ema-layout.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-layout/edit-ema-layout.component.spec.ts index b3c345499578..cd080b9a58b9 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-layout/edit-ema-layout.component.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-layout/edit-ema-layout.component.spec.ts @@ -19,7 +19,8 @@ import { DotLicenseService, DotMessageService, DotPageLayoutService, - DotRouterService + DotRouterService, + DotWorkflowsActionsService } from '@dotcms/data-access'; import { CoreWebService, LoginService } from '@dotcms/dotcms-js'; import { TemplateBuilderComponent, TemplateBuilderModule } from '@dotcms/template-builder'; @@ -93,6 +94,9 @@ describe('EditEmaLayoutComponent', () => { get: jest.fn(() => of(PAGE_RESPONSE)), getClientPage: jest.fn(() => of(PAGE_RESPONSE)) }), + mockProvider(DotWorkflowsActionsService, { + getByInode: jest.fn(() => of([])) + }), MockProvider(DotExperimentsService, DotExperimentsServiceMock, 'useValue'), MockProvider(DotRouterService, new MockDotRouterJestService(jest), 'useValue'), MockProvider(DotLanguagesService, new DotLanguagesServiceMock(), 'useValue'), 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 35c542dbc821..d6e2cd6f9c3a 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 @@ -12,7 +12,8 @@ export enum NG_CUSTOM_EVENTS { OPEN_WIZARD = 'workflow-wizard', DIALOG_CLOSED = 'dialog-closed', EDIT_CONTENTLET_UPDATED = 'edit-contentlet-data-updated', - LANGUAGE_IS_CHANGED = 'language-is-changed' + LANGUAGE_IS_CHANGED = 'language-is-changed', + UPDATE_WORKFLOW_ACTION = 'update-workflow-action' } // Status of the whole UVE 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 45de140e732c..4755c10f8e5e 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 @@ -17,7 +17,8 @@ import { DotLanguagesService, DotLicenseService, DotMessageService, - DotPropertiesService + DotPropertiesService, + DotWorkflowsActionsService } from '@dotcms/data-access'; import { LoginService } from '@dotcms/dotcms-js'; import { @@ -67,6 +68,12 @@ describe('UVEStore', () => { MessageService, mockProvider(Router), mockProvider(ActivatedRoute), + { + provide: DotWorkflowsActionsService, + useValue: { + getByInode: () => of({}) + } + }, { provide: DotPropertiesService, useValue: dotPropertiesServiceMock diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/toolbar/withUVEToolbar.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/toolbar/withUVEToolbar.ts index d43e88e037d2..1bd25f53c8fd 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/toolbar/withUVEToolbar.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/toolbar/withUVEToolbar.ts @@ -109,7 +109,6 @@ export function withUVEToolbar() { ? (pageAPIResponse?.urlContentMap ?? null) : null, runningExperiment: isExperimentRunning ? experiment : null, - workflowActionsInode: store.canEditPage() ? pageAPIResponse?.page.inode : null, unlockButton: shouldShowUnlock ? unlockButton : null, showInfoDisplay: shouldShowInfoDisplay }; diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts index 30c8d52ea39c..14dc9b20de07 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts @@ -540,7 +540,6 @@ describe('withEditor', () => { currentLanguage: MOCK_RESPONSE_HEADLESS.viewAs.language, urlContentMap: null, runningExperiment: null, - workflowActionsInode: MOCK_RESPONSE_HEADLESS.page.inode, unlockButton: null, showInfoDisplay: false }); 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 index 9a49c45296de..f5a2d088e64f 100644 --- 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 @@ -14,7 +14,8 @@ import { DotExperimentsService, DotLanguagesService, DotLicenseService, - DotMessageService + DotMessageService, + DotWorkflowsActionsService } from '@dotcms/data-access'; import { LoginService } from '@dotcms/dotcms-js'; import { @@ -80,6 +81,7 @@ describe('withLoad', () => { let spectator: SpectatorService>; let store: InstanceType; let dotPageApiService: SpyObject; + let dotWorkflowsActionsService: SpyObject; let router: Router; const createService = createServiceFactory({ @@ -87,15 +89,21 @@ describe('withLoad', () => { providers: [ mockProvider(Router), mockProvider(ActivatedRoute), + { + provide: DotWorkflowsActionsService, + useValue: { + getByInode: () => of([]) + } + }, { provide: DotPageApiService, useValue: { get() { return of({}); }, - getClientPage() { - return of({}); - }, + getClientPage: jest + .fn() + .mockImplementation(buildPageAPIResponseFromMock(MOCK_RESPONSE_HEADLESS)), save: jest.fn() } }, @@ -143,6 +151,7 @@ describe('withLoad', () => { router = spectator.inject(Router); dotPageApiService = spectator.inject(DotPageApiService); + dotWorkflowsActionsService = spectator.inject(DotWorkflowsActionsService); jest.spyOn(dotPageApiService, 'get').mockImplementation( buildPageAPIResponseFromMock(MOCK_RESPONSE_HEADLESS) ); @@ -183,6 +192,14 @@ describe('withLoad', () => { expect(store.isClientReady()).toBe(true); }); + it('should call workflow action service on loadPageAsset', () => { + const getWorkflowActionsSpy = jest.spyOn(dotWorkflowsActionsService, 'getByInode'); + store.loadPageAsset(HEADLESS_BASE_QUERY_PARAMS); + expect(getWorkflowActionsSpy).toHaveBeenCalledWith( + MOCK_RESPONSE_HEADLESS.page.inode + ); + }); + it('should update the pageParams with the vanity URL on permanent redirect', () => { const permanentRedirect = getVanityUrl( VTL_BASE_QUERY_PARAMS.url, @@ -198,10 +215,7 @@ describe('withLoad', () => { store.loadPageAsset(VTL_BASE_QUERY_PARAMS); expect(router.navigate).toHaveBeenCalledWith([], { - queryParams: { - ...VTL_BASE_QUERY_PARAMS, - url: forwardTo - }, + queryParams: { url: forwardTo }, queryParamsHandling: 'merge' }); }); @@ -221,10 +235,7 @@ describe('withLoad', () => { store.loadPageAsset(VTL_BASE_QUERY_PARAMS); expect(router.navigate).toHaveBeenCalledWith([], { - queryParams: { - ...VTL_BASE_QUERY_PARAMS, - url: forwardTo - }, + queryParams: { url: forwardTo }, queryParamsHandling: 'merge' }); }); @@ -237,12 +248,20 @@ describe('withLoad', () => { expect(getPageSpy).toHaveBeenCalledWith(pageParams, { params: null, query: '' }); }); - }); - it('should reload the store with a specific property value', () => { - store.reloadCurrentPage({ isClientReady: false }); + it('should reload the store with a specific property value', () => { + store.reloadCurrentPage({ isClientReady: false }); - expect(store.isClientReady()).toBe(false); + expect(store.isClientReady()).toBe(false); + }); + + it('should call workflow action service on reloadCurrentPage', () => { + const getWorkflowActionsSpy = jest.spyOn(dotWorkflowsActionsService, 'getByInode'); + store.reloadCurrentPage(); + expect(getWorkflowActionsSpy).toHaveBeenCalledWith( + MOCK_RESPONSE_HEADLESS.page.inode + ); + }); }); }); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/load/withLoad.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/load/withLoad.ts index 0c1b479186c8..fb532018d950 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/load/withLoad.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/load/withLoad.ts @@ -1,4 +1,3 @@ -import { tapResponse } from '@ngrx/operators'; import { patchState, signalStoreFeature, type, withMethods } from '@ngrx/signals'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { EMPTY, forkJoin, of, pipe } from 'rxjs'; @@ -7,7 +6,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { inject } from '@angular/core'; import { Router } from '@angular/router'; -import { map, shareReplay, switchMap, take, tap } from 'rxjs/operators'; +import { catchError, map, shareReplay, switchMap, take, tap } from 'rxjs/operators'; import { DotExperimentsService, DotLanguagesService, DotLicenseService } from '@dotcms/data-access'; import { LoginService } from '@dotcms/dotcms-js'; @@ -18,6 +17,7 @@ import { UVE_STATUS } from '../../../shared/enums'; import { computeCanEditPage, computePageIsLocked, isForwardOrPage } from '../../../utils'; import { UVEState } from '../../models'; import { withClient } from '../client/withClient'; +import { withWorkflow } from '../workflow/withWorkflow'; /** * Add load and reload method to the store @@ -31,6 +31,7 @@ export function withLoad() { state: type() }, withClient(), + withWorkflow(), withMethods((store) => { const router = inject(Router); const dotPageApiService = inject(DotPageApiService); @@ -74,20 +75,15 @@ export function withLoad() { switchMap((pageAsset) => { const { vanityUrl } = pageAsset; - // If there is no vanity and is not a redirect we just return the pageAPI response + // If there is not vanity and is not a redirect we just return the pageAPI response if (isForwardOrPage(vanityUrl)) { return of(pageAsset); } - const queryParams = { - ...pageParams, - url: vanityUrl.forwardTo.replace('/', '') - }; - - // Will trigger full editor page Reload + const url = vanityUrl.forwardTo.replace('/', ''); router.navigate([], { - queryParams, - queryParamsHandling: 'merge' + queryParamsHandling: 'merge', + queryParams: { url } }); // EMPTY is a simple Observable that only emits the complete notification. @@ -101,13 +97,16 @@ export function withLoad() { .pipe(take(1), shareReplay()), currentUser: loginService.getCurrentUser() }).pipe( - tap({ - error: ({ status: errorStatus }: HttpErrorResponse) => { - patchState(store, { - errorCode: errorStatus, - status: UVE_STATUS.ERROR - }); - } + tap(({ pageAsset }) => + store.getWorkflowActions(pageAsset.page.inode) + ), + catchError(({ status: errorStatus }: HttpErrorResponse) => { + patchState(store, { + errorCode: errorStatus, + status: UVE_STATUS.ERROR + }); + + return EMPTY; }), switchMap(({ pageAsset, isEnterprise, currentUser }) => { const experimentId = @@ -121,40 +120,42 @@ export function withLoad() { pageAsset.page.identifier ) }).pipe( - tap({ - next: ({ experiment, languages }) => { - const canEditPage = computeCanEditPage( - pageAsset?.page, - currentUser, - experiment - ); - - const pageIsLocked = computePageIsLocked( - pageAsset?.page, - currentUser - ); - - const isTraditionalPage = !pageParams.clientHost; // If we don't send the clientHost we are using as VTL page - - patchState(store, { - pageAPIResponse: pageAsset, - isEnterprise, - currentUser, - experiment, - languages, - canEditPage, - pageIsLocked, - isTraditionalPage, - isClientReady: isTraditionalPage, // If is a traditional page we are ready - status: UVE_STATUS.LOADED - }); - }, - error: ({ status: errorStatus }: HttpErrorResponse) => { - patchState(store, { - errorCode: errorStatus, - status: UVE_STATUS.ERROR - }); - } + catchError(({ status: errorStatus }: HttpErrorResponse) => { + patchState(store, { + errorCode: errorStatus, + status: UVE_STATUS.ERROR + }); + + return EMPTY; + }), + tap(({ experiment, languages }) => { + const canEditPage = computeCanEditPage( + pageAsset?.page, + currentUser, + experiment + ); + + const pageIsLocked = computePageIsLocked( + pageAsset?.page, + currentUser + ); + + const isPreview = pageParams.preview === 'true'; + const isTraditionalPage = !pageParams.clientHost; + const isClientReady = isTraditionalPage || isPreview; + + patchState(store, { + pageAPIResponse: pageAsset, + isEnterprise, + currentUser, + experiment, + languages, + canEditPage, + pageIsLocked, + isClientReady, + isTraditionalPage, + status: UVE_STATUS.LOADED + }); }) ); }) @@ -180,44 +181,45 @@ export function withLoad() { return dotPageApiService .getClientPage(store.pageParams(), store.clientRequestProps()) .pipe( - switchMap((pageAPIResponse) => - dotLanguagesService - .getLanguagesUsedPage(pageAPIResponse.page.identifier) - .pipe( - map((languages) => ({ - pageAPIResponse, - languages - })) + tap((pageAsset) => { + store.getWorkflowActions(pageAsset.page.inode); + }), + switchMap((pageAPIResponse) => { + return forkJoin({ + pageAPIResponse: of(pageAPIResponse), + languages: dotLanguagesService.getLanguagesUsedPage( + pageAPIResponse.page.identifier ) - ), - tapResponse({ - next: ({ pageAPIResponse, languages }) => { - const canEditPage = computeCanEditPage( - pageAPIResponse?.page, - store.currentUser(), - store.experiment() - ); + }); + }), + catchError(({ status: errorStatus }: HttpErrorResponse) => { + patchState(store, { + errorCode: errorStatus, + status: UVE_STATUS.ERROR + }); - const pageIsLocked = computePageIsLocked( - pageAPIResponse?.page, - store.currentUser() - ); + return EMPTY; + }), + tap(({ pageAPIResponse, languages }) => { + const canEditPage = computeCanEditPage( + pageAPIResponse?.page, + store.currentUser(), + store.experiment() + ); + + const pageIsLocked = computePageIsLocked( + pageAPIResponse?.page, + store.currentUser() + ); - patchState(store, { - pageAPIResponse, - languages, - canEditPage, - pageIsLocked, - status: UVE_STATUS.LOADED, - isClientReady: partialState?.isClientReady ?? true - }); - }, - error: ({ status: errorStatus }: HttpErrorResponse) => { - patchState(store, { - errorCode: errorStatus, - status: UVE_STATUS.ERROR - }); - } + patchState(store, { + pageAPIResponse, + languages, + canEditPage, + pageIsLocked, + status: UVE_STATUS.LOADED, + isClientReady: partialState?.isClientReady ?? true + }); }) ); }) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/workflow/withWorkflow.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/workflow/withWorkflow.spec.ts new file mode 100644 index 000000000000..f1bafa1e07a7 --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/workflow/withWorkflow.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect } from '@jest/globals'; +import { createServiceFactory, SpectatorService, SpyObject } from '@ngneat/spectator/jest'; +import { signalStore, withState } from '@ngrx/signals'; +import { of } from 'rxjs'; + +import { DotWorkflowsActionsService } from '@dotcms/data-access'; +import { mockWorkflowsActions } from '@dotcms/utils-testing'; + +import { withWorkflow } from './withWorkflow'; + +import { DotPageApiParams } from '../../../services/dot-page-api.service'; +import { UVE_STATUS } from '../../../shared/enums'; +import { MOCK_RESPONSE_HEADLESS } from '../../../shared/mocks'; +import { UVEState } from '../../models'; + +const pageParams: DotPageApiParams = { + url: 'new-url', + language_id: '1', + 'com.dotmarketing.persona.id': '2' +}; + +const initialState: UVEState = { + isEnterprise: false, + languages: [], + pageAPIResponse: MOCK_RESPONSE_HEADLESS, + currentUser: null, + experiment: null, + errorCode: null, + pageParams, + status: UVE_STATUS.LOADING, + isTraditionalPage: true, + canEditPage: false, + pageIsLocked: true, + isClientReady: false +}; + +export const uveStoreMock = signalStore(withState(initialState), withWorkflow()); + +describe('withLoad', () => { + let spectator: SpectatorService>; + let store: InstanceType; + let dotWorkflowsActionsService: SpyObject; + + const createService = createServiceFactory({ + service: uveStoreMock, + providers: [ + { + provide: DotWorkflowsActionsService, + useValue: { + getByInode: () => of(mockWorkflowsActions) + } + } + ] + }); + + beforeEach(() => { + spectator = createService(); + store = spectator.service; + dotWorkflowsActionsService = spectator.inject(DotWorkflowsActionsService); + }); + + it('should start with the initial state', () => { + expect(store.workflowActions()).toEqual([]); + expect(store.workflowLoading()).toBe(true); + }); + + describe('withMethods', () => { + describe('getWorkflowActions', () => { + it('should call get workflow actions using store page inode', () => { + const spyWorkflowActions = jest.spyOn(dotWorkflowsActionsService, 'getByInode'); + store.getWorkflowActions(); + expect(store.workflowLoading()).toBe(false); + expect(store.workflowActions()).toEqual(mockWorkflowsActions); + expect(spyWorkflowActions).toHaveBeenCalledWith(MOCK_RESPONSE_HEADLESS.page.inode); + }); + + it('should call get workflow actions using the provided inode', () => { + const spyWorkflowActions = jest.spyOn(dotWorkflowsActionsService, 'getByInode'); + store.getWorkflowActions('123'); + expect(store.workflowLoading()).toBe(false); + expect(store.workflowActions()).toEqual(mockWorkflowsActions); + expect(spyWorkflowActions).toHaveBeenCalledWith('123'); + }); + }); + + it('should set workflowLoading to true', () => { + store.setWorkflowActionLoading(true); + expect(store.workflowLoading()).toBe(true); + }); + }); + + afterEach(() => jest.clearAllMocks()); +}); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/workflow/withWorkflow.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/workflow/withWorkflow.ts new file mode 100644 index 000000000000..1827f86a0423 --- /dev/null +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/workflow/withWorkflow.ts @@ -0,0 +1,81 @@ +import { tapResponse } from '@ngrx/operators'; +import { patchState, signalStoreFeature, type, withMethods, withState } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { pipe } from 'rxjs'; + +import { HttpErrorResponse } from '@angular/common/http'; +import { inject } from '@angular/core'; + +import { switchMap, tap } from 'rxjs/operators'; + +import { DotWorkflowsActionsService } from '@dotcms/data-access'; +import { DotCMSWorkflowAction } from '@dotcms/dotcms-models'; + +import { UVE_STATUS } from '../../../shared/enums'; +import { UVEState } from '../../models'; + +interface WithWorkflowState { + workflowActions: DotCMSWorkflowAction[]; + workflowLoading: boolean; +} + +/** + * Add load and reload method to the store + * + * @export + * @return {*} + */ +export function withWorkflow() { + return signalStoreFeature( + { + state: type() + }, + withState({ + workflowActions: [], + workflowLoading: true + }), + withMethods((store) => { + const dotWorkflowsActionsService = inject(DotWorkflowsActionsService); + + return { + /** + * Load workflow actions + */ + getWorkflowActions: rxMethod( + pipe( + tap(() => { + patchState(store, { + workflowLoading: true + }); + }), + switchMap((inode) => { + const pageInode = inode || store.pageAPIResponse()?.page.inode; + + return dotWorkflowsActionsService.getByInode(pageInode).pipe( + tapResponse({ + next: (workflowActions = []) => { + patchState(store, { + workflowActions, + workflowLoading: false + }); + }, + error: ({ status: errorStatus }: HttpErrorResponse) => { + patchState(store, { + errorCode: errorStatus, + status: UVE_STATUS.ERROR + }); + } + }) + ); + }) + ) + ), + setWorkflowActionLoading: (loading: boolean) => { + patchState(store, { + workflowLoading: loading + }); + } + }; + }) + ); +} diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/models.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/models.ts index bb7eeb96926a..113a528e5bcc 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/models.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/models.ts @@ -1,5 +1,10 @@ import { CurrentUser } from '@dotcms/dotcms-js'; -import { DotExperiment, DotLanguage, DotPageToolUrlParams } from '@dotcms/dotcms-models'; +import { + DotCMSWorkflowAction, + DotExperiment, + DotLanguage, + DotPageToolUrlParams +} from '@dotcms/dotcms-models'; import { InfoPage } from '@dotcms/ui'; import { DotPageApiParams, DotPageApiResponse } from '../services/dot-page-api.service'; @@ -20,6 +25,7 @@ export interface UVEState { canEditPage: boolean; pageIsLocked: boolean; isClientReady: boolean; + workflowActions?: DotCMSWorkflowAction[]; } export interface ShellProps { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/utils/index.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/utils/index.ts index 5f643314740a..fa42f0370dfe 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/utils/index.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/utils/index.ts @@ -633,3 +633,19 @@ export function shouldNavigate(targetUrl: string | undefined, currentUrl: string // Navigate if the target URL is defined and different from the current URL return targetUrl !== undefined && !compareUrlPaths(targetUrl, currentUrl); } + +/** + * Get the page URI from the contentlet + * + * If the URL_MAP_FOR_CONTENT is present, it will be used as the page URI. + * + * @param {DotCMSContentlet} { urlContentMap, pageURI, url} + * @return {*} {string} + */ +export const getPageURI = ({ urlContentMap, pageURI, url }: DotCMSContentlet): string => { + const contentMapUrl = urlContentMap?.URL_MAP_FOR_CONTENT; + const pageURIUrl = pageURI ?? url; + const newUrl = contentMapUrl ?? pageURIUrl; + + return sanitizeURL(newUrl); +}; diff --git a/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.html b/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.html index ed2afd75d23d..675cf6e66225 100644 --- a/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.html +++ b/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.html @@ -4,7 +4,7 @@ @if (subActions.length) { } diff --git a/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.spec.ts b/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.spec.ts index 2db6fd164fd9..af3a5a59f419 100644 --- a/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.spec.ts +++ b/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.spec.ts @@ -189,6 +189,37 @@ describe('DotWorkflowActionsComponent', () => { }); }); + describe('disabled', () => { + beforeEach(() => { + spectator.setInput('actions', [ + ...WORKFLOW_ACTIONS_MOCK, + WORKFLOW_ACTIONS_SEPARATOR_MOCK, + WORKFLOW_ACTIONS_MOCK[0] + ]); + spectator.detectChanges(); + }); + + it('should disable the button', () => { + const button = spectator.query(Button); + expect(button.disabled).toBeFalsy(); + + spectator.setInput('disabled', true); + spectator.detectChanges(); + + expect(button.disabled).toBeTruthy(); + }); + + it('should disabled split buttons ', () => { + const splitButton = spectator.query(SplitButton); + expect(splitButton.disabled).toBeFalsy(); + + spectator.setInput('disabled', true); + spectator.detectChanges(); + + expect(splitButton.disabled).toBeTruthy(); + }); + }); + describe('size', () => { beforeEach(() => { spectator.setInput('actions', [ diff --git a/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.ts b/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.ts index 9d07764d0d75..3f1d20265c4a 100644 --- a/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.ts +++ b/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.ts @@ -51,6 +51,12 @@ export class DotWorkflowActionsComponent implements OnChanges { * @memberof DotWorkflowActionsComponent */ loading = input(false); + /** + * Disable the actions + * + * @memberof DotWorkflowActionsComponent + */ + disabled = input(false); /** * Group the actions by separator * diff --git a/dotCMS/src/main/java/com/dotcms/cache/DynamicTTLCache.java b/dotCMS/src/main/java/com/dotcms/cache/DynamicTTLCache.java index a9c4f29ade68..3966996dc7b9 100644 --- a/dotCMS/src/main/java/com/dotcms/cache/DynamicTTLCache.java +++ b/dotCMS/src/main/java/com/dotcms/cache/DynamicTTLCache.java @@ -47,6 +47,7 @@ public long expireAfterRead(K key, CacheValue value, long currentTime, long curr return currentDuration; } }) + .recordStats() .maximumSize(maxCapacity) .build(); } 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 09f233bc0120..d6ceb97dd977 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 @@ -686,6 +686,10 @@ public Contentlet findContentletByIdentifier(final String identifier, final long final Date timeMachineDate, final User user, final boolean respectFrontendRoles) throws DotDataException, DotSecurityException, DotContentletStateException{ final Contentlet contentlet = contentFactory.findContentletByIdentifier(identifier, languageId, variantId, timeMachineDate); + if (contentlet == null) { + Logger.debug(this, "Contentlet not found for identifier: " + identifier + " lang:" + languageId + " variant:" + variantId + " date:" + timeMachineDate); + return null; + } if (permissionAPI.doesUserHavePermission(contentlet, PermissionAPI.PERMISSION_READ, user, respectFrontendRoles)) { return contentlet; } else { @@ -7671,6 +7675,10 @@ public void validateContentlet(final Contentlet contentlet, final List // validate unique if (field.isUnique()) { try { + if (!UtilMethods.isSet(contentlet.getHost())) { + populateHost(contentlet); + } + uniqueFieldValidationStrategyResolver.get().get().validate(contentlet, LegacyFieldTransformer.from(field)); } catch (final UniqueFieldValueDuplicatedException e) { diff --git a/dotCMS/src/main/java/com/dotcms/graphql/business/PageAPIGraphQLFieldsProvider.java b/dotCMS/src/main/java/com/dotcms/graphql/business/PageAPIGraphQLFieldsProvider.java index 8d17d5ff43a5..36fa9bff8e3b 100644 --- a/dotCMS/src/main/java/com/dotcms/graphql/business/PageAPIGraphQLFieldsProvider.java +++ b/dotCMS/src/main/java/com/dotcms/graphql/business/PageAPIGraphQLFieldsProvider.java @@ -50,6 +50,10 @@ public Collection getFields() throws DotDataException { .name("site") .type(GraphQLString) .build()) + .argument(GraphQLArgument.newArgument() //This is time machine + .name("publishDate") + .type(GraphQLString) + .build()) .type(PageAPIGraphQLTypesProvider.INSTANCE.getTypesMap().get(DOT_PAGE)) .dataFetcher(new PageDataFetcher()).build()); } diff --git a/dotCMS/src/main/java/com/dotcms/graphql/datafetcher/page/ContainersDataFetcher.java b/dotCMS/src/main/java/com/dotcms/graphql/datafetcher/page/ContainersDataFetcher.java index 1ea5b74a62a7..84e6d17e4818 100644 --- a/dotCMS/src/main/java/com/dotcms/graphql/datafetcher/page/ContainersDataFetcher.java +++ b/dotCMS/src/main/java/com/dotcms/graphql/datafetcher/page/ContainersDataFetcher.java @@ -14,7 +14,6 @@ import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; import java.util.List; -import javax.servlet.http.HttpServletRequest; /** * This DataFetcher returns the {@link TemplateLayout} associated to the requested {@link HTMLPageAsset}. @@ -31,8 +30,6 @@ public List get(final DataFetchingEnvironment environment) throws final String languageId = (String) context.getParam("languageId"); final PageMode mode = PageMode.get(pageModeAsString); - final HttpServletRequest request = context.getHttpServletRequest(); - final HTMLPageAsset pageAsset = APILocator.getHTMLPageAssetAPI() .fromContentlet(page); diff --git a/dotCMS/src/main/java/com/dotcms/graphql/datafetcher/page/PageDataFetcher.java b/dotCMS/src/main/java/com/dotcms/graphql/datafetcher/page/PageDataFetcher.java index 719e99bc1c44..a885e60fb2d5 100644 --- a/dotCMS/src/main/java/com/dotcms/graphql/datafetcher/page/PageDataFetcher.java +++ b/dotCMS/src/main/java/com/dotcms/graphql/datafetcher/page/PageDataFetcher.java @@ -2,6 +2,8 @@ import com.dotcms.graphql.DotGraphQLContext; import com.dotcms.graphql.exception.PermissionDeniedGraphQLException; +import com.dotcms.rest.api.v1.page.PageResource; +import com.dotcms.variant.VariantAPI; import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; import com.dotmarketing.exception.DotSecurityException; @@ -15,6 +17,7 @@ import com.dotmarketing.portlets.htmlpageasset.model.HTMLPageAsset; import com.dotmarketing.portlets.rules.business.RulesEngine; import com.dotmarketing.portlets.rules.model.Rule.FireOn; +import com.dotmarketing.util.DateUtil; import com.dotmarketing.util.Logger; import com.dotmarketing.util.PageMode; import com.dotmarketing.util.UtilMethods; @@ -22,6 +25,9 @@ import com.liferay.portal.model.User; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; +import io.vavr.control.Try; +import java.time.Instant; +import java.util.Date; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -53,6 +59,7 @@ public Contentlet get(final DataFetchingEnvironment environment) throws Exceptio final boolean fireRules = environment.getArgument("fireRules"); final String persona = environment.getArgument("persona"); final String site = environment.getArgument("site"); + final String publishDate = environment.getArgument("publishDate"); context.addParam("url", url); context.addParam("languageId", languageId); @@ -60,6 +67,7 @@ public Contentlet get(final DataFetchingEnvironment environment) throws Exceptio context.addParam("fireRules", fireRules); context.addParam("persona", persona); context.addParam("site", site); + context.addParam("publishDate", publishDate); final PageMode mode = PageMode.get(pageModeAsString); PageMode.setPageMode(request, mode); @@ -77,6 +85,22 @@ public Contentlet get(final DataFetchingEnvironment environment) throws Exceptio request.setAttribute(Host.HOST_VELOCITY_VAR_NAME, site); } + Date publishDateObj = null; + + if(UtilMethods.isSet(publishDate)) { + publishDateObj = Try.of(()-> DateUtil.convertDate(publishDate)).getOrElse(() -> { + Logger.error(this, "Invalid publish date: " + publishDate); + return null; + }); + if(null != publishDateObj) { + //We get a valid time machine date + final Instant instant = publishDateObj.toInstant(); + final long epochMilli = instant.toEpochMilli(); + context.addParam(PageResource.TM_DATE, epochMilli); + request.setAttribute(PageResource.TM_DATE, epochMilli); + } + } + Logger.debug(this, ()-> "Fetching page for URL: " + url); final PageContext pageContext = PageContextBuilder.builder() diff --git a/dotCMS/src/main/java/com/dotcms/mock/request/MockParameterRequest.java b/dotCMS/src/main/java/com/dotcms/mock/request/MockParameterRequest.java index a8ffe21b4d0f..1560b22b9701 100644 --- a/dotCMS/src/main/java/com/dotcms/mock/request/MockParameterRequest.java +++ b/dotCMS/src/main/java/com/dotcms/mock/request/MockParameterRequest.java @@ -1,17 +1,18 @@ package com.dotcms.mock.request; -import java.nio.charset.Charset; +import com.google.common.collect.ImmutableMap; +import java.nio.charset.StandardCharsets; import java.util.Enumeration; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Vector; import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; import org.apache.http.NameValuePair; import org.apache.http.client.utils.URLEncodedUtils; -import com.dotcms.repackage.com.google.common.collect.ImmutableMap; /** * Mock Request Parameter using a Request Wrapper. Part of the work to be @@ -27,8 +28,7 @@ public MockParameterRequest(HttpServletRequest request) { public MockParameterRequest(HttpServletRequest request, Map setMe) { super(request); HashMap mutable = new HashMap<>(); - - List additional = URLEncodedUtils.parse(request.getQueryString(), Charset.forName("UTF-8")); + List additional = URLEncodedUtils.parse(request.getQueryString(), StandardCharsets.UTF_8); for(NameValuePair nvp : additional) { mutable.put(nvp.getName(),nvp.getValue()); } @@ -40,7 +40,7 @@ public MockParameterRequest(HttpServletRequest request, Map setM mutable.put(key, request.getParameter(key)); } mutable.putAll(setMe); - + mutable.values().removeIf(Objects::isNull); params = ImmutableMap.copyOf(mutable); } diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/services/PageRenderUtil.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/services/PageRenderUtil.java index 14c2eb885f77..d1daac3664c2 100644 --- a/dotCMS/src/main/java/com/dotcms/rendering/velocity/services/PageRenderUtil.java +++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/services/PageRenderUtil.java @@ -356,27 +356,27 @@ private Optional timeMachineDate(final HttpServletRequest request) { return Optional.empty(); } - Optional millis = Optional.empty(); + Optional millis = Optional.empty(); final HttpSession session = request.getSession(false); if (session != null) { - millis = Optional.ofNullable ((String)session.getAttribute(PageResource.TM_DATE)); + millis = Optional.ofNullable (session.getAttribute(PageResource.TM_DATE)); } if (millis.isEmpty()) { - millis = Optional.ofNullable((String)request.getAttribute(PageResource.TM_DATE)); + millis = Optional.ofNullable(request.getAttribute(PageResource.TM_DATE)); } if (millis.isEmpty()) { return Optional.empty(); } - + final Object object = millis.get(); try { - final long milliseconds = Long.parseLong(millis.get()); + final long milliseconds = object instanceof Number ? (Long) object : Long.parseLong(object.toString()); return milliseconds > 0 ? Optional.of(Date.from(Instant.ofEpochMilli(milliseconds))) : Optional.empty(); } catch (NumberFormatException e) { - Logger.error(this, "Invalid timestamp format: " + millis.get(), e); + Logger.error(this, "Invalid timestamp format: " + object, e); return Optional.empty(); } 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 de8d5cb7f97d..a55c6f1162f8 100644 --- a/dotCMS/src/main/java/com/dotcms/vanityurl/filters/VanityUrlRequestWrapper.java +++ b/dotCMS/src/main/java/com/dotcms/vanityurl/filters/VanityUrlRequestWrapper.java @@ -1,22 +1,23 @@ package com.dotcms.vanityurl.filters; -import static com.dotmarketing.filters.Constants.CMS_FILTER_QUERY_STRING_OVERRIDE; -import static com.dotmarketing.filters.Constants.CMS_FILTER_URI_OVERRIDE; - import com.dotcms.vanityurl.model.VanityUrlResult; import com.dotmarketing.util.UtilMethods; import com.google.common.collect.ImmutableMap; +import com.liferay.util.StringPool; +import org.apache.http.NameValuePair; +import org.apache.http.client.utils.URLEncodedUtils; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.Enumeration; import java.util.HashMap; import java.util.List; import java.util.Map; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletRequestWrapper; -import org.apache.http.NameValuePair; -import org.apache.http.client.utils.URLEncodedUtils; +import static com.dotmarketing.filters.Constants.CMS_FILTER_QUERY_STRING_OVERRIDE; +import static com.dotmarketing.filters.Constants.CMS_FILTER_URI_OVERRIDE; /** * The VanityUrlOverrideRequest merges the parameters set in the original request and merges them @@ -37,7 +38,7 @@ public VanityUrlRequestWrapper(final HttpServletRequest request, final VanityUrl final boolean vanityHasQueryString = UtilMethods.isSet(vanityUrlResult.getQueryString()); final StringBuilder params = new StringBuilder(); - params.append(request.getQueryString()); + params.append(UtilMethods.isSet(request.getQueryString()) ? request.getQueryString() : StringPool.BLANK); final Map vanityParams = convertURLParamsStringToMap(vanityUrlResult.getQueryString()); final Map requestParams = convertURLParamsStringToMap(request.getQueryString()); if(vanityHasQueryString){ @@ -46,24 +47,19 @@ public VanityUrlRequestWrapper(final HttpServletRequest request, final VanityUrl 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); + params.append(StringPool.AMPERSAND).append(key).append(StringPool.EQUAL).append(value); } } } this.newQueryString = params.toString(); - - - - // we create a new map here because it merges the - Map tempMap = new HashMap<>(request.getParameterMap()); + // we create a new map here because it merges the + final Map tempMap = new HashMap<>(request.getParameterMap()); if(vanityHasQueryString) { - List additional = URLEncodedUtils.parse(newQueryString, StandardCharsets.UTF_8); - for(NameValuePair nvp : additional) { + final List additional = URLEncodedUtils.parse(newQueryString, StandardCharsets.UTF_8); + for (final NameValuePair nvp : additional) { tempMap.compute(nvp.getName(), (k, v) -> (v == null) ? new String[] {nvp.getValue()} : new String[]{nvp.getValue(),v[0]}); } } - - this.queryParamMap = ImmutableMap.copyOf(tempMap); this.responseCode = vanityUrlResult.getResponseCode(); @@ -71,7 +67,6 @@ public VanityUrlRequestWrapper(final HttpServletRequest request, final VanityUrl request.setAttribute(CMS_FILTER_QUERY_STRING_OVERRIDE, this.newQueryString); this.setAttribute(CMS_FILTER_URI_OVERRIDE, vanityUrlResult.getRewrite()); this.setAttribute(CMS_FILTER_QUERY_STRING_OVERRIDE, this.newQueryString); - } /** diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/htmlpageasset/business/render/HTMLPageAssetRenderedAPIImpl.java b/dotCMS/src/main/java/com/dotmarketing/portlets/htmlpageasset/business/render/HTMLPageAssetRenderedAPIImpl.java index 5301f3ab334c..c5e606d445ef 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/htmlpageasset/business/render/HTMLPageAssetRenderedAPIImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/htmlpageasset/business/render/HTMLPageAssetRenderedAPIImpl.java @@ -367,7 +367,7 @@ private HTMLPageUrl getHtmlPageAsset(final PageContext context, final Host host, throws DotDataException, DotSecurityException { Logger.debug(this, "--HTMLPageAssetRenderedAPIImpl_getHtmlPageAsset--"); - Optional htmlPageUrlOptional = findPageByContext(host, context); + Optional htmlPageUrlOptional = findPageByContext(host, context, request); if (htmlPageUrlOptional.isEmpty()) { Logger.debug(this, "HTMLPageAssetRenderedAPIImpl_getHtmlPageAsset htmlPageUrlOptional is Empty trying to find by URL Map"); @@ -428,17 +428,18 @@ private void checkPagePermission(final PageContext context, final IHTMLPage html * @throws DotSecurityException The User accessing the APIs does not have the required permissions to perform * this action. */ - private Optional findPageByContext(final Host host, final PageContext context) + private Optional findPageByContext(final Host host, final PageContext context, final HttpServletRequest request) throws DotDataException, DotSecurityException { final User user = context.getUser(); - final String uri = context.getPageUri(); final PageMode mode = context.getPageMode(); - final String pageUri = (UUIDUtil.isUUID(uri) ||( uri.length()>0 && '/' == uri.charAt(0))) ? uri : ("/" + uri); - Logger.debug(this, "HTMLPageAssetRenderedAPIImpl_findPageByContext user: " + user + " uri: " + uri + " mode: " + mode + " host: " + host + " pageUri: " + pageUri); - final HTMLPageAsset htmlPageAsset = (HTMLPageAsset) (UUIDUtil.isUUID(pageUri) ? - this.htmlPageAssetAPI.findPage(pageUri, user, mode.respectAnonPerms) : - getPageByUri(mode, host, pageUri)); + String uri = context.getPageUri(); + uri = uri == null ? StringPool.BLANK : uri; + final String pageUriOrInode = (UUIDUtil.isUUID(uri) ||(!uri.isEmpty() && '/' == uri.charAt(0))) ? uri : ("/" + uri); + Logger.debug(this, "HTMLPageAssetRenderedAPIImpl_findPageByContext user: " + user + " uri: " + uri + " mode: " + mode + " host: " + host + " pageUriOrInode: " + pageUriOrInode); + final HTMLPageAsset htmlPageAsset = (HTMLPageAsset) (UUIDUtil.isUUID(pageUriOrInode) ? + this.htmlPageAssetAPI.findPage(pageUriOrInode, user, mode.respectAnonPerms) : + getPageByUri(mode, host, pageUriOrInode, request)); Logger.debug(this, "HTMLPageAssetRenderedAPIImpl_findPageByContext htmlPageAsset: " + (htmlPageAsset == null ? "Not Found" : htmlPageAsset.toString())); return Optional.ofNullable(htmlPageAsset == null ? null : new HTMLPageUrl(htmlPageAsset)); @@ -494,10 +495,9 @@ private Optional findByURLMap( } } - private IHTMLPage getPageByUri(final PageMode mode, final Host host, final String pageUri) + private IHTMLPage getPageByUri(final PageMode mode, final Host host, final String pageUri, final HttpServletRequest request) throws DotDataException, DotSecurityException { - final HttpServletRequest request = HttpServletRequestThreadLocal.INSTANCE.getRequest(); final Language defaultLanguage = this.languageAPI.getDefaultLanguage(); final Language language = this.getCurrentLanguage(request); Logger.debug(this, "HTMLPageAssetRenderedAPIImpl_getPageByUri pageUri: " + pageUri + " host: " + host + " language: " + language + " mode: " + mode); 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 36f3146084b9..26ba7e0d63a7 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 @@ -26,6 +26,14 @@ let variantNameParam = "<%=variantNameParam%>"; let contentletVariantId = "<%=contentlet.getVariantId()%>"; + /* + * Ajax Methods don't wait until the reindex in completed. + * We need to wait for the reindex when we edit in order to reload the page + * Maybe we can avoid this after this is merged: https://github.com/dotCMS/core/pull/30110 + * More info: https://github.com/dotCMS/core/issues/30218 + */ + const AjaxWFReindexDelay = 500; + // If the contentlet variantName is not default, it doesn't matter if we are in a variant or not, // we use the contentlet variantName to keep the consistency in the actions (false && short-circuit) @@ -65,12 +73,6 @@ } } - - - - - - var myForm = document.getElementById('fm'); var copyAsset = false; @@ -106,9 +108,6 @@ } }; dojo.xhrGet(xhrArgs); - - - } } function selectVersion(objId) { @@ -169,9 +168,6 @@ } } - - - //Structure change function structureSelected() { @@ -233,14 +229,10 @@ return loc; } - - function addTab(tabid){ tabsArray.push(tabid); } - - function submitParent(param) { if (copyAsset) { disableButtons(myForm); @@ -270,15 +262,12 @@ } } - - <% if(Config.getIntProperty("CONTENT_AUTOSAVE_INTERVAL",0) > 0){%> // http://jira.dotmarketing.net/browse/DOTCMS-2273 var autoSaveInterval = <%= Config.getIntProperty("CONTENT_AUTOSAVE_INTERVAL",0) %>; setInterval("saveContent(true)",autoSaveInterval); <%}%> - function getFormData(formId,nameValueSeparator){ // Returns form data as name value pairs with nameValueSeparator. var formData = new Array(); @@ -349,10 +338,8 @@ } // Categories selected in the Category Dialog - var catCount = <%=UtilMethods.isSet(catCount)?Integer.parseInt(catCount):0 %>; - for(var i=1; i 0) ? currentContentletInode @@ -940,16 +910,22 @@ // END: PUSH PUBLISHING ACTIONLET saveContent(false); - } var contentAdmin = new dotcms.dijit.contentlet.ContentAdmin('<%= contentlet.getIdentifier() %>','<%= contentlet.getInode() %>','<%= contentlet.getLanguageId() %>'); - function makeEditable(contentletInode){ + function dispatchCustomEvent(detail) { + setTimeout(() => { + var customEvent = document.createEvent('CustomEvent'); + customEvent.initCustomEvent('ng-event', false, false, detail); + document.dispatchEvent(customEvent); + }, AjaxWFReindexDelay); + } + + function makeEditable(contentletInode){ ContentletAjax.lockContent(contentletInode, checkoutContentletCallback); dojo.empty("contentletActionsHanger"); dojo.byId("contentletActionsHanger").innerHTML="
"; - } function checkoutContentletCallback(data){ @@ -959,8 +935,11 @@ } + const eventData = { + name: 'update-workflow-action' + }; + dispatchCustomEvent(eventData) refreshActionPanel(data["lockedIdent"]); - } @@ -975,23 +954,24 @@ return; } + const eventData = { + name: 'update-workflow-action' + }; + dispatchCustomEvent(eventData) refreshActionPanel(data["lockedIdent"]); - } - - function unlockContent(contentletInode){ - window.onbeforeunload=true; + const eventData = { + name: 'update-workflow-action' + }; + dispatchCustomEvent(eventData) ContentletAjax.unlockContent(contentletInode, unlockContentCallback); - //dojo.empty("contentletActionsHanger"); - //dojo.byId("contentletActionsHanger").innerHTML="
"; - } @@ -1003,8 +983,6 @@ } refreshActionPanel(data["lockedIdent"]); - - } @@ -1065,6 +1043,4 @@ } } - - 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 dc9bb933b5aa..a61f64e7fd1d 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 @@ -94,6 +94,7 @@ import com.rainerhahnekamp.sneakythrow.Sneaky; import com.tngtech.java.junit.dataprovider.DataProvider; import com.tngtech.java.junit.dataprovider.UseDataProvider; +import io.vavr.control.Try; import org.apache.http.HttpStatus; import org.elasticsearch.action.admin.cluster.settings.ClusterUpdateSettingsRequest; import org.elasticsearch.action.support.master.AcknowledgedResponse; @@ -4528,4 +4529,56 @@ public void cleanUpExtraTableAfterDeleteContentType() throws DotDataException, D ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); } } + + /** + * Method to test: {@link ContentletAPI#checkin(Contentlet, User, boolean)} } + * When: try to save a {@link Contentlet} that don't have the Host set already + * Should: populate the Host for the {@link Contentlet} (in this case using the {@link ContentType}'s Host) + * and save the siteId in the supporting_values Json field in the unique_fields table + * + * @throws DotDataException + * @throws DotSecurityException + */ + @Test + public void saveSiteIDRightInSupportingValues() throws DotDataException, DotSecurityException { + final boolean oldEnabledDataBaseValidation = ESContentletAPIImpl.getFeatureFlagDbUniqueFieldValidation(); + + try { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(true); + final Host host = new SiteDataGen().nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .host(host) + .nextPersisted(); + + final Language language = new LanguageDataGen().nextPersisted(); + + final Field uniqueTextField = new FieldDataGen() + .contentTypeId(contentType.id()) + .unique(true) + .type(TextField.class) + .nextPersisted(); + + final Contentlet contentlet = new Contentlet(); + contentlet.setLanguageId(language.getId()); + contentlet.setBoolProperty(Contentlet.IS_TEST_MODE, true); + contentlet.setContentTypeId(Try.of(()->APILocator.getContentTypeAPI(APILocator.systemUser()) + .find(contentType.id()).id()).getOrNull()); + contentlet.setProperty(uniqueTextField.variable(), "unique-value"); + + contentlet.setIndexPolicy(IndexPolicy.FORCE); + contentlet.setBoolProperty(Contentlet.DISABLE_WORKFLOW, true); + contentletAPI.checkin(contentlet, user, false); + + final List> results = new DotConnect().setSQL("SELECT * FROM unique_fields WHERE supporting_values->>'" + CONTENT_TYPE_ID_ATTR + "' = ?") + .addParam(contentType.id()) + .loadObjectResults(); + assertEquals(1, results.size()); + + final Map supportingValue = getSupportingValue(results.get(0)); + assertEquals(host.getIdentifier(), supportingValue.get(SITE_ID_ATTR)); + } finally { + ESContentletAPIImpl.setFeatureFlagDbUniqueFieldValidation(oldEnabledDataBaseValidation); + } + } } diff --git a/e2e/dotcms-e2e-node/frontend/.env b/e2e/dotcms-e2e-node/frontend/.env index 7da71e2d3fa8..7e3e1cd00aa6 100644 --- a/e2e/dotcms-e2e-node/frontend/.env +++ b/e2e/dotcms-e2e-node/frontend/.env @@ -2,7 +2,7 @@ CI=false DEV=false BASE_URL=http://localhost:8080 HEADLESS=false -RETRIES=1 +RETRIES=0 WORKERS=1 REUSE_SERVER=false INCLUDE_HTML=true diff --git a/e2e/dotcms-e2e-node/frontend/locators/globalLocators.ts b/e2e/dotcms-e2e-node/frontend/locators/globalLocators.ts index ef759cb80f46..e613d4d2e616 100644 --- a/e2e/dotcms-e2e-node/frontend/locators/globalLocators.ts +++ b/e2e/dotcms-e2e-node/frontend/locators/globalLocators.ts @@ -28,7 +28,7 @@ export const addContent = { /** * Locators for the Rich Text functionality. */ -export const richText = { +export const contentGeneric = { locator: "articleContent (Generic)", label: "Content (Generic)" } diff --git a/e2e/dotcms-e2e-node/frontend/tests/contentSearch/contentData.ts b/e2e/dotcms-e2e-node/frontend/tests/contentSearch/contentData.ts index f57860e32692..e7a44c8b7db4 100644 --- a/e2e/dotcms-e2e-node/frontend/tests/contentSearch/contentData.ts +++ b/e2e/dotcms-e2e-node/frontend/tests/contentSearch/contentData.ts @@ -1,9 +1,11 @@ /** * Content to add a Rich Text content */ -export const richTextContent = { +export const genericContent1 = { title: "Automation Test", - body: "This is a sample content" + body: "This is a sample content", + newTitle : "Automation Test edited", + newBody : "This is a sample content edited" } /** @@ -11,7 +13,11 @@ export const richTextContent = { */ export const contentProperties = { language: "English (US)", - publishWfAction: "Publish" + publishWfAction: "Publish", + unpublishWfAction: "Unpublish", + unlockWfAction: "Unlock", + archiveWfAction: "Archive", + deleteWfAction: "Delete" } diff --git a/e2e/dotcms-e2e-node/frontend/tests/contentSearch/contentEditing.spec.ts b/e2e/dotcms-e2e-node/frontend/tests/contentSearch/contentEditing.spec.ts new file mode 100644 index 000000000000..e98b53a8e79e --- /dev/null +++ b/e2e/dotcms-e2e-node/frontend/tests/contentSearch/contentEditing.spec.ts @@ -0,0 +1,77 @@ +import {expect, test} from '@playwright/test'; +import {dotCMSUtils, waitForVisibleAndCallback} from '../../utils/dotCMSUtils'; +import { + GroupEntriesLocators, + MenuEntriesLocators, + ToolEntriesLocators +} from '../../locators/navigation/menuLocators'; +import {ContentUtils} from "../../utils/contentUtils"; +import {iFramesLocators, contentGeneric} from "../../locators/globalLocators"; +import {genericContent1, contentProperties} from "./contentData"; +import {assert} from "console"; + +const cmsUtils = new dotCMSUtils(); + +/** + * Test to navigate to the content portlet and login to the dotCMS instance + * @param page + */ +test.beforeEach('Navigate to content portlet', async ({page}) => { + // Instance the menu Navigation locators + const menuLocators = new MenuEntriesLocators(page); + const groupsLocators = new GroupEntriesLocators(page); + const toolsLocators = new ToolEntriesLocators(page); + + // Get the username and password from the environment variables + const username = process.env.USERNAME as string; + const password = process.env.PASSWORD as string; + + // Login to dotCMS + await cmsUtils.login(page, username, password); + await cmsUtils.navigate(menuLocators.EXPAND, groupsLocators.CONTENT, toolsLocators.SEARCH_ALL); + + // Validate the portlet title + const breadcrumbLocator = page.locator('p-breadcrumb'); + await waitForVisibleAndCallback(breadcrumbLocator, () => expect(breadcrumbLocator).toContainText('Search All')); +}); + + +test('Add a new pice of content', async ({page}) => { + const contentUtils = new ContentUtils(page); + const iframe = page.frameLocator(iFramesLocators.main_iframe); + + // Adding new rich text content + await contentUtils.addNewContentAction(page, contentGeneric.locator, contentGeneric.label); + await contentUtils.fillRichTextForm(page, genericContent1.title, genericContent1.body, contentProperties.publishWfAction); + await waitForVisibleAndCallback(iframe.locator('#results_table tbody tr').first(), async () => {}); + + await contentUtils.validateContentExist(page, genericContent1.title).then(assert); +}); + +/** + * Test to edit an existing piece of content + */ +test('Edit a piece of content', async ({page}) => { + const contentUtils = new ContentUtils(page); + const iframe = page.frameLocator(iFramesLocators.main_iframe); + + // Edit the content + await contentUtils.editContent(page, genericContent1.title, genericContent1.newTitle, genericContent1.newBody, contentProperties.publishWfAction); + await waitForVisibleAndCallback(iframe.locator('#results_table tbody tr').first(), async () => {}); + + await contentUtils.validateContentExist(page, genericContent1.newTitle).then(assert); +}); + + +/** + * Test to delete an existing piece of content + */ +test('Delete a piece of content', async ({ page }) => { + const contentUtils = new ContentUtils(page); + // Delete the content + await contentUtils.deleteContent(page, genericContent1.newTitle); + } +); + + + diff --git a/e2e/dotcms-e2e-node/frontend/tests/contentSearch/portletIntegrity.spec.ts b/e2e/dotcms-e2e-node/frontend/tests/contentSearch/portletIntegrity.spec.ts index 25861a57fffa..876ec9b7cf0c 100644 --- a/e2e/dotcms-e2e-node/frontend/tests/contentSearch/portletIntegrity.spec.ts +++ b/e2e/dotcms-e2e-node/frontend/tests/contentSearch/portletIntegrity.spec.ts @@ -1,13 +1,13 @@ import {expect, test} from '@playwright/test'; import {dotCMSUtils, waitForVisibleAndCallback} from '../../utils/dotCMSUtils'; import {ContentUtils} from '../../utils/contentUtils'; -import {addContent, iFramesLocators, richText} from '../../locators/globalLocators'; +import {addContent, iFramesLocators, contentGeneric} from '../../locators/globalLocators'; import { GroupEntriesLocators, MenuEntriesLocators, ToolEntriesLocators } from '../../locators/navigation/menuLocators'; -import {contentProperties, richTextContent} from './contentData'; +import {contentProperties, genericContent1} from './contentData'; const cmsUtils = new dotCMSUtils(); @@ -32,8 +32,6 @@ test.beforeEach('Navigate to content portlet', async ({page}) => { // Validate the portlet title const breadcrumbLocator = page.locator('p-breadcrumb'); await waitForVisibleAndCallback(breadcrumbLocator, () => expect(breadcrumbLocator).toContainText('Search All')); - - await expect(page.locator('p-breadcrumb')).toContainText('Search All'); }); @@ -55,16 +53,16 @@ test('Search filter', async ({page}) => { const iframe = page.frameLocator(iFramesLocators.main_iframe); // Adding new rich text content - await contentUtils.addNewContentAction(page, richText.locator, richText.label); - await contentUtils.fillRichTextForm(page, richTextContent.title, richTextContent.body, contentProperties.publishWfAction); + await contentUtils.addNewContentAction(page, contentGeneric.locator, contentGeneric.label); + await contentUtils.fillRichTextForm(page, genericContent1.title, genericContent1.body, contentProperties.publishWfAction); // Validate the content has been created - await expect.soft(iframe.getByRole('link', {name: 'Automation Test'}).first()).toBeVisible(); - await iframe.locator('#allFieldTB').fill(richTextContent.title); + await expect.soft(iframe.getByRole('link', {name: genericContent1.title}).first()).toBeVisible(); + await iframe.locator('#allFieldTB').fill(genericContent1.title); await page.keyboard.press('Enter'); //validate the search filter is working - await expect(iframe.getByRole('link', {name: 'Automation Test'}).first()).toBeVisible(); + await expect(iframe.getByRole('link', {name: genericContent1.title}).first()).toBeVisible(); }); /** diff --git a/e2e/dotcms-e2e-node/frontend/utils/contentUtils.ts b/e2e/dotcms-e2e-node/frontend/utils/contentUtils.ts index b0e6d154f25f..267f9e4ad408 100644 --- a/e2e/dotcms-e2e-node/frontend/utils/contentUtils.ts +++ b/e2e/dotcms-e2e-node/frontend/utils/contentUtils.ts @@ -1,16 +1,18 @@ -import {Page, expect, FrameLocator} from '@playwright/test'; -import { iFramesLocators, richText } from '../locators/globalLocators'; -import { waitForVisibleAndCallback} from './dotCMSUtils'; +import {expect, FrameLocator, Locator, Page} from '@playwright/test'; +import {contentGeneric, iFramesLocators} from '../locators/globalLocators'; +import {waitForVisibleAndCallback} from './dotCMSUtils'; +import {contentProperties} from "../tests/contentSearch/contentData"; export class ContentUtils { page: Page; + constructor(page: Page) { - this.page = page; + this.page = page; } /** * Fill the rich text form - * @param page + * @param page * @param title * @param body * @param action @@ -19,7 +21,7 @@ export class ContentUtils { const dotIframe = page.frameLocator(iFramesLocators.dot_iframe); const headingLocator = page.getByRole('heading'); - await waitForVisibleAndCallback(headingLocator, () => expect.soft(headingLocator).toContainText(richText.label)); + await waitForVisibleAndCallback(headingLocator, () => expect.soft(headingLocator).toContainText(contentGeneric.label)); //Fill title await dotIframe.locator('#title').fill(title); @@ -28,10 +30,10 @@ export class ContentUtils { //await dotIframe.locator(iFramesLocators.wysiwygFrame).contentFrame().locator('#tinymce').fill(body); //Click on action - await dotIframe.getByText(action).click(); + await dotIframe.getByText(action).first().click(); //Wait for the content to be saved - await expect(dotIframe.getByText('Content saved')).toBeVisible({ timeout: 9000 }); + await expect(dotIframe.getByText('Content saved')).toBeVisible({timeout: 9000}); await expect(dotIframe.getByText('Content saved')).toBeHidden(); //Click on close const closeBtnLocator = page.getByTestId('close-button').getByRole('button'); @@ -82,17 +84,147 @@ export class ContentUtils { * Show query on the content portlet * @param iframe */ - async showQuery(iframe : FrameLocator) { - const createOptionsBtnLocator = iframe.getByRole('button', { name: 'createOptions' }); + async showQuery(iframe: FrameLocator) { + const createOptionsBtnLocator = iframe.getByRole('button', {name: 'createOptions'}); await waitForVisibleAndCallback(createOptionsBtnLocator, () => createOptionsBtnLocator.click()); //Validate the search button has a sub-menu - await expect (iframe.getByLabel('Search ▼').getByText('Search')).toBeVisible(); - await expect (iframe.getByText('Show Query')).toBeVisible(); + await expect(iframe.getByLabel('Search ▼').getByText('Search')).toBeVisible(); + await expect(iframe.getByText('Show Query')).toBeVisible(); // Click on show query await iframe.getByText('Show Query').click(); } + + /** + * Validate if the content exists in the results table on the content portlet + * @param page + * @param title + */ + async validateContentExist(page: Page, title: string) { + const iframe = page.frameLocator(iFramesLocators.main_iframe); + + await iframe.locator('#results_table tbody tr').first().waitFor({ state: 'visible' }); + const secondCell = iframe.locator('#results_table tbody tr:nth-of-type(2) td:nth-of-type(2)'); + const hasAutomationLink = await secondCell.locator(`a:has-text("${title}")`).count() > 0; + + console.log(`The content with the title ${title} ${hasAutomationLink ? 'exists' : 'does not exist'}`); + return hasAutomationLink; + } + + /** + * Get the content element from the results table on the content portlet + * @param page + * @param title + */ + async getContentElement(page: Page, title: string): Promise { + const iframe = page.frameLocator(iFramesLocators.main_iframe); + + await iframe.locator('#results_table tbody tr').first().waitFor({ state: 'visible' }); + const secondCell = iframe.locator('#results_table tbody tr:nth-of-type(2) td:nth-of-type(2)'); + const element = secondCell.locator(`a:has-text("${title}")`); + + const elementCount = await element.count(); + if (elementCount > 0) { + return element.first(); + } else { + console.log(`The content with the title ${title} does not exist`); + return null; + } + } + + /** + * Edit content on the content portlet + * @param page + * @param title + * @param newTitle + * @param newBody + * @param action + */ + async editContent(page: Page, title: string, newTitle: string, newBody: string, action: string) { + const iframe = page.frameLocator(iFramesLocators.main_iframe); + const contentElement = await this.getContentElement(page, title); + if (contentElement) { + await contentElement.click(); + }else { + console.log('Content not found'); + return; + } + await this.fillRichTextForm(page, newTitle, newBody, action); + } + + /** + * Delete content on the content portlet + * @param page + * @param title + */ + async deleteContent(page: Page, title: string) { + const iframe = page.frameLocator(iFramesLocators.main_iframe); + + while (await this.getContentState(page, title) !== null) { + const contentState = await this.getContentState(page, title); + + if (contentState === 'published') { + await this.performWorkflowAction(page, title, contentProperties.unpublishWfAction); + } else if (contentState === 'draft') { + await this.performWorkflowAction(page, title, contentProperties.archiveWfAction); + await iframe.getByRole('link', { name: 'Advanced' }).click(); + await iframe.locator('#widget_showingSelect div').first().click(); + const dropDownMenu = iframe.getByRole('option', { name: 'Archived' }); + await waitForVisibleAndCallback(dropDownMenu, () => dropDownMenu.click()); + await page.waitForTimeout(1000) + } else if (contentState === 'archived') { + await this.performWorkflowAction(page, title, contentProperties.deleteWfAction); + return; + } + + await page.waitForLoadState(); + } + } + + /** + * Perform workflow action for some specific content + * @param page + * @param title + * @param action + */ + async performWorkflowAction(page: Page, title: string, action: string) { + const iframe = page.frameLocator(iFramesLocators.main_iframe); + const contentElement = await this.getContentElement(page, title); + if (contentElement) { + await contentElement.click({ + button: 'right' + }); + } + const actionBtnLocator = iframe.getByRole('menuitem', { name: action }); + await waitForVisibleAndCallback(actionBtnLocator, () => actionBtnLocator.getByText(action).click()); + await expect.soft(iframe.getByText('Workflow executed')).toBeVisible(); + await expect.soft(iframe.getByText('Workflow executed')).toBeHidden(); + } + + async getContentState(page: Page, title: string): Promise { + const iframe = page.frameLocator(iFramesLocators.main_iframe); + await iframe.locator('#results_table tbody tr').first().waitFor({ state: 'visible' }); + + const titleCell = iframe.locator('#results_table tbody tr:nth-of-type(2) td:nth-of-type(2)'); + const element = titleCell.locator(`a:has-text("${title}")`); + const elementCount = await element.count(); + if (elementCount > 0) { + const stateColumn = iframe.locator('#results_table tbody tr:nth-of-type(2) td:nth-of-type(3)'); + const targetDiv = stateColumn.locator('div#icon'); + return await targetDiv.getAttribute('class'); + } else { + console.log('Content not found'); + return null; + } + } + + } + + + + + diff --git a/e2e/dotcms-e2e-node/frontend/utils/dotCMSUtils.ts b/e2e/dotcms-e2e-node/frontend/utils/dotCMSUtils.ts index 6138a5702d55..d84c1bdb69dc 100644 --- a/e2e/dotcms-e2e-node/frontend/utils/dotCMSUtils.ts +++ b/e2e/dotcms-e2e-node/frontend/utils/dotCMSUtils.ts @@ -1,19 +1,6 @@ import { Page, expect, Locator } from '@playwright/test'; import { loginLocators } from '../locators/globalLocators'; -export const waitFor = async (locator: Locator, state: "attached" | "detached" | "visible" | "hidden"): Promise => { - await locator.waitFor({state: state}); -} - -export const waitForAndCallback = async (locator: Locator, state: "attached" | "detached" | "visible" | "hidden", callback: () => Promise): Promise => { - await waitFor(locator, state); - await callback(); -}; - -export const waitForVisibleAndCallback = async (locator: Locator, callback: () => Promise): Promise => { - await waitForAndCallback(locator, 'visible', callback); -}; - export class dotCMSUtils { page: Page; @@ -45,4 +32,18 @@ export class dotCMSUtils { await group.click(); await tool.click(); } +}; + + +export const waitFor = async (locator: Locator, state: "attached" | "detached" | "visible" | "hidden"): Promise => { + await locator.waitFor({state: state}); +} + +export const waitForAndCallback = async (locator: Locator, state: "attached" | "detached" | "visible" | "hidden", callback: () => Promise): Promise => { + await waitFor(locator, state); + await callback(); +}; + +export const waitForVisibleAndCallback = async (locator: Locator, callback: () => Promise): Promise => { + await waitForAndCallback(locator, 'visible', callback); }; \ No newline at end of file diff --git a/src/test/java/com/dotcms/mock/request/MockParameterRequestTest.java b/src/test/java/com/dotcms/mock/request/MockParameterRequestTest.java new file mode 100644 index 000000000000..e0f54ec1699c --- /dev/null +++ b/src/test/java/com/dotcms/mock/request/MockParameterRequestTest.java @@ -0,0 +1,103 @@ +package com.dotcms.mock.request; + +import java.util.HashMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import javax.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.Enumeration; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class MockParameterRequestTest { + + + + HttpServletRequest getMockRequest() { + return Mockito.mock(HttpServletRequest.class); + } + @Test + void getParameter_returnsCorrectValue() { + + HttpServletRequest mockRequest = getMockRequest(); + + Mockito.when(mockRequest.getQueryString()).thenReturn("param2=value2"); + + MockParameterRequest mockParameterRequest = new MockParameterRequest(mockRequest); + + assertEquals("value2", mockParameterRequest.getParameter("param2")); + } + + @Test + void getParameter_returnsNullForNonExistentParameter() { + HttpServletRequest mockRequest = getMockRequest(); + Mockito.when(mockRequest.getQueryString()).thenReturn(""); + + MockParameterRequest mockParameterRequest = new MockParameterRequest(mockRequest); + + assertNull(mockParameterRequest.getParameter("nonExistentParam")); + } + + @Test + void getParameterNames_returnsAllParameterNames() { + HttpServletRequest mockRequest = getMockRequest(); + + Mockito.when(mockRequest.getQueryString()).thenReturn("param1=value1"); + + MockParameterRequest mockParameterRequest = new MockParameterRequest(mockRequest); + + Enumeration parameterNames = mockParameterRequest.getParameterNames(); + assertEquals(true, parameterNames.hasMoreElements()); + assertEquals("param1", parameterNames.nextElement()); + assertEquals(false, parameterNames.hasMoreElements()); + } + + @Test + void getParameterMap_returnsCorrectParameterMap() { + HttpServletRequest mockRequest = getMockRequest(); + Mockito.when(mockRequest.getParameterNames()).thenReturn(Collections.enumeration(Collections.singleton("param1"))); + Mockito.when(mockRequest.getParameter("param1")).thenReturn("value1"); + Mockito.when(mockRequest.getQueryString()).thenReturn("param1=value1"); + + MockParameterRequest mockParameterRequest = new MockParameterRequest(mockRequest); + + Map parameterMap = mockParameterRequest.getParameterMap(); + assertEquals(1, parameterMap.size()); + assertEquals("value1", parameterMap.get("param1")[0]); + } + + @Test + void getParameterMap_handlesEmptyQueryString() { + HttpServletRequest mockRequest = getMockRequest(); + Mockito.when(mockRequest.getQueryString()).thenReturn(""); + + MockParameterRequest mockParameterRequest = new MockParameterRequest(mockRequest); + + Map parameterMap = mockParameterRequest.getParameterMap(); + assertEquals(0, parameterMap.size()); + } + + + + @Test + void test_when_null_paramter_is_passed_in() { + + HttpServletRequest mockRequest = getMockRequest(); + + Mockito.when(mockRequest.getQueryString()).thenReturn("param2=value2"); + Map badMap = new HashMap<>(); + + badMap.put("badParam", null); + MockParameterRequest mockParameterRequest = new MockParameterRequest(mockRequest, badMap); + + assertEquals("value2", mockParameterRequest.getParameter("param2")); + assertNull(mockParameterRequest.getParameter("badParam")); + } + + + +} diff --git a/test-karate/src/test/java/KarateCITests.java b/test-karate/src/test/java/KarateCITests.java index a2a990e0fe84..07152e558a97 100644 --- a/test-karate/src/test/java/KarateCITests.java +++ b/test-karate/src/test/java/KarateCITests.java @@ -9,7 +9,10 @@ public class KarateCITests { @Test void defaults() { - Results results = Runner.path("classpath:tests/defaults").tags("~@ignore") + Results results = Runner.path( + "classpath:tests/defaults", + "classpath:tests/graphql/ftm" + ).tags("~@ignore") .outputHtmlReport(true) .outputJunitXml(true) .outputCucumberJson(true) diff --git a/test-karate/src/test/java/graphql/ftm/helpers.feature b/test-karate/src/test/java/graphql/ftm/helpers.feature new file mode 100644 index 000000000000..c33701d4ed6f --- /dev/null +++ b/test-karate/src/test/java/graphql/ftm/helpers.feature @@ -0,0 +1,132 @@ +Feature: Reusable Functions and Helpers + + Scenario: Define reusable functions + + ## General error free validation + * def validateNoErrors = + """ + function (response) { + const errors = response.errors; + if (errors) { + return errors; + } + return []; + } + """ + + ## Builds a payload for creating a new content version + * def buildContentRequestPayload = + """ + function(contentType, title, publishDate, expiresOn, identifier) { + let payload = { + "contentlets": [ + { + "contentType": contentType, + "title": title, + "host":"8a7d5e23-da1e-420a-b4f0-471e7da8ea2d" + } + ] + }; + if (publishDate) payload.contentlets[0].publishDate = publishDate; + if (expiresOn) payload.contentlets[0].expiresOn = expiresOn; + if (identifier) payload.contentlets[0].identifier = identifier; + return payload; + } + """ + ## Extracts all errors from a response + * def extractErrors = + """ + function(response) { + let errors = []; + let results = response.entity.results; + if (results && results.length > 0) { + for (let i = 0; i < results.length; i++) { + let result = results[i]; + // Handle both nested error messages and direct error messages + for (let key in result) { + if (result[key] && result[key].errorMessage) { + errors.push(result[key].errorMessage); + } + } + } + } + return errors; + } + """ + + ## Extracts all contentlets from a response + * def extractContentlets = + """ + function(response) { + let containers = response.entity.containers; + let allContentlets = []; + for (let key in containers) { + if (containers[key].contentlets) { + for (let contentletKey in containers[key].contentlets) { + allContentlets = allContentlets.concat(containers[key].contentlets[contentletKey]); + } + } + } + return allContentlets; + } + """ + + ## Generates a random suffix for test data + * def testSuffix = + """ + function() { + if (!karate.get('testSuffix')) { + let prefix = '__' + Math.floor(Math.random() * 100000); + karate.set('testSuffix', prefix); + } + return karate.get('testSuffix'); + } + """ + + ## Extracts a specific object from a JSON array by UUID + * def getContentletByUUID = + """ + function(jsonArray, uuid) { + for (let i = 0; i < jsonArray.length; i++) { + let keys = Object.keys(jsonArray[i]); + if (keys.includes(uuid)) { + return jsonArray[i][uuid]; + } + } + return null; // Return null if not found + } + """ + + ## Builds a payload for creating a new GraphQL request + * def buildGraphQLRequestPayload = + """ + function(pageUri, publishDate) { + if (!pageUri.startsWith('/')) { + pageUri = '/' + pageUri; + } + var query = 'query Page { page(url: "' + pageUri + '"'; + if (publishDate) { + query += ' publishDate: "' + publishDate + '"'; + } + query += ') { containers { containerContentlets { contentlets { title } } } } }'; + return { query: query }; + } + """ + + ## Extracts all contentlet titles from a GraphQL response + * def contentletsFromGraphQlResponse = + """ + function(response) { + let containers = response.data.page.containers; + let allTitles = []; + containers.forEach(container => { + container.containerContentlets.forEach(cc => { + cc.contentlets.forEach(contentlet => { + allTitles.push(contentlet.title); + }); + }); + }); + return allTitles; + } + """ + ## \ No newline at end of file diff --git a/test-karate/src/test/java/graphql/ftm/newContainer.feature b/test-karate/src/test/java/graphql/ftm/newContainer.feature new file mode 100644 index 000000000000..b52636cb9147 --- /dev/null +++ b/test-karate/src/test/java/graphql/ftm/newContainer.feature @@ -0,0 +1,24 @@ +Feature: Create a Container + Background: + * def containerNameVariable = 'MyContainer' + Math.floor(Math.random() * 100000) + + Scenario: Create a content type and expect 200 OK + Given url baseUrl + '/api/v1/containers' + And headers commonHeaders + And request + """ + { + "title":"#(containerNameVariable)", + "friendlyName":"My test container.", + "maxContentlets":10, + "notes":"Notes", + "containerStructures":[ + { + "structureId":"#(contentTypeId)", + "code":"$!{dotContentMap.title}" + } + ] + } + """ + When method POST + Then status 200 diff --git a/test-karate/src/test/java/graphql/ftm/newContent.feature b/test-karate/src/test/java/graphql/ftm/newContent.feature new file mode 100644 index 000000000000..64affd0890be --- /dev/null +++ b/test-karate/src/test/java/graphql/ftm/newContent.feature @@ -0,0 +1,21 @@ +Feature: Create an instance of a new Content Type and expect 200 OK +Background: + + Scenario: Create an instance of a new Content Type and expect 200 OK + + # Params are expected as arguments to the feature file + * def contentTypeId = __arg.contentTypeId + * def title = __arg.title + * def publishDate = __arg.publishDate + * def expiresOn = __arg.expiresOn + + Given url baseUrl + '/api/v1/workflow/actions/default/fire/PUBLISH?indexPolicy=WAIT_FOR' + And headers commonHeaders + + * def requestPayload = buildContentRequestPayload (contentTypeId, title, publishDate, expiresOn) + And request requestPayload + + When method POST + Then status 200 + * def errors = call extractErrors response + * match errors == [] \ No newline at end of file diff --git a/test-karate/src/test/java/graphql/ftm/newContentType.feature b/test-karate/src/test/java/graphql/ftm/newContentType.feature new file mode 100644 index 000000000000..cba1f9b210ff --- /dev/null +++ b/test-karate/src/test/java/graphql/ftm/newContentType.feature @@ -0,0 +1,118 @@ +Feature: Create a Content Type + Background: + * def contentTypeVariable = 'MyContentType' + Math.floor(Math.random() * 100000) + + Scenario: Create a content type and expect 200 OK + Given url baseUrl + '/api/v1/contenttype' + And headers commonHeaders + And request + """ + { + "baseType":"CONTENT", + "clazz":"com.dotcms.contenttype.model.type.ImmutableSimpleContentType", + "defaultType":false, + "fields":[ + { + "clazz":"com.dotcms.contenttype.model.field.ImmutableTextField", + "dataType":"TEXT", + "fieldType":"Text", + "fieldTypeLabel":"Text", + "fieldVariables":[ + + ], + "fixed":false, + "forceIncludeInApi":false, + "indexed":true, + "listed":false, + "name":"title", + "readOnly":false, + "required":true, + "searchable":true, + "sortOrder":2, + "unique":false, + "variable":"title" + }, + { + "clazz":"com.dotcms.contenttype.model.field.ImmutableDateTimeField", + "dataType":"DATE", + "fieldType":"Date-and-Time", + "fieldTypeLabel":"Date and Time", + "fieldVariables":[ + + ], + "fixed":false, + "forceIncludeInApi":false, + "indexed":true, + "listed":false, + "name":"publishDate", + "readOnly":false, + "required":false, + "searchable":true, + "sortOrder":3, + "unique":false, + "variable":"publishDate" + }, + { + "clazz":"com.dotcms.contenttype.model.field.ImmutableDateTimeField", + "dataType":"DATE", + "fieldType":"Date-and-Time", + "fieldTypeLabel":"Date and Time", + "fieldVariables":[ + + ], + "fixed":false, + "forceIncludeInApi":false, + "indexed":true, + "listed":false, + "name":"expiresOn", + "readOnly":false, + "required":false, + "searchable":true, + "sortOrder":4, + "unique":false, + "variable":"expiresOn" + }, + { + "clazz":"com.dotcms.contenttype.model.field.ImmutableTagField", + "dataType":"SYSTEM", + "fieldType":"Tag", + "fieldTypeLabel":"Tag", + "fieldVariables":[ + + ], + "fixed":false, + "forceIncludeInApi":false, + "indexed":true, + "listed":false, + "name":"tags", + "readOnly":false, + "required":false, + "searchable":false, + "sortOrder":5, + "unique":false, + "variable":"tags" + } + ], + "fixed":false, + "folder":"SYSTEM_FOLDER", + "folderPath":"/", + "host":"8a7d5e23-da1e-420a-b4f0-471e7da8ea2d", + "icon":"adjust", + "multilingualable":false, + "name":"#(contentTypeVariable)", + "publishDateVar":"publishDate", + "expireDateVar":"expiresOn", + "sortOrder":0, + "system":false, + "variable":"#(contentTypeVariable)", + "versionable":true, + "workflows" : [ { + "id" : "d61a59e1-a49c-46f2-a929-db2b4bfa88b2", + "variableName" : "SystemWorkflow" + } ] + } + """ + When method POST + Then status 200 + And match response.entity[0].id != null + And match response.entity[0].variable == contentTypeVariable \ No newline at end of file diff --git a/test-karate/src/test/java/graphql/ftm/newContentVersion.feature b/test-karate/src/test/java/graphql/ftm/newContentVersion.feature new file mode 100644 index 000000000000..e6594519efc1 --- /dev/null +++ b/test-karate/src/test/java/graphql/ftm/newContentVersion.feature @@ -0,0 +1,18 @@ +Feature: Create a new version of a piece of content + Scenario: Create a new version of a piece of content + + # Params are expected as arguments to the feature file + * def identifier = __arg.identifier + * def contentTypeId = __arg.contentTypeId + * def title = __arg.title + * def publishDate = __arg.publishDate + * def expiresOn = __arg.expiresOn + + Given url baseUrl + '/api/v1/workflow/actions/default/fire/PUBLISH?identifier='+identifier+'&indexPolicy=WAIT_FOR' + And headers commonHeaders + * def requestPayload = buildContentRequestPayload (contentTypeId, title, publishDate, expiresOn, identifier) + And request requestPayload + When method POST + Then status 200 + * def errors = call extractErrors response + * match errors == [] diff --git a/test-karate/src/test/java/graphql/ftm/newPage.feature b/test-karate/src/test/java/graphql/ftm/newPage.feature new file mode 100644 index 000000000000..5d5f7b52ac94 --- /dev/null +++ b/test-karate/src/test/java/graphql/ftm/newPage.feature @@ -0,0 +1,24 @@ + Feature: Create a Page + Scenario: Create a new version of a piece of content + Given url baseUrl + '/api/v1/workflow/actions/default/fire/PUBLISH?indexPolicy=WAIT_FOR' + And headers commonHeaders + And request + """ + { + "contentlet" : { + "title" : "#(title)", + "url": "#(pageUrl)", + "languageId" : 1, + "stInode": "c541abb1-69b3-4bc5-8430-5e09e5239cc8", + "template": "#(templateId)", + "friendlyName": "#(title)", + "hostFolder": "8a7d5e23-da1e-420a-b4f0-471e7da8ea2d", + "cachettl": 0, + "sortOrder": 0 + } + } + """ + When method POST + Then status 200 + * def errors = call extractErrors response + * match errors == [] \ No newline at end of file diff --git a/test-karate/src/test/java/graphql/ftm/newTemplate.feature b/test-karate/src/test/java/graphql/ftm/newTemplate.feature new file mode 100644 index 000000000000..652ee787ddd6 --- /dev/null +++ b/test-karate/src/test/java/graphql/ftm/newTemplate.feature @@ -0,0 +1,60 @@ +Feature: Create a new Template for later use during Time Machine testing + Background: + * def templateName = 'MyTemplate' + Math.floor(Math.random() * 1000) + Scenario: Create a new Template + Given url baseUrl + '/api/v1/templates' + And headers commonHeaders + And request + """ + { + "title":"#(templateName)", + "theme":"13f88067-1e25-4e30-bc64-7e8f42ad542f", + "friendlyName":"Test Template.", + "layout":{ + "body":{ + "rows":[ + { + "styleClass":"", + "columns":[ + { + "styleClass":"", + "leftOffset":1, + "width":100, + "containers":[ + { + "identifier":"#(containerId)", + } + ] + } + ] + },{ + "styleClass":"", + "columns":[ + { + "styleClass":"", + "leftOffset":1, + "width":100, + "containers":[ + { + "identifier":"#(containerId)", + } + ] + } + ] + } + ] + }, + "header":true, + "footer":true, + "sidebar":{ + "location":"", + "containers":[ + + ], + "width":"small" + } + } + } + """ + When method post + Then status 200 \ No newline at end of file diff --git a/test-karate/src/test/java/graphql/ftm/publishPage.feature b/test-karate/src/test/java/graphql/ftm/publishPage.feature new file mode 100644 index 000000000000..68b9b9923732 --- /dev/null +++ b/test-karate/src/test/java/graphql/ftm/publishPage.feature @@ -0,0 +1,25 @@ +Feature: Add pieces of content then Publish the Page + Background: + + * def page_id = __arg.page_id + * def content1_id = __arg.content1_id + * def content2_id = __arg.content2_id + * def container_id = __arg.container_id + + Scenario: Create a new version of a piece of content + Given url baseUrl + '/api/v1/page/'+page_id+'/content' + And headers commonHeaders + And request + """ + [ + { + "contentletsId": ["#(content1_id)", "#(content2_id)"], + "identifier": "#(container_id)", + "uuid": "1" + } + ] + """ + When method POST + Then status 200 + * def errors = call validateNoErrors response + * match errors == [] \ No newline at end of file diff --git a/test-karate/src/test/java/graphql/ftm/publishTemplate.feature b/test-karate/src/test/java/graphql/ftm/publishTemplate.feature new file mode 100644 index 000000000000..d2f314c5dc87 --- /dev/null +++ b/test-karate/src/test/java/graphql/ftm/publishTemplate.feature @@ -0,0 +1,12 @@ +Feature: Publish a Template + Background: + + Scenario: Create a new Template + Given url baseUrl + '/api/v1/templates/_publish' + And headers commonHeaders + And request + """ + ["#(templateId)"] + """ + When method PUT + Then status 200 diff --git a/test-karate/src/test/java/graphql/ftm/setup.feature b/test-karate/src/test/java/graphql/ftm/setup.feature new file mode 100644 index 000000000000..1d8b6a2f5628 --- /dev/null +++ b/test-karate/src/test/java/graphql/ftm/setup.feature @@ -0,0 +1,52 @@ +Feature: Setting up the Future Time Machine Test + + Background: + * callonce read('classpath:graphql/ftm/helpers.feature') + # Make the prefix available to the scenario + # Setup required data + # Lets start by creating a new content type, container, template and publish the template + # First the Content Type + * def contentTypeResult = callonce read('classpath:graphql/ftm/newContentType.feature') + * def contentTypeId = contentTypeResult.response.entity[0].id + * def contentTypeVariable = contentTypeResult.response.entity[0].variable + # Now the container, template and publish the template + * def containerResult = callonce read('classpath:graphql/ftm/newContainer.feature') { contentTypeId: '#(contentTypeId)' } + * def containerId = containerResult.response.entity.identifier + * def templateResult = callonce read('classpath:graphql/ftm/newTemplate.feature') { containerId: '#(containerId)' } + * def templateId = templateResult.response.entity.identifier + * callonce read('classpath:graphql/ftm/publishTemplate.feature') { templateId: '#(templateId)' } + + # Create a couple of new pieces of content + * def createContentPieceOneResult = callonce read('classpath:graphql/ftm/newContent.feature') { contentTypeId: '#(contentTypeId)', title: 'test 1' } + * def contentPieceOne = createContentPieceOneResult.response.entity.results + * def contentPieceOneId = contentPieceOne.map(result => Object.keys(result)[0]) + * def contentPieceOneId = contentPieceOneId[0] + + * def createContentPieceTwoResult = callonce read('classpath:graphql/ftm/newContent.feature') { contentTypeId: '#(contentTypeId)', title: 'test 2' } + * def contentPieceTwo = createContentPieceTwoResult.response.entity.results + * def contentPieceTwoId = contentPieceTwo.map(result => Object.keys(result)[0]) + * def contentPieceTwoId = contentPieceTwoId[0] + + # Now lets create a new version for each piece of content + * def formatter = java.time.format.DateTimeFormatter.ofPattern('yyyy-MM-dd') + * def now = java.time.LocalDateTime.now() + * def futureDateTime = now.plusDays(10) + * def formattedFutureDateTime = futureDateTime.format(formatter) + + * def newContentPiceOneVersion2 = callonce read('classpath:graphql/ftm/newContentVersion.feature') { contentTypeId: '#(contentTypeId)', identifier: '#(contentPieceOneId)', title: 'test 1 v2 (This ver will be publshed in the future)', publishDate: '#(formattedFutureDateTime)' } + * def newContentPiceTwoVersion2 = callonce read('classpath:graphql/ftm/newContentVersion.feature') { contentTypeId: '#(contentTypeId)', identifier: '#(contentPieceTwoId)', title: 'test 2 v2' } + + * def pageUrl = 'ftm-test-page' + Math.floor(Math.random() * 10000) + + # Finally lets create a new page + * def createPageResult = callonce read('classpath:graphql/ftm/newPage.feature') { pageUrl:'#(pageUrl)' ,title: 'Future Time Machine Test page', templateId:'#(templateId)' } + + * def pages = createPageResult.response.entity.results + * def pageId = pages.map(result => Object.keys(result)[0]) + * def pageId = pageId[0] + + * def publishPageResult = callonce read('classpath:graphql/ftm/publishPage.feature') { page_id: '#(pageId)', content1_id: '#(contentPieceOneId)', content2_id: '#(contentPieceTwoId)', container_id: '#(containerId)' } + + * karate.log('Page created and Published ::', pageUrl) + + Scenario: \ No newline at end of file diff --git a/test-karate/src/test/java/karate-config.js b/test-karate/src/test/java/karate-config.js index d28f0028725c..d33a045d38f0 100644 --- a/test-karate/src/test/java/karate-config.js +++ b/test-karate/src/test/java/karate-config.js @@ -1,19 +1,30 @@ function fn() { - var env = karate.env; // get system property 'karate.env' + let env = karate.env; // get system property 'karate.env' karate.log('karate.env system property was:', env); if (!env) { env = 'dev'; } - var config = { + let baseUrl = karate.properties['karate.base.url'] || 'http://localhost:8080'; + let authString = 'admin@dotcms.com:admin'; + let encodedAuth = function(s) { + return java.util.Base64.getEncoder().encodeToString(s.getBytes('UTF-8')); + }; + let authHeader = 'Basic ' + encodedAuth(authString); + let config = { env: env, - baseUrl: karate.properties['karate.base.url'] || 'http://localhost:8080' + baseUrl: baseUrl, + commonHeaders : { + 'Content-Type': 'application/json', + 'Authorization': authHeader + } } - if (env == 'dev') { + if (env === 'dev') { // customize // e.g. config.foo = 'bar'; - } else if (env == 'e2e') { + } else if (env === 'e2e') { // customize } karate.log('Base URL set to:', config.baseUrl); + return config; } \ No newline at end of file diff --git a/test-karate/src/test/java/tests/graphql/ftm/CheckingTimeMachine.feature b/test-karate/src/test/java/tests/graphql/ftm/CheckingTimeMachine.feature new file mode 100644 index 000000000000..4bd6dd382506 --- /dev/null +++ b/test-karate/src/test/java/tests/graphql/ftm/CheckingTimeMachine.feature @@ -0,0 +1,63 @@ +Feature: Test Time Machine functionality + + Background: + * callonce read('classpath:graphql/ftm/setup.feature') + + @smoke @positive + Scenario: Test Time Machine functionality when no publish date is provided + Given url baseUrl + '/api/v1/page/render/'+pageUrl+'?language_id=1&mode=LIVE' + And headers commonHeaders + When method GET + Then status 200 + * def pageContents = extractContentlets (response) + + * def contentPieceOne = getContentletByUUID(contentPieceOne, contentPieceOneId) + * def contentPieceTwo = getContentletByUUID(contentPieceTwo, contentPieceTwoId) + + * def titles = pageContents.map(x => x.title) + # This is the first version of the content, test 1 v2 as the title says it will be published in the future + * match titles contains 'test 1' + # This is the second version of the content, Thisone is already published therefore it should be displayed + * match titles contains 'test 2 v2' + + @positive + Scenario: Test Time Machine functionality when a publish date is provided expect the future content to be displayed + + Given url baseUrl + '/api/v1/page/render/'+pageUrl+'?language_id=1&mode=LIVE&publishDate='+formattedFutureDateTime + And headers commonHeaders + When method GET + Then status 200 + * def pageContents = extractContentlets (response) + + * def contentPieceOne = getContentletByUUID(contentPieceOne, contentPieceOneId) + * def contentPieceTwo = getContentletByUUID(contentPieceTwo, contentPieceTwoId) + + * def titles = pageContents.map(x => x.title) + * match titles contains 'test 1 v2 (This ver will be publshed in the future)' + + @smoke @positive + Scenario: Send GraphQL query to fetch page details no publish date is sent + * def graphQLRequestPayLoad = buildGraphQLRequestPayload (pageUrl) + Given url baseUrl + '/api/v1/graphql' + And headers commonHeaders + And request graphQLRequestPayLoad + + When method post + Then status 200 + * def contentlets = contentletsFromGraphQlResponse(response) + * karate.log('contentlets:', contentlets) + * match contentlets contains 'test 1' + * match contentlets contains 'test 2 v2' + + @smoke @positive + Scenario: Send GraphQL query to fetch page details, publish date is sent expect the future content to be displayed + * def graphQLRequestPayLoad = buildGraphQLRequestPayload (pageUrl, formattedFutureDateTime) + Given url baseUrl + '/api/v1/graphql' + And headers commonHeaders + And request graphQLRequestPayLoad + + When method post + Then status 200 + * def contentlets = contentletsFromGraphQlResponse(response) + * karate.log('contentlets:', contentlets) + * match contentlets contains 'test 1 v2 (This ver will be publshed in the future)' diff --git a/test-karate/src/test/java/tests/graphql/ftm/CheckingTimeMachineRunner.java b/test-karate/src/test/java/tests/graphql/ftm/CheckingTimeMachineRunner.java new file mode 100644 index 000000000000..d5a7c06824c2 --- /dev/null +++ b/test-karate/src/test/java/tests/graphql/ftm/CheckingTimeMachineRunner.java @@ -0,0 +1,12 @@ +package tests.graphql.ftm; + +import com.intuit.karate.junit5.Karate; + +public class CheckingTimeMachineRunner { + + @Karate.Test + Karate testCheckingTimeMachine() { + return Karate.run("CheckingTimeMachine").relativeTo(getClass()); + } + +}