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 (
- <>
-
- }
- onClick={(e) => setAnchorEl(e.currentTarget)}
- >
- v{item?.meta?.version}
-
-
-
- >
- );
-};
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 (
+
+
+
+
+ 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.
+
+
+ }
+ onClick={onSearchAgain}
+ >
+ Search Again
+
+
+ );
+};
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 (
+
+ );
+}, 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 (
+
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+ }
+ ),
+ 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 (
+ <>
+
+ }
+ onClick={(e) => setAnchorEl(e.currentTarget)}
+ disabled={
+ isLoadingVersions ||
+ isLoadingStatusLabels ||
+ isLoadingItemPublishings ||
+ isLoadingItemWorkflowStatus
+ }
+ >
+ v{activeVersion}
+ {!!activeVersionLabels?.length && (
+ <>
+
+ {activeVersionLabels?.length > 1 && (
+
+ )}
+ >
+ )}
+
+
+ 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) => {
}}
>
-
+
{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 (
+ <>
+
+
+
+
+ >
+ );
+};
+
+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 (
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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
+
+ }
+ >
+ Create Status
+
+
+
+
+ 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",
+ ])
+ )
);
}
}