From 220190bdc084b4500845136971b98f3d58de3125 Mon Sep 17 00:00:00 2001 From: freddyDOTCMS <147462678+freddyDOTCMS@users.noreply.github.com> Date: Fri, 13 Dec 2024 08:24:14 -0600 Subject: [PATCH 1/6] Issue 30919 unique field setting unique per site field var to a content type with existing content fails (#30936) ### Proposed Changes * If the Contentlet has a Unique Field and the Host is not set then populate the Host https://github.com/dotCMS/core/pull/30936/files#diff-fa1ceaa19618a6b2bbc30e24c6f930b4971f417db50babb748c2e2837ba9eb82R7674 Really I just come back this line I removed this by accident when I implemented the new code https://github.com/dotCMS/core/commit/d6eb7b89563c3c981ee355c84b43536040eb5b0d#diff-fa1ceaa19618a6b2bbc30e24c6f930b4971f417db50babb748c2e2837ba9eb82L7647-L7650 --- .../business/ESContentletAPIImpl.java | 4 ++ .../business/ESContentletAPIImplTest.java | 53 +++++++++++++++++++ 2 files changed, 57 insertions(+) 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..afeb560188e0 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 @@ -7671,6 +7671,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-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); + } + } } From 65f39f45b06c3ce29ec0ec12315ab5038aeefe9f Mon Sep 17 00:00:00 2001 From: Will Ezell Date: Fri, 13 Dec 2024 11:46:02 -0500 Subject: [PATCH 2/6] fix(cache) missed setting stat collection, adding back (#30935) #30670 The DynamicTTL does not call the stats collector when it instantiates the Caffeine Cache. This adds the stats collector to the DynamicTTL cache ![image](https://github.com/user-attachments/assets/832240c7-33b1-4af2-b4ba-e4ed9f3160bb) --- dotCMS/src/main/java/com/dotcms/cache/DynamicTTLCache.java | 1 + 1 file changed, 1 insertion(+) 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(); } From cb72fba85944ceb249ed92b5ab50709e62158320 Mon Sep 17 00:00:00 2001 From: Bryan Date: Fri, 13 Dec 2024 10:47:58 -0600 Subject: [PATCH 3/6] Testing running test on pipeline after some configuration (#30932) ### Proposed Changes * Content Search Portlet integrity test * Content Editing tests ### Checklist - [x] Tests --- e2e/dotcms-e2e-node/frontend/.env | 2 +- .../frontend/locators/globalLocators.ts | 2 +- .../tests/contentSearch/contentData.ts | 12 +- .../contentSearch/contentEditing.spec.ts | 77 +++++++++ .../contentSearch/portletIntegrity.spec.ts | 16 +- .../frontend/utils/contentUtils.ts | 156 ++++++++++++++++-- .../frontend/utils/dotCMSUtils.ts | 27 +-- 7 files changed, 253 insertions(+), 39 deletions(-) create mode 100644 e2e/dotcms-e2e-node/frontend/tests/contentSearch/contentEditing.spec.ts 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 From aca3a063617fd5fc2feec8617743afb2f11bb11b Mon Sep 17 00:00:00 2001 From: Will Ezell Date: Mon, 16 Dec 2024 08:30:50 -0500 Subject: [PATCH 4/6] fix(render) fixes issues when a parameter map with a null value is passed into the render, causing a 404/500 error (#30944) ref: #30942 --------- Co-authored-by: Jose Castro --- .../mock/request/MockParameterRequest.java | 10 +- .../filters/VanityUrlRequestWrapper.java | 33 +++--- .../request/MockParameterRequestTest.java | 103 ++++++++++++++++++ 3 files changed, 122 insertions(+), 24 deletions(-) create mode 100644 src/test/java/com/dotcms/mock/request/MockParameterRequestTest.java 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/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/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")); + } + + + +} From 750330606af7d7f9e147c4312c38f81b9398bf66 Mon Sep 17 00:00:00 2001 From: Rafael Velazco Date: Mon, 16 Dec 2024 16:25:33 -0400 Subject: [PATCH 5/6] feat(FTM): New UVE Toolbar - Implement Workflows Action button (#30908) This pull request introduces several changes to the `DotEmaShellComponent` and related components to integrate the `DotWorkflowsActionsService` and enhance workflow actions functionality. The most important changes include adding the new service to multiple components, updating component templates, and refactoring the `DotUveWorkflowActionsComponent`. ### Integration of `DotWorkflowsActionsService`: * Added `DotWorkflowsActionsService` to the imports and providers in `dot-ema-shell.component.ts` and `dot-ema-shell.component.spec.ts` to enable workflow actions. [[1]](diffhunk://#diff-8843e3a4ce8c16e83408b1a6dcc3ad54eaddd17f8e986bbdb502e11bd4446ab4R24) [[2]](diffhunk://#diff-8843e3a4ce8c16e83408b1a6dcc3ad54eaddd17f8e986bbdb502e11bd4446ab4R212-R217) [[3]](diffhunk://#diff-677330662fea6dadc7e48fd8455ec2a6fe60d624c7ed1f01f0a3e985aacd05c6L22-R23) [[4]](diffhunk://#diff-677330662fea6dadc7e48fd8455ec2a6fe60d624c7ed1f01f0a3e985aacd05c6R62) ### Component Template Updates: * Replaced the workflows button with the `dot-uve-workflow-actions` component in `dot-uve-toolbar.component.html` to improve the user interface for workflow actions. * Updated the `dot-uve-workflow-actions.component.html` to disable actions when editing is not allowed. ### Refactoring `DotUveWorkflowActionsComponent`: * Renamed `dot-edit-ema-workflow-actions` to `dot-uve-workflow-actions` and refactored the component to use `UVEStore` for managing state and actions. [[1]](diffhunk://#diff-0c14a7beca853a53da6f640191eefa9d4b9564b2473879c4cbd391d7ff091c1cL1-R1) [[2]](diffhunk://#diff-0c14a7beca853a53da6f640191eefa9d4b9564b2473879c4cbd391d7ff091c1cL22-R45) [[3]](diffhunk://#diff-0c14a7beca853a53da6f640191eefa9d4b9564b2473879c4cbd391d7ff091c1cL70-L75) [[4]](diffhunk://#diff-0c14a7beca853a53da6f640191eefa9d4b9564b2473879c4cbd391d7ff091c1cL102-L116) [[5]](diffhunk://#diff-0c14a7beca853a53da6f640191eefa9d4b9564b2473879c4cbd391d7ff091c1cL141-R111) [[6]](diffhunk://#diff-0c14a7beca853a53da6f640191eefa9d4b9564b2473879c4cbd391d7ff091c1cL150-R120) ### Test Updates: * Added mock providers and updated test cases in `dot-ema-info-display.component.spec.ts` and `dot-uve-toolbar.component.spec.ts` to accommodate the new service and component changes. [[1]](diffhunk://#diff-164a62c130e772cc25a858a58ce4c6d8099d21b119ce0ec61e9c297b94ba4297L24-R25) [[2]](diffhunk://#diff-164a62c130e772cc25a858a58ce4c6d8099d21b119ce0ec61e9c297b94ba4297R57-R62) [[3]](diffhunk://#diff-3eaa147616a5d1ff374a5fa27b0f38f0159a9039ef7e8d672dec43631f48a9e1L16-R17) [[4]](diffhunk://#diff-3eaa147616a5d1ff374a5fa27b0f38f0159a9039ef7e8d672dec43631f48a9e1R45) [[5]](diffhunk://#diff-3eaa147616a5d1ff374a5fa27b0f38f0159a9039ef7e8d672dec43631f48a9e1L95-R98) [[6]](diffhunk://#diff-3eaa147616a5d1ff374a5fa27b0f38f0159a9039ef7e8d672dec43631f48a9e1L110-R125) [[7]](diffhunk://#diff-3eaa147616a5d1ff374a5fa27b0f38f0159a9039ef7e8d672dec43631f48a9e1L363-R373) ### Codebase Simplification: * Simplified `DotUveToolbarComponent` by removing unused properties and methods, and integrating the new workflow actions component. [[1]](diffhunk://#diff-217a9e619d6590c4f652e85353b9637ba5e464ddeb0424be35aef39bb8dceb30R30) [[2]](diffhunk://#diff-217a9e619d6590c4f652e85353b9637ba5e464ddeb0424be35aef39bb8dceb30L49-R67) [[3]](diffhunk://#diff-217a9e619d6590c4f652e85353b9637ba5e464ddeb0424be35aef39bb8dceb30L74-L75) [[4]](diffhunk://#diff-217a9e619d6590c4f652e85353b9637ba5e464ddeb0424be35aef39bb8dceb30R86-R92) #### Videos https://github.com/user-attachments/assets/0d2f3692-1377-41ba-bd2f-443dcb13ee4b --- .../dot-ema-shell.component.spec.ts | 24 +++ .../dot-ema-shell/dot-ema-shell.component.ts | 10 +- .../dot-ema-info-display.component.spec.ts | 9 +- .../dot-uve-toolbar.component.html | 2 +- .../dot-uve-toolbar.component.spec.ts | 27 ++- .../dot-uve-toolbar.component.ts | 18 +- .../dot-uve-workflow-actions.component.css} | 0 .../dot-uve-workflow-actions.component.html} | 1 + ...ot-uve-workflow-actions.component.spec.ts} | 115 ++++++++---- .../dot-uve-workflow-actions.component.ts} | 101 +++++----- .../edit-ema-toolbar.component.html | 5 +- .../edit-ema-toolbar.component.spec.ts | 53 +----- .../edit-ema-toolbar.component.ts | 4 +- .../edit-ema-editor.component.spec.ts | 25 ++- .../edit-ema-editor.component.ts | 2 +- .../edit-ema-layout.component.spec.ts | 6 +- .../edit-ema/portlet/src/lib/shared/enums.ts | 3 +- .../src/lib/store/dot-uve.store.spec.ts | 9 +- .../features/editor/toolbar/withUVEToolbar.ts | 1 - .../store/features/editor/withEditor.spec.ts | 1 - .../lib/store/features/load/withLoad.spec.ts | 51 +++-- .../src/lib/store/features/load/withLoad.ts | 176 +++++++++--------- .../features/workflow/withWorkflow.spec.ts | 93 +++++++++ .../store/features/workflow/withWorkflow.ts | 81 ++++++++ .../edit-ema/portlet/src/lib/store/models.ts | 8 +- .../edit-ema/portlet/src/lib/utils/index.ts | 16 ++ .../dot-workflow-actions.component.html | 3 +- .../dot-workflow-actions.component.spec.ts | 31 +++ .../dot-workflow-actions.component.ts | 6 + .../ext/contentlet/edit_contentlet_js_inc.jsp | 88 ++++----- 30 files changed, 639 insertions(+), 330 deletions(-) rename 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 => dot-uve-workflow-actions/dot-uve-workflow-actions.component.css} (100%) rename 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 => dot-uve-workflow-actions/dot-uve-workflow-actions.component.html} (83%) rename 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 => dot-uve-workflow-actions/dot-uve-workflow-actions.component.spec.ts} (73%) rename 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 => dot-uve-workflow-actions/dot-uve-workflow-actions.component.ts} (69%) create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/workflow/withWorkflow.spec.ts create mode 100644 core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/workflow/withWorkflow.ts 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/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 @@ } } - - From 82ddbb0e83853c263bcd775718f07ab6e4af1d40 Mon Sep 17 00:00:00 2001 From: Fabrizzio Araya <37148755+fabrizzio-dotCMS@users.noreply.github.com> Date: Mon, 16 Dec 2024 16:03:55 -0600 Subject: [PATCH 6/6] feat(GraphQL) publishDate param to GraphQL Refs:#30780 (#30885) ### Proposed Changes * I'm opening the possibility of passing a publishDate through the GQL API * Additionally, I saw an opportunity to improve our passing the request into a method that uses it. Previously, it was being grabbed by the current thread. We should avoid using the Current Thread to pass around the request, as it is the source of memory leaks --- .../business/ESContentletAPIImpl.java | 4 + .../PageAPIGraphQLFieldsProvider.java | 4 + .../page/ContainersDataFetcher.java | 3 - .../datafetcher/page/PageDataFetcher.java | 24 ++++ .../velocity/services/PageRenderUtil.java | 12 +- .../render/HTMLPageAssetRenderedAPIImpl.java | 20 +-- test-karate/src/test/java/KarateCITests.java | 5 +- .../src/test/java/graphql/ftm/helpers.feature | 132 ++++++++++++++++++ .../java/graphql/ftm/newContainer.feature | 24 ++++ .../test/java/graphql/ftm/newContent.feature | 21 +++ .../java/graphql/ftm/newContentType.feature | 118 ++++++++++++++++ .../graphql/ftm/newContentVersion.feature | 18 +++ .../src/test/java/graphql/ftm/newPage.feature | 24 ++++ .../test/java/graphql/ftm/newTemplate.feature | 60 ++++++++ .../test/java/graphql/ftm/publishPage.feature | 25 ++++ .../java/graphql/ftm/publishTemplate.feature | 12 ++ .../src/test/java/graphql/ftm/setup.feature | 52 +++++++ test-karate/src/test/java/karate-config.js | 21 ++- .../graphql/ftm/CheckingTimeMachine.feature | 63 +++++++++ .../ftm/CheckingTimeMachineRunner.java | 12 ++ 20 files changed, 629 insertions(+), 25 deletions(-) create mode 100644 test-karate/src/test/java/graphql/ftm/helpers.feature create mode 100644 test-karate/src/test/java/graphql/ftm/newContainer.feature create mode 100644 test-karate/src/test/java/graphql/ftm/newContent.feature create mode 100644 test-karate/src/test/java/graphql/ftm/newContentType.feature create mode 100644 test-karate/src/test/java/graphql/ftm/newContentVersion.feature create mode 100644 test-karate/src/test/java/graphql/ftm/newPage.feature create mode 100644 test-karate/src/test/java/graphql/ftm/newTemplate.feature create mode 100644 test-karate/src/test/java/graphql/ftm/publishPage.feature create mode 100644 test-karate/src/test/java/graphql/ftm/publishTemplate.feature create mode 100644 test-karate/src/test/java/graphql/ftm/setup.feature create mode 100644 test-karate/src/test/java/tests/graphql/ftm/CheckingTimeMachine.feature create mode 100644 test-karate/src/test/java/tests/graphql/ftm/CheckingTimeMachineRunner.java 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 afeb560188e0..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 { 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/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/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/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()); + } + +}