From 5762179110f68c93cee81834b2b93d91f0b3a09f Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Fri, 14 Jun 2024 06:34:44 +0800 Subject: [PATCH 1/9] Reset user data on logout (#2709) Fixes #2565 https://github.com/zesty-io/manager-ui/assets/28705606/5f2bee49-07ed-4cfd-aa1d-8e0534d91b0b Co-authored-by: Stuart Runyan --- src/shell/store/auth.js | 3 +++ src/shell/store/user.js | 22 ++++++++++++---------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/shell/store/auth.js b/src/shell/store/auth.js index bc431d2e5b..858a26703e 100644 --- a/src/shell/store/auth.js +++ b/src/shell/store/auth.js @@ -231,6 +231,9 @@ export function logout() { dispatch({ type: "LOGOUT", }); + dispatch({ + type: "RESET_USER_DATA", + }); }) .catch((err) => { console.error(err); diff --git a/src/shell/store/user.js b/src/shell/store/user.js index bc5259e714..8ab03f5e06 100644 --- a/src/shell/store/user.js +++ b/src/shell/store/user.js @@ -3,16 +3,15 @@ import { request } from "utility/request"; import { Sentry } from "utility/sentry"; import { notify } from "shell/store/notifications"; -export function user( - state = { - ZUID: "", - firstName: "", - email: "", - permissions: [], - selected_lang: "en-US", - }, - action -) { +const INITIAL_STATE = { + ZUID: "", + firstName: "", + email: "", + permissions: [], + selected_lang: "en-US", +}; + +export function user(state = INITIAL_STATE, action) { switch (action.type) { case "VERIFY_SUCCESS": case "FETCH_LOGIN_SUCCESS": @@ -38,6 +37,9 @@ export function user( case "USER_SELECTED_LANG": return { ...state, selected_lang: action.payload.lang }; + case "RESET_USER_DATA": + return INITIAL_STATE; + default: return state; } From 6cf6782f4331fd90494887303b0a0f31ef4c1b72 Mon Sep 17 00:00:00 2001 From: Andres Galindo Date: Mon, 24 Jun 2024 12:57:41 -0700 Subject: [PATCH 2/9] Feature/multi item table (#2731) Co-authored-by: Nar Cuenca Co-authored-by: Nar -- <28705606+finnar-bin@users.noreply.github.com> --- cypress/e2e/content/list.spec.js | 139 ++- cypress/e2e/search/search-page.spec.js | 2 +- public/images/emptyItemsList.png | Bin 0 -> 19162 bytes .../content-editor/src/app/ContentEditor.js | 10 +- .../{CSVImport.js => CSVImportBody.js} | 4 +- .../src/app/views/CSVImport/index.js | 2 - .../src/app/views/CSVImport/index.tsx | 53 ++ .../views/ItemList/ConfirmDeletesDialog.tsx | 81 ++ .../views/ItemList/ConfirmPublishesDialog.tsx | 84 ++ .../app/views/ItemList/DialogContentItem.tsx | 89 ++ .../src/app/views/ItemList/ItemList.js | 803 ------------------ .../src/app/views/ItemList/ItemList.less | 39 - .../app/views/ItemList/ItemListActions.tsx | 298 +++++++ .../src/app/views/ItemList/ItemListEmpty.tsx | 33 + .../app/views/ItemList/ItemListFilters.tsx | 241 ++++++ .../src/app/views/ItemList/ItemListTable.tsx | 456 ++++++++++ .../ItemList/SchedulePublishesDialog.tsx | 152 ++++ .../views/ItemList/SelectedItemsContext.tsx | 27 + .../views/ItemList/SetActions/SetActions.js | 205 ----- .../views/ItemList/SetActions/SetActions.less | 79 -- .../app/views/ItemList/SetActions/index.js | 1 - .../views/ItemList/SetColumns/SetColumns.js | 66 -- .../views/ItemList/SetColumns/SetColumns.less | 71 -- .../app/views/ItemList/SetColumns/index.js | 1 - .../ItemList/SetRow/ColorCell/ColorCell.js | 14 - .../ItemList/SetRow/ColorCell/ColorCell.less | 8 - .../views/ItemList/SetRow/ColorCell/index.js | 1 - .../ItemList/SetRow/DateCell/DateCell.js | 29 - .../ItemList/SetRow/DateCell/DateCell.less | 14 - .../views/ItemList/SetRow/DateCell/index.js | 1 - .../SetRow/DropdownCell/DropdownCell.js | 29 - .../SetRow/DropdownCell/DropdownCell.less | 5 - .../ItemList/SetRow/DropdownCell/index.js | 1 - .../ItemList/SetRow/FileCell/FileCell.js | 102 --- .../ItemList/SetRow/FileCell/FileCell.less | 20 - .../views/ItemList/SetRow/FileCell/index.js | 1 - .../ItemList/SetRow/ImageCell/ImageCell.js | 36 - .../ItemList/SetRow/ImageCell/ImageCell.less | 42 - .../views/ItemList/SetRow/ImageCell/index.js | 1 - .../InternalLinkCell/InternalLinkCell.js | 66 -- .../InternalLinkCell/InternalLinkCell.less | 20 - .../ItemList/SetRow/InternalLinkCell/index.js | 1 - .../ItemList/SetRow/LinkCell/LinkCell.js | 51 -- .../ItemList/SetRow/LinkCell/LinkCell.less | 18 - .../views/ItemList/SetRow/LinkCell/index.js | 1 - .../SetRow/OneToManyCell/OneToManyCell.js | 97 --- .../SetRow/OneToManyCell/OneToManyCell.less | 12 - .../ItemList/SetRow/OneToManyCell/index.js | 1 - .../SetRow/OneToOneCell/OneToOneCell.js | 72 -- .../SetRow/OneToOneCell/OneToOneCell.less | 7 - .../ItemList/SetRow/OneToOneCell/index.js | 1 - .../PublishStatusCell/PublishStatusCell.js | 81 -- .../PublishStatusCell/PublishStatusCell.less | 26 - .../SetRow/PublishStatusCell/index.js | 1 - .../src/app/views/ItemList/SetRow/SetRow.js | 207 ----- .../src/app/views/ItemList/SetRow/SetRow.less | 38 - .../ItemList/SetRow/SortCell/SortCell.js | 19 - .../ItemList/SetRow/SortCell/SortCell.less | 6 - .../views/ItemList/SetRow/SortCell/index.js | 1 - .../ItemList/SetRow/TextCell/TextCell.js | 13 - .../ItemList/SetRow/TextCell/TextCell.less | 7 - .../views/ItemList/SetRow/TextCell/index.js | 1 - .../ItemList/SetRow/ToggleCell/ToggleCell.js | 64 -- .../SetRow/ToggleCell/ToggleCell.less | 9 - .../views/ItemList/SetRow/ToggleCell/index.js | 1 - .../SetRow/WYSIWYGcell/WYSIWYGcell.js | 16 - .../SetRow/WYSIWYGcell/WYSIWYGcell.less | 11 - .../ItemList/SetRow/WYSIWYGcell/index.js | 1 - .../src/app/views/ItemList/SetRow/index.js | 2 - .../views/ItemList/StagedChangesContext.tsx | 38 + .../views/ItemList/TableCells/BooleanCell.tsx | 45 + .../ItemList/TableCells/DropdownCell.tsx | 94 ++ .../ItemList/TableCells/OneToManyCell.tsx | 78 ++ .../views/ItemList/TableCells/SortCell.tsx | 24 + .../views/ItemList/TableCells/UserCell.tsx | 31 + .../views/ItemList/TableCells/VersionCell.tsx | 163 ++++ .../app/views/ItemList/UpdateListActions.tsx | 421 +++++++++ .../src/app/views/ItemList/findUtils.js | 35 - .../src/app/views/ItemList/index.js | 2 - .../src/app/views/ItemList/index.tsx | 499 +++++++++++ .../app/components/AddFieldModal/index.tsx | 3 +- .../AddFieldModal/views/FieldForm.tsx | 3 +- .../schema/src/app/components/ModelsTable.tsx | 4 +- src/shell/components/FieldTypeSort/index.tsx | 3 + .../DateFilter/DateRangeFilterModal.tsx | 1 + .../Filters/DateFilter/getDateFilter.ts | 49 ++ src/shell/components/Filters/FilterButton.tsx | 6 +- src/shell/components/Filters/UserFilter.tsx | 94 +- src/shell/hooks/useDateFilterParams.ts | 124 +++ src/shell/hooks/useParams.ts | 27 +- src/shell/services/instance.ts | 53 +- src/shell/services/types.ts | 24 +- src/shell/views/SearchPage/Filters/index.tsx | 121 +-- src/shell/views/SearchPage/SearchPage.tsx | 42 +- 94 files changed, 3386 insertions(+), 2689 deletions(-) create mode 100644 public/images/emptyItemsList.png rename src/apps/content-editor/src/app/views/CSVImport/{CSVImport.js => CSVImportBody.js} (99%) delete mode 100644 src/apps/content-editor/src/app/views/CSVImport/index.js create mode 100644 src/apps/content-editor/src/app/views/CSVImport/index.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemList/ConfirmDeletesDialog.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemList/ConfirmPublishesDialog.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemList/DialogContentItem.tsx delete mode 100644 src/apps/content-editor/src/app/views/ItemList/ItemList.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/ItemList.less create mode 100644 src/apps/content-editor/src/app/views/ItemList/ItemListActions.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemList/ItemListEmpty.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemList/SchedulePublishesDialog.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemList/SelectedItemsContext.tsx delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetActions/SetActions.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetActions/SetActions.less delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetActions/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetColumns/SetColumns.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetColumns/SetColumns.less delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetColumns/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/ColorCell/ColorCell.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/ColorCell/ColorCell.less delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/ColorCell/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/DateCell/DateCell.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/DateCell/DateCell.less delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/DateCell/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/DropdownCell/DropdownCell.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/DropdownCell/DropdownCell.less delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/DropdownCell/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/FileCell/FileCell.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/FileCell/FileCell.less delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/FileCell/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/ImageCell/ImageCell.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/ImageCell/ImageCell.less delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/ImageCell/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/InternalLinkCell/InternalLinkCell.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/InternalLinkCell/InternalLinkCell.less delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/InternalLinkCell/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/LinkCell/LinkCell.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/LinkCell/LinkCell.less delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/LinkCell/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/OneToManyCell/OneToManyCell.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/OneToManyCell/OneToManyCell.less delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/OneToManyCell/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/OneToOneCell/OneToOneCell.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/OneToOneCell/OneToOneCell.less delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/OneToOneCell/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/PublishStatusCell/PublishStatusCell.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/PublishStatusCell/PublishStatusCell.less delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/PublishStatusCell/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/SetRow.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/SetRow.less delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/SortCell/SortCell.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/SortCell/SortCell.less delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/SortCell/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/TextCell/TextCell.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/TextCell/TextCell.less delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/TextCell/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/ToggleCell/ToggleCell.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/ToggleCell/ToggleCell.less delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/ToggleCell/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/WYSIWYGcell/WYSIWYGcell.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/WYSIWYGcell/WYSIWYGcell.less delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/WYSIWYGcell/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/SetRow/index.js create mode 100644 src/apps/content-editor/src/app/views/ItemList/StagedChangesContext.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemList/TableCells/BooleanCell.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemList/TableCells/DropdownCell.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemList/TableCells/OneToManyCell.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemList/TableCells/SortCell.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemList/TableCells/UserCell.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemList/TableCells/VersionCell.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemList/UpdateListActions.tsx delete mode 100644 src/apps/content-editor/src/app/views/ItemList/findUtils.js delete mode 100644 src/apps/content-editor/src/app/views/ItemList/index.js create mode 100644 src/apps/content-editor/src/app/views/ItemList/index.tsx create mode 100644 src/shell/hooks/useDateFilterParams.ts diff --git a/cypress/e2e/content/list.spec.js b/cypress/e2e/content/list.spec.js index bfa4a4e88f..3247541228 100644 --- a/cypress/e2e/content/list.spec.js +++ b/cypress/e2e/content/list.spec.js @@ -1,4 +1,4 @@ -describe("Content List", () => { +describe("Content List Filters", () => { before(() => { cy.waitOn("/v1/content/models*", () => { cy.visit("/content/6-0c960c-d1n0kx"); @@ -6,38 +6,135 @@ describe("Content List", () => { }); it("Filters list items based on search term", () => { - cy.get("input[name='filter']").type("turkey"); - cy.get(".ItemList article").contains("Turkey Run"); - cy.get("input[name='filter']").clear(); + cy.getBySelector("MultiPageTableSearchField").type("turkey"); + cy.get(".MuiDataGrid-cell").contains("Turkey Run"); + cy.getBySelector("MultiPageTableSearchField").type("{selectAll}{del}"); + }); + + it("Filters items based on date saved", () => { + cy.getBySelector("date_default").click(); + cy.get(".MuiMenuItem-root").eq(1).click(); + cy.getBySelector("NoResults").should("exist"); + cy.getBySelector("date_clearFilter").click(); + cy.getBySelector("NoResults").should("not.exist"); + }); + + it("Filters by publish status", () => { + cy.getBySelector("statusFilter_default").click(); + cy.getBySelector("scheduledFilterOption").click(); + cy.getBySelector("NoResults").should("exist"); + cy.getBySelector("statusFilter_clearFilter").click(); + cy.getBySelector("NoResults").should("not.exist"); + }); + + it("Filters by creator", () => { + cy.getBySelector("user_default").click(); + cy.getBySelector("filter_value_5-da8c91c9da-l9cqsz").click(); + cy.getBySelector("NoResults").should("exist"); + cy.getBySelector("user_clearFilter").click(); + cy.getBySelector("NoResults").should("not.exist"); }); it("Sorts list items", () => { - cy.get(".ItemList .SortBy").first().click(); - cy.get(".ItemList article") - .first() - .contains("Parent pre selection with fast typing"); + cy.getBySelector("sortByFilter_default").click(); + cy.getBySelector("dateCreatedFilterOption").click(); + cy.get(".MuiDataGrid-cell[data-colindex='3']").contains( + "Parent pre selection with fast typing" + ); + cy.getBySelector("sortByFilter_default").click(); + cy.getBySelector("dateSavedFilterOption").click(); + }); +}); + +describe("Content List Navigation", () => { + before(() => { + cy.waitOn("/v1/content/models*", () => { + cy.waitOn("/bin/*", () => { + cy.visit("/content/6-0c960c-d1n0kx"); + }); + }); + }); + + it("Opens the content item on click", () => { + cy.get(".MuiDataGrid-cell[data-colindex='1']").first().click(); + cy.getBySelector("DuoModeToggle").should("exist"); + cy.getBySelector("breadcrumbs").find(".MuiBreadcrumbs-li").eq(2).click(); + cy.url().should("include", "/content/6-0c960c-d1n0kx"); + }); + + it("Navigates to the import csv page", () => { + cy.getBySelector("MultiPageTableMoreMenu").click(); + cy.getBySelector("ImportCSVNavButton").click(); + cy.url().should("include", "/content/6-0c960c-d1n0kx/import"); + }); + + it("Navigates to edit the model page", () => { + cy.getBySelector("MultiPageTableMoreMenu").click(); + cy.getBySelector("EditModelNavButton").click(); + cy.url().should("include", "/schema/6-0c960c-d1n0kx/fields"); + }); + + it("Navigates to edit the template page", () => { + cy.waitOn("/v1/content/models*", () => { + cy.waitOn("/bin/*", () => { + cy.visit("/content/6-0c960c-d1n0kx"); + }); + }); - cy.get(".ItemList .SortBy").eq(1).click(); - cy.get(".ItemList article").first().contains("Self-Defense Class"); + cy.getBySelector("MultiPageTableMoreMenu").click(); + cy.getBySelector("EditTemplateNavButton").click(); + cy.url().should("include", "/code/file/views"); }); +}); - it("Bulk Edits Toggle and Number Values", () => { +describe("Content List Actions", () => { + before(() => { cy.waitOn("/v1/content/models*", () => { - cy.visit("/content/6-e3d0e0-965qp6"); + cy.waitOn("/bin/*", () => { + cy.visit("/content/6-e3d0e0-965qp6"); + }); }); + }); + + it("Saves bulk edits", () => { + cy.intercept("PUT", "/v1/content/models/*/items/batch").as("batchSave"); + cy.getBySelector("sortCell").first().find("button").first().click(); + cy.getBySelector("sortCell").eq(1).find("button").first().click(); + cy.getBySelector("MultiPageTableSaveChanges").click(); + + cy.wait("@batchSave").its("response.statusCode").should("equal", 200); + }); + + it("Saves and publishes bulk edits", () => { + cy.intercept("PUT", "/v1/content/models/*/items/batch").as("batchSave"); + cy.intercept("POST", "/v1/content/models/*/items/publishings/batch").as( + "batchPublish" + ); + cy.getBySelector("sortCell").first().find("button").first().click(); + cy.getBySelector("sortCell").eq(1).find("button").first().click(); + cy.getBySelector("MultiPageTablePublish").click(); + cy.getBySelector("ConfirmPublishButton").click(); + + cy.wait("@batchSave").its("response.statusCode").should("equal", 200); + cy.wait("@batchPublish").its("response.statusCode").should("equal", 201); + }); + + it.only("Selects items and publishes", () => { + cy.intercept("PUT", "/v1/content/models/*/items/batch").as("batchSave"); + cy.intercept("POST", "/v1/content/models/*/items/publishings/batch").as( + "batchPublish" + ); + cy.get("input[type=checkbox]").eq(1).click(); + cy.get("input[type=checkbox]").eq(2).click(); + cy.getBySelector("MultiPageTablePublish").click(); + cy.getBySelector("ConfirmPublishButton").click(); - cy.get(".ItemList article").first().get(".SortCell button").first().click(); - cy.get(".ItemList article") - .first() - .get(".ToggleCell button") - .first() - .click(); - cy.contains("Save All Changes").click(); - cy.contains("changes saved").should("exist"); + cy.wait("@batchPublish").its("response.statusCode").should("equal", 201); }); - it("Opens the add item view", () => { + it("Opens the create new item view", () => { cy.getBySelector("AddItemButton").click(); cy.getBySelector("CreateItemSaveButton").should("exist"); + cy.url().should("include", "/content/6-e3d0e0-965qp6/new"); }); }); diff --git a/cypress/e2e/search/search-page.spec.js b/cypress/e2e/search/search-page.spec.js index 85cba8bbf3..4f0ca259ce 100644 --- a/cypress/e2e/search/search-page.spec.js +++ b/cypress/e2e/search/search-page.spec.js @@ -68,7 +68,7 @@ describe("Global Search: Search Page", () => { cy.location("search").should( "equal", - `?q=somerandomstringthatdoesnotexist&sort=created&user=5-da8c91c9da-l9cqsz&datePreset=today` + `?q=somerandomstringthatdoesnotexist&sort=created&user=5-84d1e6d4ae-s3m974&datePreset=today` ); }); diff --git a/public/images/emptyItemsList.png b/public/images/emptyItemsList.png new file mode 100644 index 0000000000000000000000000000000000000000..bc9bedb4e49b9f7a7787e63086112f46f235f8e4 GIT binary patch literal 19162 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 literal 0 HcmV?d00001 diff --git a/src/apps/content-editor/src/app/ContentEditor.js b/src/apps/content-editor/src/app/ContentEditor.js index 995f34fc53..24a5e88ca3 100644 --- a/src/apps/content-editor/src/app/ContentEditor.js +++ b/src/apps/content-editor/src/app/ContentEditor.js @@ -29,6 +29,8 @@ import "@zesty-io/core/vendor.css"; import styles from "./ContentEditor.less"; import Analytics from "./views/Analytics"; import { ResizableContainer } from "../../../../shell/components/ResizeableContainer"; +import { StagedChangesProvider } from "./views/ItemList/StagedChangesContext"; +import { SelectedItemsProvider } from "./views/ItemList/SelectedItemsContext"; // Makes sure that other apps using legacy theme does not get affected with the palette let customTheme = createTheme(legacyTheme, { @@ -169,7 +171,13 @@ export default function ContentEditor() { ( + + + + + + )} /> diff --git a/src/apps/content-editor/src/app/views/CSVImport/CSVImport.js b/src/apps/content-editor/src/app/views/CSVImport/CSVImportBody.js similarity index 99% rename from src/apps/content-editor/src/app/views/CSVImport/CSVImport.js rename to src/apps/content-editor/src/app/views/CSVImport/CSVImportBody.js index e4647b046a..8c02f03033 100644 --- a/src/apps/content-editor/src/app/views/CSVImport/CSVImport.js +++ b/src/apps/content-editor/src/app/views/CSVImport/CSVImportBody.js @@ -22,7 +22,7 @@ import { notify } from "shell/store/notifications"; import { fetchFields } from "shell/store/fields"; import styles from "./CSVImport.less"; -class CSVImport extends Component { +class CSVImportBody extends Component { state = { modelZUID: this.props.modelZUID, fields: this.props.fields, @@ -464,4 +464,4 @@ export default connect((state, props) => { fields, userZUID: state.user.user_zuid, }; -})(CSVImport); +})(CSVImportBody); diff --git a/src/apps/content-editor/src/app/views/CSVImport/index.js b/src/apps/content-editor/src/app/views/CSVImport/index.js deleted file mode 100644 index 54dac7b094..0000000000 --- a/src/apps/content-editor/src/app/views/CSVImport/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import CSVImport from "./CSVImport"; -export { CSVImport }; diff --git a/src/apps/content-editor/src/app/views/CSVImport/index.tsx b/src/apps/content-editor/src/app/views/CSVImport/index.tsx new file mode 100644 index 0000000000..2585191467 --- /dev/null +++ b/src/apps/content-editor/src/app/views/CSVImport/index.tsx @@ -0,0 +1,53 @@ +import { Box, Typography, ThemeProvider, Stack } from "@mui/material"; +import { useParams as useRouterParams } from "react-router"; +import { theme } from "@zesty-io/material"; + +import { useGetContentModelQuery } from "../../../../../../shell/services/instance"; +import { ContentBreadcrumbs } from "../../components/ContentBreadcrumbs"; +import { ItemListActions } from "../ItemList/ItemListActions"; +import CSVImportBody from "./CSVImportBody"; + +export const CSVImport = ({ ...routerProps }) => { + const { modelZUID } = useRouterParams<{ modelZUID: string }>(); + const { data: model } = useGetContentModelQuery(modelZUID); + + return ( + <> + + + + + + {model?.label} + + + + + + + + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemList/ConfirmDeletesDialog.tsx b/src/apps/content-editor/src/app/views/ItemList/ConfirmDeletesDialog.tsx new file mode 100644 index 0000000000..bd5fe81939 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/ConfirmDeletesDialog.tsx @@ -0,0 +1,81 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Box, + Stack, + ButtonBaseActions, + List, +} from "@mui/material"; +import { DeleteRounded } from "@mui/icons-material"; +import { useRef } from "react"; +import { ContentItem } from "../../../../../../shell/services/types"; +import { DialogContentItem } from "./DialogContentItem"; + +type ConfirmDeletesModalProps = { + items: ContentItem[]; + onConfirm: (items: ContentItem[]) => void; + onCancel: () => void; +}; +export const ConfirmDeletesDialog = ({ + items, + onConfirm, + onCancel, +}: ConfirmDeletesModalProps) => { + const actionRef = useRef(null); + const onEntered = () => actionRef?.current?.focusVisible(); + + return ( + + + + + + Delete {items.length} Items: + + Deleting these {items.length} items will remove it from all locations + throughout your site and make it unavailable to API requests. This + cannot be undone. + + + + + {items.map((item, index) => ( + + ))} + + + + + + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemList/ConfirmPublishesDialog.tsx b/src/apps/content-editor/src/app/views/ItemList/ConfirmPublishesDialog.tsx new file mode 100644 index 0000000000..7f2bdb41ec --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/ConfirmPublishesDialog.tsx @@ -0,0 +1,84 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Box, + Stack, + ButtonBaseActions, + List, +} from "@mui/material"; +import CloudUploadRoundedIcon from "@mui/icons-material/CloudUploadRounded"; +import { useRef } from "react"; +import { ContentItem } from "../../../../../../shell/services/types"; +import { DialogContentItem } from "./DialogContentItem"; +import pluralizeWord from "../../../../../../utility/pluralizeWord"; + +type ConfirmPublishesModalProps = { + items: ContentItem[]; + onConfirm: (items: ContentItem[]) => void; + onCancel: () => void; +}; +export const ConfirmPublishesModal = ({ + items, + onConfirm, + onCancel, +}: ConfirmPublishesModalProps) => { + const actionRef = useRef(null); + const onEntered = () => actionRef?.current?.focusVisible(); + + return ( + + + + + + + Publish {items.length} {pluralizeWord("Item", items.length)}: + + + This will make the the following {items.length} content{" "} + {pluralizeWord("item", items.length)} immediately available on all of + your platforms. You can always unpublish this item later if needed. + + + + {items.map((item, index) => ( + + ))} + + + + + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemList/DialogContentItem.tsx b/src/apps/content-editor/src/app/views/ItemList/DialogContentItem.tsx new file mode 100644 index 0000000000..3c3772ca65 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/DialogContentItem.tsx @@ -0,0 +1,89 @@ +import { + Typography, + List, + ListItem, + ListItemText, + ListItemAvatar, + Avatar, +} from "@mui/material"; +import { ContentItem } from "../../../../../../shell/services/types"; +import { useGetContentModelFieldsQuery } from "../../../../../../shell/services/instance"; +import { useParams } from "react-router"; +import { + useGetAllBinFilesQuery, + useGetBinsQuery, +} from "../../../../../../shell/services/mediaManager"; +import { useSelector } from "react-redux"; +import { AppState } from "../../../../../../shell/store/types"; +import { useMemo } from "react"; + +export type DialogContentItemProps = { + item: ContentItem; +}; + +export const DialogContentItem = ({ item }: DialogContentItemProps) => { + const { modelZUID } = useParams<{ modelZUID: string }>(); + const { data: fields } = useGetContentModelFieldsQuery(modelZUID); + const instanceId = useSelector((state: AppState) => state.instance.ID); + const ecoId = useSelector((state: AppState) => state.instance.ecoID); + const { data: bins } = useGetBinsQuery({ + instanceId, + ecoId, + }); + const { data: files } = useGetAllBinFilesQuery( + bins?.map((bin) => bin.id), + { skip: !bins?.length } + ); + const heroImage = useMemo(() => { + let image = ( + item?.data?.[ + fields?.find((field) => field.datatype === "images")?.name + ] as string + )?.split(",")?.[0]; + if (image?.startsWith("3-")) { + image = files?.find((file) => file.id === image)?.thumbnail; + } + + return image; + }, [fields, item, files]); + + return ( + + + + theme.palette.grey[100], + }} + src={heroImage} + imgProps={{ + style: { + objectFit: "contain", + }, + }} + > + + NA + + + + + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemList.js b/src/apps/content-editor/src/app/views/ItemList/ItemList.js deleted file mode 100644 index a8e771b4ee..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/ItemList.js +++ /dev/null @@ -1,803 +0,0 @@ -import { useRef, useState, useEffect, PureComponent } from "react"; -import { connect } from "react-redux"; -import { VariableSizeList as List } from "react-window"; -import { useHistory } from "react-router-dom"; -import isEqual from "lodash/isEqual"; -import isEmpty from "lodash/isEmpty"; - -import Button from "@mui/material/Button"; -import ClearIcon from "@mui/icons-material/Clear"; -import Typography from "@mui/material/Typography"; - -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faTimes } from "@fortawesome/free-solid-svg-icons"; -import { WithLoader } from "@zesty-io/core/WithLoader"; - -import { SetActions } from "./SetActions"; -import { SetColumns } from "./SetColumns"; -import { SetRow } from "./SetRow"; - -import { DragScroll } from "../../components/DragScroll"; -import { PendingEditsModal } from "../../components/PendingEditsModal"; -import { NotFound } from "shell/components/NotFound"; - -import { - fetchItem, - fetchItems, - searchItems, - saveItem, -} from "shell/store/content"; -import { fetchFields } from "shell/store/fields"; -import { notify } from "shell/store/notifications"; -import { findFields, findItems } from "./findUtils"; - -import styles from "./ItemList.less"; - -const PAGE_SIZE = 5000; -const DEFAULT_FILTER = { - sortedBy: null, - reverseSort: false, - status: "all", - filterTerm: "", - colType: null, -}; - -export default connect((state, props) => { - const { modelZUID } = props.match.params; - const model = state.models[modelZUID]; - - const selectedLang = state.languages - ? state.languages.find((lang) => lang.code === state.user.selected_lang) || - {} - : {}; - - const userSelectedLang = state.user.selected_lang; - - return { - selectedLang, - userSelectedLang, - model, - modelZUID, - user: state.user, - filters: state.listFilters, - instance: state.instance, - allItems: state.content, - allFields: state.fields, - dirtyItems: Object.keys(state.content).filter( - (item) => - state.content[item].meta && - state.content[item].meta.contentModelZUID === modelZUID && - state.content[item].dirty && - state.content[item].meta.ZUID.slice(0, 3) !== "new" // Don't include new items - ), - }; -})(function ItemList(props) { - const history = useHistory(); - const _isMounted = useRef(false); - - // table, list dimensions - const table = useRef(); - const list = useRef(); - const [height, setHeight] = useState(1000); - const [width, setWidth] = useState(1000); - - // saving, loading states - const [saving, setSaving] = useState(false); - const [initialLoading, setInitialLoading] = useState(false); - const [backgroundLoading, setBackgroundLoading] = useState(false); - - const [unfilteredItems, setItems] = useState([]); - const items = unfilteredItems.filter( - (item) => Array.isArray(item) || !isEmpty(item.data) - ); - const [itemCount, setItemCount] = useState(0); - const [fields, setFields] = useState([]); - const [filter, setFilter] = useState( - props.filters[props.modelZUID] - ? props.filters[props.modelZUID] - : DEFAULT_FILTER - ); - - // Used to runFilters/updateItemCount on *next* render - // we use these since otherwise executing those functions directly - // would lead to stale props/state. executing on *next* render - // guarantees latest props/state - // e.g. setShouldRunFilters(true) -> *next render* -> runFilters() - const [shouldRunFilters, setShouldRunFilters] = useState(false); - const [shouldUpdateItemCount, setShouldUpdateItemCount] = useState(false); - - // track isMounted - useEffect(() => { - _isMounted.current = true; - return () => { - _isMounted.current = false; - }; - }, []); - - // globals is a special dataset which should have a single entry - // it contains instance wide parsley referenceable values - useEffect(() => { - if (["clippings", "globals"].includes(props?.model?.name.toLowerCase())) { - // only force redirect if there is a single globals content item - if (items.length === 2) { - // the first record is the list column headers so we use the second which - // should be the first content item - history.push( - `/content/${items[1]?.meta?.contentModelZUID}/${items[1]?.meta?.ZUID}` - ); - } - } - }, [props.modelZUID, items.length]); - - // on initial and - // on modelZUID change - // update filter and load fields/items - useEffect(() => { - setFilter( - props.filters[props.modelZUID] - ? props.filters[props.modelZUID] - : DEFAULT_FILTER - ); - load(props.modelZUID, props.userSelectedLang); - }, [props.modelZUID, props.userSelectedLang]); - - // on filter change - // run and persist filters - useEffect(() => { - if (_isMounted.current) { - runFilters(); - persistFilters(); - } - }, [filter, props.selectedLang]); - - useEffect(() => { - if (_isMounted.current && shouldRunFilters) { - runFilters(); - setShouldRunFilters(false); - } - }, [shouldRunFilters]); - - useEffect(() => { - if (_isMounted.current && shouldUpdateItemCount) { - updateItemCount(); - setShouldUpdateItemCount(false); - } - }, [shouldUpdateItemCount]); - - // on items update, update itemCount - useEffect(() => { - setItemCount(Math.max(0, items.length - 1)); - }, [items]); - - // set table dimensions, re-check on props change - useEffect(() => { - if ( - table.current && - (height !== table.current.clientHeight || - width !== table.current.clientWidth) - ) { - setHeight(table.current.clientHeight); - setWidth(table.current.clientWidth); - } - }, [props]); - - function updateItemsAndFields() { - const newFields = findFields(props.allFields, props.modelZUID); - const newItems = findItems( - props.allItems, - props.modelZUID, - props.selectedLang.ID - ); - - newItems.unshift(newFields); - setFields(newFields); - setItems(newItems); - } - - async function load(modelZUID, lang) { - setInitialLoading(true); - setBackgroundLoading(true); - - try { - const [, itemsRes] = await Promise.all([ - props.dispatch(fetchFields(modelZUID)), - props.dispatch( - fetchItems(modelZUID, { - limit: PAGE_SIZE, - page: 1, - lang, - }) - ), - ]); - - if (_isMounted.current) { - // render 1st page of results - setShouldRunFilters(true); - setInitialLoading(false); - - if (itemsRes._meta.totalResults === PAGE_SIZE) { - // load page 2 until last page - await crawlFetchItems(modelZUID, lang); - // re-render after all pages fetched - setShouldRunFilters(true); - } - - setBackgroundLoading(false); - } - } catch (err) { - if (_isMounted.current) { - setInitialLoading(false); - setBackgroundLoading(false); - } - } - } - - // crawl page 2 until the end of valid pages - // after each successful page fetch, update item count for display - // end crawling if component unmounts - // end crawling on modelZUID change - // end crawling on error - async function crawlFetchItems(modelZUID, lang) { - const limit = PAGE_SIZE; - let page = 2; - let totalItems = limit; - while (totalItems === limit && _isMounted.current) { - if (modelZUID !== props.modelZUID) break; - const res = await props.dispatch( - fetchItems(modelZUID, { limit, page, lang }) - ); - page++; - totalItems = res._meta.totalResults; - if (_isMounted.current) { - setShouldUpdateItemCount(true); - } - } - } - - function updateItemCount() { - const itemCount = findItems( - props.allItems, - props.modelZUID, - props.selectedLang.ID - ).length; - setItemCount(itemCount); - } - - function loadItem(modelZUID, itemZUID) { - return props.dispatch(fetchItem(modelZUID, itemZUID)); - } - - function searchItem(itemZUID) { - return props.dispatch(searchItems(itemZUID)); - } - - function saveItems() { - setSaving(true); - return Promise.all( - props.dirtyItems.map((itemZUID) => props.dispatch(saveItem(itemZUID))) - ) - .then((results) => { - setSaving(false); - let success = false; - // display notification for each missing required field on each item - results.forEach((result) => { - if (result.err === "MISSING_REQUIRED") { - result.missingRequired.forEach((field) => { - props.dispatch( - notify({ - message: `Missing required field "${field.label}" on ${field.ZUID}`, - kind: "warn", - }) - ); - }); - } else { - success = true; - } - }); - // if any of the items succeeded display a success notification - if (success) { - props.dispatch( - notify({ - message: `All ${props.model.label} changes saved`, - kind: "save", - }) - ); - } - }) - .catch((err) => { - setSaving(false); - console.error("ItemList:saveItems:error", err); - props.dispatch( - notify({ - message: `There was an issue saving these changes`, - kind: "warn", - }) - ); - }); - } - - // Allow for item edits in list view - function onChange(itemZUID, key, value) { - if (value === null || key === null || itemZUID === null) return; - props.dispatch({ - type: "SET_ITEM_DATA", - itemZUID, - key, - value, - }); - setItems( - items.map((item) => { - if (item.meta && item.meta.ZUID === itemZUID) { - return { - ...item, - data: { - ...item.data, - [key]: value, - }, - dirty: true, - }; - } - return item; - }) - ); - } - - function getRowSize(index) { - return index === 0 ? 55 : 72; - } - - function persistFilters() { - if (!isEqual(filter, props.filters[props.modelZUID])) { - props.dispatch({ - type: "PERSIST_LIST_FILTERS", - modelZUID: props.modelZUID, - filters: filter, - }); - } - } - - function clearFilters() { - setFilter(DEFAULT_FILTER); - } - - function filterByStatus(items, status) { - switch (status) { - case "scheduled": - return items.filter( - (item) => item.scheduling && item.scheduling.isScheduled - ); - case "published": - return items.filter( - (item) => - item.publishing && - item.publishing.isPublished && - (!item.scheduling || !item.scheduling.isScheduled) - ); - case "unpublished": - return items.filter( - (item) => - (!item.publishing || !item.publishing.isPublished) && - (!item.scheduling || !item.scheduling.isScheduled) - ); - case "all": - default: - return items; - } - } - - function sortItems(items, { reverse, col, type }) { - items.sort((a, b) => { - if (type === "date" || type === "datetime") { - const dateA = new Date(a.data[col]); - const dateB = new Date(b.data[col]); - return reverse ? dateA - dateB : dateB - dateA; - } - if (type === "number" || type === "sort") { - const numA = Number(a.data[col]); - const numB = Number(b.data[col]); - return reverse ? numA - numB : numB - numA; - } - // else we assume the type is string - const aStr = String(a.data[col]); - const bStr = String(b.data[col]); - return reverse ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr); - }); - } - - function runFilters() { - const { filterTerm, sortedBy, status, colType } = filter; - if (filterTerm !== DEFAULT_FILTER.filterTerm) { - runTermFilter(); - } else if (status !== DEFAULT_FILTER.status) { - runStatusFilter(); - } else if (sortedBy !== DEFAULT_FILTER.sortedBy) { - runSort(sortedBy, colType); - } else { - updateItemsAndFields(); - } - } - - /* - -=runFilterTerm, runFilterStatus and runSort=- - each of these functions needs to respect - the state of one another. in achieving this - some code is duplicated. - - runFilterTerm can perform all three actions - as it is dealing with all of the items in the global state. - - runFilterStatus - - runSort only deals with the in state items so can work - autonomously. - */ - function onFilter(value) { - setFilter({ ...filter, filterTerm: value.toLowerCase() }); - } - function runTermFilter() { - const newFields = findFields(props.allFields, props.modelZUID); - let filteredItems = findItems( - props.allItems, - props.modelZUID, - props.selectedLang.ID - ); - - // Only calculate this once - const fieldsWithRelatedFields = newFields.filter( - (field) => field.settings && field.settings.relatedField - ); - - const relatedFields = newFields.filter( - (field) => field.relatedFieldZUID && field.relatedModelZUID - ); - - const { filterTerm } = filter; - - filteredItems = filteredItems.filter((item) => { - // first check items data - const itemData = Object.values(item.data).join(" ").toLowerCase(); - if (itemData.includes(filterTerm)) { - return true; - } - - // Second check item web - const itemWeb = Object.values(item.web).join(" ").toLowerCase(); - if (itemWeb.includes(filterTerm)) { - return true; - } - - /** - Third check this items relationships - For each field that describes a relationship find the related item - and that related items related field then build a string of those - values that we can match the users search term against - **/ - const relatedItemsData = fieldsWithRelatedFields - .map((field) => { - let data = []; - - if (item.data[field.name]) { - const relatedField = props.allFields[field.settings.relatedField]; - data = item.data[field.name].split(",").map((relatedItemZUID) => { - const relatedItem = props.allItems[relatedItemZUID]; - if (relatedItem && relatedField) { - return relatedItem.data[relatedField.name]; - } else { - return ""; - } - }); - } - - // Generate string of all related item field values - return data.join(" "); - }) - .join(" ") - .toLowerCase(); - - if (relatedItemsData.includes(filterTerm)) { - return true; - } - - // check related fields values - const filteredRelatedFields = relatedFields.filter((field) => { - const relatedZUID = item.data[field.name]; - const relatedFieldItem = props.allItems[relatedZUID]; - if (relatedFieldItem) { - // check item data - const itemData = Object.values(relatedFieldItem.data) - .join(" ") - .toLowerCase(); - if (itemData.includes(filterTerm)) { - return true; - } - - // check item web - const itemWeb = Object.values(relatedFieldItem.web) - .join(" ") - .toLowerCase(); - if (itemWeb.includes(filterTerm)) { - return true; - } - } - return false; - }); - - if (filteredRelatedFields.length) { - return true; - } - - return false; - }); - - // handle status if applied - filteredItems = filterByStatus(filteredItems, filter.status); - - // handle sort order if it is applied - if (filter.sortedBy && newFields.length) { - // maintain expected sort oder if designated by user - sortItems(filteredItems, { - reverse: filter.reverseSort, - col: filter.sortedBy, - type: newFields.find((field) => field.name === filter.sortedBy) - .datatype, - }); - } - - filteredItems.unshift(newFields); - setItems(filteredItems); - setFields(newFields); - resetListScroll(); - } - - function onStatus(status) { - setFilter({ ...filter, status }); - } - function runStatusFilter() { - const { status } = filter; - const newFields = findFields(props.allFields, props.modelZUID); - let filteredItems = findItems( - props.allItems, - props.modelZUID, - props.selectedLang.ID - ); - filteredItems = filterByStatus(filteredItems, status); - - // handle sort order if it is applied - if (filter.sortedBy && newFields.length) { - // maintain expected sort oder if designated by user - sortItems(filteredItems, { - reverse: filter.reverseSort, - col: filter.sortedBy, - type: newFields.find((field) => field.name === filter.sortedBy) - .datatype, - }); - } - - filteredItems.unshift(newFields); - setItems(filteredItems); - setFields(newFields); - resetListScroll(); - } - - function onSort(col, type) { - const reverseSort = - col === filter.sortedBy ? !filter.reverseSort : filter.reverseSort; - setFilter({ - ...filter, - sortedBy: col, - colType: type, - reverseSort, - }); - } - function runSort() { - const { sortedBy, colType, reverseSort } = filter; - // deals only with in state items, safe for filters - - const newFields = findFields(props.allFields, props.modelZUID); - let sortedItems = findItems( - props.allItems, - props.modelZUID, - props.selectedLang.ID - ); - - // sort the items in state by the column value - if (sortedBy) { - sortItems(sortedItems, { - reverse: reverseSort, - col: sortedBy, - type: colType, - }); - } else { - sortedItems.sort((a, b) => b.meta.createdAt - a.meta.createdAt); - } - - sortedItems.unshift(newFields); - setItems(sortedItems); - setFields(newFields); - } - - function resetListScroll() { - if (list.current && (list.current.scrollTop !== 0 || list.scrollX !== 0)) { - list.current.scrollTo(0, 0); - } - } - - if (!props.model) { - return ; - } - - return ( -
- - {props.model?.label} - - - {itemCount} Items - - onSort(null)} - filterTerm={filter.filterTerm} - status={filter.status} - instance={props.instance} - /> - -
- 1) || !initialLoading} - height="80vh" - > - { - props.dispatch({ - type: "UNMARK_ITEMS_DIRTY", - items: props.dirtyItems, - }); - return Promise.resolve(); - }} - /> - - - 1 ? height : 55} - width="100%" - style={{ overflow: items?.length > 1 ? "auto" : "hidden" }} - > - {RowRender} - - - {items.length === 1 && - (filter.status !== "all" || filter.filterTerm) && ( -
-

{`No ${ - props.model && props.model.label - } items are displayed, filters are in place. `}

- -
- )} - - {items.length === 1 && ( -

{`No ${ - props.model && props.model.label - } items have been created`}

- )} -
-
-
- ); -}); - -class RowRender extends PureComponent { - render() { - const { - modelZUID, - model, - items, - allItems, - fields, - allFields, - onChange, - loadItem, - searchItem, - sortedBy, - reverseSort, - } = this.props.data; - - const item = items[this.props.index]; - - if (this.props.index === 0) { - return ( - - ); - } else { - return ( - - ); - } - } -} diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemList.less b/src/apps/content-editor/src/app/views/ItemList/ItemList.less deleted file mode 100644 index bebe7d20da..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/ItemList.less +++ /dev/null @@ -1,39 +0,0 @@ -@import "~@zesty-io/core/colors.less"; -.ItemList { - background-color: #fff; - - .TableWrap { - // Parents height minus total height of actions container - height: calc(100vh - 166px); - } - .Display { - font-size: 32px; - text-align: center; - margin: auto 0; - line-height: 42px; - font-weight: 300; - height: 80vh; - letter-spacing: 0.32px; - align-items: center; - justify-content: center; - display: flex; - flex-direction: column; - } - .FiltersDisplay { - h1 { - font-size: 24px; - line-height: 42px; - margin-bottom: 16px; - } - - text-align: center; - margin: auto 0; - font-weight: 300; - height: 80vh; - letter-spacing: 0.32px; - align-items: center; - justify-content: center; - display: flex; - flex-direction: column; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListActions.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListActions.tsx new file mode 100644 index 0000000000..afb11d870b --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListActions.tsx @@ -0,0 +1,298 @@ +import { + Button, + Box, + InputAdornment, + TextField, + Menu, + MenuItem, + ListItemIcon, + Typography, + Chip, +} from "@mui/material"; +import { Database, IconButton } from "@zesty-io/material"; +import { + MoreHorizRounded, + Add, + Search, + TableViewRounded, + CheckRounded, + CodeRounded, + WidgetsRounded, + BoltRounded, + KeyboardArrowRightRounded, + DataObjectRounded, + VisibilityRounded, + DesignServicesRounded, +} from "@mui/icons-material"; +import { forwardRef, useState, useCallback } from "react"; +import { useHistory, useParams as useRouterParams } from "react-router"; +import { useFilePath } from "../../../../../../shell/hooks/useFilePath"; +import { useParams } from "../../../../../../shell/hooks/useParams"; +import { debounce } from "lodash"; +import { useGetContentModelsQuery } from "../../../../../../shell/services/instance"; +import { useGetDomainsQuery } from "../../../../../../shell/services/accounts"; +import { ApiType } from "../../../../../schema/src/app/components/ModelApi"; +import { AppState } from "../../../../../../shell/store/types"; +import { useSelector } from "react-redux"; + +export const ItemListActions = forwardRef((props, ref) => { + const { modelZUID } = useRouterParams<{ modelZUID: string }>(); + const { data: contentModels } = useGetContentModelsQuery(); + const { data: domains } = useGetDomainsQuery(); + const history = useHistory(); + const [anchorEl, setAnchorEl] = useState(null); + const codePath = useFilePath(modelZUID); + const [isCopied, setIsCopied] = useState(false); + const [params, setParams] = useParams(); + const [searchTerm, setSearchTerm] = useState(params.get("search") || ""); + const instance = useSelector((state: AppState) => state.instance); + const [showApiEndpoints, setShowApiEndpoints] = useState( + null + ); + const [apiEndpointType, setApiEndpointType] = useState("quick-access"); + const isDataset = + contentModels?.find((model) => model.ZUID === modelZUID)?.type === + "dataset"; + const apiTypeEndpointMap: Partial> = { + "quick-access": `/-/instant/${modelZUID}.json`, + "site-generators": "/?toJSON", + }; + const liveDomain = domains?.find((domain) => domain.branch == "live"); + + const handleCopyClick = (data: string) => { + navigator?.clipboard + ?.writeText(data) + .then(() => { + setIsCopied(true); + + setTimeout(() => { + setIsCopied(false); + }, 5000); + }) + .catch((err) => { + console.error(err); + }); + }; + + const debouncedSetParams = useCallback( + debounce((value) => { + setParams(value, "search"); + }, 300), + [setParams] + ); + + const handleSearchChange = (event: React.ChangeEvent) => { + const value = event.target.value; + setSearchTerm(value); + debouncedSetParams(value); + }; + + return ( + + { + setAnchorEl(event.currentTarget); + }} + > + + + setAnchorEl(null)} + anchorEl={anchorEl} + open={!!anchorEl} + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "right", + }} + > + { + history.push(`/content/${modelZUID}/import`); + }} + > + + + + Import CSV + + { + handleCopyClick(modelZUID); + }} + > + + {isCopied ? ( + + ) : ( + + )} + + Copy ZUID + + { + setShowApiEndpoints(event.currentTarget); + setApiEndpointType("quick-access"); + }} + > + + + + View Quick Access API + + + {!isDataset && ( + { + setShowApiEndpoints(event.currentTarget); + setApiEndpointType("site-generators"); + }} + > + + + + View Site Generators API + + + )} + { + history.push(`/schema/${modelZUID}`); + }} + > + + + + Edit Model + + { + history.push(codePath); + }} + > + + + + Edit Template + + + { + setShowApiEndpoints(null); + }} + > + { + setShowApiEndpoints(null); + window.open( + // @ts-expect-error config not typed + `${CONFIG.URL_PREVIEW_PROTOCOL}${instance.randomHashID}${CONFIG.URL_PREVIEW}${apiTypeEndpointMap[apiEndpointType]}`, + "_blank" + ); + }} + > + + + + + {/* @ts-expect-error config not typed */} + {`${instance.randomHashID}${CONFIG.URL_PREVIEW}${apiTypeEndpointMap[apiEndpointType]}`} + + + + {liveDomain && ( + { + setShowApiEndpoints(null); + window.open( + `https://${liveDomain.domain}${ + apiTypeEndpointMap[ + apiEndpointType as keyof typeof apiTypeEndpointMap + ] + }`, + "_blank" + ); + }} + > + + + + + {`${liveDomain.domain}${ + apiTypeEndpointMap[ + apiEndpointType as keyof typeof apiTypeEndpointMap + ] + }`} + + + + )} + + + + + ), + sx: { + backgroundColor: "grey.50", + }, + }} + /> + + + ); +}); diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListEmpty.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListEmpty.tsx new file mode 100644 index 0000000000..7f892c619e --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListEmpty.tsx @@ -0,0 +1,33 @@ +import { Box, Button, Typography } from "@mui/material"; +import { Add } from "@mui/icons-material"; +import emptyItemsList from "../../../../../../../public/images/emptyItemsList.png"; +import { useHistory, useParams } from "react-router"; + +export const ItemListEmpty = () => { + const history = useHistory(); + const { modelZUID } = useParams<{ modelZUID: string }>(); + return ( + + + + Start Creating Content Now + + + Add your content here. Start by creating your first item. + + + + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx new file mode 100644 index 0000000000..fca9c94b56 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx @@ -0,0 +1,241 @@ +import { Box, Menu, MenuItem, Button } from "@mui/material"; +import { + DateFilter, + FilterButton, + UserFilter, +} from "../../../../../../shell/components/Filters"; +import { useEffect, useMemo, useState } from "react"; +import { useParams } from "../../../../../../shell/hooks/useParams"; +import { ArrowDropDownOutlined } from "@mui/icons-material"; +import { useGetLangsQuery } from "../../../../../../shell/services/instance"; +import { useDateFilterParams } from "../../../../../../shell/hooks/useDateFilterParams"; +import { useGetUsersQuery } from "../../../../../../shell/services/accounts"; + +const SORT_ORDER = { + dateSaved: "Date Saved", + datePublished: "Date Published", + dateCreated: "Date Created", + status: "Status", +} as const; + +const STATUS_FILTER = { + published: "Published", + scheduled: "Scheduled", + notPublished: "Not Published", +} as const; + +const getCountryCode = (langCode: string) => { + if (!langCode) return ""; + const splitTag = langCode.split("-"); + const countryCode = + splitTag.length === 2 ? splitTag[1] : langCode.toUpperCase(); + return countryCode; +}; + +const getFlagEmojiFromIETFTag = (langCode: string) => { + if (!langCode) { + return ""; + } + const countryCode = getCountryCode(langCode); + + // Convert country code to flag emoji. + // Unicode flag emojis are made up of regional indicator symbols, which are a sequence of two letters. + const baseOffset = 0x1f1e6; + return ( + String.fromCodePoint(baseOffset + (countryCode.charCodeAt(0) - 65)) + + String.fromCodePoint(baseOffset + (countryCode.charCodeAt(1) - 65)) + ); +}; + +export const ItemListFilters = () => { + const [anchorEl, setAnchorEl] = useState({ + currentTarget: null, + id: "", + }); + const [params, setParams] = useParams(); + const [activeDateFilter, setActiveDateFilter] = useDateFilterParams(); + const { data: languages } = useGetLangsQuery({}); + const activeLanguageCode = params.get("lang"); + const { data: users } = useGetUsersQuery(); + + const userOptions = useMemo(() => { + return users?.map((user) => ({ + firstName: user.firstName, + lastName: user.lastName, + ZUID: user.ZUID, + email: user.email, + })); + }, [users]); + + useEffect(() => { + // if languages and no language param, set the first language as the active language + if (languages && !activeLanguageCode) { + setParams(languages[0].code, "lang"); + } + }, [languages, activeLanguageCode]); + + return ( + + ) => { + setAnchorEl({ + currentTarget: event.currentTarget, + id: "sort", + }); + }} + onRemoveFilter={() => {}} + /> + setAnchorEl(null)} + anchorEl={anchorEl?.currentTarget} + transformOrigin={{ + vertical: -8, + horizontal: "left", + }} + > + {Object.entries(SORT_ORDER).map(([key, value]) => ( + { + setParams(key, "sort"); + setAnchorEl({ + currentTarget: null, + id: "", + }); + }} + selected={ + key === "dateSaved" + ? !params.get("sort") || params.get("sort") === key + : params.get("sort") === key + } + > + {value} + + ))} + + ) => { + setAnchorEl({ + currentTarget: event.currentTarget, + id: "statusFilter", + }); + }} + onRemoveFilter={() => { + setParams(null, "statusFilter"); + }} + /> + setAnchorEl(null)} + anchorEl={anchorEl?.currentTarget} + transformOrigin={{ + vertical: -8, + horizontal: "left", + }} + > + {Object.entries(STATUS_FILTER).map(([key, value]) => ( + { + setParams(key, "statusFilter"); + setAnchorEl({ + currentTarget: null, + id: "", + }); + }} + selected={params.get("statusFilter") === key} + > + {value} + + ))} + + setParams(value, "user")} + defaultButtonText="Created By" + options={userOptions} + /> + setActiveDateFilter(value)} + value={activeDateFilter} + /> + + setAnchorEl(null)} + anchorOrigin={{ + vertical: "bottom", + horizontal: "left", + }} + transformOrigin={{ + vertical: -10, + horizontal: "left", + }} + anchorEl={anchorEl?.currentTarget} + open={!!anchorEl?.currentTarget && anchorEl.id === "language"} + PaperProps={{ + sx: { + boxShadow: (theme) => theme.shadows[8], + width: "280px", + }, + }} + > + {languages?.map((language) => ( + { + setAnchorEl(null); + setParams(language.code, "lang"); + }} + > + {getFlagEmojiFromIETFTag(language.code)}{" "} + {language.code.split("-")[0]?.toUpperCase()} ( + {getCountryCode(language.code)}) + + ))} + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx new file mode 100644 index 0000000000..324c2f2698 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx @@ -0,0 +1,456 @@ +import { useHistory, useParams as useRouterParams } from "react-router"; +import { + Box, + CircularProgress, + Chip, + Typography, + Link, + Checkbox, + Stack, +} from "@mui/material"; +import { ReportGmailerrorred } from "@mui/icons-material"; +import { useGetContentModelFieldsQuery } from "../../../../../../shell/services/instance"; +import { + DataGridPro, + GridRenderCellParams, + GRID_CHECKBOX_SELECTION_COL_DEF, + useGridApiRef, + GridInitialState, +} from "@mui/x-data-grid-pro"; +import { + memo, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { ContentItem } from "../../../../../../shell/services/types"; +import { useStagedChanges } from "./StagedChangesContext"; +import { OneToManyCell } from "./TableCells/OneToManyCell"; +import { UserCell } from "./TableCells/UserCell"; +import { useSelectedItems } from "./SelectedItemsContext"; +import { VersionCell } from "./TableCells/VersionCell"; +import { DropDownCell } from "./TableCells/DropdownCell"; +import { SortCell } from "./TableCells/SortCell"; +import { BooleanCell } from "./TableCells/BooleanCell"; + +type ItemListTableProps = { + loading: boolean; + rows: ContentItem[]; +}; + +const getHtmlText = (html: string) => { + if (!html) return ""; + + const rawData = html; + let elementFromData = document.createElement("div"); + elementFromData.innerHTML = rawData; + const strippedData = + elementFromData?.textContent || elementFromData?.innerText; + return strippedData?.replace(/<[^>]*>/g, "").slice(0, 120) || ""; +}; + +const METADATA_COLUMNS = [ + { + field: "createdBy", + headerName: "Created By", + width: 240, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => , + }, + { + field: "createdOn", + headerName: "Created On", + width: 200, + sortable: false, + filterable: false, + valueGetter: (params: any) => params.row?.meta?.createdAt, + }, + + { + field: "lastSaved", + headerName: "Last Saved", + width: 200, + sortable: false, + filterable: false, + valueGetter: (params: any) => params.row?.web?.updatedAt, + }, + { + field: "lastPublished", + headerName: "Last Published", + width: 200, + sortable: false, + filterable: false, + valueGetter: (params: any) => + params.row?.publishing?.publishAt || + params.row?.priorPublishing?.publishAt, + }, + { + field: "zuid", + headerName: "ZUID", + width: 200, + sortable: false, + filterable: false, + valueGetter: (params: any) => params.row?.meta?.ZUID, + }, +] as const; + +const fieldTypeColumnConfigMap = { + text: { + width: 360, + }, + wysiwyg_basic: { + width: 360, + valueFormatter: (params: any) => getHtmlText(params.value), + }, + wysiwyg_advanced: { + width: 360, + valueFormatter: (params: any) => getHtmlText(params.value), + }, + article_writer: { + width: 360, + valueFormatter: (params: any) => getHtmlText(params.value), + }, + markdown: { + width: 360, + }, + textarea: { + width: 360, + }, + one_to_many: { + width: 240, + renderCell: (params: GridRenderCellParams) => { + return ; + }, + }, + one_to_one: { + width: 240, + renderCell: (params: any) => + params.value && , + }, + uuid: { + width: 280, + }, + number: { + width: 160, + valueFormatter: (params: any) => { + if (!params.value) return null; + return new Intl.NumberFormat("en-US").format(params.value); + }, + }, + currency: { + width: 160, + valueFormatter: (params: any) => { + if (!params.value) return null; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(params.value); + }, + }, + images: { + width: 100, + renderCell: (params: GridRenderCellParams) => { + const src = params?.value?.thumbnail || params?.value?.split(",")?.[0]; + + if (!src) { + return ( + + + + ); + } + + return ( + theme.palette.grey[100], + objectFit: "contain", + }} + width="68px" + height="58px" + src={src} + /> + ); + }, + }, + dropdown: { + width: 240, + renderCell: (params: GridRenderCellParams) => ( + + ), + }, + date: { + width: 160, + }, + datetime: { + width: 200, + }, + link: { + width: 360, + renderCell: (params: any) => { + return ( + theme.palette.primary.main, + }, + }} + onClick={(e) => e.stopPropagation()} + > + {params.value} + + ); + }, + }, + internal_link: { + width: 240, + renderCell: (params: any) => + params.value && , + }, + yes_no: { + width: 120, + renderCell: (params: GridRenderCellParams) => ( + + ), + }, + color: { + width: 140, + renderCell: (params: GridRenderCellParams) => { + return ( + + + params.value?.toLowerCase() === "#ffffff" + ? `1px solid ${theme.palette.border}` + : "none", + }} + /> + {params.value?.toUpperCase()} + + ); + }, + }, + sort: { + width: 112, + renderCell: (params: GridRenderCellParams) => , + }, +} as const; + +export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { + const { modelZUID } = useRouterParams<{ modelZUID: string }>(); + const apiRef = useGridApiRef(); + const [initialState, setInitialState] = useState(); + const history = useHistory(); + const { stagedChanges } = useStagedChanges(); + const [selectedItems, setSelectedItems] = useSelectedItems(); + + const { data: fields } = useGetContentModelFieldsQuery(modelZUID); + + const saveSnapshot = useCallback(() => { + if (apiRef?.current?.exportState && localStorage) { + const currentState = apiRef.current.exportState(); + localStorage.setItem( + `${modelZUID}-dataGridState`, + JSON.stringify(currentState) + ); + } + }, [apiRef, modelZUID]); + + useLayoutEffect(() => { + if (!fields) return; + const stateFromLocalStorage = localStorage?.getItem( + `${modelZUID}-dataGridState` + ); + + setInitialState( + stateFromLocalStorage + ? JSON.parse(stateFromLocalStorage) + : { + pinnedColumns: { + left: [ + GRID_CHECKBOX_SELECTION_COL_DEF.field, + "version", + fields?.[0]?.name, + ], + }, + } + ); + + window.addEventListener("beforeunload", saveSnapshot); + + return () => { + window.removeEventListener("beforeunload", saveSnapshot); + saveSnapshot(); + }; + }, [saveSnapshot, fields, modelZUID]); + + const columns = useMemo(() => { + let result: any[] = [ + { + field: "version", + headerName: "Vers.", + width: 59, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + ), + }, + ]; + if (fields) { + result = [ + ...result, + ...fields + ?.filter((field) => !field.deletedAt) + ?.map((field) => ({ + field: field.name, + headerName: field.label, + sortable: false, + filterable: false, + valueGetter: (params: any) => params.row.data[field.name], + ...fieldTypeColumnConfigMap[field.datatype], + // if field is yes_no but it has custom options increase the width + ...(field.datatype === "yes_no" && + field?.settings?.options?.[0] !== "No" && + field?.settings?.options?.[1] !== "Yes" && { + width: 280, + }), + })), + ]; + } + return result; + }, [fields]); + + if (!initialState) { + return ( + + + + ); + } + + return ( + { + if (typeof row.id === "string" && row.id?.startsWith("new")) { + history.push(`/content/${modelZUID}/new`); + } else { + history.push(`/content/${modelZUID}/${row.id}`); + } + }} + components={{ + NoRowsOverlay: () => <>, + BaseCheckbox: (props) => ( + + ), + }} + componentsProps={{ + baseTooltip: { + placement: "top-start", + slotProps: { + popper: { + modifiers: [ + { + name: "offset", + options: { + offset: [0, -30], + }, + }, + ], + }, + }, + }, + }} + getRowClassName={(params) => { + // if included in staged changes, highlight the row + if (stagedChanges?.[params.id]) { + return "Mui-selected"; + } + }} + checkboxSelection + disableSelectionOnClick + initialState={initialState} + onSelectionModelChange={(newSelection) => setSelectedItems(newSelection)} + selectionModel={ + stagedChanges && Object.keys(stagedChanges)?.length ? [] : selectedItems + } + isRowSelectable={(params) => + params.row?.meta?.version && + !(stagedChanges && Object.keys(stagedChanges)?.length) + } + sx={{ + ...(!rows?.length && { + height: 56, + flex: 0, + }), + backgroundColor: "common.white", + ".MuiDataGrid-row": { + cursor: "pointer", + }, + border: "none", + "& .MuiDataGrid-columnHeaderCheckbox": { + padding: 0, + }, + " & .MuiDataGrid-columnSeparator": { + visibility: "visible", + }, + "& .MuiDataGrid-pinnedColumnHeaders": { + backgroundColor: "inherit", + }, + ".MuiDataGrid-columnHeader": { + "&:hover .MuiDataGrid-columnSeparator": { + visibility: "visible", + }, + }, + ".MuiDataGrid-columnSeparator": { + visibility: "hidden", + }, + "& .MuiDataGrid-cell:focus-within": { + outline: "none", + }, + "& .MuiDataGrid-columnHeader:focus-within": { + outline: "none", + }, + "& .MuiDataGrid-cell:has([data-cy='sortCell'])": { + padding: 0, + }, + }} + /> + ); +}); diff --git a/src/apps/content-editor/src/app/views/ItemList/SchedulePublishesDialog.tsx b/src/apps/content-editor/src/app/views/ItemList/SchedulePublishesDialog.tsx new file mode 100644 index 0000000000..ecf7d6196f --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/SchedulePublishesDialog.tsx @@ -0,0 +1,152 @@ +import { useState } from "react"; +import { + Dialog, + DialogActions, + DialogTitle, + DialogContent, + Typography, + Button, + Stack, + Box, + Alert, + List, +} from "@mui/material"; +import { LoadingButton } from "@mui/lab"; +import ScheduleRoundedIcon from "@mui/icons-material/ScheduleRounded"; +import WarningRoundedIcon from "@mui/icons-material/WarningRounded"; +import moment from "moment-timezone"; +import { ContentItem } from "../../../../../../shell/services/types"; +import { FieldTypeDateTime } from "../../../../../../shell/components/FieldTypeDateTime"; +import { DialogContentItem } from "./DialogContentItem"; +import pluralizeWord from "../../../../../../utility/pluralizeWord"; + +type SchedulePublishesModalProps = { + items: ContentItem[]; + onCancel: () => void; + onConfirm: (items: ContentItem[], publishDateTime?: string) => void; +}; +export const SchedulePublishesModal = ({ + onCancel, + items, + onConfirm, +}: SchedulePublishesModalProps) => { + const [publishDateTime, setPublishDateTime] = useState( + moment().minute(0).second(0).add(1, "hours").format("yyyy-MM-DD HH:mm:ss") + ); + const [publishTimezone, setPublishTimezone] = useState( + moment.tz.guess() ?? "America/Los_Angeles" + ); + + const isSelectedDatetimePast = moment + .utc(moment.tz(publishDateTime, publishTimezone)) + .isBefore(moment.utc()); + + return ( + + + + + + + + + + Schedule Publish of Changes to {items.length}{" "} + {pluralizeWord("Item", items.length)} + + + + You can always cancel the publish later if needed + + + + + + <> + + Publish on + + { + setPublishDateTime(datetime); + }} + onTimezoneChange={(timezone: any) => { + setPublishTimezone(timezone); + }} + /> + {isSelectedDatetimePast && ( + } + sx={{ + mt: 2.5, + }} + > + Since the selected time is a current or past date, this will be + immediately published. + + )} + + {items.map((item, index) => ( + + ))} + + + + + + + } + onClick={() => { + if (isSelectedDatetimePast) { + onConfirm(items); + } else { + onConfirm( + items, + moment + .utc(moment.tz(publishDateTime, publishTimezone)) + .format("YYYY-MM-DD HH:mm:ss") + ); + } + }} + > + Schedule Publish ({items.length}) + + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemList/SelectedItemsContext.tsx b/src/apps/content-editor/src/app/views/ItemList/SelectedItemsContext.tsx new file mode 100644 index 0000000000..4a1d876864 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/SelectedItemsContext.tsx @@ -0,0 +1,27 @@ +import React, { createContext, useContext, useState, useCallback } from "react"; + +const SelectedItemsContext = createContext(null); + +export const SelectedItemsProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [selected, setSelected] = useState([]); + + const setSelectedItems = useCallback((items) => { + if (items.length > 100) { + setSelected(items.slice(0, 100)); + } else { + setSelected(items); + } + }, []); + + return ( + + {children} + + ); +}; + +export const useSelectedItems = () => useContext(SelectedItemsContext); diff --git a/src/apps/content-editor/src/app/views/ItemList/SetActions/SetActions.js b/src/apps/content-editor/src/app/views/ItemList/SetActions/SetActions.js deleted file mode 100644 index 09959be29f..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetActions/SetActions.js +++ /dev/null @@ -1,205 +0,0 @@ -import { Component } from "react"; -import { Link } from "react-router-dom"; -import cx from "classnames"; - -import { Button, Select, MenuItem, TextField } from "@mui/material"; -import CloseIcon from "@mui/icons-material/Close"; -import CircularProgress from "@mui/material/CircularProgress"; - -import SaveIcon from "@mui/icons-material/Save"; -import StorageIcon from "@mui/icons-material/Storage"; -import TableChartRoundedIcon from "@mui/icons-material/TableChartRounded"; -import AddIcon from "@mui/icons-material/Add"; -import InputAdornment from "@mui/material/InputAdornment"; -import SearchIcon from "@mui/icons-material/Search"; - -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faSpinner, faBolt } from "@fortawesome/free-solid-svg-icons"; - -import { AppLink } from "@zesty-io/core/AppLink"; - -import MuiLink from "@mui/material/Link"; - -import { LanguageSelector } from "../../ItemEdit/components/Header/LanguageSelector"; - -import styles from "./SetActions.less"; -export class SetActions extends Component { - render() { - if (this.props.model && this.props.model.name === "clippings") { - return ( -
-

{this.props.model.label}

-
- ); - } else { - return ( -
-
- - - - ), - sx: { - height: "32px", - }, - }} - onChange={(evt) => { - const term = evt.target.value; - this.props.onFilter(term); - }} - sx={{ - height: "32px", - }} - /> - - - - - - {Boolean(this.props.isDirty) && ( - - )} - {this.props.loading && ( - - - Loading items - - )} - {this.props.isSorted && ( - - )} -
-
- {this.props.instance.basicApi ? ( - - -  Instant API - - ) : null} - - {this.props.model && this.props.user.is_developer && ( - - )} - - - - - - -
-
- ); - } - } -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetActions/SetActions.less b/src/apps/content-editor/src/app/views/ItemList/SetActions/SetActions.less deleted file mode 100644 index a61b2d97fe..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetActions/SetActions.less +++ /dev/null @@ -1,79 +0,0 @@ -@import "~@zesty-io/core/colors.less"; - -.Actions { - display: flex; - flex-wrap: wrap; - align-items: stretch; - background-color: #fff; - padding-top: 8px; - padding-bottom: 16px; - padding-left: 24px; - padding-right: 24px; - justify-content: space-between; - gap: 8px; - - .I18N { - min-width: 200px; - z-index: 30; - } - - .Clippings { - color: @zesty-white; - - font-size: 22px; - vertical-align: middle; - line-height: 2; - margin: 0 0 0 8px; - text-transform: capitalize; - } - - .Left { - display: flex; - gap: 8px; - align-items: center; - .Select { - width: 175px; - } - - .Loading { - color: #fff; - padding: 0 16px; - align-self: center; - span { - padding-left: 8px; - } - } - } - - .Right { - color: #fff; - display: flex; - align-items: center; - flex-basis: 275px; - - a { - text-decoration: none; - } - span { - margin-right: 8px; - } - } - - .ModelItemCount { - display: flex; - flex-direction: column; - .InstantApi { - margin: 0; - text-shadow: 1px 1px 0px #434b5d; - } - } - - @keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } - } -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetActions/index.js b/src/apps/content-editor/src/app/views/ItemList/SetActions/index.js deleted file mode 100644 index d7ada9b660..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetActions/index.js +++ /dev/null @@ -1 +0,0 @@ -export { SetActions } from "./SetActions"; diff --git a/src/apps/content-editor/src/app/views/ItemList/SetColumns/SetColumns.js b/src/apps/content-editor/src/app/views/ItemList/SetColumns/SetColumns.js deleted file mode 100644 index f90495a65d..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetColumns/SetColumns.js +++ /dev/null @@ -1,66 +0,0 @@ -import { PureComponent } from "react"; -import cx from "classnames"; - -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - faChevronUp, - faChevronDown, - faQuestionCircle, -} from "@fortawesome/free-solid-svg-icons"; -import { Loader } from "@zesty-io/core/Loader"; - -import styles from "./SetColumns.less"; - -export class SetColumns extends PureComponent { - render() { - return ( -
- - - - - {this.props.fields ? ( - this.props.fields - .filter((field) => field.settings && field.settings.list == 1) - .map((field) => { - return ( - - this.props.onSort && - this.props.onSort(field.name, field.datatype) - } - className={cx( - "SortBy", - styles.Cell, - styles[`${field.datatype}Header`] - )} - > - {field.label}  - {this.props.sortedBy === field.name ? ( - this.props.reverseSort ? ( - - ) : ( - - ) - ) : ( - "" - )} - - ); - }) - ) : ( - - )} - -
- ); - } -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetColumns/SetColumns.less b/src/apps/content-editor/src/app/views/ItemList/SetColumns/SetColumns.less deleted file mode 100644 index 3bcfbe6717..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetColumns/SetColumns.less +++ /dev/null @@ -1,71 +0,0 @@ -@import "~@zesty-io/core/colors.less"; -.TableHeader { - display: flex; - z-index: 1; - height: 55px; - - .wrap { - background-color: #f2f4f7; - color: #101828; - display: flex; - flex: 1; - height: 100%; - align-items: center; - // min-height: 60px; - } - - .Cell { - background-color: #f2f4f7; - // Rows have a 1px left border on their cells - // so each header needs this as well to get matching widths - border-left: 1px solid #4c5568; - cursor: pointer; - line-height: 16px; - padding: 8px 12px; - width: 100px; - font-weight: 500; - } - .PublishedHeader { - width: 40px; - display: flex; - align-items: center; - justify-content: center; - padding: 0; - height: 100%; - } - .textareaHeader, - .textHeader, - .wysiwyg_basicHeader, - .wysiwyg_advancedHeader, - .markdownHeader, - .article_writerHeader, - .uuidHeader { - width: 400px; - } - .one_to_oneHeader, - .one_to_manyHeader, - .internal_linkHeader, - .linkHeader { - width: 300px; - } - .dateHeader, - .datetimeHeader { - width: 220px; - } - .dropdownHeader { - width: 200px; - } - .filesHeader { - width: 200px; - } - .sortHeader { - width: 150px; - } - .toggleHeader, - .yes_noHeader { - width: 140px; - } - .imagesHeader { - width: 130px; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetColumns/index.js b/src/apps/content-editor/src/app/views/ItemList/SetColumns/index.js deleted file mode 100644 index c27e97483a..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetColumns/index.js +++ /dev/null @@ -1 +0,0 @@ -export { SetColumns } from "./SetColumns"; diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/ColorCell/ColorCell.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/ColorCell/ColorCell.js deleted file mode 100644 index efb7aedabd..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/ColorCell/ColorCell.js +++ /dev/null @@ -1,14 +0,0 @@ -import { memo } from "react"; -import cx from "classnames"; - -import styles from "./ColorCell.less"; -export const ColorCell = memo(function ColorCell(props) { - return ( - - ); -}); diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/ColorCell/ColorCell.less b/src/apps/content-editor/src/app/views/ItemList/SetRow/ColorCell/ColorCell.less deleted file mode 100644 index 660ae93c5b..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/ColorCell/ColorCell.less +++ /dev/null @@ -1,8 +0,0 @@ -.ColorCell { - width: 70px; - display: flex; - align-items: center; - justify-content: center; - margin: 1rem; - border-radius: 3px; -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/ColorCell/index.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/ColorCell/index.js deleted file mode 100644 index 9b10ae8620..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/ColorCell/index.js +++ /dev/null @@ -1 +0,0 @@ -export { ColorCell } from "./ColorCell"; diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/DateCell/DateCell.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/DateCell/DateCell.js deleted file mode 100644 index 8494494f7c..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/DateCell/DateCell.js +++ /dev/null @@ -1,29 +0,0 @@ -import { memo } from "react"; -import cx from "classnames"; -import moment from "moment-timezone"; - -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCalendar } from "@fortawesome/free-solid-svg-icons"; - -import styles from "./DateCell.less"; -export const DateCell = memo(function DateCell(props) { - if (props.value) { - return ( - - - {/* We recieve a GMT timestamp so we need to convert to the users local - timezone in moment format string */} - {moment(props.value).format("MMMM Do YYYY")} - - - ); - } else { - return ( - - {/* We recieve a GMT timestamp so we need to convert to the users local - timezone in moment format string */} - - - ); - } -}); diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/DateCell/DateCell.less b/src/apps/content-editor/src/app/views/ItemList/SetRow/DateCell/DateCell.less deleted file mode 100644 index ec3643f396..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/DateCell/DateCell.less +++ /dev/null @@ -1,14 +0,0 @@ -@import "~@zesty-io/core/colors.less"; - -.DateCell { - align-items: center; - display: flex; - width: 220px; -} -.DateCell.Empty { - align-items: start; - i { - // font-size: 16px; - color: @zesty-light-grey; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/DateCell/index.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/DateCell/index.js deleted file mode 100644 index cd3962a454..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/DateCell/index.js +++ /dev/null @@ -1 +0,0 @@ -export { DateCell } from "./DateCell"; diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/DropdownCell/DropdownCell.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/DropdownCell/DropdownCell.js deleted file mode 100644 index b94772d77e..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/DropdownCell/DropdownCell.js +++ /dev/null @@ -1,29 +0,0 @@ -import { memo } from "react"; - -import cx from "classnames"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons"; -import { AppLink } from "@zesty-io/core/AppLink"; - -import styles from "./DropdownCell.less"; -export const DropdownCell = memo(function DropdownCell(props) { - if (props.field.settings && props.field.settings.options) { - return ( - - {props.field.settings.options[props.value]} - - ); - } else { - return ( - - event.stopPropagation()} - > - -  Missing dropdown options. - - - ); - } -}); diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/DropdownCell/DropdownCell.less b/src/apps/content-editor/src/app/views/ItemList/SetRow/DropdownCell/DropdownCell.less deleted file mode 100644 index 828a68594c..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/DropdownCell/DropdownCell.less +++ /dev/null @@ -1,5 +0,0 @@ -.DropdownCell { - width: 200px; - display: flex; - align-items: center; -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/DropdownCell/index.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/DropdownCell/index.js deleted file mode 100644 index 27f0b8bfa5..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/DropdownCell/index.js +++ /dev/null @@ -1 +0,0 @@ -export { DropdownCell } from "./DropdownCell"; diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/FileCell/FileCell.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/FileCell/FileCell.js deleted file mode 100644 index 9e223bb110..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/FileCell/FileCell.js +++ /dev/null @@ -1,102 +0,0 @@ -import { Component } from "react"; -import cx from "classnames"; - -import { request } from "utility/request"; - -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faFile } from "@fortawesome/free-solid-svg-icons"; -import { Loader } from "@zesty-io/core/Loader"; - -import styles from "./FileCell.less"; -export class FileCell extends Component { - state = { - filename: null, - filetype: "file-o", - loading: false, - }; - componentDidMount() { - this.setState({ - loading: true, - }); - request(`${CONFIG.SERVICE_MEDIA_MANAGER}/file/${this.props.data}`) - .then((res) => { - this.setState({ loading: false }); - if (res.status === 400) { - this.props.dispatch( - notify({ - message: `Failure fetching filename: ${res.error}`, - kind: "error", - }) - ); - } else { - const filename = res.data[0].filename; - this.setState({ - filetype: this.resolveFileType(filename.split(".")[1]), - filename, - }); - } - }) - .catch((err) => { - console.error("FileCell:componentDidMount", err); - this.setState({ - loading: false, - }); - }); - } - resolveFileType = (filesuffix) => { - switch (filesuffix.toLowerCase()) { - case "jpg": - case "png": - case "gif": - case "jpeg": - return "file-image-o"; - case "js": - case "json": - case "css": - case "html": - case "htm": - return "file-code-o"; - case "pdf": - return "file-pdf-o"; - case "doc": - return "file-word-o"; - case "txt": - return "file-text-o"; - case "xls": - return "file-excel-o"; - case "wav": - case "mp3": - case "ogg": - return "file-audio-o"; - case "mp4": - case "mov": - case "mkv": - return "film"; - case "zip": - case "tar": - case "gz": - return "file-archive-o"; - - default: - return "file-o"; - } - }; - render() { - if (!this.state.loading && !this.state.filename) { - return ( - - - - ); - } else { - return ( - - {this.state.loading && } - {this.state.filename} - - ); - } - } -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/FileCell/FileCell.less b/src/apps/content-editor/src/app/views/ItemList/SetRow/FileCell/FileCell.less deleted file mode 100644 index 4f82bf730e..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/FileCell/FileCell.less +++ /dev/null @@ -1,20 +0,0 @@ -@import "~@zesty-io/core/colors.less"; - -.FileCell { - display: flex; - align-items: center; - width: 200px; - i { - padding-left: 0.5rem; - } -} - -.FileCell.Empty { - align-items: start; - a { - margin: 0; - } - i { - color: @zesty-light-grey; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/FileCell/index.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/FileCell/index.js deleted file mode 100644 index e40e63ae4b..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/FileCell/index.js +++ /dev/null @@ -1 +0,0 @@ -export { FileCell } from "./FileCell"; diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/ImageCell/ImageCell.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/ImageCell/ImageCell.js deleted file mode 100644 index 850daf36b4..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/ImageCell/ImageCell.js +++ /dev/null @@ -1,36 +0,0 @@ -import { PureComponent } from "react"; -import cx from "classnames"; - -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faImage } from "@fortawesome/free-solid-svg-icons"; - -import styles from "./ImageCell.less"; -export class ImageCell extends PureComponent { - render() { - if (this.props.value) { - if (this.props.value.slice(0, 4) === "http") { - return ( - - - - ); - } else { - return ( - - - - ); - } - } else { - return ( - - - - ); - } - } -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/ImageCell/ImageCell.less b/src/apps/content-editor/src/app/views/ItemList/SetRow/ImageCell/ImageCell.less deleted file mode 100644 index 25c3ff2ac0..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/ImageCell/ImageCell.less +++ /dev/null @@ -1,42 +0,0 @@ -@import "~@zesty-io/core/colors.less"; - -.ImageCell { - display: flex; - align-items: center; - justify-content: center; - width: 130px; - // background: #1b202c; - - img { - // width: 130px; - max-width: 100%; - height: 75px; - background-color: #3f4658; - background-position: 0 0, 10px 10px; - background-size: 21px 21px; - background-image: linear-gradient( - 45deg, - #efefef 25%, - transparent 25%, - transparent 75%, - #efefef 75%, - #efefef - ), - linear-gradient( - 45deg, - #efefef 25%, - transparent 25%, - transparent 75%, - #efefef 75%, - #efefef - ); - } -} - -.ImageCell.Empty { - justify-content: start; - align-items: start; - i { - color: @zesty-light-grey; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/ImageCell/index.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/ImageCell/index.js deleted file mode 100644 index c0bbea0790..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/ImageCell/index.js +++ /dev/null @@ -1 +0,0 @@ -export { ImageCell } from "./ImageCell"; diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/InternalLinkCell/InternalLinkCell.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/InternalLinkCell/InternalLinkCell.js deleted file mode 100644 index 125bd70523..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/InternalLinkCell/InternalLinkCell.js +++ /dev/null @@ -1,66 +0,0 @@ -import { PureComponent } from "react"; -import cx from "classnames"; - -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faLink } from "@fortawesome/free-solid-svg-icons"; -import { Loader } from "@zesty-io/core/Loader"; -import { AppLink } from "@zesty-io/core/AppLink"; - -import styles from "./InternalLinkCell.less"; -export class InternalLinkCell extends PureComponent { - render() { - if (!this.props.relatedItemZUID || this.props.relatedItemZUID == "0") { - return ( - - - - ); - } - - const relatedItem = this.props.allItems[this.props.relatedItemZUID]; - if ( - relatedItem && - relatedItem.web && - relatedItem.meta && - relatedItem.meta.contentModelZUID - ) { - return ( - - - -   - - {relatedItem.web.metaTitle ? ( - relatedItem.web.metaTitle > 80 ? ( - - {relatedItem.web.metaTitle.substr(0, 80)} - … - - ) : ( - relatedItem.web.metaTitle - ) - ) : ( - this.props.relatedItemZUID - )} - - - - ); - } else { - this.props.searchItem(this.props.relatedItemZUID); - return ( - - - - ); - } - } -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/InternalLinkCell/InternalLinkCell.less b/src/apps/content-editor/src/app/views/ItemList/SetRow/InternalLinkCell/InternalLinkCell.less deleted file mode 100644 index 9e26635e9b..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/InternalLinkCell/InternalLinkCell.less +++ /dev/null @@ -1,20 +0,0 @@ -@import "~@zesty-io/core/colors.less"; - -.InternalLinkCell { - width: 300px; - display: flex; - align-items: center; - word-break: break-all; - a { - margin: 0; - } -} - -.InternalLinkCell.Empty { - justify-content: start; - align-items: start; - - i { - color: @zesty-light-grey; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/InternalLinkCell/index.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/InternalLinkCell/index.js deleted file mode 100644 index 9e880cbe9d..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/InternalLinkCell/index.js +++ /dev/null @@ -1 +0,0 @@ -export { InternalLinkCell } from "./InternalLinkCell"; diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/LinkCell/LinkCell.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/LinkCell/LinkCell.js deleted file mode 100644 index b7562bfe07..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/LinkCell/LinkCell.js +++ /dev/null @@ -1,51 +0,0 @@ -import cx from "classnames"; - -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faExternalLinkSquareAlt } from "@fortawesome/free-solid-svg-icons"; - -import Link from "@mui/material/Link"; - -import styles from "./LinkCell.less"; -export const LinkCell = function LinkCell(props) { - if (props.value) { - return ( - - {props.value.length > 145 ? ( - - - -   - {props.value && props.value.substr(0, 145)} … - - - ) : ( - - - -   - {props.value} - - - )} - - ); - } else { - return ( - - - - ); - } -}; diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/LinkCell/LinkCell.less b/src/apps/content-editor/src/app/views/ItemList/SetRow/LinkCell/LinkCell.less deleted file mode 100644 index 4fe0302fdc..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/LinkCell/LinkCell.less +++ /dev/null @@ -1,18 +0,0 @@ -@import "~@zesty-io/core/colors.less"; - -.LinkCell { - width: 300px; - display: flex; - align-items: center; - word-break: break-all; - a { - line-height: 18px; - margin: 0; - } -} -.LinkCell.Empty { - align-items: start; - i { - color: @zesty-light-grey; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/LinkCell/index.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/LinkCell/index.js deleted file mode 100644 index d3203cb16c..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/LinkCell/index.js +++ /dev/null @@ -1 +0,0 @@ -export { LinkCell } from "./LinkCell"; diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToManyCell/OneToManyCell.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToManyCell/OneToManyCell.js deleted file mode 100644 index c18ef98b80..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToManyCell/OneToManyCell.js +++ /dev/null @@ -1,97 +0,0 @@ -import { PureComponent } from "react"; -import cx from "classnames"; - -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons"; -import { Loader } from "@zesty-io/core/Loader"; -import Chip from "@mui/material/Chip"; -import { AppLink } from "@zesty-io/core/AppLink"; - -import styles from "./OneToManyCell.less"; -export class OneToManyCell extends PureComponent { - onDelete = (itemZUID) => { - this.props.onRemove( - this.props.value - .split(",") - .filter((item) => item !== itemZUID) - .join(","), - this.props.name - ); - }; - - render() { - // OneToMany Fields require a relationship to be defined - if (!this.props.settings || !this.props.field.relatedFieldZUID) { - return ( - - - -  Missing field configuration - - - ); - } else { - const relatedItemZUIDs = this.props.value - .split(",") - .filter((ZUID) => ZUID); - const relatedItems = relatedItemZUIDs.map( - (ZUID) => this.props.allItems[ZUID] - ); - - if (!relatedItems.length) { - // No items have been related so render a blank cell - return ( - - ); - } else { - const relatedField = - this.props.allFields[this.props.field.relatedFieldZUID]; - - // NOTE: user WithLoader and show loading message with related field name - - if (relatedField && relatedField.name) { - return ( - - {relatedItems.length && - relatedItems - .filter((item) => item) - .map((item, i) => ( - this.onDelete(item.meta.ZUID)} - sx={{ - alignSelf: "center", - }} - > - ))} - - ); - } else { - // Otherwise the field is probably being loaded? - return ( - - - - ); - } - } - } - } -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToManyCell/OneToManyCell.less b/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToManyCell/OneToManyCell.less deleted file mode 100644 index ae783ee227..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToManyCell/OneToManyCell.less +++ /dev/null @@ -1,12 +0,0 @@ -@import "~@zesty-io/core/colors.less"; - -.OneToManyCell { - align-items: flex-start; - display: flex; - flex-wrap: wrap; - overflow: hidden; - width: 300px; -} -.ErrorCell { - align-items: center; -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToManyCell/index.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToManyCell/index.js deleted file mode 100644 index 7341c18207..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToManyCell/index.js +++ /dev/null @@ -1 +0,0 @@ -export { OneToManyCell } from "./OneToManyCell"; diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToOneCell/OneToOneCell.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToOneCell/OneToOneCell.js deleted file mode 100644 index 7b3174f435..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToOneCell/OneToOneCell.js +++ /dev/null @@ -1,72 +0,0 @@ -import { PureComponent } from "react"; -import cx from "classnames"; - -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons"; -import { Loader } from "@zesty-io/core/Loader"; -import { AppLink } from "@zesty-io/core/AppLink"; - -import styles from "./OneToOneCell.less"; -export class OneToOneCell extends PureComponent { - render() { - if (!this.props.field.relatedFieldZUID) { - return ( - - - -  Missing field configuration - - - ); - } - - const relatedItemZUID = this.props.value; - if (!relatedItemZUID || relatedItemZUID == "0") { - return ; - } - - const relatedItem = this.props.allItems[relatedItemZUID]; - if (!relatedItem || !relatedItem.data) { - this.props.loadItem( - this.props.field.settings.relatedModel, - relatedItemZUID - ); - return ( - - - - ); - } - - const relatedField = - this.props.allFields[this.props.field.relatedFieldZUID]; - if (!relatedField) { - // TODO: if not relatedField, load specific field? - return ( - - - - ); - } - - if ( - relatedItem && - relatedItem.data && - relatedField && - relatedField.name && - String(relatedItem.data[relatedField.name]) - ) { - return ( - - {`${String(relatedItem.data[relatedField.name]).slice(0, 60)}${ - String(relatedItem.data[relatedField.name]).length > 60 ? "..." : "" - }`} - - ); - } else { - return ; - } - } -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToOneCell/OneToOneCell.less b/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToOneCell/OneToOneCell.less deleted file mode 100644 index ee518c794f..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToOneCell/OneToOneCell.less +++ /dev/null @@ -1,7 +0,0 @@ -@import "~@zesty-io/core/colors.less"; - -.OneToOneCell { - width: 300px; - display: flex; - align-items: center; -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToOneCell/index.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToOneCell/index.js deleted file mode 100644 index 54e5903126..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/OneToOneCell/index.js +++ /dev/null @@ -1 +0,0 @@ -export { OneToOneCell } from "./OneToOneCell"; diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/PublishStatusCell/PublishStatusCell.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/PublishStatusCell/PublishStatusCell.js deleted file mode 100644 index cb6de0045f..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/PublishStatusCell/PublishStatusCell.js +++ /dev/null @@ -1,81 +0,0 @@ -import { memo } from "react"; -import cx from "classnames"; - -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faClock, faCircle } from "@fortawesome/free-solid-svg-icons"; - -import VisibilityIcon from "@mui/icons-material/Visibility"; -import ClockIcon from "@mui/icons-material/WatchLater"; -import Link from "@mui/material/Link"; - -import { AppLink } from "@zesty-io/core/AppLink"; - -import styles from "./PublishStatusCell.less"; -export const PublishStatusCell = memo(function PublishStatusCell(props) { - if (props.itemZUID.slice(0, 3) === "new") { - return ( - - - - ); - } - if (props.type === "dataset") { - return ( - - {props.item && - props.item.scheduling && - props.item.scheduling.isScheduled ? ( - - ) : props.item && - props.item.publishing && - props.item.publishing.isPublished ? ( - - ) : ( - - )} - - ); - } else { - return ( - - {props.item && - props.item.scheduling && - props.item.scheduling.isScheduled ? ( - - ) : props.item && - props.item.publishing && - props.item.publishing.isPublished ? ( - - ) : ( - - )} - - ); - } -}); diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/PublishStatusCell/PublishStatusCell.less b/src/apps/content-editor/src/app/views/ItemList/SetRow/PublishStatusCell/PublishStatusCell.less deleted file mode 100644 index 5147b06225..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/PublishStatusCell/PublishStatusCell.less +++ /dev/null @@ -1,26 +0,0 @@ -@import "~@zesty-io/core/colors.less"; - -.PublishStatusCell { - align-items: center; - border-bottom: 1px solid rgba(26, 32, 44, 0.12); - display: flex; - justify-content: center; - flex-basis: 40px; - flex-grow: 0; - flex-shrink: 0; - margin: 0; - - &:hover { - background-color: #f7f7f7; - } - - .Published { - color: @zesty-green; - } - .Scheduled { - color: @zesty-orange; - } - .Unpublished { - color: @zesty-grey; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/PublishStatusCell/index.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/PublishStatusCell/index.js deleted file mode 100644 index a8a84477b4..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/PublishStatusCell/index.js +++ /dev/null @@ -1 +0,0 @@ -export { PublishStatusCell } from "./PublishStatusCell.js"; diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/SetRow.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/SetRow.js deleted file mode 100644 index a224f3425b..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/SetRow.js +++ /dev/null @@ -1,207 +0,0 @@ -import { useHistory } from "react-router-dom"; -import cx from "classnames"; -import { connect } from "react-redux"; - -import { DateCell } from "./DateCell"; -import { ImageCell } from "./ImageCell"; -import { FileCell } from "./FileCell"; -import { LinkCell } from "./LinkCell"; -import { SortCell } from "./SortCell"; -import { TextCell } from "./TextCell"; -import { WYSIWYGcell } from "./WYSIWYGcell"; -import { ToggleCell } from "./ToggleCell"; -import { ColorCell } from "./ColorCell"; -import { OneToOneCell } from "./OneToOneCell"; -import { OneToManyCell } from "./OneToManyCell"; -import { DropdownCell } from "./DropdownCell"; -import { InternalLinkCell } from "./InternalLinkCell"; -import { PublishStatusCell } from "./PublishStatusCell"; - -import styles from "./SetRow.less"; -export default connect()(function SetRow(props) { - let history = useHistory(); - - const item = props.allItems[props.itemZUID]; - - const selectRow = () => { - if (props.itemZUID.slice(0, 3) === "new") { - history.push(`/content/${props.modelZUID}/new`); - } else { - history.push(`/content/${props.modelZUID}/${props.itemZUID}`); - } - }; - - return ( -
- -
- {props.fields.map((field) => { - switch (field.datatype) { - case "one_to_one": - return ( - - ); - - case "one_to_many": - return ( - { - return props.onChange(props.itemZUID, name, value); - }} - value={props.data[field.name] || ""} - name={field.name} - field={field} - settings={field.settings} - allItems={props.allItems} - allFields={props.allFields} - /> - ); - - case "dropdown": - return ( - - ); - - case "internal_link": - return ( - - ); - - case "wysiwyg_advanced": - case "wysiwyg_basic": - case "article_writer": - case "markdown": - return ( -
- -
- ); - - case "text": - case "textarea": - case "uuid": - return ( -
- -
- ); - - case "files": - return ( - - ); - - case "images": - return ( - - ); - - case "yes_no": - return ( - - props.onChange(props.itemZUID, name, value) - } - /> - ); - - case "color": - return ( - - ); - - case "sort": - return ( - { - return props.onChange(props.itemZUID, name, value); - }} - /> - ); - - case "link": - return ( - - ); - - case "date": - case "datetime": - return ( - - ); - - default: - return ( - - {props.data[field.name]} - - ); - } - })} -
-
- ); -}); diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/SetRow.less b/src/apps/content-editor/src/app/views/ItemList/SetRow/SetRow.less deleted file mode 100644 index 9b22362130..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/SetRow.less +++ /dev/null @@ -1,38 +0,0 @@ -@import "~@zesty-io/core/colors.less"; - -.SetRow { - display: flex; - min-height: 40px; - cursor: pointer; - - .Cells { - color: inherit; - border-bottom: 1px solid rgba(26, 32, 44, 0.12); - display: flex; - flex: 1; - text-decoration: none; - - &:hover { - box-shadow: 0px 2px 2px 0px rgba(26, 32, 44, 0.12), - 0 -2px 2px 0 rgba(26, 32, 44, 0.12); - background-color: #f7f7f7; - } - } - - &.Dirty { - .Cells { - background-color: #fffde2; - } - } -} - -.Cell { - border-left: 1px solid rgba(26, 32, 44, 0.12); - padding: 8px 12px; -} -.DefaultCell { - width: 100px; - display: flex; - align-items: center; - justify-content: center; -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/SortCell/SortCell.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/SortCell/SortCell.js deleted file mode 100644 index e6eab60b3e..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/SortCell/SortCell.js +++ /dev/null @@ -1,19 +0,0 @@ -import { memo } from "react"; -import cx from "classnames"; - -import { FieldTypeSort } from "@zesty-io/material"; - -import styles from "./SortCell.less"; -export const SortCell = memo(function SortCell(props) { - return ( - - - props.onChange(parseInt(evt.target.value), props.name) - } - /> - - ); -}); diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/SortCell/SortCell.less b/src/apps/content-editor/src/app/views/ItemList/SetRow/SortCell/SortCell.less deleted file mode 100644 index 121bd73929..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/SortCell/SortCell.less +++ /dev/null @@ -1,6 +0,0 @@ -.SortCell { - width: 180px; - display: flex; - align-items: center; - justify-content: center; -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/SortCell/index.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/SortCell/index.js deleted file mode 100644 index 6e5401c05d..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/SortCell/index.js +++ /dev/null @@ -1 +0,0 @@ -export { SortCell } from "./SortCell"; diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/TextCell/TextCell.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/TextCell/TextCell.js deleted file mode 100644 index aa6304e205..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/TextCell/TextCell.js +++ /dev/null @@ -1,13 +0,0 @@ -import { memo } from "react"; -import cx from "classnames"; - -import styles from "./TextCell.less"; - -export const TextCell = memo(function TextCell(props) { - return ( - - {props.value && props.value.substr(0, 160)} - {props.value && props.value.length > 200 && "…"} - - ); -}); diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/TextCell/TextCell.less b/src/apps/content-editor/src/app/views/ItemList/SetRow/TextCell/TextCell.less deleted file mode 100644 index 5675959746..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/TextCell/TextCell.less +++ /dev/null @@ -1,7 +0,0 @@ -.TextCell { - width: 400px; - height: 100%; - display: flex; - align-items: center; - word-break: break-all; -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/TextCell/index.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/TextCell/index.js deleted file mode 100644 index 13aab4431d..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/TextCell/index.js +++ /dev/null @@ -1 +0,0 @@ -export { TextCell } from "./TextCell"; diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/ToggleCell/ToggleCell.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/ToggleCell/ToggleCell.js deleted file mode 100644 index 4dd114a964..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/ToggleCell/ToggleCell.js +++ /dev/null @@ -1,64 +0,0 @@ -import { memo } from "react"; -import cx from "classnames"; - -import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; -import ToggleButton from "@mui/material/ToggleButton"; - -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons"; - -import { AppLink } from "@zesty-io/core/AppLink"; - -import styles from "./ToggleCell.less"; -export const ToggleCell = memo(function ToggleCell(props) { - if (props.field.settings && props.field.settings.options) { - let options = Object.values(props.field.settings.options); - - if (Array.isArray(options) && options.length) { - if (options[0].length > 3 || options[1].length > 3) { - return ( - - props.onChange(val, props.name)} - onClick={(evt) => evt.stopPropagation()} - exclusive - > - {options[0]} - {options[1]} - - - ); - } else { - return ( - - props.onChange(val, props.name)} - onClick={(evt) => evt.stopPropagation()} - > - {options?.[0] || "No"} - {options?.[1] || "Yes"} - - - ); - } - } else { - return ( - - - -  Missing toggle options. - - - ); - } - } -}); diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/ToggleCell/ToggleCell.less b/src/apps/content-editor/src/app/views/ItemList/SetRow/ToggleCell/ToggleCell.less deleted file mode 100644 index 7f1d0321b6..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/ToggleCell/ToggleCell.less +++ /dev/null @@ -1,9 +0,0 @@ -@import "~@zesty-io/core/colors.less"; - -.ToggleCell { - align-items: center; - display: flex; - flex-direction: column; - justify-content: center; - width: 140px; -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/ToggleCell/index.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/ToggleCell/index.js deleted file mode 100644 index 36b9f60af3..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/ToggleCell/index.js +++ /dev/null @@ -1 +0,0 @@ -export { ToggleCell } from "./ToggleCell"; diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/WYSIWYGcell/WYSIWYGcell.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/WYSIWYGcell/WYSIWYGcell.js deleted file mode 100644 index d04faaf58c..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/WYSIWYGcell/WYSIWYGcell.js +++ /dev/null @@ -1,16 +0,0 @@ -import { memo } from "react"; -import cx from "classnames"; - -import styles from "./WYSIWYGcell.less"; -export const WYSIWYGcell = memo(function WYSIWYGcell(props) { - const rawData = props.value; - let elementFromData = document.createElement("div"); - elementFromData.innerHTML = rawData; - const strippedData = elementFromData.textContent || elementFromData.innerText; - return ( - - {strippedData.replace(/<[^>]*>/g, "").slice(0, 120) || ""} - {props.value && props.value.length > 200 && "…"} - - ); -}); diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/WYSIWYGcell/WYSIWYGcell.less b/src/apps/content-editor/src/app/views/ItemList/SetRow/WYSIWYGcell/WYSIWYGcell.less deleted file mode 100644 index 3748ca7e57..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/WYSIWYGcell/WYSIWYGcell.less +++ /dev/null @@ -1,11 +0,0 @@ -.WYSIWYGcell { - // cursor: pointer; - width: 400px; - height: 100%; - // min-width: 250px; - // max-width: 500px; - - display: flex; - align-items: center; - // justify-content: center; -} diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/WYSIWYGcell/index.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/WYSIWYGcell/index.js deleted file mode 100644 index 6005238f52..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/WYSIWYGcell/index.js +++ /dev/null @@ -1 +0,0 @@ -export { WYSIWYGcell } from "./WYSIWYGcell"; diff --git a/src/apps/content-editor/src/app/views/ItemList/SetRow/index.js b/src/apps/content-editor/src/app/views/ItemList/SetRow/index.js deleted file mode 100644 index daa111edc0..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/SetRow/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import SetRow from "./SetRow"; -export { SetRow }; diff --git a/src/apps/content-editor/src/app/views/ItemList/StagedChangesContext.tsx b/src/apps/content-editor/src/app/views/ItemList/StagedChangesContext.tsx new file mode 100644 index 0000000000..c59b6b3be8 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/StagedChangesContext.tsx @@ -0,0 +1,38 @@ +import React, { createContext, useContext, useState, useCallback } from "react"; + +const StagedChangesContext = createContext(null); + +export const StagedChangesProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [stagedChanges, setStagedChanges] = useState({}); + + const updateStagedChanges = useCallback((id, field, value) => { + if (id.startsWith("new")) { + return; + } + setStagedChanges((prev: any) => ({ + ...prev, + [id]: { + ...prev[id], + [field]: value, + }, + })); + }, []); + + const clearStagedChanges = useCallback(() => { + setStagedChanges({}); + }, []); + + return ( + + {children} + + ); +}; + +export const useStagedChanges = () => useContext(StagedChangesContext); diff --git a/src/apps/content-editor/src/app/views/ItemList/TableCells/BooleanCell.tsx b/src/apps/content-editor/src/app/views/ItemList/TableCells/BooleanCell.tsx new file mode 100644 index 0000000000..7e716ff7ad --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/TableCells/BooleanCell.tsx @@ -0,0 +1,45 @@ +import { useParams as useRouterParams } from "react-router"; +import { useGetContentModelFieldsQuery } from "../../../../../../../shell/services/instance"; +import { useStagedChanges } from "../StagedChangesContext"; +import { GridRenderCellParams } from "@mui/x-data-grid-pro"; +import { ToggleButtonGroup, ToggleButton } from "@mui/material"; + +export const BooleanCell = ({ params }: { params: GridRenderCellParams }) => { + const { stagedChanges, updateStagedChanges } = useStagedChanges(); + const { modelZUID } = useRouterParams<{ modelZUID: string }>(); + const { data: fields, isFetching: isFieldsFetching } = + useGetContentModelFieldsQuery(modelZUID); + const field = fields?.find((field) => field.name === params.field); + const handleChange = (value: any) => { + updateStagedChanges(params.row.id, params.field, value); + }; + + return ( + { + e.stopPropagation(); + if (value === null) { + return; + } + handleChange(Number(value)); + }} + > + {field?.settings?.options && + Object.entries(field?.settings?.options)?.map(([key, value]) => ( + + {value} + + ))} + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemList/TableCells/DropdownCell.tsx b/src/apps/content-editor/src/app/views/ItemList/TableCells/DropdownCell.tsx new file mode 100644 index 0000000000..8ef160d64f --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/TableCells/DropdownCell.tsx @@ -0,0 +1,94 @@ +import { useParams as useRouterParams } from "react-router"; +import { Button, Menu, MenuItem } from "@mui/material"; +import { useGetContentModelFieldsQuery } from "../../../../../../../shell/services/instance"; +import { GridRenderCellParams } from "@mui/x-data-grid-pro"; +import { useState } from "react"; +import { KeyboardArrowDownRounded } from "@mui/icons-material"; +import { ContentItem } from "../../../../../../../shell/services/types"; +import { useStagedChanges } from "../StagedChangesContext"; + +export const DropDownCell = ({ params }: { params: GridRenderCellParams }) => { + const { stagedChanges, updateStagedChanges } = useStagedChanges(); + const [anchorEl, setAnchorEl] = useState(null); + const { modelZUID } = useRouterParams<{ modelZUID: string }>(); + const { data: fields, isFetching: isFieldsFetching } = + useGetContentModelFieldsQuery(modelZUID); + const field = fields?.find((field) => field.name === params.field); + const handleChange = (value: any) => { + setAnchorEl(null); + updateStagedChanges(params.row.id, params.field, value); + }; + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ + vertical: "bottom", + horizontal: "left", + }} + transformOrigin={{ + vertical: "top", + horizontal: "left", + }} + anchorEl={anchorEl} + open={!!anchorEl} + > + { + handleChange(null); + }} + sx={{ + textWrap: "wrap", + wordBreak: "break-word", + }} + > + Select + + {field?.settings?.options && + Object.entries(field?.settings?.options)?.map(([key, value]) => ( + { + handleChange(key); + }} + sx={{ + textWrap: "wrap", + wordBreak: "break-word", + }} + > + {value} + + ))} + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemList/TableCells/OneToManyCell.tsx b/src/apps/content-editor/src/app/views/ItemList/TableCells/OneToManyCell.tsx new file mode 100644 index 0000000000..f62d288319 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/TableCells/OneToManyCell.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef, useState } from "react"; +import { Box, Chip, Tooltip } from "@mui/material"; +import { useSelector } from "react-redux"; + +import { AppState } from "../../../../../../../shell/store/types"; + +const getNumOfItemsToRender = ( + parentWidth: number, + children: HTMLCollection +) => { + if (!children) return; + + let validIndex = 0; + let calculatedWidth = 32; //Padding + + [...children]?.forEach((chip, index) => { + calculatedWidth = calculatedWidth + chip?.clientWidth + 4; + + if (calculatedWidth <= parentWidth) { + validIndex = index; + } + }); + + return validIndex; +}; + +type OneToManyCellProps = { + items: any[]; +}; +export const OneToManyCell = ({ items }: OneToManyCellProps) => { + const allItems = useSelector((state: AppState) => state.content); + const chipContainerRef = useRef(); + const [lastValidIndex, setLastValidIndex] = useState(allItems?.length - 1); + const parentWidth = chipContainerRef.current?.parentElement?.clientWidth; + const hiddenItems = items?.length - lastValidIndex - 1; + + useEffect(() => { + setLastValidIndex( + getNumOfItemsToRender(parentWidth, chipContainerRef.current?.children) + ); + }, [parentWidth]); + + return ( + <> + + {items?.slice(0, lastValidIndex + 1)?.map((id: string) => { + return ( + + ); + })} + {!!hiddenItems && ( + + + + )} + + {/** Element below is only needed to calculate the actual chip widths */} + + {items?.map((id: string) => { + return ( + + ); + })} + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemList/TableCells/SortCell.tsx b/src/apps/content-editor/src/app/views/ItemList/TableCells/SortCell.tsx new file mode 100644 index 0000000000..05ea0041d1 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/TableCells/SortCell.tsx @@ -0,0 +1,24 @@ +import { GridRenderCellParams } from "@mui/x-data-grid-pro"; +import { useStagedChanges } from "../StagedChangesContext"; +import { FieldTypeSort } from "../../../../../../../shell/components/FieldTypeSort"; + +export const SortCell = ({ params }: { params: GridRenderCellParams }) => { + const { stagedChanges, updateStagedChanges } = useStagedChanges(); + const handleChange = (value: any) => { + updateStagedChanges(params.row.id, params.field, value); + }; + + return ( + { + handleChange(parseInt(evt.target.value)); + }} + height={40} + /> + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemList/TableCells/UserCell.tsx b/src/apps/content-editor/src/app/views/ItemList/TableCells/UserCell.tsx new file mode 100644 index 0000000000..998b53295c --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/TableCells/UserCell.tsx @@ -0,0 +1,31 @@ +import { Stack, Avatar, Typography } from "@mui/material"; +import { GridRenderCellParams } from "@mui/x-data-grid-pro"; + +import { useGetUsersQuery } from "../../../../../../../shell/services/accounts"; +import { MD5 } from "../../../../../../../utility/md5"; + +type UserCellProps = { params: GridRenderCellParams }; +export const UserCell = ({ params }: UserCellProps) => { + const { data: users } = useGetUsersQuery(); + + const user = users.find( + (user) => user.ZUID === params?.row?.meta?.createdByUserZUID + ); + + if (!user) { + return <>; + } + + return ( + + + + {user.firstName} {user.lastName} + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemList/TableCells/VersionCell.tsx b/src/apps/content-editor/src/app/views/ItemList/TableCells/VersionCell.tsx new file mode 100644 index 0000000000..4e17d11b47 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/TableCells/VersionCell.tsx @@ -0,0 +1,163 @@ +import { Chip, Stack, Tooltip } from "@mui/material"; +import { GridRenderCellParams } from "@mui/x-data-grid-pro"; +import { useGetUsersQuery } from "../../../../../../../shell/services/accounts"; + +export const VersionCell = ({ params }: { params: GridRenderCellParams }) => { + const { data: users } = useGetUsersQuery(); + + const createdByUser = users?.find( + (user) => user.ZUID === params.row?.web?.createdByUserZUID + ); + const publishedByUser = + users?.find( + (user) => user.ZUID === params.row?.publishing?.publishedByUserZUID + ) || + users?.find( + (user) => user.ZUID === params.row?.priorPublishing?.publishedByUserZUID + ); + const isScheduledPublish = + new Date( + params.row?.publishing?.publishAt || + params.row?.priorPublishing?.publishAt + )?.getTime() > new Date()?.getTime(); + + return ( + + {params.row?.meta?.version !== params.row?.publishing?.version && ( + + v{params.row?.meta?.version} saved on
+ {new Date(params.row?.meta?.updatedAt).toLocaleDateString( + "en-US", + { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "numeric", + timeZoneName: "short", + } + )}{" "} +
by {createdByUser?.firstName} {createdByUser?.lastName} + + } + slotProps={{ + popper: { + style: { + width: 120, + }, + }, + }} + > + +
+ )} + {(params.row?.publishing?.version || + params.row?.priorPublishing?.version) && ( + + v + {params.row?.publishing?.version || + params.row?.priorPublishing?.version}{" "} + {isScheduledPublish ? "scheduled to publish" : "published"} on{" "} +
+ {new Date( + params.row?.publishing?.publishAt || + params.row?.priorPublishing?.publishAt + ).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "numeric", + timeZoneName: "short", + })}{" "} +
by {publishedByUser?.firstName} {publishedByUser?.lastName} + + } + slotProps={{ + popper: { + style: { + width: isScheduledPublish ? 120 : 150, + }, + }, + }} + > + +
+ )} + + {!params.row?.meta?.version && ( + + + + )} +
+ ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemList/UpdateListActions.tsx b/src/apps/content-editor/src/app/views/ItemList/UpdateListActions.tsx new file mode 100644 index 0000000000..b51bd16763 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/UpdateListActions.tsx @@ -0,0 +1,421 @@ +import { + Box, + ButtonGroup, + Button, + Typography, + Tooltip, + Menu, + MenuItem, + ListItemIcon, +} from "@mui/material"; +import { IconButton } from "@zesty-io/material"; +import { + SaveRounded, + CloseRounded, + CloudUploadRounded, + ArrowDropDownRounded, + CalendarTodayRounded, + DeleteRounded, +} from "@mui/icons-material"; +import { useEffect, useState } from "react"; +import { useParams as useRouterParams } from "react-router"; +import { useParams } from "../../../../../../shell/hooks/useParams"; +import { useStagedChanges } from "./StagedChangesContext"; +import { LoadingButton } from "@mui/lab"; +import { + useCreateItemsPublishingMutation, + useDeleteContentItemsMutation, + useGetContentModelItemsQuery, + useUpdateContentItemsMutation, +} from "../../../../../../shell/services/instance"; +import { useMetaKey } from "../../../../../../shell/hooks/useMetaKey"; +import { ConfirmPublishesModal } from "./ConfirmPublishesDialog"; +import { useSelectedItems } from "./SelectedItemsContext"; +import { SchedulePublishesModal } from "./SchedulePublishesDialog"; +import { ConfirmDeletesDialog } from "./ConfirmDeletesDialog"; +import { notify } from "../../../../../../shell/store/notifications"; +import { useDispatch } from "react-redux"; + +export const UpdateListActions = () => { + const { modelZUID } = useRouterParams<{ modelZUID: string }>(); + const [params, setParams] = useParams(); + const dispatch = useDispatch(); + const [anchorEl, setAnchorEl] = useState(null); + const langCode = params.get("lang"); + const [itemsToPublish, setItemsToPublish] = useState([]); + const [itemsToSchedule, setItemsToSchedule] = useState([]); + const { data: items } = useGetContentModelItemsQuery({ + modelZUID, + params: { + lang: langCode, + }, + }); + const [showPublishesModal, setShowPublishesModal] = useState(false); + const [showScheduleModal, setShowScheduleModal] = useState(false); + const [showDeletesModal, setShowDeletesModal] = useState(false); + const { stagedChanges, updateStagedChanges, clearStagedChanges } = + useStagedChanges(); + const [selectedItems, setSelectedItems] = useSelectedItems(); + + const [updateContentItems, { isLoading: isSaving }] = + useUpdateContentItemsMutation(); + + const [createItemsPublishing, { isLoading: isPublishing }] = + useCreateItemsPublishingMutation(); + + const [deleteContentItems, { isLoading: isDeleting }] = + useDeleteContentItemsMutation(); + + const saveShortcut = useMetaKey("s", () => { + handleSave(); + }); + + const publishShortcut = useMetaKey("p", () => { + if (hasStagedChanges) { + handleSaveAndPublish(); + } else { + setItemsToPublish(selectedItems); + } + }); + + const hasStagedChanges = Object.keys(stagedChanges).length > 0; + + const handleSave = async () => { + try { + await updateContentItems({ + modelZUID, + body: Object.entries(stagedChanges).map(([id, changes]: any) => { + const item = items.find((item) => item.meta.ZUID === id); + return { + ...item, + data: { + ...item.data, + ...changes, + }, + }; + }), + }); + clearStagedChanges({}); + } catch (err) { + dispatch( + notify({ + kind: "error", + message: `Error saving items: ${err?.data?.error}`, + }) + ); + } + }; + + const handleSaveAndPublish = async (isSchedule = false) => { + try { + const response = await updateContentItems({ + modelZUID, + body: Object.entries(stagedChanges).map(([id, changes]: any) => { + const item = items.find((item) => item.meta.ZUID === id); + return { + ...item, + data: { + ...item.data, + ...changes, + }, + }; + }), + }); + + if (isSchedule) { + // @ts-ignore + setItemsToSchedule([...response?.data?.data]); + } else { + // @ts-ignore + setItemsToPublish([...response?.data?.data]); + } + } catch (err) { + dispatch( + notify({ + kind: "error", + message: `Error saving items: ${err?.data?.error}`, + }) + ); + } + }; + + useEffect(() => { + if (itemsToPublish.length) { + setShowPublishesModal(true); + } + }, [itemsToPublish]); + + useEffect(() => { + if (itemsToSchedule.length) { + setShowScheduleModal(true); + } + }, [itemsToSchedule]); + + return ( + <> + + + { + clearStagedChanges({}); + setSelectedItems([]); + }} + > + + + + {hasStagedChanges + ? ` Update ${Object.keys(stagedChanges)?.length} Content Items` + : `${selectedItems?.length} selected`} + + + + {hasStagedChanges ? ( + + Save Items
+ {saveShortcut} + + } + > + } + size="small" + onClick={handleSave} + loading={isSaving} + > + Save + +
+ ) : ( + { + setShowDeletesModal(true); + }} + startIcon={} + size="small" + variant="outlined" + color="inherit" + > + Delete + + )} + + + {hasStagedChanges ? "Save & Publish Items" : "Publish Items"}{" "} +
+ {publishShortcut} + + } + > + } + sx={{ + color: "common.white", + whiteSpace: "nowrap", + }} + onClick={() => { + if (hasStagedChanges) { + handleSaveAndPublish(); + } else { + setItemsToPublish(selectedItems); + } + }} + loading={isPublishing || isSaving} + color="success" + variant="contained" + data-cy="MultiPageTablePublish" + > + {hasStagedChanges ? "Save & Publish" : "Publish"} + +
+ +
+ setAnchorEl(null)} + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + transformOrigin={{ + vertical: -8, + horizontal: "right", + }} + anchorEl={anchorEl} + open={!!anchorEl} + > + { + if (hasStagedChanges) { + handleSaveAndPublish(); + } else { + setItemsToPublish(selectedItems); + } + setAnchorEl(null); + }} + > + + + + {hasStagedChanges ? "Save & Publish Now" : "Publish Now"} + + { + if (hasStagedChanges) { + handleSaveAndPublish(true); + } else { + setItemsToSchedule(selectedItems); + } + setAnchorEl(null); + }} + > + + + + {hasStagedChanges + ? "Save & Schedule Publish" + : "Schedule Publish"} + + +
+
+ {showPublishesModal && ( + + items?.find((item) => item.meta.ZUID === itemZUID) + )} + onCancel={() => { + setItemsToPublish([]); + clearStagedChanges({}); + setShowPublishesModal(false); + }} + onConfirm={(items) => { + createItemsPublishing({ + modelZUID, + body: items?.map((item) => { + return { + ZUID: item.meta.ZUID, + version: item.meta.version, + publishAt: "now", + unpublishAt: "never", + }; + }), + }) + .unwrap() + .then(() => { + setItemsToPublish([]); + clearStagedChanges({}); + setSelectedItems([]); + setShowPublishesModal(false); + }) + .catch((res) => { + dispatch( + notify({ + kind: "error", + message: `Error publishing items: ${res?.data?.error}`, + }) + ); + }); + }} + /> + )} + {showScheduleModal && ( + + items?.find((item) => item.meta.ZUID === itemZUID) + )} + onCancel={() => { + setItemsToSchedule([]); + clearStagedChanges({}); + setShowScheduleModal(false); + }} + onConfirm={(items, publishDateTime) => { + createItemsPublishing({ + modelZUID, + body: items?.map((item) => { + return { + ZUID: item.meta.ZUID, + version: item.meta.version, + publishAt: publishDateTime ? publishDateTime : "now", + unpublishAt: "never", + }; + }), + }) + .unwrap() + .then((response) => { + setItemsToSchedule([]); + clearStagedChanges({}); + setSelectedItems([]); + setShowScheduleModal(false); + }) + .catch((res) => { + dispatch( + notify({ + kind: "error", + message: `Error publishing items: ${res?.data?.error}`, + }) + ); + }); + }} + /> + )} + {showDeletesModal && ( + + items?.find((item) => item.meta.ZUID === itemZUID) + )} + onCancel={() => { + setShowDeletesModal(false); + }} + onConfirm={(items) => { + deleteContentItems({ + modelZUID, + body: items?.map((item) => item.meta.ZUID), + }).then(() => { + setSelectedItems([]); + setShowDeletesModal(false); + }); + }} + /> + )} + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemList/findUtils.js b/src/apps/content-editor/src/app/views/ItemList/findUtils.js deleted file mode 100644 index f911b097c6..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/findUtils.js +++ /dev/null @@ -1,35 +0,0 @@ -export const findFields = (fields, modelZUID) => { - return Object.keys(fields) - .filter((key) => { - return ( - !fields[key].deletedAt && - fields[key].settings && - fields[key].settings.list == 1 && - fields[key].contentModelZUID === modelZUID - ); - }) - .map((key) => fields[key]) - .sort((a, b) => a.sort - b.sort); -}; - -export const findItems = (items, modelZUID, langID = 1) => { - return Object.keys(items) - .filter((key) => { - return ( - items[key] && - items[key].meta && - items[key].meta.contentModelZUID === modelZUID && - items[key].meta.langID === langID - ); - }) - .map((key) => items[key]) - .sort((a, b) => { - if (a.meta.createdAt < b.meta.createdAt) { - return 1; - } - if (a.meta.createdAt > b.meta.createdAt) { - return -1; - } - return 0; - }); -}; diff --git a/src/apps/content-editor/src/app/views/ItemList/index.js b/src/apps/content-editor/src/app/views/ItemList/index.js deleted file mode 100644 index 8796ee31dc..0000000000 --- a/src/apps/content-editor/src/app/views/ItemList/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import ItemList from "./ItemList"; -export { ItemList }; diff --git a/src/apps/content-editor/src/app/views/ItemList/index.tsx b/src/apps/content-editor/src/app/views/ItemList/index.tsx new file mode 100644 index 0000000000..3ed838fef2 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/index.tsx @@ -0,0 +1,499 @@ +import { useParams as useRouterParams } from "react-router"; +import { ContentBreadcrumbs } from "../../components/ContentBreadcrumbs"; +import { Box, Button, ThemeProvider, Typography } from "@mui/material"; +import { + useGetAllPublishingsQuery, + useGetContentModelFieldsQuery, + useGetContentModelItemsQuery, + useGetContentModelQuery, +} from "../../../../../../shell/services/instance"; +import { theme } from "@zesty-io/material"; +import { ItemListEmpty } from "./ItemListEmpty"; +import { ItemListActions } from "./ItemListActions"; +import { useMemo, useRef } from "react"; +import { SearchRounded, RestartAltRounded } from "@mui/icons-material"; +import noSearchResults from "../../../../../../../public/images/noSearchResults.svg"; +import { ItemListFilters } from "./ItemListFilters"; +import { useParams } from "../../../../../../shell/hooks/useParams"; +import { + useGetAllBinFilesQuery, + useGetBinsQuery, +} from "../../../../../../shell/services/mediaManager"; +import { useSelector } from "react-redux"; +import { AppState } from "../../../../../../shell/store/types"; +import { cloneDeep } from "lodash"; +import { useStagedChanges } from "./StagedChangesContext"; +import { UpdateListActions } from "./UpdateListActions"; +import { getDateFilterFnByValues } from "../../../../../../shell/components/Filters/DateFilter/getDateFilter"; +import { ItemListTable } from "./ItemListTable"; +import { useSelectedItems } from "./SelectedItemsContext"; +import { useGetUsersQuery } from "../../../../../../shell/services/accounts"; + +const formatDateTime = (source: string) => { + if (!source || isNaN(new Date(source).getTime())) return ""; + + const date = new Date(source)?.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + const time = new Date(source)?.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "numeric", + }); + + return `${date} ${time}`; +}; + +const now = new Date(); + +export const ItemList = () => { + const { modelZUID } = useRouterParams<{ modelZUID: string }>(); + const [params, setParams] = useParams(); + const langCode = params.get("lang"); + const draftItems = useSelector((state: AppState) => + Object.keys(state.content) + .filter( + (item) => + state.content[item].meta?.contentModelZUID === modelZUID && + state.content[item].meta.ZUID.slice(0, 3) === "new" + ) + .map((item) => state.content[item]) + ); + const { data: model, isFetching: isModelFetching } = + useGetContentModelQuery(modelZUID); + const { data: items, isFetching: isModelItemsFetching } = + useGetContentModelItemsQuery({ + modelZUID, + params: { + lang: langCode, + }, + }); + const { data: fields, isFetching: isFieldsFetching } = + useGetContentModelFieldsQuery(modelZUID); + const { data: publishings, isFetching: isPublishingsFetching } = + useGetAllPublishingsQuery(); + const allItems = useSelector((state: AppState) => state.content); + const instanceId = useSelector((state: AppState) => state.instance.ID); + const ecoId = useSelector((state: AppState) => state.instance.ecoID); + const { data: bins } = useGetBinsQuery({ + instanceId, + ecoId, + }); + const { data: files, isFetching: isFilesFetching } = useGetAllBinFilesQuery( + bins?.map((bin) => bin.id), + { skip: !bins?.length } + ); + const { data: users } = useGetUsersQuery(); + + const { stagedChanges } = useStagedChanges(); + const [selectedItems] = useSelectedItems(); + const searchRef = useRef(null); + const search = params.get("search"); + const sort = params.get("sort"); + const statusFilter = params.get("statusFilter"); + const dateFilter = useMemo(() => { + return { + preset: params.get("datePreset") || "", + from: params.get("from") || "", + to: params.get("to") || "", + }; + }, [params]); + const userFilter = params.get("user"); + + const processedItems = useMemo(() => { + if (!items) return []; + let clonedItems = [...draftItems, ...items].map((item: any) => { + const clonedItem = cloneDeep(item); + clonedItem.id = item.meta.ZUID; + clonedItem.publishing = publishings?.find( + (publishing) => + publishing.itemZUID === item.meta.ZUID && + publishing.version === item.meta.version + ); + if (clonedItem.publishing) { + clonedItem.publishing = { + ...clonedItem.publishing, + publishAt: clonedItem?.publishing?.publishAt + ? formatDateTime(clonedItem?.publishing?.publishAt) + : null, + }; + } + clonedItem.priorPublishing = publishings?.find( + (publishing) => + publishing.itemZUID === item.meta.ZUID && + publishing.version !== item.meta.version + ); + if (clonedItem.priorPublishing) { + clonedItem.priorPublishing = { + ...clonedItem.priorPublishing, + publishAt: clonedItem?.priorPublishing?.publishAt + ? formatDateTime(clonedItem.priorPublishing.publishAt) + : null, + }; + } + clonedItem.meta.createdAt = clonedItem?.meta?.createdAt + ? formatDateTime(clonedItem.meta.createdAt) + : null; + clonedItem.web.updatedAt = clonedItem?.web?.updatedAt + ? formatDateTime(clonedItem.web.updatedAt) + : null; + const creatorData = users.find( + (user) => user.ZUID === clonedItem?.meta?.createdByUserZUID + ); + clonedItem.meta.createdByUserName = creatorData + ? `${creatorData?.firstName} ${creatorData?.lastName}` + : null; + + Object.keys(clonedItem.data).forEach((key) => { + const fieldType = fields?.find((field) => field.name === key)?.datatype; + // @ts-ignore + if ( + fieldType === "images" && + clonedItem.data[key]?.split(",")?.[0]?.startsWith("3-") + ) { + clonedItem.data[key] = files?.find( + (file) => file.id === clonedItem.data[key]?.split(",")?.[0] + ); + } + + if (fieldType === "internal_link" || fieldType === "one_to_one") { + clonedItem.data[key] = + allItems?.[clonedItem.data[key]]?.web?.metaTitle || + clonedItem.data[key]; + } + + if (fieldType === "one_to_many") { + clonedItem.data[key] = clonedItem.data[key] + ?.split(",") + .map((id: string) => allItems?.[id]?.web?.metaTitle || id) + ?.join(","); + } + + if (fieldType === "date") { + if (!clonedItem.data[key]) return; + + clonedItem.data[key] = new Date( + clonedItem.data[key] + )?.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + } + + if (fieldType === "datetime") { + if (!clonedItem.data[key]) return; + + clonedItem.data[key] = formatDateTime(clonedItem.data[key]); + } + }); + + return clonedItem; + }); + return clonedItems; + }, [items, publishings, files, allItems, fields, users]); + + const sortedAndFilteredItems = useMemo(() => { + let clonedItems = [...processedItems]; + clonedItems?.sort((a: any, b: any) => { + if (sort === "datePublished") { + // Handle undefined publishAt by setting a default far-future date for sorting purposes + + let dateA = a?.publishing?.publishAt || a?.priorPublishing?.publishAt; + dateA = dateA ? new Date(dateA).getTime() : Number.NEGATIVE_INFINITY; + + let dateB = b?.publishing?.publishAt || b?.priorPublishing?.publishAt; + dateB = dateB ? new Date(dateB).getTime() : Number.NEGATIVE_INFINITY; + + return dateB - dateA; + } else if (sort === "dateCreated") { + return ( + new Date(b.meta.createdAt).getTime() - + new Date(a.meta.createdAt).getTime() + ); + } else if (sort === "status") { + const aPublishing = a?.publishing || a?.priorPublishing; + const bPublishing = b?.publishing || b?.priorPublishing; + + const aDate = aPublishing?.publishAt + ? new Date(aPublishing.publishAt) + : null; + const bDate = bPublishing?.publishAt + ? new Date(bPublishing.publishAt) + : null; + + // Determine the status of each item + const aIsPublished = aDate && aDate <= now; + const bIsPublished = bDate && bDate <= now; + + const aIsScheduled = aDate && aDate > now; + const bIsScheduled = bDate && bDate > now; + + // Check if meta.version exists + const aHasVersion = a?.meta?.version != null; + const bHasVersion = b?.meta?.version != null; + + // Place items without meta.version at the bottom + if (!aHasVersion && bHasVersion) { + return 1; + } else if (aHasVersion && !bHasVersion) { + return -1; + } + + // Continue with the original sorting logic + if (aIsPublished && !bIsPublished) { + return -1; // A is published, B is not + } else if (!aIsPublished && bIsPublished) { + return 1; // B is published, A is not + } else if (aIsPublished && bIsPublished) { + return bDate.getTime() - aDate.getTime(); // Both are published, sort by publish date descending + } + + if (aIsScheduled && !bIsScheduled) { + return -1; // A is scheduled, B is not + } else if (!aIsScheduled && bIsScheduled) { + return 1; // B is scheduled, A is not + } else if (aIsScheduled && bIsScheduled) { + return aDate.getTime() - bDate.getTime(); // Both are scheduled, sort by publish date ascending + } + + return 0; // Neither is published nor scheduled, consider them equal + } else { + return ( + new Date(b.meta.updatedAt).getTime() - + new Date(a.meta.updatedAt).getTime() + ); + } + }); + if (search) { + clonedItems = clonedItems?.filter((item) => { + return ( + Object.values(item.data).some((value: any) => { + if (!value) return false; + if (value?.filename || value?.title) { + return ( + value?.filename + ?.toLowerCase() + ?.includes(search.toLowerCase()) || + value?.title?.toLowerCase()?.includes(search.toLowerCase()) + ); + } + return value + .toString() + .toLowerCase() + .includes(search.toLowerCase()); + }) || + item?.meta?.createdAt?.toLowerCase().includes(search.toLowerCase()) || + item?.web?.updatedAt?.toLowerCase().includes(search.toLowerCase()) || + item?.publishing?.publishAt + ?.toLowerCase() + .includes(search.toLowerCase()) || + item?.priorPublishing?.publishAt + ?.toLowerCase() + .includes(search.toLowerCase()) || + item?.meta?.ZUID?.toLowerCase().includes(search.toLowerCase()) || + item?.meta?.createdByUserName + ?.toLowerCase() + .includes(search.toLowerCase()) + ); + }); + } + if (statusFilter) { + clonedItems = clonedItems?.filter((item) => { + if (statusFilter === "published") { + return ( + (item.publishing?.publishAt && + new Date(item.publishing.publishAt) < new Date()) || + item?.priorPublishing?.publishAt + ); + } else if (statusFilter === "scheduled") { + return ( + item.publishing?.publishAt && + new Date(item.publishing.publishAt) > new Date() + ); + } else if (statusFilter === "notPublished") { + return ( + !item.publishing?.publishAt && !item?.priorPublishing?.publishAt + ); + } + }); + } + + const dateFilterFn = getDateFilterFnByValues(dateFilter); + + if (dateFilterFn) { + clonedItems = clonedItems.filter((item) => { + if (item?.meta?.updatedAt) { + return dateFilterFn(item?.meta?.updatedAt); + } + + return false; + }); + } + + if (userFilter) { + clonedItems = clonedItems.filter( + (item) => item?.web?.createdByUserZUID === userFilter + ); + } + + // filter items by all fields + return clonedItems; + }, [processedItems, search, sort, statusFilter, dateFilter, userFilter]); + + return ( + + + + {(stagedChanges && Object.keys(stagedChanges)?.length) || + selectedItems?.length ? ( + + ) : ( + <> + + + + {model?.label} + + + + + )} + + + {!items?.length && !isModelItemsFetching ? ( + + ) : ( + <> + + + {!sortedAndFilteredItems?.length && + search && + !isModelItemsFetching && ( + + + No search results + + Your filter "{search}" could not find any results + + + Try adjusting your search. We suggest check all words + are spelled correctly or try using different keywords. + + + + + )} + {!sortedAndFilteredItems?.length && + !isModelItemsFetching && + !search && ( + + + No search results + + No results that matched your filters could be found + + + Try adjusting your filters to find what you're looking + for + + + + + )} + + )} + + + + ); +}; diff --git a/src/apps/schema/src/app/components/AddFieldModal/index.tsx b/src/apps/schema/src/app/components/AddFieldModal/index.tsx index 5b22a7594d..16f2ce5cac 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/index.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/index.tsx @@ -6,6 +6,7 @@ import { theme } from "@zesty-io/material"; import { FieldSelection } from "./views/FieldSelection"; import { FieldForm } from "./views/FieldForm"; import { useGetContentModelFieldsQuery } from "../../../../../../shell/services/instance"; +import { ContentModelFieldDataType } from "../../../../../../shell/services/types"; type Params = { id: string; @@ -78,7 +79,7 @@ export const AddFieldModal = ({ onModalClose, mode, sortIndex }: Props) => { {viewMode === "new_field" && ( setViewMode("fields_list")} diff --git a/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx b/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx index 0707249ebb..d9c11d8e85 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx @@ -48,6 +48,7 @@ import { FieldSettings, ContentModelFieldValue, FieldSettingsOptions, + ContentModelFieldDataType, } from "../../../../../../../shell/services/types"; import { FIELD_COPY_CONFIG, TYPE_TEXT, FORM_CONFIG } from "../../configs"; import { ComingSoon } from "../ComingSoon"; @@ -67,7 +68,7 @@ interface Errors { [key: string]: string | [string, string][]; } interface Props { - type: string; + type: ContentModelFieldDataType; name: string; onModalClose: () => void; onBackClick?: () => void; diff --git a/src/apps/schema/src/app/components/ModelsTable.tsx b/src/apps/schema/src/app/components/ModelsTable.tsx index 4a25e1acb7..7f7adb91c1 100644 --- a/src/apps/schema/src/app/components/ModelsTable.tsx +++ b/src/apps/schema/src/app/components/ModelsTable.tsx @@ -33,7 +33,9 @@ const FieldsCell = ({ ZUID }: any) => { }; const ContentItemsCell = ({ ZUID }: any) => { - const { data, isLoading } = useGetContentModelItemsQuery(ZUID); + const { data, isLoading } = useGetContentModelItemsQuery({ + modelZUID: ZUID, + }); if (isLoading) { return ; } diff --git a/src/shell/components/FieldTypeSort/index.tsx b/src/shell/components/FieldTypeSort/index.tsx index 830541fc96..7043f09842 100644 --- a/src/shell/components/FieldTypeSort/index.tsx +++ b/src/shell/components/FieldTypeSort/index.tsx @@ -7,12 +7,14 @@ import RemoveRoundedIcon from "@mui/icons-material/RemoveRounded"; export interface FieldTypeSortProps extends Omit { value: string; + height?: number; } export const FieldTypeSort = ({ value, InputProps, onChange, + height, ...props }: FieldTypeSortProps) => { const modifyValue = (action: "increment" | "decrement") => { @@ -82,6 +84,7 @@ export const FieldTypeSort = ({ width: "fit-content", "& .MuiInputBase-input.MuiOutlinedInput-input": { + height: height ?? "auto", width: value?.length + "ch", }, }} diff --git a/src/shell/components/Filters/DateFilter/DateRangeFilterModal.tsx b/src/shell/components/Filters/DateFilter/DateRangeFilterModal.tsx index 470d5f6808..1c7c0e27ae 100644 --- a/src/shell/components/Filters/DateFilter/DateRangeFilterModal.tsx +++ b/src/shell/components/Filters/DateFilter/DateRangeFilterModal.tsx @@ -81,6 +81,7 @@ export const DateRangeFilterModal: FC = ({ { setSelectedDateRange(newValue); diff --git a/src/shell/components/Filters/DateFilter/getDateFilter.ts b/src/shell/components/Filters/DateFilter/getDateFilter.ts index c8cf60bbe6..7d77ead91a 100644 --- a/src/shell/components/Filters/DateFilter/getDateFilter.ts +++ b/src/shell/components/Filters/DateFilter/getDateFilter.ts @@ -2,6 +2,51 @@ import moment from "moment-timezone"; import { DateFilterValue, DateRangeFilterValue, PresetType } from "./types"; +export const getDateFilterFnByValues = ({ + preset, + from, + to, +}: { + preset: string; + from: string; + to: string; +}) => { + const isPreset = !!preset; + const isBefore = !!to && !!!from; + const isAfter = !!from && !!!to; + const isOn = !!to && !!from && to === from; + const isRange = !!to && !!from && to !== from; + let dateFilterFn: (date: string) => boolean; + + if (isPreset) { + dateFilterFn = getDateFilterFn({ + type: "preset", + value: preset, + }); + } + + if (isBefore) { + dateFilterFn = getDateFilterFn({ type: "before", value: to }); + } + + if (isAfter) { + dateFilterFn = getDateFilterFn({ type: "after", value: from }); + } + + if (isOn) { + dateFilterFn = getDateFilterFn({ type: "on", value: from }); + } + + if (isRange) { + dateFilterFn = getDateFilterFn({ + type: "daterange", + value: { from: from, to: to }, + }); + } + + return dateFilterFn; +}; + export const getDateFilterFn = ({ type, value }: DateFilterValue) => { switch (type) { case "preset": @@ -17,6 +62,10 @@ export const getDateFilterFn = ({ type, value }: DateFilterValue) => { return (date: string) => moment(date).isSameOrAfter(moment().subtract(7, "days"), "day"); + case "last_14_days": + return (date: string) => + moment(date).isSameOrAfter(moment().subtract(14, "days"), "day"); + case "last_30_days": return (date: string) => moment(date).isSameOrAfter(moment().subtract(30, "days"), "day"); diff --git a/src/shell/components/Filters/FilterButton.tsx b/src/shell/components/Filters/FilterButton.tsx index ddc37a1ea6..c5d7f6795f 100644 --- a/src/shell/components/Filters/FilterButton.tsx +++ b/src/shell/components/Filters/FilterButton.tsx @@ -34,7 +34,11 @@ export const FilterButton: FC = ({ > {buttonText} - diff --git a/src/shell/components/Filters/UserFilter.tsx b/src/shell/components/Filters/UserFilter.tsx index 721db3037b..60f3c4532d 100644 --- a/src/shell/components/Filters/UserFilter.tsx +++ b/src/shell/components/Filters/UserFilter.tsx @@ -1,4 +1,4 @@ -import { FC, useState, useMemo } from "react"; +import { FC, useState, useMemo, useRef } from "react"; import { Menu, MenuItem, @@ -10,6 +10,8 @@ import { IconButton, ListSubheader, ListItem, + Box, + MenuList, } from "@mui/material"; import SearchIcon from "@mui/icons-material/Search"; import CloseRoundedIcon from "@mui/icons-material/CloseRounded"; @@ -32,6 +34,7 @@ export const UserFilter: FC = ({ defaultButtonText = "Created By", options, }) => { + const userListRef = useRef(null); const [filter, setFilter] = useState(""); const [menuAnchorEl, setMenuAnchorEl] = useState( null @@ -101,15 +104,31 @@ export const UserFilter: FC = ({ maxHeight: 420, width: 320, mt: 1, + overflow: "hidden", }, }} MenuListProps={{ sx: { - pt: 0, - pb: 1, + py: 0, }, }} autoFocus={false} + onKeyDown={(evt) => { + evt.preventDefault(); + evt.stopPropagation(); + + if (evt.key === "ArrowDown") { + userListRef.current?.querySelector("li")?.focus(); + return; + } + + if (evt.key === "ArrowUp") { + userListRef.current + ?.querySelector("li:last-of-type") + ?.focus(); + return; + } + }} > { @@ -151,35 +170,54 @@ export const UserFilter: FC = ({ {!filteredUsers?.length && Boolean(filter) && ( - + No users found )} - {filteredUsers?.map((user) => { - return ( - handleFilterSelect(user?.ZUID)} - selected={value ? value === user?.ZUID : false} - sx={{ - height: "52px", - }} - data-cy={`filter_value_${user?.ZUID}`} - > - - - - - {user?.firstName} {user?.lastName} - - - ); - })} + {!!filteredUsers?.length && ( + { + evt.preventDefault(); + evt.stopPropagation(); + + if (evt.key === "Escape") { + setMenuAnchorEl(null); + } + }} + > + {filteredUsers.map((user) => { + return ( + handleFilterSelect(user?.ZUID)} + selected={value ? value === user?.ZUID : false} + sx={{ + height: "52px", + }} + data-cy={`filter_value_${user?.ZUID}`} + > + + + + + {user?.firstName} {user?.lastName} + + + ); + })} + + )} ); diff --git a/src/shell/hooks/useDateFilterParams.ts b/src/shell/hooks/useDateFilterParams.ts new file mode 100644 index 0000000000..89c09b7419 --- /dev/null +++ b/src/shell/hooks/useDateFilterParams.ts @@ -0,0 +1,124 @@ +import { useParams } from "./useParams"; +import { DateFilterValue } from "../components/Filters/DateFilter"; +import { useMemo } from "react"; +import { DateRangeFilterValue } from "../components/Filters/DateFilter/types"; + +type UseDateFilterParams = [ + DateFilterValue, + (dateFilter: DateFilterValue) => void +]; +export const useDateFilterParams: () => UseDateFilterParams = () => { + const [params, setParams] = useParams(); + + const setDateFilter = (dateFilter: DateFilterValue) => { + switch (dateFilter.type) { + case "daterange": { + const value = dateFilter.value as DateRangeFilterValue; + + setParams(value.to, "to"); + setParams(value.from, "from"); + setParams(null, "datePreset"); + return; + } + + case "on": { + const value = dateFilter.value as string; + + setParams(value, "to"); + setParams(value, "from"); + setParams(null, "datePreset"); + return; + } + case "before": { + const value = dateFilter.value as string; + + setParams(value, "to"); + setParams(null, "from"); + setParams(null, "datePreset"); + return; + } + case "after": { + const value = dateFilter.value as string; + + setParams(value, "from"); + setParams(null, "to"); + setParams(null, "datePreset"); + return; + } + case "preset": { + const value = dateFilter.value as string; + + setParams(value, "datePreset"); + setParams(null, "to"); + setParams(null, "from"); + return; + } + + default: { + setParams(null, "to"); + setParams(null, "from"); + setParams(null, "datePreset"); + return; + } + } + }; + + const activeDateFilter: DateFilterValue = useMemo(() => { + const isPreset = !!params.get("datePreset"); + const isBefore = !!params.get("to") && !!!params.get("from"); + const isAfter = !!params.get("from") && !!!params.get("to"); + const isOn = + !!params.get("to") && + !!params.get("from") && + params.get("to") === params.get("from"); + const isDateRange = + !!params.get("to") && + !!params.get("from") && + params.get("to") !== params.get("from"); + + if (isPreset) { + return { + type: "preset", + value: params.get("datePreset"), + }; + } + + if (isBefore) { + return { + type: "before", + value: params.get("to"), + }; + } + + if (isAfter) { + return { + type: "after", + value: params.get("from"), + }; + } + + if (isOn) { + return { + type: "on", + value: params.get("from"), + }; + } + + if (isDateRange) { + return { + type: "daterange", + value: { + from: params.get("from"), + to: params.get("to"), + }, + }; + } + + return { + type: "", + value: "", + }; + }, [params]); + + return [activeDateFilter, setDateFilter]; +}; diff --git a/src/shell/hooks/useParams.ts b/src/shell/hooks/useParams.ts index ba61c92884..bc5d889d07 100644 --- a/src/shell/hooks/useParams.ts +++ b/src/shell/hooks/useParams.ts @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { useLocation, useHistory } from "react-router-dom"; /* This hook serves to easily retrieve and add url parameters to the url. @@ -14,18 +14,21 @@ export const useParams: () => UseParams = () => { [location.search] ); - const setParams = (val: string, name: string) => { - if (val) { - params.set(name, val); - } else { - params.delete(name); - } + const setParams = useCallback( + (val: string | null, name: string) => { + if (val) { + params.set(name, val); + } else { + params.delete(name); + } - history.replace({ - pathname: location.pathname, - search: params.toString(), - }); - }; + history.replace({ + pathname: location.pathname, + search: params.toString(), + }); + }, + [history, location.pathname, params] + ); return [params, setParams]; }; diff --git a/src/shell/services/instance.ts b/src/shell/services/instance.ts index 134c807395..feec33574b 100644 --- a/src/shell/services/instance.ts +++ b/src/shell/services/instance.ts @@ -49,6 +49,8 @@ export const instanceApi = createApi({ "ContentItem", "ItemVersions", "HeadTags", + "ContentItems", + "ItemPublishings", ], endpoints: (builder) => ({ // https://www.zesty.io/docs/instances/api-reference/content/models/items/publishings/#Get-All-Item-Publishings @@ -77,6 +79,7 @@ export const instanceApi = createApi({ transformResponse: getResponseData, // TODO: Remove once all item publishing mutations are using rtk query keepUnusedDataFor: 0, + providesTags: ["ItemPublishings"], }), // https://www.zesty.io/docs/instances/api-reference/content/models/items/publishings/#Create-Item-Publishing createItemPublishing: builder.mutation< @@ -100,6 +103,14 @@ export const instanceApi = createApi({ { type: "ItemPublishing", id: id.itemZUID }, ], }), + createItemsPublishing: builder.mutation({ + query: ({ modelZUID, body }) => ({ + url: `content/models/${modelZUID}/items/publishings/batch`, + method: "POST", + body, + }), + invalidatesTags: ["ItemPublishings"], + }), // https://www.zesty.io/docs/instances/api-reference/content/models/items/publishings/#Delete-Item-Publishing deleteItemPublishing: builder.mutation< any, @@ -175,16 +186,24 @@ export const instanceApi = createApi({ }, }), // https://www.zesty.io/docs/instances/api-reference/content/models/items/#Get-All-Items - getContentModelItems: builder.query({ - query: (ZUID) => ({ - url: `content/models/${ZUID}/items`, + getContentModelItems: builder.query< + ContentItem[], + { + modelZUID: string; + params?: Record; + } + >({ + query: ({ modelZUID, params = {} }) => ({ + url: `content/models/${modelZUID}/items`, params: { limit: 5000, + ...params, }, }), transformResponse: getResponseData, // Restore cache when content/schema uses rtk query for mutations and can invalidate this keepUnusedDataFor: 0.0001, + providesTags: ["ContentItems"], }), // https://www.zesty.io/docs/instances/api-reference/search/#Search searchContent: builder.query({ @@ -540,6 +559,17 @@ export const instanceApi = createApi({ { type: "ContentItem", id: arg.itemZUID }, ], }), + // https://www.zesty.io/docs/instances/api-reference/content/models/items/#Update-Item + updateContentItems: builder.mutation( + { + query: ({ modelZUID, body }) => ({ + url: `content/models/${modelZUID}/items/batch`, + method: "PUT", + body, + }), + invalidatesTags: ["ContentItems"], + } + ), // https://www.zesty.io/docs/instances/api-reference/content/models/items/#Delete-Item deleteContentItem: builder.mutation< any, @@ -554,6 +584,20 @@ export const instanceApi = createApi({ }), invalidatesTags: ["ContentNav"], }), + deleteContentItems: builder.mutation< + any, + { + modelZUID: string; + body: string[]; + } + >({ + query: ({ modelZUID, body }) => ({ + url: `content/models/${modelZUID}/items/batch`, + method: "DELETE", + body, + }), + invalidatesTags: ["ContentItems"], + }), // https://www.zesty.io/docs/instances/api-reference/web/stylesheets/variables/categories/#Get-Variable-Stylesheet-Categories getInstanceStylesCategories: builder.query({ query: () => `/web/stylesheets/variables/categories`, @@ -605,4 +649,7 @@ export const { useCreateHeadTagMutation, useDeleteHeadTagMutation, useGetInstanceStylesCategoriesQuery, + useUpdateContentItemsMutation, + useCreateItemsPublishingMutation, + useDeleteContentItemsMutation, } = instanceApi; diff --git a/src/shell/services/types.ts b/src/shell/services/types.ts index 856fab0a0f..97a86a89c2 100644 --- a/src/shell/services/types.ts +++ b/src/shell/services/types.ts @@ -204,13 +204,35 @@ export type ContentModelFieldValue = | FieldSettings | FieldSettingsOptions[]; +export type ContentModelFieldDataType = + | "text" + | "wysiwyg_basic" + | "wysiwyg_advanced" + | "article_writer" + | "markdown" + | "textarea" + | "one_to_many" + | "one_to_one" + | "uuid" + | "number" + | "currency" + | "date" + | "datetime" + | "images" + | "yes_no" + | "dropdown" + | "link" + | "internal_link" + | "yes_no" + | "color"; + export interface ContentModelField { ZUID: string; contentModelZUID: string; name: string; label: string; description: string; - datatype: string; + datatype: ContentModelFieldDataType; sort: number; required?: boolean; relationship?: any; diff --git a/src/shell/views/SearchPage/Filters/index.tsx b/src/shell/views/SearchPage/Filters/index.tsx index 5615dfeb77..65ebeca6a9 100644 --- a/src/shell/views/SearchPage/Filters/index.tsx +++ b/src/shell/views/SearchPage/Filters/index.tsx @@ -3,19 +3,16 @@ import { Stack } from "@mui/material"; import { useParams } from "../../../hooks/useParams"; import { SortByFilter, FilterValues } from "./SortByFilter"; -import { - UserFilter, - DateFilter, - DateRangeFilterValue, -} from "../../../components/Filters/"; +import { UserFilter, DateFilter } from "../../../components/Filters/"; import { useGetUsersQuery } from "../../../services/accounts"; -import { DateFilterValue } from "../../../components/Filters/DateFilter"; import { ResourceTypeFilter } from "./ResourceTypeFilter"; import { ResourceType } from "../../../services/types"; import { LanguageFilter } from "./LanguageFilter"; +import { useDateFilterParams } from "../../../hooks/useDateFilterParams"; export const Filters = () => { const [params, setParams] = useParams(); + const [activeDateFilter, setActiveDateFilter] = useDateFilterParams(); const { data: users } = useGetUsersQuery(); const userOptions = useMemo(() => { return users?.map((user) => ({ @@ -26,116 +23,6 @@ export const Filters = () => { })); }, [users]); - const handleDateFilterChanged = (dateFilter: DateFilterValue) => { - switch (dateFilter.type) { - case "daterange": { - const value = dateFilter.value as DateRangeFilterValue; - - setParams(value.to, "to"); - setParams(value.from, "from"); - setParams(null, "datePreset"); - return; - } - - case "on": { - const value = dateFilter.value as string; - - setParams(value, "to"); - setParams(value, "from"); - setParams(null, "datePreset"); - return; - } - case "before": { - const value = dateFilter.value as string; - - setParams(value, "to"); - setParams(null, "from"); - setParams(null, "datePreset"); - return; - } - case "after": { - const value = dateFilter.value as string; - - setParams(value, "from"); - setParams(null, "to"); - setParams(null, "datePreset"); - return; - } - case "preset": { - const value = dateFilter.value as string; - - setParams(value, "datePreset"); - setParams(null, "to"); - setParams(null, "from"); - return; - } - - default: { - setParams(null, "to"); - setParams(null, "from"); - setParams(null, "datePreset"); - return; - } - } - }; - - const activeDateFilter: DateFilterValue = useMemo(() => { - const isPreset = Boolean(params.get("datePreset")); - const isBefore = Boolean(params.get("to")) && !Boolean(params.get("from")); - const isAfter = Boolean(params.get("from")) && !Boolean(params.get("to")); - const isOn = - Boolean(params.get("to")) && - Boolean(params.get("from")) && - params.get("to") === params.get("from"); - const isDateRange = - Boolean(params.get("to")) && - Boolean(params.get("from")) && - params.get("to") !== params.get("from"); - - if (isPreset) { - return { - type: "preset", - value: params.get("datePreset"), - }; - } - - if (isBefore) { - return { - type: "before", - value: params.get("to"), - }; - } - - if (isAfter) { - return { - type: "after", - value: params.get("from"), - }; - } - - if (isOn) { - return { - type: "on", - value: params.get("from"), - }; - } - - if (isDateRange) { - return { - type: "daterange", - value: { - from: params.get("from"), - to: params.get("to"), - }, - }; - } - - return { - type: "", - value: "", - }; - }, [params]); - return ( { handleDateFilterChanged(value)} + onChange={(value) => setActiveDateFilter(value)} value={activeDateFilter} /> { from: params.get("from") || "", to: params.get("to") || "", }; - const isPreset = Boolean(dateFilter.preset); - const isBefore = Boolean(dateFilter.to) && !Boolean(dateFilter.from); - const isAfter = Boolean(dateFilter.from) && !Boolean(dateFilter.to); - const isOn = - Boolean(dateFilter.to) && - Boolean(dateFilter.from) && - dateFilter.to === dateFilter.from; - const isRange = - Boolean(dateFilter.to) && - Boolean(dateFilter.from) && - dateFilter.to !== dateFilter.from; - let dateFilterFn: (date: string) => boolean; + const dateFilterFn = getDateFilterFnByValues(dateFilter); // Filter by user if (userFilter) { @@ -240,33 +229,6 @@ export const SearchPage: FC = () => { _results = _results.filter((result) => result.langID === selectedLangID); } - // Determine the date filter function to use - if (isPreset) { - dateFilterFn = getDateFilterFn({ - type: "preset", - value: dateFilter.preset, - }); - } - - if (isBefore) { - dateFilterFn = getDateFilterFn({ type: "before", value: dateFilter.to }); - } - - if (isAfter) { - dateFilterFn = getDateFilterFn({ type: "after", value: dateFilter.from }); - } - - if (isOn) { - dateFilterFn = getDateFilterFn({ type: "on", value: dateFilter.from }); - } - - if (isRange) { - dateFilterFn = getDateFilterFn({ - type: "daterange", - value: { from: dateFilter.from, to: dateFilter.to }, - }); - } - // Apply date filter if there is any if (dateFilterFn) { _results = _results.filter((result) => { From 154f323b2e8016854b92403475479f810b262f58 Mon Sep 17 00:00:00 2001 From: Andres Galindo Date: Mon, 24 Jun 2024 13:48:22 -0700 Subject: [PATCH 3/9] Handle instance granular role handling (#2747) Solution to handle immediate need of applying an Instance granular role with publish: false Required changes: 1. Content items only get filtered if at least one granular role targets a content item. This ensures that given ONLY an instance granular role a user is still able to see all the content items. 2. Use-permission hook now defaults zuid argument to instance zuid (argument has been unused so far) in order to check permissions. This allows for an instance granular role to be checked and have priority over system role in the case no other entity zuid is provided --- .../src/app/components/ContentNav/index.tsx | 3 ++- .../content-editor/src/store/navContent.js | 6 ++++- src/shell/hooks/use-permissions.js | 23 ++++++++++--------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/apps/content-editor/src/app/components/ContentNav/index.tsx b/src/apps/content-editor/src/app/components/ContentNav/index.tsx index c2ca932c2a..12049d8e48 100644 --- a/src/apps/content-editor/src/app/components/ContentNav/index.tsx +++ b/src/apps/content-editor/src/app/components/ContentNav/index.tsx @@ -171,7 +171,8 @@ export const ContentNav = () => { setClippingsZUID(clippingsModel?.ZUID ?? ""); - if (!!granularRoles?.length) { + // Only filter content items by granular roles if there is at least one granular role that target content items + if (granularRoles?.some((zuid) => zuid?.startsWith("7-"))) { // Filter nav based on user's granular role access filteredNavData = filteredNavData.filter((navItem) => { return granularRoles.includes(navItem.ZUID); diff --git a/src/apps/content-editor/src/store/navContent.js b/src/apps/content-editor/src/store/navContent.js index 1692c375bf..1e2181606c 100644 --- a/src/apps/content-editor/src/store/navContent.js +++ b/src/apps/content-editor/src/store/navContent.js @@ -89,8 +89,12 @@ export function fetchNav() { const hiddenArr = hidden ? JSON.parse(hidden) : []; const hiddenZUIDS = hiddenArr.map((node) => node.ZUID); + const hasContentItemGranularRole = granularRoles?.some((zuid) => + zuid?.startsWith("7-") + ); + // Only filter content items by granular roles if there is at least one granular role that target content items const filteredByRole = res.data.filter((el) => { - if (granularRoles) { + if (granularRoles && hasContentItemGranularRole) { return granularRoles.find((zuid) => zuid === el.ZUID); } else { return el; diff --git a/src/shell/hooks/use-permissions.js b/src/shell/hooks/use-permissions.js index 172d464d3d..23c00df880 100644 --- a/src/shell/hooks/use-permissions.js +++ b/src/shell/hooks/use-permissions.js @@ -3,6 +3,7 @@ import { useMemo } from "react"; import { useSelector } from "react-redux"; import { createSelector } from "reselect"; +import instanceZUID from "../../utility/instanceZUID"; const getUser = (state) => state.user; const getRole = (state) => state.userRole; @@ -11,9 +12,11 @@ const selectRole = createSelector([getRole], (role) => role); /** * TODO given a specific ZUID, determine whether that user is allowed to do a certain action + * @param {string} action + * @param {string} zuid - default to instance ZUID * e.g. Can user publish content item */ -export function usePermission(action, zuid = "") { +export function usePermission(action, zuid = instanceZUID) { const user = useSelector(selectUser); const role = useSelector(selectRole); @@ -30,28 +33,26 @@ export function usePermission(action, zuid = "") { return true; } - // Check granular - // TODO check granular permission on specific ZUID - // const granular = () => { - // return false; - // }; + const granularRole = role?.granularRoles?.find( + (r) => r.resourceZUID === zuid + ); // Check system switch (action) { case "CREATE": - return role.systemRole.create; + return granularRole?.create ?? role.systemRole.create; case "READ": - return role.systemRole.read; + return granularRole?.read ?? role.systemRole.read; case "UPDATE": - return role.systemRole.update; + return granularRole?.update ?? role.systemRole.update; case "DELETE": - return role.systemRole.delete; + return granularRole?.delete ?? role.systemRole.delete; case "PUBLISH": - return role.systemRole.publish; + return granularRole?.publish ?? role.systemRole.publish; // check for specific product access // NOTE this has been bolted on to the action check From b2c6b48ffb6bce57513434de548bcd70b5602508 Mon Sep 17 00:00:00 2001 From: Andres Date: Tue, 25 Jun 2024 10:59:50 -0700 Subject: [PATCH 4/9] fix craeated by filter and move lang code setup to parent --- .../app/views/ItemList/ItemListFilters.tsx | 7 ----- .../src/app/views/ItemList/ItemListTable.tsx | 9 +++--- .../src/app/views/ItemList/index.tsx | 29 ++++++++++++++----- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx index fca9c94b56..ab68f247b2 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx @@ -67,13 +67,6 @@ export const ItemListFilters = () => { })); }, [users]); - useEffect(() => { - // if languages and no language param, set the first language as the active language - if (languages && !activeLanguageCode) { - setParams(languages[0].code, "lang"); - } - }, [languages, activeLanguageCode]); - return ( { !(stagedChanges && Object.keys(stagedChanges)?.length) } sx={{ - ...(!rows?.length && { - height: 56, - flex: 0, - }), + ...(!rows?.length && + !loading && { + height: 56, + flex: 0, + }), backgroundColor: "common.white", ".MuiDataGrid-row": { cursor: "pointer", diff --git a/src/apps/content-editor/src/app/views/ItemList/index.tsx b/src/apps/content-editor/src/app/views/ItemList/index.tsx index 3ed838fef2..6d74d02d76 100644 --- a/src/apps/content-editor/src/app/views/ItemList/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/index.tsx @@ -6,11 +6,12 @@ import { useGetContentModelFieldsQuery, useGetContentModelItemsQuery, useGetContentModelQuery, + useGetLangsQuery, } from "../../../../../../shell/services/instance"; import { theme } from "@zesty-io/material"; import { ItemListEmpty } from "./ItemListEmpty"; import { ItemListActions } from "./ItemListActions"; -import { useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { SearchRounded, RestartAltRounded } from "@mui/icons-material"; import noSearchResults from "../../../../../../../public/images/noSearchResults.svg"; import { ItemListFilters } from "./ItemListFilters"; @@ -63,12 +64,17 @@ export const ItemList = () => { const { data: model, isFetching: isModelFetching } = useGetContentModelQuery(modelZUID); const { data: items, isFetching: isModelItemsFetching } = - useGetContentModelItemsQuery({ - modelZUID, - params: { - lang: langCode, + useGetContentModelItemsQuery( + { + modelZUID, + params: { + lang: langCode, + }, }, - }); + { + skip: !langCode, + } + ); const { data: fields, isFetching: isFieldsFetching } = useGetContentModelFieldsQuery(modelZUID); const { data: publishings, isFetching: isPublishingsFetching } = @@ -85,6 +91,8 @@ export const ItemList = () => { { skip: !bins?.length } ); const { data: users } = useGetUsersQuery(); + const { data: languages } = useGetLangsQuery({}); + const activeLanguageCode = params.get("lang"); const { stagedChanges } = useStagedChanges(); const [selectedItems] = useSelectedItems(); @@ -101,6 +109,13 @@ export const ItemList = () => { }, [params]); const userFilter = params.get("user"); + useEffect(() => { + // if languages and no language param, set the first language as the active language + if (languages && !activeLanguageCode) { + setParams(languages[0].code, "lang"); + } + }, [languages, activeLanguageCode]); + const processedItems = useMemo(() => { if (!items) return []; let clonedItems = [...draftItems, ...items].map((item: any) => { @@ -334,7 +349,7 @@ export const ItemList = () => { if (userFilter) { clonedItems = clonedItems.filter( - (item) => item?.web?.createdByUserZUID === userFilter + (item) => item?.meta?.createdByUserZUID === userFilter ); } From a8899f029f38390e8a61e61554fe7b4f8ddcf3bc Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Thu, 27 Jun 2024 04:56:59 +0800 Subject: [PATCH 5/9] Comment System (#2711) **This is dependent on BE Change: [insert pr here]** https://github.com/zesty-io/manager-ui/assets/28705606/80fd0937-9c61-427d-b1da-7e3f34263776 --------- Co-authored-by: Stuart Runyan Co-authored-by: Andres Galindo --- README.md | 17 + cypress/e2e/content/comments.js | 84 ++++ cypress/e2e/content/content.spec.js | 2 +- .../components/Editor/Field/FieldShell.tsx | 6 + .../src/app/views/ItemEdit/ItemEdit.js | 6 +- .../components/ItemEditHeader/index.tsx | 6 +- src/shell/components/Comment/CommentItem.tsx | 307 ++++++++++++++ src/shell/components/Comment/CommentsList.tsx | 198 +++++++++ .../components/Comment/ConfirmDeleteModal.tsx | 65 +++ src/shell/components/Comment/InputField.tsx | 380 ++++++++++++++++++ src/shell/components/Comment/MentionList.tsx | 163 ++++++++ src/shell/components/Comment/index.tsx | 170 ++++++++ src/shell/components/Comment/utils.ts | 18 + .../components/FieldTypeTinyMCE/index.tsx | 1 + src/shell/contexts/CommentProvider.tsx | 43 ++ src/shell/index.js | 14 +- src/shell/services/accounts.ts | 175 +++++++- src/shell/services/types.ts | 37 ++ 18 files changed, 1683 insertions(+), 9 deletions(-) create mode 100644 cypress/e2e/content/comments.js create mode 100644 src/shell/components/Comment/CommentItem.tsx create mode 100644 src/shell/components/Comment/CommentsList.tsx create mode 100644 src/shell/components/Comment/ConfirmDeleteModal.tsx create mode 100644 src/shell/components/Comment/InputField.tsx create mode 100644 src/shell/components/Comment/MentionList.tsx create mode 100644 src/shell/components/Comment/index.tsx create mode 100644 src/shell/components/Comment/utils.ts create mode 100644 src/shell/contexts/CommentProvider.tsx diff --git a/README.md b/README.md index d878e66101..50167fc13e 100644 --- a/README.md +++ b/README.md @@ -125,3 +125,20 @@ In some case when sending null this will break the togglebutton UI, thus the rea ## Uploading Assets To upload assets for your projects put them on the CDN, do not put them in the repository. Assets can be uploaded at https://console.cloud.google.com/storage/browser/assets.zesty.io?project=zesty-prod , upload to the respective folder that match your project name, for example, the SVGs and PNG that are being commited to manager-ui should be moved into this storage bucket under the `manager` folder, once they are uploaded they accessible from https://assets.zesty.io e.g. https://assets.zesty.io/website/assets/images/dxp_bottom_bg.svg + +## Deeplinks + +For in-app deeplinks the preferred url structure is to use url path parameters as opposed to query parameters. Generally, paths are best used used to deep link into a specific view (a combination of UI layout and elements rendered on screen) whereas query parameters are used to refine a view. + +#### Example + +``` +Table view: /content/6-000-0000 +Table view filtered to published items: /content/6-000-0000?status=published + +Content edit view: /content/6-000-0000/7-000-0000 +Content edit view that displays deactivated field: /content/6-000-0000/7-000-0000?showDeactivated=true + +Comment in content edit view: /content/6-000-0000/7-000-0000/comment/12-000-0000/24-000-0000 +Comment in content edit view that displays deleted replies: /content/6-000-0000/7-000-0000/comment/12-000-0000/24-000-0000?showDeleted=true +``` diff --git a/cypress/e2e/content/comments.js b/cypress/e2e/content/comments.js new file mode 100644 index 0000000000..61671fc3fa --- /dev/null +++ b/cypress/e2e/content/comments.js @@ -0,0 +1,84 @@ +describe("Content Item: Comments", () => { + before(() => { + cy.waitOn("/v1/content/models*", () => { + cy.waitOn("/v1/comments*", () => { + cy.visit("/content/6-556370-8sh47g/7-b939a4-457q19"); + }); + }); + cy.getBySelector("DuoModeToggle").click(); + }); + + it("Creates an initial comment", () => { + cy.getBySelector("OpenCommentsButton").first().click(); + cy.get("#commentInputField").click().type("This is a new comment."); + cy.getBySelector("SubmitNewComment").click(); + cy.intercept("/v1/comments/*").as("getAllComments"); + cy.wait("@getAllComments"); + cy.getBySelector("CommentItem").should("have.length", 1); + }); + + it("Replies to a comment", () => { + cy.get("#commentInputField").click().type("Hello, this is a new reply!"); + cy.getBySelector("SubmitNewComment").click(); + cy.intercept("/v1/comments/*?showReplies=true&showResolved=true").as( + "getReplies" + ); + cy.wait("@getReplies"); + cy.getBySelector("CommentItem").should("have.length", 2); + }); + + it("Updates an existing comment", () => { + const UPDATED_TEXT = "I am updating this comment now."; + + cy.getBySelector("CommentMenuButton").first().click(); + cy.getBySelector("EditCommentButton").click(); + cy.get("#commentInputField") + .click() + .type(`{selectall}{backspace}${UPDATED_TEXT}`); + cy.getBySelector("SubmitNewComment").click(); + cy.intercept("/v1/comments/*?showReplies=true&showResolved=true").as( + "getReplies" + ); + cy.wait("@getReplies"); + cy.getBySelector("CommentItem").first().contains(UPDATED_TEXT); + }); + + it("Resolves a comment", () => { + cy.getBySelector("ResolveCommentButton").click(); + cy.intercept("/v1/comments/*?showReplies=true&showResolved=true").as( + "getReplies" + ); + cy.intercept("/v1/instances/*/comments?resource=*").as( + "getCommentResourceData" + ); + cy.wait("@getReplies"); + cy.wait("@getCommentResourceData"); + cy.getBySelector("ResolveCommentButton").should("not.exist"); + }); + + it("Reopens a comment when there is a new reply", () => { + cy.get("#commentInputField").click().type("Reopening ticket."); + cy.getBySelector("SubmitNewComment").click(); + cy.intercept("/v1/comments/*?showReplies=true&showResolved=true").as( + "getReplies" + ); + cy.intercept("/v1/instances/*/comments?resource=*").as( + "getCommentResourceData" + ); + cy.wait("@getReplies"); + cy.wait("@getCommentResourceData"); + cy.getBySelector("ResolveCommentButton").should("exist"); + }); + + it("Delete a comment", () => { + cy.getBySelector("CommentMenuButton").first().click(); + cy.getBySelector("DeleteCommentButton").click(); + cy.getBySelector("ConfirmDeleteCommentButton").click(); + cy.intercept("/v1/instances/*/comments?resource=*").as( + "getCommentResourceData" + ); + cy.wait("@getCommentResourceData"); + cy.getBySelector("OpenCommentsButton").first().click(); + cy.getBySelector("CommentItem").should("not.exist"); + }); +}); diff --git a/cypress/e2e/content/content.spec.js b/cypress/e2e/content/content.spec.js index 65efbcaa3c..0d629acca7 100644 --- a/cypress/e2e/content/content.spec.js +++ b/cypress/e2e/content/content.spec.js @@ -217,7 +217,7 @@ describe("Content Specs", () => { .clear() .type("{rightArrow}12"); - cy.get("#12-4e1914-kcqznz button").first().click(); + cy.get("#12-4e1914-kcqznz button").eq(1).click(); cy.get("#12-4e1914-kcqznz input[type='text']").should("have.value", "11"); diff --git a/src/apps/content-editor/src/app/components/Editor/Field/FieldShell.tsx b/src/apps/content-editor/src/app/components/Editor/Field/FieldShell.tsx index b9f7bc4dd8..19813a2846 100644 --- a/src/apps/content-editor/src/app/components/Editor/Field/FieldShell.tsx +++ b/src/apps/content-editor/src/app/components/Editor/Field/FieldShell.tsx @@ -9,10 +9,12 @@ import { } from "@mui/material"; import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded"; +import { useLocation } from "react-router"; import { InteractiveTooltip } from "../../../../../../../shell/components/InteractiveTooltip"; import { FieldTooltipBody } from "./FieldTooltipBody"; import { ContentModelField } from "../../../../../../../shell/services/types"; +import { Comment } from "../../../../../../../shell/components/Comment"; export type EditorType = | "markdown" @@ -58,6 +60,7 @@ export const FieldShell = ({ errors, withInteractiveTooltip = true, }: FieldShellProps) => { + const location = useLocation(); const [anchorEl, setAnchorEl] = useState(null); const getErrorMessage = (errors: Error) => { @@ -76,6 +79,8 @@ export const FieldShell = ({ return ""; }; + const isCreateNewItemPage = location?.pathname?.split("/")?.pop() === "new"; + return ( @@ -141,6 +146,7 @@ export const FieldShell = ({ )} {endLabel} + {!isCreateNewItemPage && } {settings?.description && ( diff --git a/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js b/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js index 601a8f6a50..6c00271223 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js +++ b/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js @@ -443,7 +443,11 @@ export default function ItemEdit() { /> ( { )?.value || "" } onChange={(event, value) => - history.push(`/content/${modelZUID}/${itemZUID}/${value}`) + history.push( + value + ? `/content/${modelZUID}/${itemZUID}/${value}` + : `/content/${modelZUID}/${itemZUID}` + ) } sx={{ position: "relative", diff --git a/src/shell/components/Comment/CommentItem.tsx b/src/shell/components/Comment/CommentItem.tsx new file mode 100644 index 0000000000..21f9a034d5 --- /dev/null +++ b/src/shell/components/Comment/CommentItem.tsx @@ -0,0 +1,307 @@ +import { useRef, useEffect, useState, useContext } from "react"; +import { + Stack, + Typography, + Avatar, + IconButton, + Box, + Menu, + MenuItem, + ListItemIcon, + ListItemText, + Tooltip, +} from "@mui/material"; +import MoreVertRoundedIcon from "@mui/icons-material/MoreVertRounded"; +import CheckRoundedIcon from "@mui/icons-material/CheckRounded"; +import DriveFileRenameOutlineRoundedIcon from "@mui/icons-material/DriveFileRenameOutlineRounded"; +import LinkRoundedIcon from "@mui/icons-material/LinkRounded"; +import DeleteRoundedIcon from "@mui/icons-material/DeleteRounded"; +import moment from "moment"; +import { useLocation, useParams } from "react-router"; +import { useSelector } from "react-redux"; + +import { useUpdateCommentStatusMutation } from "../../services/accounts"; +import { MD5 } from "../../../utility/md5"; +import { InputField } from "./InputField"; +import { CommentContext } from "../../contexts/CommentProvider"; +import { AppState } from "../../store/types"; +import { User } from "../../services/types"; +import { ConfirmDeleteModal } from "./ConfirmDeleteModal"; +import { + useDeleteCommentMutation, + useDeleteReplyMutation, +} from "../../services/accounts"; + +const URL_REGEX = + /(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)/gm; + +type PathParams = { + modelZUID: string; + itemZUID: string; + resourceZUID: string; + commentZUID: string; +}; +type CommentItemProps = { + commentZUID: string; + body: string; + creator: { + name: string; + ZUID: string; + email: string; + }; + createdOn: string; + parentCommentZUID: string; + withResolveButton?: boolean; + onParentCommentDeleted: () => void; +}; +export const CommentItem = ({ + commentZUID, + body, + creator, + createdOn, + parentCommentZUID, + withResolveButton, + onParentCommentDeleted, +}: CommentItemProps) => { + const { resourceZUID } = useParams(); + const location = useLocation(); + const [_, __, commentZUIDtoEdit, setCommentZUIDtoEdit] = + useContext(CommentContext); + const [ + deleteComment, + { isLoading: isDeletingComment, isSuccess: isCommentDeleted }, + ] = useDeleteCommentMutation(); + const [ + deleteReply, + { isLoading: isDeletingReply, isSuccess: isReplyDeleted }, + ] = useDeleteReplyMutation(); + const [updateCommentStatus, { isLoading: isUpdatingCommentStatus }] = + useUpdateCommentStatusMutation(); + const [menuAnchorEl, setMenuAnchorEl] = useState(); + const [isCopied, setIsCopied] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const commentBodyRef = useRef(); + const loggedInUser: User = useSelector((state: AppState) => state.user); + + const isLoggedInUserCommentCreator = loggedInUser?.ZUID === creator?.ZUID; + + useEffect(() => { + if (commentBodyRef.current) { + const hyperlinkedContent = body?.replaceAll(URL_REGEX, (text) => { + // Highlights @ mentions + if (text.includes("@") && text.startsWith("@")) { + return `${text}`; + } + + // Converts url strings to anchor tags + if (!text.includes("@")) { + const url = + text.includes("http://") || text.includes("https://") + ? text + : `https://${text}`; + + return `${text}`; + } + + return text; + }); + + commentBodyRef.current.innerHTML = hyperlinkedContent; + } + }, [body, commentBodyRef]); + + useEffect(() => { + if (isCommentDeleted || isReplyDeleted) { + setIsDeleteModalOpen(false); + + if (commentZUID.startsWith("24")) { + onParentCommentDeleted(); + } + } + }, [isCommentDeleted, isReplyDeleted]); + + const handleCopyClick = () => { + navigator?.clipboard + ?.writeText( + `${window.location.origin}${location.pathname}/${commentZUID}` + ) + .then(() => { + setIsCopied(true); + setTimeout(() => { + setIsCopied(false); + }, 500); + }) + .catch((err) => { + console.error(err); + }); + }; + + const handleDeleteComment = () => { + if (commentZUID.startsWith("24")) { + deleteComment({ + resourceZUID, + commentZUID, + }); + } else { + deleteReply({ + resourceZUID, + commentZUID, + parentCommentZUID, + }); + } + }; + + const handleUpdateCommentStatus = () => { + updateCommentStatus({ + resourceZUID, + commentZUID, + parentCommentZUID, + isResolved: true, + }); + }; + + if (commentZUIDtoEdit === commentZUID) { + return ( + setCommentZUIDtoEdit(null)} + commentResourceZUID={resourceZUID} + parentCommentZUID={parentCommentZUID} + /> + ); + } + + return ( + <> + + + + + + + + {creator?.name} + + + {moment(createdOn).fromNow()} + + + + + {withResolveButton && ( + + + + + + )} + + setMenuAnchorEl(evt.currentTarget)} + > + + + + + + + + {menuAnchorEl && ( + setMenuAnchorEl(null)} + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "right", + }} + slotProps={{ + paper: { + sx: { + width: 160, + }, + }, + }} + > + {isLoggedInUserCommentCreator && ( + { + setMenuAnchorEl(null); + setCommentZUIDtoEdit(commentZUID); + }} + > + + + + Edit + + )} + + + {isCopied ? : } + + Copy Link + + {isLoggedInUserCommentCreator && ( + { + setMenuAnchorEl(null); + setIsDeleteModalOpen(true); + }} + > + + + + Delete + + )} + + )} + + {isDeleteModalOpen && ( + setIsDeleteModalOpen(false)} + onConfirmDelete={handleDeleteComment} + /> + )} + + ); +}; diff --git a/src/shell/components/Comment/CommentsList.tsx b/src/shell/components/Comment/CommentsList.tsx new file mode 100644 index 0000000000..8d8989884a --- /dev/null +++ b/src/shell/components/Comment/CommentsList.tsx @@ -0,0 +1,198 @@ +import { + Divider, + Paper, + Popper, + Backdrop, + PopperPlacementType, + Stack, + Skeleton, +} from "@mui/material"; +import { theme } from "@zesty-io/material"; +import { Fragment, useContext, useEffect, useRef, useState } from "react"; + +import { CommentItem } from "./CommentItem"; +import { InputField } from "./InputField"; +import { useGetCommentThreadQuery } from "../../services/accounts"; +import { CommentContext } from "../../contexts/CommentProvider"; +import { useParams } from "react-router"; + +type PathParams = { + modelZUID: string; + itemZUID: string; + resourceZUID: string; + commentZUID: string; +}; +type CommentsListProps = { + anchorEl: Element; + onClose: () => void; + isResolved: boolean; + parentCommentZUID: string; +}; +export const CommentsList = ({ + anchorEl, + onClose, + isResolved, + parentCommentZUID, +}: CommentsListProps) => { + const { resourceZUID, commentZUID } = useParams(); + const [_, __, commentZUIDtoEdit] = useContext(CommentContext); + const [popperTopOffset, setPopperTopOffset] = useState(0); + const [popperBottomOffset, setPopperBottomOffset] = useState(0); + const [placement, setPlacement] = + useState("bottom-start"); + const topOffsetRef = useRef(); + + const { data: commentThread, isLoading: isLoadingCommentThread } = + useGetCommentThreadQuery( + { commentZUID: parentCommentZUID }, + { skip: !parentCommentZUID } + ); + + useEffect(() => { + if ( + window.innerHeight - anchorEl?.getBoundingClientRect().top > + window.innerHeight * 0.3 + ) { + setPlacement("bottom-start"); + } else { + setPlacement("top-start"); + } + + setTimeout(() => { + const { top, bottom } = topOffsetRef.current?.getBoundingClientRect(); + + setPopperTopOffset(top); + setPopperBottomOffset(bottom); + }); + + // HACK: Prevents UI flicker when popper renders and is temporarily out of bounds + document.body.style.overflow = "hidden"; + + return () => { + document.body.style.overflow = null; + }; + }, []); + + useEffect(() => { + // Scrolls to a specific reply indicated in the search params + if (!isLoadingCommentThread) { + setTimeout(() => { + const replyEl = document.getElementById(commentZUID); + + if (replyEl) { + replyEl.scrollIntoView(); + } + }); + } + }, [commentZUID, isLoadingCommentThread]); + + const calculateMaxHeight = () => { + if (placement === "bottom-start") { + return window.innerHeight - popperTopOffset - 8; + } else { + return popperBottomOffset - 8; + } + }; + + return ( + { + const element = evt.target as HTMLElement; + + if (element?.id === "popperBg") { + onClose(); + } + }} + > + + + {isLoadingCommentThread && } + {commentThread?.map((comment, index) => ( + + + {index + 1 < commentThread?.length && ( + + )} + + ))} + {!commentZUIDtoEdit && ( + + )} + + + + ); +}; + +const CommentItemLoading = () => { + return ( + + + + + + + + + + + + + + ); +}; diff --git a/src/shell/components/Comment/ConfirmDeleteModal.tsx b/src/shell/components/Comment/ConfirmDeleteModal.tsx new file mode 100644 index 0000000000..1384e218c4 --- /dev/null +++ b/src/shell/components/Comment/ConfirmDeleteModal.tsx @@ -0,0 +1,65 @@ +import { + Dialog, + DialogTitle, + DialogActions, + Button, + Stack, + Box, + Typography, + DialogContent, +} from "@mui/material"; +import DeleteRoundedIcon from "@mui/icons-material/DeleteRounded"; +import { LoadingButton } from "@mui/lab"; + +type ConfirmDeleteModalProps = { + onClose: () => void; + onConfirmDelete: () => void; + isDeletingComment: boolean; +}; +export const ConfirmDeleteModal = ({ + onClose, + onConfirmDelete, + isDeletingComment, +}: ConfirmDeleteModalProps) => ( + + + + + + + + Delete Comment? + + + + + + Deleting this comment will also remove all replies associated with it. + + + + + + Delete Forever + + + +); diff --git a/src/shell/components/Comment/InputField.tsx b/src/shell/components/Comment/InputField.tsx new file mode 100644 index 0000000000..eff49ff5c0 --- /dev/null +++ b/src/shell/components/Comment/InputField.tsx @@ -0,0 +1,380 @@ +import { useEffect, useRef, useState, useContext } from "react"; +import { Box, Typography, Button, Stack } from "@mui/material"; +import { Editor } from "@tinymce/tinymce-react"; +import { theme } from "@zesty-io/material"; +import { LoadingButton } from "@mui/lab"; +import { useParams } from "react-router"; + +import { MentionList } from "./MentionList"; +import tinymce from "tinymce"; +import { countCharUsage, getResourceTypeByZuid } from "./utils"; +import { CommentContext } from "../../contexts/CommentProvider"; +import { + useCreateCommentMutation, + useCreateReplyMutation, + useUpdateCommentMutation, + useUpdateReplyMutation, +} from "../../services/accounts"; + +const PLACEHOLDER = '

Reply or add others with @

'; +const EMAIL_MENTION_REGEX = + /(?)@[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}(?!\)/gm; + +type InputFieldProps = { + isFirstComment: boolean; + onCancel: () => void; + commentResourceZUID: string; + parentCommentZUID: string; + isEditMode?: boolean; + editModeValue?: string; +}; +export const InputField = ({ + isFirstComment, + onCancel, + commentResourceZUID, + parentCommentZUID, + isEditMode = false, + editModeValue = "", +}: InputFieldProps) => { + const [ + createComment, + { + isLoading: isCreatingComment, + isError: isCommentCreationError, + isSuccess: isCommentCreated, + }, + ] = useCreateCommentMutation(); + const [ + createReply, + { + isLoading: isCreatingReply, + isError: isReplyCreationError, + isSuccess: isReplyCreated, + }, + ] = useCreateReplyMutation(); + const [ + updateComment, + { + isLoading: isUpdatingComment, + isError: isCommentUpdateError, + isSuccess: isCommentUpdated, + }, + ] = useUpdateCommentMutation(); + const [ + updateReply, + { + isLoading: isUpdatingReply, + isError: isReplyUpdateError, + isSuccess: isReplyUpdated, + }, + ] = useUpdateReplyMutation(); + const { itemZUID } = useParams<{ itemZUID: string }>(); + const [comments, updateComments, commentZUIDtoEdit, setCommentZUIDtoEdit] = + useContext(CommentContext); + const buttonsContainerRef = useRef(); + const inputRef = useRef(); + const mentionListRef = useRef(null); + const [inputValue, setInputValue] = useState(""); + const [initialValue, setInitialValue] = useState(PLACEHOLDER); + const [mentionListAnchorEl, setMentionListAnchorEl] = useState(null); + const [userFilterKeyword, setUserFilterKeyword] = useState(""); + + const handleSubmit = () => { + if (isFirstComment) { + createComment({ + resourceZUID: itemZUID, + content: inputValue, + scopeTo: commentResourceZUID, + }); + } else { + createReply({ + content: inputValue, + commentZUID: parentCommentZUID, + resourceZUID: commentResourceZUID, + }); + } + }; + + const handleUpdate = () => { + if (commentZUIDtoEdit.startsWith("24")) { + updateComment({ + resourceZUID: commentResourceZUID, + commentZUID: commentZUIDtoEdit, + content: inputValue, + }); + } else { + updateReply({ + commentZUID: commentZUIDtoEdit, + parentCommentZUID, + content: inputValue, + }); + } + }; + + const getPrimaryButtonText = () => { + if (isEditMode) { + return "Save"; + } + + if (isFirstComment) { + return "Comment"; + } else { + return "Reply"; + } + }; + + const insertUserMention = (email: string) => { + const selection = window.getSelection(); + + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const startOffset = range.startOffset; + + range.setStart( + range.startContainer, + startOffset - (userFilterKeyword?.length + 1) + ); + range.setEnd(range.startContainer, startOffset); + selection.removeAllRanges(); + selection.addRange(range); + } + + tinymce.activeEditor.selection.setContent( + `@${email}` + ); + tinymce.activeEditor.selection.setContent(" "); + + setMentionListAnchorEl(null); + }; + + useEffect(() => { + if (comments[commentResourceZUID]) { + setInitialValue(comments[commentResourceZUID]); + setInputValue(comments[commentResourceZUID]); + } + }, []); + + useEffect(() => { + // No need to save edit mode changes in draft + if (inputValue && !isEditMode) { + updateComments({ + [commentResourceZUID]: inputValue === PLACEHOLDER ? "" : inputValue, + }); + } + }, [inputValue, isEditMode]); + + useEffect(() => { + if (isCommentCreated || isReplyCreated) { + tinymce?.activeEditor.setContent(PLACEHOLDER); + setInputValue(""); + updateComments({ + [commentResourceZUID]: "", + }); + } + }, [isCommentCreated, isReplyCreated]); + + useEffect(() => { + if (isCommentUpdated || isReplyUpdated) { + tinymce?.activeEditor.setContent(PLACEHOLDER); + setInputValue(""); + setCommentZUIDtoEdit(null); + } + }, [isCommentUpdated, isReplyUpdated]); + + useEffect(() => { + if (isEditMode) { + setInitialValue(editModeValue); + setInputValue(editModeValue); + } + }, [isEditMode, editModeValue]); + + const isLoading = + isCreatingComment || + isCreatingReply || + isUpdatingComment || + isUpdatingReply; + const hasError = + isCommentCreationError || + isReplyCreationError || + isCommentUpdateError || + isReplyUpdateError; + + return ( + <> + + `1px solid ${theme.palette.border}`, + cursor: "text", + "&:focus-visible": { + outline: (theme) => `${theme.palette.primary.light} solid 1px`, + }, + "& .placeholder": { + color: "text.disabled", + }, + "& .mentioned-user": { + color: "primary.main", + }, + "& .mce-offscreen-selection": { + position: "absolute", + // HACK: Makes sure that the offscreen selection is offscreen when user selects the mentioned user element + left: "-9999999px", + }, + }, + }} + > + { + editor.on("ResizeEditor", () => { + if (!editor.isNotDirty) { + buttonsContainerRef.current?.scrollIntoView(); + } + }); + }, + }} + onClick={() => { + // Removes the placeholder + if (tinymce?.activeEditor.getContent() === PLACEHOLDER) { + tinymce?.activeEditor.setContent(""); + } + }} + onBlur={() => { + // Re-adds the placeholder when user clicks out and there's no value + if (!tinymce?.activeEditor.getContent()) { + tinymce?.activeEditor.setContent(PLACEHOLDER); + } + }} + onEditorChange={(value, editor) => { + setInputValue(value); + }} + onKeyDown={(evt, editor) => { + // Checks if the mention list should be opened or not + if (evt.key === "@") { + setTimeout(() => { + setMentionListAnchorEl(inputRef.current); + }); + } + + // Logs the entered values after the mention list was opened + if (!!mentionListAnchorEl) { + if (evt.key.length === 1) { + setUserFilterKeyword(userFilterKeyword + evt.key); + } else if (evt.key === "Backspace") { + setUserFilterKeyword( + userFilterKeyword.slice(0, userFilterKeyword?.length - 1) + ); + } + } else { + setUserFilterKeyword(""); + } + + // Changes selected item from the mention list when open + if ( + (evt.key === "ArrowDown" || evt.key === "ArrowUp") && + !!mentionListAnchorEl + ) { + evt.preventDefault(); + mentionListRef.current?.handleChangeSelectedUser(evt.key); + return; + } + + // Closes the mention list + if ( + (evt.key === "ArrowLeft" || + evt.key === "ArrowRight" || + evt.key === " ") && + !!mentionListAnchorEl + ) { + setMentionListAnchorEl(null); + return; + } + + // Checks if the @ that opened the mention list was deleted + if ( + (evt.key === "Backspace" || evt.key === "Delete") && + !!mentionListAnchorEl + ) { + const countBeforeDeletion = countCharUsage( + inputRef.current?.innerText, + "@" + ); + + setTimeout(() => { + const countAfterDeletion = countCharUsage( + inputRef.current?.innerText, + "@" + ); + + if (countAfterDeletion < countBeforeDeletion) { + setMentionListAnchorEl(null); + } + }); + return; + } + + // Selects the highlighted item when mention list is open + if (evt.key === "Enter" && !!mentionListAnchorEl) { + evt.preventDefault(); + mentionListRef.current?.handleSelectUser(); + } + }} + /> + + + {!!mentionListAnchorEl && ( + + )} + {hasError && ( + + Unable to add comment. Please check your internet connection and try + again. + + )} + + + + {getPrimaryButtonText()} + + + + ); +}; diff --git a/src/shell/components/Comment/MentionList.tsx b/src/shell/components/Comment/MentionList.tsx new file mode 100644 index 0000000000..de162be501 --- /dev/null +++ b/src/shell/components/Comment/MentionList.tsx @@ -0,0 +1,163 @@ +import { + useEffect, + useRef, + useState, + forwardRef, + useImperativeHandle, + useMemo, +} from "react"; +import { + Popper, + Paper, + List, + ListItemButton, + ListItemText, + ListItemAvatar, + Avatar, + PopperPlacementType, +} from "@mui/material"; +import { theme } from "@zesty-io/material"; +import { useGetUsersQuery } from "../../services/accounts"; +import { MD5 } from "../../../utility/md5"; + +type MentionListProps = { + anchorEl: Element; + filterKeyword: string; + onUserSelected: (email: string) => void; +}; +export const MentionList = forwardRef( + ({ anchorEl, filterKeyword, onUserSelected }: MentionListProps, ref) => { + const { data: users } = useGetUsersQuery(); + const [popperTopOffset, setPopperTopOffset] = useState(0); + const [popperBottomOffset, setPopperBottomOffset] = useState(0); + const [selectedUserIndex, setSelectedUserIndex] = useState(0); + const [placement, setPlacement] = + useState("bottom-start"); + const popperRef = useRef(); + const listRef = useRef(); + + const sortedUsers = useMemo(() => { + return [...users] + ?.sort((userA, userB) => userA.firstName.localeCompare(userB.firstName)) + .filter((user) => user.email.includes(filterKeyword)); + }, [users, filterKeyword]); + + useEffect(() => { + if ( + window.innerHeight - anchorEl?.getBoundingClientRect().top > + window.innerHeight * 0.2 + ) { + setPlacement("bottom-start"); + } else { + setPlacement("top-start"); + } + + setTimeout(() => { + const { top, bottom } = popperRef.current?.getBoundingClientRect(); + setPopperTopOffset(top); + setPopperBottomOffset(bottom); + }); + }, []); + + useEffect(() => { + listRef.current + ?.querySelector(".Mui-selected") + ?.scrollIntoView({ block: "nearest" }); + }, [selectedUserIndex]); + + useEffect(() => { + // Makes sure that the first user is always selected when filtering + setSelectedUserIndex(0); + }, [filterKeyword]); + + useImperativeHandle( + ref, + () => { + return { + handleChangeSelectedUser(action: "ArrowUp" | "ArrowDown") { + switch (action) { + case "ArrowDown": + const nextIndex = selectedUserIndex + 1; + setSelectedUserIndex( + nextIndex <= users?.length - 1 ? nextIndex : 0 + ); + break; + + case "ArrowUp": + setSelectedUserIndex( + Math.sign(selectedUserIndex - 1) !== -1 + ? selectedUserIndex - 1 + : users?.length - 1 + ); + break; + + default: + break; + } + }, + handleSelectUser() { + onUserSelected(sortedUsers[selectedUserIndex]?.email); + }, + }; + }, + [selectedUserIndex, sortedUsers] + ); + + const calculateMaxHeight = () => { + if (placement === "bottom-start") { + return window.innerHeight - popperTopOffset - 8; + } else { + return popperBottomOffset - 8; + } + }; + + if (!sortedUsers?.length) { + return <>; + } + + return ( + + + + {sortedUsers?.map((user, index) => ( + onUserSelected(user.email)} + > + + + + + + ))} + + + + ); + } +); diff --git a/src/shell/components/Comment/index.tsx b/src/shell/components/Comment/index.tsx new file mode 100644 index 0000000000..fa80162a05 --- /dev/null +++ b/src/shell/components/Comment/index.tsx @@ -0,0 +1,170 @@ +import { IconButton, Button, alpha, Box, Tooltip } from "@mui/material"; +import AddCommentRoundedIcon from "@mui/icons-material/AddCommentRounded"; +import CommentRoundedIcon from "@mui/icons-material/CommentRounded"; +import { useState, useRef, useEffect, useMemo, useContext } from "react"; +import { useParams, useHistory, useLocation } from "react-router"; + +import { CommentsList } from "./CommentsList"; +import { useGetCommentByResourceQuery } from "../../services/accounts"; +import { CommentContext } from "../../contexts/CommentProvider"; + +type PathParams = { + modelZUID: string; + itemZUID: string; + resourceZUID: string; +}; +type CommentProps = { + resourceZUID: string; +}; +export const Comment = ({ resourceZUID }: CommentProps) => { + const history = useHistory(); + const location = useLocation(); + const [_, __, ___, setCommentZUIDtoEdit] = useContext(CommentContext); + const { itemZUID, resourceZUID: activeResourceZUID } = + useParams(); + const { data: comment, isLoading: isLoadingComment } = + useGetCommentByResourceQuery( + { itemZUID, resourceZUID }, + { skip: !resourceZUID } + ); + const buttonContainerRef = useRef(); + const [isCommentListOpen, setIsCommentListOpen] = useState(false); + const [isButtonAutoscroll, setIsButtonAutoscroll] = useState(true); + + const parentComment = useMemo(() => { + return comment?.find((comment) => comment.resourceZUID === itemZUID); + }, [comment, itemZUID]); + + useEffect(() => { + if (!!resourceZUID) { + setIsCommentListOpen( + activeResourceZUID === resourceZUID && !!buttonContainerRef.current + ); + } + }, [buttonContainerRef.current, activeResourceZUID]); + + useEffect(() => { + // Autoscrolls to the button before opening the comment list popup + // Only applicable when popup is opened via deeplink + if (isCommentListOpen && isButtonAutoscroll) { + buttonContainerRef.current?.scrollIntoView(); + } + }, [isCommentListOpen, isButtonAutoscroll]); + + const handleOpenCommentsList = () => { + setIsButtonAutoscroll(false); + history.replace(`${location.pathname}/comment/${resourceZUID}`); + }; + + if (!resourceZUID) { + return <>; + } + + return ( + <> + + {parentComment ? ( + + + + ) : ( + + alpha(theme.palette.primary.main, 0.08) + : "transparent", + color: isCommentListOpen ? "primary.main" : "action", + "&:hover": { + backgroundColor: (theme) => + alpha(theme.palette.primary.main, 0.08), + color: "primary.main", + }, + }} + > + + + + )} + + {isCommentListOpen && ( + { + setIsButtonAutoscroll(false); + setCommentZUIDtoEdit(null); + + // Makes sure that we can properly pop back to the component where the comment was rendered + const pathnameArr = location.pathname?.split("/"); + const commentIndex = pathnameArr.findIndex( + (path) => path === "comment" + ); + + history.replace(pathnameArr?.slice(0, commentIndex)?.join("/")); + }} + isResolved={parentComment?.resolved} + /> + )} + + ); +}; diff --git a/src/shell/components/Comment/utils.ts b/src/shell/components/Comment/utils.ts new file mode 100644 index 0000000000..1d2440df76 --- /dev/null +++ b/src/shell/components/Comment/utils.ts @@ -0,0 +1,18 @@ +import { CommentResourceType } from "../../services/types"; + +export const countCharUsage = (string: string, char: string) => { + const regex = new RegExp(char, "g"); + return [...string.matchAll(regex)]?.length ?? 0; +}; + +export const getResourceTypeByZuid = ( + zuid: string +): CommentResourceType | "" => { + switch (zuid?.split("-")?.[0]) { + case "12": + return "fields"; + + default: + return ""; + } +}; diff --git a/src/shell/components/FieldTypeTinyMCE/index.tsx b/src/shell/components/FieldTypeTinyMCE/index.tsx index f6bab991b8..5b9b40fd72 100644 --- a/src/shell/components/FieldTypeTinyMCE/index.tsx +++ b/src/shell/components/FieldTypeTinyMCE/index.tsx @@ -33,6 +33,7 @@ import "tinymce/plugins/quickbars"; import "tinymce/plugins/searchreplace"; import "tinymce/plugins/table"; import "tinymce/plugins/wordcount"; +import "tinymce/plugins/autoresize"; import "./plugins/slashcommands"; import "./plugins/socialmediaembed"; import "./plugins/imageresizer"; diff --git a/src/shell/contexts/CommentProvider.tsx b/src/shell/contexts/CommentProvider.tsx new file mode 100644 index 0000000000..e1cb56377c --- /dev/null +++ b/src/shell/contexts/CommentProvider.tsx @@ -0,0 +1,43 @@ +import React, { useReducer, createContext, useState, Dispatch } from "react"; + +type CommentContextType = [ + Record, + Dispatch>, + string, + Dispatch +]; +export const CommentContext = createContext([ + {}, + () => {}, + null, + () => {}, +]); + +type CommentProviderType = { + children?: React.ReactNode; +}; +export const CommentProvider = ({ children }: CommentProviderType) => { + const [commentZUIDtoEdit, setCommentZUIDtoEdit] = useState(null); + const [comments, updateComments] = useReducer( + (state: Record, action: Record) => { + return { + ...state, + ...action, + }; + }, + {} + ); + + return ( + + {children} + + ); +}; diff --git a/src/shell/index.js b/src/shell/index.js index 7cf57cc428..9c4422b8de 100644 --- a/src/shell/index.js +++ b/src/shell/index.js @@ -33,6 +33,7 @@ import Shell from "./views/Shell"; import { MonacoSetup } from "../apps/code-editor/src/app/components/Editor/components/MemoizedEditor/MonacoSetup"; import { actions } from "shell/store/ui"; +import { CommentProvider } from "./contexts/CommentProvider"; // needed for Breadcrumbs in Shell injectReducer(store, "navContent", navContent); @@ -50,6 +51,7 @@ window.CONFIG.API_INSTANCE = `${window.CONFIG.API_INSTANCE_PROTOCOL}${instanceZU MonacoSetup(store); +// TODO: Add a context here that will store all draft comments const App = Sentry.withProfiler(() => ( }> @@ -57,11 +59,13 @@ const App = Sentry.withProfiler(() => ( - - - - - + + + + + + + diff --git a/src/shell/services/accounts.ts b/src/shell/services/accounts.ts index 758e5ae017..ce240c1444 100644 --- a/src/shell/services/accounts.ts +++ b/src/shell/services/accounts.ts @@ -1,7 +1,19 @@ import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; +import moment from "moment"; + import instanceZUID from "../../utility/instanceZUID"; import { getResponseData, prepareHeaders } from "./util"; -import { User, UserRole, Domain, Instance, Role, InstalledApp } from "./types"; +import { + User, + UserRole, + Domain, + Instance, + Role, + InstalledApp, + Comment, + CommentReply, + CommentResourceType, +} from "./types"; // Define a service using a base URL and expected endpoints export const accountsApi = createApi({ @@ -11,6 +23,7 @@ export const accountsApi = createApi({ baseUrl: `${__CONFIG__.API_ACCOUNTS}/`, prepareHeaders, }), + tagTypes: ["Comments", "CommentThread"], // always use the instanceZUID from the URL endpoints: (builder) => ({ getDomains: builder.query({ @@ -58,6 +71,157 @@ export const accountsApi = createApi({ query: () => `instances/${instanceZUID}/app-installs`, transformResponse: getResponseData, }), + // Note: scopeTo needs to be null when fetching comments for non-content item field resources + createComment: builder.mutation< + any, + { + resourceZUID: string; + scopeTo: string; + content: string; + } + >({ + query: ({ resourceZUID, content, scopeTo }) => ({ + url: "/comments", + method: "POST", + body: { + resourceZUID, + content, + instanceZUID, + scopeTo, + }, + }), + invalidatesTags: (_, __, { scopeTo }) => [ + { type: "Comments", id: scopeTo }, + ], + }), + createReply: builder.mutation< + any, + { + commentZUID: string; + resourceZUID: string; + content: string; + } + >({ + query: ({ commentZUID, content }) => ({ + url: `/comments/${commentZUID}/replies`, + method: "POST", + body: { + content, + }, + }), + invalidatesTags: (_, __, { resourceZUID, commentZUID }) => [ + { type: "Comments", id: resourceZUID }, + { type: "CommentThread", id: commentZUID }, + ], + }), + getCommentByResource: builder.query< + Comment[], + { itemZUID: string; resourceZUID: string } + >({ + query: ({ itemZUID, resourceZUID }) => + `/instances/${instanceZUID}/comments?resource=${itemZUID}&scope=${resourceZUID}&showResolved=true`, + transformResponse: (response: any) => + response.data?.sort((a: any, b: any) => + moment(b.createdAt).diff(a.createdAt) + ), + providesTags: (_, __, { resourceZUID }) => [ + { type: "Comments", id: resourceZUID }, + ], + }), + getCommentThread: builder.query({ + query: ({ commentZUID }) => + `/comments/${commentZUID}?showReplies=true&showResolved=true`, + transformResponse: (response: any) => [ + { + ZUID: response.data?.ZUID, + commentZUID: null, + content: response.data?.content, + createdAt: response.data?.createdAt, + createdByUserEmail: response.data?.createdByUserEmail, + createdByUserName: response.data?.createdByUserName, + createdByUserZUID: response.data?.createdByUserZUID, + mentions: response.data?.mentions, + updatedAt: response.data?.updatedAt, + }, + ...response.data?.replies, + ], + providesTags: (_, __, { commentZUID }) => [ + { type: "CommentThread", id: commentZUID }, + ], + }), + updateComment: builder.mutation< + any, + { resourceZUID: string; commentZUID: string; content: string } + >({ + query: ({ commentZUID, content }) => ({ + url: `/comments/${commentZUID}`, + method: "PUT", + body: { content }, + }), + invalidatesTags: (_, __, { resourceZUID, commentZUID }) => [ + { type: "Comments", id: resourceZUID }, + { type: "CommentThread", id: commentZUID }, + ], + }), + updateReply: builder.mutation< + any, + { commentZUID: string; parentCommentZUID: string; content: string } + >({ + query: ({ commentZUID, parentCommentZUID, content }) => ({ + url: `/comments/${parentCommentZUID}/replies/${commentZUID}`, + method: "PUT", + body: { content }, + }), + invalidatesTags: (_, __, { parentCommentZUID }) => [ + { type: "CommentThread", id: parentCommentZUID }, + ], + }), + deleteComment: builder.mutation< + any, + { resourceZUID: string; commentZUID: string } + >({ + query: ({ commentZUID }) => ({ + url: `/comments/${commentZUID}`, + method: "DELETE", + }), + invalidatesTags: (_, __, { resourceZUID, commentZUID }) => [ + { type: "Comments", id: resourceZUID }, + { type: "CommentThread", id: commentZUID }, + ], + }), + deleteReply: builder.mutation< + any, + { commentZUID: string; parentCommentZUID: string; resourceZUID: string } + >({ + query: ({ commentZUID, parentCommentZUID }) => ({ + url: `/comments/${parentCommentZUID}/replies/${commentZUID}`, + method: "DELETE", + }), + invalidatesTags: (_, __, { parentCommentZUID, resourceZUID }) => [ + { type: "Comments", id: resourceZUID }, + { type: "CommentThread", id: parentCommentZUID }, + ], + }), + updateCommentStatus: builder.mutation< + any, + { + resourceZUID: string; + commentZUID: string; + parentCommentZUID: string; + isResolved: boolean; + } + >({ + query: ({ commentZUID, isResolved }) => ({ + url: `/comments/${commentZUID}?action=${ + isResolved ? "resolve" : "unresolve" + }`, + method: "PUT", + }), + invalidatesTags: (_, __, { resourceZUID, parentCommentZUID }) => [ + { type: "Comments", id: resourceZUID }, + { type: "CommentThread", id: parentCommentZUID }, + ], + }), }), }); @@ -72,4 +236,13 @@ export const { useCreateUserInviteMutation, useGetCurrentUserRolesQuery, useGetInstalledAppsQuery, + useCreateCommentMutation, + useCreateReplyMutation, + useGetCommentThreadQuery, + useGetCommentByResourceQuery, + useUpdateCommentMutation, + useUpdateReplyMutation, + useDeleteCommentMutation, + useDeleteReplyMutation, + useUpdateCommentStatusMutation, } = accountsApi; diff --git a/src/shell/services/types.ts b/src/shell/services/types.ts index 97a86a89c2..19f457f056 100644 --- a/src/shell/services/types.ts +++ b/src/shell/services/types.ts @@ -526,3 +526,40 @@ export type Announcement = { end_date_and_time: string; created_at: string; }; + +export type CommentResourceType = "fields" | "items"; +export type Mention = { + email: string; + userZUID: string; +}; + +export type Comment = { + ZUID: string; + content: string; + createdAt: string; + createdByUserEmail: string; + createdByUserName: string; + createdByUserZUID: string; + instanceZUID: string; + mentions?: Mention[]; + replyCount?: number; + resolved: boolean; + resourceType: CommentResourceType; + resourceUserEmail: string; + resourceUserZUID: string; + resourceZUID: string; + scopeTo: string; + updatedAt: string; +}; + +export type CommentReply = { + ZUID: string; + commentZUID: string; + content: string; + createdAt: string; + createdByUserEmail: string; + createdByUserName: string; + createdByUserZUID: string; + mentions?: Mention[]; + updatedAt: string; +}; From 9d2836c465e931bc32505e98fa307c4356238355 Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Thu, 27 Jun 2024 15:57:32 +0800 Subject: [PATCH 6/9] Schema min/max characters rule (#2755) Resolves #2719 https://github.com/zesty-io/manager-ui/assets/28705606/e1d545ef-46c1-4e12-bfe2-2618b0949068 --- cypress/e2e/content/actions.spec.js | 49 ++ cypress/e2e/schema/field.spec.js | 16 + .../src/app/components/Editor/Editor.js | 63 ++- .../src/app/components/Editor/Field/Field.tsx | 4 + .../components/Editor/Field/FieldShell.tsx | 55 ++- .../Editor/Field/FieldTooltipBody.tsx | 7 +- .../src/app/components/Editor/FieldError.tsx | 74 ++- .../src/app/views/ItemCreate/ItemCreate.tsx | 59 ++- .../src/app/views/ItemEdit/ItemEdit.js | 56 ++- .../AddFieldModal/CharacterLimit.tsx | 157 ++++++ .../AddFieldModal/FieldFormInput.tsx | 8 +- .../app/components/AddFieldModal/Learn.tsx | 9 +- .../components/AddFieldModal/MediaRules.tsx | 1 - .../app/components/AddFieldModal/Regex.tsx | 446 ++++++++++++++++++ .../app/components/AddFieldModal/index.tsx | 5 +- .../AddFieldModal/views/FieldForm.tsx | 172 +++++-- .../components/AddFieldModal/views/Rules.tsx | 116 +++++ .../schema/src/app/components/Field/index.tsx | 4 +- src/apps/schema/src/app/components/configs.ts | 81 +++- src/shell/components/FieldTypeNumber.tsx | 23 +- src/shell/services/types.ts | 6 + src/shell/store/content.js | 82 +++- 22 files changed, 1383 insertions(+), 110 deletions(-) create mode 100644 src/apps/schema/src/app/components/AddFieldModal/CharacterLimit.tsx create mode 100644 src/apps/schema/src/app/components/AddFieldModal/Regex.tsx create mode 100644 src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx diff --git a/cypress/e2e/content/actions.spec.js b/cypress/e2e/content/actions.spec.js index 22b8fdfcf0..b8f5cf4b20 100644 --- a/cypress/e2e/content/actions.spec.js +++ b/cypress/e2e/content/actions.spec.js @@ -22,6 +22,55 @@ describe("Actions in content editor", () => { cy.get("[data-cy=toast]").contains("Missing Data in Required Fields"); }); + it("Must not save when exceeding or lacking characters", () => { + cy.waitOn("/v1/content/models*", () => { + cy.visit("/content/6-a4f5f1beaa-zc5l6v/7-ce9ca8cfb0-cc1mnz"); + }); + + cy.get("#12-e6a5cfe3f6-k94nbg input").clear().type("aa"); + cy.get("#SaveItemButton").click(); + cy.getBySelector("FieldErrorsList").should("exist"); + cy.getBySelector("FieldErrorsList") + .find("ol") + .find("li") + .first() + .contains("Requires 8 more characters."); + cy.get("#12-e6a5cfe3f6-k94nbg input") + .clear() + .type("Lorem ipsum dolor sit amet, consect"); + cy.get("#SaveItemButton").click(); + cy.getBySelector("FieldErrorsList").should("exist"); + cy.getBySelector("FieldErrorsList") + .find("ol") + .find("li") + .first() + .contains("Exceeding by 5 characters."); + cy.get("#12-e6a5cfe3f6-k94nbg input").clear().type("Lorem ipsum"); + cy.get("#SaveItemButton").click(); + cy.get("[data-cy=toast]").contains("Item Saved: New Schema All Fields"); + }); + + it.only("Must not save when regex is not matched", () => { + cy.waitOn("/v1/content/models*", () => { + cy.visit("/content/6-a4f5f1beaa-zc5l6v/7-ce9ca8cfb0-cc1mnz"); + }); + + cy.get("#12-b6d09d92d0-7911ld textarea").first().clear().type("aa"); + cy.get("#SaveItemButton").click(); + cy.getBySelector("FieldErrorsList").should("exist"); + cy.getBySelector("FieldErrorsList") + .find("ol") + .find("li") + .first() + .contains("Must be an email (e.g. hello@zesty.io)"); + cy.get("#12-b6d09d92d0-7911ld textarea") + .first() + .clear() + .type("hello@zesty.io"); + cy.get("#SaveItemButton").click(); + cy.get("[data-cy=toast]").contains("Item Saved: New Schema All Fields"); + }); + /** * NOTE: this depends upon `toggle` field on the schema being marked as being required and deactivated. Because it's deactivated it doesn't render in the content editor and the expectation is the content item should save. there fore there is nothing to do and confirm that this item saves successfully. Adding this notes because nothing really happens inside this test but it's important this test remains. * */ diff --git a/cypress/e2e/schema/field.spec.js b/cypress/e2e/schema/field.spec.js index d7279c7b69..efa88d4184 100644 --- a/cypress/e2e/schema/field.spec.js +++ b/cypress/e2e/schema/field.spec.js @@ -46,6 +46,11 @@ const SELECTORS = { SYSTEM_FIELDS: "SystemFields", DEFAULT_VALUE_CHECKBOX: "DefaultValueCheckbox", DEFAULT_VALUE_INPUT: "DefaultValueInput", + CHARACTER_LIMIT_CHECKBOX: "CharacterLimitCheckbox", + MIN_CHARACTER_LIMIT_INPUT: "MinCharacterLimitInput", + MAX_CHARACTER_LIMIT_INPUT: "MaxCharacterLimitInput", + MIN_CHARACTER_ERROR_MSG: "MinCharacterErrorMsg", + MAX_CHARACTER_ERROR_MSG: "MaxCharacterErrorMsg", }; /** @@ -110,6 +115,17 @@ describe("Schema: Fields", () => { .find("input") .should("have.value", "default value"); + // Set min/max character limits + cy.getBySelector(SELECTORS.CHARACTER_LIMIT_CHECKBOX).click(); + cy.getBySelector(SELECTORS.MAX_CHARACTER_LIMIT_INPUT).clear().type("10000"); + cy.getBySelector(SELECTORS.MAX_CHARACTER_ERROR_MSG).should("exist"); + cy.getBySelector(SELECTORS.MAX_CHARACTER_LIMIT_INPUT).clear().type("20"); + cy.getBySelector(SELECTORS.MAX_CHARACTER_ERROR_MSG).should("not.exist"); + cy.getBySelector(SELECTORS.MIN_CHARACTER_LIMIT_INPUT).clear().type("10000"); + cy.getBySelector(SELECTORS.MIN_CHARACTER_ERROR_MSG).should("exist"); + cy.getBySelector(SELECTORS.MIN_CHARACTER_LIMIT_INPUT).clear().type("5"); + cy.getBySelector(SELECTORS.MIN_CHARACTER_ERROR_MSG).should("not.exist"); + // Click done cy.getBySelector(SELECTORS.SAVE_FIELD_BUTTON).should("exist").click(); cy.getBySelector(SELECTORS.ADD_FIELD_MODAL).should("not.exist"); diff --git a/src/apps/content-editor/src/app/components/Editor/Editor.js b/src/apps/content-editor/src/app/components/Editor/Editor.js index 0877fd7e53..f523c14cce 100644 --- a/src/apps/content-editor/src/app/components/Editor/Editor.js +++ b/src/apps/content-editor/src/app/components/Editor/Editor.js @@ -80,23 +80,19 @@ export default memo(function Editor({ throw new Error("Input is missing name attribute"); } - const isFieldRequired = activeFields.find( - (field) => field.name === name - )?.required; - const fieldDatatype = activeFields.find( - (field) => field.name === name - )?.datatype; - const fieldMaxLength = MaxLengths[fieldDatatype]; + const field = activeFields?.find((field) => field.name === name); + const fieldMaxLength = + field?.settings?.maxCharLimit ?? MaxLengths[field?.datatype]; const errors = cloneDeep(fieldErrors); // Remove the required field error message when a value has been added - if (isFieldRequired) { - if (fieldDatatype === "yes_no" && value !== null) { + if (field?.required) { + if (field?.datatype === "yes_no" && value !== null) { errors[name] = { ...(errors[name] ?? {}), MISSING_REQUIRED: false, }; - } else if (fieldDatatype !== "yes_no" && value) { + } else if (field?.datatype !== "yes_no" && value) { errors[name] = { ...(errors[name] ?? {}), MISSING_REQUIRED: false, @@ -116,6 +112,48 @@ export default memo(function Editor({ } } + if (field?.settings?.minCharLimit) { + if (value.length < field?.settings?.minCharLimit) { + errors[name] = { + ...(errors[name] ?? []), + LACKING_MINLENGTH: field?.settings?.minCharLimit - value.length, + }; + } else { + errors[name] = { ...(errors[name] ?? []), LACKING_MINLENGTH: 0 }; + } + } + + if (field?.settings?.regexMatchPattern) { + const regex = new RegExp(field?.settings?.regexMatchPattern); + if (!regex.test(value)) { + errors[name] = { + ...(errors[name] ?? []), + REGEX_PATTERN_MISMATCH: field?.settings?.regexMatchErrorMessage, + }; + } else { + errors[name] = { + ...(errors[name] ?? []), + REGEX_PATTERN_MISMATCH: "", + }; + } + } + + if (field?.settings?.regexRestrictPattern) { + const regex = new RegExp(field?.settings?.regexRestrictPattern); + if (regex.test(value)) { + errors[name] = { + ...(errors[name] ?? []), + REGEX_RESTRICT_PATTERN_MATCH: + field?.settings?.regexRestrictErrorMessage, + }; + } else { + errors[name] = { + ...(errors[name] ?? []), + REGEX_RESTRICT_PATTERN_MATCH: "", + }; + } + } + onUpdateFieldErrors(errors); // Always dispatch the data update @@ -255,7 +293,10 @@ export default memo(function Editor({ item={item} langID={item?.meta?.langID} errors={fieldErrors[field.name]} - maxLength={MaxLengths[field.datatype]} + maxLength={ + field.settings?.maxCharLimit ?? MaxLengths[field.datatype] + } + minLength={field.settings?.minCharLimit ?? 0} /> ); diff --git a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx index 6d9bd2a04d..886b96dc93 100644 --- a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx +++ b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx @@ -168,6 +168,7 @@ type FieldProps = { onSave: () => void; errors: Error; maxLength: number; + minLength: number; }; export const Field = ({ ZUID, @@ -185,6 +186,7 @@ export const Field = ({ onSave, errors, maxLength, + minLength, }: FieldProps) => { const dispatch = useDispatch(); const allItems = useSelector((state: AppState) => state.content); @@ -289,6 +291,7 @@ export const Field = ({ } withLengthCounter maxLength={maxLength} + minLength={minLength} errors={errors} aiType="text" > @@ -371,6 +374,7 @@ export const Field = ({ errors={errors} aiType="paragraph" maxLength={maxLength} + minLength={minLength} > = { export type Error = { MISSING_REQUIRED?: boolean; EXCEEDING_MAXLENGTH?: number; + LACKING_MINLENGTH?: number; CUSTOM_ERROR?: string; + REGEX_PATTERN_MISMATCH?: string; + REGEX_RESTRICT_PATTERN_MATCH?: string; }; type FieldShellProps = { @@ -38,6 +43,7 @@ type FieldShellProps = { valueLength?: number; endLabel?: JSX.Element; maxLength?: number; + minLength?: number; withLengthCounter?: boolean; missingRequired?: boolean; onEditorChange?: (editorType: string) => void; @@ -52,6 +58,7 @@ export const FieldShell = ({ endLabel, valueLength, maxLength = 150, + minLength = 0, withLengthCounter = false, onEditorChange, editorType = "markdown", @@ -64,19 +71,57 @@ export const FieldShell = ({ const [anchorEl, setAnchorEl] = useState(null); const getErrorMessage = (errors: Error) => { + const errorMessages = []; + if (errors?.MISSING_REQUIRED) { - return "Required Field. Please enter a value."; + errorMessages.push("Required Field. Please enter a value."); } if (errors?.EXCEEDING_MAXLENGTH > 0) { - return `Exceeding by ${errors.EXCEEDING_MAXLENGTH} characters.`; + errorMessages.push( + `Exceeding by ${errors.EXCEEDING_MAXLENGTH} ${pluralizeWord( + "character", + errors.EXCEEDING_MAXLENGTH + )}.` + ); + } + + if (errors?.LACKING_MINLENGTH > 0) { + errorMessages.push( + `Requires ${errors.LACKING_MINLENGTH} more ${pluralizeWord( + "character", + errors.LACKING_MINLENGTH + )}.` + ); + } + + if (errors?.REGEX_PATTERN_MISMATCH) { + errorMessages.push(errors.REGEX_PATTERN_MISMATCH); + } + + if (errors?.REGEX_RESTRICT_PATTERN_MATCH) { + errorMessages.push(errors.REGEX_RESTRICT_PATTERN_MATCH); } if (errors?.CUSTOM_ERROR) { - return errors.CUSTOM_ERROR; + errorMessages.push(errors.CUSTOM_ERROR); } - return ""; + if (errorMessages.length === 0) { + return ""; + } + + if (errorMessages.length === 1) { + return errorMessages[0]; + } + + return ( + + {errorMessages.map((msg) => ( +
  • {msg}
  • + ))} +
    + ); }; const isCreateNewItemPage = location?.pathname?.split("/")?.pop() === "new"; @@ -166,6 +211,8 @@ export const FieldShell = ({ {withLengthCounter && ( {valueLength} + {!!minLength && + ` (min. ${minLength} ${pluralizeWord("character", minLength)})`} {!!maxLength && `/${maxLength}`} )} diff --git a/src/apps/content-editor/src/app/components/Editor/Field/FieldTooltipBody.tsx b/src/apps/content-editor/src/app/components/Editor/Field/FieldTooltipBody.tsx index 88495656c0..74e26fde43 100644 --- a/src/apps/content-editor/src/app/components/Editor/Field/FieldTooltipBody.tsx +++ b/src/apps/content-editor/src/app/components/Editor/Field/FieldTooltipBody.tsx @@ -18,7 +18,10 @@ import moment from "moment-timezone"; import { ContentModelField } from "../../../../../../../shell/services/types"; import { FieldIcon } from "../../../../../../schema/src/app/components/Field/FieldIcon"; -import { TYPE_TEXT } from "../../../../../../schema/src/app/components/configs"; +import { + TYPE_TEXT, + FieldType, +} from "../../../../../../schema/src/app/components/configs"; type FieldTooltipBodyProps = { data: Partial; @@ -45,7 +48,7 @@ export const FieldTooltipBody = ({ data }: FieldTooltipBodyProps) => { {data?.label} {data?.required && "*"} - {TYPE_TEXT[data?.datatype]} Field + {TYPE_TEXT[data?.datatype as FieldType]} Field
    diff --git a/src/apps/content-editor/src/app/components/Editor/FieldError.tsx b/src/apps/content-editor/src/app/components/Editor/FieldError.tsx index b14aa8809b..2ebb11b7fe 100644 --- a/src/apps/content-editor/src/app/components/Editor/FieldError.tsx +++ b/src/apps/content-editor/src/app/components/Editor/FieldError.tsx @@ -4,12 +4,53 @@ import DangerousRoundedIcon from "@mui/icons-material/DangerousRounded"; import { theme } from "@zesty-io/material"; import { Error } from "./Field/FieldShell"; import { ContentModelField } from "../../../../../../shell/services/types"; +import pluralizeWord from "../../../../../../utility/pluralizeWord"; type FieldErrorProps = { errors: Record; fields: ContentModelField[]; }; +const getErrorMessage = (errors: Error) => { + const errorMessages = []; + + if (errors?.MISSING_REQUIRED) { + errorMessages.push("Required Field. Please enter a value."); + } + + if (errors?.EXCEEDING_MAXLENGTH > 0) { + errorMessages.push( + `Exceeding by ${errors.EXCEEDING_MAXLENGTH} ${pluralizeWord( + "character", + errors.EXCEEDING_MAXLENGTH + )}.` + ); + } + + if (errors?.LACKING_MINLENGTH > 0) { + errorMessages.push( + `Requires ${errors.LACKING_MINLENGTH} more ${pluralizeWord( + "character", + errors.LACKING_MINLENGTH + )}.` + ); + } + + if (errors?.REGEX_PATTERN_MISMATCH) { + errorMessages.push(errors.REGEX_PATTERN_MISMATCH); + } + + if (errors?.REGEX_RESTRICT_PATTERN_MATCH) { + errorMessages.push(errors.REGEX_RESTRICT_PATTERN_MATCH); + } + + if (errors?.CUSTOM_ERROR) { + errorMessages.push(errors.CUSTOM_ERROR); + } + + return errorMessages; +}; + export const FieldError = ({ errors, fields }: FieldErrorProps) => { const errorContainerEl = useRef(null); @@ -23,22 +64,14 @@ export const FieldError = ({ errors, fields }: FieldErrorProps) => { }, []); const fieldErrors = useMemo(() => { - const errorMap = Object.entries(errors)?.map(([name, errors]) => { - let errorMessage = ""; - - if (errors?.MISSING_REQUIRED) { - errorMessage = "Required Field. Please enter a value."; - } else if (errors?.EXCEEDING_MAXLENGTH > 0) { - errorMessage = `Exceeding by ${errors.EXCEEDING_MAXLENGTH} characters.`; - } else { - errorMessage = ""; - } + const errorMap = Object.entries(errors)?.map(([name, errorDetails]) => { + const errorMessages = getErrorMessage(errorDetails); const fieldData = fields?.find((field) => field.name === name); return { label: fieldData?.label, - errorMessage, + errorMessages, sort: fieldData?.sort, ZUID: fieldData?.ZUID, }; @@ -47,7 +80,9 @@ export const FieldError = ({ errors, fields }: FieldErrorProps) => { return errorMap.sort((a, b) => a.sort - b.sort); }, [errors, fields]); - const fieldsWithErrors = fieldErrors?.filter((error) => error.errorMessage); + const fieldsWithErrors = fieldErrors?.filter( + (error) => error.errorMessages.length > 0 + ); const handleErrorClick = (fieldZUID: string) => { const fieldElement = document.getElementById(fieldZUID); @@ -57,6 +92,7 @@ export const FieldError = ({ errors, fields }: FieldErrorProps) => { return ( { {fieldErrors?.map((error, index) => { - if (error.errorMessage) { + if (error.errorMessages.length > 0) { return ( { onClick={() => handleErrorClick(error.ZUID)} > {error.label} - {" "} - - {error.errorMessage} + + {error.errorMessages.length === 1 ? ( + - {error.errorMessages[0]} + ) : ( + + {error.errorMessages.map((msg, idx) => ( +
  • {msg}
  • + ))} +
    + )} ); } 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 0ca37e185c..2b4159dbfd 100644 --- a/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx +++ b/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx @@ -146,12 +146,16 @@ export const ItemCreate = () => { }; const save = async (action: ActionAfterSave) => { - setSaving(true); setSaveClicked(true); + + if (hasErrors) return; + + setSaving(true); + try { const res: any = await dispatch(createItem(modelZUID, itemZUID)); if (res.err || res.error) { - if (res.missingRequired) { + if (res.missingRequired || res.lackingCharLength) { const missingRequiredFieldNames: string[] = res.missingRequired?.reduce( (acc: string[], curr: ContentModelField) => { @@ -161,9 +165,9 @@ export const ItemCreate = () => { [] ); - if (missingRequiredFieldNames?.length) { - const errors = cloneDeep(fieldErrors); + const errors = cloneDeep(fieldErrors); + if (missingRequiredFieldNames?.length) { missingRequiredFieldNames?.forEach((fieldName) => { errors[fieldName] = { ...(errors[fieldName] ?? {}), @@ -171,17 +175,50 @@ export const ItemCreate = () => { }; }); - setFieldErrors(errors); + dispatch( + notify({ + message: "Missing Data in Required Fields", + kind: "error", + }) + ); } - dispatch( - notify({ - message: "Missing Data in Required Fields", - kind: "error", - }) - ); + + // Map min length validation errors + if (res.lackingCharLength?.length) { + res.lackingCharLength?.forEach((field: ContentModelField) => { + errors[field.name] = { + ...(errors[field.name] ?? {}), + LACKING_MINLENGTH: field.settings?.minCharLimit, + }; + }); + } + + if (res.regexPatternMismatch?.length) { + res.regexPatternMismatch?.forEach((field: ContentModelField) => { + errors[field.name] = { + ...(errors[field.name] ?? {}), + REGEX_PATTERN_MISMATCH: field.settings?.regexMatchErrorMessage, + }; + }); + } + + if (res.regexRestrictPatternMatch?.length) { + res.regexRestrictPatternMatch?.forEach( + (field: ContentModelField) => { + errors[field.name] = { + ...(errors[field.name] ?? {}), + REGEX_RESTRICT_PATTERN_MATCH: + field.settings?.regexRestrictErrorMessage, + }; + } + ); + } + + setFieldErrors(errors); // scroll to required field } + if (res.error) { dispatch( notify({ diff --git a/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js b/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js index 6c00271223..fdf7637b70 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js +++ b/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js @@ -223,11 +223,14 @@ export default function ItemEdit() { } async function save() { - setSaving(true); setSaveClicked(true); + + if (hasErrors) return; + + setSaving(true); try { const res = await dispatch(saveItem(itemZUID)); - if (res.err === "MISSING_REQUIRED") { + if (res.err === "VALIDATION_ERROR") { const missingRequiredFieldNames = res.missingRequired?.reduce( (acc, curr) => { acc = [curr.name, ...acc]; @@ -236,9 +239,9 @@ export default function ItemEdit() { [] ); - if (missingRequiredFieldNames?.length) { - const errors = cloneDeep(fieldErrors); + const errors = cloneDeep(fieldErrors); + if (missingRequiredFieldNames?.length) { missingRequiredFieldNames?.forEach((fieldName) => { errors[fieldName] = { ...(errors[fieldName] ?? {}), @@ -246,16 +249,45 @@ export default function ItemEdit() { }; }); - setFieldErrors(errors); + dispatch( + notify({ + heading: `Cannot Save: ${item.web.metaTitle}`, + message: "Missing Data in Required Fields", + kind: "error", + }) + ); } - dispatch( - notify({ - heading: `Cannot Save: ${item.web.metaTitle}`, - message: "Missing Data in Required Fields", - kind: "error", - }) - ); + // Map min length validation errors + if (res.lackingCharLength?.length) { + res.lackingCharLength?.forEach((field) => { + errors[field.name] = { + ...(errors[field.name] ?? {}), + LACKING_MINLENGTH: field.settings?.minCharLimit, + }; + }); + } + + if (res.regexPatternMismatch?.length) { + res.regexPatternMismatch?.forEach((field) => { + errors[field.name] = { + ...(errors[field.name] ?? {}), + REGEX_PATTERN_MISMATCH: field.settings?.regexMatchErrorMessage, + }; + }); + } + + if (res.regexRestrictPatternMatch?.length) { + res.regexRestrictPatternMatch?.forEach((field) => { + errors[field.name] = { + ...(errors[field.name] ?? {}), + REGEX_RESTRICT_PATTERN_MATCH: + field.settings?.regexRestrictErrorMessage, + }; + }); + } + + setFieldErrors(errors); return; } if (res.status === 400) { diff --git a/src/apps/schema/src/app/components/AddFieldModal/CharacterLimit.tsx b/src/apps/schema/src/app/components/AddFieldModal/CharacterLimit.tsx new file mode 100644 index 0000000000..127a3085cc --- /dev/null +++ b/src/apps/schema/src/app/components/AddFieldModal/CharacterLimit.tsx @@ -0,0 +1,157 @@ +import { + Stack, + Box, + Checkbox, + FormControlLabel, + Typography, + FormControl, + FormHelperText, +} from "@mui/material"; +import { FieldTypeNumber } from "../../../../../../shell/components/FieldTypeNumber"; +import { MaxLengths } from "../../../../../content-editor/src/app/components/Editor/Editor"; +import { Errors } from "./views/FieldForm"; + +type CharacterLimitProps = { + type: "text" | "textarea"; + isCharacterLimitEnabled: boolean; + onToggleCharacterLimitState: (enabled: boolean) => void; + onChange: ({ + inputName, + value, + }: { + inputName: string; + value: number; + }) => void; + minValue: number; + maxValue: number; + errors: Errors; +}; + +export const CharacterLimit = ({ + type, + isCharacterLimitEnabled, + onToggleCharacterLimitState, + onChange, + minValue = 0, + maxValue = 150, + errors, +}: CharacterLimitProps) => { + return ( + + { + onToggleCharacterLimitState(evt.target.checked); + if (evt.target.checked) { + onChange({ inputName: "minCharLimit", value: 0 }); + onChange({ + inputName: "maxCharLimit", + value: type === "textarea" ? 16000 : 150, + }); + } else { + onChange({ inputName: "minCharLimit", value: null }); + onChange({ inputName: "maxCharLimit", value: null }); + } + }} + /> + } + label={ + + + Limit Character Count + + + Set a minimum and/or maximum allowed number of characters + + + } + /> + {isCharacterLimitEnabled && ( + + + { + if (value >= 0) { + onChange({ inputName: "minCharLimit", value }); + } + }} + name="minimum" + hasError={!!errors?.minCharLimit} + allowNegative={false} + /> + } + label={ + + Minimum character count (with spaces) + + } + /> + {!!errors?.minCharLimit && ( + + {errors?.minCharLimit} + + )} + + + { + if (value >= 0) { + onChange({ inputName: "maxCharLimit", value }); + } + }} + name="maximum" + hasError={!!errors?.maxCharLimit} + allowNegative={false} + /> + } + label={ + + Maximum character count (with spaces) + + } + /> + {!!errors?.maxCharLimit && ( + + {errors?.maxCharLimit} + + )} + + + )} + + ); +}; diff --git a/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx b/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx index 92800ae187..77a721da3b 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx @@ -41,7 +41,13 @@ export type FieldNames = | "group_id" | "limit" | "tooltip" - | "defaultValue"; + | "defaultValue" + | "maxCharLimit" + | "minCharLimit" + | "regexMatchPattern" + | "regexMatchErrorMessage" + | "regexRestrictPattern" + | "regexRestrictErrorMessage"; type FieldType = | "input" | "checkbox" diff --git a/src/apps/schema/src/app/components/AddFieldModal/Learn.tsx b/src/apps/schema/src/app/components/AddFieldModal/Learn.tsx index cef3636b5d..77d0639c1a 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/Learn.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/Learn.tsx @@ -1,11 +1,16 @@ import { useEffect, useState } from "react"; import { Box, Typography } from "@mui/material"; -import { TYPE_TEXT, FIELD_COPY_CONFIG, FieldListData } from "../configs"; +import { + TYPE_TEXT, + FIELD_COPY_CONFIG, + FieldListData, + FieldType, +} from "../configs"; import { stringStartsWithVowel, getCategory } from "../../utils"; interface Props { - type: string; + type: FieldType; } export const Learn = ({ type }: Props) => { const category = getCategory(type); diff --git a/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx b/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx index 953ff49683..18223ec1a1 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx @@ -114,7 +114,6 @@ export const MediaRules = ({
    ); })} -
    ); diff --git a/src/apps/schema/src/app/components/AddFieldModal/Regex.tsx b/src/apps/schema/src/app/components/AddFieldModal/Regex.tsx new file mode 100644 index 0000000000..f6adf8ee13 --- /dev/null +++ b/src/apps/schema/src/app/components/AddFieldModal/Regex.tsx @@ -0,0 +1,446 @@ +import { + Box, + Checkbox, + FormControlLabel, + Typography, + Select, + InputLabel, + MenuItem, + TextField, + FormControl, + FormHelperText, + Link, + Tooltip, +} from "@mui/material"; +import { InfoRounded } from "@mui/icons-material"; +import { Errors } from "./views/FieldForm"; + +type RegexProps = { + type: "text" | "textarea"; + isCharacterLimitEnabled: boolean; + onToggleCharacterLimitState: (enabled: boolean) => void; + onChange: ({ + inputName, + value, + }: { + inputName: string; + value: string; + }) => void; + regexMatchPattern: string; + regexMatchErrorMessage: string; + regexRestrictPattern: string; + regexRestrictErrorMessage: string; + errors: Errors; +}; + +const regexTypePatternMap = { + custom: "", + url: "^(http://www\\.|https://www\\.|http://|https://)?[a-z0-9]+([-.][a-z0-9]+)*\\.[a-z]{2,5}(:[0-9]{1,5})?(\\/.*)?$", + slug: "^[a-z0-9]+(?:[-/][a-z0-9]+)*$", + email: + "[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", +} as const; + +const regexTypeErrorMessageMap = { + custom: "Not matching expected pattern", + url: "Must be a URL (e.g. https://www.google.com/)", + slug: "Must be a slug (e.g. everything-about-content-marketing)", + email: "Must be an email (e.g. hello@zesty.io)", +} as const; + +const regexTypeRestrictErrorMessageMap = { + custom: "Not matching expected pattern", + url: "Cannot be a URL (e.g. https://www.google.com/)", + slug: "Cannot be a slug (e.g. everything-about-content-marketing)", + email: "Cannot be an email (e.g. hello@zesty.io)", +} as const; + +export const Regex = ({ + onChange, + regexMatchPattern, + regexMatchErrorMessage, + regexRestrictPattern, + regexRestrictErrorMessage, + errors, +}: RegexProps) => { + return ( + + Regex Pattern Matching Rules + + Knowledge of Regular Expressions (Regex) is required. Entering incorrect + regex patterns may prevent content fields from accepting user inputs. + + { + if (evt.target.checked) { + onChange({ inputName: "regexMatchPattern", value: "" }); + onChange({ + inputName: "regexMatchErrorMessage", + value: regexTypeErrorMessageMap["custom"], + }); + } else { + onChange({ inputName: "regexMatchPattern", value: null }); + onChange({ inputName: "regexMatchErrorMessage", value: null }); + } + }} + /> + } + label={ + + + Match a specific pattern + + + Set the regular expression (e.g. email, URL, etc) the input should + match + + + } + /> + {regexMatchPattern !== null && ( + <> + + + + Type + + + + + + + + + + Pattern + + + + + + { + onChange({ + inputName: "regexMatchPattern", + value: evt.target.value, + }); + if ( + Object.keys(regexTypePatternMap).find( + (key) => + regexTypePatternMap[ + key as keyof typeof regexTypePatternMap + ] === regexMatchPattern + ) + ) { + onChange({ + inputName: "regexMatchErrorMessage", + value: regexTypeErrorMessageMap["custom"], + }); + } + }} + /> + {errors?.regexMatchPattern && ( + + Regex provided is not valid.
    Please{" "} + + review regex documentation on MDN. + +
    + )} +
    +
    + + + + + Custom Error Message * + + + + + + { + onChange({ + inputName: "regexMatchErrorMessage", + value: evt.target.value, + }); + }} + /> + {errors?.regexMatchErrorMessage} + + + + )} + { + if (evt.target.checked) { + onChange({ inputName: "regexRestrictPattern", value: "" }); + onChange({ + inputName: "regexRestrictErrorMessage", + value: regexTypeRestrictErrorMessageMap["custom"], + }); + } else { + onChange({ inputName: "regexRestrictPattern", value: null }); + onChange({ + inputName: "regexRestrictErrorMessage", + value: null, + }); + } + }} + /> + } + label={ + + + Restrict a specific pattern + + + Set the regular expression (e.g. email, URL, etc) the input should + not match + + + } + /> + {regexRestrictPattern !== null && ( + <> + + + + Type + + + + + + + + + + Pattern + + + + + + { + onChange({ + inputName: "regexRestrictPattern", + value: evt.target.value, + }); + if ( + Object.keys(regexTypePatternMap).find( + (key) => + regexTypePatternMap[ + key as keyof typeof regexTypePatternMap + ] === regexRestrictPattern + ) + ) { + onChange({ + inputName: "regexRestrictErrorMessage", + value: regexTypeRestrictErrorMessageMap["custom"], + }); + } + }} + /> + {errors?.regexRestrictPattern && ( + + Regex provided is not valid.
    Please{" "} + + review regex documentation on MDN. + +
    + )} +
    +
    + + + + + Custom Error Message * + + + + + + { + onChange({ + inputName: "regexRestrictErrorMessage", + value: evt.target.value, + }); + }} + /> + + {errors?.regexRestrictErrorMessage} + + + + + )} +
    + ); +}; diff --git a/src/apps/schema/src/app/components/AddFieldModal/index.tsx b/src/apps/schema/src/app/components/AddFieldModal/index.tsx index 16f2ce5cac..8d90e56657 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/index.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/index.tsx @@ -6,6 +6,7 @@ import { theme } from "@zesty-io/material"; import { FieldSelection } from "./views/FieldSelection"; import { FieldForm } from "./views/FieldForm"; import { useGetContentModelFieldsQuery } from "../../../../../../shell/services/instance"; +import { FieldType } from "../configs"; import { ContentModelFieldDataType } from "../../../../../../shell/services/types"; type Params = { @@ -38,7 +39,7 @@ export const AddFieldModal = ({ onModalClose, mode, sortIndex }: Props) => { return fields?.find((field) => field.ZUID === fieldId); }, [fieldId, fields]); - const handleFieldClick = (fieldType: string, fieldName: string) => { + const handleFieldClick = (fieldType: FieldType, fieldName: string) => { setViewMode("new_field"); setSelectedField({ fieldType, fieldName }); }; @@ -90,7 +91,7 @@ export const AddFieldModal = ({ onModalClose, mode, sortIndex }: Props) => { {viewMode === "update_field" && ( ; export interface FormData { [key: string]: FormValue; } -interface Errors { +export interface Errors { [key: string]: string | [string, string][]; } interface Props { @@ -198,6 +207,18 @@ export const FieldForm = ({ fieldData.settings.defaultValue !== undefined ? fieldData.settings.defaultValue : null; + } else if (field.name === "minCharLimit") { + formFields["minCharLimit"] = fieldData.settings?.minCharLimit ?? null; + } else if (field.name === "maxCharLimit") { + formFields["maxCharLimit"] = fieldData.settings?.maxCharLimit ?? null; + } else if (field.name === "regexMatchPattern") { + formFields[field.name] = fieldData.settings[field.name] || null; + } else if (field.name === "regexMatchErrorMessage") { + formFields[field.name] = fieldData.settings[field.name] || null; + } else if (field.name === "regexRestrictPattern") { + formFields[field.name] = fieldData.settings[field.name] || null; + } else if (field.name === "regexRestrictErrorMessage") { + formFields[field.name] = fieldData.settings[field.name] || null; } else { formFields[field.name] = fieldData[field.name] as FormValue; } @@ -215,7 +236,15 @@ export const FieldForm = ({ } else if (field.type === "toggle_options") { formFields[field.name] = [{ 0: "No" }, { 1: "Yes" }]; } else { - if (field.name === "defaultValue") { + if ( + field.name === "defaultValue" || + field.name === "minCharLimit" || + field.name === "maxCharLimit" || + field.name === "regexMatchPattern" || + field.name === "regexMatchErrorMessage" || + field.name === "regexRestrictPattern" || + field.name === "regexRestrictErrorMessage" + ) { formFields[field.name] = null; } else { formFields[field.name] = ""; @@ -284,7 +313,76 @@ export const FieldForm = ({ ) { newErrorsObj[inputName] = "Required Field. Please enter a value."; } - if (inputName in errors && inputName !== "defaultValue") { + + if (type === "text" || type === "textarea") { + if (inputName === "minCharLimit" && !isNaN(+formData.minCharLimit)) { + if ((formData.minCharLimit as number) > MaxLengths[type]) { + newErrorsObj[ + inputName + ] = `Cannot exceed ${MaxLengths[type]} characters`; + } else if (formData.minCharLimit > formData.maxCharLimit) { + newErrorsObj[inputName] = "Cannot exceed maximum character count"; + } + } + + if ( + inputName === "maxCharLimit" && + !isNaN(+formData.maxCharLimit) && + (formData.maxCharLimit as number) > MaxLengths[type] + ) { + newErrorsObj[ + inputName + ] = `Cannot exceed ${MaxLengths[type]} characters`; + } + + if (inputName === "regexMatchPattern" && formData.regexMatchPattern) { + try { + new RegExp(formData.regexMatchPattern as string); + } catch (e) { + newErrorsObj[inputName] = "Invalid regex pattern"; + } + } + + if ( + inputName === "regexMatchErrorMessage" && + formData.regexMatchPattern !== null && + formData.regexMatchErrorMessage === "" + ) { + newErrorsObj[inputName] = "Required Field. Please enter a value."; + } + + if ( + inputName === "regexRestrictPattern" && + formData.regexRestrictPattern + ) { + try { + new RegExp(formData.regexRestrictPattern as string); + } catch (e) { + newErrorsObj[inputName] = "Invalid regex pattern"; + } + } + + if ( + inputName === "regexRestrictErrorMessage" && + formData.regexRestrictPattern !== null && + formData.regexRestrictErrorMessage === "" + ) { + newErrorsObj[inputName] = "Required Field. Please enter a value."; + } + } + + if ( + inputName in errors && + ![ + "defaultValue", + "minCharLimit", + "maxCharLimit", + "regexMatchPattern", + "regexRestrictPattern", + "regexMatchErrorMessage", + "regexRestrictErrorMessage", + ].includes(inputName) + ) { const { maxLength, label, validate } = FORM_CONFIG[type].details.find( (field) => field.name === inputName ); @@ -377,7 +475,15 @@ export const FieldForm = ({ if (hasErrors) { // Switch the active tab to details to show the user the errors if // they're not on the details tab and they clicked the submit button - if (errors.defaultValue) { + if ( + errors.defaultValue || + errors.minCharLimit || + errors.maxCharLimit || + errors.regexMatchPattern || + errors.regexMatchErrorMessage || + errors.regexRestrictPattern || + errors.regexRestrictErrorMessage + ) { setActiveTab("rules"); } else { setActiveTab("details"); @@ -405,6 +511,25 @@ export const FieldForm = ({ tooltip: formData.tooltip as string, }), defaultValue: formData.defaultValue as string, + ...(formData.maxCharLimit !== null && { + maxCharLimit: formData.maxCharLimit as number, + }), + ...(formData.minCharLimit !== null && { + minCharLimit: formData.minCharLimit as number, + }), + ...(formData.regexMatchPattern && { + regexMatchPattern: formData.regexMatchPattern as string, + }), + ...(formData.regexMatchErrorMessage && { + regexMatchErrorMessage: formData.regexMatchErrorMessage as string, + }), + ...(formData.regexRestrictPattern && { + regexRestrictPattern: formData.regexRestrictPattern as string, + }), + ...(formData.regexRestrictErrorMessage && { + regexRestrictErrorMessage: + formData.regexRestrictErrorMessage as string, + }), }, sort: isUpdateField ? fieldData.sort : sort, // Just use the length since sort starts at 0 }; @@ -662,39 +787,16 @@ export const FieldForm = ({ )} - {activeTab === "rules" && type === "images" && ( - - )} - - {activeTab === "rules" && type === "uuid" && } - - {activeTab === "rules" && type !== "uuid" && ( - { - handleFieldDataChange({ inputName: "defaultValue", value }); - }} + onFieldDataChanged={handleFieldDataChange} + mediaFoldersOptions={mediaFoldersOptions} + formData={formData} + isSubmitClicked={isSubmitClicked} + errors={errors} isDefaultValueEnabled={isDefaultValueEnabled} setIsDefaultValueEnabled={setIsDefaultValueEnabled} - error={isSubmitClicked && (errors["defaultValue"] as string)} - mediaRules={{ - limit: formData["limit"], - group_id: formData["group_id"], - }} - relationshipFields={{ - relatedModelZUID: formData["relatedModelZUID"] as string, - relatedFieldZUID: formData["relatedFieldZUID"] as string, - }} - options={formData["options"] as FieldSettingsOptions[]} /> )} diff --git a/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx b/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx new file mode 100644 index 0000000000..d72c265b08 --- /dev/null +++ b/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { Stack } from "@mui/material"; +import { FieldSettingsOptions } from "../../../../../../../shell/services/types"; + +import { FieldType, FORM_CONFIG } from "../../configs"; +import { CustomGroup } from "../../hooks/useMediaRules"; +import { CharacterLimit } from "../CharacterLimit"; +import { ComingSoon } from "../ComingSoon"; +import { DefaultValue } from "../DefaultValue"; +import { MediaRules } from "../MediaRules"; +import { Errors, FormData, FormValue } from "./FieldForm"; +import { Regex } from "../Regex"; + +type RulesProps = { + type: FieldType; + onFieldDataChanged: ({ + inputName, + value, + }: { + inputName: string; + value: FormValue; + }) => void; + mediaFoldersOptions: CustomGroup[]; + formData: FormData; + errors: Errors; + isSubmitClicked: boolean; + isDefaultValueEnabled: boolean; + setIsDefaultValueEnabled: (value: boolean) => void; +}; +export const Rules = ({ + type, + onFieldDataChanged, + mediaFoldersOptions, + formData, + errors, + isSubmitClicked, + isDefaultValueEnabled, + setIsDefaultValueEnabled, +}: RulesProps) => { + const [isCharacterLimitEnabled, setIsCharacterLimitEnabled] = useState( + formData?.minCharLimit !== null && formData?.maxCharLimit !== null + ); + + if (type === "uuid") { + return ; + } + + return ( + + {type === "images" && ( + + )} + + { + onFieldDataChanged({ inputName: "defaultValue", value }); + }} + isDefaultValueEnabled={isDefaultValueEnabled} + setIsDefaultValueEnabled={setIsDefaultValueEnabled} + error={isSubmitClicked && (errors["defaultValue"] as string)} + mediaRules={{ + limit: formData["limit"], + group_id: formData["group_id"], + }} + relationshipFields={{ + relatedModelZUID: formData["relatedModelZUID"] as string, + relatedFieldZUID: formData["relatedFieldZUID"] as string, + }} + options={formData["options"] as FieldSettingsOptions[]} + /> + + {(type === "text" || type === "textarea") && ( + <> + + setIsCharacterLimitEnabled(enabled) + } + onChange={onFieldDataChanged} + minValue={formData["minCharLimit"] as number} + maxValue={formData["maxCharLimit"] as number} + errors={errors} + /> + + setIsCharacterLimitEnabled(enabled) + } + onChange={onFieldDataChanged} + regexMatchPattern={formData["regexMatchPattern"] as string} + regexMatchErrorMessage={ + formData["regexMatchErrorMessage"] as string + } + regexRestrictPattern={formData["regexRestrictPattern"] as string} + regexRestrictErrorMessage={ + formData["regexRestrictErrorMessage"] as string + } + errors={errors} + /> + + )} + + ); +}; diff --git a/src/apps/schema/src/app/components/Field/index.tsx b/src/apps/schema/src/app/components/Field/index.tsx index 603a9b4b48..1873cec78c 100644 --- a/src/apps/schema/src/app/components/Field/index.tsx +++ b/src/apps/schema/src/app/components/Field/index.tsx @@ -27,7 +27,7 @@ import { useDeleteContentModelFieldMutation, useUndeleteContentModelFieldMutation, } from "../../../../../../shell/services/instance"; -import { TYPE_TEXT, SystemField } from "../configs"; +import { TYPE_TEXT, SystemField, FieldType } from "../configs"; import { notify } from "../../../../../../shell/store/notifications"; type Params = { @@ -286,7 +286,7 @@ export const Field = ({ - {TYPE_TEXT[field.datatype]} + {TYPE_TEXT[field.datatype as FieldType]} diff --git a/src/apps/schema/src/app/components/configs.ts b/src/apps/schema/src/app/components/configs.ts index dc4592ef63..074fbaa5ff 100644 --- a/src/apps/schema/src/app/components/configs.ts +++ b/src/apps/schema/src/app/components/configs.ts @@ -1,8 +1,31 @@ import { InputField } from "./AddFieldModal/FieldFormInput"; import { ContentModelField } from "../../../../../shell/services/types"; +export type FieldType = + | "text" + | "textarea" + | "wysiwyg_basic" + | "markdown" + | "images" + | "one_to_one" + | "one_to_many" + | "link" + | "internal_link" + | "number" + | "currency" + | "date" + | "datetime" + | "yes_no" + | "dropdown" + | "color" + | "sort" + | "uuid" + | "files" + | "fontawesome" + | "wysiwyg_advanced" + | "article_writer"; interface FieldListData { - type: string; + type: FieldType; name: string; shortDescription: string; description: string; @@ -305,7 +328,7 @@ const FIELD_COPY_CONFIG: { [key: string]: FieldListData[] } = { ], }; -const TYPE_TEXT: { [key: string]: string } = { +const TYPE_TEXT: Record = { article_writer: "Article Writer", color: "Color", currency: "Currency", @@ -404,7 +427,55 @@ const COMMON_RULES: InputField[] = [ }, ]; -const FORM_CONFIG: { [key: string]: FormConfig } = { +const CHARACTER_LIMIT_RULES: InputField[] = [ + { + name: "minCharLimit", + type: "input", + label: "Minimum character count (with spaces)", + required: false, + gridSize: 6, + }, + { + name: "maxCharLimit", + type: "input", + label: "Maximum character count (with spaces)", + required: false, + gridSize: 6, + }, +]; + +const REGEX_RULES: InputField[] = [ + { + name: "regexMatchPattern", + type: "input", + label: "Regex Match Pattern", + required: false, + gridSize: 6, + }, + { + name: "regexMatchErrorMessage", + type: "input", + label: "Regex Match Error Message", + required: false, + gridSize: 6, + }, + { + name: "regexRestrictPattern", + type: "input", + label: "Regex Restrict Pattern", + required: false, + gridSize: 6, + }, + { + name: "regexRestrictErrorMessage", + type: "input", + label: "Regex Restrict Error Message", + required: false, + gridSize: 6, + }, +]; + +const FORM_CONFIG: Record = { article_writer: { details: [...COMMON_FIELDS], rules: [...COMMON_RULES], @@ -547,11 +618,11 @@ const FORM_CONFIG: { [key: string]: FormConfig } = { }, text: { details: [...COMMON_FIELDS], - rules: [...COMMON_RULES], + rules: [...COMMON_RULES, ...CHARACTER_LIMIT_RULES, ...REGEX_RULES], }, textarea: { details: [...COMMON_FIELDS], - rules: [...COMMON_RULES], + rules: [...COMMON_RULES, ...CHARACTER_LIMIT_RULES, ...REGEX_RULES], }, uuid: { details: [...COMMON_FIELDS], diff --git a/src/shell/components/FieldTypeNumber.tsx b/src/shell/components/FieldTypeNumber.tsx index 5948b28d0e..aacf8169f1 100644 --- a/src/shell/components/FieldTypeNumber.tsx +++ b/src/shell/components/FieldTypeNumber.tsx @@ -11,6 +11,8 @@ type FieldTypeNumberProps = { value: number; onChange: (value: number, name: string) => void; hasError: boolean; + allowNegative?: boolean; + limit?: number; }; export const FieldTypeNumber = ({ required, @@ -18,6 +20,9 @@ export const FieldTypeNumber = ({ onChange, name, hasError, + allowNegative = true, + limit, + ...props }: FieldTypeNumberProps) => { const numberInputRef = useRef(null); @@ -45,11 +50,16 @@ export const FieldTypeNumber = ({ break; } - onChange(+integerFractionalSplit.join("."), name); + const newValue = +integerFractionalSplit.join("."); + + if (!limit || (limit && newValue <= limit)) { + onChange(newValue, name); + } }; return ( limit + ) { + evt.preventDefault(); + } }} error={hasError} InputProps={{ @@ -86,6 +106,7 @@ export const FieldTypeNumber = ({ inputProps: { thousandSeparator: true, valueIsNumericString: true, + allowNegative, }, }} /> diff --git a/src/shell/services/types.ts b/src/shell/services/types.ts index 19f457f056..9cf43a5dac 100644 --- a/src/shell/services/types.ts +++ b/src/shell/services/types.ts @@ -195,6 +195,12 @@ export interface FieldSettings { list: boolean; tooltip?: string; defaultValue?: string; + minCharLimit?: number; + maxCharLimit?: number; + regexMatchPattern?: string; + regexMatchErrorMessage?: string; + regexRestrictPattern?: string; + regexRestrictErrorMessage?: string; } export type ContentModelFieldValue = diff --git a/src/shell/store/content.js b/src/shell/store/content.js index 28c78d3dc7..c9c6441ac6 100644 --- a/src/shell/store/content.js +++ b/src/shell/store/content.js @@ -398,10 +398,45 @@ export function saveItem(itemZUID, action = "") { field.required && (item.data[field.name] === "" || item.data[field.name] === null) ); - if (missingRequired.length) { + + // Check minlength is satisfied + const lackingCharLength = fields?.filter( + (field) => + field.settings?.minCharLimit && + (item.data[field.name]?.length < field.settings?.minCharLimit || + !item.data[field.name]) + ); + + const regexPatternMismatch = fields?.filter( + (field) => + field.settings?.regexMatchPattern && + !new RegExp(field.settings?.regexMatchPattern).test( + item.data[field.name] + ) + ); + + const regexRestrictPatternMatch = fields?.filter( + (field) => + field.settings?.regexRestrictPattern && + new RegExp(field.settings?.regexRestrictPattern).test( + item.data[field.name] + ) + ); + + if ( + missingRequired?.length || + lackingCharLength?.length || + regexPatternMismatch?.length || + regexRestrictPatternMatch?.length + ) { return Promise.resolve({ - err: "MISSING_REQUIRED", - missingRequired, + err: "VALIDATION_ERROR", + ...(!!missingRequired?.length && { missingRequired }), + ...(!!lackingCharLength?.length && { lackingCharLength }), + ...(!!regexPatternMismatch?.length && { regexPatternMismatch }), + ...(!!regexRestrictPatternMatch?.length && { + regexRestrictPatternMatch, + }), }); } @@ -500,10 +535,45 @@ export function createItem(modelZUID, itemZUID) { } return false; }); - if (missingRequired.length) { + + // Check minlength is satisfied + const lackingCharLength = fields?.filter( + (field) => + field.settings?.minCharLimit && + (item.data[field.name]?.length < field.settings?.minCharLimit || + !item.data[field.name]) + ); + + const regexPatternMismatch = fields?.filter( + (field) => + field.settings?.regexMatchPattern && + !new RegExp(field.settings?.regexMatchPattern).test( + item.data[field.name] + ) + ); + + const regexRestrictPatternMatch = fields?.filter( + (field) => + field.settings?.regexRestrictPattern && + new RegExp(field.settings?.regexRestrictPattern).test( + item.data[field.name] + ) + ); + + if ( + missingRequired?.length || + lackingCharLength?.length || + regexPatternMismatch?.length || + regexRestrictPatternMatch?.length + ) { return Promise.resolve({ - err: "MISSING_REQUIRED", - missingRequired, + err: "VALIDATION_ERROR", + ...(!!missingRequired?.length && { missingRequired }), + ...(!!lackingCharLength?.length && { lackingCharLength }), + ...(!!regexPatternMismatch?.length && { regexPatternMismatch }), + ...(!!regexRestrictPatternMatch?.length && { + regexRestrictPatternMatch, + }), }); } From 5cd2bf0373a6cd133b4fa3f3370e2482a0a87b96 Mon Sep 17 00:00:00 2001 From: Andres Date: Thu, 27 Jun 2024 10:04:33 -0700 Subject: [PATCH 7/9] Vqa fixes and updates multi item table --- package-lock.json | 15 ++++++------- package.json | 2 +- .../views/ItemList/ConfirmPublishesDialog.tsx | 2 +- .../src/app/views/ItemList/ItemListEmpty.tsx | 8 ++++++- .../src/app/views/ItemList/ItemListTable.tsx | 7 +++++++ .../ItemList/TableCells/DropdownCell.tsx | 21 +++++++++++-------- .../views/ItemList/TableCells/VersionCell.tsx | 2 +- .../src/app/views/ItemList/index.tsx | 3 ++- 8 files changed, 39 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5d8907e238..73d29b28d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "manager-ui", "version": "1.0.1", "license": "Commons Clause License Condition v1.0", "dependencies": { @@ -30,7 +31,7 @@ "@tinymce/tinymce-react": "^4.3.0", "@welldone-software/why-did-you-render": "^6.1.1", "@zesty-io/core": "1.10.0", - "@zesty-io/material": "^0.15.1", + "@zesty-io/material": "^0.15.2", "chart.js": "^3.8.0", "chartjs-adapter-moment": "^1.0.1", "chartjs-plugin-datalabels": "^2.0.0", @@ -3911,9 +3912,9 @@ } }, "node_modules/@zesty-io/material": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.1.tgz", - "integrity": "sha512-idUgV5pSc5tdP0MixVAGdusP/zdyhJQnGqC4CjYHf6w6MoV5vlo0A02N+AdpGJ400o2PfiS03Drci3m7zLeHoA==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.2.tgz", + "integrity": "sha512-m5dLNBpZtPtXUlo57In+k2ldo8OtAUPLvjLATV7S4qC6GKVSZHpq4Sqvab3YZ8C2dskcHGTos4aOJicgI3/UKA==", "dependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", @@ -18396,9 +18397,9 @@ } }, "@zesty-io/material": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.1.tgz", - "integrity": "sha512-idUgV5pSc5tdP0MixVAGdusP/zdyhJQnGqC4CjYHf6w6MoV5vlo0A02N+AdpGJ400o2PfiS03Drci3m7zLeHoA==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.2.tgz", + "integrity": "sha512-m5dLNBpZtPtXUlo57In+k2ldo8OtAUPLvjLATV7S4qC6GKVSZHpq4Sqvab3YZ8C2dskcHGTos4aOJicgI3/UKA==", "requires": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", diff --git a/package.json b/package.json index 2a5032a5fd..8ec0d7b68e 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@tinymce/tinymce-react": "^4.3.0", "@welldone-software/why-did-you-render": "^6.1.1", "@zesty-io/core": "1.10.0", - "@zesty-io/material": "^0.15.1", + "@zesty-io/material": "^0.15.2", "chart.js": "^3.8.0", "chartjs-adapter-moment": "^1.0.1", "chartjs-plugin-datalabels": "^2.0.0", diff --git a/src/apps/content-editor/src/app/views/ItemList/ConfirmPublishesDialog.tsx b/src/apps/content-editor/src/app/views/ItemList/ConfirmPublishesDialog.tsx index 7f2bdb41ec..c45e9dd79e 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ConfirmPublishesDialog.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ConfirmPublishesDialog.tsx @@ -76,7 +76,7 @@ export const ConfirmPublishesModal = ({ }} data-cy="ConfirmPublishButton" > - Publish Items ({items.length}) + Publish Changes to ({items.length}) Items diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListEmpty.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListEmpty.tsx index 7f892c619e..1eaa8ecdaf 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListEmpty.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListEmpty.tsx @@ -7,7 +7,13 @@ export const ItemListEmpty = () => { const history = useHistory(); const { modelZUID } = useParams<{ modelZUID: string }>(); return ( - + Start Creating Content Now diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx index d93c920075..5919d0b3d6 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx @@ -139,6 +139,7 @@ const fieldTypeColumnConfigMap = { if (!params.value) return null; return new Intl.NumberFormat("en-US").format(params.value); }, + align: "right", }, currency: { width: 160, @@ -149,6 +150,7 @@ const fieldTypeColumnConfigMap = { currency: "USD", }).format(params.value); }, + align: "right", }, images: { width: 100, @@ -165,6 +167,7 @@ const fieldTypeColumnConfigMap = { alignItems: "center", justifyContent: "center", overflow: "hidden", + zIndex: -1, }} > @@ -178,6 +181,7 @@ const fieldTypeColumnConfigMap = { sx={{ backgroundColor: (theme) => theme.palette.grey[100], objectFit: "contain", + zIndex: -1, }} width="68px" height="58px" @@ -451,6 +455,9 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { "& .MuiDataGrid-cell:has([data-cy='sortCell'])": { padding: 0, }, + "& .MuiDataGrid-row.Mui-selected": { + borderBottom: (theme) => `2px solid ${theme.palette.primary.main}`, + }, }} /> ); diff --git a/src/apps/content-editor/src/app/views/ItemList/TableCells/DropdownCell.tsx b/src/apps/content-editor/src/app/views/ItemList/TableCells/DropdownCell.tsx index 8ef160d64f..7b557d3e05 100644 --- a/src/apps/content-editor/src/app/views/ItemList/TableCells/DropdownCell.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/TableCells/DropdownCell.tsx @@ -4,7 +4,6 @@ import { useGetContentModelFieldsQuery } from "../../../../../../../shell/servic import { GridRenderCellParams } from "@mui/x-data-grid-pro"; import { useState } from "react"; import { KeyboardArrowDownRounded } from "@mui/icons-material"; -import { ContentItem } from "../../../../../../../shell/services/types"; import { useStagedChanges } from "../StagedChangesContext"; export const DropDownCell = ({ params }: { params: GridRenderCellParams }) => { @@ -19,6 +18,16 @@ export const DropDownCell = ({ params }: { params: GridRenderCellParams }) => { updateStagedChanges(params.row.id, params.field, value); }; + const currVal = + stagedChanges?.[params.row.id]?.[params.field] === null || + !field?.settings?.options + ? "Select" + : field?.settings?.options?.[ + stagedChanges?.[params.row.id]?.[params.field] + ] || + field?.settings?.options?.[params?.value] || + "Select"; + return ( <> setAnchorEl(null)} @@ -80,6 +82,7 @@ export const DropDownCell = ({ params }: { params: GridRenderCellParams }) => { onClick={() => { handleChange(key); }} + selected={value === currVal} sx={{ textWrap: "wrap", wordBreak: "break-word", diff --git a/src/apps/content-editor/src/app/views/ItemList/TableCells/VersionCell.tsx b/src/apps/content-editor/src/app/views/ItemList/TableCells/VersionCell.tsx index 4e17d11b47..6e87e122a2 100644 --- a/src/apps/content-editor/src/app/views/ItemList/TableCells/VersionCell.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/TableCells/VersionCell.tsx @@ -33,7 +33,7 @@ export const VersionCell = ({ params }: { params: GridRenderCellParams }) => { { name: "offset", options: { - offset: [0, -8], + offset: [0, -10], }, }, ], diff --git a/src/apps/content-editor/src/app/views/ItemList/index.tsx b/src/apps/content-editor/src/app/views/ItemList/index.tsx index 6d74d02d76..4d194a83fc 100644 --- a/src/apps/content-editor/src/app/views/ItemList/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/index.tsx @@ -452,7 +452,8 @@ export const ItemList = () => { > No search results - Your filter "{search}" could not find any results + Your filter "{search}" could not find + any results Try adjusting your search. We suggest check all words From 03829f3b6068c8fefb7361539331a13968c813d7 Mon Sep 17 00:00:00 2001 From: Andres Date: Thu, 27 Jun 2024 10:11:34 -0700 Subject: [PATCH 8/9] Removed fixed menu item heights --- src/apps/schema/src/app/components/Filters/ModelType.tsx | 3 --- src/shell/components/Filters/GenericFilter.tsx | 3 --- 2 files changed, 6 deletions(-) diff --git a/src/apps/schema/src/app/components/Filters/ModelType.tsx b/src/apps/schema/src/app/components/Filters/ModelType.tsx index 5cdb6e8e2c..41b161afa8 100644 --- a/src/apps/schema/src/app/components/Filters/ModelType.tsx +++ b/src/apps/schema/src/app/components/Filters/ModelType.tsx @@ -62,9 +62,6 @@ export const ModelType: FC = ({ value, onChange }) => { } key={index} onClick={() => handleFilterSelect(filter)} - sx={{ - height: "40px", - }} > diff --git a/src/shell/components/Filters/GenericFilter.tsx b/src/shell/components/Filters/GenericFilter.tsx index 517009e8ba..27db5fbcba 100644 --- a/src/shell/components/Filters/GenericFilter.tsx +++ b/src/shell/components/Filters/GenericFilter.tsx @@ -59,9 +59,6 @@ export const GenericFilter: FC = ({ {options?.map((option, index) => ( handleFilterSelect(option.value)} selected={Boolean(value) ? option.value === value : index === 0} data-cy={`filter_value_${option.value}`} From fb592ced77141cea6bada767178eb0d192093da8 Mon Sep 17 00:00:00 2001 From: Andres Date: Thu, 27 Jun 2024 11:50:05 -0700 Subject: [PATCH 9/9] up tooltip widths --- .../src/app/views/ItemList/TableCells/VersionCell.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemList/TableCells/VersionCell.tsx b/src/apps/content-editor/src/app/views/ItemList/TableCells/VersionCell.tsx index 6e87e122a2..01c0437f07 100644 --- a/src/apps/content-editor/src/app/views/ItemList/TableCells/VersionCell.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/TableCells/VersionCell.tsx @@ -58,7 +58,7 @@ export const VersionCell = ({ params }: { params: GridRenderCellParams }) => { slotProps={{ popper: { style: { - width: 120, + width: 160, }, }, }} @@ -113,7 +113,7 @@ export const VersionCell = ({ params }: { params: GridRenderCellParams }) => { slotProps={{ popper: { style: { - width: isScheduledPublish ? 120 : 150, + width: 160, }, }, }}