diff --git a/cypress.config.js b/cypress.config.js index 2a88f86fa1..169a8f3dd4 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -5,6 +5,7 @@ module.exports = defineConfig({ viewportWidth: 1920, viewportHeight: 1080, video: false, + defaultCommandTimeout: 15000, env: { API_AUTH: "https://auth.api.dev.zesty.io", COOKIE_NAME: "DEV_APP_SID", diff --git a/cypress/e2e/content/pages/ContentItemPage.js b/cypress/e2e/content/pages/ContentItemPage.js new file mode 100644 index 0000000000..b2379e8e0d --- /dev/null +++ b/cypress/e2e/content/pages/ContentItemPage.js @@ -0,0 +1,24 @@ +class ContentItemPage { + elements = { + createItemButton: () => cy.getBySelector("CreateItemSaveButton"), + duoModeToggle: () => cy.getBySelector("DuoModeToggle"), + moreMenu: () => cy.getBySelector("ContentItemMoreButton"), + deleteItemButton: () => cy.getBySelector("DeleteContentItem"), + confirmDeleteItemButton: () => + cy.getBySelector("DeleteContentItemConfirmButton"), + versionSelector: () => cy.getBySelector("VersionSelector"), + versionItem: () => cy.getBySelector("VersionItem"), + addWorkflowStatusLabel: () => cy.getBySelector("AddWorkflowStatusLabel"), + workflowStatusLabelOption: () => + cy.getBySelector("WorkflowStatusLabelOption"), + activeWorkflowStatusLabel: () => + cy.getBySelector("ActiveWorkflowStatusLabel"), + publishItemButton: () => cy.getBySelector("PublishButton"), + confirmPublishItemButton: () => cy.getBySelector("ConfirmPublishButton"), + toast: () => cy.getBySelector("toast"), + contentPublishedIndicator: () => + cy.getBySelector("ContentPublishedIndicator"), + }; +} + +export default new ContentItemPage(); diff --git a/cypress/e2e/content/workflows.spec.js b/cypress/e2e/content/workflows.spec.js new file mode 100644 index 0000000000..2775672194 --- /dev/null +++ b/cypress/e2e/content/workflows.spec.js @@ -0,0 +1,198 @@ +import ContentItemPage from "./pages/ContentItemPage"; +import CONFIG from "../../../src/shell/app.config"; +import instanceZUID from "../../../src/utility/instanceZUID"; + +const INSTANCE_API = `${ + CONFIG?.[process.env.NODE_ENV]?.API_INSTANCE_PROTOCOL +}${instanceZUID}${CONFIG?.[process.env.NODE_ENV]?.API_INSTANCE}`; +const TITLES = { + contentItem: "Content item workflow test", + publishLabel: "Publish Approval", + testLabel: "Random Test Label", +}; +const LABEL_DATA = { + publishLabel: { + name: TITLES.publishLabel, + description: "", + color: "#4E5BA6", + allowPublish: true, + addPermissionRoles: ["30-86f8ccec82-swp72s", "30-8ee88afe82-gmx631"], + removePermissionRoles: ["30-86f8ccec82-swp72s", "30-8ee88afe82-gmx631"], + }, + testLabel: { + name: TITLES.testLabel, + description: "", + color: "#4E5BA6", + allowPublish: false, + addPermissionRoles: ["30-86f8ccec82-swp72s", "30-8ee88afe82-gmx631"], + removePermissionRoles: ["30-86f8ccec82-swp72s", "30-8ee88afe82-gmx631"], + }, +}; + +describe("Content Item Workflows", () => { + before(() => { + cy.intercept("POST", "**/labels").as("createLabel"); + cy.intercept("GET", "**/labels*").as("getLabels"); + + // Create allow publish workflow label + Object.values(LABEL_DATA).forEach((data) => { + cy.apiRequest({ + method: "POST", + url: `${INSTANCE_API}/env/labels`, + body: data, + }); + }); + + // Visit test page + cy.apiRequest({ + method: "POST", + url: `${INSTANCE_API}/content/models/6-b6cde1aa9f-wftv50/items`, + body: { + data: { + title: TITLES.contentItem, + description: TITLES.contentItem, + tc_title: TITLES.contentItem, + tc_description: TITLES.contentItem, + tc_image: null, + }, + web: { + canonicalTagMode: 1, + parentZUID: "0", + metaLinkText: TITLES.contentItem, + metaTitle: TITLES.contentItem, + pathPart: TITLES.contentItem?.replaceAll(" ", "-")?.toLowerCase(), + metaDescription: TITLES.contentItem, + }, + meta: { langID: 1, contentModelZUID: "6-b6cde1aa9f-wftv50" }, + }, + }).then((response) => { + cy.visit(`/content/6-b6cde1aa9f-wftv50/${response.data?.ZUID}`); + }); + }); + + after(() => { + // Delete test content item + cy.location("pathname").then((loc) => { + const [_, __, modelZUID, itemZUID] = loc?.split("/"); + cy.apiRequest({ + method: "DELETE", + url: `${INSTANCE_API}/content/models/${modelZUID}/items/${itemZUID}`, + }); + }); + + // Delete test labels + cy.apiRequest({ url: `${INSTANCE_API}/env/labels?showDeleted=true` }).then( + (response) => { + response?.data + ?.filter( + (label) => + !label?.deletedAt && + [TITLES.publishLabel, TITLES.testLabel].includes(label?.name) + ) + .forEach((label) => { + cy.apiRequest({ + url: `${INSTANCE_API}/env/labels/${label.ZUID}`, + method: "DELETE", + }); + }); + } + ); + }); + + it("Can add a workflow label", () => { + ContentItemPage.elements.versionSelector().should("exist").click(); + ContentItemPage.elements.addWorkflowStatusLabel().should("exist").click(); + ContentItemPage.elements + .workflowStatusLabelOption() + .contains(TITLES.testLabel) + .should("exist") + .click({ force: true }); + + cy.get("body").type("{esc}"); + + cy.intercept("PUT", "**/labels/*").as("updateLabel"); + cy.wait("@updateLabel"); + + cy.reload(); + + ContentItemPage.elements.versionSelector().should("exist").click(); + ContentItemPage.elements + .versionItem() + .first() + .within(() => { + ContentItemPage.elements + .activeWorkflowStatusLabel() + .should("have.length", 1); + }); + + cy.get("body").type("{esc}"); + }); + + it("Cannot add a workflow label when role has no permission", () => { + ContentItemPage.elements.versionSelector().should("exist").click(); + ContentItemPage.elements.addWorkflowStatusLabel().should("exist").click(); + ContentItemPage.elements + .workflowStatusLabelOption() + .first() + .should("exist") + .click({ force: true }); + + cy.get("body").type("{esc}"); + + cy.reload(); + + ContentItemPage.elements.versionSelector().should("exist").click(); + ContentItemPage.elements + .versionItem() + .first() + .within(() => { + ContentItemPage.elements + .activeWorkflowStatusLabel() + .should("have.length", 1); + }); + + cy.get("body").type("{esc}"); + }); + + it("Cannot publish a content item if label with allowPublish is missing", () => { + ContentItemPage.elements + .publishItemButton() + .should("exist") + .click({ force: true }); + ContentItemPage.elements + .confirmPublishItemButton() + .should("exist") + .click({ force: true }); + ContentItemPage.elements + .toast() + .contains( + `Cannot Publish: "${TITLES.contentItem}". Does not have a status that allows publishing` + ); + }); + + it("Can publish a content item if label with allowPublish is applied", () => { + cy.reload(); + ContentItemPage.elements.versionSelector().should("exist").click(); + ContentItemPage.elements.addWorkflowStatusLabel().should("exist").click(); + ContentItemPage.elements + .workflowStatusLabelOption() + .contains(TITLES.publishLabel) + .should("exist") + .click({ force: true }); + + cy.get("body").type("{esc}"); + + cy.intercept("PUT", "**/labels/*").as("updateLabel"); + cy.wait("@updateLabel"); + + cy.reload(); + + ContentItemPage.elements.publishItemButton().should("exist").click(); + ContentItemPage.elements.confirmPublishItemButton().should("exist").click(); + + cy.intercept("GET", "**/publishings").as("publish"); + cy.wait("@publish"); + + ContentItemPage.elements.contentPublishedIndicator().should("exist"); + }); +}); diff --git a/cypress/e2e/settings/workflows.spec.js b/cypress/e2e/settings/workflows.spec.js new file mode 100644 index 0000000000..1ecc8b48c8 --- /dev/null +++ b/cypress/e2e/settings/workflows.spec.js @@ -0,0 +1,530 @@ +import instanceZUID from "../../../src/utility/instanceZUID"; +import CONFIG from "../../../src/shell/app.config"; +import { + AUTHORIZED_ROLES, + colorMenu, +} from "../../../src/apps/settings/src/app/views/User/Workflows/constants"; + +const TIMEOUT = { timeout: 40_000 }; + +const INSTANCE_API = `${ + CONFIG?.[process.env.NODE_ENV]?.API_INSTANCE_PROTOCOL +}${instanceZUID}${CONFIG?.[process.env.NODE_ENV]?.API_INSTANCE}`; + +const FOCUSED_LABEL_COLOR = "rgba(253, 133, 58, 0.1)"; + +const ENDPOINTS = { + userRoles: "**/v1/users/**/roles", + instanceUserRoles: `**/v1/instances/**/users/roles`, + allStatusLabels: "**/v1/env/labels?showDeleted=true", + statusLabels: "**/v1/env/labels", +}; + +const RESTRICTED_USER = { + data: [ + { + ZUID: "30-8ee88afe82-gmx631", + entityZUID: "8-f48cf3a682-7fthvk", + name: "Developer", + systemRoleZUID: "31-71cfc74-d3v3l0p3r", + systemRole: { + ZUID: "31-71cfc74-d3v3l0p3r", + name: "Developer", + }, + }, + ], +}; + +const LABELS = { + restrictedPageHeader: "You need permission to view and edit workflows", + restrictedPageSubheader: + "Contact the instance owner or administrators listed below to upgrade your role to Admin or Owner", + activeLabelsHeader: "Active Statuses", + activeLabelsSubheader: + "Active statuses are available to be added and removed from content items", + deactivatedLabelsHeader: "Deactivated Statuses", + deactivatedLabelsSubheader: + "These statuses can be re-activated at any time if you would like to add or remove them from content items", +}; + +const EMPTY_SEARCH_TEXT = "xx_yy_zz_00"; + +const TEST_DATA = { + new: { + name: "Test__new", + description: "Test__new Description", + color: "Grey", + addPermissionRoles: "Admin", + removePermissionRoles: "Admin", + allowPublish: true, + }, + edited: { + name: "Test__edited", + description: "Test__edited Description", + color: "Pink", + addPermissionRoles: [], + removePermissionRoles: [], + allowPublish: true, + }, + temp1: { + name: "Test__temp1", + description: "Test__temp1 Description", + color: "Red", + addPermissionRoles: [], + removePermissionRoles: [], + allowPublish: false, + }, + temp2: { + name: "Test__temp2", + description: "Test__temp2 Description", + color: "Yellow", + addPermissionRoles: [], + removePermissionRoles: [], + allowPublish: false, + }, + temp3: { + name: "Test__temp3", + description: "Test__temp3 Description", + color: "Purple", + addPermissionRoles: [], + removePermissionRoles: [], + allowPublish: false, + }, +}; + +before(() => { + cy.cleanTestData(); + cy.createTestData(); +}); + +after(() => { + cy.cleanTestData(); +}); + +describe("Restricted User", { retries: 1 }, () => { + it("displays restricted access message and admin profiles", () => { + cy.intercept("GET", ENDPOINTS?.userRoles, { + statusCode: 200, + body: RESTRICTED_USER, + }).as("getRestrictedUser"); + cy.intercept("GET", ENDPOINTS?.instanceUserRoles).as( + "getInstanceUserRoles" + ); + + cy.visit("/settings/user/workflows"); + cy.get('[data-cy="workflows-restricted-page"]', TIMEOUT); + + cy.wait("@getInstanceUserRoles") + .its("response.body.data") + .then((users) => { + const authorizedUsers = users.filter((user) => + AUTHORIZED_ROLES.includes(user?.role?.systemRoleZUID) + ); + cy.get('[data-cy="user-profile-container"] > *').should( + "have.length", + authorizedUsers.length + ); + }); + cy.contains(LABELS?.restrictedPageHeader).should("exist"); + cy.contains(LABELS?.restrictedPageSubheader).should("exist"); + cy.get('[data-cy="restricted-image"]').should("exist"); + }); +}); + +describe("Authorized User", { retries: 1 }, () => { + before(() => { + cy.goToWorkflowsPage(); + }); + + it("displays workflow page elements for authorized users", () => { + cy.get('[data-cy="active-labels-container"]', TIMEOUT); + + cy.contains("Workflows").should("exist"); + cy.get("button").contains("Create Status").should("exist"); + cy.get('input[placeholder="Search Statuses"]').should("exist"); + cy.get('input[value="deactivated"]').should("exist"); + }); + + it("Show Deactivated Labels: Displays active and deactivated sections", () => { + cy.get('input[value="deactivated"]').click(); + cy.contains(LABELS.activeLabelsHeader).should("exist"); + cy.contains(LABELS.deactivatedLabelsHeader).should("exist"); + }); +}); + +describe("Create New Status Label", { retries: 1 }, () => { + before(() => { + cy.goToWorkflowsPage(); + }); + + it("Form Validation: should display error message when required fields are empty", function () { + cy.get("button").contains("Create Status").click(TIMEOUT); + + cy.get('[data-cy="status-label-form"]', TIMEOUT); + + const nameFieldErrorMessage = "Name is required"; + cy.get('[data-cy="status-label-submit-button"]').click(); + cy.contains(nameFieldErrorMessage).should("exist"); + cy.get("button").contains("Cancel").click(); + }); + + it("Fills out and submits form", () => { + cy.get("button").contains("Create Status").click(TIMEOUT); + + cy.get('[data-cy="status-label-form"]', TIMEOUT); + + cy.get('input[name="name"]').type(TEST_DATA.new.name); + cy.get('textarea[name="description"]').type(TEST_DATA.new.description); + cy.get('input[name="color"]').parent().find("button").click(); + cy.get('ul li[role="option"]').contains(TEST_DATA.new.color).click(); + + cy.get('input[name="addPermissionRoles"]').parent().find("button").click(); + cy.get('ul li[role="option"]') + .contains(TEST_DATA.new.addPermissionRoles) + .click(); + cy.get('form[role="dialog"]').click(); + + cy.get('input[name="removePermissionRoles"]') + .parent() + .find("button") + .click(); + cy.get('ul li[role="option"]') + .contains(TEST_DATA.new.removePermissionRoles) + .click(); + cy.get('form[role="dialog"]').click(); + + if (TEST_DATA.new.allowPublish) { + cy.get('input[name="allowPublish"]').check(); + } else { + cy.get('input[name="allowPublish"]').uncheck(); + } + + cy.intercept("POST", ENDPOINTS?.statusLabels).as("createStatusLabel"); + cy.intercept(ENDPOINTS.allStatusLabels).as("getAllStatusLabels"); + + cy.get('[data-cy="status-label-submit-button"]').click(); + + cy.wait(["@createStatusLabel", "@getAllStatusLabels"], TIMEOUT).spread( + (createStatusLabel, getAllStatusLabels) => { + const createdStatusLabel = createStatusLabel?.response?.body?.data; + const { active } = parseStatusLabels( + getAllStatusLabels?.response?.body?.data + ); + + expect(createdStatusLabel).to.include({ + name: TEST_DATA.new.name, + description: TEST_DATA.new.description, + color: colorMenu.find((color) => color?.label === TEST_DATA.new.color) + ?.value, + allowPublish: TEST_DATA.new.allowPublish, + }); + expect(createdStatusLabel.addPermissionRoles).to.have.lengthOf(1); + expect(createdStatusLabel.removePermissionRoles).to.have.lengthOf(1); + + cy.get( + '[data-cy="active-labels-container"] [data-cy="status-label"]' + ).should("have.length", active.length); + } + ); + cy.wait(1500); + }); + + it("Highlights the newly created status label.", () => { + cy.get('[data-cy="active-labels-container"] [data-cy="status-label"]') + .contains(TEST_DATA.new.name) + .parents('[data-cy="status-label"]') + .should("have.css", "background-color", FOCUSED_LABEL_COLOR, TIMEOUT); + }); + + it("Clicking outside the focused label restores it to its default state.", () => { + cy.get('[data-cy="active-labels-container"]').click(); + cy.get('[data-cy="active-labels-container"] [data-cy="status-label"]') + .contains(TEST_DATA.new.name) + .parents('[data-cy="status-label"]') + .should("not.have.css", "background-color", FOCUSED_LABEL_COLOR); + }); +}); + +describe("Edit Status Label", { retries: 1 }, () => { + it("Open Status Label and Edit Details", () => { + cy.goToWorkflowsPage(); + + cy.get('[data-cy="active-labels-container"]', TIMEOUT); + + cy.get('[data-cy="active-labels-container"] [data-cy="status-label"]') + .contains(TEST_DATA?.temp1?.name) + .parents('[data-cy="status-label"]') + .find('[data-cy="status-label-more-actions"]') + .click({ force: true }); + + cy.get('[data-cy="menu-item-edit"]').click({ force: true }); + + cy.get('[data-cy="status-label-form"]', TIMEOUT); + + cy.get("button").contains("Deactivate Status").should("be.enabled"); + + cy.get('input[name="name"]').clear().type(TEST_DATA.edited.name); + + cy.get('textarea[name="description"]') + .clear() + .type(TEST_DATA.edited.description); + + cy.get('input[name="color"]').parent().find("button").click(); + cy.get('ul li[role="option"]').contains(TEST_DATA.edited.color).click(); + + cy.intercept("PUT", `${ENDPOINTS?.statusLabels}/**`).as("editStatusLabel"); + cy.intercept(ENDPOINTS.allStatusLabels).as("getAllStatusLabels"); + + cy.get('[data-cy="status-label-submit-button"]').click(); + + cy.wait(["@editStatusLabel", "@getAllStatusLabels"], TIMEOUT).spread( + (editStatusLabel, getAllStatusLabels) => { + const targetLabelZUID = editStatusLabel.response.body.data; + const updatedLabel = getAllStatusLabels.response.body.data.find( + (label) => label.ZUID === targetLabelZUID + ); + + expect(editStatusLabel.response.statusCode).to.eq(200); + expect(getAllStatusLabels.response.statusCode).to.eq(200); + expect(updatedLabel).to.deep.include({ + name: TEST_DATA.edited.name, + description: TEST_DATA.edited.description, + color: colorMenu.find( + (color) => color?.label === TEST_DATA.edited.color + )?.value, + }); + } + ); + }); +}); + +describe("Re-order Status Labels", { retries: 1 }, () => { + before(() => { + cy.goToWorkflowsPage(); + }); + + it("Drag status label to a new position", () => { + const dataTransfer = new DataTransfer(); + + cy.get('[data-cy="active-labels-container"]', TIMEOUT); + + cy.intercept("PUT", ENDPOINTS.statusLabels).as("reorderStatusLabel"); + cy.intercept("GET", ENDPOINTS.allStatusLabels).as("getAllStatusLabels"); + + cy.get(`[data-cy="status-label"] [data-cy="status-label-drag-handle"]`) + .eq(0) + .trigger("dragstart", { dataTransfer }); + + cy.get(`[data-cy="status-label"] [data-cy="status-label-drag-handle"]`) + .eq(1) + .trigger("dragover", { dataTransfer }) + .trigger("drop", { dataTransfer }); + + cy.wait(["@reorderStatusLabel", "@getAllStatusLabels"], TIMEOUT).spread( + (reorderStatusLabel, getAllStatusLabels) => { + const reorderedLabels = reorderStatusLabel?.request?.body?.data; + const updatedLabel = getAllStatusLabels?.response?.body?.data; + + expect(reorderStatusLabel.response.statusCode).to.eq(200); + expect(getAllStatusLabels.response.statusCode).to.eq(200); + + reorderedLabels.forEach((label) => { + expect( + updatedLabel.find((item) => item?.ZUID === label?.ZUID)?.sort + ).to.eq(label.sort); + }); + } + ); + }); +}); + +describe("Deactivate Status Label", { retries: 1 }, () => { + beforeEach(() => { + cy.goToWorkflowsPage(); + }); + + it("Deactivate using menu options", () => { + cy.get('[data-cy="active-labels-container"]', TIMEOUT); + + cy.get('[data-cy="active-labels-container"] [data-cy="status-label"]') + .contains(TEST_DATA?.temp2?.name) + .parents('[data-cy="status-label"]') + .find('[data-cy="status-label-more-actions"]') + .click(TIMEOUT); + + cy.get('[data-cy="menu-item-deactivate"]').click(); + + cy.intercept("DELETE", `${ENDPOINTS?.statusLabels}/**`).as( + "deactivateStatusLabel" + ); + cy.intercept("GET", ENDPOINTS.allStatusLabels).as("getAllStatusLabels"); + + cy.get('[data-cy="deactivation-dialog-confirm-button"]').click(); + + cy.wait(["@deactivateStatusLabel", "@getAllStatusLabels"]).spread( + (deactivateStatusLabel, getAllStatusLabels) => { + const deactivatedLabel = deactivateStatusLabel?.response?.body?.data; + const { active, deactivated } = parseStatusLabels( + getAllStatusLabels?.response?.body?.data + ); + + expect(active?.some((label) => label.ZUID === deactivatedLabel)).to.be + .false; + expect(deactivated?.some((label) => label.ZUID === deactivatedLabel)).to + .be.true; + } + ); + cy.wait(1000); + + cy.get(".notistack-Snackbar").contains( + new RegExp(`Status De-activated:\\s*${TEST_DATA?.temp2?.name}`) + ); + }); + + it("Deactivate using form button", () => { + cy.get('[data-cy="active-labels-container"]', TIMEOUT); + + cy.get('[data-cy="active-labels-container"] [data-cy="status-label"]') + .contains(TEST_DATA?.temp3?.name) + .parents('[data-cy="status-label"]') + .find('[data-cy="status-label-more-actions"]') + .click(TIMEOUT); + + cy.get('[data-cy="menu-item-edit"]').click(); + + cy.intercept("DELETE", `${ENDPOINTS?.statusLabels}/**`).as( + "deactivateStatusLabel" + ); + cy.intercept("GET", ENDPOINTS.allStatusLabels).as("getAllStatusLabels"); + + cy.get('[data-cy="form-deactivate-status-button"]').click(); + + cy.get('[data-cy="deactivation-dialog-confirm-button"]').click(); + + cy.wait(["@deactivateStatusLabel", "@getAllStatusLabels"]).spread( + (deactivateStatusLabel, getAllStatusLabels) => { + const deactivatedLabel = deactivateStatusLabel?.response?.body?.data; + const { active, deactivated } = parseStatusLabels( + getAllStatusLabels?.response?.body?.data + ); + + expect(active?.some((label) => label.ZUID === deactivatedLabel)).to.be + .false; + expect(deactivated?.some((label) => label.ZUID === deactivatedLabel)).to + .be.true; + } + ); + cy.wait(1000); + cy.get(".notistack-Snackbar").contains( + new RegExp(`Status De-activated:\\s*${TEST_DATA?.temp3?.name}`) + ); + }); +}); + +describe("Filter Active and Deactivated Status Labels", { retries: 1 }, () => { + before(() => { + cy.getStatusLabels().then((data) => + cy.wrap(data).as("activeDeactivatedStatusLabels") + ); + cy.goToWorkflowsPage(); + cy.get('input[value="deactivated"]').click(TIMEOUT); + }); + + it("Displays active/deactivated status labels", function () { + const { active, deactivated } = this.activeDeactivatedStatusLabels || {}; + cy.get( + '[data-cy="active-labels-container"] [data-cy="status-label"]' + ).should("have.length", active?.length); + cy.get( + '[data-cy="deactivated-labels-container"] [data-cy="status-label"]' + ).should("have.length", deactivated?.length); + }); + + it("Displays Error page when search results returns empty", () => { + cy.get('input[placeholder="Search Statuses"]') + .clear() + .type(EMPTY_SEARCH_TEXT); + cy.get( + '[data-cy="active-labels-container"], [data-cy="deactivated-labels-container"]' + ).should("not.exist"); + cy.get('[data-cy="no-results-page"]').should("exist"); + }); + + it("Clears and focuses search field when clicking 'Search Again'", () => { + cy.get("button").contains("Search Again").click(); + cy.get( + '[data-cy="active-labels-container"], [data-cy="deactivated-labels-container"]' + ).should("exist"); + cy.get('[data-cy="no-results-page"]').should("not.exist"); + cy.get('input[placeholder="Search Statuses"]').should("be.empty"); + }); +}); + +Cypress.Commands.add("goToWorkflowsPage", () => { + cy.visit("/settings/user/workflows"); + cy.get('[data-cy="workflows-authorized-page"]', { timeout: 60_000 }); +}); + +Cypress.Commands.add("cleanTestData", function () { + const testLabels = [ + TEST_DATA?.new?.name, + TEST_DATA?.edited?.name, + TEST_DATA?.temp1?.name, + TEST_DATA?.temp2?.name, + TEST_DATA?.temp3?.name, + ]; + + cy.apiRequest({ url: `${INSTANCE_API}/env/labels?showDeleted=true` }).then( + (response) => { + response?.data + ?.filter( + (label) => !label?.deletedAt && testLabels.includes(label?.name) + ) + .forEach((label) => { + cy.apiRequest({ + url: `${INSTANCE_API}/env/labels/${label.ZUID}`, + method: "DELETE", + }); + }); + } + ); +}); + +Cypress.Commands.add("createTestData", () => { + const testLabels = [TEST_DATA?.temp1, TEST_DATA?.temp2, TEST_DATA?.temp3]; + testLabels.forEach((label) => { + cy.apiRequest({ + url: `${INSTANCE_API}/env/labels`, + method: "POST", + body: { + ...label, + color: colorMenu.find((color) => color?.label === label.color)?.value, + }, + }); + }); +}); + +Cypress.Commands.add("getStatusLabels", () => { + return cy + .apiRequest({ + url: `${INSTANCE_API}/env/labels?showDeleted=true`, + }) + .then((response) => { + return parseStatusLabels(response?.data); + }); +}); + +function parseStatusLabels(statusLabels = []) { + const { active, deactivated } = statusLabels.reduce( + (acc, curr) => { + if (!!curr.deletedAt) { + acc.deactivated.push(curr); + } else { + acc.active.push(curr); + } + return acc; + }, + { active: [], deactivated: [] } + ); + return { active, deactivated }; +} diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 3ecb8d9bef..917707b7c4 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -51,3 +51,23 @@ Cypress.Commands.add("blockAnnouncements", () => { req.reply({}); }); }); + +Cypress.Commands.add( + "apiRequest", + ({ method = "GET", url = "", body = undefined }) => { + return cy.getCookie(Cypress.env("COOKIE_NAME")).then((cookie) => { + const token = cookie?.value; + return cy + .request({ + url, + method, + headers: { authorization: `Bearer ${token}` }, + ...(body ? { body: body } : {}), + }) + .then((response) => ({ + status: response?.isOkStatusCode ? "success" : "error", + data: response?.body?.data, + })); + }); + } +); diff --git a/public/images/restricted-image.svg b/public/images/restricted-image.svg new file mode 100644 index 0000000000..94152cab0c --- /dev/null +++ b/public/images/restricted-image.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx b/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx index ff5819d989..effbb06b25 100644 --- a/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx +++ b/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx @@ -27,6 +27,7 @@ import { useCreateItemPublishingMutation, useGetContentItemQuery, useGetContentModelFieldsQuery, + useGetWorkflowStatusLabelsQuery, } from "../../../../../../shell/services/instance"; import { Error } from "../../components/Editor/Field/FieldShell"; import { @@ -100,6 +101,10 @@ export const ItemCreate = () => { isSuccess: isSuccessNewModelFields, isLoading: isFetchingNewModelFields, } = useGetContentModelFieldsQuery(modelZUID); + const { data: statusLabels } = useGetWorkflowStatusLabelsQuery(); + const hasAllowPublishLabel = statusLabels?.some( + (label) => label.allowPublish + ); // on mount and update modelZUID, load item fields useEffect(() => { @@ -310,28 +315,85 @@ export const ItemCreate = () => { break; case "publishNow": - // Make an api call to publish now - handlePublish(res.data.ZUID); - setWillRedirect(true); + if (hasAllowPublishLabel) { + // New items will by default have no workflow labels, therefore as long as there's a workflow label + // that is used to allow publish permissions, new content items should never be allowed to be published + // from the create new content item page + dispatch( + notify({ + message: `Cannot Publish: "${item.web.metaTitle}". Does not have a status that allows publishing`, + kind: "error", + }) + ); + history.push( + `/${ + model?.type === "block" ? "blocks" : "content" + }/${modelZUID}/${res.data.ZUID}` + ); + } else { + // Make an api call to publish now + handlePublish(res.data.ZUID); + setWillRedirect(true); + } break; case "schedulePublish": - // Open schedule publish flyout and redirect to item once done - setIsScheduleDialogOpen(true); - setWillRedirect(true); + if (hasAllowPublishLabel) { + // New items will by default have no workflow labels, therefore as long as there's a workflow label + // that is used to allow publish permissions, new content items should never be allowed to be published + // from the create new content item page + dispatch( + notify({ + message: `Cannot Publish: "${item.web.metaTitle}". Does not have a status that allows publishing`, + kind: "error", + }) + ); + history.push( + `/${ + model?.type === "block" ? "blocks" : "content" + }/${modelZUID}/${res.data.ZUID}` + ); + } else { + // Open schedule publish flyout and redirect to item once done + setIsScheduleDialogOpen(true); + setWillRedirect(true); + } break; case "publishAddNew": - // Publish but stay on page - handlePublish(res.data.ZUID); - setWillRedirect(false); - + if (hasAllowPublishLabel) { + // New items will by default have no workflow labels, therefore as long as there's a workflow label + // that is used to allow publish permissions, new content items should never be allowed to be published + // from the create new content item page + dispatch( + notify({ + message: `Cannot Publish: "${item.web.metaTitle}". Does not have a status that allows publishing`, + kind: "error", + }) + ); + } else { + // Publish but stay on page + handlePublish(res.data.ZUID); + setWillRedirect(false); + } break; case "schedulePublishAddNew": - // Open schedule publish flyout but stay on page once done - setIsScheduleDialogOpen(true); - setWillRedirect(false); + if (hasAllowPublishLabel) { + // New items will by default have no workflow labels, therefore as long as there's a workflow label + // that is used to allow publish permissions, new content items should never be allowed to be published + // from the create new content item page + dispatch( + notify({ + message: `Cannot Publish: "${item.web.metaTitle}". Does not have a status that allows publishing`, + kind: "error", + }) + ); + } else { + // Open schedule publish flyout but stay on page once done + setIsScheduleDialogOpen(true); + setWillRedirect(false); + } break; default: diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index dafbf137f4..f25252523a 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -18,6 +18,8 @@ import { useGetAuditsQuery, useGetContentModelFieldsQuery, useGetItemPublishingsQuery, + useGetItemWorkflowStatusQuery, + useGetWorkflowStatusLabelsQuery, } from "../../../../../../../../shell/services/instance"; import { useHistory, useParams } from "react-router"; import { useEffect, useMemo, useState } from "react"; @@ -50,6 +52,8 @@ import { ContentModel, } from "../../../../../../../../shell/services/types"; import { SchedulePublish } from "../../../../../../../../shell/components/SchedulePublish"; +import { notify } from "../../../../../../../../shell/store/notifications"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query"; import { ConfirmPublishModal } from "../../../../../../../../shell/components/ConfirmPublishModal"; import { UnpublishedRelatedItem } from "./UnpublishedRelatedItem"; import { uniqBy } from "lodash"; @@ -120,6 +124,12 @@ export const ItemEditHeaderActions = ({ const activePublishing = itemPublishings?.find( (itemPublishing) => itemPublishing._active ); + const { data: statusLabels } = useGetWorkflowStatusLabelsQuery(); + const { data: itemWorkflowStatus, isLoading: isLoadingItemWorkflowStatus } = + useGetItemWorkflowStatusQuery( + { itemZUID, modelZUID }, + { skip: !itemZUID || !modelZUID } + ); const saveShortcut = useMetaKey("s", () => { if (!canUpdate) return; @@ -224,61 +234,90 @@ export const ItemEditHeaderActions = ({ } })(); + const allowPublish = useMemo(() => { + const allowPublishLabelZUIDs = statusLabels?.reduce((acc, next) => { + if (next.allowPublish) { + return (acc = [...acc, next.ZUID]); + } + + return acc; + }, []); + + if (!allowPublishLabelZUIDs?.length) return true; + + const itemWorkflowLabelZUIDs = itemWorkflowStatus?.find( + (i) => i.itemVersion === item?.meta?.version + )?.labelZUIDs; + + return itemWorkflowLabelZUIDs?.some((labelZUID) => + allowPublishLabelZUIDs?.includes(labelZUID) + ); + }, [statusLabels, itemWorkflowStatus, item?.meta?.version]); + const handlePublish = async () => { - setIsPublishing(true); - try { - // Delete scheduled publishings first - const deleteScheduledPromises = [ - // Delete main item's scheduled publishing if it exists - itemState === ITEM_STATES.scheduled && - deleteItemPublishing({ + if (allowPublish) { + setIsPublishing(true); + try { + // Delete scheduled publishings first + const deleteScheduledPromises = [ + // Delete main item's scheduled publishing if it exists + itemState === ITEM_STATES.scheduled && + deleteItemPublishing({ + modelZUID, + itemZUID, + publishingZUID: item?.scheduling?.ZUID, + }), + // Delete related items' scheduled publishings if they exist + ...relatedItemsToPublish + .filter((item) => !!item.scheduling?.ZUID) + .map((item) => + deleteItemPublishing({ + modelZUID: item.meta.contentModelZUID, + itemZUID: item.meta.ZUID, + publishingZUID: item.scheduling.ZUID, + }) + ), + ].filter((item) => !!item); + + await Promise.all(deleteScheduledPromises); + + // Proceed with publishing + await Promise.allSettled([ + createPublishing({ modelZUID, itemZUID, - publishingZUID: item?.scheduling?.ZUID, + body: { + version: item?.meta.version, + publishAt: "now", + unpublishAt: "never", + }, }), - // Delete related items' scheduled publishings if they exist - ...relatedItemsToPublish - .filter((item) => !!item.scheduling?.ZUID) - .map((item) => - deleteItemPublishing({ + ...relatedItemsToPublish.map((item) => + createPublishing({ modelZUID: item.meta.contentModelZUID, itemZUID: item.meta.ZUID, - publishingZUID: item.scheduling.ZUID, + body: { + version: item.meta.version, + publishAt: "now", + unpublishAt: "never", + }, }) ), - ].filter((item) => !!item); - - await Promise.all(deleteScheduledPromises); - - // Proceed with publishing - await Promise.allSettled([ - createPublishing({ - modelZUID, - itemZUID, - body: { - version: item?.meta.version, - publishAt: "now", - unpublishAt: "never", - }, - }), - ...relatedItemsToPublish.map((item) => - createPublishing({ - modelZUID: item.meta.contentModelZUID, - itemZUID: item.meta.ZUID, - body: { - version: item.meta.version, - publishAt: "now", - unpublishAt: "never", - }, - }) - ), - ]); - - // Retain non rtk-query fetch of item publishing for legacy code - dispatch(fetchItemPublishings()); - } finally { - setIsPublishing(false); - setIsConfirmPublishModalOpen(false); + ]); + + // Retain non rtk-query fetch of item publishing for legacy code + dispatch(fetchItemPublishings()); + } finally { + setIsPublishing(false); + setIsConfirmPublishModalOpen(false); + } + } else { + dispatch( + notify({ + message: `Cannot Publish: "${item.web.metaTitle}". Does not have a status that allows publishing`, + kind: "error", + }) + ); } }; @@ -611,7 +650,18 @@ export const ItemEditHeaderActions = ({ setPublishAfterSave={setPublishAfterSave} setScheduleAfterSave={setScheduleAfterSave} setUnpublishDialogOpen={setUnpublishDialogOpen} - setScheduledPublishDialogOpen={setScheduledPublishDialogOpen} + setScheduledPublishDialogOpen={(open) => { + if (!allowPublish) { + dispatch( + notify({ + message: `Cannot Publish: "${item.web.metaTitle}". Does not have a status that allows publishing`, + kind: "error", + }) + ); + } else { + setScheduledPublishDialogOpen(open); + } + }} setPublishAfterUnschedule={() => { setScheduledPublishDialogOpen(true); setPublishAfterUnschedule(true); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/VersionSelector.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/VersionSelector.tsx deleted file mode 100644 index 80a4c93da8..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/VersionSelector.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { Button, Menu, MenuItem, Box, Tooltip } from "@mui/material"; -import { - useGetContentItemVersionsQuery, - useGetItemPublishingsQuery, -} from "../../../../../../../../shell/services/instance"; -import { useLocation, useParams } from "react-router"; -import { KeyboardArrowDownRounded } from "@mui/icons-material"; -import { useEffect, useState } from "react"; -import { useDispatch, useSelector } from "react-redux"; -import { ContentItem } from "../../../../../../../../shell/services/types"; -import { AppState } from "../../../../../../../../shell/store/types"; -import { formatDate } from "../../../../../../../../utility/formatDate"; - -export const VersionSelector = () => { - const dispatch = useDispatch(); - const { modelZUID, itemZUID } = useParams<{ - modelZUID: string; - itemZUID: string; - }>(); - const location = useLocation(); - const queryParams = new URLSearchParams(location.search); - const [anchorEl, setAnchorEl] = useState(null); - const { data: versions } = useGetContentItemVersionsQuery({ - modelZUID, - itemZUID, - }); - const { data: itemPublishings } = useGetItemPublishingsQuery({ - modelZUID, - itemZUID, - }); - - const item = useSelector( - (state: AppState) => state.content[itemZUID] as ContentItem - ); - - const onSelect = (version: ContentItem) => { - dispatch({ - type: "LOAD_ITEM_VERSION", - itemZUID: itemZUID, - data: version, - }); - }; - - useEffect(() => { - const versionParam = queryParams.get("version"); - const version = versions?.find((v) => v.meta.version === +versionParam); - if (version) { - onSelect(version); - } - }, [queryParams.get("version"), versions]); - - return ( - <> - - - - setAnchorEl(null)} - anchorOrigin={{ - vertical: "bottom", - horizontal: "right", - }} - transformOrigin={{ - vertical: -10, - horizontal: "right", - }} - anchorEl={anchorEl} - open={!!anchorEl} - PaperProps={{ - style: { - maxHeight: "496px", - overflow: "auto", - width: "320px", - }, - }} - > - {versions?.map((version) => ( - { - setAnchorEl(null); - onSelect(version); - }} - > - - - {`v${version.meta.version}${ - itemPublishings?.find( - (itemPublishing) => itemPublishing._active - )?.version === version.meta.version - ? " - Live" - : "" - }`} - - {formatDate(version.web.createdAt)} - - - ))} - - - ); -}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/VersionSelector/NoResults.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/VersionSelector/NoResults.tsx new file mode 100644 index 0000000000..b3bce959cf --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/VersionSelector/NoResults.tsx @@ -0,0 +1,37 @@ +import { Button, Box, Stack, Typography } from "@mui/material"; +import { Search } from "@mui/icons-material"; + +import noResults from "../../../../../../../../../../public/images/noSearchResults.jpg"; + +type NoResultsProps = { + query: string; + onSearchAgain: () => void; +}; +export const NoResults = ({ query, onSearchAgain }: NoResultsProps) => { + return ( + + No Search Results + + + Your search "{query}" could not find any results + + + Try adjusting your search. We suggest check all words are spelled + correctly or try using different keywords. + + + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/VersionSelector/Row.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/VersionSelector/Row.tsx new file mode 100644 index 0000000000..6557202c09 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/VersionSelector/Row.tsx @@ -0,0 +1,67 @@ +import { useEffect, CSSProperties, useRef, memo } from "react"; +import { MenuItem } from "@mui/material"; +import { areEqual } from "react-window"; + +import { Version, VersionItem } from "./VersionItem"; +import { useResizeObserver } from "../../../../../../../../../shell/hooks/useResizeObserver"; + +type RowProps = { + index: number; + style: CSSProperties; + data: { + versions: Version[]; + activeVersion: number; + handleLoadVersion: (version: number) => void; + setRowHeight: (index: number, size: number) => void; + }; +}; +export const Row = memo(({ index, style, data }: RowProps) => { + const rowRef = useRef(null); + const version = data?.versions[index]; + const dimensions = useResizeObserver(rowRef); + + useEffect(() => { + if (!!dimensions) { + data?.setRowHeight(index, dimensions?.height); + } + }, [dimensions]); + + return ( + { + data?.handleLoadVersion(version?.itemVersion); + }} + > + + + ); +}, areEqual); + +Row.displayName = "VersionRow"; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/VersionSelector/VersionItem.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/VersionSelector/VersionItem.tsx new file mode 100644 index 0000000000..a946c3a3de --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/VersionSelector/VersionItem.tsx @@ -0,0 +1,395 @@ +import { + memo, + useState, + forwardRef, + useRef, + ForwardedRef, + useMemo, +} from "react"; +import { + Box, + MenuItem, + Stack, + Typography, + Chip, + TextField, + InputAdornment, + ListItemIcon, + ListItemText, + Tooltip, +} from "@mui/material"; +import { + ScheduleRounded, + LanguageRounded, + AddRounded, + SearchRounded, + EditRounded, + Check, +} from "@mui/icons-material"; +import { useSelector } from "react-redux"; +import { useDebounce, useUnmount } from "react-use"; +import { useHistory, useParams } from "react-router"; +import { areEqual } from "react-window"; + +import { + useGetWorkflowStatusLabelsQuery, + useUpdateItemWorkflowStatusMutation, +} from "../../../../../../../../../shell/services/instance"; +import { + User, + WorkflowStatusLabel, +} from "../../../../../../../../../shell/services/types"; +import { AppState } from "../../../../../../../../../shell/store/types"; +import { useGetUsersRolesQuery } from "../../../../../../../../../shell/services/accounts"; +import { NoResults } from "./NoResults"; + +export const BG_COLOR_MAPPING: Record = { + "#0ba5ec": "blue.100", + "#12b76a": "green.100", + "#f79009": "yellow.100", + "#4e5ba6": "deepPurple.100", + "#7a5af8": "purple.100", + "#ee46bc": "pink.100", + "#ff5c08": "deepOrange.100", + "#f04438": "red.100", + "#f63d68": "#ffe4e8", + "#667085": "grey.100", +} as const; +export type Version = { + itemZUID: string; + modelZUID: string; + itemVersionZUID: string; + itemVersion: number; + itemWorkflowZUID: string; + labels: WorkflowStatusLabel[]; + createdAt: string; + isPublished: boolean; + isScheduled: boolean; +}; +type VersionItemProps = { + data: Version; + isActive: boolean; +}; +export const VersionItem = memo( + forwardRef( + ( + { data, isActive }: VersionItemProps, + ref: ForwardedRef + ) => { + const history = useHistory(); + const { modelZUID, itemZUID } = useParams<{ + modelZUID: string; + itemZUID: string; + }>(); + const user: User = useSelector((state: AppState) => state.user); + const addNewLabelRef = useRef(null); + const searchRef = useRef(null); + const { data: statusLabels } = useGetWorkflowStatusLabelsQuery(); + const { data: usersRoles } = useGetUsersRolesQuery(); + const [updateItemWorkflowStatus] = useUpdateItemWorkflowStatusMutation(); + const [isAddNewLabelOpen, setIsAddNewLabelOpen] = useState(false); + const [filterKeyword, setFilterKeyword] = useState(""); + const [debouncedFilterKeyword, setDebouncedFilterKeyword] = useState(""); + const [activeLabels, setActiveLabels] = useState( + data?.labels?.map((label) => label?.ZUID)?.filter((label) => !!label) + ); + + const currentUserRoleZUID = usersRoles?.find( + (userWithRole) => userWithRole.ZUID === user.ZUID + )?.role?.ZUID; + + useDebounce(() => setDebouncedFilterKeyword(filterKeyword), 200, [ + filterKeyword, + ]); + + useUnmount(() => saveLabelChanges()); + + const filteredStatusLabels = useMemo(() => { + const sortedStatusLabels = [...statusLabels]?.sort( + (a, b) => a.sort - b.sort + ); + + if (!debouncedFilterKeyword) return sortedStatusLabels; + + return sortedStatusLabels + ?.filter((label) => + label.name + ?.toLowerCase() + .includes(debouncedFilterKeyword?.toLowerCase()?.trim()) + ) + .sort((a, b) => a.sort - b.sort); + }, [statusLabels, debouncedFilterKeyword]); + + const handleOpenAddNewLabel = (evt: any) => { + evt.stopPropagation(); + + if (isActive) { + setIsAddNewLabelOpen((prev) => !prev); + + setTimeout(() => { + searchRef.current?.querySelector("input").focus(); + }); + } + }; + + const handleToggleLabel = (ZUID: string) => { + if (activeLabels?.includes(ZUID)) { + setActiveLabels(activeLabels.filter((label) => label !== ZUID)); + } else { + setActiveLabels([...activeLabels, ZUID]); + } + }; + + const saveLabelChanges = () => { + if (activeLabels.length !== data?.labels?.length) { + updateItemWorkflowStatus({ + modelZUID, + itemZUID, + itemWorkflowZUID: data?.itemWorkflowZUID, + labelZUIDs: activeLabels, + }); + } + }; + + return ( + + + + + v{data?.itemVersion} + + {data?.isPublished && ( + + + + Published + + + )} + {data?.isScheduled && ( + + + + Scheduled + + + )} + + + {data?.createdAt} + + + + {activeLabels?.map((labelZUID) => { + const labelData = statusLabels?.find( + (status) => status.ZUID === labelZUID + ); + + return ( + + ); + })} + {isActive && ( + {}} + deleteIcon={} + /> + )} + + {isAddNewLabelOpen && ( + evt.stopPropagation()} + borderTop={1} + borderColor="border" + width="100%" + overflow="hidden" + > + setFilterKeyword(evt.currentTarget.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + placeholder="Search status" + size="small" + fullWidth + sx={{ + my: 1.5, + px: 1, + }} + /> + {!filteredStatusLabels?.length && filterKeyword && ( + { + setFilterKeyword(""); + searchRef.current?.querySelector("input").focus(); + }} + /> + )} + {filteredStatusLabels?.map((label, index) => { + let title = ""; + const canRemove = + label.removePermissionRoles?.includes(currentUserRoleZUID); + const canAdd = + label.addPermissionRoles?.includes(currentUserRoleZUID); + + if (!canAdd && !activeLabels.includes(label.ZUID)) { + title = "Do not have permission to add this status"; + } + + if (!canRemove && activeLabels.includes(label.ZUID)) { + title = "Do not have permission to remove this status"; + } + + return ( + + { + if ( + (activeLabels.includes(label.ZUID) && canRemove) || + (!activeLabels.includes(label.ZUID) && canAdd) + ) { + handleToggleLabel(label.ZUID); + } + }} + > + + + + + + {label.name} + + + + + {label.description} + + + + ); + })} + history.push("/settings/user/workflows")} + > + + + + Edit Statuses + + + )} + + ); + } + ), + areEqual +); + +VersionItem.displayName = "VersionItem"; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/VersionSelector/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/VersionSelector/index.tsx new file mode 100644 index 0000000000..ac26b5394c --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/VersionSelector/index.tsx @@ -0,0 +1,298 @@ +import { useState, memo, useMemo, useRef, useEffect } from "react"; +import { Button, Tooltip, Chip, MenuList, Popover } from "@mui/material"; +import { KeyboardArrowDownRounded } from "@mui/icons-material"; +import { useParams } from "react-router"; +import moment from "moment"; +import { useDispatch } from "react-redux"; +import { VariableSizeList } from "react-window"; +import AutoSizer from "react-virtualized-auto-sizer"; + +import { Row } from "./Row"; +import { BG_COLOR_MAPPING } from "./VersionItem"; + +const formatDateTime = (dateTimeString: string) => { + if (!dateTimeString) return ""; + + const momentDate = moment(dateTimeString); + const now = moment(); + + if (momentDate.isSame(now, "day")) { + return `Today ${momentDate.format("h:mm A")}`; + } else if (momentDate.isSame(now.clone().subtract(1, "day"), "day")) { + return `Yesterday ${momentDate.format("h:mm A")}`; + } else if (momentDate.isSame(now.clone().add(1, "day"), "day")) { + return `Tomorrow ${momentDate.format("h:mm A")}`; + } else { + return momentDate.format("MMM D h:mm A"); + } +}; + +import { + useGetContentItemVersionsQuery, + useGetItemPublishingsQuery, + useGetItemWorkflowStatusQuery, + useGetWorkflowStatusLabelsQuery, +} from "../../../../../../../../../shell/services/instance"; +import { Version } from "./VersionItem"; +import { WorkflowStatusLabel } from "../../../../../../../../../shell/services/types"; + +export let ROW_HEIGHTS: Record = {}; +export const DEFAULT_ROW_HEIGHT = 66; +const DEFAULT_LIST_HEIGHT = 540; + +type VersionSelectorProps = { + activeVersion: number; +}; +export const VersionSelector = memo( + ({ activeVersion }: VersionSelectorProps) => { + const dispatch = useDispatch(); + const listRef = useRef(null); + const rowHeights = useRef(null); + const [anchorEl, setAnchorEl] = useState(null); + const [listHeight, setListHeight] = useState(DEFAULT_LIST_HEIGHT); + const { modelZUID, itemZUID } = useParams<{ + modelZUID: string; + itemZUID: string; + }>(); + const { data: statusLabels, isLoading: isLoadingStatusLabels } = + useGetWorkflowStatusLabelsQuery(); + const { data: itemWorkflowStatus, isLoading: isLoadingItemWorkflowStatus } = + useGetItemWorkflowStatusQuery( + { itemZUID, modelZUID }, + { skip: !itemZUID || !modelZUID } + ); + const { data: itemPublishings, isLoading: isLoadingItemPublishings } = + useGetItemPublishingsQuery( + { + modelZUID, + itemZUID, + }, + { skip: !modelZUID || !itemZUID } + ); + const { data: versions, isLoading: isLoadingVersions } = + useGetContentItemVersionsQuery( + { + modelZUID, + itemZUID, + }, + { skip: !modelZUID || !itemZUID } + ); + + const mappedVersions: Version[] = useMemo(() => { + if (!versions?.length) return []; + + const activeVersion = itemPublishings?.find( + (itemPublishing) => itemPublishing._active + ); + const scheduledVersion = itemPublishings?.find( + (item) => + !item._active && + moment.utc(item.publishAt).isAfter(moment.utc()) && + !item.unpublishAt + ); + + return versions.map((v) => { + let labels: WorkflowStatusLabel[] = []; + let itemWorkflowZUID = ""; + + if (statusLabels?.length && itemWorkflowStatus?.length) { + const workflowStatusData = itemWorkflowStatus.find( + (status) => status.itemVersion === v.meta?.version + ); + const labelZUIDs = workflowStatusData?.labelZUIDs; + itemWorkflowZUID = workflowStatusData?.ZUID; + + labels = labelZUIDs?.map((labelZUID) => + statusLabels.find((statusLabel) => statusLabel.ZUID === labelZUID) + ); + } + + return { + itemZUID: v.meta?.ZUID, + modelZUID: v.meta?.contentModelZUID, + itemVersionZUID: v.web?.versionZUID, + itemVersion: v.meta?.version, + itemWorkflowZUID, + labels, + createdAt: formatDateTime(v.web?.createdAt), + isPublished: activeVersion?.version === v.meta?.version, + isScheduled: scheduledVersion?.version === v.meta?.version, + }; + }); + }, [versions, itemPublishings, itemWorkflowStatus, statusLabels]); + + const activeVersionLabels = useMemo(() => { + return mappedVersions + ?.find((version) => version.itemVersion === activeVersion) + ?.labels?.filter((label) => !!label); + }, [mappedVersions, activeVersion]); + + useEffect(() => { + ROW_HEIGHTS = {}; + }, []); + + const handleLoadVersion = (version: number) => { + const versionToLoad = versions?.find((v) => v?.meta?.version === version); + + if (!!versionToLoad) { + dispatch({ + type: "LOAD_ITEM_VERSION", + itemZUID, + data: versionToLoad, + }); + setAnchorEl(null); + } + }; + + const setRowHeight = (index: number, size: number) => { + if (ROW_HEIGHTS[index] !== size) { + ROW_HEIGHTS = { ...ROW_HEIGHTS, [index]: size }; + listRef.current?.resetAfterIndex(index); + } + }; + + const getRowHeight = (index: number) => { + setTimeout(() => { + const totalHeight = +Object.values(ROW_HEIGHTS).reduce( + (acc: number, curr: number) => acc + curr, + 0 + ); + + setListHeight(totalHeight < 540 ? totalHeight : 540); + }); + + return ROW_HEIGHTS[index] || DEFAULT_ROW_HEIGHT; + }; + + return ( + <> + + + + setAnchorEl(null)} + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + transformOrigin={{ + vertical: -8, + horizontal: "right", + }} + anchorEl={anchorEl} + open={!!anchorEl} + slotProps={{ + paper: { + sx: { + height: listHeight, + overflow: "auto", + width: 379, + bgcolor: "grey.50", + }, + }, + }} + sx={{ + "& .MuiList-root": { + py: 0, + }, + }} + > + + {({ height, width }) => { + return ( + + {Row} + + ); + }} + + + + ); + } +); +VersionSelector.displayName = "VersionSelector"; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/index.tsx index 84addffbb7..cfa2ba606a 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/index.tsx @@ -178,7 +178,7 @@ export const ItemEditHeader = ({ saving, onSave, hasError }: HeaderProps) => { {type === "block" && ( <> - + )} { - + )} diff --git a/src/apps/schema/src/app/components/NoResults.tsx b/src/apps/schema/src/app/components/NoResults.tsx index f7f2f55dfb..1fe04e1dca 100644 --- a/src/apps/schema/src/app/components/NoResults.tsx +++ b/src/apps/schema/src/app/components/NoResults.tsx @@ -38,7 +38,13 @@ export const NoResults = ({ type, searchTerm, onButtonClick, sx }: Props) => { }} > No search results - + {TEXT_CONFIG[type].header.replace("{searchTerm}", searchTerm)} diff --git a/src/apps/settings/src/app/App.js b/src/apps/settings/src/app/App.js index a678fdac2f..cd41902d58 100644 --- a/src/apps/settings/src/app/App.js +++ b/src/apps/settings/src/app/App.js @@ -24,7 +24,7 @@ import { fetchFontsInstalled, } from "shell/store/settings"; import { ResizableContainer } from "../../../../shell/components/ResizeableContainer"; - +import Workflows from "./views/User/Workflows"; // Makes sure that other apps using legacy theme does not get affected with the palette const customTheme = createTheme(legacyTheme, { palette: { @@ -138,7 +138,10 @@ export default connect((state) => ({ ({ )} /> + { const location = useLocation(); @@ -110,6 +118,9 @@ export const SettingsNav = () => { instance: instanceSettings?.filter((setting) => setting.label.toLowerCase().includes(keyword) ), + user: USER_SETTINGS_CAT?.filter((setting) => + setting.label.toLowerCase().includes(keyword) + ), meta: GLOBAL_META_CAT?.filter((setting) => setting.label.toLowerCase().includes(keyword) ), @@ -124,6 +135,7 @@ export const SettingsNav = () => { return { instance: instanceSettings, + user: USER_SETTINGS_CAT, meta: GLOBAL_META_CAT, styles: styleSettings, fonts: FONTS_CAT, @@ -141,6 +153,7 @@ export const SettingsNav = () => { > {keyword && !navItems.fonts?.length && + !navItems.user?.length && !navItems.instance?.length && !navItems.meta?.length && !navItems.styles?.length ? ( @@ -164,6 +177,14 @@ export const SettingsNav = () => { tree={navItems.instance} selected={location.pathname} /> + + } + tree={navItems.user} + selected={location.pathname} + /> + = ({ + name, + role, + email, + imageUrl = "", +}) => ( + theme.palette.border} + > + + + + {name} + + {`${role} • ${email}`} + + +); + +const RestrictedPage = () => { + const { isLoading, isError, data } = useGetUsersRolesQuery(); + + const profileList = data + ?.filter((profile) => + AUTHORIZED_ROLES.includes(profile?.role?.systemRoleZUID) + ) + .map((item, index) => ({ + id: item?.ZUID, + name: `${item?.firstName} ${item?.lastName}`, + role: item?.role?.name, + email: item?.email, + imageUrl: "", + sort: + (item?.role?.name && + ROLE_ORDER_MAPPING[ + item?.role?.name as keyof typeof ROLE_ORDER_MAPPING + ]) || + index + 2, + })) + .sort((a, b) => a.sort - b.sort); + + return ( + + + + Workflows + + + + + + + + You need permission to view and edit workflows + + + Contact the instance owner or administrators listed below to + upgrade your role to Admin or Owner. + + + + {!isLoading && + !isError && + profileList.length > 0 && + profileList.map((profile) => ( + + ))} + + + + + + + + + ); +}; + +export default RestrictedPage; diff --git a/src/apps/settings/src/app/views/User/Workflows/authorized/ActiveStatus.tsx b/src/apps/settings/src/app/views/User/Workflows/authorized/ActiveStatus.tsx new file mode 100644 index 0000000000..a3ef253e84 --- /dev/null +++ b/src/apps/settings/src/app/views/User/Workflows/authorized/ActiveStatus.tsx @@ -0,0 +1,89 @@ +import { FC, useCallback, useEffect, useState } from "react"; +import { Box } from "@mui/material"; +import { useUpdateWorkflowStatusLabelOrderMutation } from "../../../../../../../../shell/services/instance"; +import { useFormDialogContext } from "./forms-dialogs"; +import { StatusLabelSorting } from "."; +import { StatusLabel, StatusLabelLoader } from "./StatusLabel"; +import { UpdateSortingOrder } from "../../../../../../../../shell/services/types"; + +type ActiveStatusProps = { + labels: StatusLabelSorting[]; + isLoading: boolean; +}; + +const ActiveStatus: FC = ({ labels, isLoading = false }) => { + const { focusedLabel } = useFormDialogContext(); + const [statusLabels, setStatusLabels] = + useState(labels); + const [updateWorkflowStatusLabelOrder] = + useUpdateWorkflowStatusLabelOrderMutation(); + + const findCard = useCallback( + (id: string) => { + const label = statusLabels.find((c) => c.id === id); + return { label, index: statusLabels.indexOf(label) }; + }, + [statusLabels] + ); + + const moveCard = useCallback( + (id: string, atIndex: number) => { + const { label, index } = findCard(id); + + if (label) { + const updatedLabels = [...statusLabels]; + updatedLabels.splice(index, 1); + updatedLabels.splice(atIndex, 0, label); + + setStatusLabels(updatedLabels); + } + }, + [findCard, statusLabels] + ); + + const onReorder = useCallback(async () => { + const requestPayload: UpdateSortingOrder[] = statusLabels.map( + (item, index) => ({ + ZUID: item.id, + sort: index + 1, + }) + ); + + try { + await updateWorkflowStatusLabelOrder(requestPayload); + } catch (error) { + console.error("Error during reorder:", error); + } + }, [statusLabels, updateWorkflowStatusLabelOrder]); + + useEffect(() => { + if (!isLoading) { + setStatusLabels(labels); + } + }, [labels, isLoading]); + + return ( + <> + {isLoading ? ( + + ) : ( + + {statusLabels.map((label, index) => ( + + ))} + + )} + + ); +}; + +export default ActiveStatus; diff --git a/src/apps/settings/src/app/views/User/Workflows/authorized/DeactivatedStatus.tsx b/src/apps/settings/src/app/views/User/Workflows/authorized/DeactivatedStatus.tsx new file mode 100644 index 0000000000..318652aa99 --- /dev/null +++ b/src/apps/settings/src/app/views/User/Workflows/authorized/DeactivatedStatus.tsx @@ -0,0 +1,37 @@ +import { FC, useEffect, useState } from "react"; +import { Box, Collapse } from "@mui/material"; +import { StatusLabelSorting } from "."; +import { StatusLabel, StatusLabelLoader } from "./StatusLabel"; + +type DeactivatedStatusProps = { + labels: StatusLabelSorting[]; + isLoading: boolean; +}; + +const DeactivatedStatus: FC = ({ + labels, + isLoading, +}) => { + return ( + + {isLoading ? ( + + ) : ( + <> + {labels.map((label: StatusLabelSorting, index: number) => ( + + + + ))} + + )} + + ); +}; + +export default DeactivatedStatus; diff --git a/src/apps/settings/src/app/views/User/Workflows/authorized/StatusLabel.tsx b/src/apps/settings/src/app/views/User/Workflows/authorized/StatusLabel.tsx new file mode 100644 index 0000000000..f7de5ee324 --- /dev/null +++ b/src/apps/settings/src/app/views/User/Workflows/authorized/StatusLabel.tsx @@ -0,0 +1,328 @@ +import { MouseEvent, FC, ReactElement, useState, useCallback } from "react"; +import { useDrag, useDrop } from "react-dnd"; +import { + IconButton, + Menu, + MenuItem, + Fade, + Box, + Paper, + Collapse, + Typography, + Card, + CardActions, + CardContent, + ListItemIcon, + Skeleton, + alpha, +} from "@mui/material"; +import Brightness1Icon from "@mui/icons-material/Brightness1"; +import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; +import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutline"; +import PauseCircleOutlineRoundedIcon from "@mui/icons-material/PauseCircleOutlineRounded"; +import DragIndicatorRoundedIcon from "@mui/icons-material/DragIndicatorRounded"; +import { ClickAwayListener } from "@mui/base/ClickAwayListener"; +import { useFormDialogContext } from "./forms-dialogs"; +import { StatusLabel as StatusLabelTypes } from "../../../../../../../../shell/services/types"; + +export type StatusLabelProps = { + id: string; + isFiltered: boolean; + moveCard?: (id: string, to: number) => void; + findCard?: (id: string) => { index: number }; + isDeactivated?: boolean; + onReorder?: () => void; + isFocused?: boolean; + data: StatusLabelTypes; +}; + +export const StatusLabel: FC = ({ + id, + isFiltered, + moveCard, + findCard, + isDeactivated, + onReorder, + isFocused, + data, +}: StatusLabelProps) => { + const { setFocusedLabel, openStatusLabelForm } = useFormDialogContext(); + + const originalIndex = isDeactivated ? 0 : findCard?.(id)?.index; + + const [{ isDragging }, drag, preview] = useDrag( + () => ({ + type: "draggable", + item: { id, originalIndex }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + options: { + dropEffect: "copy", + }, + end: (item, monitor) => { + const { id: droppedId, originalIndex } = item; + const didDrop = monitor.didDrop(); + if (!didDrop) { + moveCard?.(droppedId, originalIndex); + } + }, + }), + [id, originalIndex, moveCard] + ); + + const [, drop] = useDrop( + () => ({ + accept: "draggable", + hover({ id: draggedId }: { id: string; originalIndex: number }) { + if (draggedId !== id) { + const { index: overIndex } = findCard?.(id); + moveCard?.(draggedId, overIndex); + } + }, + drop: () => { + onReorder?.(); + }, + }), + [findCard, moveCard, onReorder] + ); + + const withClickAwayListener = useCallback( + (component: ReactElement) => { + if (isFocused) { + return ( + setFocusedLabel("")}> + {component} + + ); + } + return component; + }, + [isFocused, setFocusedLabel] + ); + + return ( + + {withClickAwayListener( + drop(preview(node as HTMLElement)) + } + sx={{ + mx: 4, + my: 1, + height: "76px", + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + pt: 2, + pr: 2, + pb: 2, + pl: 1, + columnGap: 1.5, + borderRadius: 2, + opacity: isDragging ? 0 : isDeactivated ? 0.7 : 1, + border: "1px solid", + borderColor: (theme) => theme.palette.border, + backgroundColor: isFocused + ? (theme) => alpha(theme.palette.primary.light, 0.1) + : "background.paper", + }} + > + drag(node as HTMLElement)} + sx={{ + cursor: isDeactivated ? "default" : "grab", + display: "flex", + justifyContent: "center", + alignItems: "center", + flexGrow: 0, + }} + > + + + + + + + + {data?.name} + + + {data?.description} + + + + + + + + )} + + ); +}; + +const MoreActionsMenu = ({ + data, + isDeactivated, +}: { + data: StatusLabelTypes; + isDeactivated?: boolean; +}) => { + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const { openStatusLabelForm, openDeactivationDialog } = + useFormDialogContext(); + + const handleOpen = (event: MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const openEditForm = () => { + openStatusLabelForm({ values: data, isDeactivated: isDeactivated }); + setAnchorEl(null); + }; + + const openDeleteDialog = () => { + openDeactivationDialog({ ZUID: data?.ZUID, name: data?.name }); + setAnchorEl(null); + }; + + return ( + <> + + + + + + + + + + Edit Status + + + + {!isDeactivated && ( + + + + + + Deactivate Status + + + )} + + + ); +}; + +export const StatusLabelSkeleton = ({ width = 1 }: { width: number }) => { + return ( + + + + + + + + + + + + ); +}; + +export const StatusLabelLoader = () => ( + + + + + +); diff --git a/src/apps/settings/src/app/views/User/Workflows/authorized/forms-dialogs/DeactivationDialog.tsx b/src/apps/settings/src/app/views/User/Workflows/authorized/forms-dialogs/DeactivationDialog.tsx new file mode 100644 index 0000000000..9a9da157f3 --- /dev/null +++ b/src/apps/settings/src/app/views/User/Workflows/authorized/forms-dialogs/DeactivationDialog.tsx @@ -0,0 +1,121 @@ +import { FC } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import PauseCircleOutlineRoundedIcon from "@mui/icons-material/PauseCircleOutlineRounded"; +import Typography from "@mui/material/Typography"; +import { useDispatch } from "react-redux"; +import { notify } from "../../../../../../../../../shell/store/notifications"; +import { LoadingButton } from "@mui/lab"; +import { useDeactivateWorkflowStatusLabelMutation } from "../../../../../../../../../shell/services/instance"; + +type DeactivationDialogProps = { + open: boolean; + onClose: () => void; + name: string; + ZUID: string; + callBack?: () => void; +}; + +const DeactivationDialog: FC = ({ + open, + onClose, + name, + ZUID, + callBack, +}) => { + const dispatch = useDispatch(); + const [deactivateWorkflowStatusLabel, { isLoading }] = + useDeactivateWorkflowStatusLabelMutation(); + + const handleConfirm = async () => { + try { + await deactivateWorkflowStatusLabel({ ZUID }); + + onClose(); + callBack?.(); + + dispatch( + notify({ + kind: "error", + message: `Status De-activated: ${name}`, + }) + ); + } catch (error) { + console.error("Status Label Deactivation Error: ", error); + dispatch( + notify({ + kind: "error", + message: `Failed to deactivate status: ${name}. Please try again.`, + }) + ); + } + }; + + return ( + + + + + + + + + Deactivate Status: + + + {name} + + + + + Deactivating this status will remove it from all content items that + currently have it. You can always reactivate this status in the + future. + + + + + + + + Deactivate Status + + + + ); +}; + +export default DeactivationDialog; diff --git a/src/apps/settings/src/app/views/User/Workflows/authorized/forms-dialogs/StatusLabelForm.tsx b/src/apps/settings/src/app/views/User/Workflows/authorized/forms-dialogs/StatusLabelForm.tsx new file mode 100644 index 0000000000..d051b0547b --- /dev/null +++ b/src/apps/settings/src/app/views/User/Workflows/authorized/forms-dialogs/StatusLabelForm.tsx @@ -0,0 +1,529 @@ +import { FC, FormEvent, ReactNode, useEffect, useState } from "react"; +import { + Box, + Button, + TextField, + IconButton, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, + OutlinedInput, + FormControlLabel, + Checkbox, + Autocomplete, +} from "@mui/material"; +import CloseRoundedIcon from "@mui/icons-material/CloseRounded"; +import Brightness1Icon from "@mui/icons-material/Brightness1"; +import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; +import CheckIcon from "@mui/icons-material/Check"; +import SaveIcon from "@mui/icons-material/Save"; +import PauseCircleOutlineRoundedIcon from "@mui/icons-material/PauseCircleOutlineRounded"; +import { LoadingButton } from "@mui/lab"; +import { + useCreateWorkflowStatusLabelMutation, + useUpdateWorkflowStatusLabelMutation, +} from "../../../../../../../../../shell/services/instance"; +import { useFormDialogContext } from "."; +import { useDispatch } from "react-redux"; +import { notify } from "../../../../../../../../../shell/store/notifications"; +import { useGetUsersRolesQuery } from "../../../../../../../../../shell/services/accounts"; +import { + CreateStatusLabel, + StatusLabel, +} from "../../../../../../../../../shell/services/types"; +import { ColorMenu, colorMenu, RoleMenu } from "../../constants"; + +interface FormInputFieldWrapperProps { + label: string; + required?: boolean; + description?: string; + error?: string; + children: ReactNode; +} + +export type StatusLabelFormProps = { + open: boolean; + onClose: () => void; + labels?: StatusLabel[]; + values?: StatusLabel | undefined; + isDeactivated?: boolean; +}; + +const FormInputFieldWrapper: FC = ({ + label, + required = false, + description, + error, + children, +}) => ( + + + {label} + {required && ( + + )} + + {description && ( + + {description} + + )} + + {children} + + {!!error && ( + + {error} + + )} + +); + +const ColorSelectInput = ({ + name, + defaultValue = "", + usedColors = [], +}: { + name: string; + defaultValue?: string | ""; + usedColors: string[]; +}) => { + const availableColors = colorMenu + .sort((a, b) => a.label.localeCompare(b.label)) + .filter((item) => !usedColors.includes(item.value)); + + const defaultColor = + colorMenu?.find( + (item) => + item?.value?.trim()?.toUpperCase() === + defaultValue?.trim()?.toUpperCase() + ) || + availableColors?.[0] || + colorMenu?.[0]; + + const [selectedColor, setSelectedColor] = useState(defaultColor); + + return ( + <> + setSelectedColor(newValue)} + value={selectedColor} + renderOption={(props, option) => ( +
  • + + + + {option.label} + + +
  • + )} + renderInput={(params) => ( + + ), + }} + /> + )} + /> + + + ); +}; + +const RolesSelectInput = ({ + name, + listData, + defaultValue = "", +}: { + name: string; + listData: RoleMenu[]; + defaultValue?: string; +}) => { + const [value, setSelectedColor] = useState(defaultValue || ""); + const sortedListData = [...listData].sort((a, b) => + a.label.localeCompare(b.label) + ); + const getRoleInfo = (zuids: string) => + zuids + ? sortedListData.filter((item) => + zuids.split(",").includes(item.value.trim()) + ) + : []; + const handleChange = (_: unknown, newValue: RoleMenu[]) => + setSelectedColor(newValue.map((item) => item.value).join(",")); + + return ( + <> + option.label} + renderOption={(props, option, { selected }) => ( +
  • + } + checkedIcon={} + checked={selected} + value={option.value} + sx={{ + marginRight: "5px", + position: "absolute", + left: 0, + }} + /> + + {option.label} + +
  • + )} + renderInput={(params) => ( + + )} + ChipProps={{ + sx: { + height: "1.25rem", + backgroundColor: "transparent", + outline: "1px solid", + outlineColor: (theme) => theme.palette.border, + }, + }} + /> + + + ); +}; + +const validateFormData = (formData: CreateStatusLabel) => { + const errors: Record = {}; + if (!formData.name) errors.name = "Name is required"; + // if (!formData.description) errors.description = "Description is required"; + if (!formData.color) errors.color = "Color is required"; + return errors; +}; + +const StatusLabelForm: FC = ({ + open, + onClose, + labels = [], + values, + isDeactivated = false, +}) => { + const ZUID = values?.ZUID || undefined; + const [rolesMenuItems, setRolesMenuItems] = useState([]); + const { + isLoading: rolesLoading, + isFetching, + data: rolesMenuData, + } = useGetUsersRolesQuery(); + + const [formErrors, setFormErrors] = useState>({}); + const dispatch = useDispatch(); + const [createWorkflowStatusLabel, { isLoading: createLabelIsLoading }] = + useCreateWorkflowStatusLabelMutation(); + const [updateWorkflowStatusLabel, { isLoading: editLabelIsLoading }] = + useUpdateWorkflowStatusLabelMutation(); + const { openDeactivationDialog, setFocusedLabel } = useFormDialogContext(); + + const usedColors = labels.map((label) => label.color); + + const transformRoleValues = (value: string): string[] => + value ? value.split(",") : []; + + const handleFormSubmit = async (e: FormEvent) => { + e.preventDefault(); + const formData = Object.fromEntries(new FormData(e.currentTarget)); + + const newStatusLabel: CreateStatusLabel = { + name: formData.name as string, + description: (formData.description as string) || "", + color: formData.color as string, + allowPublish: formData.allowPublish === "true", + addPermissionRoles: transformRoleValues( + formData.addPermissionRoles as string + ), + removePermissionRoles: transformRoleValues( + formData.removePermissionRoles as string + ), + }; + + const errors = validateFormData(newStatusLabel); + if (Object.keys(errors).length > 0) { + setFormErrors(errors); + return; + } else { + setFormErrors({}); + } + + try { + const response: any = ZUID + ? await updateWorkflowStatusLabel({ ZUID, payload: newStatusLabel }) + : await createWorkflowStatusLabel(newStatusLabel); + + if (response?.error) { + throw new Error(response.error.data?.error || "An error occurred."); + } + + setFocusedLabel(response?.data?.ZUID); + } catch (error) { + dispatch( + notify({ + kind: "error", + message: `Error: ${ + error instanceof Error + ? error.message + : "An unexpected error occurred." + }`, + }) + ); + } finally { + onClose(); + } + }; + const handleDeactivation = () => + openDeactivationDialog({ + ZUID, + name: values?.name, + callBack: onClose, + }); + + useEffect(() => { + if (!open) { + setFormErrors({}); + } + + if (rolesLoading || isFetching) return; + const roles = + rolesMenuData?.map((item) => ({ + label: item?.role?.name as string, + value: item?.role?.ZUID, + })) || []; + + const uniqueRoles = Array.from( + new Map(roles.map((role) => [role.value, role])).values() + ); + setRolesMenuItems(uniqueRoles); + }, [rolesLoading, isFetching, rolesMenuData, open]); + + return ( + + + + {!!ZUID && ( + + )} + + {values?.name ? `Edit ${values.name}` : "Create Status"} + + + + + + + + + + + + + + + + + + + + + + + + , + checked: boolean + ) => { + e.target.value = checked.toString(); + }} + sx={{ + p: 0, + ml: 1, + color: "action.active", + "&.Mui-checked": { + color: "primary.main", + }, + }} + /> + } + label={ + + + Allow publish with this status + + + This means a content item with this status can be published + + + } + /> + {!!ZUID && !isDeactivated && ( + + + + )} + + + + + } + > + {ZUID ? "Save" : "Create Status"} + + + + ); +}; + +export default StatusLabelForm; diff --git a/src/apps/settings/src/app/views/User/Workflows/authorized/forms-dialogs/index.tsx b/src/apps/settings/src/app/views/User/Workflows/authorized/forms-dialogs/index.tsx new file mode 100644 index 0000000000..a918d905a2 --- /dev/null +++ b/src/apps/settings/src/app/views/User/Workflows/authorized/forms-dialogs/index.tsx @@ -0,0 +1,130 @@ +import { createContext, useContext, useState, useCallback } from "react"; +import { + StatusLabel, + StatusLabelQuery, +} from "../../../../../../../../../shell/services/types"; + +import DeactivationDialog from "./DeactivationDialog"; +import StatusLabelForm from "./StatusLabelForm"; + +// Types for StatusLabelForm and DeactivationDialog +export type OpenStatusLabelFormTypes = { + labels?: StatusLabelQuery[] | []; + values?: StatusLabel; + isDeactivated?: boolean; +}; + +export type OpenDeactivationDialogTypes = { + ZUID: string; + name: string; + callBack?: () => void; +}; + +// FormDialogContext Types +export type FormDialogContextTypes = { + openStatusLabelForm: ({ + values, + labels, + isDeactivated, + }: OpenStatusLabelFormTypes) => void; + closeStatusLabelForm: () => void; + openDeactivationDialog: ({ + ZUID, + name, + callBack, + }: OpenDeactivationDialogTypes) => void; + closeDeactivationDialog: () => void; + focusedLabel: string | undefined; + setFocusedLabel: (id: string | undefined) => void; +}; + +const FormDialogContext = createContext(null); + +const FormDialogContextProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [focusedLabel, setFocusedLabel] = useState( + undefined + ); + + // Form dialog states + const [formIsOpen, setFormIsOpen] = useState(false); + const [formValues, setFormValues] = useState< + OpenStatusLabelFormTypes | undefined + >(undefined); + + // Deactivation dialog states + const [dialogIsOpen, setDialogIsOpen] = useState(false); + const [dialogProps, setDialogProps] = useState< + OpenDeactivationDialogTypes | undefined + >(undefined); + + // Handlers for status label form dialog + const openStatusLabelForm = useCallback( + ({ values, labels, isDeactivated }: OpenStatusLabelFormTypes) => { + setFormValues({ values, labels, isDeactivated }); + setFormIsOpen(true); + }, + [] + ); + + const closeStatusLabelForm = useCallback(() => { + setFormValues(undefined); + setFormIsOpen(false); + }, []); + + // Handlers for deactivation dialog + const openDeactivationDialog = useCallback( + ({ ZUID, name, callBack }: OpenDeactivationDialogTypes) => { + setDialogProps({ ZUID, name, callBack }); + setDialogIsOpen(true); + }, + [] + ); + + const closeDeactivationDialog = useCallback(() => { + setDialogProps(undefined); + setDialogIsOpen(false); + }, []); + + return ( + + {children} + + + + ); +}; + +export const useFormDialogContext = () => { + const context = useContext(FormDialogContext); + if (context === null) { + throw new Error( + "useFormDialogContext must be used within a FormDialogContextProvider" + ); + } + return context; +}; + +export default FormDialogContextProvider; diff --git a/src/apps/settings/src/app/views/User/Workflows/authorized/index.tsx b/src/apps/settings/src/app/views/User/Workflows/authorized/index.tsx new file mode 100644 index 0000000000..920a8d9474 --- /dev/null +++ b/src/apps/settings/src/app/views/User/Workflows/authorized/index.tsx @@ -0,0 +1,260 @@ +import { useEffect, useRef, useState, useCallback, useMemo } from "react"; +import { + Box, + Typography, + Button, + TextField, + InputAdornment, + FormGroup, + FormControlLabel, + Switch, + Collapse, +} from "@mui/material"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import AddIcon from "@mui/icons-material/Add"; +import { Search } from "@mui/icons-material"; +import ActiveStatus from "./ActiveStatus"; +import DeactivatedStatus from "./DeactivatedStatus"; +import { useGetWorkflowStatusLabelsQuery } from "../../../../../../../../shell/services/instance"; +import { useFormDialogContext } from "./forms-dialogs"; +import { NoResults } from "../../../../../../../schema/src/app/components/NoResults"; +import { StatusLabelQuery } from "../../../../../../../../shell/services/types"; + +export type StatusLabelSorting = { + id: string; + index?: number; + data: StatusLabelQuery; + isFiltered: boolean; + isDeactivated: boolean; +}; + +const LabelHeader = ({ + title, + subtitle, +}: { + title: string; + subtitle: string; +}) => ( + + + {title} + + + {subtitle} + + +); + +export const AuthorizedUserPage = () => { + const searchInputRef = useRef(null); + const [showDeactivated, setShowDeactivated] = useState(false); + const [searchValue, setSearchValue] = useState(""); + const { openStatusLabelForm } = useFormDialogContext(); + const { + isLoading, + isFetching, + data: labels, + } = useGetWorkflowStatusLabelsQuery({ showDeleted: true }); + + const { activeLabels, deactivatedLabels, emptySearchResult } = useMemo(() => { + if (isLoading || !labels) + return { + activeLabels: [], + deactivatedLabels: [], + emptySearchResult: false, + }; + + const parsedLabels = labels + .map((label) => { + const searchString = `${label.name?.toLowerCase()}_${label.description?.toLowerCase()}`; + return { + id: label.ZUID, + data: label, + isFiltered: !searchString.includes(searchValue.toLowerCase().trim()), + isDeactivated: !!label.deletedAt, + }; + }) + .sort((a, b) => a?.data?.sort - b?.data?.sort); + + const activeDeactivated = parsedLabels.reduce( + (acc, curr) => { + if (curr.isDeactivated) { + acc.deactivated.push({ ...curr, index: acc.deactivated.length }); + } else { + acc.active.push({ ...curr, index: acc.active.length }); + } + return acc; + }, + { active: [], deactivated: [] } + ); + + const activeCount = activeDeactivated.active.filter( + (label) => !label.isFiltered + ).length; + const deactivatedCount = activeDeactivated.deactivated.filter( + (label) => !label.isFiltered + ).length; + + const searchResultsCount = showDeactivated + ? activeCount + deactivatedCount + : activeCount; + + return { + activeLabels: activeDeactivated.active, + deactivatedLabels: activeDeactivated.deactivated, + emptySearchResult: searchResultsCount < 1, + }; + }, [labels, isLoading, searchValue, showDeactivated]); + + const handleSearchRetry = () => { + setSearchValue(""); + searchInputRef.current?.focus(); + }; + + const handleOpenStatusLabelForm = () => { + const labelList = [ + ...activeLabels.map((item) => item.data), + ...deactivatedLabels.map((item) => item.data), + ]; + openStatusLabelForm({ labels: labelList }); + }; + + return ( + <> + + + Workflows + + + + + + setSearchValue(e.target.value)} + InputProps={{ + inputRef: searchInputRef, + startAdornment: ( + + + + ), + sx: { + backgroundColor: "background.paper", + minWidth: "320px", + }, + }} + /> + + setShowDeactivated(e.target.checked)} + /> + } + label={ + Show Deactivated + } + /> + + + + + {emptySearchResult ? ( + + + + ) : ( + + + + + + + + + + + + {showDeactivated && ( + + )} + + + + )} + + + ); +}; diff --git a/src/apps/settings/src/app/views/User/Workflows/constants.tsx b/src/apps/settings/src/app/views/User/Workflows/constants.tsx new file mode 100644 index 0000000000..40b29fb925 --- /dev/null +++ b/src/apps/settings/src/app/views/User/Workflows/constants.tsx @@ -0,0 +1,29 @@ +export type ColorMenu = { + label: string; + value: string; +}; + +export type RoleMenu = { + label: string; + value: string; +}; + +export const colorMenu: ColorMenu[] = [ + { label: "Blue", value: "#0BA5EC" }, + { label: "Deep Purple", value: "#4E5BA6" }, + { label: "Green", value: "#12b76a" }, + { label: "Orange", value: "#FF5C08" }, + { label: "Pink", value: "#EE46BC" }, + { label: "Purple", value: "#7A5AF8" }, + { label: "Red", value: "#F04438" }, + { label: "Rose", value: "#F63D68" }, + { label: "Yellow", value: "#F79009" }, + { label: "Grey", value: "#667085" }, +]; + +const ADMIN_ZUID = "31-71cfc74-4dm13"; +const OWNER_ZUID = "31-71cfc74-0wn3r"; + +export const AUTHORIZED_ROLES: string[] = [ADMIN_ZUID, OWNER_ZUID]; + +export type AuthorizedRole = typeof AUTHORIZED_ROLES[number]; diff --git a/src/apps/settings/src/app/views/User/Workflows/index.tsx b/src/apps/settings/src/app/views/User/Workflows/index.tsx new file mode 100644 index 0000000000..dbc7d79180 --- /dev/null +++ b/src/apps/settings/src/app/views/User/Workflows/index.tsx @@ -0,0 +1,44 @@ +import { ThemeProvider, Box } from "@mui/material"; +import { theme } from "@zesty-io/material"; +import RestrictedPage from "./RestrictedPage"; +import { useSelector } from "react-redux"; +import { AppState } from "../../../../../../../shell/store/types"; +import { AuthorizedUserPage } from "./authorized"; +import FormDialogContextProvider from "./authorized/forms-dialogs"; +import { AUTHORIZED_ROLES } from "./constants"; + +type UserType = { + systemRoleZUID: string; + staff: boolean; +}; + +const Workflows = () => { + const { systemRoleZUID, staff }: UserType = useSelector( + (state: AppState) => ({ + systemRoleZUID: state?.userRole?.systemRoleZUID, + staff: state.user?.staff, + }) + ); + + const isAuthorized = AUTHORIZED_ROLES.includes(systemRoleZUID) || staff; + + return ( + + + + {isAuthorized ? : } + + + + ); +}; + +export default Workflows; diff --git a/src/shell/hooks/useResizeObserver.ts b/src/shell/hooks/useResizeObserver.ts new file mode 100644 index 0000000000..8d4ba5125e --- /dev/null +++ b/src/shell/hooks/useResizeObserver.ts @@ -0,0 +1,35 @@ +import { + useEffect, + useCallback, + useState, + RefObject, + ForwardedRef, +} from "react"; + +export const useResizeObserver = ( + ref: RefObject | ForwardedRef +) => { + const [dimensions, setDimensions] = useState(null); + + const callback = useCallback((entries: ResizeObserverEntry[]) => { + const entry = entries[0]; + + setDimensions(entry.contentRect); + }, []); + + useEffect(() => { + const observer = new ResizeObserver(callback); + + if (ref && "current" in ref && ref.current) { + observer.observe(ref.current); + } + + return () => { + if (ref && "current" in ref && ref.current) { + observer.unobserve(ref.current); + } + }; + }, [ref, callback]); + + return dimensions; +}; diff --git a/src/shell/services/instance.ts b/src/shell/services/instance.ts index 71379e29d8..83893f4b96 100644 --- a/src/shell/services/instance.ts +++ b/src/shell/services/instance.ts @@ -22,10 +22,16 @@ import { Language, Data, StyleCategory, - GroupItem, + WorkflowStatusLabel, + ItemWorkflowStatus, + WorkflowStatusLabelQueryParams, + CreateStatusLabel, + UpdateSortingOrder, } from "./types"; import { batchApiRequests } from "../../utility/batchApiRequests"; +// import {UpdateSortingOrderProps} from "../../apps/settings/src/app/views/User/Workflows/types"; + // Define a service using a base URL and expected endpoints export const instanceApi = createApi({ reducerPath: "instanceApi", @@ -52,6 +58,8 @@ export const instanceApi = createApi({ "HeadTags", "ContentItems", "ItemPublishings", + "ItemWorkflowStatus", + "WorkflowStatusLabels", "Groups", ], endpoints: (builder) => ({ @@ -607,6 +615,69 @@ export const instanceApi = createApi({ query: () => `/web/stylesheets/variables/categories`, transformResponse: getResponseData, }), + getItemWorkflowStatus: builder.query< + ItemWorkflowStatus[], + { modelZUID: string; itemZUID: string } + >({ + query: ({ modelZUID, itemZUID }) => + `/content/models/${modelZUID}/items/${itemZUID}/labels`, + transformResponse: getResponseData, + providesTags: (_, __, { itemZUID }) => [ + { type: "ItemWorkflowStatus", id: itemZUID }, + ], + }), + createItemWorkflowStatus: builder.mutation< + any, + { + modelZUID: string; + itemZUID: string; + itemVersionZUID: string; + itemVersion: number; + label_zuids: string[]; + } + >({ + query: (payload) => ({ + url: `/content/models/${payload.modelZUID}/items/${payload.itemZUID}/labels`, + method: "POST", + body: { + itemVersionZUID: payload.itemVersionZUID, + itemVersion: payload.itemVersion, + label_zuids: payload.label_zuids, + }, + }), + invalidatesTags: (_, __, { itemZUID }) => [ + { type: "ItemWorkflowStatus", id: itemZUID }, + ], + }), + updateItemWorkflowStatus: builder.mutation< + any, + { + modelZUID: string; + itemZUID: string; + itemWorkflowZUID: string; + labelZUIDs: string[]; + } + >({ + query: ({ modelZUID, itemZUID, itemWorkflowZUID, labelZUIDs }) => ({ + url: `/content/models/${modelZUID}/items/${itemZUID}/labels/${itemWorkflowZUID}`, + method: "PUT", + body: { + labelZUIDs, + }, + }), + invalidatesTags: (_, __, { itemZUID }) => [ + { type: "ItemWorkflowStatus", id: itemZUID }, + ], + }), + getWorkflowStatusLabels: builder.query< + WorkflowStatusLabel[], + WorkflowStatusLabelQueryParams | void + >({ + query: ({ showDeleted = false }: WorkflowStatusLabelQueryParams = {}) => + `/env/labels?showDeleted=${showDeleted}`, + transformResponse: getResponseData, + providesTags: ["WorkflowStatusLabels"], + }), getGroups: builder.query | void>({ query: (params) => { if (!!params && Object.keys(params)?.length) { @@ -636,6 +707,52 @@ export const instanceApi = createApi({ }), invalidatesTags: ["Groups"], }), + + createWorkflowStatusLabel: builder.mutation({ + query: (payload) => { + return { + url: `/env/labels`, + method: "POST", + body: payload, + }; + }, + transformResponse: getResponseData, + invalidatesTags: ["WorkflowStatusLabels"], + }), + + //Update workflow status label + updateWorkflowStatusLabel: builder.mutation< + any, + { ZUID: string; payload: CreateStatusLabel } + >({ + query: ({ ZUID, payload }) => ({ + url: `/env/labels/${ZUID}`, + method: "PUT", + body: payload, + }), + transformResponse: getResponseData, + invalidatesTags: ["WorkflowStatusLabels"], + }), + //Update workflow status order + updateWorkflowStatusLabelOrder: builder.mutation< + any[], + UpdateSortingOrder[] + >({ + query: (payload) => ({ + url: `/env/labels`, + method: "PUT", + body: { data: payload }, + }), + invalidatesTags: ["WorkflowStatusLabels"], + }), + //Deactivate workflow status label + deactivateWorkflowStatusLabel: builder.mutation({ + query: ({ ZUID }) => ({ + url: `/env/labels/${ZUID}`, + method: "DELETE", + }), + invalidatesTags: ["WorkflowStatusLabels"], + }), }), }); @@ -686,7 +803,15 @@ export const { useUpdateContentItemsMutation, useCreateItemsPublishingMutation, useDeleteContentItemsMutation, + useGetItemWorkflowStatusQuery, + useCreateItemWorkflowStatusMutation, + useUpdateItemWorkflowStatusMutation, + useGetWorkflowStatusLabelsQuery, useGetGroupsQuery, useGetGroupByZUIDQuery, useCreateGroupMutation, + useCreateWorkflowStatusLabelMutation, + useUpdateWorkflowStatusLabelMutation, + useUpdateWorkflowStatusLabelOrderMutation, + useDeactivateWorkflowStatusLabelMutation, } = instanceApi; diff --git a/src/shell/services/types.ts b/src/shell/services/types.ts index 1d04fca105..5995070af2 100644 --- a/src/shell/services/types.ts +++ b/src/shell/services/types.ts @@ -579,6 +579,35 @@ export type CommentReply = { updatedAt: string; }; +export type WorkflowStatusLabel = { + ZUID: string; + name: string; + description: string; + color: string; + allowPublish: boolean; + sort: number; + addPermissionRoles: string[]; + removePermissionRoles: string[]; + createdByUserZUID: string; + updatedByUserZUID: string; + createdAt: string; + updatedAt: string; + deletedAt?: string; +}; + +export type ItemWorkflowStatus = { + ZUID: string; + itemZUID: string; + setZUID: string; + itemVersionZUID: string; + itemVersion: number; + labelZUIDs: string[]; + createdByUserZUID: string; + updatedByUserZUID: string; + createdAt: string; + updatedAt: string; +}; + export type GroupItem = { zuid: string; type?: string; @@ -589,3 +618,45 @@ export type GroupItem = { createdAt: string; updatedAt: string; }; + +export type WorkflowStatusLabelQueryParams = { + showDeleted?: boolean; +}; + +export type StatusLabelQuery = { + ZUID: string; + name: string; + description: string; + color: string; + allowPublish: boolean; + sort: number; + addPermissionRoles: string[]; + removePermissionRoles: string[]; + createdByUserZUID: string; + updatedByUserZUID: string; + createdAt: string; + updatedAt: string; + deletedAt?: string | undefined; +}; + +export type StatusLabel = Omit< + WorkflowStatusLabel, + | "createdByUserZUID" + | "updatedByUserZUID" + | "createdAt" + | "updatedAt" + | "deletedAt" +>; + +export type UpdateStatusLabel = Omit< + WorkflowStatusLabel, + | "createdByUserZUID" + | "updatedByUserZUID" + | "createdAt" + | "updatedAt" + | "deletedAt" +>; + +export type CreateStatusLabel = Partial>; + +export type UpdateSortingOrder = Pick; diff --git a/src/shell/store/content.js b/src/shell/store/content.js index acff43c44d..1e7ca8e58d 100644 --- a/src/shell/store/content.js +++ b/src/shell/store/content.js @@ -544,7 +544,9 @@ export function saveItem({ } ) .then(async (res) => { - dispatch(instanceApi.util.invalidateTags(["ContentNav"])); + dispatch( + instanceApi.util.invalidateTags(["ContentNav", "ItemWorkflowStatus"]) + ); dispatch( instanceApi.util.invalidateTags([{ type: "ItemVersions", itemZUID }]) ); @@ -565,7 +567,12 @@ export function saveItem({ `${CONFIG.URL_PREVIEW_PROTOCOL}${itemBlockPreviewUrl}` ) ).then(() => - dispatch(instanceApi.util.invalidateTags(["ContentItems"])) + dispatch( + instanceApi.util.invalidateTags([ + "ContentItems", + "ItemWorkflowStatus", + ]) + ) ); } }