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/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/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/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/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/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 = { @@ -36,6 +43,7 @@ type FieldShellProps = { valueLength?: number; endLabel?: JSX.Element; maxLength?: number; + minLength?: number; withLengthCounter?: boolean; missingRequired?: boolean; onEditorChange?: (editorType: string) => void; @@ -50,6 +58,7 @@ export const FieldShell = ({ endLabel, valueLength, maxLength = 150, + minLength = 0, withLengthCounter = false, onEditorChange, editorType = "markdown", @@ -58,24 +67,65 @@ export const FieldShell = ({ errors, withInteractiveTooltip = true, }: FieldShellProps) => { + const location = useLocation(); 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"; + return ( @@ -141,6 +191,7 @@ export const FieldShell = ({ )} {endLabel} + {!isCreateNewItemPage && } {settings?.description && ( @@ -160,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 601a8f6a50..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) { @@ -443,7 +475,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/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/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 ( @@ -178,6 +181,7 @@ const fieldTypeColumnConfigMap = { sx={{ backgroundColor: (theme) => theme.palette.grey[100], objectFit: "contain", + zIndex: -1, }} width="68px" height="58px" @@ -415,10 +419,11 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { !(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", @@ -450,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..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 @@ -33,7 +33,7 @@ export const VersionCell = ({ params }: { params: GridRenderCellParams }) => { { name: "offset", options: { - offset: [0, -8], + offset: [0, -10], }, }, ], @@ -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, }, }, }} 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..4d194a83fc 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 ); } @@ -437,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 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/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/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/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/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/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/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}`} 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..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 = @@ -526,3 +532,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; +}; 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, + }), }); }