From e69736b70a09614bb1305682d92682b4932e94d7 Mon Sep 17 00:00:00 2001 From: Andres Galindo Date: Mon, 22 Jul 2024 11:45:17 -0700 Subject: [PATCH 01/44] Enhancment/Grant app permissions to new system roles (#2863) --- src/shell/store/products.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/shell/store/products.js b/src/shell/store/products.js index 6b5b9befd3..c0e5fece04 100644 --- a/src/shell/store/products.js +++ b/src/shell/store/products.js @@ -20,6 +20,7 @@ export function fetchProducts() { // seo: 31-71cfc74-s30 case "31-71cfc74-0wn3r": case "31-71cfc74-4dm13": + case "31-71cfc74-4cc4dm13": data = [ "launchpad", "content", @@ -35,6 +36,7 @@ export function fetchProducts() { ]; break; case "31-71cfc74-d3v3l0p3r": + case "31-71cfc74-d3vc0n": data = [ "launchpad", "content", From 7398833b8b1839c813261cfaf86365433bd051cd Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Tue, 23 Jul 2024 11:15:51 +0800 Subject: [PATCH 02/44] [Content] Do not show edit template for datasets (#2870) Fixes #2823 ### Demo ![Screenshot 2024-07-23 105044](https://github.com/user-attachments/assets/e59e973d-75f8-4197-8c7e-48d2be3b5163) --- .../app/views/ItemList/ItemListActions.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListActions.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListActions.tsx index afb11d870b..ff38b2eff9 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListActions.tsx @@ -178,17 +178,19 @@ export const ItemListActions = forwardRef((props, ref) => { Edit Model - { - history.push(codePath); - }} - > - - - - Edit Template - + {!isDataset && ( + { + history.push(codePath); + }} + > + + + + Edit Template + + )} Date: Mon, 22 Jul 2024 22:38:26 -0700 Subject: [PATCH 03/44] Schema rules limit file type for media field (#2867) --- .../src/app/components/Editor/Field/Field.tsx | 1 + .../src/app/components/FieldTypeMedia.tsx | 116 ++++++- .../AddFieldModal/FieldFormInput.tsx | 4 +- .../components/AddFieldModal/MediaRules.tsx | 284 +++++++++++++++++- .../AddFieldModal/views/FieldForm.tsx | 37 ++- .../components/AddFieldModal/views/Rules.tsx | 3 + src/apps/schema/src/app/components/configs.ts | 14 + src/shell/services/types.ts | 3 + 8 files changed, 443 insertions(+), 19 deletions(-) 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 886b96dc93..d9a42aa90e 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 @@ -482,6 +482,7 @@ export const Field = ({ ), }); }} + settings={settings} name={name} onChange={onChange} lockedToGroupId={ diff --git a/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx b/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx index 6ea4f8af00..bec4d47bd3 100644 --- a/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx +++ b/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx @@ -53,6 +53,7 @@ type FieldTypeMediaProps = { hasError?: boolean; hideDrag?: boolean; lockedToGroupId: string | null; + settings?: any; }; export const FieldTypeMedia = ({ @@ -64,6 +65,7 @@ export const FieldTypeMedia = ({ hasError, hideDrag, lockedToGroupId, + settings, }: FieldTypeMediaProps) => { const [draggedIndex, setDraggedIndex] = useState(null); const [hoveredIndex, setHoveredIndex] = useState(null); @@ -77,6 +79,7 @@ export const FieldTypeMedia = ({ const [imageToReplace, setImageToReplace] = useState(""); const [isBynderOpen, setIsBynderOpen] = useState(false); const { data: rawInstanceSettings } = useGetInstanceSettingsQuery(); + const [selectionError, setSelectionError] = useState(""); const bynderPortalUrlSetting = rawInstanceSettings?.find( (setting) => setting.key === "bynder_portal_url" @@ -109,26 +112,92 @@ export const FieldTypeMedia = ({ }, [bynderTokenSetting]); const addZestyImage = (selectedImages: any[]) => { - const newImageZUIDs = selectedImages.map((image) => image.id); + const removedImages: any[] = []; + const filteredSelectedImages = selectedImages?.filter((selectedImage) => { + //remove any images that do not match the file extension + if (settings?.fileExtensions) { + if ( + settings?.fileExtensions?.includes( + `.${fileExtension(selectedImage.filename)}` + ) + ) { + return true; + } else { + removedImages.push(selectedImage); + return false; + } + } else { + return true; + } + }); + + if (removedImages.length) { + const filenames = removedImages.map((image) => image.filename); + const formattedFilenames = + filenames.length > 1 + ? filenames.slice(0, -1).join(", ") + " and " + filenames.slice(-1) + : filenames[0]; + + setSelectionError( + `Could not add ${formattedFilenames}. ${settings?.fileExtensionsErrorMessage}` + ); + } else { + setSelectionError(""); + } + + const newImageZUIDs = filteredSelectedImages?.map((image) => image.id); + // remove any duplicates const filteredImageZUIDs = newImageZUIDs.filter( (zuid) => !images.includes(zuid) ); + // Do not trigger onChange if no images are added + if (![...images, ...filteredImageZUIDs]?.length) return; + onChange([...images, ...filteredImageZUIDs].join(","), name); }; const addBynderAsset = (selectedAsset: any[]) => { if (images.length > limit) return; - const newBynderAssets = selectedAsset + const removedAssets: any[] = []; + const filteredBynderAssets = selectedAsset?.filter((asset) => { + if (settings?.fileExtensions) { + const assetExtension = `.${asset.extensions[0]}`; + if (settings?.fileExtensions?.includes(assetExtension)) { + return true; + } else { + removedAssets.push(asset); + return false; + } + } else { + return true; + } + }); + + if (removedAssets.length) { + const filenames = removedAssets.map((asset) => asset.name); + const formattedFilenames = + filenames.length > 1 + ? filenames.slice(0, -1).join(", ") + " and " + filenames.slice(-1) + : filenames[0]; + + setSelectionError( + `Could not add ${formattedFilenames}. ${settings?.fileExtensionsErrorMessage}` + ); + } else { + setSelectionError(""); + } + + const newBynderAssets = filteredBynderAssets .slice(0, limit - images.length) .map((asset) => asset.originalUrl); - const filteredBynderAssets = newBynderAssets.filter( + const filteredBynderAssetsUrls = newBynderAssets.filter( (asset) => !images.includes(asset) ); - onChange([...images, ...filteredBynderAssets].join(","), name); + onChange([...images, ...filteredBynderAssetsUrls].join(","), name); }; const removeImage = (imageId: string) => { @@ -146,6 +215,21 @@ export const FieldTypeMedia = ({ }); // if selected replacement image is already in the list of images, do nothing if (localImageZUIDs.includes(imageZUID)) return; + // if extension is not allowed set error message + if (settings?.fileExtensions) { + if ( + !settings?.fileExtensions?.includes( + `.${fileExtension(images[0].filename)}` + ) + ) { + setSelectionError( + `Could not replace. ${settings?.fileExtensionsErrorMessage}` + ); + return; + } else { + setSelectionError(""); + } + } const newImageZUIDs = localImageZUIDs.map((zuid) => { if (zuid === imageToReplace) { return imageZUID; @@ -153,6 +237,7 @@ export const FieldTypeMedia = ({ return zuid; }); + onChange(newImageZUIDs.join(","), name); }; @@ -160,6 +245,19 @@ export const FieldTypeMedia = ({ // Prevent adding bynder asset that has already been added if (localImageZUIDs.includes(selectedAsset.originalUrl)) return; + const assetExtension = `.${selectedAsset.extensions[0]}`; + if ( + settings?.fileExtensions && + !settings?.fileExtensions?.includes(assetExtension) + ) { + setSelectionError( + `Could not replace. ${settings?.fileExtensionsErrorMessage}` + ); + return; + } else { + setSelectionError(""); + } + const newImages = localImageZUIDs.map((image) => { if (image === imageToReplace) { return selectedAsset.originalUrl; @@ -323,6 +421,11 @@ export const FieldTypeMedia = ({ )} + {selectionError && ( + + {selectionError} + + )} setIsBynderOpen(false)}> @@ -421,6 +524,11 @@ export const FieldTypeMedia = ({ )} + {selectionError && ( + + {selectionError} + + )} {showFileModal && ( ; -type MediaFieldName = Extract; const MediaLabelsConfig: { [key in MediaFieldName]: { label: string; subLabel: string }; } = { @@ -24,8 +32,83 @@ const MediaLabelsConfig: { label: "Lock to a folder", subLabel: "Ensures files can only be selected from a specific folder", }, + fileExtensions: { + label: "Limit File Types", + subLabel: "Ensures only certain file types can be accepted", + }, }; +const ExtensionPresets = [ + { + label: "Images", + value: [".png", ".jpg", ".jpeg", ".svg", ".gif", ".tif", ".webp"], + }, + { + label: "Videos", + value: [ + ".mob", + ".avi", + ".wmv", + ".mp4", + ".mpeg", + ".mkv", + ".m4v", + ".mpg", + ".webm", + ], + }, + { + label: "Audios", + value: [ + ".mp3", + ".flac", + ".wav", + ".m4a", + ".aac", + ".ape", + ".opus", + ".aiff", + ".aif", + ], + }, + { + label: "Documents", + value: [".doc", ".pdf", ".docx", ".txt", ".rtf", ".odt", ".pages"], + }, + { + label: "Presentations", + value: [ + ".ppt", + ".pptx", + ".key", + ".odp", + ".pps", + ".ppsx", + ".sldx", + ".potx", + ".otp", + ".sxi", + ], + }, + { + label: "Spreadsheets", + value: [ + ".xls", + ".xlsx", + ".csv", + ".tsv", + ".numbers", + ".ods", + ".xlsm", + ".xlsb", + ".xlt", + ".xltx", + ], + }, +] as const; + +const RestrictedExtensions = [".exe", ".dmg"]; + interface Props { fieldConfig: InputField[]; groups: CustomGroup[]; @@ -37,13 +120,73 @@ interface Props { value: FormValue; }) => void; fieldData: { [key: string]: FormValue }; + errors: Errors; } + export const MediaRules = ({ fieldConfig, onDataChange, groups, fieldData, + errors, }: Props) => { + const [inputValue, setInputValue] = useState(""); + const [autoFill, setAutoFill] = useState( + !fieldData.fileExtensionsErrorMessage + ); + const [extensionsError, setExtensionsError] = useState(false); + + useEffect(() => { + if (autoFill) { + onDataChange({ + inputName: "fileExtensionsErrorMessage", + value: + "Only files with the following extensions are allowed: " + + (fieldData["fileExtensions"] as string[])?.join(", "), + }); + } + }, [autoFill, fieldData["fileExtensions"]]); + + const handleInputChange = ( + event: any, + newInputValue: string, + ruleName: string + ) => { + const formattedInput = "." + newInputValue.replace(/\./g, ""); + setInputValue(formattedInput); + }; + + const handleKeyDown = (event: any, ruleName: string) => { + if (event.key === "Enter" || event.key === "," || event.key === " ") { + event.preventDefault(); + const newOption = inputValue.toLowerCase().trim(); + if ( + newOption && + !(fieldData[ruleName] as string[]).includes(newOption) && + !RestrictedExtensions.includes(newOption) + ) { + onDataChange({ + inputName: ruleName, + value: [...(fieldData[ruleName] as string[]), newOption], + }); + setInputValue(""); + } + } + }; + + const handleDelete = (option: string, ruleName: string) => { + const newTags = (fieldData[ruleName] as string[]).filter( + (item) => item !== option + ); + if (!newTags.length) { + setExtensionsError(true); + } + onDataChange({ + inputName: ruleName, + value: newTags, + }); + }; + return ( {fieldConfig?.map((rule: InputField, key: number) => { - if (rule.name === "defaultValue") return; + if ( + rule.name === "defaultValue" || + rule.name === "fileExtensionsErrorMessage" + ) + return null; return ( @@ -101,7 +257,9 @@ export const MediaRules = ({ } /> - {Boolean(fieldData[rule.name]) && ( + {Boolean( + fieldData[rule.name] && rule.name !== "fileExtensions" + ) && ( )} + + {Boolean(fieldData[rule.name]) && rule.name === "fileExtensions" && ( + + Extensions * + ( + handleKeyDown(event, rule.name)} + /> + )} + onInputChange={(event, newInputValue) => + handleInputChange(event, newInputValue, rule.name) + } + renderTags={(tagValue, getTagProps) => + tagValue.map((option, index) => ( + handleDelete(option, rule.name)} + clickable={false} + sx={{ + backgroundColor: "common.white", + borderColor: "grey.300", + borderWidth: 1, + borderStyle: "solid", + }} + /> + )) + } + /> + {errors["fileExtensions"] && extensionsError && ( + + {errors["fileExtensions"]} + + )} + + Add: + {ExtensionPresets.map((preset) => ( + { + const newTags = fieldData[rule.name] as string[]; + const tags = new Set(newTags); + preset.value.forEach((tag) => tags.add(tag)); + onDataChange({ + inputName: rule.name, + value: Array.from(tags), + }); + }} + sx={{ + backgroundColor: "common.white", + borderColor: "grey.300", + borderWidth: 1, + borderStyle: "solid", + }} + /> + ))} + + + Custom Error Message * + + { + setAutoFill(false); + onDataChange({ + inputName: "fileExtensionsErrorMessage", + value: e.target.value, + }); + }} + /> + {errors["fileExtensionsErrorMessage"] && ( + + {errors["fileExtensionsErrorMessage"]} + + )} + + )} ); })} diff --git a/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx b/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx index c315404b73..810467613a 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx @@ -223,6 +223,10 @@ export const FieldForm = ({ formFields[field.name] = fieldData.settings[field.name] ?? null; } else if (field.name === "maxValue") { formFields[field.name] = fieldData.settings[field.name] ?? null; + } else if (field.name === "fileExtensions") { + formFields[field.name] = fieldData.settings[field.name] ?? null; + } else if (field.name === "fileExtensionsErrorMessage") { + formFields[field.name] = fieldData.settings[field.name] ?? null; } else { formFields[field.name] = fieldData[field.name] as FormValue; } @@ -249,7 +253,9 @@ export const FieldForm = ({ field.name === "regexRestrictPattern" || field.name === "regexRestrictErrorMessage" || field.name === "minValue" || - field.name === "maxValue" + field.name === "maxValue" || + field.name === "fileExtensions" || + field.name === "fileExtensionsErrorMessage" ) { formFields[field.name] = null; } else { @@ -393,6 +399,22 @@ export const FieldForm = ({ } } + if ( + inputName === "fileExtensions" && + formData.fileExtensions !== null && + !(formData.fileExtensions as string[])?.length + ) { + newErrorsObj[inputName] = "This field is required"; + } + + if ( + inputName === "fileExtensionsErrorMessage" && + formData.fileExtensions !== null && + formData.fileExtensionsErrorMessage === "" + ) { + newErrorsObj[inputName] = "This field is required"; + } + if ( inputName in errors && ![ @@ -405,6 +427,8 @@ export const FieldForm = ({ "regexRestrictErrorMessage", "minValue", "maxValue", + "fileExtensions", + "fileExtensionsErrorMessage", ].includes(inputName) ) { const { maxLength, label, validate } = FORM_CONFIG[type].details.find( @@ -508,7 +532,9 @@ export const FieldForm = ({ errors.regexRestrictPattern || errors.regexRestrictErrorMessage || errors.minValue || - errors.maxValue + errors.maxValue || + errors.fileExtensions || + errors.fileExtensionsErrorMessage ) { setActiveTab("rules"); } else { @@ -562,6 +588,13 @@ export const FieldForm = ({ ...(formData.maxValue !== null && { maxValue: formData.maxValue as number, }), + ...(formData.fileExtensions && { + fileExtensions: formData.fileExtensions as string[], + }), + ...(formData.fileExtensionsErrorMessage && { + fileExtensionsErrorMessage: + formData.fileExtensionsErrorMessage as string, + }), }, sort: isUpdateField ? fieldData.sort : sort, // Just use the length since sort starts at 0 }; diff --git a/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx b/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx index 8c7d473707..23c146ffc8 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx @@ -50,12 +50,15 @@ export const Rules = ({ {type === "images" && ( )} diff --git a/src/apps/schema/src/app/components/configs.ts b/src/apps/schema/src/app/components/configs.ts index 5854dded08..ec60c38746 100644 --- a/src/apps/schema/src/app/components/configs.ts +++ b/src/apps/schema/src/app/components/configs.ts @@ -556,6 +556,20 @@ const FORM_CONFIG: Record = { required: false, gridSize: 12, }, + { + name: "fileExtensions", + type: "input", + label: "File Extensions", + required: false, + gridSize: 12, + }, + { + name: "fileExtensionsErrorMessage", + type: "input", + label: "File extensions error message", + required: false, + gridSize: 12, + }, ...COMMON_RULES, ], }, diff --git a/src/shell/services/types.ts b/src/shell/services/types.ts index 62c7cd1aac..b31e07a159 100644 --- a/src/shell/services/types.ts +++ b/src/shell/services/types.ts @@ -203,12 +203,15 @@ export interface FieldSettings { regexRestrictErrorMessage?: string; minValue?: number; maxValue?: number; + fileExtensions?: string[]; + fileExtensionsErrorMessage?: string; } export type ContentModelFieldValue = | string | number | boolean + | string[] | FieldSettings | FieldSettingsOptions[]; From 4d91e56f2e78e80deac506dee31616b27aae8455 Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Tue, 23 Jul 2024 15:00:05 +0800 Subject: [PATCH 04/44] [Content] Do not show the model as a breadcrumb item when in multipage table view (#2861) Resolves #2829 ### Demo https://github.com/user-attachments/assets/24cb4229-e209-460e-a765-639e7cbc7a24 --------- Co-authored-by: Stuart Runyan --- .../src/app/components/ContentBreadcrumbs.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/apps/content-editor/src/app/components/ContentBreadcrumbs.tsx b/src/apps/content-editor/src/app/components/ContentBreadcrumbs.tsx index 3d4dcbd0ab..dfedaa2610 100644 --- a/src/apps/content-editor/src/app/components/ContentBreadcrumbs.tsx +++ b/src/apps/content-editor/src/app/components/ContentBreadcrumbs.tsx @@ -4,7 +4,7 @@ import { useGetContentNavItemsQuery, } from "../../../../../shell/services/instance"; import { Home } from "@zesty-io/material"; -import { useHistory, useParams } from "react-router"; +import { useHistory, useParams, useLocation } from "react-router"; import { useMemo } from "react"; import { ContentNavItem } from "../../../../../shell/services/types"; import { MODEL_ICON } from "../../../../../shell/constants"; @@ -18,8 +18,12 @@ export const ContentBreadcrumbs = () => { itemZUID: string; }>(); const history = useHistory(); + const location = useLocation(); const breadcrumbData = useMemo(() => { + const isInMultipageTableView = !["new", "import"].includes( + location?.pathname?.split("/")?.pop() + ); let activeItem: ContentNavItem; const crumbs = []; @@ -52,6 +56,12 @@ export const ContentBreadcrumbs = () => { parent = null; } } + + if (!itemZUID && isInMultipageTableView) { + // Remove the model as a breadcrumb item when viewing in multipage table view + crumbs?.pop(); + } + return crumbs.map((item) => ({ node: , onClick: () => { @@ -62,7 +72,7 @@ export const ContentBreadcrumbs = () => { } }, })); - }, [nav, itemZUID]); + }, [nav, itemZUID, modelZUID, location]); return ( Date: Tue, 23 Jul 2024 12:37:38 -0700 Subject: [PATCH 05/44] Fix/table sort by display date field by accounting for empty date values (#2864) Co-authored-by: Stuart Runyan --- .../content-editor/src/app/views/ItemList/index.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 8e572918f7..f3ee800eaa 100644 --- a/src/apps/content-editor/src/app/views/ItemList/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/index.tsx @@ -334,9 +334,16 @@ export const ItemList = () => { : b.data[sort] - a.data[sort]; } if (dataType === "date" || dataType === "datetime") { - return ( - new Date(b.data[sort]).getTime() - new Date(a.data[sort]).getTime() - ); + if (!a.data[sort]) { + return 1; + } else if (!b.data[sort]) { + return -1; + } else { + return ( + new Date(b.data[sort]).getTime() - + new Date(a.data[sort]).getTime() + ); + } } const aValue = dataType === "images" ? a.data[sort]?.filename : a.data[sort]; From b9acd9968a7dc209a842246b2c4c9163410b101d Mon Sep 17 00:00:00 2001 From: Andres Galindo Date: Wed, 24 Jul 2024 13:53:13 -0700 Subject: [PATCH 06/44] Reference createdAt timestamp outside of history object (#2868) --- src/shell/services/marketing.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shell/services/marketing.ts b/src/shell/services/marketing.ts index 3a0f367010..a23b553684 100644 --- a/src/shell/services/marketing.ts +++ b/src/shell/services/marketing.ts @@ -43,7 +43,7 @@ export const marketingApi = createApi({ video_link, start_date_and_time, end_date_and_time, - created_at: currVal?.version?.history?.data?.pop()?.createdAt, + created_at: currVal?.version?.createdAt, }, ]; } From 172758193909be7ff8d0fd9dde214f538f10fd62 Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Thu, 25 Jul 2024 13:00:56 +0800 Subject: [PATCH 07/44] [Schema | Content] Currency field revamp (#2873) - Allows users to select the default currency at a model level - Improved currency selection - Show selected currency in the content item Resolves #2455 Resolves #2454 Resolves #2790 ### Demo ![Screenshot 2024-07-24 080146](https://github.com/user-attachments/assets/411a7343-221e-46bf-9460-9457f38abcd0) ![Screenshot 2024-07-24 080228](https://github.com/user-attachments/assets/28e7ce4d-2f64-437b-8e47-60e6dceb565a) ![Screenshot 2024-07-24 080405](https://github.com/user-attachments/assets/13c66db9-bebf-4058-9024-88fe88abfd43) --- cypress/e2e/content/content.spec.js | 2 +- cypress/e2e/schema/field.spec.js | 40 + public/images/flags/ad.svg | 150 ++++ public/images/flags/ae.svg | 6 + public/images/flags/af.svg | 81 ++ public/images/flags/ag.svg | 14 + public/images/flags/ai.svg | 29 + public/images/flags/al.svg | 5 + public/images/flags/am.svg | 5 + public/images/flags/ao.svg | 13 + public/images/flags/aq.svg | 5 + public/images/flags/ar.svg | 32 + public/images/flags/arab.svg | 109 +++ public/images/flags/as.svg | 72 ++ public/images/flags/at.svg | 4 + public/images/flags/au.svg | 8 + public/images/flags/aw.svg | 186 ++++ public/images/flags/ax.svg | 18 + public/images/flags/az.svg | 8 + public/images/flags/ba.svg | 12 + public/images/flags/bb.svg | 6 + public/images/flags/bd.svg | 4 + public/images/flags/be.svg | 7 + public/images/flags/bf.svg | 7 + public/images/flags/bg.svg | 5 + public/images/flags/bh.svg | 4 + public/images/flags/bi.svg | 15 + public/images/flags/bj.svg | 14 + public/images/flags/bl.svg | 5 + public/images/flags/bm.svg | 97 ++ public/images/flags/bn.svg | 36 + public/images/flags/bo.svg | 674 ++++++++++++++ public/images/flags/bq.svg | 5 + public/images/flags/br.svg | 45 + public/images/flags/bs.svg | 13 + public/images/flags/bt.svg | 89 ++ public/images/flags/bv.svg | 13 + public/images/flags/bw.svg | 7 + public/images/flags/by.svg | 18 + public/images/flags/bz.svg | 145 +++ public/images/flags/ca.svg | 4 + public/images/flags/cc.svg | 19 + public/images/flags/cd.svg | 5 + public/images/flags/cefta.svg | 13 + public/images/flags/cf.svg | 15 + public/images/flags/cg.svg | 12 + public/images/flags/ch.svg | 9 + public/images/flags/ci.svg | 7 + public/images/flags/ck.svg | 9 + public/images/flags/cl.svg | 13 + public/images/flags/cm.svg | 15 + public/images/flags/cn.svg | 11 + public/images/flags/co.svg | 7 + public/images/flags/cp.svg | 7 + public/images/flags/cr.svg | 7 + public/images/flags/cu.svg | 13 + public/images/flags/cv.svg | 13 + public/images/flags/cw.svg | 14 + public/images/flags/cx.svg | 15 + public/images/flags/cy.svg | 6 + public/images/flags/cz.svg | 5 + public/images/flags/de.svg | 5 + public/images/flags/dg.svg | 130 +++ public/images/flags/dj.svg | 13 + public/images/flags/dk.svg | 5 + public/images/flags/dm.svg | 152 ++++ public/images/flags/do.svg | 121 +++ public/images/flags/dz.svg | 5 + public/images/flags/eac.svg | 48 + public/images/flags/ec.svg | 138 +++ public/images/flags/ee.svg | 5 + public/images/flags/eg.svg | 38 + public/images/flags/eh.svg | 16 + public/images/flags/er.svg | 8 + public/images/flags/es-ct.svg | 4 + public/images/flags/es-ga.svg | 187 ++++ public/images/flags/es-pv.svg | 5 + public/images/flags/es.svg | 544 +++++++++++ public/images/flags/et.svg | 14 + public/images/flags/eu.svg | 28 + public/images/flags/fi.svg | 5 + public/images/flags/fj.svg | 120 +++ public/images/flags/fk.svg | 90 ++ public/images/flags/fm.svg | 11 + public/images/flags/fo.svg | 12 + public/images/flags/fr.svg | 5 + public/images/flags/ga.svg | 7 + public/images/flags/gb-eng.svg | 5 + public/images/flags/gb-nir.svg | 132 +++ public/images/flags/gb-sct.svg | 4 + public/images/flags/gb-wls.svg | 9 + public/images/flags/gb.svg | 7 + public/images/flags/gd.svg | 27 + public/images/flags/ge.svg | 6 + public/images/flags/gf.svg | 5 + public/images/flags/gg.svg | 9 + public/images/flags/gh.svg | 6 + public/images/flags/gi.svg | 32 + public/images/flags/gl.svg | 4 + public/images/flags/gm.svg | 14 + public/images/flags/gn.svg | 7 + public/images/flags/gp.svg | 5 + public/images/flags/gq.svg | 23 + public/images/flags/gr.svg | 16 + public/images/flags/gs.svg | 133 +++ public/images/flags/gt.svg | 204 +++++ public/images/flags/gu.svg | 23 + public/images/flags/gw.svg | 13 + public/images/flags/gy.svg | 9 + public/images/flags/hk.svg | 8 + public/images/flags/hm.svg | 8 + public/images/flags/hn.svg | 18 + public/images/flags/hr.svg | 58 ++ public/images/flags/ht.svg | 116 +++ public/images/flags/hu.svg | 7 + public/images/flags/ic.svg | 7 + public/images/flags/id.svg | 4 + public/images/flags/ie.svg | 7 + public/images/flags/il.svg | 14 + public/images/flags/im.svg | 36 + public/images/flags/in.svg | 25 + public/images/flags/io.svg | 130 +++ public/images/flags/iq.svg | 10 + public/images/flags/ir.svg | 219 +++++ public/images/flags/is.svg | 12 + public/images/flags/it.svg | 7 + public/images/flags/je.svg | 62 ++ public/images/flags/jm.svg | 8 + public/images/flags/jo.svg | 16 + public/images/flags/jp.svg | 11 + public/images/flags/ke.svg | 23 + public/images/flags/kg.svg | 15 + public/images/flags/kh.svg | 61 ++ public/images/flags/ki.svg | 36 + public/images/flags/km.svg | 16 + public/images/flags/kn.svg | 14 + public/images/flags/kp.svg | 15 + public/images/flags/kr.svg | 24 + public/images/flags/kw.svg | 13 + public/images/flags/ky.svg | 103 +++ public/images/flags/kz.svg | 36 + public/images/flags/la.svg | 12 + public/images/flags/lb.svg | 15 + public/images/flags/lc.svg | 8 + public/images/flags/li.svg | 43 + public/images/flags/lk.svg | 22 + public/images/flags/lr.svg | 14 + public/images/flags/ls.svg | 8 + public/images/flags/lt.svg | 7 + public/images/flags/lu.svg | 5 + public/images/flags/lv.svg | 6 + public/images/flags/ly.svg | 13 + public/images/flags/ma.svg | 4 + public/images/flags/mc.svg | 6 + public/images/flags/md.svg | 70 ++ public/images/flags/me.svg | 116 +++ public/images/flags/mf.svg | 5 + public/images/flags/mg.svg | 7 + public/images/flags/mh.svg | 7 + public/images/flags/mk.svg | 5 + public/images/flags/ml.svg | 7 + public/images/flags/mm.svg | 12 + public/images/flags/mn.svg | 14 + public/images/flags/mo.svg | 9 + public/images/flags/mp.svg | 86 ++ public/images/flags/mq.svg | 5 + public/images/flags/mr.svg | 6 + public/images/flags/ms.svg | 29 + public/images/flags/mt.svg | 58 ++ public/images/flags/mu.svg | 8 + public/images/flags/mv.svg | 6 + public/images/flags/mw.svg | 10 + public/images/flags/mx.svg | 382 ++++++++ public/images/flags/my.svg | 26 + public/images/flags/mz.svg | 21 + public/images/flags/na.svg | 16 + public/images/flags/nc.svg | 13 + public/images/flags/ne.svg | 6 + public/images/flags/nf.svg | 9 + public/images/flags/ng.svg | 6 + public/images/flags/ni.svg | 129 +++ public/images/flags/nl.svg | 5 + public/images/flags/no.svg | 7 + public/images/flags/np.svg | 13 + public/images/flags/nr.svg | 12 + public/images/flags/nu.svg | 10 + public/images/flags/nz.svg | 36 + public/images/flags/om.svg | 115 +++ public/images/flags/pa.svg | 14 + public/images/flags/pc.svg | 33 + public/images/flags/pe.svg | 4 + public/images/flags/pf.svg | 19 + public/images/flags/pg.svg | 9 + public/images/flags/ph.svg | 6 + public/images/flags/pk.svg | 15 + public/images/flags/pl.svg | 6 + public/images/flags/pm.svg | 5 + public/images/flags/pn.svg | 53 ++ public/images/flags/pr.svg | 13 + public/images/flags/ps.svg | 15 + public/images/flags/pt.svg | 57 ++ public/images/flags/pw.svg | 11 + public/images/flags/py.svg | 157 ++++ public/images/flags/qa.svg | 4 + public/images/flags/re.svg | 5 + public/images/flags/ro.svg | 7 + public/images/flags/rs.svg | 292 ++++++ public/images/flags/ru.svg | 5 + public/images/flags/rw.svg | 13 + public/images/flags/sa.svg | 25 + public/images/flags/sb.svg | 13 + public/images/flags/sc.svg | 7 + public/images/flags/sd.svg | 13 + public/images/flags/se.svg | 4 + public/images/flags/sg.svg | 13 + public/images/flags/sh-ac.svg | 689 ++++++++++++++ public/images/flags/sh-hl.svg | 164 ++++ public/images/flags/sh-ta.svg | 76 ++ public/images/flags/sh.svg | 7 + public/images/flags/si.svg | 18 + public/images/flags/sj.svg | 7 + public/images/flags/sk.svg | 9 + public/images/flags/sl.svg | 7 + public/images/flags/sm.svg | 75 ++ public/images/flags/sn.svg | 8 + public/images/flags/so.svg | 11 + public/images/flags/sr.svg | 6 + public/images/flags/ss.svg | 8 + public/images/flags/st.svg | 16 + public/images/flags/sv.svg | 594 ++++++++++++ public/images/flags/sx.svg | 56 ++ public/images/flags/sy.svg | 6 + public/images/flags/sz.svg | 34 + public/images/flags/tc.svg | 50 ++ public/images/flags/td.svg | 7 + public/images/flags/tf.svg | 15 + public/images/flags/tg.svg | 14 + public/images/flags/th.svg | 7 + public/images/flags/tj.svg | 22 + public/images/flags/tk.svg | 5 + public/images/flags/tl.svg | 13 + public/images/flags/tm.svg | 204 +++++ public/images/flags/tn.svg | 4 + public/images/flags/to.svg | 10 + public/images/flags/tr.svg | 8 + public/images/flags/tt.svg | 5 + public/images/flags/tv.svg | 9 + public/images/flags/tw.svg | 34 + public/images/flags/tz.svg | 13 + public/images/flags/ua.svg | 6 + public/images/flags/ug.svg | 30 + public/images/flags/um.svg | 9 + public/images/flags/un.svg | 16 + public/images/flags/us.svg | 9 + public/images/flags/uy.svg | 28 + public/images/flags/uz.svg | 30 + public/images/flags/va.svg | 190 ++++ public/images/flags/vc.svg | 8 + public/images/flags/ve.svg | 26 + public/images/flags/vg.svg | 59 ++ public/images/flags/vi.svg | 28 + public/images/flags/vn.svg | 11 + public/images/flags/vu.svg | 21 + public/images/flags/wf.svg | 5 + public/images/flags/ws.svg | 7 + public/images/flags/xk.svg | 5 + public/images/flags/xx.svg | 4 + public/images/flags/ye.svg | 7 + public/images/flags/yt.svg | 5 + public/images/flags/za.svg | 17 + public/images/flags/zm.svg | 27 + public/images/flags/zw.svg | 21 + .../src/app/components/Editor/Field/Field.tsx | 5 +- .../ItemEditHeader/LanguageSelector.tsx | 17 +- .../src/app/views/ItemList/ItemListTable.tsx | 38 +- .../components/AddFieldModal/DefaultValue.tsx | 3 + .../AddFieldModal/DefaultValueInput.tsx | 9 +- .../AddFieldModal/FieldFormInput.tsx | 69 +- .../AddFieldModal/views/FieldForm.tsx | 96 ++ .../components/AddFieldModal/views/Rules.tsx | 1 + src/apps/schema/src/app/components/configs.ts | 19 +- .../FieldTypeCurrency/FieldTypeCurrency.js | 69 -- .../FieldTypeCurrency/FieldTypeCurrency.less | 52 -- .../{currencies.js => currencies.ts} | 845 ++++++++++-------- .../components/FieldTypeCurrency/index.js | 1 - .../components/FieldTypeCurrency/index.tsx | 64 ++ src/shell/components/FieldTypeNumber.tsx | 6 +- .../components/NumberFormatInput/index.tsx | 11 +- src/shell/services/types.ts | 1 + src/utility/getFlagEmoji.ts | 9 + 290 files changed, 11582 insertions(+), 535 deletions(-) create mode 100644 public/images/flags/ad.svg create mode 100644 public/images/flags/ae.svg create mode 100644 public/images/flags/af.svg create mode 100644 public/images/flags/ag.svg create mode 100644 public/images/flags/ai.svg create mode 100644 public/images/flags/al.svg create mode 100644 public/images/flags/am.svg create mode 100644 public/images/flags/ao.svg create mode 100644 public/images/flags/aq.svg create mode 100644 public/images/flags/ar.svg create mode 100644 public/images/flags/arab.svg create mode 100644 public/images/flags/as.svg create mode 100644 public/images/flags/at.svg create mode 100644 public/images/flags/au.svg create mode 100644 public/images/flags/aw.svg create mode 100644 public/images/flags/ax.svg create mode 100644 public/images/flags/az.svg create mode 100644 public/images/flags/ba.svg create mode 100644 public/images/flags/bb.svg create mode 100644 public/images/flags/bd.svg create mode 100644 public/images/flags/be.svg create mode 100644 public/images/flags/bf.svg create mode 100644 public/images/flags/bg.svg create mode 100644 public/images/flags/bh.svg create mode 100644 public/images/flags/bi.svg create mode 100644 public/images/flags/bj.svg create mode 100644 public/images/flags/bl.svg create mode 100644 public/images/flags/bm.svg create mode 100644 public/images/flags/bn.svg create mode 100644 public/images/flags/bo.svg create mode 100644 public/images/flags/bq.svg create mode 100644 public/images/flags/br.svg create mode 100644 public/images/flags/bs.svg create mode 100644 public/images/flags/bt.svg create mode 100644 public/images/flags/bv.svg create mode 100644 public/images/flags/bw.svg create mode 100644 public/images/flags/by.svg create mode 100644 public/images/flags/bz.svg create mode 100644 public/images/flags/ca.svg create mode 100644 public/images/flags/cc.svg create mode 100644 public/images/flags/cd.svg create mode 100644 public/images/flags/cefta.svg create mode 100644 public/images/flags/cf.svg create mode 100644 public/images/flags/cg.svg create mode 100644 public/images/flags/ch.svg create mode 100644 public/images/flags/ci.svg create mode 100644 public/images/flags/ck.svg create mode 100644 public/images/flags/cl.svg create mode 100644 public/images/flags/cm.svg create mode 100644 public/images/flags/cn.svg create mode 100644 public/images/flags/co.svg create mode 100644 public/images/flags/cp.svg create mode 100644 public/images/flags/cr.svg create mode 100644 public/images/flags/cu.svg create mode 100644 public/images/flags/cv.svg create mode 100644 public/images/flags/cw.svg create mode 100644 public/images/flags/cx.svg create mode 100644 public/images/flags/cy.svg create mode 100644 public/images/flags/cz.svg create mode 100644 public/images/flags/de.svg create mode 100644 public/images/flags/dg.svg create mode 100644 public/images/flags/dj.svg create mode 100644 public/images/flags/dk.svg create mode 100644 public/images/flags/dm.svg create mode 100644 public/images/flags/do.svg create mode 100644 public/images/flags/dz.svg create mode 100644 public/images/flags/eac.svg create mode 100644 public/images/flags/ec.svg create mode 100644 public/images/flags/ee.svg create mode 100644 public/images/flags/eg.svg create mode 100644 public/images/flags/eh.svg create mode 100644 public/images/flags/er.svg create mode 100644 public/images/flags/es-ct.svg create mode 100644 public/images/flags/es-ga.svg create mode 100644 public/images/flags/es-pv.svg create mode 100644 public/images/flags/es.svg create mode 100644 public/images/flags/et.svg create mode 100644 public/images/flags/eu.svg create mode 100644 public/images/flags/fi.svg create mode 100644 public/images/flags/fj.svg create mode 100644 public/images/flags/fk.svg create mode 100644 public/images/flags/fm.svg create mode 100644 public/images/flags/fo.svg create mode 100644 public/images/flags/fr.svg create mode 100644 public/images/flags/ga.svg create mode 100644 public/images/flags/gb-eng.svg create mode 100644 public/images/flags/gb-nir.svg create mode 100644 public/images/flags/gb-sct.svg create mode 100644 public/images/flags/gb-wls.svg create mode 100644 public/images/flags/gb.svg create mode 100644 public/images/flags/gd.svg create mode 100644 public/images/flags/ge.svg create mode 100644 public/images/flags/gf.svg create mode 100644 public/images/flags/gg.svg create mode 100644 public/images/flags/gh.svg create mode 100644 public/images/flags/gi.svg create mode 100644 public/images/flags/gl.svg create mode 100644 public/images/flags/gm.svg create mode 100644 public/images/flags/gn.svg create mode 100644 public/images/flags/gp.svg create mode 100644 public/images/flags/gq.svg create mode 100644 public/images/flags/gr.svg create mode 100644 public/images/flags/gs.svg create mode 100644 public/images/flags/gt.svg create mode 100644 public/images/flags/gu.svg create mode 100644 public/images/flags/gw.svg create mode 100644 public/images/flags/gy.svg create mode 100644 public/images/flags/hk.svg create mode 100644 public/images/flags/hm.svg create mode 100644 public/images/flags/hn.svg create mode 100644 public/images/flags/hr.svg create mode 100644 public/images/flags/ht.svg create mode 100644 public/images/flags/hu.svg create mode 100644 public/images/flags/ic.svg create mode 100644 public/images/flags/id.svg create mode 100644 public/images/flags/ie.svg create mode 100644 public/images/flags/il.svg create mode 100644 public/images/flags/im.svg create mode 100644 public/images/flags/in.svg create mode 100644 public/images/flags/io.svg create mode 100644 public/images/flags/iq.svg create mode 100644 public/images/flags/ir.svg create mode 100644 public/images/flags/is.svg create mode 100644 public/images/flags/it.svg create mode 100644 public/images/flags/je.svg create mode 100644 public/images/flags/jm.svg create mode 100644 public/images/flags/jo.svg create mode 100644 public/images/flags/jp.svg create mode 100644 public/images/flags/ke.svg create mode 100644 public/images/flags/kg.svg create mode 100644 public/images/flags/kh.svg create mode 100644 public/images/flags/ki.svg create mode 100644 public/images/flags/km.svg create mode 100644 public/images/flags/kn.svg create mode 100644 public/images/flags/kp.svg create mode 100644 public/images/flags/kr.svg create mode 100644 public/images/flags/kw.svg create mode 100644 public/images/flags/ky.svg create mode 100644 public/images/flags/kz.svg create mode 100644 public/images/flags/la.svg create mode 100644 public/images/flags/lb.svg create mode 100644 public/images/flags/lc.svg create mode 100644 public/images/flags/li.svg create mode 100644 public/images/flags/lk.svg create mode 100644 public/images/flags/lr.svg create mode 100644 public/images/flags/ls.svg create mode 100644 public/images/flags/lt.svg create mode 100644 public/images/flags/lu.svg create mode 100644 public/images/flags/lv.svg create mode 100644 public/images/flags/ly.svg create mode 100644 public/images/flags/ma.svg create mode 100644 public/images/flags/mc.svg create mode 100644 public/images/flags/md.svg create mode 100644 public/images/flags/me.svg create mode 100644 public/images/flags/mf.svg create mode 100644 public/images/flags/mg.svg create mode 100644 public/images/flags/mh.svg create mode 100644 public/images/flags/mk.svg create mode 100644 public/images/flags/ml.svg create mode 100644 public/images/flags/mm.svg create mode 100644 public/images/flags/mn.svg create mode 100644 public/images/flags/mo.svg create mode 100644 public/images/flags/mp.svg create mode 100644 public/images/flags/mq.svg create mode 100644 public/images/flags/mr.svg create mode 100644 public/images/flags/ms.svg create mode 100644 public/images/flags/mt.svg create mode 100644 public/images/flags/mu.svg create mode 100644 public/images/flags/mv.svg create mode 100644 public/images/flags/mw.svg create mode 100644 public/images/flags/mx.svg create mode 100644 public/images/flags/my.svg create mode 100644 public/images/flags/mz.svg create mode 100644 public/images/flags/na.svg create mode 100644 public/images/flags/nc.svg create mode 100644 public/images/flags/ne.svg create mode 100644 public/images/flags/nf.svg create mode 100644 public/images/flags/ng.svg create mode 100644 public/images/flags/ni.svg create mode 100644 public/images/flags/nl.svg create mode 100644 public/images/flags/no.svg create mode 100644 public/images/flags/np.svg create mode 100644 public/images/flags/nr.svg create mode 100644 public/images/flags/nu.svg create mode 100644 public/images/flags/nz.svg create mode 100644 public/images/flags/om.svg create mode 100644 public/images/flags/pa.svg create mode 100644 public/images/flags/pc.svg create mode 100644 public/images/flags/pe.svg create mode 100644 public/images/flags/pf.svg create mode 100644 public/images/flags/pg.svg create mode 100644 public/images/flags/ph.svg create mode 100644 public/images/flags/pk.svg create mode 100644 public/images/flags/pl.svg create mode 100644 public/images/flags/pm.svg create mode 100644 public/images/flags/pn.svg create mode 100644 public/images/flags/pr.svg create mode 100644 public/images/flags/ps.svg create mode 100644 public/images/flags/pt.svg create mode 100644 public/images/flags/pw.svg create mode 100644 public/images/flags/py.svg create mode 100644 public/images/flags/qa.svg create mode 100644 public/images/flags/re.svg create mode 100644 public/images/flags/ro.svg create mode 100644 public/images/flags/rs.svg create mode 100644 public/images/flags/ru.svg create mode 100644 public/images/flags/rw.svg create mode 100644 public/images/flags/sa.svg create mode 100644 public/images/flags/sb.svg create mode 100644 public/images/flags/sc.svg create mode 100644 public/images/flags/sd.svg create mode 100644 public/images/flags/se.svg create mode 100644 public/images/flags/sg.svg create mode 100644 public/images/flags/sh-ac.svg create mode 100644 public/images/flags/sh-hl.svg create mode 100644 public/images/flags/sh-ta.svg create mode 100644 public/images/flags/sh.svg create mode 100644 public/images/flags/si.svg create mode 100644 public/images/flags/sj.svg create mode 100644 public/images/flags/sk.svg create mode 100644 public/images/flags/sl.svg create mode 100644 public/images/flags/sm.svg create mode 100644 public/images/flags/sn.svg create mode 100644 public/images/flags/so.svg create mode 100644 public/images/flags/sr.svg create mode 100644 public/images/flags/ss.svg create mode 100644 public/images/flags/st.svg create mode 100644 public/images/flags/sv.svg create mode 100644 public/images/flags/sx.svg create mode 100644 public/images/flags/sy.svg create mode 100644 public/images/flags/sz.svg create mode 100644 public/images/flags/tc.svg create mode 100644 public/images/flags/td.svg create mode 100644 public/images/flags/tf.svg create mode 100644 public/images/flags/tg.svg create mode 100644 public/images/flags/th.svg create mode 100644 public/images/flags/tj.svg create mode 100644 public/images/flags/tk.svg create mode 100644 public/images/flags/tl.svg create mode 100644 public/images/flags/tm.svg create mode 100644 public/images/flags/tn.svg create mode 100644 public/images/flags/to.svg create mode 100644 public/images/flags/tr.svg create mode 100644 public/images/flags/tt.svg create mode 100644 public/images/flags/tv.svg create mode 100644 public/images/flags/tw.svg create mode 100644 public/images/flags/tz.svg create mode 100644 public/images/flags/ua.svg create mode 100644 public/images/flags/ug.svg create mode 100644 public/images/flags/um.svg create mode 100644 public/images/flags/un.svg create mode 100644 public/images/flags/us.svg create mode 100644 public/images/flags/uy.svg create mode 100644 public/images/flags/uz.svg create mode 100644 public/images/flags/va.svg create mode 100644 public/images/flags/vc.svg create mode 100644 public/images/flags/ve.svg create mode 100644 public/images/flags/vg.svg create mode 100644 public/images/flags/vi.svg create mode 100644 public/images/flags/vn.svg create mode 100644 public/images/flags/vu.svg create mode 100644 public/images/flags/wf.svg create mode 100644 public/images/flags/ws.svg create mode 100644 public/images/flags/xk.svg create mode 100644 public/images/flags/xx.svg create mode 100644 public/images/flags/ye.svg create mode 100644 public/images/flags/yt.svg create mode 100644 public/images/flags/za.svg create mode 100644 public/images/flags/zm.svg create mode 100644 public/images/flags/zw.svg delete mode 100644 src/shell/components/FieldTypeCurrency/FieldTypeCurrency.js delete mode 100644 src/shell/components/FieldTypeCurrency/FieldTypeCurrency.less rename src/shell/components/FieldTypeCurrency/{currencies.js => currencies.ts} (61%) delete mode 100644 src/shell/components/FieldTypeCurrency/index.js create mode 100644 src/shell/components/FieldTypeCurrency/index.tsx create mode 100644 src/utility/getFlagEmoji.ts diff --git a/cypress/e2e/content/content.spec.js b/cypress/e2e/content/content.spec.js index 0d629acca7..5762d0c1f6 100644 --- a/cypress/e2e/content/content.spec.js +++ b/cypress/e2e/content/content.spec.js @@ -184,7 +184,7 @@ describe("Content Specs", () => { }); it("Currency Field", () => { - cy.get("#12-b35c68-jd1s8s input[type=number]") + cy.get("#12-b35c68-jd1s8s input") .focus() .clear() .type("100.00") diff --git a/cypress/e2e/schema/field.spec.js b/cypress/e2e/schema/field.spec.js index 4ec52339ce..92625bf5b0 100644 --- a/cypress/e2e/schema/field.spec.js +++ b/cypress/e2e/schema/field.spec.js @@ -17,12 +17,14 @@ const SELECTORS = { FIELD_SELECT_MEDIA: "FieldItem_images", FIELD_SELECT_BOOLEAN: "FieldItem_yes_no", FIELD_SELECT_ONE_TO_ONE: "FieldItem_one_to_one", + FIELD_SELECT_CURRENCY: "FieldItem_currency", MEDIA_CHECKBOX_LIMIT: "MediaCheckbox_limit", MEDIA_CHECKBOX_LOCK: "MediaCheckbox_group_id", DROPDOWN_ADD_OPTION: "DropdownAddOption", DROPDOWN_DELETE_OPTION: "DeleteOption", AUTOCOMPLETE_MODEL_ZUID: "Autocomplete_relatedModelZUID", AUTOCOMPLETE_FIELED_ZUID: "Autocomplete_relatedFieldZUID", + AUTOCOMPLETE_FIELD_CURRENCY: "Autocomplete_currency", INPUT_LABEL: "FieldFormInput_label", INPUT_NAME: "FieldFormInput_name", INPUT_OPTION_LABEL: "OptionLabel", @@ -357,6 +359,44 @@ describe("Schema: Fields", () => { cy.getBySelector(`Field_${fieldName}`).should("exist"); }); + it("Creates a currency field", () => { + cy.intercept("**/fields?showDeleted=true").as("getFields"); + + const fieldLabel = `Currency ${timestamp}`; + const fieldName = `currency_${timestamp}`; + + // Open the add field modal + cy.getBySelector(SELECTORS.ADD_FIELD_BTN).should("exist").click(); + cy.getBySelector(SELECTORS.ADD_FIELD_MODAL).should("exist"); + + // Select one-to-one relationship field + cy.getBySelector(SELECTORS.FIELD_SELECT_CURRENCY).should("exist").click(); + + // Select default currency + cy.getBySelector(SELECTORS.AUTOCOMPLETE_FIELD_CURRENCY).type("phil"); + cy.get("[role=listbox] [role=option]").first().click(); + + // Fill up fields + cy.getBySelector(SELECTORS.INPUT_LABEL).should("exist").type(fieldLabel); + + // Navigate to rules tab and add default value + cy.getBySelector(SELECTORS.RULES_TAB_BTN).click(); + // click on the default value checkbox + cy.getBySelector(SELECTORS.DEFAULT_VALUE_CHECKBOX).click(); + // enter a default value + cy.getBySelector(SELECTORS.DEFAULT_VALUE_INPUT).type("1000.50"); + // Verify default currency + cy.getBySelector(SELECTORS.DEFAULT_VALUE_INPUT).contains("PHP"); + // Click done + cy.getBySelector(SELECTORS.SAVE_FIELD_BUTTON).should("exist").click(); + cy.getBySelector(SELECTORS.ADD_FIELD_MODAL).should("not.exist"); + + cy.wait("@getFields"); + + // Check if field exists + cy.getBySelector(`Field_${fieldName}`).should("exist"); + }); + it("Creates a field via add another field button", () => { cy.intercept("**/fields?showDeleted=true").as("getFields"); diff --git a/public/images/flags/ad.svg b/public/images/flags/ad.svg new file mode 100644 index 0000000000..067ab772f6 --- /dev/null +++ b/public/images/flags/ad.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ae.svg b/public/images/flags/ae.svg new file mode 100644 index 0000000000..651ac8523d --- /dev/null +++ b/public/images/flags/ae.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/af.svg b/public/images/flags/af.svg new file mode 100644 index 0000000000..521ac4cfd8 --- /dev/null +++ b/public/images/flags/af.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ag.svg b/public/images/flags/ag.svg new file mode 100644 index 0000000000..243c3d8f9e --- /dev/null +++ b/public/images/flags/ag.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/ai.svg b/public/images/flags/ai.svg new file mode 100644 index 0000000000..628ad9be93 --- /dev/null +++ b/public/images/flags/ai.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/al.svg b/public/images/flags/al.svg new file mode 100644 index 0000000000..1135b4b80a --- /dev/null +++ b/public/images/flags/al.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/am.svg b/public/images/flags/am.svg new file mode 100644 index 0000000000..99fa4dc597 --- /dev/null +++ b/public/images/flags/am.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/ao.svg b/public/images/flags/ao.svg new file mode 100644 index 0000000000..b1863bd0f6 --- /dev/null +++ b/public/images/flags/ao.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/aq.svg b/public/images/flags/aq.svg new file mode 100644 index 0000000000..53840cccb0 --- /dev/null +++ b/public/images/flags/aq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/ar.svg b/public/images/flags/ar.svg new file mode 100644 index 0000000000..d20cbbdcdc --- /dev/null +++ b/public/images/flags/ar.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/arab.svg b/public/images/flags/arab.svg new file mode 100644 index 0000000000..96d27157e9 --- /dev/null +++ b/public/images/flags/arab.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/as.svg b/public/images/flags/as.svg new file mode 100644 index 0000000000..3543556725 --- /dev/null +++ b/public/images/flags/as.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/at.svg b/public/images/flags/at.svg new file mode 100644 index 0000000000..9d2775c083 --- /dev/null +++ b/public/images/flags/at.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/au.svg b/public/images/flags/au.svg new file mode 100644 index 0000000000..96e80768bb --- /dev/null +++ b/public/images/flags/au.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/aw.svg b/public/images/flags/aw.svg new file mode 100644 index 0000000000..413b7c45b6 --- /dev/null +++ b/public/images/flags/aw.svg @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ax.svg b/public/images/flags/ax.svg new file mode 100644 index 0000000000..0584d713b5 --- /dev/null +++ b/public/images/flags/ax.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/az.svg b/public/images/flags/az.svg new file mode 100644 index 0000000000..3557522110 --- /dev/null +++ b/public/images/flags/az.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/ba.svg b/public/images/flags/ba.svg new file mode 100644 index 0000000000..93bd9cf937 --- /dev/null +++ b/public/images/flags/ba.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/flags/bb.svg b/public/images/flags/bb.svg new file mode 100644 index 0000000000..cecd5cc334 --- /dev/null +++ b/public/images/flags/bb.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/bd.svg b/public/images/flags/bd.svg new file mode 100644 index 0000000000..16b794debd --- /dev/null +++ b/public/images/flags/bd.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/be.svg b/public/images/flags/be.svg new file mode 100644 index 0000000000..ac706a0b5a --- /dev/null +++ b/public/images/flags/be.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/bf.svg b/public/images/flags/bf.svg new file mode 100644 index 0000000000..4713822584 --- /dev/null +++ b/public/images/flags/bf.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/bg.svg b/public/images/flags/bg.svg new file mode 100644 index 0000000000..af2d0d07c3 --- /dev/null +++ b/public/images/flags/bg.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/bh.svg b/public/images/flags/bh.svg new file mode 100644 index 0000000000..7a2ea549b6 --- /dev/null +++ b/public/images/flags/bh.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/bi.svg b/public/images/flags/bi.svg new file mode 100644 index 0000000000..a4434a955f --- /dev/null +++ b/public/images/flags/bi.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/bj.svg b/public/images/flags/bj.svg new file mode 100644 index 0000000000..0846724d17 --- /dev/null +++ b/public/images/flags/bj.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/bl.svg b/public/images/flags/bl.svg new file mode 100644 index 0000000000..f84cbbaeb1 --- /dev/null +++ b/public/images/flags/bl.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/bm.svg b/public/images/flags/bm.svg new file mode 100644 index 0000000000..bab3e0abe0 --- /dev/null +++ b/public/images/flags/bm.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/bn.svg b/public/images/flags/bn.svg new file mode 100644 index 0000000000..4b416ebb73 --- /dev/null +++ b/public/images/flags/bn.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/bo.svg b/public/images/flags/bo.svg new file mode 100644 index 0000000000..46dc76735e --- /dev/null +++ b/public/images/flags/bo.svg @@ -0,0 +1,674 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/bq.svg b/public/images/flags/bq.svg new file mode 100644 index 0000000000..0e6bc76e62 --- /dev/null +++ b/public/images/flags/bq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/br.svg b/public/images/flags/br.svg new file mode 100644 index 0000000000..22c908e7e3 --- /dev/null +++ b/public/images/flags/br.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/bs.svg b/public/images/flags/bs.svg new file mode 100644 index 0000000000..5cc918e5ad --- /dev/null +++ b/public/images/flags/bs.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/bt.svg b/public/images/flags/bt.svg new file mode 100644 index 0000000000..798c79b381 --- /dev/null +++ b/public/images/flags/bt.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/bv.svg b/public/images/flags/bv.svg new file mode 100644 index 0000000000..40e16d9482 --- /dev/null +++ b/public/images/flags/bv.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/bw.svg b/public/images/flags/bw.svg new file mode 100644 index 0000000000..3435608d6c --- /dev/null +++ b/public/images/flags/bw.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/by.svg b/public/images/flags/by.svg new file mode 100644 index 0000000000..7e90ff255c --- /dev/null +++ b/public/images/flags/by.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/bz.svg b/public/images/flags/bz.svg new file mode 100644 index 0000000000..25386a51a4 --- /dev/null +++ b/public/images/flags/bz.svg @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ca.svg b/public/images/flags/ca.svg new file mode 100644 index 0000000000..89da5b7b55 --- /dev/null +++ b/public/images/flags/ca.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/cc.svg b/public/images/flags/cc.svg new file mode 100644 index 0000000000..ddfd180382 --- /dev/null +++ b/public/images/flags/cc.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/cd.svg b/public/images/flags/cd.svg new file mode 100644 index 0000000000..b9cf528941 --- /dev/null +++ b/public/images/flags/cd.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/cefta.svg b/public/images/flags/cefta.svg new file mode 100644 index 0000000000..f748d08a12 --- /dev/null +++ b/public/images/flags/cefta.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/cf.svg b/public/images/flags/cf.svg new file mode 100644 index 0000000000..a6cd3670f2 --- /dev/null +++ b/public/images/flags/cf.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/cg.svg b/public/images/flags/cg.svg new file mode 100644 index 0000000000..f5a0e42d45 --- /dev/null +++ b/public/images/flags/cg.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/flags/ch.svg b/public/images/flags/ch.svg new file mode 100644 index 0000000000..b42d6709cf --- /dev/null +++ b/public/images/flags/ch.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/ci.svg b/public/images/flags/ci.svg new file mode 100644 index 0000000000..e400f0c1cd --- /dev/null +++ b/public/images/flags/ci.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/ck.svg b/public/images/flags/ck.svg new file mode 100644 index 0000000000..18e547b17d --- /dev/null +++ b/public/images/flags/ck.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/cl.svg b/public/images/flags/cl.svg new file mode 100644 index 0000000000..5b3c72fa7c --- /dev/null +++ b/public/images/flags/cl.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/cm.svg b/public/images/flags/cm.svg new file mode 100644 index 0000000000..70adc8b681 --- /dev/null +++ b/public/images/flags/cm.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/cn.svg b/public/images/flags/cn.svg new file mode 100644 index 0000000000..10d3489a0e --- /dev/null +++ b/public/images/flags/cn.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/flags/co.svg b/public/images/flags/co.svg new file mode 100644 index 0000000000..ebd0a0fb2d --- /dev/null +++ b/public/images/flags/co.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/cp.svg b/public/images/flags/cp.svg new file mode 100644 index 0000000000..b8aa9cfd69 --- /dev/null +++ b/public/images/flags/cp.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/cr.svg b/public/images/flags/cr.svg new file mode 100644 index 0000000000..5a409eebb2 --- /dev/null +++ b/public/images/flags/cr.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/cu.svg b/public/images/flags/cu.svg new file mode 100644 index 0000000000..053c9ee3a0 --- /dev/null +++ b/public/images/flags/cu.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/cv.svg b/public/images/flags/cv.svg new file mode 100644 index 0000000000..aec8994902 --- /dev/null +++ b/public/images/flags/cv.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/cw.svg b/public/images/flags/cw.svg new file mode 100644 index 0000000000..bb0ece22e4 --- /dev/null +++ b/public/images/flags/cw.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/cx.svg b/public/images/flags/cx.svg new file mode 100644 index 0000000000..374ff2dab5 --- /dev/null +++ b/public/images/flags/cx.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/cy.svg b/public/images/flags/cy.svg new file mode 100644 index 0000000000..7e3d883da8 --- /dev/null +++ b/public/images/flags/cy.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/cz.svg b/public/images/flags/cz.svg new file mode 100644 index 0000000000..7913de3895 --- /dev/null +++ b/public/images/flags/cz.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/de.svg b/public/images/flags/de.svg new file mode 100644 index 0000000000..71aa2d2c30 --- /dev/null +++ b/public/images/flags/de.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/dg.svg b/public/images/flags/dg.svg new file mode 100644 index 0000000000..f163caf947 --- /dev/null +++ b/public/images/flags/dg.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/dj.svg b/public/images/flags/dj.svg new file mode 100644 index 0000000000..9b00a82056 --- /dev/null +++ b/public/images/flags/dj.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/dk.svg b/public/images/flags/dk.svg new file mode 100644 index 0000000000..563277f81d --- /dev/null +++ b/public/images/flags/dk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/dm.svg b/public/images/flags/dm.svg new file mode 100644 index 0000000000..f692094ddb --- /dev/null +++ b/public/images/flags/dm.svg @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/do.svg b/public/images/flags/do.svg new file mode 100644 index 0000000000..b1be393ed1 --- /dev/null +++ b/public/images/flags/do.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/dz.svg b/public/images/flags/dz.svg new file mode 100644 index 0000000000..5ff29a74a0 --- /dev/null +++ b/public/images/flags/dz.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/eac.svg b/public/images/flags/eac.svg new file mode 100644 index 0000000000..aaf8133f35 --- /dev/null +++ b/public/images/flags/eac.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ec.svg b/public/images/flags/ec.svg new file mode 100644 index 0000000000..397bfd9822 --- /dev/null +++ b/public/images/flags/ec.svg @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ee.svg b/public/images/flags/ee.svg new file mode 100644 index 0000000000..8b98c2c429 --- /dev/null +++ b/public/images/flags/ee.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/eg.svg b/public/images/flags/eg.svg new file mode 100644 index 0000000000..00d1fa59ee --- /dev/null +++ b/public/images/flags/eg.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/eh.svg b/public/images/flags/eh.svg new file mode 100644 index 0000000000..6aec72883c --- /dev/null +++ b/public/images/flags/eh.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/flags/er.svg b/public/images/flags/er.svg new file mode 100644 index 0000000000..3f4f3f2921 --- /dev/null +++ b/public/images/flags/er.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/es-ct.svg b/public/images/flags/es-ct.svg new file mode 100644 index 0000000000..4d85911402 --- /dev/null +++ b/public/images/flags/es-ct.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/es-ga.svg b/public/images/flags/es-ga.svg new file mode 100644 index 0000000000..31657813ea --- /dev/null +++ b/public/images/flags/es-ga.svg @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/es-pv.svg b/public/images/flags/es-pv.svg new file mode 100644 index 0000000000..21c8759ec0 --- /dev/null +++ b/public/images/flags/es-pv.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/es.svg b/public/images/flags/es.svg new file mode 100644 index 0000000000..acdf927f23 --- /dev/null +++ b/public/images/flags/es.svg @@ -0,0 +1,544 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/et.svg b/public/images/flags/et.svg new file mode 100644 index 0000000000..3f99be4860 --- /dev/null +++ b/public/images/flags/et.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/eu.svg b/public/images/flags/eu.svg new file mode 100644 index 0000000000..b0874c1ed4 --- /dev/null +++ b/public/images/flags/eu.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/fi.svg b/public/images/flags/fi.svg new file mode 100644 index 0000000000..470be2d07c --- /dev/null +++ b/public/images/flags/fi.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/fj.svg b/public/images/flags/fj.svg new file mode 100644 index 0000000000..23fbe57a8d --- /dev/null +++ b/public/images/flags/fj.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/fk.svg b/public/images/flags/fk.svg new file mode 100644 index 0000000000..c65bf96de9 --- /dev/null +++ b/public/images/flags/fk.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/fm.svg b/public/images/flags/fm.svg new file mode 100644 index 0000000000..c1b7c97784 --- /dev/null +++ b/public/images/flags/fm.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/flags/fo.svg b/public/images/flags/fo.svg new file mode 100644 index 0000000000..f802d285ac --- /dev/null +++ b/public/images/flags/fo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/flags/fr.svg b/public/images/flags/fr.svg new file mode 100644 index 0000000000..4110e59e4c --- /dev/null +++ b/public/images/flags/fr.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/ga.svg b/public/images/flags/ga.svg new file mode 100644 index 0000000000..76edab429c --- /dev/null +++ b/public/images/flags/ga.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/gb-eng.svg b/public/images/flags/gb-eng.svg new file mode 100644 index 0000000000..12e3b67d56 --- /dev/null +++ b/public/images/flags/gb-eng.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/gb-nir.svg b/public/images/flags/gb-nir.svg new file mode 100644 index 0000000000..e6be8dbc2d --- /dev/null +++ b/public/images/flags/gb-nir.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/gb-sct.svg b/public/images/flags/gb-sct.svg new file mode 100644 index 0000000000..f50cd322ac --- /dev/null +++ b/public/images/flags/gb-sct.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/gb-wls.svg b/public/images/flags/gb-wls.svg new file mode 100644 index 0000000000..6e15fd0158 --- /dev/null +++ b/public/images/flags/gb-wls.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/gb.svg b/public/images/flags/gb.svg new file mode 100644 index 0000000000..799138319d --- /dev/null +++ b/public/images/flags/gb.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/gd.svg b/public/images/flags/gd.svg new file mode 100644 index 0000000000..cb51e9618e --- /dev/null +++ b/public/images/flags/gd.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ge.svg b/public/images/flags/ge.svg new file mode 100644 index 0000000000..d8126ec8d8 --- /dev/null +++ b/public/images/flags/ge.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/gf.svg b/public/images/flags/gf.svg new file mode 100644 index 0000000000..f8fe94c659 --- /dev/null +++ b/public/images/flags/gf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/gg.svg b/public/images/flags/gg.svg new file mode 100644 index 0000000000..f8216c8bc1 --- /dev/null +++ b/public/images/flags/gg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/gh.svg b/public/images/flags/gh.svg new file mode 100644 index 0000000000..5c3e3e69ab --- /dev/null +++ b/public/images/flags/gh.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/gi.svg b/public/images/flags/gi.svg new file mode 100644 index 0000000000..e2b590afef --- /dev/null +++ b/public/images/flags/gi.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/gl.svg b/public/images/flags/gl.svg new file mode 100644 index 0000000000..eb5a52e9e4 --- /dev/null +++ b/public/images/flags/gl.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/gm.svg b/public/images/flags/gm.svg new file mode 100644 index 0000000000..8fe9d66920 --- /dev/null +++ b/public/images/flags/gm.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/gn.svg b/public/images/flags/gn.svg new file mode 100644 index 0000000000..40d6ad4f03 --- /dev/null +++ b/public/images/flags/gn.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/gp.svg b/public/images/flags/gp.svg new file mode 100644 index 0000000000..ee55c4bcd3 --- /dev/null +++ b/public/images/flags/gp.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/gq.svg b/public/images/flags/gq.svg new file mode 100644 index 0000000000..134e442173 --- /dev/null +++ b/public/images/flags/gq.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/gr.svg b/public/images/flags/gr.svg new file mode 100644 index 0000000000..599741eec8 --- /dev/null +++ b/public/images/flags/gr.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/flags/gs.svg b/public/images/flags/gs.svg new file mode 100644 index 0000000000..1536e073ec --- /dev/null +++ b/public/images/flags/gs.svg @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/gt.svg b/public/images/flags/gt.svg new file mode 100644 index 0000000000..f7cffbdc7a --- /dev/null +++ b/public/images/flags/gt.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/gu.svg b/public/images/flags/gu.svg new file mode 100644 index 0000000000..0d66e1bfa8 --- /dev/null +++ b/public/images/flags/gu.svg @@ -0,0 +1,23 @@ + + + + + + + + + + G + U + A + M + + + + + + + + + + diff --git a/public/images/flags/gw.svg b/public/images/flags/gw.svg new file mode 100644 index 0000000000..d470bac9f7 --- /dev/null +++ b/public/images/flags/gw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/gy.svg b/public/images/flags/gy.svg new file mode 100644 index 0000000000..569fb56275 --- /dev/null +++ b/public/images/flags/gy.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/hk.svg b/public/images/flags/hk.svg new file mode 100644 index 0000000000..4fd55bc14b --- /dev/null +++ b/public/images/flags/hk.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/hm.svg b/public/images/flags/hm.svg new file mode 100644 index 0000000000..815c482085 --- /dev/null +++ b/public/images/flags/hm.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/hn.svg b/public/images/flags/hn.svg new file mode 100644 index 0000000000..11fde67db9 --- /dev/null +++ b/public/images/flags/hn.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/hr.svg b/public/images/flags/hr.svg new file mode 100644 index 0000000000..44fed27d54 --- /dev/null +++ b/public/images/flags/hr.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ht.svg b/public/images/flags/ht.svg new file mode 100644 index 0000000000..5d48eb93b2 --- /dev/null +++ b/public/images/flags/ht.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/hu.svg b/public/images/flags/hu.svg new file mode 100644 index 0000000000..baddf7f5ea --- /dev/null +++ b/public/images/flags/hu.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/ic.svg b/public/images/flags/ic.svg new file mode 100644 index 0000000000..81e6ee2e13 --- /dev/null +++ b/public/images/flags/ic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/id.svg b/public/images/flags/id.svg new file mode 100644 index 0000000000..3b7c8fcfd9 --- /dev/null +++ b/public/images/flags/id.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/ie.svg b/public/images/flags/ie.svg new file mode 100644 index 0000000000..049be14de1 --- /dev/null +++ b/public/images/flags/ie.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/il.svg b/public/images/flags/il.svg new file mode 100644 index 0000000000..f43be7e8ed --- /dev/null +++ b/public/images/flags/il.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/im.svg b/public/images/flags/im.svg new file mode 100644 index 0000000000..f06f3d6fe1 --- /dev/null +++ b/public/images/flags/im.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/in.svg b/public/images/flags/in.svg new file mode 100644 index 0000000000..bc47d74911 --- /dev/null +++ b/public/images/flags/in.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/io.svg b/public/images/flags/io.svg new file mode 100644 index 0000000000..77016679ef --- /dev/null +++ b/public/images/flags/io.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/iq.svg b/public/images/flags/iq.svg new file mode 100644 index 0000000000..259da9adc5 --- /dev/null +++ b/public/images/flags/iq.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/flags/ir.svg b/public/images/flags/ir.svg new file mode 100644 index 0000000000..8c6d516216 --- /dev/null +++ b/public/images/flags/ir.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/is.svg b/public/images/flags/is.svg new file mode 100644 index 0000000000..a6588afaef --- /dev/null +++ b/public/images/flags/is.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/flags/it.svg b/public/images/flags/it.svg new file mode 100644 index 0000000000..20a8bfdcc8 --- /dev/null +++ b/public/images/flags/it.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/je.svg b/public/images/flags/je.svg new file mode 100644 index 0000000000..611180d42a --- /dev/null +++ b/public/images/flags/je.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/jm.svg b/public/images/flags/jm.svg new file mode 100644 index 0000000000..269df03836 --- /dev/null +++ b/public/images/flags/jm.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/jo.svg b/public/images/flags/jo.svg new file mode 100644 index 0000000000..d6f927d44f --- /dev/null +++ b/public/images/flags/jo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/flags/jp.svg b/public/images/flags/jp.svg new file mode 100644 index 0000000000..cc1c181ce9 --- /dev/null +++ b/public/images/flags/jp.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/flags/ke.svg b/public/images/flags/ke.svg new file mode 100644 index 0000000000..3a67ca3ccd --- /dev/null +++ b/public/images/flags/ke.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/kg.svg b/public/images/flags/kg.svg new file mode 100644 index 0000000000..68c210b1cf --- /dev/null +++ b/public/images/flags/kg.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/kh.svg b/public/images/flags/kh.svg new file mode 100644 index 0000000000..c658838f4e --- /dev/null +++ b/public/images/flags/kh.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ki.svg b/public/images/flags/ki.svg new file mode 100644 index 0000000000..0c80328071 --- /dev/null +++ b/public/images/flags/ki.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/km.svg b/public/images/flags/km.svg new file mode 100644 index 0000000000..414d65e47f --- /dev/null +++ b/public/images/flags/km.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/flags/kn.svg b/public/images/flags/kn.svg new file mode 100644 index 0000000000..47fe64d617 --- /dev/null +++ b/public/images/flags/kn.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/kp.svg b/public/images/flags/kp.svg new file mode 100644 index 0000000000..4d1dbab246 --- /dev/null +++ b/public/images/flags/kp.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/kr.svg b/public/images/flags/kr.svg new file mode 100644 index 0000000000..6947eab2b3 --- /dev/null +++ b/public/images/flags/kr.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/kw.svg b/public/images/flags/kw.svg new file mode 100644 index 0000000000..3dd89e9962 --- /dev/null +++ b/public/images/flags/kw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/ky.svg b/public/images/flags/ky.svg new file mode 100644 index 0000000000..74a2fea2a1 --- /dev/null +++ b/public/images/flags/ky.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/kz.svg b/public/images/flags/kz.svg new file mode 100644 index 0000000000..04a47f53e8 --- /dev/null +++ b/public/images/flags/kz.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/la.svg b/public/images/flags/la.svg new file mode 100644 index 0000000000..6aea6b72b4 --- /dev/null +++ b/public/images/flags/la.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/flags/lb.svg b/public/images/flags/lb.svg new file mode 100644 index 0000000000..8619f2410e --- /dev/null +++ b/public/images/flags/lb.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/lc.svg b/public/images/flags/lc.svg new file mode 100644 index 0000000000..bb256541c6 --- /dev/null +++ b/public/images/flags/lc.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/li.svg b/public/images/flags/li.svg new file mode 100644 index 0000000000..68ea26fa30 --- /dev/null +++ b/public/images/flags/li.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/lk.svg b/public/images/flags/lk.svg new file mode 100644 index 0000000000..2c5cdbe09d --- /dev/null +++ b/public/images/flags/lk.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/lr.svg b/public/images/flags/lr.svg new file mode 100644 index 0000000000..e482ab9d74 --- /dev/null +++ b/public/images/flags/lr.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/ls.svg b/public/images/flags/ls.svg new file mode 100644 index 0000000000..a7c01a98ff --- /dev/null +++ b/public/images/flags/ls.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/lt.svg b/public/images/flags/lt.svg new file mode 100644 index 0000000000..90ec5d240e --- /dev/null +++ b/public/images/flags/lt.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/lu.svg b/public/images/flags/lu.svg new file mode 100644 index 0000000000..cc12206812 --- /dev/null +++ b/public/images/flags/lu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/lv.svg b/public/images/flags/lv.svg new file mode 100644 index 0000000000..6a9e75ec97 --- /dev/null +++ b/public/images/flags/lv.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/ly.svg b/public/images/flags/ly.svg new file mode 100644 index 0000000000..1eaa51e468 --- /dev/null +++ b/public/images/flags/ly.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/ma.svg b/public/images/flags/ma.svg new file mode 100644 index 0000000000..7ce56eff70 --- /dev/null +++ b/public/images/flags/ma.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/mc.svg b/public/images/flags/mc.svg new file mode 100644 index 0000000000..9cb6c9e8a0 --- /dev/null +++ b/public/images/flags/mc.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/md.svg b/public/images/flags/md.svg new file mode 100644 index 0000000000..6dc441e177 --- /dev/null +++ b/public/images/flags/md.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/me.svg b/public/images/flags/me.svg new file mode 100644 index 0000000000..d891890746 --- /dev/null +++ b/public/images/flags/me.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/mf.svg b/public/images/flags/mf.svg new file mode 100644 index 0000000000..6305edc1c2 --- /dev/null +++ b/public/images/flags/mf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/mg.svg b/public/images/flags/mg.svg new file mode 100644 index 0000000000..5fa2d2440d --- /dev/null +++ b/public/images/flags/mg.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/mh.svg b/public/images/flags/mh.svg new file mode 100644 index 0000000000..7b9f490755 --- /dev/null +++ b/public/images/flags/mh.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/mk.svg b/public/images/flags/mk.svg new file mode 100644 index 0000000000..4f5cae77ed --- /dev/null +++ b/public/images/flags/mk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/ml.svg b/public/images/flags/ml.svg new file mode 100644 index 0000000000..6f6b71695c --- /dev/null +++ b/public/images/flags/ml.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/mm.svg b/public/images/flags/mm.svg new file mode 100644 index 0000000000..42b4dee2b8 --- /dev/null +++ b/public/images/flags/mm.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/flags/mn.svg b/public/images/flags/mn.svg new file mode 100644 index 0000000000..152c2fcb0f --- /dev/null +++ b/public/images/flags/mn.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/mo.svg b/public/images/flags/mo.svg new file mode 100644 index 0000000000..d39985d05f --- /dev/null +++ b/public/images/flags/mo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/mp.svg b/public/images/flags/mp.svg new file mode 100644 index 0000000000..ff59ebf87b --- /dev/null +++ b/public/images/flags/mp.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/mq.svg b/public/images/flags/mq.svg new file mode 100644 index 0000000000..b221951e36 --- /dev/null +++ b/public/images/flags/mq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/mr.svg b/public/images/flags/mr.svg new file mode 100644 index 0000000000..7558234cbf --- /dev/null +++ b/public/images/flags/mr.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/ms.svg b/public/images/flags/ms.svg new file mode 100644 index 0000000000..faf07b07fd --- /dev/null +++ b/public/images/flags/ms.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/mt.svg b/public/images/flags/mt.svg new file mode 100644 index 0000000000..c597266c36 --- /dev/null +++ b/public/images/flags/mt.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/mu.svg b/public/images/flags/mu.svg new file mode 100644 index 0000000000..82d7a3bec5 --- /dev/null +++ b/public/images/flags/mu.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/mv.svg b/public/images/flags/mv.svg new file mode 100644 index 0000000000..10450f9845 --- /dev/null +++ b/public/images/flags/mv.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/mw.svg b/public/images/flags/mw.svg new file mode 100644 index 0000000000..d83ddb2178 --- /dev/null +++ b/public/images/flags/mw.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/flags/mx.svg b/public/images/flags/mx.svg new file mode 100644 index 0000000000..f98a89e173 --- /dev/null +++ b/public/images/flags/mx.svg @@ -0,0 +1,382 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/my.svg b/public/images/flags/my.svg new file mode 100644 index 0000000000..89576f69ea --- /dev/null +++ b/public/images/flags/my.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/mz.svg b/public/images/flags/mz.svg new file mode 100644 index 0000000000..2ee6ec14b4 --- /dev/null +++ b/public/images/flags/mz.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/na.svg b/public/images/flags/na.svg new file mode 100644 index 0000000000..35b9f783e1 --- /dev/null +++ b/public/images/flags/na.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/flags/nc.svg b/public/images/flags/nc.svg new file mode 100644 index 0000000000..068f0c69aa --- /dev/null +++ b/public/images/flags/nc.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/ne.svg b/public/images/flags/ne.svg new file mode 100644 index 0000000000..39a82b8277 --- /dev/null +++ b/public/images/flags/ne.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/nf.svg b/public/images/flags/nf.svg new file mode 100644 index 0000000000..c8b30938d7 --- /dev/null +++ b/public/images/flags/nf.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/ng.svg b/public/images/flags/ng.svg new file mode 100644 index 0000000000..81eb35f78e --- /dev/null +++ b/public/images/flags/ng.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/ni.svg b/public/images/flags/ni.svg new file mode 100644 index 0000000000..6dcdc9a806 --- /dev/null +++ b/public/images/flags/ni.svg @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/nl.svg b/public/images/flags/nl.svg new file mode 100644 index 0000000000..e90f5b0351 --- /dev/null +++ b/public/images/flags/nl.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/no.svg b/public/images/flags/no.svg new file mode 100644 index 0000000000..a5f2a152a9 --- /dev/null +++ b/public/images/flags/no.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/np.svg b/public/images/flags/np.svg new file mode 100644 index 0000000000..8d71d106bb --- /dev/null +++ b/public/images/flags/np.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/nr.svg b/public/images/flags/nr.svg new file mode 100644 index 0000000000..ff394c4112 --- /dev/null +++ b/public/images/flags/nr.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/flags/nu.svg b/public/images/flags/nu.svg new file mode 100644 index 0000000000..4067bafff0 --- /dev/null +++ b/public/images/flags/nu.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/flags/nz.svg b/public/images/flags/nz.svg new file mode 100644 index 0000000000..935d8a749d --- /dev/null +++ b/public/images/flags/nz.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/om.svg b/public/images/flags/om.svg new file mode 100644 index 0000000000..c003f86e46 --- /dev/null +++ b/public/images/flags/om.svg @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/pa.svg b/public/images/flags/pa.svg new file mode 100644 index 0000000000..8dc03bc61b --- /dev/null +++ b/public/images/flags/pa.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/pc.svg b/public/images/flags/pc.svg new file mode 100644 index 0000000000..882197da67 --- /dev/null +++ b/public/images/flags/pc.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/pe.svg b/public/images/flags/pe.svg new file mode 100644 index 0000000000..33e6cfd417 --- /dev/null +++ b/public/images/flags/pe.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/pf.svg b/public/images/flags/pf.svg new file mode 100644 index 0000000000..e06b236e82 --- /dev/null +++ b/public/images/flags/pf.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/pg.svg b/public/images/flags/pg.svg new file mode 100644 index 0000000000..237cb6eeed --- /dev/null +++ b/public/images/flags/pg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/ph.svg b/public/images/flags/ph.svg new file mode 100644 index 0000000000..65489e1cb2 --- /dev/null +++ b/public/images/flags/ph.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/pk.svg b/public/images/flags/pk.svg new file mode 100644 index 0000000000..491e58ab16 --- /dev/null +++ b/public/images/flags/pk.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/pl.svg b/public/images/flags/pl.svg new file mode 100644 index 0000000000..0fa5145241 --- /dev/null +++ b/public/images/flags/pl.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/pm.svg b/public/images/flags/pm.svg new file mode 100644 index 0000000000..19a9330a31 --- /dev/null +++ b/public/images/flags/pm.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/pn.svg b/public/images/flags/pn.svg new file mode 100644 index 0000000000..07958aca12 --- /dev/null +++ b/public/images/flags/pn.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/pr.svg b/public/images/flags/pr.svg new file mode 100644 index 0000000000..ec51831dcd --- /dev/null +++ b/public/images/flags/pr.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/ps.svg b/public/images/flags/ps.svg new file mode 100644 index 0000000000..b33824a5dd --- /dev/null +++ b/public/images/flags/ps.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/pt.svg b/public/images/flags/pt.svg new file mode 100644 index 0000000000..445cf7f536 --- /dev/null +++ b/public/images/flags/pt.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/pw.svg b/public/images/flags/pw.svg new file mode 100644 index 0000000000..9f89c5f148 --- /dev/null +++ b/public/images/flags/pw.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/flags/py.svg b/public/images/flags/py.svg new file mode 100644 index 0000000000..38e2051eb2 --- /dev/null +++ b/public/images/flags/py.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/qa.svg b/public/images/flags/qa.svg new file mode 100644 index 0000000000..901f3fa761 --- /dev/null +++ b/public/images/flags/qa.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/re.svg b/public/images/flags/re.svg new file mode 100644 index 0000000000..64e788e011 --- /dev/null +++ b/public/images/flags/re.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/ro.svg b/public/images/flags/ro.svg new file mode 100644 index 0000000000..fda0f7bec9 --- /dev/null +++ b/public/images/flags/ro.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/rs.svg b/public/images/flags/rs.svg new file mode 100644 index 0000000000..2f971025b8 --- /dev/null +++ b/public/images/flags/rs.svg @@ -0,0 +1,292 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ru.svg b/public/images/flags/ru.svg new file mode 100644 index 0000000000..cf243011ae --- /dev/null +++ b/public/images/flags/ru.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/rw.svg b/public/images/flags/rw.svg new file mode 100644 index 0000000000..06e26ae44e --- /dev/null +++ b/public/images/flags/rw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/sa.svg b/public/images/flags/sa.svg new file mode 100644 index 0000000000..c0a148663b --- /dev/null +++ b/public/images/flags/sa.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sb.svg b/public/images/flags/sb.svg new file mode 100644 index 0000000000..6066f94cd1 --- /dev/null +++ b/public/images/flags/sb.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/sc.svg b/public/images/flags/sc.svg new file mode 100644 index 0000000000..9a46b369b3 --- /dev/null +++ b/public/images/flags/sc.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/sd.svg b/public/images/flags/sd.svg new file mode 100644 index 0000000000..12818b4110 --- /dev/null +++ b/public/images/flags/sd.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/se.svg b/public/images/flags/se.svg new file mode 100644 index 0000000000..8ba745acaf --- /dev/null +++ b/public/images/flags/se.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/sg.svg b/public/images/flags/sg.svg new file mode 100644 index 0000000000..c4dd4ac9eb --- /dev/null +++ b/public/images/flags/sg.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/sh-ac.svg b/public/images/flags/sh-ac.svg new file mode 100644 index 0000000000..22b365832e --- /dev/null +++ b/public/images/flags/sh-ac.svg @@ -0,0 +1,689 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sh-hl.svg b/public/images/flags/sh-hl.svg new file mode 100644 index 0000000000..b92e703f27 --- /dev/null +++ b/public/images/flags/sh-hl.svg @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sh-ta.svg b/public/images/flags/sh-ta.svg new file mode 100644 index 0000000000..a103aac05f --- /dev/null +++ b/public/images/flags/sh-ta.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sh.svg b/public/images/flags/sh.svg new file mode 100644 index 0000000000..7aba0aec8a --- /dev/null +++ b/public/images/flags/sh.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/si.svg b/public/images/flags/si.svg new file mode 100644 index 0000000000..66a390dcd2 --- /dev/null +++ b/public/images/flags/si.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sj.svg b/public/images/flags/sj.svg new file mode 100644 index 0000000000..bb2799ce73 --- /dev/null +++ b/public/images/flags/sj.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/sk.svg b/public/images/flags/sk.svg new file mode 100644 index 0000000000..81476940eb --- /dev/null +++ b/public/images/flags/sk.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/sl.svg b/public/images/flags/sl.svg new file mode 100644 index 0000000000..a07baf75b4 --- /dev/null +++ b/public/images/flags/sl.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/sm.svg b/public/images/flags/sm.svg new file mode 100644 index 0000000000..00e9286c44 --- /dev/null +++ b/public/images/flags/sm.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sn.svg b/public/images/flags/sn.svg new file mode 100644 index 0000000000..7c0673d6d6 --- /dev/null +++ b/public/images/flags/sn.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/so.svg b/public/images/flags/so.svg new file mode 100644 index 0000000000..a581ac63cf --- /dev/null +++ b/public/images/flags/so.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/flags/sr.svg b/public/images/flags/sr.svg new file mode 100644 index 0000000000..5e71c40026 --- /dev/null +++ b/public/images/flags/sr.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/ss.svg b/public/images/flags/ss.svg new file mode 100644 index 0000000000..b257aa0b3e --- /dev/null +++ b/public/images/flags/ss.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/st.svg b/public/images/flags/st.svg new file mode 100644 index 0000000000..1294bcb70e --- /dev/null +++ b/public/images/flags/st.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sv.svg b/public/images/flags/sv.svg new file mode 100644 index 0000000000..c811e912f0 --- /dev/null +++ b/public/images/flags/sv.svg @@ -0,0 +1,594 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sx.svg b/public/images/flags/sx.svg new file mode 100644 index 0000000000..18f7a1397b --- /dev/null +++ b/public/images/flags/sx.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sy.svg b/public/images/flags/sy.svg new file mode 100644 index 0000000000..5225550525 --- /dev/null +++ b/public/images/flags/sy.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/sz.svg b/public/images/flags/sz.svg new file mode 100644 index 0000000000..294a2cc1a8 --- /dev/null +++ b/public/images/flags/sz.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/tc.svg b/public/images/flags/tc.svg new file mode 100644 index 0000000000..63f13c359b --- /dev/null +++ b/public/images/flags/tc.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/td.svg b/public/images/flags/td.svg new file mode 100644 index 0000000000..fa3bd927c1 --- /dev/null +++ b/public/images/flags/td.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/tf.svg b/public/images/flags/tf.svg new file mode 100644 index 0000000000..fba233563f --- /dev/null +++ b/public/images/flags/tf.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/tg.svg b/public/images/flags/tg.svg new file mode 100644 index 0000000000..c63a6d1a94 --- /dev/null +++ b/public/images/flags/tg.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/th.svg b/public/images/flags/th.svg new file mode 100644 index 0000000000..1e93a61e95 --- /dev/null +++ b/public/images/flags/th.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/tj.svg b/public/images/flags/tj.svg new file mode 100644 index 0000000000..9fba246cde --- /dev/null +++ b/public/images/flags/tj.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/tk.svg b/public/images/flags/tk.svg new file mode 100644 index 0000000000..05d3e86ce6 --- /dev/null +++ b/public/images/flags/tk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/tl.svg b/public/images/flags/tl.svg new file mode 100644 index 0000000000..3d0701a2c8 --- /dev/null +++ b/public/images/flags/tl.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/tm.svg b/public/images/flags/tm.svg new file mode 100644 index 0000000000..8b656cc2b8 --- /dev/null +++ b/public/images/flags/tm.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/tn.svg b/public/images/flags/tn.svg new file mode 100644 index 0000000000..5735c1984d --- /dev/null +++ b/public/images/flags/tn.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/to.svg b/public/images/flags/to.svg new file mode 100644 index 0000000000..d072337066 --- /dev/null +++ b/public/images/flags/to.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/flags/tr.svg b/public/images/flags/tr.svg new file mode 100644 index 0000000000..b96da21f0e --- /dev/null +++ b/public/images/flags/tr.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/tt.svg b/public/images/flags/tt.svg new file mode 100644 index 0000000000..bc24938cf8 --- /dev/null +++ b/public/images/flags/tt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/tv.svg b/public/images/flags/tv.svg new file mode 100644 index 0000000000..675210ec55 --- /dev/null +++ b/public/images/flags/tv.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/tw.svg b/public/images/flags/tw.svg new file mode 100644 index 0000000000..57fd98b433 --- /dev/null +++ b/public/images/flags/tw.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/tz.svg b/public/images/flags/tz.svg new file mode 100644 index 0000000000..a2cfbca42a --- /dev/null +++ b/public/images/flags/tz.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/ua.svg b/public/images/flags/ua.svg new file mode 100644 index 0000000000..a339eb1b9c --- /dev/null +++ b/public/images/flags/ua.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/ug.svg b/public/images/flags/ug.svg new file mode 100644 index 0000000000..737eb2ce1a --- /dev/null +++ b/public/images/flags/ug.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/um.svg b/public/images/flags/um.svg new file mode 100644 index 0000000000..9e9eddaa4a --- /dev/null +++ b/public/images/flags/um.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/un.svg b/public/images/flags/un.svg new file mode 100644 index 0000000000..e57793bc79 --- /dev/null +++ b/public/images/flags/un.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/flags/us.svg b/public/images/flags/us.svg new file mode 100644 index 0000000000..9cfd0c927f --- /dev/null +++ b/public/images/flags/us.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/uy.svg b/public/images/flags/uy.svg new file mode 100644 index 0000000000..62c36f8e5e --- /dev/null +++ b/public/images/flags/uy.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/uz.svg b/public/images/flags/uz.svg new file mode 100644 index 0000000000..0ccca1b1b4 --- /dev/null +++ b/public/images/flags/uz.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/va.svg b/public/images/flags/va.svg new file mode 100644 index 0000000000..87e0fbbdcc --- /dev/null +++ b/public/images/flags/va.svg @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/vc.svg b/public/images/flags/vc.svg new file mode 100644 index 0000000000..f26c2d8da9 --- /dev/null +++ b/public/images/flags/vc.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/ve.svg b/public/images/flags/ve.svg new file mode 100644 index 0000000000..314e7f5f7f --- /dev/null +++ b/public/images/flags/ve.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/vg.svg b/public/images/flags/vg.svg new file mode 100644 index 0000000000..0ee90fb28c --- /dev/null +++ b/public/images/flags/vg.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/vi.svg b/public/images/flags/vi.svg new file mode 100644 index 0000000000..4270257799 --- /dev/null +++ b/public/images/flags/vi.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/vn.svg b/public/images/flags/vn.svg new file mode 100644 index 0000000000..7e4bac8f4a --- /dev/null +++ b/public/images/flags/vn.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/flags/vu.svg b/public/images/flags/vu.svg new file mode 100644 index 0000000000..91e1236a0a --- /dev/null +++ b/public/images/flags/vu.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/wf.svg b/public/images/flags/wf.svg new file mode 100644 index 0000000000..054c57df99 --- /dev/null +++ b/public/images/flags/wf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/ws.svg b/public/images/flags/ws.svg new file mode 100644 index 0000000000..0e758a7a95 --- /dev/null +++ b/public/images/flags/ws.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/xk.svg b/public/images/flags/xk.svg new file mode 100644 index 0000000000..551e7a4145 --- /dev/null +++ b/public/images/flags/xk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/xx.svg b/public/images/flags/xx.svg new file mode 100644 index 0000000000..9333be3635 --- /dev/null +++ b/public/images/flags/xx.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/ye.svg b/public/images/flags/ye.svg new file mode 100644 index 0000000000..1c9e6d6392 --- /dev/null +++ b/public/images/flags/ye.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/yt.svg b/public/images/flags/yt.svg new file mode 100644 index 0000000000..e7776b3078 --- /dev/null +++ b/public/images/flags/yt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/za.svg b/public/images/flags/za.svg new file mode 100644 index 0000000000..d563adb90c --- /dev/null +++ b/public/images/flags/za.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/zm.svg b/public/images/flags/zm.svg new file mode 100644 index 0000000000..13239f5e23 --- /dev/null +++ b/public/images/flags/zm.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/zw.svg b/public/images/flags/zw.svg new file mode 100644 index 0000000000..6399ab4ab3 --- /dev/null +++ b/public/images/flags/zw.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + 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 d9a42aa90e..64c2e47f12 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 @@ -882,10 +882,9 @@ export const Field = ({ errors={errors} > !!error)} /> diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/LanguageSelector.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/LanguageSelector.tsx index 617c83d054..8ed340b481 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/LanguageSelector.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/LanguageSelector.tsx @@ -11,6 +11,7 @@ import { useDispatch, useSelector } from "react-redux"; import { ContentItem } from "../../../../../../../../shell/services/types"; import { AppState } from "../../../../../../../../shell/store/types"; import { selectLang } from "../../../../../../../../shell/store/user"; +import getFlagEmoji from "../../../../../../../../utility/getFlagEmoji"; const getCountryCode = (langCode: string) => { const splitTag = langCode.split("-"); @@ -19,18 +20,6 @@ const getCountryCode = (langCode: string) => { return countryCode; }; -const getFlagEmojiFromIETFTag = (langCode: string) => { - const countryCode = getCountryCode(langCode); - - // Convert country code to flag emoji. - // Unicode flag emojis are made up of regional indicator symbols, which are a sequence of two letters. - const baseOffset = 0x1f1e6; - return ( - String.fromCodePoint(baseOffset + (countryCode.charCodeAt(0) - 65)) + - String.fromCodePoint(baseOffset + (countryCode.charCodeAt(1) - 65)) - ); -}; - export const LanguageSelector = () => { const dispatch = useDispatch(); const history = useHistory(); @@ -97,7 +86,7 @@ export const LanguageSelector = () => { data-cy="language-selector" > - {getFlagEmojiFromIETFTag(activeLanguage?.code)} + {getFlagEmoji(getCountryCode(activeLanguage?.code))} {" "} {activeLanguage?.code?.split("-")[0]?.toUpperCase()} ( {getCountryCode(activeLanguage?.code)}) @@ -130,7 +119,7 @@ export const LanguageSelector = () => { onSelect(language.code); }} > - {getFlagEmojiFromIETFTag(language.code)}{" "} + {getFlagEmoji(getCountryCode(language.code))}{" "} {language.code.split("-")[0]?.toUpperCase()} ( {getCountryCode(language.code)}) diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx index ca8ec799d0..273c3771a9 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx @@ -27,6 +27,8 @@ import { VersionCell } from "./TableCells/VersionCell"; import { DropDownCell } from "./TableCells/DropdownCell"; import { SortCell } from "./TableCells/SortCell"; import { BooleanCell } from "./TableCells/BooleanCell"; +import { currencies } from "../../../../../../shell/components/FieldTypeCurrency/currencies"; +import { Currency } from "../../../../../../shell/components/FieldTypeCurrency/currencies"; import { ImageCell } from "./TableCells/ImageCell"; import { SingleRelationshipCell } from "./TableCells/SingleRelationshipCell"; @@ -35,6 +37,18 @@ type ItemListTableProps = { rows: ContentItem[]; }; +const CURRENCY_OBJECT: Record = currencies.reduce( + (acc, curr) => { + return { + ...acc, + [curr.value]: { + ...curr, + }, + }; + }, + {} +); + const getHtmlText = (html: string) => { if (!html) return ""; @@ -136,11 +150,14 @@ const fieldTypeColumnConfigMap = { currency: { width: 160, valueFormatter: (params: any) => { - if (!params.value) return null; - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(params.value); + if (params.value?.value === undefined || params.value?.value === null) + return ""; + + return `${ + CURRENCY_OBJECT[params.value?.currency]?.symbol_native + } ${new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + }).format(params.value.value)}`; }, align: "right", }, @@ -293,7 +310,16 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { headerName: field.label, sortable: false, filterable: false, - valueGetter: (params: any) => params.row.data[field.name], + valueGetter: (params: any) => { + if (field.datatype === "currency") { + return { + value: params.row.data[field.name], + currency: field.settings?.currency || "USD", + }; + } + + return params.row.data[field.name]; + }, ...fieldTypeColumnConfigMap[field.datatype], // if field is yes_no but it has custom options increase the width ...(field.datatype === "yes_no" && diff --git a/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx b/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx index 4a57292a00..e9bd193e9c 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx @@ -27,6 +27,7 @@ type DefaultValueProps = { relatedFieldZUID: string; }; options: FieldSettingsOptions[]; + currency?: string; }; export const DefaultValue = ({ @@ -39,6 +40,7 @@ export const DefaultValue = ({ mediaRules, relationshipFields, options, + currency, }: DefaultValueProps) => { return ( @@ -87,6 +89,7 @@ export const DefaultValue = ({ mediaRules={mediaRules} relationshipFields={relationshipFields} options={options} + currency={currency} /> diff --git a/src/apps/schema/src/app/components/AddFieldModal/DefaultValueInput.tsx b/src/apps/schema/src/app/components/AddFieldModal/DefaultValueInput.tsx index 633776d5a9..91c52679db 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/DefaultValueInput.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/DefaultValueInput.tsx @@ -65,6 +65,7 @@ type DefaultValueInputProps = { relatedFieldZUID: string; }; options: FieldSettingsOptions[]; + currency?: string; }; export const DefaultValueInput = ({ @@ -75,6 +76,7 @@ export const DefaultValueInput = ({ mediaRules, relationshipFields: { relatedModelZUID, relatedFieldZUID }, options, + currency, }: DefaultValueInputProps) => { const [imageModal, setImageModal] = useState(null); const dispatch = useDispatch(); @@ -573,12 +575,11 @@ export const DefaultValueInput = ({ return ( ); case "date": diff --git a/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx b/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx index 7e5eb773c1..500d57b262 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx @@ -14,6 +14,10 @@ import { Button, IconButton, Stack, + AutocompleteProps, + InputProps, + OutlinedInputProps, + FilledInputProps, } from "@mui/material"; import { SelectChangeEvent } from "@mui/material/Select"; import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; @@ -25,6 +29,7 @@ import { FormValue } from "./views/FieldForm"; import { FieldSettingsOptions } from "../../../../../../shell/services/types"; import { convertDropdownValue } from "../../utils"; import { withCursorPosition } from "../../../../../../shell/components/withCursorPosition"; +import { Currency } from "../../../../../../shell/components/FieldTypeCurrency/currencies"; const TextFieldWithCursorPosition = withCursorPosition(TextField); @@ -50,6 +55,7 @@ export type FieldNames = | "regexRestrictErrorMessage" | "minValue" | "maxValue" + | "currency" | "fileExtensions" | "fileExtensionsErrorMessage"; type FieldType = @@ -80,7 +86,7 @@ export interface DropdownOptions { label: string; value: string; } -interface FieldFormInputProps { +type FieldFormInputProps = { fieldConfig: InputField; errorMsg?: string | [string, string][]; onDataChange: ({ @@ -91,9 +97,16 @@ interface FieldFormInputProps { value: FormValue; }) => void; prefillData?: FormValue; - dropdownOptions?: DropdownOptions[]; + dropdownOptions?: DropdownOptions[] | Currency[]; disabled?: boolean; -} + autocompleteInputProps?: + | Partial + | Partial + | Partial; +} & Pick< + AutocompleteProps, + "renderOption" | "filterOptions" +>; export const FieldFormInput = ({ fieldConfig, errorMsg, @@ -101,6 +114,9 @@ export const FieldFormInput = ({ prefillData, dropdownOptions, disabled, + renderOption, + filterOptions, + autocompleteInputProps, }: FieldFormInputProps) => { const options = fieldConfig.type === "options" || @@ -214,9 +230,19 @@ export const FieldFormInput = ({ {fieldConfig.type === "autocomplete" && ( <> - - {fieldConfig.label} - + + + {fieldConfig.label} + + {fieldConfig.tooltip && ( + + + + )} + )} isOptionEqualToValue={(option, value) => @@ -248,16 +280,12 @@ export const FieldFormInput = ({ height: "40px", }, }} + renderOption={renderOption} + filterOptions={filterOptions} /> {prefillData && !dropdownOptions.find((option) => option.value === prefillData) && ( - + {fieldConfig.name === "group_id" && "The folder this was locked to has been deleted"} {fieldConfig.name === "relatedModelZUID" && @@ -326,12 +354,9 @@ export const FieldFormInput = ({ error={Boolean(errorMsg)} helperText={ errorMsg && ( - + {errorMsg} - + ) } type={fieldConfig.inputType || "text"} @@ -447,9 +472,9 @@ const KeyValueInput = ({ handleDataChanged("value", e.target?.value); }} helperText={ - + {labelErrorMsg} - + } error={Boolean(labelErrorMsg)} disabled={disabledFields.includes("value")} @@ -465,9 +490,9 @@ const KeyValueInput = ({ handleDataChanged("key", e.target?.value); }} helperText={ - + {valueErrorMsg} - + } error={Boolean(valueErrorMsg)} disabled={disabledFields.includes("key")} diff --git a/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx b/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx index 810467613a..a6b20e607e 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx @@ -13,6 +13,11 @@ import { Button, Grid, Stack, + ListItem, + FilledInputProps, + InputProps, + OutlinedInputProps, + InputAdornment, } from "@mui/material"; import LoadingButton from "@mui/lab/LoadingButton"; import { isEmpty } from "lodash"; @@ -64,6 +69,11 @@ import { DefaultValue } from "../DefaultValue"; import { CharacterLimit } from "../CharacterLimit"; import { Rules } from "./Rules"; import { MaxLengths } from "../../../../../../content-editor/src/app/components/Editor/Editor"; +import { + Currency, + currencies, +} from "../../../../../../../shell/components/FieldTypeCurrency/currencies"; +import getFlagEmoji from "../../../../../../../utility/getFlagEmoji"; type ActiveTab = "details" | "rules" | "learn"; type Params = { @@ -223,6 +233,8 @@ export const FieldForm = ({ formFields[field.name] = fieldData.settings[field.name] ?? null; } else if (field.name === "maxValue") { formFields[field.name] = fieldData.settings[field.name] ?? null; + } else if (field.name === "currency") { + formFields[field.name] = fieldData.settings?.currency ?? "USD"; } else if (field.name === "fileExtensions") { formFields[field.name] = fieldData.settings[field.name] ?? null; } else if (field.name === "fileExtensionsErrorMessage") { @@ -399,6 +411,10 @@ export const FieldForm = ({ } } + if (inputName === "currency" && !formData.currency) { + newErrorsObj[inputName] = "Please select a currency"; + } + if ( inputName === "fileExtensions" && formData.fileExtensions !== null && @@ -427,6 +443,7 @@ export const FieldForm = ({ "regexRestrictErrorMessage", "minValue", "maxValue", + "currency", "fileExtensions", "fileExtensionsErrorMessage", ].includes(inputName) @@ -588,6 +605,9 @@ export const FieldForm = ({ ...(formData.maxValue !== null && { maxValue: formData.maxValue as number, }), + ...(formData.currency !== null && { + currency: formData.currency as string, + }), ...(formData.fileExtensions && { fileExtensions: formData.fileExtensions as string[], }), @@ -790,6 +810,12 @@ export const FieldForm = ({ let dropdownOptions: DropdownOptions[]; let disabled = false; + let renderOption: any; + let filterOptions: any; + let autocompleteInputProps: + | Partial + | Partial + | Partial; if (fieldConfig.name === "relatedModelZUID") { dropdownOptions = modelsOptions; @@ -801,6 +827,73 @@ export const FieldForm = ({ disabled = isFetchingSelectedModelFields; } + if (fieldConfig.name === "currency") { + const selectedValue = currencies.find( + (currency) => currency.value === formData.currency + ); + dropdownOptions = currencies; + renderOption = (props: any, value: Currency) => ( + + + + {value.value} {value.symbol_native}   + + {value.label} + + ); + filterOptions = (options: Currency[], state: any) => { + if (state.inputValue) { + return options.filter( + (option) => + option.label + ?.toLowerCase() + .includes(state.inputValue.toLowerCase()) || + option.value + ?.toLowerCase() + .includes(state.inputValue.toLowerCase()) + ); + } else { + return options; + } + }; + autocompleteInputProps = { + startAdornment: !!selectedValue && ( + + + + {selectedValue.value} {selectedValue.symbol_native} + + + ), + }; + } + return ( ); })} diff --git a/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx b/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx index 23c146ffc8..65c0dab5ee 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx @@ -81,6 +81,7 @@ export const Rules = ({ relatedFieldZUID: formData["relatedFieldZUID"] as string, }} options={formData["options"] as FieldSettingsOptions[]} + currency={(formData["currency"] as string) || "USD"} /> {(type === "text" || type === "textarea") && ( diff --git a/src/apps/schema/src/app/components/configs.ts b/src/apps/schema/src/app/components/configs.ts index ec60c38746..ec94b50998 100644 --- a/src/apps/schema/src/app/components/configs.ts +++ b/src/apps/schema/src/app/components/configs.ts @@ -502,7 +502,24 @@ const FORM_CONFIG: Record = { rules: [...COMMON_RULES], }, currency: { - details: [...COMMON_FIELDS], + details: [ + { + name: "currency", + type: "autocomplete", + label: "Currency", + required: true, + gridSize: 12, + tooltip: + "The selected currency code, symbol, and flag will be displayed for this field in the content item and can be accessed through the field settings via the API.", + placeholder: "Select a Currency", + autoFocus: true, + }, + { + ...COMMON_FIELDS[0], + autoFocus: false, + }, + ...COMMON_FIELDS.slice(1), + ], rules: [...COMMON_RULES, ...INPUT_RANGE_RULES], }, date: { diff --git a/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.js b/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.js deleted file mode 100644 index 19ccb43fea..0000000000 --- a/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.js +++ /dev/null @@ -1,69 +0,0 @@ -import React, { useState } from "react"; - -import { Input } from "@zesty-io/core/Input"; -import { Select, Option } from "@zesty-io/core/Select"; -import { currencies } from "./currencies"; - -import styles from "./FieldTypeCurrency.less"; -export const FieldTypeCurrency = React.memo(function FieldTypeCurrency(props) { - // console.log("FieldTypeCurrency:render"); - - const [monetaryValue, setMonetaryValue] = useState(props.value || "0.00"); - const [currency, setCurrency] = useState( - (props.code && currencies[props.code]) || currencies["USD"] - ); - - return ( - - ); -}); diff --git a/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.less b/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.less deleted file mode 100644 index 03c4c02fe0..0000000000 --- a/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.less +++ /dev/null @@ -1,52 +0,0 @@ -@import "~@zesty-io/core/colors.less"; -@import "~@zesty-io/core/typography.less"; - -.FieldTypeCurrency { - display: flex; - flex-direction: column; - max-width: 300px; - - .FieldTypeCurrencyLabel { - display: flex; - justify-content: space-between; - margin-bottom: 3px; - font-size: @font-size-label; - span { - display: flex; - } - } - - .CurrencyFields { - display: flex; - .SelectCurrency { - width: 100px; - span > span { - background: @zesty-tab-blue; - color: @white; - border: @zesty-tab-blue; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - - // &:active, - // &:focus, - // &:hover { - // span > span { - // color: #5b667d; - // } - // } - - ul { - left: 0; - min-width: 320px; - } - } - .CurrencyInput { - width: 100%; - border-radius: 0px 8px 8px 0px; - } - } -} -.CurrencyInput { - border: 1px solid #f2f4f7; -} diff --git a/src/shell/components/FieldTypeCurrency/currencies.js b/src/shell/components/FieldTypeCurrency/currencies.ts similarity index 61% rename from src/shell/components/FieldTypeCurrency/currencies.js rename to src/shell/components/FieldTypeCurrency/currencies.ts index 10102d6fa9..1630cc386c 100644 --- a/src/shell/components/FieldTypeCurrency/currencies.js +++ b/src/shell/components/FieldTypeCurrency/currencies.ts @@ -1,1064 +1,1193 @@ -export const currencies = { - USD: { +export type Currency = { + symbol: string; + label: string; + symbol_native: string; + decimal_digits: number; + rounding: number; + value: string; + name_plural: string; + countryCode: string; +}; + +export const currencies: Currency[] = [ + { symbol: " $ ", - name: "US Dollar", + label: "US Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "USD", + value: "USD", name_plural: "US dollars", + countryCode: "US", }, - CAD: { + { symbol: "CA$ ", - name: "Canadian Dollar", + label: "Canadian Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "CAD", + value: "CAD", name_plural: "Canadian dollars", + countryCode: "CA", }, - EUR: { + { symbol: " € ", - name: "Euro", + label: "Euro", symbol_native: "€", decimal_digits: 2, rounding: 0, - code: "EUR", + value: "EUR", name_plural: "euros", + countryCode: "EU", }, - AED: { + { symbol: "AED", - name: "United Arab Emirates Dirham", + label: "United Arab Emirates Dirham", symbol_native: "د.إ.‏", decimal_digits: 2, rounding: 0, - code: "AED", + value: "AED", name_plural: "UAE dirhams", + countryCode: "AE", }, - AFN: { + { symbol: "Af ", - name: "Afghan Afghani", + label: "Afghan Afghani", symbol_native: "؋", decimal_digits: 0, rounding: 0, - code: "AFN", + value: "AFN", name_plural: "Afghan Afghanis", + countryCode: "AF", }, - ALL: { + { symbol: "ALL", - name: "Albanian Lek", + label: "Albanian Lek", symbol_native: "Lek", decimal_digits: 0, rounding: 0, - code: "ALL", + value: "ALL", name_plural: "Albanian lekë", + countryCode: "AL", }, - AMD: { + { symbol: "AMD", - name: "Armenian Dram", + label: "Armenian Dram", symbol_native: "դր.", decimal_digits: 0, rounding: 0, - code: "AMD", + value: "AMD", name_plural: "Armenian drams", + countryCode: "AM", }, - ARS: { + { symbol: "AR$", - name: "Argentine Peso", + label: "Argentine Peso", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "ARS", + value: "ARS", name_plural: "Argentine pesos", + countryCode: "AR", }, - AUD: { + { symbol: "AU$", - name: "Australian Dollar", + label: "Australian Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "AUD", + value: "AUD", name_plural: "Australian dollars", + countryCode: "AU", }, - AZN: { + { symbol: "man.", - name: "Azerbaijani Manat", + label: "Azerbaijani Manat", symbol_native: "ман.", decimal_digits: 2, rounding: 0, - code: "AZN", + value: "AZN", name_plural: "Azerbaijani manats", + countryCode: "AZ", }, - BAM: { + { symbol: "KM", - name: "Bosnia-Herzegovina Convertible Mark", + label: "Bosnia-Herzegovina Convertible Mark", symbol_native: "KM", decimal_digits: 2, rounding: 0, - code: "BAM", + value: "BAM", name_plural: "Bosnia-Herzegovina convertible marks", + countryCode: "BA", }, - BDT: { + { symbol: "Tk", - name: "Bangladeshi Taka", + label: "Bangladeshi Taka", symbol_native: "৳", decimal_digits: 2, rounding: 0, - code: "BDT", + value: "BDT", name_plural: "Bangladeshi takas", + countryCode: "BD", }, - BGN: { + { symbol: "BGN", - name: "Bulgarian Lev", + label: "Bulgarian Lev", symbol_native: "лв.", decimal_digits: 2, rounding: 0, - code: "BGN", + value: "BGN", name_plural: "Bulgarian leva", + countryCode: "BG", }, - BHD: { + { symbol: "BD", - name: "Bahraini Dinar", + label: "Bahraini Dinar", symbol_native: "د.ب.‏", decimal_digits: 3, rounding: 0, - code: "BHD", + value: "BHD", name_plural: "Bahraini dinars", + countryCode: "BH", }, - BIF: { + { symbol: "FBu", - name: "Burundian Franc", + label: "Burundian Franc", symbol_native: "FBu", decimal_digits: 0, rounding: 0, - code: "BIF", + value: "BIF", name_plural: "Burundian francs", + countryCode: "BI", }, - BND: { + { symbol: "BN$", - name: "Brunei Dollar", + label: "Brunei Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "BND", + value: "BND", name_plural: "Brunei dollars", + countryCode: "BN", }, - BOB: { + { symbol: "Bs", - name: "Bolivian Boliviano", + label: "Bolivian Boliviano", symbol_native: "Bs", decimal_digits: 2, rounding: 0, - code: "BOB", + value: "BOB", name_plural: "Bolivian bolivianos", + countryCode: "BO", }, - BRL: { + { symbol: "R$", - name: "Brazilian Real", + label: "Brazilian Real", symbol_native: "R$", decimal_digits: 2, rounding: 0, - code: "BRL", + value: "BRL", name_plural: "Brazilian reals", + countryCode: "BR", }, - BWP: { + { symbol: "BWP", - name: "Botswanan Pula", + label: "Botswanan Pula", symbol_native: "P", decimal_digits: 2, rounding: 0, - code: "BWP", + value: "BWP", name_plural: "Botswanan pulas", + countryCode: "BW", }, - BYR: { + { symbol: "BYR", - name: "Belarusian Ruble", + label: "Belarusian Ruble", symbol_native: "BYR", decimal_digits: 0, rounding: 0, - code: "BYR", + value: "BYR", name_plural: "Belarusian rubles", + countryCode: "BY", }, - BZD: { + { symbol: "BZ$", - name: "Belize Dollar", + label: "Belize Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "BZD", + value: "BZD", name_plural: "Belize dollars", + countryCode: "BZ", }, - CDF: { + { symbol: "CDF", - name: "Congolese Franc", + label: "Congolese Franc", symbol_native: "FrCD", decimal_digits: 2, rounding: 0, - code: "CDF", + value: "CDF", name_plural: "Congolese francs", + countryCode: "CD", }, - CHF: { + { symbol: "CHF", - name: "Swiss Franc", + label: "Swiss Franc", symbol_native: "CHF", decimal_digits: 2, rounding: 0.05, - code: "CHF", + value: "CHF", name_plural: "Swiss francs", + countryCode: "CH", }, - CLP: { + { symbol: "CL$", - name: "Chilean Peso", + label: "Chilean Peso", symbol_native: "$", decimal_digits: 0, rounding: 0, - code: "CLP", + value: "CLP", name_plural: "Chilean pesos", + countryCode: "CL", }, - CNY: { + { symbol: "CN¥", - name: "Chinese Yuan", + label: "Chinese Yuan", symbol_native: "CN¥", decimal_digits: 2, rounding: 0, - code: "CNY", + value: "CNY", name_plural: "Chinese yuan", + countryCode: "CN", }, - COP: { + { symbol: "CO$", - name: "Colombian Peso", + label: "Colombian Peso", symbol_native: "$", decimal_digits: 0, rounding: 0, - code: "COP", + value: "COP", name_plural: "Colombian pesos", + countryCode: "CO", }, - CRC: { + { symbol: "₡", - name: "Costa Rican Colón", + label: "Costa Rican Colón", symbol_native: "₡", decimal_digits: 0, rounding: 0, - code: "CRC", + value: "CRC", name_plural: "Costa Rican colóns", + countryCode: "CR", }, - CVE: { + { symbol: "CV$", - name: "Cape Verdean Escudo", + label: "Cape Verdean Escudo", symbol_native: "CV$", decimal_digits: 2, rounding: 0, - code: "CVE", + value: "CVE", name_plural: "Cape Verdean escudos", + countryCode: "CV", }, - CZK: { + { symbol: "Kč", - name: "Czech Republic Koruna", + label: "Czech Republic Koruna", symbol_native: "Kč", decimal_digits: 2, rounding: 0, - code: "CZK", + value: "CZK", name_plural: "Czech Republic korunas", + countryCode: "CZ", }, - DJF: { + { symbol: "Fdj", - name: "Djiboutian Franc", + label: "Djiboutian Franc", symbol_native: "Fdj", decimal_digits: 0, rounding: 0, - code: "DJF", + value: "DJF", name_plural: "Djiboutian francs", + countryCode: "DJ", }, - DKK: { + { symbol: "Dkr", - name: "Danish Krone", + label: "Danish Krone", symbol_native: "kr", decimal_digits: 2, rounding: 0, - code: "DKK", + value: "DKK", name_plural: "Danish kroner", + countryCode: "DK", }, - DOP: { + { symbol: "RD$", - name: "Dominican Peso", + label: "Dominican Peso", symbol_native: "RD$", decimal_digits: 2, rounding: 0, - code: "DOP", + value: "DOP", name_plural: "Dominican pesos", + countryCode: "DO", }, - DZD: { + { symbol: "DA", - name: "Algerian Dinar", + label: "Algerian Dinar", symbol_native: "د.ج.‏", decimal_digits: 2, rounding: 0, - code: "DZD", + value: "DZD", name_plural: "Algerian dinars", + countryCode: "DZ", }, - EEK: { + { symbol: "Ekr", - name: "Estonian Kroon", + label: "Estonian Kroon", symbol_native: "kr", decimal_digits: 2, rounding: 0, - code: "EEK", + value: "EEK", name_plural: "Estonian kroons", + countryCode: "EE", }, - EGP: { + { symbol: "EGP", - name: "Egyptian Pound", + label: "Egyptian Pound", symbol_native: "ج.م.‏", decimal_digits: 2, rounding: 0, - code: "EGP", + value: "EGP", name_plural: "Egyptian pounds", + countryCode: "EG", }, - ERN: { + { symbol: "Nfk", - name: "Eritrean Nakfa", + label: "Eritrean Nakfa", symbol_native: "Nfk", decimal_digits: 2, rounding: 0, - code: "ERN", + value: "ERN", name_plural: "Eritrean nakfas", + countryCode: "ER", }, - ETB: { + { symbol: "Br", - name: "Ethiopian Birr", + label: "Ethiopian Birr", symbol_native: "Br", decimal_digits: 2, rounding: 0, - code: "ETB", + value: "ETB", name_plural: "Ethiopian birrs", + countryCode: "ET", }, - GBP: { + { symbol: "£", - name: "British Pound Sterling", + label: "British Pound Sterling", symbol_native: "£", decimal_digits: 2, rounding: 0, - code: "GBP", + value: "GBP", name_plural: "British pounds sterling", + countryCode: "GB", }, - GEL: { + { symbol: "GEL", - name: "Georgian Lari", + label: "Georgian Lari", symbol_native: "GEL", decimal_digits: 2, rounding: 0, - code: "GEL", + value: "GEL", name_plural: "Georgian laris", + countryCode: "GE", }, - GHS: { + { symbol: "GH₵", - name: "Ghanaian Cedi", + label: "Ghanaian Cedi", symbol_native: "GH₵", decimal_digits: 2, rounding: 0, - code: "GHS", + value: "GHS", name_plural: "Ghanaian cedis", + countryCode: "GH", }, - GNF: { + { symbol: "FG", - name: "Guinean Franc", + label: "Guinean Franc", symbol_native: "FG", decimal_digits: 0, rounding: 0, - code: "GNF", + value: "GNF", name_plural: "Guinean francs", + countryCode: "GN", }, - GTQ: { + { symbol: "GTQ", - name: "Guatemalan Quetzal", + label: "Guatemalan Quetzal", symbol_native: "Q", decimal_digits: 2, rounding: 0, - code: "GTQ", + value: "GTQ", name_plural: "Guatemalan quetzals", + countryCode: "GT", }, - HKD: { + { symbol: "HK$", - name: "Hong Kong Dollar", + label: "Hong Kong Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "HKD", + value: "HKD", name_plural: "Hong Kong dollars", + countryCode: "HK", }, - HNL: { + { symbol: "HNL", - name: "Honduran Lempira", + label: "Honduran Lempira", symbol_native: "L", decimal_digits: 2, rounding: 0, - code: "HNL", + value: "HNL", name_plural: "Honduran lempiras", + countryCode: "HN", }, - HRK: { + { symbol: "kn", - name: "Croatian Kuna", + label: "Croatian Kuna", symbol_native: "kn", decimal_digits: 2, rounding: 0, - code: "HRK", + value: "HRK", name_plural: "Croatian kunas", + countryCode: "HR", }, - HUF: { + { symbol: "Ft", - name: "Hungarian Forint", + label: "Hungarian Forint", symbol_native: "Ft", decimal_digits: 0, rounding: 0, - code: "HUF", + value: "HUF", name_plural: "Hungarian forints", + countryCode: "HU", }, - IDR: { + { symbol: "Rp", - name: "Indonesian Rupiah", + label: "Indonesian Rupiah", symbol_native: "Rp", decimal_digits: 0, rounding: 0, - code: "IDR", + value: "IDR", name_plural: "Indonesian rupiahs", + countryCode: "ID", }, - ILS: { + { symbol: "₪", - name: "Israeli New Sheqel", + label: "Israeli New Sheqel", symbol_native: "₪", decimal_digits: 2, rounding: 0, - code: "ILS", + value: "ILS", name_plural: "Israeli new sheqels", + countryCode: "IL", }, - INR: { + { symbol: "Rs", - name: "Indian Rupee", - symbol_native: "টকা", + label: "Indian Rupee", + symbol_native: "₹", decimal_digits: 2, rounding: 0, - code: "INR", + value: "INR", name_plural: "Indian rupees", + countryCode: "IN", }, - IQD: { + { symbol: "IQD", - name: "Iraqi Dinar", + label: "Iraqi Dinar", symbol_native: "د.ع.‏", decimal_digits: 0, rounding: 0, - code: "IQD", + value: "IQD", name_plural: "Iraqi dinars", + countryCode: "IQ", }, - IRR: { + { symbol: "IRR", - name: "Iranian Rial", + label: "Iranian Rial", symbol_native: "﷼", decimal_digits: 0, rounding: 0, - code: "IRR", + value: "IRR", name_plural: "Iranian rials", + countryCode: "IR", }, - ISK: { + { symbol: "Ikr", - name: "Icelandic Króna", + label: "Icelandic Króna", symbol_native: "kr", decimal_digits: 0, rounding: 0, - code: "ISK", + value: "ISK", name_plural: "Icelandic krónur", + countryCode: "IS", }, - JMD: { + { symbol: "J$", - name: "Jamaican Dollar", + label: "Jamaican Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "JMD", + value: "JMD", name_plural: "Jamaican dollars", + countryCode: "JM", }, - JOD: { + { symbol: "JD", - name: "Jordanian Dinar", + label: "Jordanian Dinar", symbol_native: "د.أ.‏", decimal_digits: 3, rounding: 0, - code: "JOD", + value: "JOD", name_plural: "Jordanian dinars", + countryCode: "JO", }, - JPY: { + { symbol: "¥", - name: "Japanese Yen", + label: "Japanese Yen", symbol_native: "¥", decimal_digits: 0, rounding: 0, - code: "JPY", + value: "JPY", name_plural: "Japanese yen", + countryCode: "JP", }, - KES: { + { symbol: "Ksh", - name: "Kenyan Shilling", + label: "Kenyan Shilling", symbol_native: "Ksh", decimal_digits: 2, rounding: 0, - code: "KES", + value: "KES", name_plural: "Kenyan shillings", + countryCode: "KE", }, - KHR: { + { symbol: "KHR", - name: "Cambodian Riel", + label: "Cambodian Riel", symbol_native: "៛", decimal_digits: 2, rounding: 0, - code: "KHR", + value: "KHR", name_plural: "Cambodian riels", + countryCode: "KH", }, - KMF: { + { symbol: "CF", - name: "Comorian Franc", + label: "Comorian Franc", symbol_native: "FC", decimal_digits: 0, rounding: 0, - code: "KMF", + value: "KMF", name_plural: "Comorian francs", + countryCode: "KM", }, - KRW: { + { symbol: "₩", - name: "South Korean Won", + label: "South Korean Won", symbol_native: "₩", decimal_digits: 0, rounding: 0, - code: "KRW", + value: "KRW", name_plural: "South Korean won", + countryCode: "KR", }, - KWD: { + { symbol: "KD", - name: "Kuwaiti Dinar", + label: "Kuwaiti Dinar", symbol_native: "د.ك.‏", decimal_digits: 3, rounding: 0, - code: "KWD", + value: "KWD", name_plural: "Kuwaiti dinars", + countryCode: "KW", }, - KZT: { + { symbol: "KZT", - name: "Kazakhstani Tenge", + label: "Kazakhstani Tenge", symbol_native: "тңг.", decimal_digits: 2, rounding: 0, - code: "KZT", + value: "KZT", name_plural: "Kazakhstani tenges", + countryCode: "KZ", }, - LBP: { + { symbol: "LB£", - name: "Lebanese Pound", + label: "Lebanese Pound", symbol_native: "ل.ل.‏", decimal_digits: 0, rounding: 0, - code: "LBP", + value: "LBP", name_plural: "Lebanese pounds", + countryCode: "LB", }, - LKR: { + { symbol: "SLRs", - name: "Sri Lankan Rupee", + label: "Sri Lankan Rupee", symbol_native: "SL Re", decimal_digits: 2, rounding: 0, - code: "LKR", + value: "LKR", name_plural: "Sri Lankan rupees", + countryCode: "LK", }, - LTL: { + { symbol: "Lt", - name: "Lithuanian Litas", + label: "Lithuanian Litas", symbol_native: "Lt", decimal_digits: 2, rounding: 0, - code: "LTL", + value: "LTL", name_plural: "Lithuanian litai", + countryCode: "LT", }, - LVL: { + { symbol: "Ls", - name: "Latvian Lats", + label: "Latvian Lats", symbol_native: "Ls", decimal_digits: 2, rounding: 0, - code: "LVL", + value: "LVL", name_plural: "Latvian lati", + countryCode: "LV", }, - LYD: { + { symbol: "LD", - name: "Libyan Dinar", + label: "Libyan Dinar", symbol_native: "د.ل.‏", decimal_digits: 3, rounding: 0, - code: "LYD", + value: "LYD", name_plural: "Libyan dinars", + countryCode: "LY", }, - MAD: { + { symbol: "MAD", - name: "Moroccan Dirham", + label: "Moroccan Dirham", symbol_native: "د.م.‏", decimal_digits: 2, rounding: 0, - code: "MAD", + value: "MAD", name_plural: "Moroccan dirhams", + countryCode: "MA", }, - MDL: { + { symbol: "MDL", - name: "Moldovan Leu", + label: "Moldovan Leu", symbol_native: "MDL", decimal_digits: 2, rounding: 0, - code: "MDL", + value: "MDL", name_plural: "Moldovan lei", + countryCode: "MD", }, - MGA: { + { symbol: "MGA", - name: "Malagasy Ariary", + label: "Malagasy Ariary", symbol_native: "MGA", decimal_digits: 0, rounding: 0, - code: "MGA", + value: "MGA", name_plural: "Malagasy Ariaries", + countryCode: "MG", }, - MKD: { + { symbol: "MKD", - name: "Macedonian Denar", - symbol_native: "MKD", + label: "Macedonian Denar", + symbol_native: "ден", decimal_digits: 2, rounding: 0, - code: "MKD", + value: "MKD", name_plural: "Macedonian denari", + countryCode: "MK", }, - MMK: { + { symbol: "MMK", - name: "Myanma Kyat", + label: "Myanma Kyat", symbol_native: "K", decimal_digits: 0, rounding: 0, - code: "MMK", + value: "MMK", name_plural: "Myanma kyats", + countryCode: "MM", }, - MOP: { + { symbol: "MOP$", - name: "Macanese Pataca", + label: "Macanese Pataca", symbol_native: "MOP$", decimal_digits: 2, rounding: 0, - code: "MOP", + value: "MOP", name_plural: "Macanese patacas", + countryCode: "MO", }, - MUR: { + { symbol: "MURs", - name: "Mauritian Rupee", + label: "Mauritian Rupee", symbol_native: "MURs", decimal_digits: 0, rounding: 0, - code: "MUR", + value: "MUR", name_plural: "Mauritian rupees", + countryCode: "MU", }, - MXN: { + { symbol: "MX$", - name: "Mexican Peso", + label: "Mexican Peso", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "MXN", + value: "MXN", name_plural: "Mexican pesos", + countryCode: "MX", }, - MYR: { + { symbol: "RM", - name: "Malaysian Ringgit", + label: "Malaysian Ringgit", symbol_native: "RM", decimal_digits: 2, rounding: 0, - code: "MYR", + value: "MYR", name_plural: "Malaysian ringgits", + countryCode: "MY", }, - MZN: { + { symbol: "MTn", - name: "Mozambican Metical", + label: "Mozambican Metical", symbol_native: "MTn", decimal_digits: 2, rounding: 0, - code: "MZN", + value: "MZN", name_plural: "Mozambican meticals", + countryCode: "MZ", }, - NAD: { + { symbol: "N$", - name: "Namibian Dollar", + label: "Namibian Dollar", symbol_native: "N$", decimal_digits: 2, rounding: 0, - code: "NAD", + value: "NAD", name_plural: "Namibian dollars", + countryCode: "NA", }, - NGN: { + { symbol: "₦", - name: "Nigerian Naira", + label: "Nigerian Naira", symbol_native: "₦", decimal_digits: 2, rounding: 0, - code: "NGN", + value: "NGN", name_plural: "Nigerian nairas", + countryCode: "NG", }, - NIO: { + { symbol: "C$", - name: "Nicaraguan Córdoba", + label: "Nicaraguan Córdoba", symbol_native: "C$", decimal_digits: 2, rounding: 0, - code: "NIO", + value: "NIO", name_plural: "Nicaraguan córdobas", + countryCode: "NI", }, - NOK: { + { symbol: "Nkr", - name: "Norwegian Krone", + label: "Norwegian Krone", symbol_native: "kr", decimal_digits: 2, rounding: 0, - code: "NOK", + value: "NOK", name_plural: "Norwegian kroner", + countryCode: "NO", }, - NPR: { + { symbol: "NPRs", - name: "Nepalese Rupee", + label: "Nepalese Rupee", symbol_native: "नेरू", decimal_digits: 2, rounding: 0, - code: "NPR", + value: "NPR", name_plural: "Nepalese rupees", + countryCode: "NP", }, - NZD: { + { symbol: "NZ$", - name: "New Zealand Dollar", + label: "New Zealand Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "NZD", + value: "NZD", name_plural: "New Zealand dollars", + countryCode: "NZ", }, - OMR: { + { symbol: "OMR", - name: "Omani Rial", + label: "Omani Rial", symbol_native: "ر.ع.‏", decimal_digits: 3, rounding: 0, - code: "OMR", + value: "OMR", name_plural: "Omani rials", + countryCode: "OM", }, - PAB: { + { symbol: "B/.", - name: "Panamanian Balboa", + label: "Panamanian Balboa", symbol_native: "B/.", decimal_digits: 2, rounding: 0, - code: "PAB", + value: "PAB", name_plural: "Panamanian balboas", + countryCode: "PA", }, - PEN: { + { symbol: "S/.", - name: "Peruvian Nuevo Sol", + label: "Peruvian Nuevo Sol", symbol_native: "S/.", decimal_digits: 2, rounding: 0, - code: "PEN", + value: "PEN", name_plural: "Peruvian nuevos soles", + countryCode: "PE", }, - PHP: { + { symbol: "₱", - name: "Philippine Peso", + label: "Philippine Peso", symbol_native: "₱", decimal_digits: 2, rounding: 0, - code: "PHP", + value: "PHP", name_plural: "Philippine pesos", + countryCode: "PH", }, - PKR: { + { symbol: "PKRs", - name: "Pakistani Rupee", + label: "Pakistani Rupee", symbol_native: "₨", decimal_digits: 0, rounding: 0, - code: "PKR", + value: "PKR", name_plural: "Pakistani rupees", + countryCode: "PK", }, - PLN: { + { symbol: "zł", - name: "Polish Zloty", + label: "Polish Zloty", symbol_native: "zł", decimal_digits: 2, rounding: 0, - code: "PLN", + value: "PLN", name_plural: "Polish zlotys", + countryCode: "PL", }, - PYG: { + { symbol: "₲", - name: "Paraguayan Guarani", + label: "Paraguayan Guarani", symbol_native: "₲", decimal_digits: 0, rounding: 0, - code: "PYG", + value: "PYG", name_plural: "Paraguayan guaranis", + countryCode: "PY", }, - QAR: { + { symbol: "QR", - name: "Qatari Rial", + label: "Qatari Rial", symbol_native: "ر.ق.‏", decimal_digits: 2, rounding: 0, - code: "QAR", + value: "QAR", name_plural: "Qatari rials", + countryCode: "QA", }, - RON: { + { symbol: "RON", - name: "Romanian Leu", + label: "Romanian Leu", symbol_native: "RON", decimal_digits: 2, rounding: 0, - code: "RON", + value: "RON", name_plural: "Romanian lei", + countryCode: "RO", }, - RSD: { + { symbol: "din.", - name: "Serbian Dinar", + label: "Serbian Dinar", symbol_native: "дин.", decimal_digits: 0, rounding: 0, - code: "RSD", + value: "RSD", name_plural: "Serbian dinars", + countryCode: "RS", }, - RUB: { + { symbol: "RUB", - name: "Russian Ruble", + label: "Russian Ruble", symbol_native: "руб.", decimal_digits: 2, rounding: 0, - code: "RUB", + value: "RUB", name_plural: "Russian rubles", + countryCode: "RU", }, - RWF: { + { symbol: "RWF", - name: "Rwandan Franc", + label: "Rwandan Franc", symbol_native: "FR", decimal_digits: 0, rounding: 0, - code: "RWF", + value: "RWF", name_plural: "Rwandan francs", + countryCode: "RW", }, - SAR: { + { symbol: "SR", - name: "Saudi Riyal", + label: "Saudi Riyal", symbol_native: "ر.س.‏", decimal_digits: 2, rounding: 0, - code: "SAR", + value: "SAR", name_plural: "Saudi riyals", + countryCode: "SA", }, - SDG: { + { symbol: "SDG", - name: "Sudanese Pound", + label: "Sudanese Pound", symbol_native: "SDG", decimal_digits: 2, rounding: 0, - code: "SDG", + value: "SDG", name_plural: "Sudanese pounds", + countryCode: "SD", }, - SEK: { + { symbol: "Skr", - name: "Swedish Krona", + label: "Swedish Krona", symbol_native: "kr", decimal_digits: 2, rounding: 0, - code: "SEK", + value: "SEK", name_plural: "Swedish kronor", + countryCode: "SE", }, - SGD: { + { symbol: "S$", - name: "Singapore Dollar", + label: "Singapore Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "SGD", + value: "SGD", name_plural: "Singapore dollars", + countryCode: "SG", }, - SOS: { + { symbol: "Ssh", - name: "Somali Shilling", + label: "Somali Shilling", symbol_native: "Ssh", decimal_digits: 0, rounding: 0, - code: "SOS", + value: "SOS", name_plural: "Somali shillings", + countryCode: "SO", }, - SYP: { + { symbol: "SY£", - name: "Syrian Pound", + label: "Syrian Pound", symbol_native: "ل.س.‏", decimal_digits: 0, rounding: 0, - code: "SYP", + value: "SYP", name_plural: "Syrian pounds", + countryCode: "SY", }, - THB: { + { symbol: "฿", - name: "Thai Baht", + label: "Thai Baht", symbol_native: "฿", decimal_digits: 2, rounding: 0, - code: "THB", + value: "THB", name_plural: "Thai baht", + countryCode: "TH", }, - TND: { + { symbol: "DT", - name: "Tunisian Dinar", + label: "Tunisian Dinar", symbol_native: "د.ت.‏", decimal_digits: 3, rounding: 0, - code: "TND", + value: "TND", name_plural: "Tunisian dinars", + countryCode: "TN", }, - TOP: { + { symbol: "T$", - name: "Tongan Paʻanga", + label: "Tongan Paʻanga", symbol_native: "T$", decimal_digits: 2, rounding: 0, - code: "TOP", + value: "TOP", name_plural: "Tongan paʻanga", + countryCode: "TO", }, - TRY: { + { symbol: "TL", - name: "Turkish Lira", + label: "Turkish Lira", symbol_native: "TL", decimal_digits: 2, rounding: 0, - code: "TRY", + value: "TRY", name_plural: "Turkish Lira", + countryCode: "TR", }, - TTD: { + { symbol: "TT$", - name: "Trinidad and Tobago Dollar", + label: "Trinidad and Tobago Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "TTD", + value: "TTD", name_plural: "Trinidad and Tobago dollars", + countryCode: "TT", }, - TWD: { + { symbol: "NT$", - name: "New Taiwan Dollar", + label: "New Taiwan Dollar", symbol_native: "NT$", decimal_digits: 2, rounding: 0, - code: "TWD", + value: "TWD", name_plural: "New Taiwan dollars", + countryCode: "TW", }, - TZS: { + { symbol: "TSh", - name: "Tanzanian Shilling", + label: "Tanzanian Shilling", symbol_native: "TSh", decimal_digits: 0, rounding: 0, - code: "TZS", + value: "TZS", name_plural: "Tanzanian shillings", + countryCode: "TZ", }, - UAH: { + { symbol: "₴", - name: "Ukrainian Hryvnia", + label: "Ukrainian Hryvnia", symbol_native: "₴", decimal_digits: 2, rounding: 0, - code: "UAH", + value: "UAH", name_plural: "Ukrainian hryvnias", + countryCode: "UA", }, - UGX: { + { symbol: "USh", - name: "Ugandan Shilling", + label: "Ugandan Shilling", symbol_native: "USh", decimal_digits: 0, rounding: 0, - code: "UGX", + value: "UGX", name_plural: "Ugandan shillings", + countryCode: "UG", }, - UYU: { + { symbol: "$U", - name: "Uruguayan Peso", + label: "Uruguayan Peso", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "UYU", + value: "UYU", name_plural: "Uruguayan pesos", + countryCode: "UY", }, - UZS: { + { symbol: "UZS", - name: "Uzbekistan Som", + label: "Uzbekistan Som", symbol_native: "UZS", decimal_digits: 0, rounding: 0, - code: "UZS", + value: "UZS", name_plural: "Uzbekistan som", + countryCode: "UZ", }, - VEF: { + { symbol: "Bs.F.", - name: "Venezuelan Bolívar", + label: "Venezuelan Bolívar", symbol_native: "Bs.F.", decimal_digits: 2, rounding: 0, - code: "VEF", + value: "VEF", name_plural: "Venezuelan bolívars", + countryCode: "VE", }, - VND: { + { symbol: "₫", - name: "Vietnamese Dong", + label: "Vietnamese Dong", symbol_native: "₫", decimal_digits: 0, rounding: 0, - code: "VND", + value: "VND", name_plural: "Vietnamese dong", + countryCode: "VN", }, - XAF: { + { symbol: "FCFA", - name: "CFA Franc BEAC", + label: "CFA Franc BEAC", symbol_native: "FCFA", decimal_digits: 0, rounding: 0, - code: "XAF", + value: "XAF", name_plural: "CFA francs BEAC", + countryCode: "CF", }, - XOF: { + { symbol: "CFA", - name: "CFA Franc BCEAO", + label: "CFA Franc BCEAO", symbol_native: "CFA", decimal_digits: 0, rounding: 0, - code: "XOF", + value: "XOF", name_plural: "CFA francs BCEAO", + countryCode: "CI", // We're defaulting to Ivory Coast as the country for this currency }, - YER: { + { symbol: "YR", - name: "Yemeni Rial", + label: "Yemeni Rial", symbol_native: "ر.ي.‏", decimal_digits: 0, rounding: 0, - code: "YER", + value: "YER", name_plural: "Yemeni rials", + countryCode: "YE", }, - ZAR: { + { symbol: "R", - name: "South African Rand", + label: "South African Rand", symbol_native: "R", decimal_digits: 2, rounding: 0, - code: "ZAR", + value: "ZAR", name_plural: "South African rand", + countryCode: "ZA", }, - ZMK: { + { symbol: "ZK", - name: "Zambian Kwacha", + label: "Zambian Kwacha", symbol_native: "ZK", decimal_digits: 0, rounding: 0, - code: "ZMK", + value: "ZMK", name_plural: "Zambian kwachas", + countryCode: "ZM", }, -}; +]; diff --git a/src/shell/components/FieldTypeCurrency/index.js b/src/shell/components/FieldTypeCurrency/index.js deleted file mode 100644 index ad300a9373..0000000000 --- a/src/shell/components/FieldTypeCurrency/index.js +++ /dev/null @@ -1 +0,0 @@ -export { FieldTypeCurrency } from "./FieldTypeCurrency"; diff --git a/src/shell/components/FieldTypeCurrency/index.tsx b/src/shell/components/FieldTypeCurrency/index.tsx new file mode 100644 index 0000000000..a74cad89c9 --- /dev/null +++ b/src/shell/components/FieldTypeCurrency/index.tsx @@ -0,0 +1,64 @@ +import { useMemo } from "react"; +import { TextField, Typography, Box, Stack } from "@mui/material"; + +import { currencies } from "./currencies"; +import { NumberFormatInput } from "../NumberFormatInput"; + +type FieldTypeCurrencyProps = { + name: string; + value: string; + currency: string; + error: boolean; + onChange: (value: string, name: string) => void; +}; +export const FieldTypeCurrency = ({ + name, + currency, + value, + error, + onChange, + ...otherProps +}: FieldTypeCurrencyProps) => { + const selectedCurrency = useMemo(() => { + return currencies.find((_currency) => _currency.value === currency); + }, [currency]); + + return ( + onChange(evt?.target?.value?.value, name)} + InputProps={{ + inputComponent: NumberFormatInput as any, + inputProps: { + thousandSeparator: true, + valueIsNumericString: true, + }, + startAdornment: ( + + {selectedCurrency?.symbol_native} + + ), + endAdornment: ( + + + + {selectedCurrency.value} + + + ), + }} + /> + ); +}; diff --git a/src/shell/components/FieldTypeNumber.tsx b/src/shell/components/FieldTypeNumber.tsx index aacf8169f1..5329ea7020 100644 --- a/src/shell/components/FieldTypeNumber.tsx +++ b/src/shell/components/FieldTypeNumber.tsx @@ -66,8 +66,10 @@ export const FieldTypeNumber = ({ value={value || 0} name={name} required={required} - onChange={(evt) => { - onChange(+evt.target.value?.toString()?.replace(/^0+/, "") ?? 0, name); + onChange={(evt: any) => { + const value = evt?.target?.value?.floatValue ?? 0; + + onChange(+value?.toString()?.replace(/^0+/, "") ?? 0, name); }} onKeyDown={(evt) => { if ((evt.key === "Backspace" || evt.key === "Delete") && value === 0) { diff --git a/src/shell/components/NumberFormatInput/index.tsx b/src/shell/components/NumberFormatInput/index.tsx index 6efa07b581..db7effd76a 100644 --- a/src/shell/components/NumberFormatInput/index.tsx +++ b/src/shell/components/NumberFormatInput/index.tsx @@ -3,10 +3,17 @@ import { NumericFormatProps, InputAttributes, NumericFormat, + NumberFormatValues, } from "react-number-format"; +export type NumberFormatInputEvent = { + target: { + name: string; + value: NumberFormatValues; + }; +}; type NumberFormatInputProps = { - onChange: (event: { target: { name: string; value: number } }) => void; + onChange: (event: NumberFormatInputEvent) => void; name: string; }; export const NumberFormatInput = forwardRef< @@ -23,7 +30,7 @@ export const NumberFormatInput = forwardRef< onChange({ target: { name: props.name, - value: values.floatValue || 0, + value: values, }, }); }} diff --git a/src/shell/services/types.ts b/src/shell/services/types.ts index b31e07a159..1af2f44da7 100644 --- a/src/shell/services/types.ts +++ b/src/shell/services/types.ts @@ -203,6 +203,7 @@ export interface FieldSettings { regexRestrictErrorMessage?: string; minValue?: number; maxValue?: number; + currency?: string; fileExtensions?: string[]; fileExtensionsErrorMessage?: string; } diff --git a/src/utility/getFlagEmoji.ts b/src/utility/getFlagEmoji.ts new file mode 100644 index 0000000000..dd4b9854ec --- /dev/null +++ b/src/utility/getFlagEmoji.ts @@ -0,0 +1,9 @@ +export default (countryCode: string) => { + // Convert country code to flag emoji. + // Unicode flag emojis are made up of regional indicator symbols, which are a sequence of two letters. + const baseOffset = 0x1f1e6; + return ( + String.fromCodePoint(baseOffset + (countryCode.charCodeAt(0) - 65)) + + String.fromCodePoint(baseOffset + (countryCode.charCodeAt(1) - 65)) + ); +}; From bae5cea981c690e94dfacf9677f09545bda39be0 Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Fri, 26 Jul 2024 12:32:55 +0800 Subject: [PATCH 08/44] [Schema] Currency field vqa updates (#2883) - Set max-height to 256px - Changed US Dollar to United States Dollar - Sort options alphabetically ### Demo ![image](https://github.com/user-attachments/assets/afcc1a0f-af5a-4356-8941-8ccb1a64bbd9) ![image](https://github.com/user-attachments/assets/686383fc-0d54-4f28-a9bc-d5b4d55373dd) --- .../AddFieldModal/FieldFormInput.tsx | 26 +- .../AddFieldModal/views/FieldForm.tsx | 16 +- .../FieldTypeCurrency/currencies.ts | 932 +++++++++--------- 3 files changed, 495 insertions(+), 479 deletions(-) diff --git a/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx b/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx index 500d57b262..7517f00c6d 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx @@ -86,6 +86,13 @@ export interface DropdownOptions { label: string; value: string; } +export type AutocompleteConfig = { + inputProps?: + | Partial + | Partial + | Partial; + maxHeight?: number; +}; type FieldFormInputProps = { fieldConfig: InputField; errorMsg?: string | [string, string][]; @@ -99,10 +106,7 @@ type FieldFormInputProps = { prefillData?: FormValue; dropdownOptions?: DropdownOptions[] | Currency[]; disabled?: boolean; - autocompleteInputProps?: - | Partial - | Partial - | Partial; + autocompleteConfig?: AutocompleteConfig; } & Pick< AutocompleteProps, "renderOption" | "filterOptions" @@ -116,7 +120,7 @@ export const FieldFormInput = ({ disabled, renderOption, filterOptions, - autocompleteInputProps, + autocompleteConfig, }: FieldFormInputProps) => { const options = fieldConfig.type === "options" || @@ -262,7 +266,7 @@ export const FieldFormInput = ({ helperText={errorMsg} InputProps={{ ...params.InputProps, - ...autocompleteInputProps, + ...autocompleteConfig?.inputProps, }} /> )} @@ -282,6 +286,16 @@ export const FieldFormInput = ({ }} renderOption={renderOption} filterOptions={filterOptions} + slotProps={{ + paper: { + sx: { + "& .MuiAutocomplete-listbox": { + maxHeight: autocompleteConfig?.maxHeight || "40vh", + boxSizing: "border-box", + }, + }, + }, + }} /> {prefillData && !dropdownOptions.find((option) => option.value === prefillData) && ( diff --git a/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx b/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx index a6b20e607e..661edcf3ff 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx @@ -32,7 +32,11 @@ import PauseCircleOutlineRoundedIcon from "@mui/icons-material/PauseCircleOutlin import PlayCircleOutlineRoundedIcon from "@mui/icons-material/PlayCircleOutlineRounded"; import { FieldIcon } from "../../Field/FieldIcon"; -import { FieldFormInput, DropdownOptions } from "../FieldFormInput"; +import { + FieldFormInput, + DropdownOptions, + AutocompleteConfig, +} from "../FieldFormInput"; import { useMediaRules } from "../../hooks/useMediaRules"; import { MediaRules } from "../MediaRules"; import { @@ -812,10 +816,7 @@ export const FieldForm = ({ let disabled = false; let renderOption: any; let filterOptions: any; - let autocompleteInputProps: - | Partial - | Partial - | Partial; + let autocompleteConfig: AutocompleteConfig = {}; if (fieldConfig.name === "relatedModelZUID") { dropdownOptions = modelsOptions; @@ -866,7 +867,7 @@ export const FieldForm = ({ return options; } }; - autocompleteInputProps = { + autocompleteConfig.inputProps = { startAdornment: !!selectedValue && ( ), }; + autocompleteConfig.maxHeight = 256; } return ( @@ -905,7 +907,7 @@ export const FieldForm = ({ disabled={disabled} renderOption={renderOption} filterOptions={filterOptions} - autocompleteInputProps={autocompleteInputProps} + autocompleteConfig={autocompleteConfig} /> ); })} diff --git a/src/shell/components/FieldTypeCurrency/currencies.ts b/src/shell/components/FieldTypeCurrency/currencies.ts index 1630cc386c..ab439b5265 100644 --- a/src/shell/components/FieldTypeCurrency/currencies.ts +++ b/src/shell/components/FieldTypeCurrency/currencies.ts @@ -10,46 +10,6 @@ export type Currency = { }; export const currencies: Currency[] = [ - { - symbol: " $ ", - label: "US Dollar", - symbol_native: "$", - decimal_digits: 2, - rounding: 0, - value: "USD", - name_plural: "US dollars", - countryCode: "US", - }, - { - symbol: "CA$ ", - label: "Canadian Dollar", - symbol_native: "$", - decimal_digits: 2, - rounding: 0, - value: "CAD", - name_plural: "Canadian dollars", - countryCode: "CA", - }, - { - symbol: " € ", - label: "Euro", - symbol_native: "€", - decimal_digits: 2, - rounding: 0, - value: "EUR", - name_plural: "euros", - countryCode: "EU", - }, - { - symbol: "AED", - label: "United Arab Emirates Dirham", - symbol_native: "د.إ.‏", - decimal_digits: 2, - rounding: 0, - value: "AED", - name_plural: "UAE dirhams", - countryCode: "AE", - }, { symbol: "Af ", label: "Afghan Afghani", @@ -71,14 +31,14 @@ export const currencies: Currency[] = [ countryCode: "AL", }, { - symbol: "AMD", - label: "Armenian Dram", - symbol_native: "դր.", - decimal_digits: 0, + symbol: "DA", + label: "Algerian Dinar", + symbol_native: "د.ج.‏", + decimal_digits: 2, rounding: 0, - value: "AMD", - name_plural: "Armenian drams", - countryCode: "AM", + value: "DZD", + name_plural: "Algerian dinars", + countryCode: "DZ", }, { symbol: "AR$", @@ -90,6 +50,16 @@ export const currencies: Currency[] = [ name_plural: "Argentine pesos", countryCode: "AR", }, + { + symbol: "AMD", + label: "Armenian Dram", + symbol_native: "դր.", + decimal_digits: 0, + rounding: 0, + value: "AMD", + name_plural: "Armenian drams", + countryCode: "AM", + }, { symbol: "AU$", label: "Australian Dollar", @@ -111,14 +81,14 @@ export const currencies: Currency[] = [ countryCode: "AZ", }, { - symbol: "KM", - label: "Bosnia-Herzegovina Convertible Mark", - symbol_native: "KM", - decimal_digits: 2, + symbol: "BD", + label: "Bahraini Dinar", + symbol_native: "د.ب.‏", + decimal_digits: 3, rounding: 0, - value: "BAM", - name_plural: "Bosnia-Herzegovina convertible marks", - countryCode: "BA", + value: "BHD", + name_plural: "Bahraini dinars", + countryCode: "BH", }, { symbol: "Tk", @@ -131,44 +101,24 @@ export const currencies: Currency[] = [ countryCode: "BD", }, { - symbol: "BGN", - label: "Bulgarian Lev", - symbol_native: "лв.", - decimal_digits: 2, - rounding: 0, - value: "BGN", - name_plural: "Bulgarian leva", - countryCode: "BG", - }, - { - symbol: "BD", - label: "Bahraini Dinar", - symbol_native: "د.ب.‏", - decimal_digits: 3, - rounding: 0, - value: "BHD", - name_plural: "Bahraini dinars", - countryCode: "BH", - }, - { - symbol: "FBu", - label: "Burundian Franc", - symbol_native: "FBu", + symbol: "BYR", + label: "Belarusian Ruble", + symbol_native: "BYR", decimal_digits: 0, rounding: 0, - value: "BIF", - name_plural: "Burundian francs", - countryCode: "BI", + value: "BYR", + name_plural: "Belarusian rubles", + countryCode: "BY", }, { - symbol: "BN$", - label: "Brunei Dollar", + symbol: "BZ$", + label: "Belize Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - value: "BND", - name_plural: "Brunei dollars", - countryCode: "BN", + value: "BZD", + name_plural: "Belize dollars", + countryCode: "BZ", }, { symbol: "Bs", @@ -181,14 +131,14 @@ export const currencies: Currency[] = [ countryCode: "BO", }, { - symbol: "R$", - label: "Brazilian Real", - symbol_native: "R$", + symbol: "KM", + label: "Bosnia-Herzegovina Convertible Mark", + symbol_native: "KM", decimal_digits: 2, rounding: 0, - value: "BRL", - name_plural: "Brazilian reals", - countryCode: "BR", + value: "BAM", + name_plural: "Bosnia-Herzegovina convertible marks", + countryCode: "BA", }, { symbol: "BWP", @@ -201,44 +151,104 @@ export const currencies: Currency[] = [ countryCode: "BW", }, { - symbol: "BYR", - label: "Belarusian Ruble", - symbol_native: "BYR", - decimal_digits: 0, + symbol: "R$", + label: "Brazilian Real", + symbol_native: "R$", + decimal_digits: 2, rounding: 0, - value: "BYR", - name_plural: "Belarusian rubles", - countryCode: "BY", + value: "BRL", + name_plural: "Brazilian reals", + countryCode: "BR", }, { - symbol: "BZ$", - label: "Belize Dollar", + symbol: "£", + label: "British Pound Sterling", + symbol_native: "£", + decimal_digits: 2, + rounding: 0, + value: "GBP", + name_plural: "British pounds sterling", + countryCode: "GB", + }, + { + symbol: "BN$", + label: "Brunei Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - value: "BZD", - name_plural: "Belize dollars", - countryCode: "BZ", + value: "BND", + name_plural: "Brunei dollars", + countryCode: "BN", }, { - symbol: "CDF", - label: "Congolese Franc", - symbol_native: "FrCD", + symbol: "BGN", + label: "Bulgarian Lev", + symbol_native: "лв.", decimal_digits: 2, rounding: 0, - value: "CDF", - name_plural: "Congolese francs", - countryCode: "CD", + value: "BGN", + name_plural: "Bulgarian leva", + countryCode: "BG", }, { - symbol: "CHF", - label: "Swiss Franc", - symbol_native: "CHF", + symbol: "FBu", + label: "Burundian Franc", + symbol_native: "FBu", + decimal_digits: 0, + rounding: 0, + value: "BIF", + name_plural: "Burundian francs", + countryCode: "BI", + }, + { + symbol: "KHR", + label: "Cambodian Riel", + symbol_native: "៛", decimal_digits: 2, - rounding: 0.05, - value: "CHF", - name_plural: "Swiss francs", - countryCode: "CH", + rounding: 0, + value: "KHR", + name_plural: "Cambodian riels", + countryCode: "KH", + }, + { + symbol: "CA$ ", + label: "Canadian Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + value: "CAD", + name_plural: "Canadian dollars", + countryCode: "CA", + }, + { + symbol: "CV$", + label: "Cape Verdean Escudo", + symbol_native: "CV$", + decimal_digits: 2, + rounding: 0, + value: "CVE", + name_plural: "Cape Verdean escudos", + countryCode: "CV", + }, + { + symbol: "CFA", + label: "CFA Franc BCEAO", + symbol_native: "CFA", + decimal_digits: 0, + rounding: 0, + value: "XOF", + name_plural: "CFA francs BCEAO", + countryCode: "CI", + }, + { + symbol: "FCFA", + label: "CFA Franc BEAC", + symbol_native: "FCFA", + decimal_digits: 0, + rounding: 0, + value: "XAF", + name_plural: "CFA francs BEAC", + countryCode: "CF", }, { symbol: "CL$", @@ -270,6 +280,26 @@ export const currencies: Currency[] = [ name_plural: "Colombian pesos", countryCode: "CO", }, + { + symbol: "CF", + label: "Comorian Franc", + symbol_native: "FC", + decimal_digits: 0, + rounding: 0, + value: "KMF", + name_plural: "Comorian francs", + countryCode: "KM", + }, + { + symbol: "CDF", + label: "Congolese Franc", + symbol_native: "FrCD", + decimal_digits: 2, + rounding: 0, + value: "CDF", + name_plural: "Congolese francs", + countryCode: "CD", + }, { symbol: "₡", label: "Costa Rican Colón", @@ -281,14 +311,14 @@ export const currencies: Currency[] = [ countryCode: "CR", }, { - symbol: "CV$", - label: "Cape Verdean Escudo", - symbol_native: "CV$", + symbol: "kn", + label: "Croatian Kuna", + symbol_native: "kn", decimal_digits: 2, rounding: 0, - value: "CVE", - name_plural: "Cape Verdean escudos", - countryCode: "CV", + value: "HRK", + name_plural: "Croatian kunas", + countryCode: "HR", }, { symbol: "Kč", @@ -300,16 +330,6 @@ export const currencies: Currency[] = [ name_plural: "Czech Republic korunas", countryCode: "CZ", }, - { - symbol: "Fdj", - label: "Djiboutian Franc", - symbol_native: "Fdj", - decimal_digits: 0, - rounding: 0, - value: "DJF", - name_plural: "Djiboutian francs", - countryCode: "DJ", - }, { symbol: "Dkr", label: "Danish Krone", @@ -320,6 +340,16 @@ export const currencies: Currency[] = [ name_plural: "Danish kroner", countryCode: "DK", }, + { + symbol: "Fdj", + label: "Djiboutian Franc", + symbol_native: "Fdj", + decimal_digits: 0, + rounding: 0, + value: "DJF", + name_plural: "Djiboutian francs", + countryCode: "DJ", + }, { symbol: "RD$", label: "Dominican Peso", @@ -330,26 +360,6 @@ export const currencies: Currency[] = [ name_plural: "Dominican pesos", countryCode: "DO", }, - { - symbol: "DA", - label: "Algerian Dinar", - symbol_native: "د.ج.‏", - decimal_digits: 2, - rounding: 0, - value: "DZD", - name_plural: "Algerian dinars", - countryCode: "DZ", - }, - { - symbol: "Ekr", - label: "Estonian Kroon", - symbol_native: "kr", - decimal_digits: 2, - rounding: 0, - value: "EEK", - name_plural: "Estonian kroons", - countryCode: "EE", - }, { symbol: "EGP", label: "Egyptian Pound", @@ -370,6 +380,16 @@ export const currencies: Currency[] = [ name_plural: "Eritrean nakfas", countryCode: "ER", }, + { + symbol: "Ekr", + label: "Estonian Kroon", + symbol_native: "kr", + decimal_digits: 2, + rounding: 0, + value: "EEK", + name_plural: "Estonian kroons", + countryCode: "EE", + }, { symbol: "Br", label: "Ethiopian Birr", @@ -381,14 +401,14 @@ export const currencies: Currency[] = [ countryCode: "ET", }, { - symbol: "£", - label: "British Pound Sterling", - symbol_native: "£", + symbol: " € ", + label: "Euro", + symbol_native: "€", decimal_digits: 2, rounding: 0, - value: "GBP", - name_plural: "British pounds sterling", - countryCode: "GB", + value: "EUR", + name_plural: "euros", + countryCode: "EU", }, { symbol: "GEL", @@ -410,16 +430,6 @@ export const currencies: Currency[] = [ name_plural: "Ghanaian cedis", countryCode: "GH", }, - { - symbol: "FG", - label: "Guinean Franc", - symbol_native: "FG", - decimal_digits: 0, - rounding: 0, - value: "GNF", - name_plural: "Guinean francs", - countryCode: "GN", - }, { symbol: "GTQ", label: "Guatemalan Quetzal", @@ -431,14 +441,14 @@ export const currencies: Currency[] = [ countryCode: "GT", }, { - symbol: "HK$", - label: "Hong Kong Dollar", - symbol_native: "$", - decimal_digits: 2, + symbol: "FG", + label: "Guinean Franc", + symbol_native: "FG", + decimal_digits: 0, rounding: 0, - value: "HKD", - name_plural: "Hong Kong dollars", - countryCode: "HK", + value: "GNF", + name_plural: "Guinean francs", + countryCode: "GN", }, { symbol: "HNL", @@ -451,14 +461,14 @@ export const currencies: Currency[] = [ countryCode: "HN", }, { - symbol: "kn", - label: "Croatian Kuna", - symbol_native: "kn", + symbol: "HK$", + label: "Hong Kong Dollar", + symbol_native: "$", decimal_digits: 2, rounding: 0, - value: "HRK", - name_plural: "Croatian kunas", - countryCode: "HR", + value: "HKD", + name_plural: "Hong Kong dollars", + countryCode: "HK", }, { symbol: "Ft", @@ -471,24 +481,14 @@ export const currencies: Currency[] = [ countryCode: "HU", }, { - symbol: "Rp", - label: "Indonesian Rupiah", - symbol_native: "Rp", + symbol: "Ikr", + label: "Icelandic Króna", + symbol_native: "kr", decimal_digits: 0, rounding: 0, - value: "IDR", - name_plural: "Indonesian rupiahs", - countryCode: "ID", - }, - { - symbol: "₪", - label: "Israeli New Sheqel", - symbol_native: "₪", - decimal_digits: 2, - rounding: 0, - value: "ILS", - name_plural: "Israeli new sheqels", - countryCode: "IL", + value: "ISK", + name_plural: "Icelandic krónur", + countryCode: "IS", }, { symbol: "Rs", @@ -501,14 +501,14 @@ export const currencies: Currency[] = [ countryCode: "IN", }, { - symbol: "IQD", - label: "Iraqi Dinar", - symbol_native: "د.ع.‏", + symbol: "Rp", + label: "Indonesian Rupiah", + symbol_native: "Rp", decimal_digits: 0, rounding: 0, - value: "IQD", - name_plural: "Iraqi dinars", - countryCode: "IQ", + value: "IDR", + name_plural: "Indonesian rupiahs", + countryCode: "ID", }, { symbol: "IRR", @@ -521,14 +521,24 @@ export const currencies: Currency[] = [ countryCode: "IR", }, { - symbol: "Ikr", - label: "Icelandic Króna", - symbol_native: "kr", + symbol: "IQD", + label: "Iraqi Dinar", + symbol_native: "د.ع.‏", decimal_digits: 0, rounding: 0, - value: "ISK", - name_plural: "Icelandic krónur", - countryCode: "IS", + value: "IQD", + name_plural: "Iraqi dinars", + countryCode: "IQ", + }, + { + symbol: "₪", + label: "Israeli New Sheqel", + symbol_native: "₪", + decimal_digits: 2, + rounding: 0, + value: "ILS", + name_plural: "Israeli new sheqels", + countryCode: "IL", }, { symbol: "J$", @@ -540,6 +550,16 @@ export const currencies: Currency[] = [ name_plural: "Jamaican dollars", countryCode: "JM", }, + { + symbol: "¥", + label: "Japanese Yen", + symbol_native: "¥", + decimal_digits: 0, + rounding: 0, + value: "JPY", + name_plural: "Japanese yen", + countryCode: "JP", + }, { symbol: "JD", label: "Jordanian Dinar", @@ -551,14 +571,14 @@ export const currencies: Currency[] = [ countryCode: "JO", }, { - symbol: "¥", - label: "Japanese Yen", - symbol_native: "¥", - decimal_digits: 0, + symbol: "KZT", + label: "Kazakhstani Tenge", + symbol_native: "тңг.", + decimal_digits: 2, rounding: 0, - value: "JPY", - name_plural: "Japanese yen", - countryCode: "JP", + value: "KZT", + name_plural: "Kazakhstani tenges", + countryCode: "KZ", }, { symbol: "Ksh", @@ -570,36 +590,6 @@ export const currencies: Currency[] = [ name_plural: "Kenyan shillings", countryCode: "KE", }, - { - symbol: "KHR", - label: "Cambodian Riel", - symbol_native: "៛", - decimal_digits: 2, - rounding: 0, - value: "KHR", - name_plural: "Cambodian riels", - countryCode: "KH", - }, - { - symbol: "CF", - label: "Comorian Franc", - symbol_native: "FC", - decimal_digits: 0, - rounding: 0, - value: "KMF", - name_plural: "Comorian francs", - countryCode: "KM", - }, - { - symbol: "₩", - label: "South Korean Won", - symbol_native: "₩", - decimal_digits: 0, - rounding: 0, - value: "KRW", - name_plural: "South Korean won", - countryCode: "KR", - }, { symbol: "KD", label: "Kuwaiti Dinar", @@ -611,14 +601,14 @@ export const currencies: Currency[] = [ countryCode: "KW", }, { - symbol: "KZT", - label: "Kazakhstani Tenge", - symbol_native: "тңг.", + symbol: "Ls", + label: "Latvian Lats", + symbol_native: "Ls", decimal_digits: 2, rounding: 0, - value: "KZT", - name_plural: "Kazakhstani tenges", - countryCode: "KZ", + value: "LVL", + name_plural: "Latvian lati", + countryCode: "LV", }, { symbol: "LB£", @@ -630,36 +620,6 @@ export const currencies: Currency[] = [ name_plural: "Lebanese pounds", countryCode: "LB", }, - { - symbol: "SLRs", - label: "Sri Lankan Rupee", - symbol_native: "SL Re", - decimal_digits: 2, - rounding: 0, - value: "LKR", - name_plural: "Sri Lankan rupees", - countryCode: "LK", - }, - { - symbol: "Lt", - label: "Lithuanian Litas", - symbol_native: "Lt", - decimal_digits: 2, - rounding: 0, - value: "LTL", - name_plural: "Lithuanian litai", - countryCode: "LT", - }, - { - symbol: "Ls", - label: "Latvian Lats", - symbol_native: "Ls", - decimal_digits: 2, - rounding: 0, - value: "LVL", - name_plural: "Latvian lati", - countryCode: "LV", - }, { symbol: "LD", label: "Libyan Dinar", @@ -671,34 +631,24 @@ export const currencies: Currency[] = [ countryCode: "LY", }, { - symbol: "MAD", - label: "Moroccan Dirham", - symbol_native: "د.م.‏", + symbol: "Lt", + label: "Lithuanian Litas", + symbol_native: "Lt", decimal_digits: 2, rounding: 0, - value: "MAD", - name_plural: "Moroccan dirhams", - countryCode: "MA", + value: "LTL", + name_plural: "Lithuanian litai", + countryCode: "LT", }, { - symbol: "MDL", - label: "Moldovan Leu", - symbol_native: "MDL", + symbol: "MOP$", + label: "Macanese Pataca", + symbol_native: "MOP$", decimal_digits: 2, rounding: 0, - value: "MDL", - name_plural: "Moldovan lei", - countryCode: "MD", - }, - { - symbol: "MGA", - label: "Malagasy Ariary", - symbol_native: "MGA", - decimal_digits: 0, - rounding: 0, - value: "MGA", - name_plural: "Malagasy Ariaries", - countryCode: "MG", + value: "MOP", + name_plural: "Macanese patacas", + countryCode: "MO", }, { symbol: "MKD", @@ -711,24 +661,24 @@ export const currencies: Currency[] = [ countryCode: "MK", }, { - symbol: "MMK", - label: "Myanma Kyat", - symbol_native: "K", + symbol: "MGA", + label: "Malagasy Ariary", + symbol_native: "MGA", decimal_digits: 0, rounding: 0, - value: "MMK", - name_plural: "Myanma kyats", - countryCode: "MM", + value: "MGA", + name_plural: "Malagasy Ariaries", + countryCode: "MG", }, { - symbol: "MOP$", - label: "Macanese Pataca", - symbol_native: "MOP$", + symbol: "RM", + label: "Malaysian Ringgit", + symbol_native: "RM", decimal_digits: 2, rounding: 0, - value: "MOP", - name_plural: "Macanese patacas", - countryCode: "MO", + value: "MYR", + name_plural: "Malaysian ringgits", + countryCode: "MY", }, { symbol: "MURs", @@ -751,14 +701,24 @@ export const currencies: Currency[] = [ countryCode: "MX", }, { - symbol: "RM", - label: "Malaysian Ringgit", - symbol_native: "RM", + symbol: "MDL", + label: "Moldovan Leu", + symbol_native: "MDL", decimal_digits: 2, rounding: 0, - value: "MYR", - name_plural: "Malaysian ringgits", - countryCode: "MY", + value: "MDL", + name_plural: "Moldovan lei", + countryCode: "MD", + }, + { + symbol: "MAD", + label: "Moroccan Dirham", + symbol_native: "د.م.‏", + decimal_digits: 2, + rounding: 0, + value: "MAD", + name_plural: "Moroccan dirhams", + countryCode: "MA", }, { symbol: "MTn", @@ -770,6 +730,16 @@ export const currencies: Currency[] = [ name_plural: "Mozambican meticals", countryCode: "MZ", }, + { + symbol: "MMK", + label: "Myanma Kyat", + symbol_native: "K", + decimal_digits: 0, + rounding: 0, + value: "MMK", + name_plural: "Myanma kyats", + countryCode: "MM", + }, { symbol: "N$", label: "Namibian Dollar", @@ -781,14 +751,34 @@ export const currencies: Currency[] = [ countryCode: "NA", }, { - symbol: "₦", - label: "Nigerian Naira", - symbol_native: "₦", + symbol: "NPRs", + label: "Nepalese Rupee", + symbol_native: "नेरू", decimal_digits: 2, rounding: 0, - value: "NGN", - name_plural: "Nigerian nairas", - countryCode: "NG", + value: "NPR", + name_plural: "Nepalese rupees", + countryCode: "NP", + }, + { + symbol: "NT$", + label: "New Taiwan Dollar", + symbol_native: "NT$", + decimal_digits: 2, + rounding: 0, + value: "TWD", + name_plural: "New Taiwan dollars", + countryCode: "TW", + }, + { + symbol: "NZ$", + label: "New Zealand Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + value: "NZD", + name_plural: "New Zealand dollars", + countryCode: "NZ", }, { symbol: "C$", @@ -801,34 +791,24 @@ export const currencies: Currency[] = [ countryCode: "NI", }, { - symbol: "Nkr", - label: "Norwegian Krone", - symbol_native: "kr", - decimal_digits: 2, - rounding: 0, - value: "NOK", - name_plural: "Norwegian kroner", - countryCode: "NO", - }, - { - symbol: "NPRs", - label: "Nepalese Rupee", - symbol_native: "नेरू", + symbol: "₦", + label: "Nigerian Naira", + symbol_native: "₦", decimal_digits: 2, rounding: 0, - value: "NPR", - name_plural: "Nepalese rupees", - countryCode: "NP", + value: "NGN", + name_plural: "Nigerian nairas", + countryCode: "NG", }, { - symbol: "NZ$", - label: "New Zealand Dollar", - symbol_native: "$", + symbol: "Nkr", + label: "Norwegian Krone", + symbol_native: "kr", decimal_digits: 2, rounding: 0, - value: "NZD", - name_plural: "New Zealand dollars", - countryCode: "NZ", + value: "NOK", + name_plural: "Norwegian kroner", + countryCode: "NO", }, { symbol: "OMR", @@ -840,6 +820,16 @@ export const currencies: Currency[] = [ name_plural: "Omani rials", countryCode: "OM", }, + { + symbol: "PKRs", + label: "Pakistani Rupee", + symbol_native: "₨", + decimal_digits: 0, + rounding: 0, + value: "PKR", + name_plural: "Pakistani rupees", + countryCode: "PK", + }, { symbol: "B/.", label: "Panamanian Balboa", @@ -850,6 +840,16 @@ export const currencies: Currency[] = [ name_plural: "Panamanian balboas", countryCode: "PA", }, + { + symbol: "₲", + label: "Paraguayan Guarani", + symbol_native: "₲", + decimal_digits: 0, + rounding: 0, + value: "PYG", + name_plural: "Paraguayan guaranis", + countryCode: "PY", + }, { symbol: "S/.", label: "Peruvian Nuevo Sol", @@ -870,16 +870,6 @@ export const currencies: Currency[] = [ name_plural: "Philippine pesos", countryCode: "PH", }, - { - symbol: "PKRs", - label: "Pakistani Rupee", - symbol_native: "₨", - decimal_digits: 0, - rounding: 0, - value: "PKR", - name_plural: "Pakistani rupees", - countryCode: "PK", - }, { symbol: "zł", label: "Polish Zloty", @@ -890,16 +880,6 @@ export const currencies: Currency[] = [ name_plural: "Polish zlotys", countryCode: "PL", }, - { - symbol: "₲", - label: "Paraguayan Guarani", - symbol_native: "₲", - decimal_digits: 0, - rounding: 0, - value: "PYG", - name_plural: "Paraguayan guaranis", - countryCode: "PY", - }, { symbol: "QR", label: "Qatari Rial", @@ -920,16 +900,6 @@ export const currencies: Currency[] = [ name_plural: "Romanian lei", countryCode: "RO", }, - { - symbol: "din.", - label: "Serbian Dinar", - symbol_native: "дин.", - decimal_digits: 0, - rounding: 0, - value: "RSD", - name_plural: "Serbian dinars", - countryCode: "RS", - }, { symbol: "RUB", label: "Russian Ruble", @@ -961,24 +931,14 @@ export const currencies: Currency[] = [ countryCode: "SA", }, { - symbol: "SDG", - label: "Sudanese Pound", - symbol_native: "SDG", - decimal_digits: 2, - rounding: 0, - value: "SDG", - name_plural: "Sudanese pounds", - countryCode: "SD", - }, - { - symbol: "Skr", - label: "Swedish Krona", - symbol_native: "kr", - decimal_digits: 2, + symbol: "din.", + label: "Serbian Dinar", + symbol_native: "дин.", + decimal_digits: 0, rounding: 0, - value: "SEK", - name_plural: "Swedish kronor", - countryCode: "SE", + value: "RSD", + name_plural: "Serbian dinars", + countryCode: "RS", }, { symbol: "S$", @@ -1000,6 +960,66 @@ export const currencies: Currency[] = [ name_plural: "Somali shillings", countryCode: "SO", }, + { + symbol: "R", + label: "South African Rand", + symbol_native: "R", + decimal_digits: 2, + rounding: 0, + value: "ZAR", + name_plural: "South African rand", + countryCode: "ZA", + }, + { + symbol: "₩", + label: "South Korean Won", + symbol_native: "₩", + decimal_digits: 0, + rounding: 0, + value: "KRW", + name_plural: "South Korean won", + countryCode: "KR", + }, + { + symbol: "SLRs", + label: "Sri Lankan Rupee", + symbol_native: "SL Re", + decimal_digits: 2, + rounding: 0, + value: "LKR", + name_plural: "Sri Lankan rupees", + countryCode: "LK", + }, + { + symbol: "SDG", + label: "Sudanese Pound", + symbol_native: "SDG", + decimal_digits: 2, + rounding: 0, + value: "SDG", + name_plural: "Sudanese pounds", + countryCode: "SD", + }, + { + symbol: "Skr", + label: "Swedish Krona", + symbol_native: "kr", + decimal_digits: 2, + rounding: 0, + value: "SEK", + name_plural: "Swedish kronor", + countryCode: "SE", + }, + { + symbol: "CHF", + label: "Swiss Franc", + symbol_native: "CHF", + decimal_digits: 2, + rounding: 0.05, + value: "CHF", + name_plural: "Swiss francs", + countryCode: "CH", + }, { symbol: "SY£", label: "Syrian Pound", @@ -1010,6 +1030,16 @@ export const currencies: Currency[] = [ name_plural: "Syrian pounds", countryCode: "SY", }, + { + symbol: "TSh", + label: "Tanzanian Shilling", + symbol_native: "TSh", + decimal_digits: 0, + rounding: 0, + value: "TZS", + name_plural: "Tanzanian shillings", + countryCode: "TZ", + }, { symbol: "฿", label: "Thai Baht", @@ -1020,16 +1050,6 @@ export const currencies: Currency[] = [ name_plural: "Thai baht", countryCode: "TH", }, - { - symbol: "DT", - label: "Tunisian Dinar", - symbol_native: "د.ت.‏", - decimal_digits: 3, - rounding: 0, - value: "TND", - name_plural: "Tunisian dinars", - countryCode: "TN", - }, { symbol: "T$", label: "Tongan Paʻanga", @@ -1040,16 +1060,6 @@ export const currencies: Currency[] = [ name_plural: "Tongan paʻanga", countryCode: "TO", }, - { - symbol: "TL", - label: "Turkish Lira", - symbol_native: "TL", - decimal_digits: 2, - rounding: 0, - value: "TRY", - name_plural: "Turkish Lira", - countryCode: "TR", - }, { symbol: "TT$", label: "Trinidad and Tobago Dollar", @@ -1061,24 +1071,34 @@ export const currencies: Currency[] = [ countryCode: "TT", }, { - symbol: "NT$", - label: "New Taiwan Dollar", - symbol_native: "NT$", + symbol: "DT", + label: "Tunisian Dinar", + symbol_native: "د.ت.‏", + decimal_digits: 3, + rounding: 0, + value: "TND", + name_plural: "Tunisian dinars", + countryCode: "TN", + }, + { + symbol: "TL", + label: "Turkish Lira", + symbol_native: "TL", decimal_digits: 2, rounding: 0, - value: "TWD", - name_plural: "New Taiwan dollars", - countryCode: "TW", + value: "TRY", + name_plural: "Turkish Lira", + countryCode: "TR", }, { - symbol: "TSh", - label: "Tanzanian Shilling", - symbol_native: "TSh", + symbol: "USh", + label: "Ugandan Shilling", + symbol_native: "USh", decimal_digits: 0, rounding: 0, - value: "TZS", - name_plural: "Tanzanian shillings", - countryCode: "TZ", + value: "UGX", + name_plural: "Ugandan shillings", + countryCode: "UG", }, { symbol: "₴", @@ -1091,14 +1111,24 @@ export const currencies: Currency[] = [ countryCode: "UA", }, { - symbol: "USh", - label: "Ugandan Shilling", - symbol_native: "USh", - decimal_digits: 0, + symbol: "AED", + label: "United Arab Emirates Dirham", + symbol_native: "د.إ.‏", + decimal_digits: 2, rounding: 0, - value: "UGX", - name_plural: "Ugandan shillings", - countryCode: "UG", + value: "AED", + name_plural: "UAE dirhams", + countryCode: "AE", + }, + { + symbol: " $ ", + label: "United States Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + value: "USD", + name_plural: "US dollars", + countryCode: "US", }, { symbol: "$U", @@ -1140,26 +1170,6 @@ export const currencies: Currency[] = [ name_plural: "Vietnamese dong", countryCode: "VN", }, - { - symbol: "FCFA", - label: "CFA Franc BEAC", - symbol_native: "FCFA", - decimal_digits: 0, - rounding: 0, - value: "XAF", - name_plural: "CFA francs BEAC", - countryCode: "CF", - }, - { - symbol: "CFA", - label: "CFA Franc BCEAO", - symbol_native: "CFA", - decimal_digits: 0, - rounding: 0, - value: "XOF", - name_plural: "CFA francs BCEAO", - countryCode: "CI", // We're defaulting to Ivory Coast as the country for this currency - }, { symbol: "YR", label: "Yemeni Rial", @@ -1170,16 +1180,6 @@ export const currencies: Currency[] = [ name_plural: "Yemeni rials", countryCode: "YE", }, - { - symbol: "R", - label: "South African Rand", - symbol_native: "R", - decimal_digits: 2, - rounding: 0, - value: "ZAR", - name_plural: "South African rand", - countryCode: "ZA", - }, { symbol: "ZK", label: "Zambian Kwacha", From 8c0f3ac8e3f0ce2ea67bdc5be6981a75cde0883e Mon Sep 17 00:00:00 2001 From: Andres Galindo Date: Mon, 29 Jul 2024 15:54:43 -0700 Subject: [PATCH 09/44] vqa fixes media schema rules (#2890) --- .../components/AddFieldModal/MediaRules.tsx | 25 +++++++++++++--- .../components/AddFieldModal/views/Rules.tsx | 30 +++++++++---------- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx b/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx index eb6b8140f3..6660a1c11d 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx @@ -152,12 +152,19 @@ export const MediaRules = ({ newInputValue: string, ruleName: string ) => { - const formattedInput = "." + newInputValue.replace(/\./g, ""); - setInputValue(formattedInput); + const formattedInput = newInputValue.trim().toLowerCase(); + if (formattedInput && formattedInput[0] !== ".") { + setInputValue(`.${formattedInput}`); + } else { + setInputValue(formattedInput); + } }; const handleKeyDown = (event: any, ruleName: string) => { - if (event.key === "Enter" || event.key === "," || event.key === " ") { + if ( + (event.key === "Enter" || event.key === "," || event.key === " ") && + inputValue + ) { event.preventDefault(); const newOption = inputValue.toLowerCase().trim(); if ( @@ -171,12 +178,22 @@ export const MediaRules = ({ }); setInputValue(""); } + } else if (event.key === "Backspace" && !inputValue) { + const newTags = [...(fieldData[ruleName] as string[])]; + newTags.pop(); + if (!newTags.length) { + setExtensionsError(true); + } + onDataChange({ + inputName: ruleName, + value: newTags, + }); } }; const handleDelete = (option: string, ruleName: string) => { const newTags = (fieldData[ruleName] as string[]).filter( - (item) => item !== option + (item) => item.trim() !== option.trim() ); if (!newTags.length) { setExtensionsError(true); diff --git a/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx b/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx index 65c0dab5ee..a92b5caf27 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx @@ -48,21 +48,6 @@ export const Rules = ({ return ( - {type === "images" && ( - - )} - + {type === "images" && ( + + )} + {(type === "text" || type === "textarea") && ( <> Date: Tue, 30 Jul 2024 08:20:56 +0800 Subject: [PATCH 10/44] [Content] Show image placeholder in publish modal dialog (#2888) Resolves #2837 ![image](https://github.com/user-attachments/assets/c30864de-d1e0-4c11-b660-7f9bb9974f0f) --- .../app/views/ItemList/DialogContentItem.tsx | 48 +++++++++++++------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemList/DialogContentItem.tsx b/src/apps/content-editor/src/app/views/ItemList/DialogContentItem.tsx index 3c3772ca65..4fafb2948b 100644 --- a/src/apps/content-editor/src/app/views/ItemList/DialogContentItem.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/DialogContentItem.tsx @@ -5,6 +5,7 @@ import { ListItemText, ListItemAvatar, Avatar, + Stack, } from "@mui/material"; import { ContentItem } from "../../../../../../shell/services/types"; import { useGetContentModelFieldsQuery } from "../../../../../../shell/services/instance"; @@ -16,6 +17,7 @@ import { import { useSelector } from "react-redux"; import { AppState } from "../../../../../../shell/store/types"; import { useMemo } from "react"; +import { ImageRounded } from "@mui/icons-material"; export type DialogContentItemProps = { item: ContentItem; @@ -50,26 +52,44 @@ export const DialogContentItem = ({ item }: DialogContentItemProps) => { return ( - - + theme.palette.grey[100], + }} + src={heroImage} + imgProps={{ + style: { + objectFit: "contain", + }, + }} + > + + NA + + + + ) : ( + theme.palette.grey[100], - }} - src={heroImage} - imgProps={{ - style: { - objectFit: "contain", - }, }} > - - NA - - - + + + )} Date: Mon, 29 Jul 2024 19:04:33 -0700 Subject: [PATCH 11/44] Hide publish based on permissions on code app (#2887) --- .../components/EditorActions/EditorActions.js | 16 +++++++----- .../src/app/components/FileList/FileList.js | 25 ++++++++++++------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/apps/code-editor/src/app/components/FileActions/components/EditorActions/EditorActions.js b/src/apps/code-editor/src/app/components/FileActions/components/EditorActions/EditorActions.js index d2f6d33212..ec1caf87bb 100644 --- a/src/apps/code-editor/src/app/components/FileActions/components/EditorActions/EditorActions.js +++ b/src/apps/code-editor/src/app/components/FileActions/components/EditorActions/EditorActions.js @@ -4,7 +4,9 @@ import { Save } from "./Save"; import { Publish } from "./Publish"; import styles from "./EditorActions.less"; +import { usePermission } from "../../../../../../../../shell/hooks/use-permissions"; export const EditorActions = memo(function EditorActions(props) { + const canPublish = usePermission("PUBLISH"); return (
- + {canPublish && ( + + )}
); }); diff --git a/src/apps/code-editor/src/app/components/FileList/FileList.js b/src/apps/code-editor/src/app/components/FileList/FileList.js index f7c85c180c..823999426a 100644 --- a/src/apps/code-editor/src/app/components/FileList/FileList.js +++ b/src/apps/code-editor/src/app/components/FileList/FileList.js @@ -17,9 +17,10 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCloudUploadAlt } from "@fortawesome/free-solid-svg-icons"; import { resolvePathPart, publishFile } from "../../../store/files"; import { collapseNavItem } from "../../../store/navCode"; - +import { usePermission } from "../../../../../../shell/hooks/use-permissions"; import styles from "./FileList.less"; export const FileList = memo(function FileList(props) { + const canPublish = usePermission("PUBLISH"); // const [branch, setBranch] = useState(props.branch); const [shownFiles, setShownFiles] = useState( props.navCode.tree.sort(byLabel) @@ -47,14 +48,20 @@ export const FileList = memo(function FileList(props) { }; const actions = [ - !file.isLive} - onClick={(file) => props.dispatch(publishFile(file.ZUID, file.status))} - />, + ...(canPublish + ? [ + !file.isLive} + onClick={(file) => + props.dispatch(publishFile(file.ZUID, file.status)) + } + />, + ] + : []), ]; return ( From 361736c60d91fc5d3878b41d81257f8b1a6dea66 Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Wed, 31 Jul 2024 07:46:22 +0800 Subject: [PATCH 12/44] [Content] Do not store pinned columns to local storage (#2892) Prevents multipage table pinned columns from being stored in local storage Resolves #2824 #### Demo [Screencast from Tuesday, 30 July, 2024 09:05:20 AM PST.webm](https://github.com/user-attachments/assets/07281341-7b91-45f9-9f99-245b16d83242) --- .../src/app/views/ItemList/ItemListTable.tsx | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx index 273c3771a9..0ad398ebda 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx @@ -16,6 +16,7 @@ import { GRID_CHECKBOX_SELECTION_COL_DEF, useGridApiRef, GridInitialState, + GridPinnedColumns, } from "@mui/x-data-grid-pro"; import { memo, useCallback, useLayoutEffect, useMemo, useState } from "react"; import { ContentItem } from "../../../../../../shell/services/types"; @@ -246,6 +247,7 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { const history = useHistory(); const { stagedChanges } = useStagedChanges(); const [selectedItems, setSelectedItems] = useSelectedItems(); + const [pinnedColumns, setPinnedColumns] = useState({}); const { data: fields } = useGetContentModelFieldsQuery(modelZUID); @@ -266,18 +268,11 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { ); setInitialState( - stateFromLocalStorage - ? JSON.parse(stateFromLocalStorage) - : { - pinnedColumns: { - left: [ - GRID_CHECKBOX_SELECTION_COL_DEF.field, - "version", - fields?.[0]?.name, - ], - }, - } + stateFromLocalStorage ? JSON.parse(stateFromLocalStorage) : {} ); + setPinnedColumns({ + left: ["__check__", "version", fields?.[0]?.name], + }); window.addEventListener("beforeunload", saveSnapshot); @@ -352,6 +347,10 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { loading={loading} rows={rows} columns={[...columns, ...METADATA_COLUMNS]} + pinnedColumns={pinnedColumns} + onPinnedColumnsChange={(newPinnedColumns) => + setPinnedColumns(newPinnedColumns) + } rowHeight={54} hideFooter onRowClick={(row) => { From b88aa6602f05bc2b8d2267adf6e5a698613ceb60 Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Wed, 31 Jul 2024 08:58:05 +0800 Subject: [PATCH 13/44] [Content] Multipage table advanced sorting (#2884) - Adds created by and zuid sorting - Rename some columns for consistency - Add table column header sorting - Sync sorting pill button and column header sorting status - Save sort order to local storage Resolves #2836 Resolves #2845 --- .../content-editor/src/app/ContentEditor.js | 5 +- .../app/views/ItemList/ItemListFilters.tsx | 128 +++++++++++++----- .../src/app/views/ItemList/ItemListTable.tsx | 30 ++-- .../app/views/ItemList/TableSortProvider.tsx | 47 +++++++ .../src/app/views/ItemList/index.tsx | 111 ++++++++++++--- .../components/CascadingMenuItem/index.tsx | 44 +++++- 6 files changed, 300 insertions(+), 65 deletions(-) create mode 100644 src/apps/content-editor/src/app/views/ItemList/TableSortProvider.tsx diff --git a/src/apps/content-editor/src/app/ContentEditor.js b/src/apps/content-editor/src/app/ContentEditor.js index 24a5e88ca3..3d22f11e63 100644 --- a/src/apps/content-editor/src/app/ContentEditor.js +++ b/src/apps/content-editor/src/app/ContentEditor.js @@ -31,6 +31,7 @@ import Analytics from "./views/Analytics"; import { ResizableContainer } from "../../../../shell/components/ResizeableContainer"; import { StagedChangesProvider } from "./views/ItemList/StagedChangesContext"; import { SelectedItemsProvider } from "./views/ItemList/SelectedItemsContext"; +import { TableSortProvider } from "./views/ItemList/TableSortProvider"; // Makes sure that other apps using legacy theme does not get affected with the palette let customTheme = createTheme(legacyTheme, { @@ -174,7 +175,9 @@ export default function ContentEditor() { render={() => ( - + + + )} 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 b791dae062..f796b02817 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx @@ -1,12 +1,23 @@ -import { Box, Menu, MenuItem, Button, Typography } from "@mui/material"; +import { + Box, + Menu, + MenuItem, + Button, + Typography, + MenuList, + ListItemText, +} from "@mui/material"; import { DateFilter, FilterButton, UserFilter, } from "../../../../../../shell/components/Filters"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useContext } from "react"; import { useParams } from "../../../../../../shell/hooks/useParams"; -import { ArrowDropDownOutlined } from "@mui/icons-material"; +import { + ArrowDropDownOutlined, + ChevronRightOutlined, +} from "@mui/icons-material"; import { useGetContentModelFieldsQuery, useGetLangsQuery, @@ -14,12 +25,14 @@ import { import { useDateFilterParams } from "../../../../../../shell/hooks/useDateFilterParams"; import { useGetUsersQuery } from "../../../../../../shell/services/accounts"; import { useParams as useRouterParams } from "react-router"; +import { CascadingMenuItem } from "../../../../../../shell/components/CascadingMenuItem"; +import { TableSortContext } from "./TableSortProvider"; const SORT_ORDER = { - dateSaved: "Date Saved", - datePublished: "Date Published", - dateCreated: "Date Created", - status: "Status", + lastSaved: "Last Saved", + lastPublished: "Last Published", + createdOn: "Date Created", + version: "Status", } as const; const STATUS_FILTER = { @@ -87,6 +100,9 @@ export const ItemListFilters = () => { const { data: users } = useGetUsersQuery(); const { data: fields, isFetching: isFieldsFetching } = useGetContentModelFieldsQuery(modelZUID); + const [sortModel, setSortModel] = useContext(TableSortContext); + + const activeSortOrder = sortModel?.[0]?.field; const userOptions = useMemo(() => { return users?.map((user) => ({ @@ -97,17 +113,49 @@ export const ItemListFilters = () => { })); }, [users]); + const handleUpdateSortOrder = (sortType: string) => { + setAnchorEl({ + currentTarget: null, + id: "", + }); + + setSortModel([ + { + field: sortType, + sort: "desc", + }, + ]); + }; + + const getButtonText = (activeSortOrder: string) => { + if (!activeSortOrder) { + return SORT_ORDER.lastSaved; + } + + if (activeSortOrder === "createdBy") { + return "Created By"; + } + + if (activeSortOrder === "zuid") { + return "ZUID"; + } + + if (SORT_ORDER.hasOwnProperty(activeSortOrder)) { + return SORT_ORDER[activeSortOrder as keyof typeof SORT_ORDER]; + } + + const fieldLabel = fields?.find( + (field) => field.name === activeSortOrder + )?.label; + return fieldLabel; + }; + return ( field.name === params.get("sort")) - ?.label) ?? - SORT_ORDER.dateSaved - }`} + buttonText={`Sort: ${getButtonText(activeSortOrder)}`} onOpenMenu={(event: React.MouseEvent) => { setAnchorEl({ currentTarget: event.currentTarget, @@ -134,23 +182,46 @@ export const ItemListFilters = () => { > {Object.entries(SORT_ORDER).map(([key, value]) => ( { - setParams(key, "sort"); - setAnchorEl({ - currentTarget: null, - id: "", - }); - }} + onClick={() => handleUpdateSortOrder(key)} selected={ - key === "dateSaved" - ? !params.get("sort") || params.get("sort") === key - : params.get("sort") === key + key === "lastSaved" + ? !activeSortOrder || activeSortOrder === "lastSaved" + : activeSortOrder === key } > {value} ))} + + More + + + } + PaperProps={{ + sx: { + width: 240, + }, + }} + > + + handleUpdateSortOrder("createdBy")} + > + Created By + + handleUpdateSortOrder("zuid")} + > + ZUID + + + { ?.map((field) => ( { - setParams(field.name, "sort"); - setAnchorEl({ - currentTarget: null, - id: "", - }); - }} - selected={params.get("sort") === field.name} + onClick={() => handleUpdateSortOrder(field.name)} + selected={activeSortOrder === field.name} > {field.label} @@ -217,6 +282,7 @@ export const ItemListFilters = () => { > {Object.entries(STATUS_FILTER).map(([key, value]) => ( { setParams(key, "statusFilter"); diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx index 0ad398ebda..50fb6fb7ea 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx @@ -16,9 +16,17 @@ import { GRID_CHECKBOX_SELECTION_COL_DEF, useGridApiRef, GridInitialState, + GridComparatorFn, GridPinnedColumns, } from "@mui/x-data-grid-pro"; -import { memo, useCallback, useLayoutEffect, useMemo, useState } from "react"; +import { + memo, + useCallback, + useLayoutEffect, + useMemo, + useState, + useContext, +} from "react"; import { ContentItem } from "../../../../../../shell/services/types"; import { useStagedChanges } from "./StagedChangesContext"; import { OneToManyCell } from "./TableCells/OneToManyCell"; @@ -32,6 +40,8 @@ import { currencies } from "../../../../../../shell/components/FieldTypeCurrency import { Currency } from "../../../../../../shell/components/FieldTypeCurrency/currencies"; import { ImageCell } from "./TableCells/ImageCell"; import { SingleRelationshipCell } from "./TableCells/SingleRelationshipCell"; +import { useParams } from "../../../../../../shell/hooks/useParams"; +import { TableSortContext } from "./TableSortProvider"; type ItemListTableProps = { loading: boolean; @@ -66,15 +76,13 @@ const METADATA_COLUMNS = [ field: "createdBy", headerName: "Created By", width: 240, - sortable: false, filterable: false, renderCell: (params: GridRenderCellParams) => , }, { field: "createdOn", - headerName: "Created On", + headerName: "Date Created", width: 200, - sortable: false, filterable: false, valueGetter: (params: any) => params.row?.meta?.createdAt, }, @@ -83,7 +91,6 @@ const METADATA_COLUMNS = [ field: "lastSaved", headerName: "Last Saved", width: 200, - sortable: false, filterable: false, valueGetter: (params: any) => params.row?.web?.updatedAt, }, @@ -91,7 +98,6 @@ const METADATA_COLUMNS = [ field: "lastPublished", headerName: "Last Published", width: 200, - sortable: false, filterable: false, valueGetter: (params: any) => params.row?.publishing?.publishAt, }, @@ -99,7 +105,6 @@ const METADATA_COLUMNS = [ field: "zuid", headerName: "ZUID", width: 200, - sortable: false, filterable: false, valueGetter: (params: any) => params.row?.meta?.ZUID, }, @@ -247,6 +252,8 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { const history = useHistory(); const { stagedChanges } = useStagedChanges(); const [selectedItems, setSelectedItems] = useSelectedItems(); + const [params, setParams] = useParams(); + const [sortModel, setSortModel] = useContext(TableSortContext); const [pinnedColumns, setPinnedColumns] = useState({}); const { data: fields } = useGetContentModelFieldsQuery(modelZUID); @@ -288,7 +295,7 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { field: "version", headerName: "Vers.", width: 59, - sortable: false, + sortable: true, filterable: false, renderCell: (params: GridRenderCellParams) => ( @@ -303,7 +310,6 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { ?.map((field) => ({ field: field.name, headerName: field.label, - sortable: false, filterable: false, valueGetter: (params: any) => { if (field.datatype === "currency") { @@ -395,6 +401,12 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { checkboxSelection disableSelectionOnClick initialState={initialState} + sortingOrder={["desc", "asc", null]} + sortModel={sortModel} + sortingMode="server" + onSortModelChange={(newSortModel) => { + setSortModel(newSortModel); + }} onSelectionModelChange={(newSelection) => setSelectedItems(newSelection)} selectionModel={ stagedChanges && Object.keys(stagedChanges)?.length ? [] : selectedItems diff --git a/src/apps/content-editor/src/app/views/ItemList/TableSortProvider.tsx b/src/apps/content-editor/src/app/views/ItemList/TableSortProvider.tsx new file mode 100644 index 0000000000..46af57c1a4 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/TableSortProvider.tsx @@ -0,0 +1,47 @@ +import { useState, createContext, useLayoutEffect } from "react"; +import { GridSortModel, GridSortItem } from "@mui/x-data-grid-pro"; +import { useParams as useRouterParams } from "react-router"; + +type TableSortContextType = [ + GridSortModel, + (newSortModel: GridSortModel) => void +]; +export const TableSortContext = createContext([ + [], + () => {}, +]); + +type TableSortProviderType = { + children?: React.ReactNode; +}; +export const TableSortProvider = ({ children }: TableSortProviderType) => { + const [sortModel, setSortModel] = useState([]); + const { modelZUID } = useRouterParams<{ modelZUID: string }>(); + + useLayoutEffect(() => { + if (!modelZUID) return; + + const stateFromLocalStorage = localStorage?.getItem( + `${modelZUID}-dataGridState` + ); + + if (stateFromLocalStorage) { + const { sortModel: sortModelFromLocalStorage } = JSON.parse( + stateFromLocalStorage + )?.sorting; + + if ( + Array.isArray(sortModelFromLocalStorage) && + sortModelFromLocalStorage?.length + ) { + setSortModel(sortModelFromLocalStorage); + } + } + }, [modelZUID]); + + return ( + + {children} + + ); +}; 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 f3ee800eaa..15b77667ab 100644 --- a/src/apps/content-editor/src/app/views/ItemList/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/index.tsx @@ -9,7 +9,7 @@ import { import { theme } from "@zesty-io/material"; import { ItemListEmpty } from "./ItemListEmpty"; import { ItemListActions } from "./ItemListActions"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, useContext } from "react"; import { SearchRounded, RestartAltRounded } from "@mui/icons-material"; import noSearchResults from "../../../../../../../public/images/noSearchResults.svg"; import { ItemListFilters } from "./ItemListFilters"; @@ -31,6 +31,7 @@ import { ContentItemWithDirtyAndPublishing, } from "../../../../../../shell/services/types"; import { fetchItems } from "../../../../../../shell/store/content"; +import { TableSortContext } from "./TableSortProvider"; const formatDateTime = (source: string) => { const dateObj = new Date(source); @@ -97,11 +98,12 @@ export const ItemList = () => { const [isModelItemsFetching, setIsModelItemsFetching] = useState(true); + const [sortModel] = useContext(TableSortContext); const { stagedChanges } = useStagedChanges(); const [selectedItems] = useSelectedItems(); const searchRef = useRef(null); const search = params.get("search"); - const sort = params.get("sort"); + // const sort = params.get("sort"); const statusFilter = params.get("statusFilter"); const dateFilter = useMemo(() => { return { @@ -243,9 +245,11 @@ export const ItemList = () => { }, [items, allItems, fields, users, isFieldsFetching, isUsersFetching]); const sortedAndFilteredItems = useMemo(() => { + const sort = sortModel?.[0]?.field; + const sortOrder = sortModel?.[0]?.sort; let clonedItems = [...processedItems]; clonedItems?.sort((a: any, b: any) => { - if (!sort || sort === "dateSaved") { + if (!sort || sort === "lastSaved") { const dateA = new Date(a.web.createdAt).getTime(); const dateB = new Date(b.web.createdAt).getTime(); @@ -254,9 +258,9 @@ export const ItemList = () => { } else if (!b.web.createdAt) { return 1; } else { - return dateB - dateA; + return sortOrder === "asc" ? dateA - dateB : dateB - dateA; } - } else if (sort === "datePublished") { + } else if (sort === "lastPublished") { // Handle undefined publishAt by setting a default far-future date for sorting purposes let dateA = a?.scheduling?.publishAt || a?.publishing?.publishAt; @@ -265,13 +269,20 @@ export const ItemList = () => { let dateB = b?.scheduling?.publishAt || b?.publishing?.publishAt; dateB = dateB ? new Date(dateB).getTime() : Number.NEGATIVE_INFINITY; - return dateB - dateA; - } else if (sort === "dateCreated") { + return sortOrder === "asc" ? dateA - dateB : dateB - dateA; + } else if (sort === "createdOn") { + if (sortOrder === "asc") { + return ( + new Date(a.meta.createdAt).getTime() - + new Date(b.meta.createdAt).getTime() + ); + } + return ( new Date(b.meta.createdAt).getTime() - new Date(a.meta.createdAt).getTime() ); - } else if (sort === "status") { + } else if (sort === "version") { const aIsPublished = a?.publishing?.publishAt; const bIsPublished = b?.publishing?.publishAt; @@ -291,6 +302,13 @@ export const ItemList = () => { // Items with only publish date if (aIsPublished && !aIsScheduled && bIsPublished && !bIsScheduled) { + if (sortOrder === "asc") { + return ( + new Date(aIsPublished).getTime() - + new Date(bIsPublished).getTime() + ); + } + return ( new Date(bIsPublished).getTime() - new Date(aIsPublished).getTime() ); // Both have only published date, sort by publish date descending @@ -302,6 +320,13 @@ export const ItemList = () => { // Items with scheduled date (and also publish date) if (aIsScheduled && bIsScheduled) { + if (sortOrder === "asc") { + return ( + new Date(bIsScheduled).getTime() - + new Date(aIsScheduled).getTime() + ); + } + return ( new Date(aIsScheduled).getTime() - new Date(bIsScheduled).getTime() ); // Both are scheduled, sort by scheduled date ascending @@ -313,6 +338,13 @@ export const ItemList = () => { // Items with neither publish nor schedule dates if (aIsPublished && bIsPublished) { + if (sortOrder === "asc") { + return ( + new Date(aIsPublished).getTime() - + new Date(bIsPublished).getTime() + ); + } + return ( new Date(bIsPublished).getTime() - new Date(aIsPublished).getTime() ); // Both are published, sort by publish date descending @@ -323,13 +355,36 @@ export const ItemList = () => { } return 0; // Neither are published or scheduled + } else if (sort === "createdBy") { + const userA = a?.meta?.createdByUserName; + const userB = b?.meta?.createdByUserName; + + if (!userA) { + return 1; + } else if (!userB) { + return -1; + } else { + return sortOrder === "asc" + ? userB.localeCompare(userA) + : userA.localeCompare(userB); + } + } else if (sort === "zuid") { + return sortOrder === "asc" + ? b.meta?.ZUID?.localeCompare(a.meta?.ZUID) + : a.meta?.ZUID?.localeCompare(b.meta?.ZUID); } else if (fields?.find((field) => field.name === sort)) { const dataType = fields?.find((field) => field.name === sort)?.datatype; if (typeof a.data[sort] === "number") { if (a.data[sort] == null) return 1; if (b.data[sort] == null) return -1; - return dataType === "sort" + if (dataType === "sort") { + return sortOrder === "asc" + ? a.data[sort] - b.data[sort] + : b.data[sort] - a.data[sort]; + } + + return sortOrder === "asc" ? a.data[sort] - b.data[sort] : b.data[sort] - a.data[sort]; } @@ -339,22 +394,38 @@ export const ItemList = () => { } else if (!b.data[sort]) { return -1; } else { - return ( - new Date(b.data[sort]).getTime() - - new Date(a.data[sort]).getTime() - ); + return sortOrder === "asc" + ? new Date(a.data[sort]).getTime() - + new Date(b.data[sort]).getTime() + : new Date(b.data[sort]).getTime() - + new Date(a.data[sort]).getTime(); + } + } + + if (dataType === "yes_no") { + if (!a.data[sort]) { + return 1; + } else if (!b.data[sort]) { + return -1; + } else { + return sortOrder === "asc" ? a - b : b - a; } } + const aValue = dataType === "images" ? a.data[sort]?.filename : a.data[sort]; const bValue = dataType === "images" ? b.data[sort]?.filename : b.data[sort]; - return aValue?.trim()?.localeCompare(bValue?.trim()); + + return sortOrder === "asc" + ? bValue?.trim()?.localeCompare(aValue?.trim()) + : aValue?.trim()?.localeCompare(bValue?.trim()); } else { - return ( - new Date(b.meta.updatedAt).getTime() - - new Date(a.meta.updatedAt).getTime() - ); + return sortOrder === "asc" + ? new Date(a.meta.updatedAt).getTime() - + new Date(b.meta.updatedAt).getTime() + : new Date(b.meta.updatedAt).getTime() - + new Date(a.meta.updatedAt).getTime(); } }); if (search) { @@ -422,7 +493,7 @@ export const ItemList = () => { // filter items by all fields return clonedItems; - }, [processedItems, search, sort, statusFilter, dateFilter, userFilter]); + }, [processedItems, search, sortModel, statusFilter, dateFilter, userFilter]); return ( @@ -534,7 +605,7 @@ export const ItemList = () => { {!sortedAndFilteredItems?.length && !isModelItemsFetching && !search && - (sort || + (!!sortModel?.length || statusFilter || dateFilter?.preset || dateFilter?.from || diff --git a/src/shell/components/CascadingMenuItem/index.tsx b/src/shell/components/CascadingMenuItem/index.tsx index b929507ad7..abd0bdce44 100644 --- a/src/shell/components/CascadingMenuItem/index.tsx +++ b/src/shell/components/CascadingMenuItem/index.tsx @@ -1,4 +1,4 @@ -import React, { FC, useState } from "react"; +import React, { FC, useEffect, useState } from "react"; import { MenuItem, MenuItemProps, @@ -23,11 +23,37 @@ export const CascadingMenuItem: FC = ({ ...props }) => { const [anchorEl, setAnchorEl] = useState(null); + const [isChildHovered, setIsChildHovered] = useState(false); + const [isParentHovered, setIsParentHovered] = useState(false); + + /** Note: This essentially adds a small delay to allow a user to move their mouse + * to the child component instead of just immediately closing it outright + */ + useEffect(() => { + let timeoutId: NodeJS.Timeout; + + if (!isParentHovered) { + timeoutId = setTimeout(() => { + if (!isChildHovered) { + setAnchorEl(null); + } + }, 100); + } + + return () => { + clearTimeout(timeoutId); + }; + }, [isParentHovered, isChildHovered]); return ( setAnchorEl(evt.currentTarget)} - onMouseLeave={() => setAnchorEl(null)} + onMouseEnter={(evt) => { + setAnchorEl(evt.currentTarget); + setIsParentHovered(true); + }} + onMouseLeave={() => { + setIsParentHovered(false); + }} sx={{ // HACK: Prevents the menu item to be in active style state when the sub-menu is opened. "&.MuiMenuItem-root": { @@ -51,7 +77,17 @@ export const CascadingMenuItem: FC = ({ }} {...PopperProps} > - + { + setIsChildHovered(true); + }} + onMouseLeave={() => { + setIsChildHovered(false); + setAnchorEl(null); + }} + > {children} From d49cc5fdde5a24669ef7201085e7d4d7e56efcc6 Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Thu, 1 Aug 2024 04:09:02 +0800 Subject: [PATCH 14/44] [Content] Filter button icon update & show api endpoints on hover (#2896) Resolves #2889 Resolves #2830 ### Demo [Screencast from Wednesday, 31 July, 2024 10:52:37 AM PST.webm](https://github.com/user-attachments/assets/74116615-c945-4943-9482-fa41404b854d) --- .../src/app/components/APIEndpoints.tsx | 89 +++++++++++ .../components/ItemEditHeader/MoreMenu.tsx | 143 ++++-------------- .../app/views/ItemList/ItemListActions.tsx | 137 ++++------------- .../app/views/ItemList/ItemListFilters.tsx | 4 +- .../app/components/Controls/DateFilter.tsx | 4 +- src/shell/components/Filters/FilterButton.tsx | 4 +- 6 files changed, 147 insertions(+), 234 deletions(-) create mode 100644 src/apps/content-editor/src/app/components/APIEndpoints.tsx diff --git a/src/apps/content-editor/src/app/components/APIEndpoints.tsx b/src/apps/content-editor/src/app/components/APIEndpoints.tsx new file mode 100644 index 0000000000..52b43eff1d --- /dev/null +++ b/src/apps/content-editor/src/app/components/APIEndpoints.tsx @@ -0,0 +1,89 @@ +import { useSelector } from "react-redux"; +import { useParams } from "react-router"; +import { + MenuList, + MenuItem, + ListItemIcon, + Typography, + Chip, +} from "@mui/material"; +import { DesignServicesRounded, VisibilityRounded } from "@mui/icons-material"; + +import { AppState } from "../../../../../shell/store/types"; +import { ContentItem } from "../../../../../shell/services/types"; +import { useGetDomainsQuery } from "../../../../../shell/services/accounts"; +import { ApiType } from "../../../../schema/src/app/components/ModelApi"; + +type APIEndpointsProps = { + type: Extract; +}; +export const APIEndpoints = ({ type }: APIEndpointsProps) => { + const { itemZUID } = useParams<{ + itemZUID: string; + }>(); + const item = useSelector( + (state: AppState) => state.content[itemZUID] as ContentItem + ); + const instance = useSelector((state: AppState) => state.instance); + const { data: domains } = useGetDomainsQuery(); + + const apiTypeEndpointMap: Partial> = { + "quick-access": `/-/instant/${itemZUID}.json`, + "site-generators": item ? `/${item?.web?.path}/?toJSON` : "/?toJSON", + }; + + const liveDomain = domains?.find((domain) => domain.branch == "live"); + + return ( + + { + window.open( + // @ts-expect-error config not typed + `${CONFIG.URL_PREVIEW_PROTOCOL}${instance.randomHashID}${CONFIG.URL_PREVIEW}${apiTypeEndpointMap[type]}`, + "_blank" + ); + }} + > + + + + + {/* @ts-expect-error config not typed */} + {`${instance.randomHashID}${CONFIG.URL_PREVIEW}${apiTypeEndpointMap[type]}`} + + + + {liveDomain && ( + { + window.open( + `https://${liveDomain.domain}${apiTypeEndpointMap[type]}`, + "_blank" + ); + }} + > + + + + + {`${liveDomain.domain}${apiTypeEndpointMap[type]}`} + + + + )} + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/MoreMenu.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/MoreMenu.tsx index 0ff94721c6..30c7f04297 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/MoreMenu.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/MoreMenu.tsx @@ -1,10 +1,8 @@ import { - Chip, IconButton, ListItemIcon, Menu, MenuItem, - Typography, Tooltip, } from "@mui/material"; import { @@ -16,23 +14,18 @@ import { CodeRounded, DeleteRounded, CheckRounded, - DesignServicesRounded, - VisibilityRounded, KeyboardArrowRightRounded, } from "@mui/icons-material"; import { useState } from "react"; import { Database } from "@zesty-io/material"; import { useHistory, useParams } from "react-router"; -import { useSelector } from "react-redux"; -import { AppState } from "../../../../../../../../shell/store/types"; -import { ContentItem } from "../../../../../../../../shell/services/types"; import { DuplicateItemDialog } from "./DuplicateItemDialog"; -import { ApiType } from "../../../../../../../schema/src/app/components/ModelApi"; -import { useGetDomainsQuery } from "../../../../../../../../shell/services/accounts"; import { useFilePath } from "../../../../../../../../shell/hooks/useFilePath"; import { DeleteItemDialog } from "./DeleteItemDialog"; import { useGetContentModelsQuery } from "../../../../../../../../shell/services/instance"; import { usePermission } from "../../../../../../../../shell/hooks/use-permissions"; +import { CascadingMenuItem } from "../../../../../../../../shell/components/CascadingMenuItem"; +import { APIEndpoints } from "../../../../components/APIEndpoints"; export const MoreMenu = () => { const { modelZUID, itemZUID } = useParams<{ @@ -42,17 +35,8 @@ export const MoreMenu = () => { const [anchorEl, setAnchorEl] = useState(null); const [isCopied, setIsCopied] = useState(false); const [showDuplicateItemDialog, setShowDuplicateItemDialog] = useState(false); - const [showApiEndpoints, setShowApiEndpoints] = useState( - null - ); const [showDeleteItemDialog, setShowDeleteItemDialog] = useState(false); - const [apiEndpointType, setApiEndpointType] = useState("quick-access"); const history = useHistory(); - const item = useSelector( - (state: AppState) => state.content[itemZUID] as ContentItem - ); - const instance = useSelector((state: AppState) => state.instance); - const { data: domains } = useGetDomainsQuery(); const codePath = useFilePath(modelZUID); const { data: contentModels } = useGetContentModelsQuery(); const type = @@ -73,13 +57,6 @@ export const MoreMenu = () => { }); }; - const apiTypeEndpointMap: Partial> = { - "quick-access": `/-/instant/${itemZUID}.json`, - "site-generators": item ? `/${item?.web?.path}/?toJSON` : "/?toJSON", - }; - - const liveDomain = domains?.find((domain) => domain.branch == "live"); - return ( <> { Copy ZUID - { - setShowApiEndpoints(event.currentTarget); - setApiEndpointType("quick-access"); - }} + + + + + View Quick Access API + + + } > - - - - View Quick Access API - - + + {type !== "dataset" && ( - { - setShowApiEndpoints(event.currentTarget); - setApiEndpointType("site-generators"); - }} + + + + + View Site Generators API + + + } > - - - - View Site Generators API - - + + )} { @@ -201,76 +180,6 @@ export const MoreMenu = () => { onClose={() => setShowDuplicateItemDialog(false)} /> )} - { - setShowApiEndpoints(null); - }} - > - { - setShowApiEndpoints(null); - window.open( - // @ts-expect-error config not typed - `${CONFIG.URL_PREVIEW_PROTOCOL}${instance.randomHashID}${CONFIG.URL_PREVIEW}${apiTypeEndpointMap[apiEndpointType]}`, - "_blank" - ); - }} - > - - - - - {/* @ts-expect-error config not typed */} - {`${instance.randomHashID}${CONFIG.URL_PREVIEW}${apiTypeEndpointMap[apiEndpointType]}`} - - - - {liveDomain && ( - { - setShowApiEndpoints(null); - window.open( - `https://${liveDomain.domain}${ - apiTypeEndpointMap[ - apiEndpointType as keyof typeof apiTypeEndpointMap - ] - }`, - "_blank" - ); - }} - > - - - - - {`${liveDomain.domain}${ - apiTypeEndpointMap[ - apiEndpointType as keyof typeof apiTypeEndpointMap - ] - }`} - - - - )} - {showDeleteItemDialog && ( setShowDeleteItemDialog(false)} /> )} diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListActions.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListActions.tsx index ff38b2eff9..be99600cdd 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListActions.tsx @@ -6,8 +6,6 @@ import { Menu, MenuItem, ListItemIcon, - Typography, - Chip, } from "@mui/material"; import { Database, IconButton } from "@zesty-io/material"; import { @@ -21,8 +19,6 @@ import { BoltRounded, KeyboardArrowRightRounded, DataObjectRounded, - VisibilityRounded, - DesignServicesRounded, } from "@mui/icons-material"; import { forwardRef, useState, useCallback } from "react"; import { useHistory, useParams as useRouterParams } from "react-router"; @@ -30,34 +26,21 @@ import { useFilePath } from "../../../../../../shell/hooks/useFilePath"; import { useParams } from "../../../../../../shell/hooks/useParams"; import { debounce } from "lodash"; import { useGetContentModelsQuery } from "../../../../../../shell/services/instance"; -import { useGetDomainsQuery } from "../../../../../../shell/services/accounts"; -import { ApiType } from "../../../../../schema/src/app/components/ModelApi"; -import { AppState } from "../../../../../../shell/store/types"; -import { useSelector } from "react-redux"; +import { CascadingMenuItem } from "../../../../../../shell/components/CascadingMenuItem"; +import { APIEndpoints } from "../../components/APIEndpoints"; export const ItemListActions = forwardRef((props, ref) => { const { modelZUID } = useRouterParams<{ modelZUID: string }>(); const { data: contentModels } = useGetContentModelsQuery(); - const { data: domains } = useGetDomainsQuery(); const history = useHistory(); const [anchorEl, setAnchorEl] = useState(null); const codePath = useFilePath(modelZUID); const [isCopied, setIsCopied] = useState(false); const [params, setParams] = useParams(); const [searchTerm, setSearchTerm] = useState(params.get("search") || ""); - const instance = useSelector((state: AppState) => state.instance); - const [showApiEndpoints, setShowApiEndpoints] = useState( - null - ); - const [apiEndpointType, setApiEndpointType] = useState("quick-access"); const isDataset = contentModels?.find((model) => model.ZUID === modelZUID)?.type === "dataset"; - const apiTypeEndpointMap: Partial> = { - "quick-access": `/-/instant/${modelZUID}.json`, - "site-generators": "/?toJSON", - }; - const liveDomain = domains?.find((domain) => domain.branch == "live"); const handleCopyClick = (data: string) => { navigator?.clipboard @@ -141,31 +124,33 @@ export const ItemListActions = forwardRef((props, ref) => { Copy ZUID - { - setShowApiEndpoints(event.currentTarget); - setApiEndpointType("quick-access"); - }} + + + + + View Quick Access API + + + } > - - - - View Quick Access API - - + + {!isDataset && ( - { - setShowApiEndpoints(event.currentTarget); - setApiEndpointType("site-generators"); - }} + + + + + View Site Generators API + + + } > - - - - View Site Generators API - - + + )} { )}
- { - setShowApiEndpoints(null); - }} - > - { - setShowApiEndpoints(null); - window.open( - // @ts-expect-error config not typed - `${CONFIG.URL_PREVIEW_PROTOCOL}${instance.randomHashID}${CONFIG.URL_PREVIEW}${apiTypeEndpointMap[apiEndpointType]}`, - "_blank" - ); - }} - > - - - - - {/* @ts-expect-error config not typed */} - {`${instance.randomHashID}${CONFIG.URL_PREVIEW}${apiTypeEndpointMap[apiEndpointType]}`} - - - - {liveDomain && ( - { - setShowApiEndpoints(null); - window.open( - `https://${liveDomain.domain}${ - apiTypeEndpointMap[ - apiEndpointType as keyof typeof apiTypeEndpointMap - ] - }`, - "_blank" - ); - }} - > - - - - - {`${liveDomain.domain}${ - apiTypeEndpointMap[ - apiEndpointType as keyof typeof apiTypeEndpointMap - ] - }`} - - - - )} - { size="small" variant="outlined" color="inherit" - endIcon={} + endIcon={} onClick={(e) => setAnchorEl({ currentTarget: e.currentTarget, diff --git a/src/apps/media/src/app/components/Controls/DateFilter.tsx b/src/apps/media/src/app/components/Controls/DateFilter.tsx index abbf11235c..000ceca456 100644 --- a/src/apps/media/src/app/components/Controls/DateFilter.tsx +++ b/src/apps/media/src/app/components/Controls/DateFilter.tsx @@ -7,7 +7,7 @@ import MenuItem from "@mui/material/MenuItem"; import Menu from "@mui/material/Menu"; import Typography from "@mui/material/Typography"; -import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; +import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded"; import CloseRounded from "@mui/icons-material/CloseRounded"; import CheckIcon from "@mui/icons-material/Check"; import Divider from "@mui/material/Divider"; @@ -98,7 +98,7 @@ export const DateRangeFilter: FC = () => { const inactiveButton = ( + + + ); + } + + return ( + <> + + + + + + + + Replace File: + +   + {originalFile?.filename} + + + + + The original file will be deleted and replaced by its new file. This + action cannot be undone and the file cannot be recovered. The file + will retain its URL and ZUID. + + + + + + + + { + setNewFile(evt.target.files[0]); + }} + hidden + accept={acceptedExtension} + style={{ display: "none" }} + /> + + ); +}; diff --git a/src/apps/media/src/app/components/FileModal/index.tsx b/src/apps/media/src/app/components/FileModal/index.tsx index e726957cd5..282c6877d0 100644 --- a/src/apps/media/src/app/components/FileModal/index.tsx +++ b/src/apps/media/src/app/components/FileModal/index.tsx @@ -18,6 +18,7 @@ import { useGetFileQuery } from "../../../../../../shell/services/mediaManager"; import { OTFEditor } from "./OTFEditor"; import { File } from "../../../../../../shell/services/types"; import { useParams } from "../../../../../../shell/hooks/useParams"; +import { ReplaceFileModal } from "./ReplaceFileModal"; const styledModal = { position: "absolute", @@ -48,6 +49,8 @@ export const FileModal: FC = ({ const location = useLocation(); const { data, isLoading, isError, isFetching } = useGetFileQuery(fileId); const [showEdit, setShowEdit] = useState(false); + const [showReplaceFileModal, setShowReplaceFileModal] = useState(false); + const [fileToReplace, setFileToReplace] = useState(null); const [params, setParams] = useParams(); const [adjacentFiles, setAdjacentFiles] = useState({ prevFile: null, @@ -150,128 +153,145 @@ export const FileModal: FC = ({ }; }, []); + if (isFetching || (!data && !isError)) { + return ( + + + + ); + } + + if (showReplaceFileModal) { + return ( + setShowReplaceFileModal(false)} + onClose={handleCloseModal} + /> + ); + } + + if (!data) { + return <>; + } + return ( - <> - {data && !isError && !isFetching ? ( - + {adjacentFiles.nextFile && ( + { + handleArrow(adjacentFiles.nextFile); + }} + sx={{ + position: "absolute", + right: -72, + top: "50%", }} > - {adjacentFiles.nextFile && ( - { - handleArrow(adjacentFiles.nextFile); - }} - sx={{ - position: "absolute", - right: -72, - top: "50%", - }} - > - - - )} - {adjacentFiles.prevFile && ( - - { - handleArrow(adjacentFiles.prevFile); - }} - sx={{ - position: "absolute", - left: -72, - top: "50%", - }} - > - - - - )} - + + )} + {adjacentFiles.prevFile && ( + + { + handleArrow(adjacentFiles.prevFile); + }} sx={{ - display: "flex", - justifyContent: "space-between", - p: 0, - overflow: "hidden", + position: "absolute", + left: -72, + top: "50%", }} > - {/* */} - - - - - - {showEdit ? ( - - ) : ( - - )} - - {/* */} - - - ) : isFetching || (!data && !isError) ? ( - + + + )} + + {/* */} + - - - ) : ( - <> - )} - + + + + + {showEdit ? ( + + ) : ( + { + setShowReplaceFileModal(true); + }} + /> + )} + + {/* */} + + ); }; diff --git a/src/apps/media/src/app/components/Thumbnail/ThumbnailContent.tsx b/src/apps/media/src/app/components/Thumbnail/ThumbnailContent.tsx index 6ce30ef6a5..009ff0de1e 100644 --- a/src/apps/media/src/app/components/Thumbnail/ThumbnailContent.tsx +++ b/src/apps/media/src/app/components/Thumbnail/ThumbnailContent.tsx @@ -17,16 +17,20 @@ interface Props { filename: string; onFilenameChange?: (value: string) => void; onTitleChange?: (value: string) => void; - isEditable?: boolean; isSelected?: boolean; + isFilenameEditable?: boolean; + isTitleEditable?: boolean; + title?: string; } export const ThumbnailContent: FC = ({ filename, onFilenameChange, onTitleChange, - isEditable, isSelected, + isFilenameEditable, + isTitleEditable, + title, }) => { const styledCardContent = { px: onFilenameChange ? 0 : 1, @@ -48,13 +52,16 @@ export const ThumbnailContent: FC = ({ {onFilenameChange ? ( - + ) => onFilenameChange(e.target.value.replace(" ", "-")) } @@ -79,15 +86,16 @@ export const ThumbnailContent: FC = ({ }, }} /> - + void; onTitleChange?: (value: string) => void; onClick?: () => void; + showRemove?: boolean; + isFilenameEditable?: boolean; + isTitleEditable?: boolean; + title?: string; } export const Thumbnail: FC = ({ src, url, filename, - isEditable, + isDraggable, showVideo, onRemove, onFilenameChange, @@ -83,6 +87,10 @@ export const Thumbnail: FC = ({ onTitleChange, imageHeight, selectable, + showRemove = true, + isFilenameEditable, + isTitleEditable, + title, }) => { const theme = useTheme(); const imageEl = useRef(); @@ -119,6 +127,10 @@ export const Thumbnail: FC = ({ }; const RemoveIcon = () => { + if (!showRemove) { + return <>; + } + return ( <> {onRemove && ( @@ -331,7 +343,7 @@ export const Thumbnail: FC = ({ sx={styledCard} elevation={0} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} data-cy={id} > @@ -408,10 +420,12 @@ export const Thumbnail: FC = ({ file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -425,7 +439,7 @@ export const Thumbnail: FC = ({ sx={styledCard} elevation={0} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} data-cy={id} onDragStart={(evt) => onDragStart(evt)} > @@ -503,8 +517,9 @@ export const Thumbnail: FC = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -516,7 +531,7 @@ export const Thumbnail: FC = ({ sx={styledCard} elevation={0} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -588,7 +604,7 @@ export const Thumbnail: FC = ({ sx={styledCard} elevation={0} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -663,7 +680,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -737,7 +755,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -812,7 +831,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -889,7 +909,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -972,7 +993,7 @@ export const Thumbnail: FC = ({ elevation={0} data-cy={id} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1069,7 +1091,7 @@ export const Thumbnail: FC = ({ elevation={0} data-cy={id} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1147,7 +1170,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1220,7 +1244,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1293,7 +1318,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1366,7 +1392,7 @@ export const Thumbnail: FC = ({ elevation={0} data-cy={id} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1439,7 +1466,7 @@ export const Thumbnail: FC = ({ elevation={0} data-cy={id} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1515,7 +1543,7 @@ export const Thumbnail: FC = ({ elevation={0} data-cy={id} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1595,7 +1624,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1675,7 +1705,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1759,6 +1790,6 @@ export const Thumbnail: FC = ({ }; Thumbnail.defaultProps = { - isEditable: false, + isDraggable: false, showVideo: true, }; diff --git a/src/apps/media/src/app/components/UploadModal.tsx b/src/apps/media/src/app/components/UploadModal.tsx index d7cee6d083..3c2c4e0eec 100644 --- a/src/apps/media/src/app/components/UploadModal.tsx +++ b/src/apps/media/src/app/components/UploadModal.tsx @@ -31,9 +31,13 @@ import pluralizeWord from "../../../../../utility/pluralizeWord"; export const UploadModal: FC = () => { const dispatch = useDispatch(); - const uploads = useSelector((state: AppState) => state.mediaRevamp.uploads); + const uploads = useSelector((state: AppState) => + state.mediaRevamp.uploads.filter((upload) => !upload.replacementFile) + ); const filesToUpload = useSelector((state: AppState) => - state.mediaRevamp.uploads.filter((upload) => upload.status !== "failed") + state.mediaRevamp.uploads.filter( + (upload) => upload.status !== "failed" && !upload.replacementFile + ) ); const ids = filesToUpload.length && { currentBinId: filesToUpload[0].bin_id, @@ -186,8 +190,14 @@ const UploadErrors = () => { type UploadHeaderTextProps = { uploads: Upload[]; + headerKeyword?: string; + showCount?: boolean; }; -const UploadHeaderText = ({ uploads }: UploadHeaderTextProps) => { +export const UploadHeaderText = ({ + uploads, + headerKeyword = "File", + showCount = true, +}: UploadHeaderTextProps) => { const filesUploading = uploads?.filter( (upload) => upload.status === "inProgress" ); @@ -225,12 +235,18 @@ const UploadHeaderText = ({ uploads }: UploadHeaderTextProps) => { )} + {showCount ? ( + filesUploading?.length > 0 ? ( + filesUploading.length + ) : ( + filesUploaded.length + ) + ) : ( + <> + )}{" "} {filesUploading?.length > 0 - ? filesUploading.length - : filesUploaded.length}{" "} - {filesUploading?.length > 0 - ? pluralizeWord("File", filesUploading.length) - : pluralizeWord("File", filesUploaded.length)}{" "} + ? pluralizeWord(headerKeyword, filesUploading.length) + : pluralizeWord(headerKeyword, filesUploaded.length)}{" "} {filesUploading?.length > 0 ? "Uploading" : "Uploaded"} diff --git a/src/apps/media/src/app/components/UploadThumbnail.tsx b/src/apps/media/src/app/components/UploadThumbnail.tsx index 83504e031f..35d7f0b5c2 100644 --- a/src/apps/media/src/app/components/UploadThumbnail.tsx +++ b/src/apps/media/src/app/components/UploadThumbnail.tsx @@ -13,13 +13,23 @@ import { Upload, fileUploadSetFilename, deleteUpload, + replaceFile, } from "../../../../../shell/store/media-revamp"; +import { File as ZestyMediaFile } from "../../../../../shell/services/types"; interface Props { file: Upload; + action?: "new" | "replace"; + originalFile?: ZestyMediaFile; + showRemove?: boolean; } -export const UploadThumbnail: FC = ({ file }) => { +export const UploadThumbnail: FC = ({ + file, + action = "new", + originalFile, + showRemove = true, +}) => { const dispatch = useDispatch(); const { data: bin } = mediaManagerApi.useGetBinQuery(file.bin_id, { @@ -28,7 +38,11 @@ export const UploadThumbnail: FC = ({ file }) => { useEffect(() => { if (bin && file.status === "staged") { - dispatch(uploadFile(file, bin[0])); + if (action === "new") { + dispatch(uploadFile(file, bin[0])); + } else { + dispatch(replaceFile(file, originalFile)); + } } }, [bin]); @@ -61,10 +75,14 @@ export const UploadThumbnail: FC = ({ file }) => { > { if (file.status === "success") { dispatch( diff --git a/src/shell/components/InviteMembersModal/index.tsx b/src/shell/components/InviteMembersModal/index.tsx index 5c5b5ce2b0..5e6b9a7035 100644 --- a/src/shell/components/InviteMembersModal/index.tsx +++ b/src/shell/components/InviteMembersModal/index.tsx @@ -21,7 +21,7 @@ import { useGetCurrentUserRolesQuery, } from "../../services/accounts"; import { LoadingButton } from "@mui/lab"; -import { NoPermission } from "./NoPermission"; +import { NoPermission } from "../NoPermission"; import instanzeZUID from "../../../utility/instanceZUID"; import { ConfirmationModal } from "./ConfirmationDialog"; diff --git a/src/shell/components/InviteMembersModal/NoPermission.tsx b/src/shell/components/NoPermission.tsx similarity index 80% rename from src/shell/components/InviteMembersModal/NoPermission.tsx rename to src/shell/components/NoPermission.tsx index b22c8d1bc3..9ca0ec879f 100644 --- a/src/shell/components/InviteMembersModal/NoPermission.tsx +++ b/src/shell/components/NoPermission.tsx @@ -15,14 +15,20 @@ import { } from "@mui/material"; import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded"; -import { useGetUsersRolesQuery } from "../../services/accounts"; -import { MD5 } from "../../../utility/md5"; +import { useGetUsersRolesQuery } from "../services/accounts"; +import { MD5 } from "../../utility/md5"; type NoPermissionProps = { onClose: () => void; + headerTitle?: string; + headerSubtitle?: string; }; -export const NoPermission = ({ onClose }: NoPermissionProps) => { +export const NoPermission = ({ + onClose, + headerSubtitle, + headerTitle, +}: NoPermissionProps) => { const { data: users } = useGetUsersRolesQuery(); const ownersAndAdmins = useMemo(() => { @@ -51,12 +57,14 @@ export const NoPermission = ({ onClose }: NoPermissionProps) => { }} /> - You do not have permission to invite users + {headerTitle + ? headerTitle + : "You do not have permission to invite users"} - Contact your instance owners or administrators listed below to change - your role to Admin or Owner on this instance for user invitation - priveleges. + {headerSubtitle + ? headerSubtitle + : "Contact your instance owners or administrators listed below to change your role to Admin or Owner on this instance for user invitation priveleges."} diff --git a/src/shell/services/types.ts b/src/shell/services/types.ts index 1af2f44da7..b8658b0d48 100644 --- a/src/shell/services/types.ts +++ b/src/shell/services/types.ts @@ -60,6 +60,8 @@ export interface File { deleted_at?: string; deleted_from_storage_at?: string; thumbnail: string; + storage_driver: string; + storage_name: string; } export type ModelType = "pageset" | "templateset" | "dataset"; diff --git a/src/shell/services/util.js b/src/shell/services/util.js index a8e232048c..1b3236a582 100644 --- a/src/shell/services/util.js +++ b/src/shell/services/util.js @@ -10,5 +10,13 @@ export const prepareHeaders = (headers) => { return headers; }; -export const generateThumbnail = (file) => - `${file.url}?width=300&height=300&fit=bounds`; +export const generateThumbnail = (file) => { + if (!!file.updated_at && !isNaN(new Date(file.updated_at).getTime())) { + // Prevents browser image cache when a certain file has been already replaced + return `${file.url}?width=300&height=300&fit=bounds&versionHash=${new Date( + file.updated_at + ).getTime()}`; + } + + return `${file.url}?width=300&height=300&fit=bounds`; +}; diff --git a/src/shell/store/media-revamp.ts b/src/shell/store/media-revamp.ts index f75d83af73..239303430b 100644 --- a/src/shell/store/media-revamp.ts +++ b/src/shell/store/media-revamp.ts @@ -27,12 +27,18 @@ export type UploadFile = { loading?: boolean; bin_id?: string; group_id?: string; + replacementFile?: boolean; }; type FileUploadStart = StoreFile & { file: File }; type FileUploadSuccess = StoreFile & FileBase & { id: string }; type FileUploadProgress = { uploadID: string; progress: number }; -type FileUploadStageArg = { file: File; bin_id: string; group_id: string }; +type FileUploadStageArg = { + file: File; + bin_id: string; + group_id: string; + replacementFile?: boolean; +}; type StagedUpload = { status: "staged"; @@ -146,6 +152,7 @@ const mediaSlice = createSlice({ uploadID: uuidv4(), url: URL.createObjectURL(file.file), filename: file.file.name, + replacementFile: file.replacementFile, ...file, }; }); @@ -334,11 +341,11 @@ type FileAugmentation = { group_id?: string; }; -async function getSignedUrl(file: any, bin: Bin) { +async function getSignedUrl(filename: string, storageName: string) { try { return request( //@ts-expect-error - `${CONFIG.SERVICE_MEDIA_STORAGE}/signed-url/${bin.storage_name}/${file.file.name}` + `${CONFIG.SERVICE_MEDIA_STORAGE}/signed-url/${storageName}/${filename}` ).then((res) => res.data.url); } catch (err) { console.error(err); @@ -349,6 +356,152 @@ async function getSignedUrl(file: any, bin: Bin) { } } +export function replaceFile(newFile: UploadFile, originalFile: FileBase) { + return async (dispatch: Dispatch, getState: () => AppState) => { + const bodyData = new FormData(); + const req = new XMLHttpRequest(); + const file = { + progress: 0, + loading: true, + ...newFile, + }; + + bodyData.append("file", file.file, originalFile.filename); + bodyData.append("file_id", originalFile.id); + + req.upload.addEventListener("progress", function (e) { + file.progress = (e.loaded / e.total) * 100; + + dispatch(fileUploadProgress(file)); + }); + + function handleError() { + dispatch(fileUploadError(file)); + dispatch( + notify({ + message: "Failed uploading file", + kind: "error", + }) + ); + } + + req.addEventListener("abort", handleError); + req.addEventListener("error", handleError); + req.addEventListener("load", (_) => { + if (req.status === 200) { + dispatch( + notify({ + message: `File Replaced: ${originalFile.filename}`, + kind: "success", + }) + ); + const successFile = { + ...originalFile, + uploadID: file.uploadID, + progress: 100, + loading: false, + url: URL.createObjectURL(file.file), + }; + dispatch(fileUploadSuccess(successFile)); + } else { + dispatch( + notify({ + message: "Failed uploading file", + kind: "error", + }) + ); + dispatch(fileUploadError(file)); + } + }); + + // Use signed url flow for large files + if (file.file.size > 32000000) { + /** + * GAE has an inherent 32mb limit at their global nginx load balancer + * We use a signed url for large file uploads directly to the assocaited bucket + */ + + const signedUrl = await getSignedUrl( + originalFile?.filename, + originalFile?.storage_name + ); + req.open("PUT", signedUrl); + + // The sent content-type needs to match what was provided when generating the signed url + // @see https://medium.com/imersotechblog/upload-files-to-google-cloud-storage-gcs-from-the-browser-159810bb11e3 + req.setRequestHeader("Content-Type", file.file.type); + + req.addEventListener("load", () => { + if (req.status === 200) { + return request( + //@ts-expect-error + `${CONFIG.SERVICE_MEDIA_MANAGER}/file/${originalFile?.id}/purge?triggerUpdate=true`, + { + method: "POST", + json: true, + } + ) + .then((res) => { + if (res.status === 200) { + const state: State = getState().mediaRevamp; + if (state.uploads.length) { + dispatch( + fileUploadSuccess({ + ...res.data, + uploadID: file.uploadID, + }) + ); + } else { + dispatch( + notify({ + message: `Successfully uploaded file`, + kind: "success", + }) + ); + } + } else { + throw res; + } + }) + .catch((err) => { + dispatch(fileUploadError(file)); + dispatch( + notify({ + message: + "Failed creating file record after signed url upload", + kind: "error", + }) + ); + }); + } else { + dispatch(fileUploadError(file)); + dispatch( + notify({ + message: "Failed uploading file to signed url", + kind: "error", + }) + ); + } + }); + + // When sending directly to bucket it needs to be just the file + // and not the extra meta data for the zesty services + req.send(file.file); + } else { + req.withCredentials = true; + req.open( + "PUT", + //@ts-expect-error + `${CONFIG.SERVICE_MEDIA_STORAGE}/replace/${originalFile?.storage_driver}/${originalFile?.storage_name}` + ); + + req.send(bodyData); + } + + dispatch(fileUploadStart(file)); + }; +} + //type FileMonstrosity = {file: File } & FileAugmentation & FileBase export function uploadFile(fileArg: UploadFile, bin: Bin) { return async (dispatch: Dispatch, getState: () => AppState) => { @@ -417,7 +570,7 @@ export function uploadFile(fileArg: UploadFile, bin: Bin) { * We use a signed url for large file uploads directly to the assocaited bucket */ - const signedUrl = await getSignedUrl(file, bin); + const signedUrl = await getSignedUrl(file.file.name, bin.storage_name); req.open("PUT", signedUrl); // The sent content-type needs to match what was provided when generating the signed url @@ -596,16 +749,18 @@ export function dismissFileUploads() { ); } if (successfulUploads.length) { - dispatch( - notify({ - message: `Successfully uploaded ${successfulUploads.length} files${ - inProgressUploads.length - ? `...${inProgressUploads.length} files still in progress` - : "" - }`, - kind: "success", - }) - ); + if (!successfulUploads[0].replacementFile) { + dispatch( + notify({ + message: `Successfully uploaded ${successfulUploads.length} files${ + inProgressUploads.length + ? `...${inProgressUploads.length} files still in progress` + : "" + }`, + kind: "success", + }) + ); + } } if (failedUploads.length) { dispatch( @@ -622,6 +777,12 @@ export function dismissFileUploads() { kind: "warn", }) ); + } else { + successfulUploads?.forEach((upload) => { + dispatch( + mediaManagerApi.util.invalidateTags([{ type: "File", id: upload.id }]) + ); + }); } dispatch(fileUploadReset()); }; From 23627090f0a10e14ba1feef4a2ea36a413ab90c2 Mon Sep 17 00:00:00 2001 From: Andres Galindo Date: Thu, 1 Aug 2024 15:57:25 -0700 Subject: [PATCH 17/44] media rules vqa (#2904) --- .../src/app/components/FieldTypeMedia.tsx | 4 ++-- .../src/app/components/AddFieldModal/DefaultValue.tsx | 11 ++++++----- .../src/app/components/AddFieldModal/MediaRules.tsx | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx b/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx index 713b146799..13028d9eee 100644 --- a/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx +++ b/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx @@ -424,7 +424,7 @@ export const FieldTypeMedia = ({ {selectionError && ( - + {selectionError} )} @@ -527,7 +527,7 @@ export const FieldTypeMedia = ({ )} {selectionError && ( - + {selectionError} )} diff --git a/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx b/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx index e9bd193e9c..d0b6a5427a 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx @@ -71,15 +71,16 @@ export const DefaultValue = ({ variant="body3" color="text.secondary" fontWeight="600" - sx={{ mb: 1, display: "block" }} + sx={{ display: "block" }} > Set a predefined value for this field } /> - - {isDefaultValueEnabled && ( + + {isDefaultValueEnabled && ( + - )} - + + )} ); }; diff --git a/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx b/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx index 6660a1c11d..101eb794fc 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx @@ -288,7 +288,7 @@ export const MediaRules = ({ )} {Boolean(fieldData[rule.name]) && rule.name === "fileExtensions" && ( - + Extensions * Date: Fri, 2 Aug 2024 07:15:07 +0800 Subject: [PATCH 18/44] [Content] Remove replace file option for non-zesty media files (#2905) Remove replace file option for non-zesty media files ### Demo ![image](https://github.com/user-attachments/assets/b3b1cd09-1889-48dd-8abb-9d1508093cfe) --- .../src/app/components/FieldTypeMedia.tsx | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx b/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx index 13028d9eee..a3bbf9166d 100644 --- a/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx +++ b/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx @@ -852,43 +852,43 @@ const MediaItem = ({ }} > {!isURL && !isBynderAsset && ( - { - event.stopPropagation(); - setAnchorEl(null); - setShowRenameFileModal(true); - }} - > - - - - Rename - - )} - { - event.stopPropagation(); - setAnchorEl(null); - setIsReplaceFileModalOpen(true); - }} - > - - - - Replace File - - {!isURL && !isBynderAsset && ( - { - event.stopPropagation(); - handleCopyClick(imageZUID, true); - }} - > - - {isCopiedZuid ? : } - - Copy ZUID - + <> + { + event.stopPropagation(); + setAnchorEl(null); + setShowRenameFileModal(true); + }} + > + + + + Rename + + { + event.stopPropagation(); + setAnchorEl(null); + setIsReplaceFileModalOpen(true); + }} + > + + + + Replace File + + { + event.stopPropagation(); + handleCopyClick(imageZUID, true); + }} + > + + {isCopiedZuid ? : } + + Copy ZUID + + )} { From 13cf24fea70a40a59b5d5c6c4f4b2eedc4e4dddb Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Tue, 6 Aug 2024 09:54:32 +0800 Subject: [PATCH 19/44] [Content] Show that table is sorted in Last Saved desc when no other sorting is applied (#2909) https://github.com/user-attachments/assets/64bef5ea-46ab-4eca-a2fc-e63eabc7d339 --- .../src/app/views/ItemList/ItemListTable.tsx | 11 ++++++++++- .../src/app/views/ItemList/TableSortProvider.tsx | 8 +++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx index 50fb6fb7ea..08f89f3277 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx @@ -405,7 +405,16 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { sortModel={sortModel} sortingMode="server" onSortModelChange={(newSortModel) => { - setSortModel(newSortModel); + if (!Object.entries(newSortModel)?.length) { + setSortModel([ + { + field: "lastSaved", + sort: "desc", + }, + ]); + } else { + setSortModel(newSortModel); + } }} onSelectionModelChange={(newSelection) => setSelectedItems(newSelection)} selectionModel={ diff --git a/src/apps/content-editor/src/app/views/ItemList/TableSortProvider.tsx b/src/apps/content-editor/src/app/views/ItemList/TableSortProvider.tsx index 46af57c1a4..bb6bee3b40 100644 --- a/src/apps/content-editor/src/app/views/ItemList/TableSortProvider.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/TableSortProvider.tsx @@ -15,7 +15,13 @@ type TableSortProviderType = { children?: React.ReactNode; }; export const TableSortProvider = ({ children }: TableSortProviderType) => { - const [sortModel, setSortModel] = useState([]); + // Note: We always want it to default to lastSaved if no other sorting is applied + const [sortModel, setSortModel] = useState([ + { + field: "lastSaved", + sort: "desc", + }, + ]); const { modelZUID } = useRouterParams<{ modelZUID: string }>(); useLayoutEffect(() => { From adf7fff80db255cce0f64186a60bdd8578002d88 Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Tue, 13 Aug 2024 11:17:28 +0800 Subject: [PATCH 20/44] [Content | Schema] Do not show site-generators api options for dataset items (#2923) Resovles #2828 ### Demo ![image](https://github.com/user-attachments/assets/d95bc894-a44a-4d17-b17f-356f27740229) ![image](https://github.com/user-attachments/assets/7d8524c1-5f54-4c24-a0c1-1ba905fce8a4) --- .../app/components/ModelApi/ApiCardList.tsx | 18 +++++++++++- .../app/components/ModelApi/ApiDetails.tsx | 28 +++++++++++++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/apps/schema/src/app/components/ModelApi/ApiCardList.tsx b/src/apps/schema/src/app/components/ModelApi/ApiCardList.tsx index 828f145a30..3eea234d2c 100644 --- a/src/apps/schema/src/app/components/ModelApi/ApiCardList.tsx +++ b/src/apps/schema/src/app/components/ModelApi/ApiCardList.tsx @@ -1,8 +1,24 @@ +import { useParams } from "react-router"; +import { useMemo } from "react"; import { Box, Stack, Typography } from "@mui/material"; import { ApiCard } from "./ApiCard"; import { apiTypes } from "."; +import { useGetContentModelQuery } from "../../../../../../shell/services/instance"; export const ApiCardList = () => { + const { contentModelZUID } = useParams<{ contentModelZUID: string }>(); + const { data: modelData } = useGetContentModelQuery(contentModelZUID, { + skip: !contentModelZUID, + }); + + const filteredApiTypes = useMemo(() => { + if (modelData?.type === "dataset") { + return apiTypes.filter((apiType) => apiType !== "site-generators"); + } + + return apiTypes; + }, [modelData]); + return ( { height: "100%", }} > - {apiTypes.map((apiType) => ( + {filteredApiTypes?.map((apiType) => ( ))} diff --git a/src/apps/schema/src/app/components/ModelApi/ApiDetails.tsx b/src/apps/schema/src/app/components/ModelApi/ApiDetails.tsx index 0e14b4327a..89822d8800 100644 --- a/src/apps/schema/src/app/components/ModelApi/ApiDetails.tsx +++ b/src/apps/schema/src/app/components/ModelApi/ApiDetails.tsx @@ -8,7 +8,7 @@ import { Stack, CircularProgress, } from "@mui/material"; -import { useHistory, useLocation } from "react-router"; +import { useHistory, useLocation, useParams } from "react-router"; import { SvgIconComponent } from "@mui/icons-material"; import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; import BoltRoundedIcon from "@mui/icons-material/BoltRounded"; @@ -24,8 +24,12 @@ import { useSelector } from "react-redux"; import { AppState } from "../../../../../../shell/store/types"; import { ApiDomainEndpoints } from "./ApiDomainEndpoints"; import CodeRoundedIcon from "@mui/icons-material/CodeRounded"; -import { useGetInstanceSettingsQuery } from "../../../../../../shell/services/instance"; +import { + useGetContentModelQuery, + useGetInstanceSettingsQuery, +} from "../../../../../../shell/services/instance"; import { HeadlessSwitcher } from "./HeadlessSwitcher"; +import { useEffect, useMemo } from "react"; const apiTypeIconMap: Record = { "quick-access": BoltRoundedIcon, @@ -46,6 +50,10 @@ const apiTypesWithEndpoints = [ export const ApiDetails = () => { const history = useHistory(); const location = useLocation(); + const { contentModelZUID } = useParams<{ contentModelZUID: string }>(); + const { data: modelData } = useGetContentModelQuery(contentModelZUID, { + skip: !contentModelZUID, + }); const installedApps = useSelector((state: AppState) => state.apps.installed); const selectedType = location.pathname.split("/").pop() as ApiType; const { data: instanceSettings, isFetching } = useGetInstanceSettingsQuery( @@ -56,6 +64,14 @@ export const ApiDetails = () => { instanceSettings?.find((setting) => setting.key === "mode")?.value !== "traditional"; + const filteredApiTypes = useMemo(() => { + if (modelData?.type === "dataset") { + return apiTypes.filter((apiType) => apiType !== "site-generators"); + } + + return apiTypes; + }, [modelData]); + const handleVisualLayoutClick = () => { const layoutApp = installedApps?.find( (app: any) => app?.name === "layouts" @@ -67,6 +83,12 @@ export const ApiDetails = () => { } }; + useEffect(() => { + if (selectedType === "site-generators" && modelData?.type === "dataset") { + history.replace(`${location.pathname.split("/").slice(0, -1).join("/")}`); + } + }, [selectedType, modelData]); + return ( { - {apiTypes.map((type) => ( + {filteredApiTypes?.map((type) => ( Date: Wed, 14 Aug 2024 10:05:38 -0700 Subject: [PATCH 21/44] Fix/Prevent safari focus issue when manipulating cursor position on inputs (#2925) --- src/shell/components/withCursorPosition/index.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/shell/components/withCursorPosition/index.tsx b/src/shell/components/withCursorPosition/index.tsx index 94084b9b32..4bb01d4276 100644 --- a/src/shell/components/withCursorPosition/index.tsx +++ b/src/shell/components/withCursorPosition/index.tsx @@ -13,11 +13,19 @@ export const withCursorPosition = (WrappedComponent: ComponentType) => const inputRef = useRef(null); useEffect(() => { - inputRef.current?.setSelectionRange(cursorPosition, cursorPosition); + /* + In Safari, setting the cursor position can cause the input to refocus, + leading to a poor user experience if the input isn't already focused. + This conditional check ensures the cursor position is only set if the input is focused, + preventing unnecessary refocusing on value changes, which is a problem in Safari. + */ + if (document.activeElement === inputRef.current) { + inputRef.current?.setSelectionRange(cursorPosition, cursorPosition); + } }, [props.value]); const handleChange = (e: React.ChangeEvent) => { - setCursorPosition(e.target.selectionStart); + setCursorPosition(e.target.selectionStart || 0); props.onChange && props.onChange(e); }; From 247ac756c32856915689e96670dd34616acad18c Mon Sep 17 00:00:00 2001 From: Andres Galindo Date: Wed, 14 Aug 2024 10:22:03 -0700 Subject: [PATCH 22/44] Check granular role permissions for specific resources (#2899) closes #2811 closes #2812 --- .../src/app/views/ItemEdit/Content/Actions/Actions.js | 6 +++--- .../ItemEditHeader/ItemEditHeaderActions.tsx | 4 ++-- .../ItemEdit/components/ItemEditHeader/MoreMenu.tsx | 2 +- .../src/app/views/ItemList/UpdateListActions.tsx | 6 +++--- src/shell/hooks/use-permissions.js | 11 ++++++++--- 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Content/Actions/Actions.js b/src/apps/content-editor/src/app/views/ItemEdit/Content/Actions/Actions.js index cd2e3c32e0..d06352b6d5 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Content/Actions/Actions.js +++ b/src/apps/content-editor/src/app/views/ItemEdit/Content/Actions/Actions.js @@ -20,9 +20,9 @@ export function Actions(props) { return ; } - const canPublish = usePermission("PUBLISH"); - const canDelete = usePermission("DELETE"); - const canUpdate = usePermission("UPDATE"); + const canPublish = usePermission("PUBLISH", props.itemZUID); + const canDelete = usePermission("DELETE", props.itemZUID); + const canUpdate = usePermission("UPDATE", props.itemZUID); const domain = useDomain(); const { publishing } = props.item; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index 2a9d069f13..c42ce7fd7f 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -64,8 +64,8 @@ export const ItemEditHeaderActions = ({ itemZUID: string; }>(); const dispatch = useDispatch(); - const canPublish = usePermission("PUBLISH"); - const canUpdate = usePermission("UPDATE"); + const canPublish = usePermission("PUBLISH", itemZUID); + const canUpdate = usePermission("UPDATE", itemZUID); const [publishMenu, setPublishMenu] = useState(null); const [publishAfterSave, setPublishAfterSave] = useState(false); const [unpublishDialogOpen, setUnpublishDialogOpen] = useState(false); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/MoreMenu.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/MoreMenu.tsx index 30c7f04297..5a5dcb2ced 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/MoreMenu.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/MoreMenu.tsx @@ -41,7 +41,7 @@ export const MoreMenu = () => { const { data: contentModels } = useGetContentModelsQuery(); const type = contentModels?.find((model) => model.ZUID === modelZUID)?.type ?? ""; - const canDelete = usePermission("DELETE"); + const canDelete = usePermission("DELETE", itemZUID); const handleCopyClick = (data: string) => { navigator?.clipboard diff --git a/src/apps/content-editor/src/app/views/ItemList/UpdateListActions.tsx b/src/apps/content-editor/src/app/views/ItemList/UpdateListActions.tsx index 891cb6e7dc..c42cab140f 100644 --- a/src/apps/content-editor/src/app/views/ItemList/UpdateListActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/UpdateListActions.tsx @@ -46,9 +46,9 @@ type UpdateListActionsProps = { export const UpdateListActions = ({ items }: UpdateListActionsProps) => { const { modelZUID } = useRouterParams<{ modelZUID: string }>(); - const canPublish = usePermission("PUBLISH"); - const canDelete = usePermission("DELETE"); - const canUpdate = usePermission("UPDATE"); + const canPublish = usePermission("PUBLISH", modelZUID); + const canDelete = usePermission("DELETE", modelZUID); + const canUpdate = usePermission("UPDATE", modelZUID); const dispatch = useDispatch(); const [anchorEl, setAnchorEl] = useState(null); const [itemsToPublish, setItemsToPublish] = useState([]); diff --git a/src/shell/hooks/use-permissions.js b/src/shell/hooks/use-permissions.js index 23c00df880..f2886bcdfc 100644 --- a/src/shell/hooks/use-permissions.js +++ b/src/shell/hooks/use-permissions.js @@ -33,9 +33,14 @@ export function usePermission(action, zuid = instanceZUID) { return true; } - const granularRole = role?.granularRoles?.find( - (r) => r.resourceZUID === zuid - ); + /* + If the user is not a super user, check granular roles. + First check specific resource, if not found check instance level. + TODO: Check additional granular roles for parent resources depending on resource type (e.g. content model when checking content item) + */ + const granularRole = + role?.granularRoles?.find((r) => r.resourceZUID === zuid) || + role?.granularRoles?.find((r) => r.resourceZUID === instanceZUID); // Check system switch (action) { From b977c64e6217c13518ab771cd386aa32558b41fe Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Tue, 27 Aug 2024 06:14:43 +0800 Subject: [PATCH 23/44] [Content] Enable duo mode when x-frame-options is set to either null or sameorigin (#2926) Resolves #2399 ### Demo [Screencast from 08-20-2024 11:01:14 AM.webm](https://github.com/user-attachments/assets/56398edf-4f01-402b-a8d1-1b3fa37d72e6) --- src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 ace3b56c03..5f28d1f3be 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js +++ b/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js @@ -101,7 +101,9 @@ export default function ItemEdit() { (setting.key === "basic_content_api_key" && setting.value) || (setting.key === "headless_authorization_key" && setting.value) || (setting.key === "authorization_key" && setting.value) || - (setting.key === "x_frame_options" && setting.value) + (setting.key === "x_frame_options" && + !!setting.value && + setting.value !== "sameorigin") ); }) || model?.type === "dataset"; From ebf62aba47cffb65f8dc5c73acaf2c4e79e0a4c8 Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Tue, 27 Aug 2024 13:49:54 +0800 Subject: [PATCH 24/44] [Content] Resolve relational field zuids with the actual field values (#2922) - Makes sure that `one_to_one` and `one_to_many` field values are properly resolved to whatever the actual text is. - This also makes sure that each individual `one_to_one` and `one_to_many` chip no longer has to do an api call to retrieve their data Resolves #2901 ### Demo ![image](https://github.com/user-attachments/assets/bd59ee28-ea4f-4c2f-b0b7-33c9453cf084) --- .../ItemList/TableCells/OneToManyCell.tsx | 38 ++++--------- .../TableCells/SingleRelationshipCell.tsx | 10 ---- .../src/app/views/ItemList/index.tsx | 57 ++++++++++++++++++- 3 files changed, 64 insertions(+), 41 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemList/TableCells/OneToManyCell.tsx b/src/apps/content-editor/src/app/views/ItemList/TableCells/OneToManyCell.tsx index 8c8df58e85..70f1a4ef24 100644 --- a/src/apps/content-editor/src/app/views/ItemList/TableCells/OneToManyCell.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/TableCells/OneToManyCell.tsx @@ -29,22 +29,14 @@ type OneToManyCellProps = { items: any[]; }; export const OneToManyCell = ({ items }: OneToManyCellProps) => { - const dispatch = useDispatch(); const allItems = useSelector((state: AppState) => state.content); const chipContainerRef = useRef(); - const [lastValidIndex, setLastValidIndex] = useState(allItems?.length - 1); + const [lastValidIndex, setLastValidIndex] = useState( + Object.keys(allItems)?.length - 1 + ); const parentWidth = chipContainerRef.current?.parentElement?.clientWidth; const hiddenItems = items?.length - lastValidIndex - 1; - useEffect(() => { - items?.forEach((item) => { - // If value starts with '7-', that means it was unable to find the item in the store so we need to fetch it - if (item?.startsWith("7-")) { - dispatch(searchItems(item)); - } - }); - }, [items, dispatch]); - useEffect(() => { setLastValidIndex( getNumOfItemsToRender(parentWidth, chipContainerRef.current?.children) @@ -54,15 +46,11 @@ export const OneToManyCell = ({ items }: OneToManyCellProps) => { return ( <> - {items?.slice(0, lastValidIndex + 1)?.map((id: string) => { - return ( - - ); - })} + {items + ?.slice(0, lastValidIndex + 1) + ?.map((id: string, index: number) => { + return ; + })} {!!hiddenItems && ( { {/** Element below is only needed to calculate the actual chip widths */} - {items?.map((id: string) => { - return ( - - ); + {items?.map((id: string, index: number) => { + return ; })} diff --git a/src/apps/content-editor/src/app/views/ItemList/TableCells/SingleRelationshipCell.tsx b/src/apps/content-editor/src/app/views/ItemList/TableCells/SingleRelationshipCell.tsx index d952e5a12f..5963905d67 100644 --- a/src/apps/content-editor/src/app/views/ItemList/TableCells/SingleRelationshipCell.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/TableCells/SingleRelationshipCell.tsx @@ -1,20 +1,10 @@ import { GridRenderCellParams } from "@mui/x-data-grid-pro"; import { Chip } from "@mui/material"; -import { useEffect } from "react"; -import { useDispatch } from "react-redux"; -import { searchItems } from "../../../../../../../shell/store/content"; export const SingleRelationshipCell = ({ params, }: { params: GridRenderCellParams; }) => { - const dispatch = useDispatch(); - useEffect(() => { - // If value starts with '7-', that means it was unable to find the item in the store so we need to fetch it - if (params.value?.startsWith("7-")) { - dispatch(searchItems(params.value)); - } - }, [params.value, dispatch]); return ; }; 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 15b77667ab..73f9fcf42a 100644 --- a/src/apps/content-editor/src/app/views/ItemList/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/index.tsx @@ -9,7 +9,14 @@ import { import { theme } from "@zesty-io/material"; import { ItemListEmpty } from "./ItemListEmpty"; import { ItemListActions } from "./ItemListActions"; -import { useEffect, useMemo, useRef, useState, useContext } from "react"; +import { + useEffect, + useMemo, + useRef, + useState, + useContext, + useCallback, +} from "react"; import { SearchRounded, RestartAltRounded } from "@mui/icons-material"; import noSearchResults from "../../../../../../../public/images/noSearchResults.svg"; import { ItemListFilters } from "./ItemListFilters"; @@ -29,9 +36,11 @@ import { useGetUsersQuery } from "../../../../../../shell/services/accounts"; import { ContentItem, ContentItemWithDirtyAndPublishing, + ContentModelFieldDataType, } from "../../../../../../shell/services/types"; import { fetchItems } from "../../../../../../shell/store/content"; import { TableSortContext } from "./TableSortProvider"; +import { fetchFields } from "../../../../../../shell/store/fields"; const formatDateTime = (source: string) => { const dateObj = new Date(source); @@ -94,6 +103,7 @@ export const ItemList = () => { const items = useSelector((state: AppState) => selectFilteredItems(state, modelZUID, activeLangId, !hasMounted) ); + const allFields = useSelector((state: AppState) => state.fields); const { data: users, isFetching: isUsersFetching } = useGetUsersQuery(); const [isModelItemsFetching, setIsModelItemsFetching] = useState(true); @@ -114,7 +124,44 @@ export const ItemList = () => { }, [params]); const userFilter = params.get("user"); + const resolveFieldRelationshipTitle = useCallback( + ( + fieldName: string, + fieldDataType: ContentModelFieldDataType, + relatedContentItemZUID: string + ) => { + if ( + !fields?.length || + !allFields || + !allItems || + !fieldName || + !fieldDataType || + !relatedContentItemZUID + ) { + return; + } + + // Finds the related field zuid that's stored in the specific field's data + const fieldData = fields?.find( + (field) => + field.name === fieldName && + !field.deletedAt && + field.datatype === fieldDataType + ); + + // Gets the data of the related field determined above + const relatedFieldData = allFields?.[fieldData?.relatedFieldZUID]; + + return ( + allItems?.[relatedContentItemZUID]?.data?.[relatedFieldData?.name] ?? + relatedContentItemZUID + ); + }, + [allItems, fields, allFields, modelZUID] + ); + useEffect(() => { + dispatch(fetchFields(modelZUID)); setTimeout(() => { setHasMounted(true); }, 0); @@ -215,12 +262,16 @@ export const ItemList = () => { break; case "internal_link": case "one_to_one": - clonedItem.data[key] = allItems?.[value]?.web?.metaTitle || value; + clonedItem.data[key] = resolveFieldRelationshipTitle( + key, + fieldType, + value + ); break; case "one_to_many": clonedItem.data[key] = value ?.split(",") - ?.map((id) => allItems?.[id]?.web?.metaTitle || id) + ?.map((id) => resolveFieldRelationshipTitle(key, fieldType, id)) ?.join(","); break; case "date": From fa740c1c89016977186afe9c422703ee7b164cb1 Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Tue, 27 Aug 2024 15:07:40 +0800 Subject: [PATCH 25/44] [Content] SEO tab revamp Phase 1 (#2927) Phase 1 of the SEO tab revamp ![Screenshot from 2024-08-21 10-45-35](https://github.com/user-attachments/assets/a54c50b0-f710-4ef6-af4b-4f7f7254bbc6) ![Screenshot from 2024-08-21 10-46-15](https://github.com/user-attachments/assets/13b2bc64-017c-40f1-b6cd-365e772101a4) --- .../src/app/components/Editor/Editor.js | 4 +- .../src/app/components/Editor/Editor.less | 1 - .../src/app/components/FieldTypeMedia.tsx | 1123 +++++++++-------- .../src/app/views/ItemCreate/ItemCreate.tsx | 49 +- .../src/app/views/ItemEdit/ItemEdit.js | 20 +- .../Meta/ContentInsights/MatchedWords.tsx | 85 ++ .../ContentInsights/MostMentionedWords.tsx | 108 ++ .../Meta/ContentInsights/WordCount.tsx | 63 + .../ItemEdit/Meta/ContentInsights/index.tsx | 268 ++++ .../ContentInsights/ContentInsights.js | 417 ------ .../ContentInsights/ContentInsights.less | 83 -- .../ItemSettings/ContentInsights/index.js | 1 - .../Meta/ItemSettings/DataSettings.js | 48 - .../Meta/ItemSettings/ItemSettings.js | 227 ---- .../Meta/ItemSettings/ItemSettings.less | 79 -- .../views/ItemEdit/Meta/ItemSettings/index.js | 1 - .../Meta/ItemSettings/settings/ItemParent.js | 263 ---- .../ItemSettings/settings/ItemParent.less | 9 - .../Meta/ItemSettings/settings/ItemRoute.js | 186 --- .../Meta/ItemSettings/settings/ItemRoute.less | 39 - .../ItemSettings/settings/MetaDescription.js | 86 -- .../settings/MetaDescription.less | 13 - .../ItemSettings/settings/MetaKeywords.less | 7 - .../ItemSettings/settings/MetaLinkText.js | 37 - .../ItemSettings/settings/MetaLinkText.less | 7 - .../Meta/ItemSettings/settings/MetaTitle.js | 35 - .../Meta/ItemSettings/settings/MetaTitle.less | 7 - .../src/app/views/ItemEdit/Meta/Meta.js | 26 - .../src/app/views/ItemEdit/Meta/Meta.less | 10 - .../SocialMediaPreview/FacebookPreview.tsx | 80 ++ .../Meta/SocialMediaPreview/GooglePreview.tsx | 173 +++ .../SocialMediaPreview/LinkedInPreview.tsx | 80 ++ .../SocialMediaPreview/TwitterPreview.tsx | 114 ++ .../Meta/SocialMediaPreview/index.tsx | 69 + .../Meta/SocialMediaPreview/useImageURL.ts | 83 ++ .../src/app/views/ItemEdit/Meta/index.js | 1 - .../src/app/views/ItemEdit/Meta/index.tsx | 242 ++++ .../settings/CanonicalTag.js | 4 +- .../settings/CanonicalTag.less | 0 .../ItemEdit/Meta/settings/ItemParent.tsx | 258 ++++ .../ItemEdit/Meta/settings/ItemRoute.tsx | 199 +++ .../Meta/settings/MetaDescription.tsx | 94 ++ .../ItemEdit/Meta/settings/MetaImage.tsx | 281 +++++ .../MetaKeywords.tsx} | 30 +- .../ItemEdit/Meta/settings/MetaLinkText.tsx | 40 + .../ItemEdit/Meta/settings/MetaTitle.tsx | 44 + .../settings/SitemapPriority.js | 5 +- .../settings/SitemapPriority.less | 0 .../app/views/ItemEdit/Meta/settings/util.ts | 7 + src/shell/store/content.js | 4 +- src/shell/store/notifications.ts | 2 +- src/shell/store/types.ts | 8 +- 52 files changed, 2933 insertions(+), 2187 deletions(-) create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MatchedWords.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MostMentionedWords.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/index.tsx delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.js delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.less delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/DataSettings.js delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.js delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.less delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/index.js delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.js delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.less delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.js delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.less delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.js delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.less delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.less delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.js delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.less delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.js delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.less delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.js delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.less create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/FacebookPreview.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/GooglePreview.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/LinkedInPreview.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/TwitterPreview.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/index.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/useImageURL.ts delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/index.js create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx rename src/apps/content-editor/src/app/views/ItemEdit/Meta/{ItemSettings => }/settings/CanonicalTag.js (96%) rename src/apps/content-editor/src/app/views/ItemEdit/Meta/{ItemSettings => }/settings/CanonicalTag.less (100%) create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemParent.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaImage.tsx rename src/apps/content-editor/src/app/views/ItemEdit/Meta/{ItemSettings/settings/MetaKeywords.js => settings/MetaKeywords.tsx} (59%) create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaLinkText.tsx create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx rename src/apps/content-editor/src/app/views/ItemEdit/Meta/{ItemSettings => }/settings/SitemapPriority.js (91%) rename src/apps/content-editor/src/app/views/ItemEdit/Meta/{ItemSettings => }/settings/SitemapPriority.less (100%) create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/util.ts 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 48cd1e4cb7..d1ee79724b 100644 --- a/src/apps/content-editor/src/app/components/Editor/Editor.js +++ b/src/apps/content-editor/src/app/components/Editor/Editor.js @@ -50,7 +50,9 @@ export default memo(function Editor({ const activeFields = useMemo(() => { if (fields?.length) { - return fields.filter((field) => !field.deletedAt); + return fields.filter( + (field) => !field.deletedAt && field.name !== "og_image" + ); } return []; diff --git a/src/apps/content-editor/src/app/components/Editor/Editor.less b/src/apps/content-editor/src/app/components/Editor/Editor.less index 60565bf620..e60308712b 100644 --- a/src/apps/content-editor/src/app/components/Editor/Editor.less +++ b/src/apps/content-editor/src/app/components/Editor/Editor.less @@ -3,7 +3,6 @@ .Fields { display: flex; flex-direction: column; - height: 100%; width: 100%; scrollbar-width: none; /* FireFox */ diff --git a/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx b/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx index a3bbf9166d..024b364f1e 100644 --- a/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx +++ b/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + useImperativeHandle, + forwardRef, +} from "react"; import { useDispatch, useSelector } from "react-redux"; import { Box, @@ -58,171 +65,212 @@ type FieldTypeMediaProps = { settings?: any; }; -export const FieldTypeMedia = ({ - images, - limit, - openMediaBrowser, - onChange, - name, - hasError, - hideDrag, - lockedToGroupId, - settings, -}: FieldTypeMediaProps) => { - const [draggedIndex, setDraggedIndex] = useState(null); - const [hoveredIndex, setHoveredIndex] = useState(null); - const [localImageZUIDs, setLocalImageZUIDs] = useState(images); - const instanceId = useSelector((state: any) => state.instance.ID); - const ecoId = useSelector((state: any) => state.instance.ecoID); - const { data: bins } = useGetBinsQuery({ instanceId, ecoId }); - const defaultBin = bins?.find((bin) => bin.default); - const dispatch = useDispatch(); - const [showFileModal, setShowFileModal] = useState(""); - const [imageToReplace, setImageToReplace] = useState(""); - const [isBynderOpen, setIsBynderOpen] = useState(false); - const { data: rawInstanceSettings } = useGetInstanceSettingsQuery(); - const [selectionError, setSelectionError] = useState(""); - - const bynderPortalUrlSetting = rawInstanceSettings?.find( - (setting) => setting.key === "bynder_portal_url" - ); - const bynderTokenSetting = rawInstanceSettings?.find( - (setting) => setting.key === "bynder_token" - ); - // Checks if the bynder portal and token are set - const isBynderSessionValid = - localStorage.getItem("cvrt") && localStorage.getItem("cvad"); +export const FieldTypeMedia = forwardRef( + ( + { + images, + limit, + openMediaBrowser, + onChange, + name, + hasError, + hideDrag, + lockedToGroupId, + settings, + }: FieldTypeMediaProps, + ref + ) => { + const [draggedIndex, setDraggedIndex] = useState(null); + const [hoveredIndex, setHoveredIndex] = useState(null); + const [localImageZUIDs, setLocalImageZUIDs] = useState(images); + const instanceId = useSelector((state: any) => state.instance.ID); + const ecoId = useSelector((state: any) => state.instance.ecoID); + const { data: bins } = useGetBinsQuery({ instanceId, ecoId }); + const defaultBin = bins?.find((bin) => bin.default); + const dispatch = useDispatch(); + const [showFileModal, setShowFileModal] = useState(""); + const [imageToReplace, setImageToReplace] = useState(""); + const [isBynderOpen, setIsBynderOpen] = useState(false); + const { data: rawInstanceSettings } = useGetInstanceSettingsQuery(); + const [selectionError, setSelectionError] = useState(""); + + const bynderPortalUrlSetting = rawInstanceSettings?.find( + (setting) => setting.key === "bynder_portal_url" + ); + const bynderTokenSetting = rawInstanceSettings?.find( + (setting) => setting.key === "bynder_token" + ); + // Checks if the bynder portal and token are set + const isBynderSessionValid = + localStorage.getItem("cvrt") && localStorage.getItem("cvad"); - useEffect(() => { - setLocalImageZUIDs(images); - }, [images]); + useEffect(() => { + setLocalImageZUIDs(images); + }, [images]); - useEffect(() => { - if (bynderPortalUrlSetting?.value) { - localStorage.setItem("cvad", bynderPortalUrlSetting.value); - } else { - localStorage.removeItem("cvad"); - } - }, [bynderPortalUrlSetting]); - - useEffect(() => { - if (bynderTokenSetting?.value) { - localStorage.setItem("cvrt", bynderTokenSetting.value); - } else { - localStorage.removeItem("cvrt"); - } - }, [bynderTokenSetting]); + useEffect(() => { + if (bynderPortalUrlSetting?.value) { + localStorage.setItem("cvad", bynderPortalUrlSetting.value); + } else { + localStorage.removeItem("cvad"); + } + }, [bynderPortalUrlSetting]); - const addZestyImage = (selectedImages: any[]) => { - const removedImages: any[] = []; - const filteredSelectedImages = selectedImages?.filter((selectedImage) => { - //remove any images that do not match the file extension - if (settings?.fileExtensions) { - if ( - settings?.fileExtensions?.includes( - `.${fileExtension(selectedImage.filename)}` - ) - ) { - return true; + useEffect(() => { + if (bynderTokenSetting?.value) { + localStorage.setItem("cvrt", bynderTokenSetting.value); + } else { + localStorage.removeItem("cvrt"); + } + }, [bynderTokenSetting]); + + useImperativeHandle(ref, () => ({ + triggerOpenMediaBrowser() { + openMediaBrowser({ + limit, + callback: addZestyImage, + }); + }, + })); + + const addZestyImage = (selectedImages: any[]) => { + const removedImages: any[] = []; + const filteredSelectedImages = selectedImages?.filter((selectedImage) => { + //remove any images that do not match the file extension + if (settings?.fileExtensions) { + if ( + settings?.fileExtensions?.includes( + `.${fileExtension(selectedImage.filename)}` + ) + ) { + return true; + } else { + removedImages.push(selectedImage); + return false; + } } else { - removedImages.push(selectedImage); - return false; + return true; } + }); + + if (removedImages.length) { + const filenames = removedImages.map((image) => image.filename); + const formattedFilenames = + filenames.length > 1 + ? filenames.slice(0, -1).join(", ") + " and " + filenames.slice(-1) + : filenames[0]; + + setSelectionError( + `Could not add ${formattedFilenames}. ${settings?.fileExtensionsErrorMessage}` + ); } else { - return true; + setSelectionError(""); } - }); - if (removedImages.length) { - const filenames = removedImages.map((image) => image.filename); - const formattedFilenames = - filenames.length > 1 - ? filenames.slice(0, -1).join(", ") + " and " + filenames.slice(-1) - : filenames[0]; + const newImageZUIDs = filteredSelectedImages?.map((image) => image.id); - setSelectionError( - `Could not add ${formattedFilenames}. ${settings?.fileExtensionsErrorMessage}` + // remove any duplicates + const filteredImageZUIDs = newImageZUIDs.filter( + (zuid) => !images.includes(zuid) ); - } else { - setSelectionError(""); - } - - const newImageZUIDs = filteredSelectedImages?.map((image) => image.id); - - // remove any duplicates - const filteredImageZUIDs = newImageZUIDs.filter( - (zuid) => !images.includes(zuid) - ); - // Do not trigger onChange if no images are added - if (![...images, ...filteredImageZUIDs]?.length) return; + // Do not trigger onChange if no images are added + if (![...images, ...filteredImageZUIDs]?.length) return; - onChange([...images, ...filteredImageZUIDs].join(","), name); - }; + onChange([...images, ...filteredImageZUIDs].join(","), name); + }; - const addBynderAsset = (selectedAsset: any[]) => { - if (images.length > limit) return; + const addBynderAsset = (selectedAsset: any[]) => { + if (images.length > limit) return; - const removedAssets: any[] = []; - const filteredBynderAssets = selectedAsset?.filter((asset) => { - if (settings?.fileExtensions) { - const assetExtension = `.${asset.extensions[0]}`; - if (settings?.fileExtensions?.includes(assetExtension)) { - return true; + const removedAssets: any[] = []; + const filteredBynderAssets = selectedAsset?.filter((asset) => { + if (settings?.fileExtensions) { + const assetExtension = `.${asset.extensions[0]}`; + if (settings?.fileExtensions?.includes(assetExtension)) { + return true; + } else { + removedAssets.push(asset); + return false; + } } else { - removedAssets.push(asset); - return false; + return true; } + }); + + if (removedAssets.length) { + const filenames = removedAssets.map((asset) => asset.name); + const formattedFilenames = + filenames.length > 1 + ? filenames.slice(0, -1).join(", ") + " and " + filenames.slice(-1) + : filenames[0]; + + setSelectionError( + `Could not add ${formattedFilenames}. ${settings?.fileExtensionsErrorMessage}` + ); } else { - return true; + setSelectionError(""); } - }); - - if (removedAssets.length) { - const filenames = removedAssets.map((asset) => asset.name); - const formattedFilenames = - filenames.length > 1 - ? filenames.slice(0, -1).join(", ") + " and " + filenames.slice(-1) - : filenames[0]; - setSelectionError( - `Could not add ${formattedFilenames}. ${settings?.fileExtensionsErrorMessage}` + const newBynderAssets = filteredBynderAssets + .slice(0, limit - images.length) + .map((asset) => asset.originalUrl); + const filteredBynderAssetsUrls = newBynderAssets.filter( + (asset) => !images.includes(asset) ); - } else { - setSelectionError(""); - } - const newBynderAssets = filteredBynderAssets - .slice(0, limit - images.length) - .map((asset) => asset.originalUrl); - const filteredBynderAssetsUrls = newBynderAssets.filter( - (asset) => !images.includes(asset) - ); + onChange([...images, ...filteredBynderAssetsUrls].join(","), name); + }; - onChange([...images, ...filteredBynderAssetsUrls].join(","), name); - }; + const removeImage = (imageId: string) => { + const newImageZUIDs = images.filter((image) => image !== imageId); - const removeImage = (imageId: string) => { - const newImageZUIDs = images.filter((image) => image !== imageId); + onChange(newImageZUIDs.join(","), name); + }; - onChange(newImageZUIDs.join(","), name); - }; + const replaceImage = (images: any[]) => { + const imageZUID = images.map((image) => image.id)?.[0]; + let imageToReplace: string; + setImageToReplace((value: string) => { + imageToReplace = value; + return ""; + }); + // if selected replacement image is already in the list of images, do nothing + if (localImageZUIDs.includes(imageZUID)) return; + // if extension is not allowed set error message + if (settings?.fileExtensions) { + if ( + !settings?.fileExtensions?.includes( + `.${fileExtension(images[0].filename)}` + ) + ) { + setSelectionError( + `Could not replace. ${settings?.fileExtensionsErrorMessage}` + ); + return; + } else { + setSelectionError(""); + } + } + const newImageZUIDs = localImageZUIDs.map((zuid) => { + if (zuid === imageToReplace) { + return imageZUID; + } - const replaceImage = (images: any[]) => { - const imageZUID = images.map((image) => image.id)?.[0]; - let imageToReplace: string; - setImageToReplace((value: string) => { - imageToReplace = value; - return ""; - }); - // if selected replacement image is already in the list of images, do nothing - if (localImageZUIDs.includes(imageZUID)) return; - // if extension is not allowed set error message - if (settings?.fileExtensions) { + return zuid; + }); + + onChange(newImageZUIDs.join(","), name); + }; + + const replaceBynderAsset = (selectedAsset: any) => { + // Prevent adding bynder asset that has already been added + if (localImageZUIDs.includes(selectedAsset.originalUrl)) return; + + const assetExtension = `.${selectedAsset.extensions[0]}`; if ( - !settings?.fileExtensions?.includes( - `.${fileExtension(images[0].filename)}` - ) + settings?.fileExtensions && + !settings?.fileExtensions?.includes(assetExtension) ) { setSelectionError( `Could not replace. ${settings?.fileExtensionsErrorMessage}` @@ -231,210 +279,303 @@ export const FieldTypeMedia = ({ } else { setSelectionError(""); } - } - const newImageZUIDs = localImageZUIDs.map((zuid) => { - if (zuid === imageToReplace) { - return imageZUID; - } - return zuid; - }); + const newImages = localImageZUIDs.map((image) => { + if (image === imageToReplace) { + return selectedAsset.originalUrl; + } - onChange(newImageZUIDs.join(","), name); - }; + return image; + }); - const replaceBynderAsset = (selectedAsset: any) => { - // Prevent adding bynder asset that has already been added - if (localImageZUIDs.includes(selectedAsset.originalUrl)) return; - - const assetExtension = `.${selectedAsset.extensions[0]}`; - if ( - settings?.fileExtensions && - !settings?.fileExtensions?.includes(assetExtension) - ) { - setSelectionError( - `Could not replace. ${settings?.fileExtensionsErrorMessage}` - ); - return; - } else { - setSelectionError(""); - } + setImageToReplace(""); + onChange(newImages.join(","), name); + }; + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + if (!defaultBin) return; + + openMediaBrowser({ + limit, + callback: addZestyImage, + }); + + dispatch( + fileUploadStage( + acceptedFiles.map((file) => { + return { + file, + bin_id: defaultBin.id, + group_id: lockedToGroupId ? lockedToGroupId : defaultBin.id, + }; + }) + ) + ); + }, + [defaultBin, dispatch, addZestyImage] + ); - const newImages = localImageZUIDs.map((image) => { - if (image === imageToReplace) { - return selectedAsset.originalUrl; + const handleReorder = () => { + const newLocalImages = [...localImageZUIDs]; + const draggedField = newLocalImages[draggedIndex]; + newLocalImages.splice(draggedIndex, 1); + newLocalImages.splice(hoveredIndex, 0, draggedField); + + setDraggedIndex(null); + setHoveredIndex(null); + setLocalImageZUIDs(newLocalImages); + onChange(newLocalImages.join(","), name); + }; + + const sortedImages = useMemo(() => { + if (draggedIndex === null || hoveredIndex === null) { + return localImageZUIDs; + } else { + const newImages = [...localImageZUIDs]; + const draggedImage = newImages[draggedIndex]; + newImages.splice(draggedIndex, 1); + newImages.splice(hoveredIndex, 0, draggedImage); + return newImages; } + }, [draggedIndex, hoveredIndex, localImageZUIDs]); - return image; + const { getRootProps, getInputProps, open, isDragActive } = useDropzone({ + onDrop, }); - setImageToReplace(""); - onChange(newImages.join(","), name); - }; - - const onDrop = useCallback( - (acceptedFiles: File[]) => { - if (!defaultBin) return; - - openMediaBrowser({ - limit, - callback: addZestyImage, - }); - - dispatch( - fileUploadStage( - acceptedFiles.map((file) => { - return { - file, - bin_id: defaultBin.id, - group_id: lockedToGroupId ? lockedToGroupId : defaultBin.id, - }; - }) - ) - ); - }, - [defaultBin, dispatch, addZestyImage] - ); - - const handleReorder = () => { - const newLocalImages = [...localImageZUIDs]; - const draggedField = newLocalImages[draggedIndex]; - newLocalImages.splice(draggedIndex, 1); - newLocalImages.splice(hoveredIndex, 0, draggedField); - - setDraggedIndex(null); - setHoveredIndex(null); - setLocalImageZUIDs(newLocalImages); - onChange(newLocalImages.join(","), name); - }; - - const sortedImages = useMemo(() => { - if (draggedIndex === null || hoveredIndex === null) { - return localImageZUIDs; - } else { - const newImages = [...localImageZUIDs]; - const draggedImage = newImages[draggedIndex]; - newImages.splice(draggedIndex, 1); - newImages.splice(hoveredIndex, 0, draggedImage); - return newImages; - } - }, [draggedIndex, hoveredIndex, localImageZUIDs]); - - const { getRootProps, getInputProps, open, isDragActive } = useDropzone({ - onDrop, - }); - - if (!images?.length) - return ( - <> -
evt.stopPropagation(), - onKeyDown: (evt) => evt.stopPropagation(), - })} - > - - `1px dashed ${theme.palette.primary.main}`, - borderRadius: "8px", - backgroundColor: (theme) => - alpha(theme.palette.primary.main, 0.04), - borderColor: hasError ? "error.main" : "primary.main", - }} + if (!images?.length) + return ( + <> +
evt.stopPropagation(), + onKeyDown: (evt) => evt.stopPropagation(), + })} > - - {isDragActive ? ( - - ) : ( - - )} - + + `1px dashed ${theme.palette.primary.main}`, + borderRadius: "8px", + backgroundColor: (theme) => + alpha(theme.palette.primary.main, 0.04), + borderColor: hasError ? "error.main" : "primary.main", + }} + > + {isDragActive ? ( - "Drop your files here to Upload" + ) : ( - <> - Drag and drop your files here
or - + )} -
- {!isDragActive && ( - - - - {isBynderSessionValid && ( - )} - + + {isBynderSessionValid && ( + + )} + + )} +
+ + {selectionError && ( + + {selectionError} + + )} +
+ setIsBynderOpen(false)}> + + { + if (assets?.length) { + addBynderAsset(assets); + setIsBynderOpen(false); + } + }} + /> + + + + ); + + return ( + <> + + hasError ? `1px solid ${theme.palette.error.main}` : "none", + }} + > + {sortedImages.map((image, index) => { + const isBynderAsset = image.includes("bynder.com"); + + return ( + setShowFileModal(imageZUID)} + onRemove={removeImage} + onReplace={(imageZUID) => { + setImageToReplace(imageZUID); + + if (isBynderAsset) { + setIsBynderOpen(true); + } else { + openMediaBrowser({ + callback: replaceImage, + isReplace: true, + }); + } + }} + hideDrag={hideDrag || limit === 1} + isBynderAsset={isBynderAsset} + isBynderSessionValid={!!isBynderSessionValid} + /> + ); + })} + {limit > images.length && ( + + {!isBynderSessionValid && ( + )} - -
- {selectionError && ( - - {selectionError} - + + {isBynderSessionValid && ( + + )} + )} -
+ + {selectionError && ( + + {selectionError} + + )} + {showFileModal && ( + setShowFileModal("")} + currentFiles={ + sortedImages?.filter( + (image) => typeof image === "string" + ) as string[] + } + onFileChange={(fileId) => { + setShowFileModal(fileId); + }} + /> + )} setIsBynderOpen(false)}> { if (assets?.length) { - addBynderAsset(assets); + if (imageToReplace) { + replaceBynderAsset(assets[0]); + } else { + addBynderAsset(assets); + } + setIsBynderOpen(false); } }} @@ -443,128 +584,8 @@ export const FieldTypeMedia = ({ ); - - return ( - <> - - hasError ? `1px solid ${theme.palette.error.main}` : "none", - }} - > - {sortedImages.map((image, index) => { - const isBynderAsset = image.includes("bynder.com"); - - return ( - setShowFileModal(imageZUID)} - onRemove={removeImage} - onReplace={(imageZUID) => { - setImageToReplace(imageZUID); - - if (isBynderAsset) { - setIsBynderOpen(true); - } else { - openMediaBrowser({ - callback: replaceImage, - isReplace: true, - }); - } - }} - hideDrag={hideDrag || limit === 1} - isBynderAsset={isBynderAsset} - isBynderSessionValid={!!isBynderSessionValid} - /> - ); - })} - {limit > images.length && ( - - {!isBynderSessionValid && ( - - )} - - {isBynderSessionValid && ( - - )} - - )} - - {selectionError && ( - - {selectionError} - - )} - {showFileModal && ( - setShowFileModal("")} - currentFiles={ - sortedImages?.filter( - (image) => typeof image === "string" - ) as string[] - } - onFileChange={(fileId) => { - setShowFileModal(fileId); - }} - /> - )} - setIsBynderOpen(false)}> - - { - if (assets?.length) { - if (imageToReplace) { - replaceBynderAsset(assets[0]); - } else { - addBynderAsset(assets); - } - - setIsBynderOpen(false); - } - }} - /> - - - - ); -}; + } +); type MediaItemProps = { imageZUID: string; @@ -572,14 +593,15 @@ type MediaItemProps = { setDraggedIndex?: (index: number) => void; setHoveredIndex?: (index: number) => void; index: number; - onPreview: (imageZUID: string) => void; - onRemove: (imageZUID: string) => void; - onReplace: (imageZUID: string) => void; + onPreview?: (imageZUID: string) => void; + onRemove?: (imageZUID: string) => void; + onReplace?: (imageZUID: string) => void; hideDrag?: boolean; isBynderAsset: boolean; isBynderSessionValid: boolean; + hideActionButtons?: boolean; }; -const MediaItem = ({ +export const MediaItem = ({ imageZUID, onReorder, setDraggedIndex, @@ -591,6 +613,7 @@ const MediaItem = ({ hideDrag, isBynderAsset, isBynderSessionValid, + hideActionButtons, }: MediaItemProps) => { const [isDragging, setIsDragging] = useState(false); const [isDraggable, setIsDraggable] = useState(false); @@ -698,7 +721,7 @@ const MediaItem = ({ onClick={() => { if (isURL) return; - onPreview(imageZUID); + onPreview && onPreview(imageZUID); }} alignItems="center" sx={{ @@ -768,7 +791,9 @@ const MediaItem = ({ )} - - {!isBynderAsset || (isBynderAsset && isBynderSessionValid) ? ( - + {!hideActionButtons && ( + + {!isBynderAsset || (isBynderAsset && isBynderSessionValid) ? ( + + { + event.stopPropagation(); + onReplace && onReplace(imageZUID); + }} + > + + + + ) : ( + <> + )} + {!isURL && ( + + + + + + )} + { event.stopPropagation(); - onReplace(imageZUID); + setAnchorEl(event.currentTarget); }} > - + - ) : ( - <> - )} - {!isURL && ( - - - - - - )} - - { + { event.stopPropagation(); - setAnchorEl(event.currentTarget); + setAnchorEl(null); }} - > - - - - { - event.stopPropagation(); - setAnchorEl(null); - }} - PaperProps={{ - style: { - width: "288px", - }, - }} - anchorOrigin={{ - vertical: "bottom", - horizontal: "right", - }} - transformOrigin={{ - vertical: "top", - horizontal: "right", - }} - > - {!isURL && !isBynderAsset && ( - <> - { - event.stopPropagation(); - setAnchorEl(null); - setShowRenameFileModal(true); - }} - > - - - - Rename - - { - event.stopPropagation(); - setAnchorEl(null); - setIsReplaceFileModalOpen(true); - }} - > - - - - Replace File - - { - event.stopPropagation(); - handleCopyClick(imageZUID, true); - }} - > - - {isCopiedZuid ? : } - - Copy ZUID - - - )} - { - event.stopPropagation(); - handleCopyClick(isURL ? imageZUID : data?.url, false); + PaperProps={{ + style: { + width: "288px", + }, }} - > - - {isCopied ? : } - - Copy File Url - - { - event.stopPropagation(); - setAnchorEl(null); - onRemove(imageZUID); + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "right", }} > - - - - Remove - - - + {!isURL && !isBynderAsset && ( + <> + { + event.stopPropagation(); + setAnchorEl(null); + setShowRenameFileModal(true); + }} + > + + + + Rename + + { + event.stopPropagation(); + setAnchorEl(null); + setIsReplaceFileModalOpen(true); + }} + > + + + + Replace File + + { + event.stopPropagation(); + handleCopyClick(imageZUID, true); + }} + > + + {isCopiedZuid ? : } + + Copy ZUID + + + )} + { + event.stopPropagation(); + handleCopyClick(isURL ? imageZUID : data?.url, false); + }} + > + + {isCopied ? : } + + Copy File Url + + { + event.stopPropagation(); + setAnchorEl(null); + onRemove && onRemove(imageZUID); + }} + > + + + + Remove + + + + )}
{showRenameFileModal && ( 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 6259b29f40..7af2c7adbb 100644 --- a/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx +++ b/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx @@ -6,14 +6,13 @@ import isEmpty from "lodash/isEmpty"; import { createSelector } from "@reduxjs/toolkit"; import { cloneDeep } from "lodash"; -import { Divider, Box, Stack } from "@mui/material"; +import { Divider, Box, Stack, ThemeProvider } from "@mui/material"; +import { theme } from "@zesty-io/material"; import { WithLoader } from "@zesty-io/core/WithLoader"; import { NotFound } from "../../../../../../shell/components/NotFound"; import { Header } from "./Header"; import { Editor } from "../../components/Editor"; -import { ItemSettings } from "../ItemEdit/Meta/ItemSettings"; -import { DataSettings } from "../ItemEdit/Meta/ItemSettings/DataSettings"; import { fetchFields } from "../../../../../../shell/store/fields"; import { createItem, @@ -35,6 +34,8 @@ import { ContentModelField, } from "../../../../../../shell/services/types"; import { SchedulePublish } from "../../../../../../shell/components/SchedulePublish"; +import { Meta } from "../ItemEdit/Meta"; +import { SocialMediaPreview } from "../ItemEdit/Meta/SocialMediaPreview"; export type ActionAfterSave = | "" @@ -79,6 +80,7 @@ export const ItemCreate = () => { const [willRedirect, setWillRedirect] = useState(true); const [fieldErrors, setFieldErrors] = useState({}); const [saveClicked, setSaveClicked] = useState(false); + const [hasSEOErrors, setHasSEOErrors] = useState(false); const [ createPublishing, @@ -102,7 +104,14 @@ export const ItemCreate = () => { // if item doesn't exist, generate a new one useEffect(() => { if (isEmpty(item) && !saving) { - dispatch(generateItem(modelZUID)); + const initialData = fields?.reduce((accu, curr) => { + if (!curr.deletedAt) { + accu[curr.name] = null; + } + return accu; + }, {}); + + dispatch(generateItem(modelZUID, initialData)); } }, [modelZUID, item, saving]); @@ -148,7 +157,7 @@ export const ItemCreate = () => { const save = async (action: ActionAfterSave) => { setSaveClicked(true); - if (hasErrors) return; + if (hasErrors || hasSEOErrors) return; setSaving(true); @@ -339,8 +348,10 @@ export const ItemCreate = () => { className={styles.ItemCreate} bgcolor="grey.50" alignItems="center" + direction="row" + gap={4} > - + { setFieldErrors(errors); }} /> - - -

Meta Settings

- {model && model?.type === "dataset" ? ( - - ) : ( - - )} + { + setHasSEOErrors(hasErrors); + }} + isSaving={saving} + />
+ + + + +
{isScheduleDialogOpen && !isLoadingNewItem && ( 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 5f28d1f3be..a16a79b9fb 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js +++ b/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js @@ -27,7 +27,6 @@ import { WithLoader } from "@zesty-io/core/WithLoader"; import { PendingEditsModal } from "../../components/PendingEditsModal"; import { LockedItem } from "../../components/LockedItem"; import { Content } from "./Content"; -import { Meta } from "./Meta"; import { ItemHead } from "./ItemHead"; import { NotFound } from "../NotFound"; @@ -47,6 +46,7 @@ import { import { DuoModeContext } from "../../../../../../shell/contexts/duoModeContext"; import { useLocalStorage } from "react-use"; import { FreestyleWrapper } from "./FreestyleWrapper"; +import { Meta } from "./Meta"; const selectItemHeadTags = createSelector( (state) => state.headTags, @@ -86,6 +86,7 @@ export default function ItemEdit() { const [notFound, setNotFound] = useState(""); const [saveClicked, setSaveClicked] = useState(false); const [fieldErrors, setFieldErrors] = useState({}); + const [hasSEOErrors, setHasSEOErrors] = useState(false); const { data: fields, isLoading: isLoadingFields } = useGetContentModelFieldsQuery(modelZUID); const [showDuoModeLS, setShowDuoModeLS] = useLocalStorage( @@ -229,7 +230,7 @@ export default function ItemEdit() { async function save() { setSaveClicked(true); - if (hasErrors) return; + if (hasErrors || hasSEOErrors) return; setSaving(true); try { @@ -431,17 +432,10 @@ export default function ItemEdit() { path="/content/:modelZUID/:itemZUID/meta" render={() => ( { + setHasSEOErrors(hasErrors); + }} + isSaving={saving} /> )} /> diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MatchedWords.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MatchedWords.tsx new file mode 100644 index 0000000000..b6c027907a --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MatchedWords.tsx @@ -0,0 +1,85 @@ +import { Box, Stack, Typography, Chip } from "@mui/material"; +import { Check } from "@mui/icons-material"; +import { useMemo } from "react"; +import { useSelector } from "react-redux"; +import { useParams } from "react-router"; + +import { + stripDashesAndSlashes, + stripDoubleSpace, + stripPunctuation, +} from "./index"; +import { AppState } from "../../../../../../../../shell/store/types"; + +type MatchedWordsProps = { + uniqueNonCommonWordsArray: string[]; +}; +export const MatchedWords = ({ + uniqueNonCommonWordsArray, +}: MatchedWordsProps) => { + const { itemZUID } = useParams<{ + itemZUID: string; + }>(); + const item = useSelector((state: AppState) => state.content[itemZUID]); + + const contentAndMetaWordMatches = useMemo(() => { + const textMetaFieldNames = [ + "metaDescription", + "metaTitle", + "metaKeywords", + "pathPart", + ]; + + if ( + item?.web && + Object.values(item.web)?.length && + uniqueNonCommonWordsArray?.length + ) { + const metaWords = Object.entries(item.web)?.reduce( + (accu: string[], [fieldName, value]) => { + if (textMetaFieldNames.includes(fieldName) && !!value) { + const cleanedValue = stripDoubleSpace( + stripPunctuation( + stripDashesAndSlashes(value.trim().toLowerCase()) + ) + ); + + accu = [...accu, ...cleanedValue?.split(" ")]; + + return accu; + } + + return accu; + }, + [] + ); + + const uniqueMetaWords = Array.from(new Set(metaWords)); + + return uniqueMetaWords.filter((metaWord) => + uniqueNonCommonWordsArray.includes(metaWord) + ); + } + + return []; + }, [uniqueNonCommonWordsArray, item?.web]); + + return ( + + + Content and Meta Matched Words + + + {contentAndMetaWordMatches?.map((word) => ( + } + variant="outlined" + /> + ))} + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MostMentionedWords.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MostMentionedWords.tsx new file mode 100644 index 0000000000..7a70626ff2 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MostMentionedWords.tsx @@ -0,0 +1,108 @@ +import { + Box, + Stack, + Typography, + Chip, + TextField, + InputAdornment, +} from "@mui/material"; +import { Search, Add, Remove } from "@mui/icons-material"; +import { useMemo, useState } from "react"; +import { COMMON_WORDS } from "."; + +type MostMentionedWordsProps = { + wordsArray: string[]; +}; +export const MostMentionedWords = ({ wordsArray }: MostMentionedWordsProps) => { + const [filterKeyword, setFilterKeyword] = useState(""); + const [showAll, setShowAll] = useState(false); + + const wordCount = useMemo(() => { + if (!!wordsArray?.length) { + const wordsWithCount = wordsArray?.reduce( + (accu: Record, word) => { + if (!COMMON_WORDS.includes(word)) { + if (word in accu) { + accu[word] += 1; + } else { + accu[word] = 1; + } + } + + return accu; + }, + {} + ); + + return Object.entries(wordsWithCount ?? {})?.sort( + ([, a], [, b]) => b - a + ); + } + + return []; + }, [wordsArray]); + + const filteredWords = useMemo(() => { + if (!!filterKeyword) { + return wordCount?.filter(([word]) => + word.includes(filterKeyword.toLowerCase().trim()) + ); + } + + return wordCount; + }, [filterKeyword, wordCount]); + + return ( + + + + Most Mentioned Words in Content Item + + + Check that your focus keywords are occurring a minimum 2 times. + + + setFilterKeyword(evt.target.value)} + size="small" + placeholder="Filter words" + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + {filteredWords + ?.slice(0, showAll ? undefined : 9) + ?.map(([word, count]) => ( + + {word} + + {count} + + + } + size="small" + variant="outlined" + /> + ))} + {filteredWords?.length > 10 && ( + : } + onClick={() => setShowAll(!showAll)} + /> + )} + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx new file mode 100644 index 0000000000..4478d5b120 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx @@ -0,0 +1,63 @@ +import { Stack, Box, Typography, Divider } from "@mui/material"; + +type WordCountProps = { + totalWords: number; + totalUniqueWords: number; + totalUniqueNonCommonWords: number; +}; +export const WordCount = ({ + totalWords, + totalUniqueWords, + totalUniqueNonCommonWords, +}: WordCountProps) => { + return ( + + + + Words + + + {totalWords} + + + + + + Unique Words + + + {totalUniqueWords} + + + + + + Non Common Words + + + {totalUniqueNonCommonWords} + + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/index.tsx new file mode 100644 index 0000000000..a03f3ede41 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/index.tsx @@ -0,0 +1,268 @@ +import { useMemo, useState } from "react"; +import { useSelector } from "react-redux"; +import { useParams } from "react-router"; + +import { AppState } from "../../../../../../../../shell/store/types"; +import { useGetContentModelFieldsQuery } from "../../../../../../../../shell/services/instance"; +import { WordCount } from "./WordCount"; +import { MatchedWords } from "./MatchedWords"; +import { MostMentionedWords } from "./MostMentionedWords"; + +export const COMMON_WORDS: Readonly = [ + "null", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "0", + "our", + "one", + "two", + "three", + "four", + "five", + "I'm", + "that's", + "it's", + "aren't", + "we've", + "i've", + "didn't", + "don't", + "you'll", + "you're", + "we're", + "Here's", + "about", + "actually", + "always", + "even", + "given", + "into", + "just", + "not", + "Im", + "thats", + "its", + "arent", + "weve", + "ive", + "didnt", + "dont", + "the", + "of", + "to", + "and", + "a", + "in", + "is", + "it", + "you", + "that", + "he", + "was", + "for", + "on", + "are", + "with", + "as", + "I", + "his", + "they", + "be", + "at", + "one", + "have", + "this", + "from", + "or", + "had", + "by", + "but", + "some", + "what", + "there", + "we", + "can", + "out", + "were", + "all", + "your", + "when", + "up", + "use", + "how", + "said", + "an", + "each", + "she", + "which", + "do", + "their", + "if", + "will", + "way", + "many", + "then", + "them", + "would", + "like", + "so", + "these", + "her", + "see", + "him", + "has", + "more", + "could", + "go", + "come", + "did", + "my", + "no", + "get", + "me", + "say", + "too", + "here", + "must", + "such", + "try", + "us", + "own", + "oh", + "any", + "youll", + "youre", + "also", + "than", + "those", + "though", + "thing", + "things", +] as const; + +// clean up functions +const stripTags = (string: string) => { + return string.replace(/(<([^>]+)>)/gi, ""); +}; + +const stripEncoded = (string: string) => { + return string.replace(/(&(.*?);)/gi, " "); +}; + +const stripHidden = (string: string) => { + return string.replace(/(\r|\n|\t)/gi, " "); +}; + +const stripZUIDs = (string: string) => { + return string.replace(/(\d-(.*?)-(.*?))(,| )/gi, " "); +}; + +export const stripPunctuation = (string: string) => { + return string.replace(/("|,|:|;|\. |!)/gi, " "); +}; + +export const stripDoubleSpace = (string: string) => { + return string.replace(/\s\s+/g, " "); +}; + +export const stripDashesAndSlashes = (string: string) => { + return string.replace(/-|\//g, " "); +}; + +const findMatch = (needle: string, haystack: string[]) => { + let truth = false; + haystack.forEach((word) => { + if (word.toLowerCase() == needle.toLowerCase()) truth = true; + }); + return truth; +}; + +export const ContentInsights = ({}) => { + const { itemZUID, modelZUID } = useParams<{ + itemZUID: string; + modelZUID: string; + }>(); + const item = useSelector((state: AppState) => state.content[itemZUID]); + const { data: modelFields } = useGetContentModelFieldsQuery(modelZUID, { + skip: !modelZUID, + }); + const [showAllWords, setShowAllWords] = useState(false); + + const textFieldNames = useMemo(() => { + if (modelFields?.length) { + const textFieldTypes = [ + "text", + "wysiwyg_basic", + "wysiwyg_advanced", + "article_writer", + "markdown", + "textarea", + ]; + + return modelFields.reduce((accu: string[], curr) => { + if (textFieldTypes.includes(curr.datatype) && !curr.deletedAt) { + accu = [...accu, curr.name]; + return accu; + } + + return accu; + }, []); + } + }, [modelFields]); + + const contentItemWordsArray = useMemo(() => { + if ( + item?.data && + Object.values(item.data)?.length && + textFieldNames?.length + ) { + let words: string[] = []; + + textFieldNames.forEach((fieldName) => { + let value = item?.data[fieldName]; + + if (!!value) { + value = stripDoubleSpace( + stripPunctuation( + stripHidden( + stripEncoded( + stripTags(stripZUIDs(String(value).trim().toLowerCase())) + ) + ) + ) + ); + + words = [...words, ...value.split(" ")]; + } + }); + + return words; + } + + return []; + }, [textFieldNames, item?.data]); + + const uniqueWordsArray = Array.from(new Set(contentItemWordsArray)); + const uniqueNonCommonWordsArray = Array.from( + uniqueWordsArray?.filter((word) => !COMMON_WORDS.includes(word)) + ); + + return ( + <> + + + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.js deleted file mode 100644 index 4fcaee4823..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.js +++ /dev/null @@ -1,417 +0,0 @@ -import React, { useState } from "react"; - -import Divider from "@mui/material/Divider"; -import Button from "@mui/material/Button"; - -import Card from "@mui/material/Card"; -import CardHeader from "@mui/material/CardHeader"; -import CardContent from "@mui/material/CardContent"; -import SavedSearchIcon from "@mui/icons-material/SavedSearch"; - -import { faCheck, faSearchDollar } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import cx from "classnames"; - -import styles from "./ContentInsights.less"; -export function ContentInsights(props) { - const [showAllWords, setShowAllWords] = useState(false); - - // clean up functions - const stripTags = (string) => { - return string.replace(/(<([^>]+)>)/gi, ""); - }; - const stripEncoded = (string) => { - return string.replace(/(&(.*?);)/gi, " "); - }; - const stripHidden = (string) => { - return string.replace(/(\r|\n|\t)/gi, " "); - }; - const stripZUIDs = (string) => { - return string.replace(/(\d-(.*?)-(.*?))(,| )/gi, " "); - }; - const stripPunctuation = (string) => { - return string.replace(/("|,|:|;|\. |!)/gi, " "); - }; - const stripDoubleSpace = (string) => { - return string.replace(/\s\s+/g, " "); - }; - const stripDashesAndSlashes = (string) => { - return string.replace(/-|\//g, " "); - }; - const commonwords = [ - "null", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "0", - "our", - "one", - "two", - "three", - "four", - "five", - "I'm", - "that's", - "it's", - "aren't", - "we've", - "i've", - "didn't", - "don't", - "you'll", - "you're", - "we're", - "Here's", - "about", - "actually", - "always", - "even", - "given", - "into", - "just", - "not", - "Im", - "thats", - "its", - "arent", - "weve", - "ive", - "didnt", - "dont", - "the", - "of", - "to", - "and", - "a", - "in", - "is", - "it", - "you", - "that", - "he", - "was", - "for", - "on", - "are", - "with", - "as", - "I", - "his", - "they", - "be", - "at", - "one", - "have", - "this", - "from", - "or", - "had", - "by", - "but", - "some", - "what", - "there", - "we", - "can", - "out", - "were", - "all", - "your", - "when", - "up", - "use", - "how", - "said", - "an", - "each", - "she", - "which", - "do", - "their", - "if", - "will", - "way", - "many", - "then", - "them", - "would", - "like", - "so", - "these", - "her", - "see", - "him", - "has", - "more", - "could", - "go", - "come", - "did", - "my", - "no", - "get", - "me", - "say", - "too", - "here", - "must", - "such", - "try", - "us", - "own", - "oh", - "any", - "youll", - "youre", - "also", - "than", - "those", - "though", - "thing", - "things", - ]; - - const findMatch = (needle, haystack) => { - let truth = false; - haystack.forEach((word) => { - if (word.toLowerCase() == needle.toLowerCase()) truth = true; - }); - return truth; - }; - - let combinedString = ""; - let wordCount = {}; - let metaWordCount = {}; - - // Working with Content - // Content: combine all the text content we find from the item - for (const [key, value] of Object.entries(props.content)) { - combinedString += " " + value; - } - - // Content: clean the string up by remove values that are not considered word content - combinedString = stripDoubleSpace( - stripPunctuation( - stripHidden(stripEncoded(stripTags(stripZUIDs(combinedString)))) - ) - ); - combinedString = combinedString.toLowerCase(); - - // Meta: build combined string - let combinedMetaString = - props.meta.metaTitle + - " " + - props.meta.path + - " " + - props.meta.metaDescription; - // Meta: clean the string - combinedMetaString = stripDoubleSpace( - stripPunctuation(stripDashesAndSlashes(combinedMetaString.toLowerCase())) - ); - - // Content: get total word counts with initial split - let splitWords = combinedString.split(" "); - let totalWords = splitWords.length; - - // Content & Meta: remove common words - commonwords.forEach((commonWord) => { - let re = new RegExp("\\b" + commonWord.toLowerCase() + "\\b", "ig"); - combinedString = combinedString.replace(re, ""); - combinedMetaString = combinedMetaString.replace(re, ""); - }); - // Content: strip left over double spaces - combinedString = stripDoubleSpace(combinedString); - splitWords = combinedString.split(" "); - let totalNonCommonWords = splitWords.length; - - // Content: use split words to tally total count of numbers - splitWords.forEach((word) => { - if (!wordCount.hasOwnProperty(word)) { - wordCount[word] = 1; - } else { - wordCount[word]++; - } - }); - - // Meta: use split meta words to tally total count of numbers - let splitMetaWords = combinedMetaString.split(" "); - splitMetaWords.forEach((word) => { - if (!metaWordCount.hasOwnProperty(word)) { - metaWordCount[word] = 1; - } else { - metaWordCount[word]++; - } - }); - // Meta: Build Word Lists for output - let metaWordArray = []; - for (const [key, value] of Object.entries(metaWordCount)) { - if (key != "") { - metaWordArray.push({ - word: key, - count: value, - match: findMatch(key, splitWords), - }); - } - } - // Content: Sort Word List by more occuring - metaWordArray = metaWordArray.sort((a, b) => { - return b.count - a.count; - }); - - // Content: Build Word Lists for output - let wordArray = []; - for (const [key, value] of Object.entries(wordCount)) { - if (key != "") { - wordArray.push({ - word: key, - count: value, - match: findMatch(key, splitMetaWords), - }); - } - } - let totalUniqueNonCommonWords = wordArray.length; - // Content: Sort Word List by more occuring - wordArray = wordArray.sort((a, b) => { - return b.count - a.count; - }); - - return ( - - } - title="Content Insights" - sx={{ - backgroundColor: "grey.100", - }} - titleTypographyProps={{ - sx: { - color: "text.primary", - }, - }} - > - -
-
- Total Words {totalWords} -
-
- Non-Common* Words {totalNonCommonWords} -
-
- Unique Words* {totalUniqueNonCommonWords} -
-
- -

Content and Meta Matched Words

-
- {metaWordArray.map((item, i) => { - if (item.match) { - return ( -
- - - - {item.word} -
- ); - } - })} -
- -

- Word occurrences from this Content Item (not the fully rendered page) -

-
- {wordArray.map((item, i) => { - if (item.count > 1) { - return ( -
- {item.count} - {item.word} -
- ); - } - })} - {!showAllWords && ( - - )} - {showAllWords && ( - - )} -
- {showAllWords && ( -
- {wordArray.map((item, i) => { - if (item.count == 1) { - return ( -
- {item.count} - {item.word} -
- ); - } - })} -
- )} - -

Word occurrences from the URL, Meta Title and Description

-
- {metaWordArray.map((item, i) => { - return ( -
- {item.count} - {item.word} -
- ); - })} -
-
-
- ); -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.less deleted file mode 100644 index 394af8d17c..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.less +++ /dev/null @@ -1,83 +0,0 @@ -@import "~@zesty-io/core/typography.less"; -@import "~@zesty-io/core/colors.less"; -@roundPx: 6px; -.ContentInsights { - .toggleButton { - padding: 0px 10px !important; - margin-bottom: auto; - } - .level { - display: flex; - div { - flex: 1; - text-align: center; - background-color: lighten(@zesty-light-gray, 15%); - border: 1px lighten(@zesty-light-gray, 15%) solid; - color: @zesty-gray; - border-radius: 4px; - margin-right: 8px; - overflow: hidden; - padding-top: 4px; - &:last-child { - margin-right: 0px; - } - span { - display: block; - font-size: 2em; - background: white; - color: @zesty-dark-blue; - padding: 8px 0px; - margin-top: 4px; - } - } - } - .wordBank { - display: flex; - flex-direction: row; - flex-wrap: wrap; - margin-top: 8px; - .wordGroup { - display: flex; - width: min-content; - font-size: 1em; - margin-right: 8px; - margin-bottom: 8px; - border: 1px lighten(@zesty-light-gray, 15%) solid; - border-radius: @roundPx; - overflow: hidden; - - strong { - width: 20px; - flex: 1; - display: inline-block; - background: lighten(@zesty-light-gray, 15%); - color: @zesty-dark-blue; - - padding: 5px 8px; - font-weight: 600; - } - span { - flex: 5; - flex-direction: column; - flex-basis: 100%; - display: inline-block; - color: @zesty-dark-blue; - background: white; - overflow: hidden; - text-overflow: ellipsis; - - padding: 5px 8px; - } - &.hidden { - display: none; - } - &.matched { - border: 1px lighten(@zesty-green, 20%) solid; - strong { - background: lighten(@zesty-green, 20%); - color: darken(@zesty-green, 20%); - } - } - } - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/index.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/index.js deleted file mode 100644 index 9ac2579447..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/index.js +++ /dev/null @@ -1 +0,0 @@ -export { ContentInsights } from "./ContentInsights"; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/DataSettings.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/DataSettings.js deleted file mode 100644 index f8796526e4..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/DataSettings.js +++ /dev/null @@ -1,48 +0,0 @@ -import { Component } from "react"; -import cx from "classnames"; - -import { MetaTitle } from "./settings/MetaTitle"; -import MetaDescription from "./settings/MetaDescription"; -import { MetaKeywords } from "./settings/MetaKeywords"; -import { MetaLinkText } from "./settings/MetaLinkText"; -import { Stack } from "@mui/material"; - -import styles from "./ItemSettings.less"; -export class DataSettings extends Component { - onChange = (value, name) => { - if (!name) { - throw new Error("Input is missing name attribute"); - } - this.props.dispatch({ - type: "SET_ITEM_WEB", - itemZUID: this.props.item.meta.ZUID, - key: name, - value: value, - }); - }; - - render() { - let web = this.props.item.web || {}; - return ( -
-
- - - - - - -
-
- ); - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.js deleted file mode 100644 index ca4d408705..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.js +++ /dev/null @@ -1,227 +0,0 @@ -import { - memo, - Fragment, - useCallback, - useMemo, - useState, - useEffect, -} from "react"; -import { useSelector } from "react-redux"; -import { useDispatch } from "react-redux"; - -import { MetaTitle } from "./settings/MetaTitle"; -import MetaDescription from "./settings/MetaDescription"; -import { MetaKeywords } from "./settings/MetaKeywords"; -import { MetaLinkText } from "./settings/MetaLinkText"; -import { ItemRoute } from "./settings/ItemRoute"; -import { ContentInsights } from "./ContentInsights"; -import { ItemParent } from "./settings/ItemParent"; -import { CanonicalTag } from "./settings/CanonicalTag"; -import { SitemapPriority } from "./settings/SitemapPriority"; -import { useDomain } from "shell/hooks/use-domain"; - -import Card from "@mui/material/Card"; -import CardHeader from "@mui/material/CardHeader"; -import CardContent from "@mui/material/CardContent"; -import SearchIcon from "@mui/icons-material/Search"; - -import styles from "./ItemSettings.less"; -import { fetchGlobalItem } from "../../../../../../../../shell/store/content"; - -export const MaxLengths = { - metaLinkText: 150, - metaTitle: 150, - metaDescription: 160, - metaKeywords: 255, -}; - -export const ItemSettings = memo( - function ItemSettings(props) { - const showSiteNameInMetaTitle = useSelector( - (state) => - state.settings.instance.find( - (setting) => setting.key === "show_in_title" - )?.value - ); - const dispatch = useDispatch(); - const domain = useDomain(); - let { data, meta, web } = props.item; - const [errors, setErrors] = useState({}); - - data = data || {}; - meta = meta || {}; - web = web || {}; - - const siteName = useMemo(() => dispatch(fetchGlobalItem())?.site_name, []); - - const onChange = useCallback( - (value, name) => { - if (!name) { - throw new Error("Input is missing name attribute"); - } - - if (MaxLengths[name]) { - setErrors({ - ...errors, - [name]: { - EXCEEDING_MAXLENGTH: - value?.length > MaxLengths[name] - ? value?.length - MaxLengths[name] - : 0, - }, - }); - } - - props.dispatch({ - type: "SET_ITEM_WEB", - itemZUID: meta.ZUID, - key: name, - value: value, - }); - }, - [meta.ZUID, errors] - ); - - useEffect(() => { - if (props.saving) { - setErrors({}); - return; - } - }, [props.saving]); - - return ( -
-
- {web.pathPart !== "zesty_home" && ( - - - - - )} - - - - - - {props.item && ( - - )} -
- -
- ); - }, - (prevProps, nextProps) => { - // NOTE We want to update children when the `item` changes but only when `content` length changes - - // If the model we are viewing changes we need to re-render - if (prevProps.modelZUID !== nextProps.modelZUID) { - return false; - } - - // If the item referential equailty of the item changes we want to re-render - // should mean the item has updated data - if (prevProps.item !== nextProps.item) { - return false; - } - - // Avoid referential equality check and compare content length to see if new ones where added - let prevItemsLen = Object.keys(prevProps["content"]).length; - let nextItemsLen = Object.keys(nextProps["content"]).length; - if (prevItemsLen !== nextItemsLen) { - return false; - } - - /** - * We ignore changes to the `instance` object and `dispatch` - * as these values should not change. - */ - - return true; - } -); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.less deleted file mode 100644 index 3f0eb24a4f..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.less +++ /dev/null @@ -1,79 +0,0 @@ -@import "~@zesty-io/core/colors.less"; -@import "~@zesty-io/core/typography.less"; - -.Meta { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 32px; - // flex-direction: row; - // margin: 32px; - - .DataSettings { - flex: 0.6; - } - - .MetaMain { - flex: 3; - // margin-right: 32px; - min-width: 0px; - - &.DataSettings { - margin-right: 0; - } - - article { - margin: 16px 0; - } - } - - .MetaSide { - flex: 2; - margin: 16px 0; - min-width: 0px; - - .SearchResult { - border-radius: 5px; - background: white; - border: 1px @zesty-light-gray solid; - padding: 16px; - - .GoogleTitle { - font-family: Roboto, "Gibson", arial, sans-serif; - color: #1a0dab; // sampled from google - word-wrap: break-word; - font-size: 18px; - line-height: 21.6px; - font-weight: 400; - } - .GoogleLink { - color: #006621; // sampled from google - display: block; - font-family: Roboto, arial, sans-serif; - font-size: 14px; - font-style: normal; - font-weight: 400; - height: auto; - line-height: 16.8px; - padding: 2px 0 3px 0; - - a { - text-decoration: none; - color: #006621; // sampled from google - } - - span.Icon { - margin-left: 5px; - } - } - .GoogleDesc { - word-wrap: break-word; - color: rgb(84, 84, 84); - font-family: Roboto, arial, sans-serif; - font-size: 13px; - font-weight: 400; - height: auto; - line-height: 18.2px; - } - } - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/index.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/index.js deleted file mode 100644 index 57750330fa..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/index.js +++ /dev/null @@ -1 +0,0 @@ -export { ItemSettings } from "./ItemSettings"; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.js deleted file mode 100644 index c6748bec6c..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.js +++ /dev/null @@ -1,263 +0,0 @@ -import { memo, Fragment, useState, useEffect } from "react"; -import { connect, useSelector } from "react-redux"; -import { debounce, uniqBy } from "lodash"; -import { notify } from "shell/store/notifications"; - -import { Select, Option } from "@zesty-io/core/Select"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faHome } from "@fortawesome/free-solid-svg-icons"; - -import { searchItems } from "shell/store/content"; -import { FieldShell } from "../../../../../components/Editor/Field/FieldShell"; - -import styles from "./ItemParent.less"; -export const ItemParent = connect((state) => { - return { - nav: state.navContent.raw, - }; -})( - memo( - function ItemParent(props) { - const items = useSelector((state) => state.content); - const [loading, setLoading] = useState(false); - const [parent, setParent] = useState({ - meta: { - ZUID: "0", // "0" = root level route - path: "/", - }, - }); - - const [parents, setParents] = useState( - parentOptions(props.currentItemLangID, props.path, items) - ); - - const onSearch = debounce((term) => { - if (term) { - setLoading(true); - props.dispatch(searchItems(term)).then((res) => { - setLoading(false); - setParents( - parentOptions(props.currentItemLangID, props.path, { - ...items, - // needs to reduce and converts this data as the same format of the items to - // prevent having an issue on having an itemZUID with an incorrect format - // the reason is that the item has a format of {[itemZUID]:data} - // while the res.data has a value of an array which cause the needs of converting - // the response to an object with a zuid as a key - ...res?.data.reduce((acc, curr) => { - return { ...acc, [curr.meta.ZUID]: curr }; - }, {}), - }) - ); - }); - } - }, 250); - - /** - * Recurse nav linked list to find current items parent - * @param {*} zuid - * @param {*} count - */ - const findNavParent = (zuid, count = 0) => { - count++; - const navEntry = props.nav.find((el) => el.ZUID === zuid); - if (navEntry) { - // This first item should be the model we are resolving for so - // continue on up the nav tree - if (count === 0) { - return findNavParent(navEntry.parentZUID, count); - } else { - if (navEntry.type === "item") { - return navEntry; - } else if (navEntry.parentZUID) { - return findNavParent(navEntry.parentZUID, count); - } else { - return { ZUID: "0" }; - } - } - } else { - return { ZUID: "0" }; - } - }; - - useEffect(() => { - let parentZUID = props.parentZUID; - - // If it's a new item chase down the parentZUID within navigation - // This way we avoid an API request - if (props.itemZUID && props.itemZUID.slice(0, 3) === "new") { - const result = findNavParent(props.modelZUID); - - // change for preselection - parentZUID = result.ZUID; - - // Update redux store so if the item is saved we know it's parent - props.onChange(parentZUID, "parentZUID"); - } - - // Try to preselect parent - if (parentZUID && parentZUID != "0" && parentZUID !== null) { - const item = items[parentZUID]; - if (item && item.meta && item.meta.ZUID && item.meta.path) { - setParent(item); - } else { - props.dispatch(searchItems(parentZUID)).then((res) => { - if (res) { - if (res.data) { - if (Array.isArray(res.data) && res.data.length) { - // Handles cases where the model's parent is the homepage. This is no longer possible for newly created models but - // there are some old models that still have the homepage as their parent models. - if (res.data[0]?.web?.path === "/") { - setParent({ - meta: { - ZUID: "0", // "0" = root level route - path: "/", - }, - }); - } else { - setParent(res.data[0]); - /** - * // HACK Because we pre-load all item publishings and store them in the same reducer as the `content` - * we can't use array length comparision to determine a new parent has been added. Also since updates to the item - * currently being edited cause a new `content` object to be created in it's reducer we can't use - * referential equality checks to determine re-rendering. This scenario causes either the parent to not be pre-selected - * or a performance issue. To work around this we maintain the `parents` state internal and add the new parent we load from the - * API to allow it to be pre-selected while avoiding re-renders on changes to this item. - */ - - setParents( - parentOptions(props.currentItemLangID, props.path, { - ...items, - [res.data[0].meta.ZUID]: res.data[0], - }) - ); - } - } else { - props.dispatch( - notify({ - kind: "warn", - heading: `Cannot Save: ${props.metaTitle}`, - messsage: "Set page parent in SEO Tab", - }) - ); - } - } else { - props.dispatch( - notify({ - kind: "warn", - message: `API failed to return data. Try Again.`, - }) - ); - } - } else { - props.dispatch( - notify({ - kind: "warn", - heading: `"Cannot Save: ${props.metaTitle}`, - message: `Page's Parent does not exist or has been deleted`, - }) - ); - } - }); - } - } - }, []); - - return ( -
- - {/* - Delay rendering select until we have a parent. - Sometimes we have to resolve the parent from the API asynchronously - */} - {!parent ? ( - "Loading item parent" - ) : ( - - )} - -
- ); - }, - (prevProps, nextProps) => { - let isEqual = true; - - // Shallow compare all props - Object.keys(prevProps).forEach((key) => { - // ignore content, we'll check it seperately after - if (key !== "content") { - if (prevProps[key] !== nextProps[key]) { - isEqual = false; - } - } - }); - - return isEqual; - } - ) -); - -function parentOptions(currentItemLangID, path, items) { - const options = Object.entries(items) - ?.reduce((acc, [itemZUID, itemData]) => { - if ( - itemZUID.slice(0, 3) !== "new" && // Exclude new items - itemData?.meta?.ZUID && // must have a ZUID - itemData?.web?.path && // must have a path - itemData?.web.path !== "/" && // Exclude homepage - itemData?.web.path !== path && // Exclude current item - itemData?.meta?.langID === currentItemLangID // display only relevant language options - ) { - acc.push({ - value: itemZUID, - text: itemData.web.path, - }); - } - - return acc; - }, []) - .sort((a, b) => { - if (a.text > b.text) { - return 1; - } else if (a.text < b.text) { - return -1; - } else { - return 0; - } - }); - - return uniqBy(options, "value"); -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.less deleted file mode 100644 index c088a932ec..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.less +++ /dev/null @@ -1,9 +0,0 @@ -@import "~@zesty-io/core/colors.less"; - -.ItemParent { - label { - display: flex; - color: @zesty-dark-blue; - font-size: 14px; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.js deleted file mode 100644 index da3fc8c513..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.js +++ /dev/null @@ -1,186 +0,0 @@ -import { memo, Fragment, useCallback, useState, useEffect } from "react"; -import { connect, useDispatch } from "react-redux"; -import debounce from "lodash/debounce"; -import { searchItems } from "shell/store/content"; -import { notify } from "shell/store/notifications"; - -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faSpinner, faCheck, faTimes } from "@fortawesome/free-solid-svg-icons"; -import TextField from "@mui/material/TextField"; -import { InputIcon } from "@zesty-io/core/InputIcon"; -import { AppLink } from "@zesty-io/core/AppLink"; - -import styles from "./ItemRoute.less"; -import { withCursorPosition } from "../../../../../../../../../shell/components/withCursorPosition"; -import { FieldShell } from "../../../../../components/Editor/Field/FieldShell"; -const TextFieldWithCursorPosition = withCursorPosition(TextField); - -export const ItemRoute = connect((state) => { - return { - content: state.content, - }; -})( - memo(function ItemRoute(props) { - const dispatch = useDispatch(); - const [pathPart, setPathPart] = useState(props.path_part); - const [loading, setLoading] = useState(false); - const [unique, setUnique] = useState(true); - - const validate = useCallback( - debounce((path) => { - if (!path) { - setUnique(false); - return; - } - - const parent = props.content[props.parentZUID]; - const fullPath = parent ? parent.web.path + path : path; - - setLoading(true); - - return dispatch(searchItems(fullPath)) - .then((res) => { - if (res) { - if (res.data) { - if (Array.isArray(res.data) && res.data.length) { - // check list of partial matches for exact path match - const matches = res.data.filter((item) => { - /** - * Exclude currently viewed item zuid, as it's currently saved path would match. - * Check if other results have a matching path, if so then it is already taken and - * can not be used. - * Result paths come with leading and trailing slashes - */ - return ( - item.meta.ZUID !== props.ZUID && - item.web.path === "/" + fullPath + "/" - ); - }); - if (matches.length) { - props.dispatch( - notify({ - kind: "warn", - message: ( -

- URL {matches[0].web.path} is - unavailable. Used by  - - {matches[0].web.metaLinkText || - matches[0].web.metaTitle} - -

- ), - }) - ); - } - - setUnique(!matches.length); - } else { - setUnique(true); - } - } else { - props.dispatch( - notify({ - kind: "warn", - message: `API failed to return data ${res.status}`, - }) - ); - } - } else { - props.dispatch( - notify({ - kind: "warn", - message: `API failed to return response ${res.status}`, - }) - ); - } - }) - .finally(() => setLoading(false)); - }, 500), - [props.parentZUID] - ); - - const onChange = (evt) => { - // All URLs are lowercased - // Replace ampersand characters with 'and' - // Only allow alphanumeric characters - const path = evt.target.value - .trim() - .toLowerCase() - .replace(/\&/g, "and") - .replace(/[^a-zA-Z0-9]/g, "-"); - - validate(path); - setPathPart(path); - - props.dispatch({ - type: "SET_ITEM_WEB", - itemZUID: props.ZUID, - key: "pathPart", - value: path, - }); - }; - - // update internal state if external path part changes - useEffect(() => { - if (props.path_part) { - validate(props.path_part); - setPathPart(props.path_part); - } - }, [props.path_part, props.parentZUID]); - - return ( -
- -
- {props.path_part === "zesty_home" ? ( -

- -  Homepage -

- ) : ( - - - - {loading && ( - - - - )} - - {!loading && unique && ( - - - - )} - - {!loading && !unique && ( - - - - )} - - )} -
-
-
- ); - }) -); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.less deleted file mode 100644 index c35a9c0d31..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.less +++ /dev/null @@ -1,39 +0,0 @@ -@import "~@zesty-io/core/keyframes.less"; -@import "~@zesty-io/core/colors.less"; - -.ItemRoute { - label { - display: flex; - color: @zesty-dark-blue; - font-size: 14px; - } - .Path { - display: flex; - .Parent { - border-radius: 3px; - background-color: #4c5567; - align-items: center; - display: flex; - padding: 0 8px; - color: #fff; - } - .Checking { - margin: 0; - background-color: #404759; - border: 1px solid #404759; - i { - animation: spin 2s linear infinite; - } - } - .Valid { - margin: 0; - background-color: #63a41e; - border: 1px solid #63a41e; - } - .Invalid { - margin: 0; - background-color: #9a2803; - border: 1px solid #9a2803; - } - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.js deleted file mode 100644 index 9d786d1385..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.js +++ /dev/null @@ -1,86 +0,0 @@ -import { useState, useEffect } from "react"; -import { connect } from "react-redux"; -import { TextField } from "@mui/material"; - -import { notify } from "shell/store/notifications"; -import { FieldShell } from "../../../../../components/Editor/Field/FieldShell"; -import { MaxLengths } from "../ItemSettings"; - -import styles from "./MetaDescription.less"; -export default connect()(function MetaDescription({ - meta_description, - onChange, - dispatch, - errors, -}) { - const [error, setError] = useState(""); - - useEffect(() => { - if (meta_description) { - let message = ""; - - if (!(meta_description.indexOf("\u0152") === -1)) { - message = - "Found OE ligature. These special characters are not allowed in meta descriptions."; - } else if (!(meta_description.indexOf("\u0153") === -1)) { - message = - "Found oe ligature. These special characters are not allowed in meta descriptions."; - } else if (!(meta_description.indexOf("\xAB") === -1)) { - message = - "Found << character. These special characters are not allowed in meta descriptions."; - } else if (!(meta_description.indexOf("\xBB") === -1)) { - message = - "Found >> character. These special characters are not allowed in meta descriptions."; - } else if (/[\u201C\u201D\u201E]/.test(meta_description)) { - message = - "Found Microsoft smart double quotes and apostrophe. These special characters are not allowed in meta descriptions."; - } else if (/[\u2018\u2019\u201A]/.test(meta_description)) { - message = - "Found Microsoft Smart single quotes and apostrophe. These special characters are not allowed in meta descriptions."; - } - - setError(message); - } - }, [meta_description]); - - if (error) { - dispatch( - notify({ - kind: "warn", - message: error, - }) - ); - } - - return ( -
- - onChange(evt.target.value, "metaDescription")} - multiline - rows={6} - /> - -
- ); -}); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.less deleted file mode 100644 index 4e94f5b229..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.less +++ /dev/null @@ -1,13 +0,0 @@ -@import "~@zesty-io/core/colors.less"; - -.MetaDescription { - display: flex; - flex-direction: column; - label { - display: flex; - } - .error { - background-color: @zesty-highlight; - padding: 8px; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.less deleted file mode 100644 index 5e8029ecc2..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.less +++ /dev/null @@ -1,7 +0,0 @@ -.MetaKeywords { - display: flex; - flex-direction: column; - label { - display: flex; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.js deleted file mode 100644 index 07fe483b7d..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.js +++ /dev/null @@ -1,37 +0,0 @@ -import { memo } from "react"; - -import { TextField } from "@mui/material"; -import { FieldShell } from "../../../../../components/Editor/Field/FieldShell"; -import { MaxLengths } from "../ItemSettings"; - -import styles from "./MetaLinkText.less"; -export const MetaLinkText = memo(function MetaLinkText({ - meta_link_text, - onChange, - errors, -}) { - return ( -
- - onChange(evt.target.value, "metaLinkText")} - /> - -
- ); -}); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.less deleted file mode 100644 index ddbb8b4eda..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.less +++ /dev/null @@ -1,7 +0,0 @@ -.MetaLinkText { - display: flex; - flex-direction: column; - label { - display: flex; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.js deleted file mode 100644 index 65dd215ff8..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.js +++ /dev/null @@ -1,35 +0,0 @@ -import { memo } from "react"; - -import { TextField } from "@mui/material"; - -import { FieldShell } from "../../../../../components/Editor/Field/FieldShell"; -import { MaxLengths } from "../ItemSettings"; -import styles from "./MetaTitle.less"; -export const MetaTitle = memo(function MetaTitle({ - meta_title, - onChange, - errors, -}) { - return ( -
- - onChange(evt.target.value, "metaTitle")} - /> - -
- ); -}); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.less deleted file mode 100644 index 7ed0d77cec..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.less +++ /dev/null @@ -1,7 +0,0 @@ -.MetaTitle { - display: flex; - flex-direction: column; - label { - display: flex; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.js deleted file mode 100644 index 34f572cf6e..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.js +++ /dev/null @@ -1,26 +0,0 @@ -import { Header } from "../components/Header"; - -import { ItemSettings } from "./ItemSettings"; -import { DataSettings } from "./ItemSettings/DataSettings"; - -import styles from "./Meta.less"; -export function Meta(props) { - return ( -
-
- {props.model && props.model?.type === "dataset" ? ( - - ) : ( - - )} -
-
- ); -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.less deleted file mode 100644 index f73ba1b173..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.less +++ /dev/null @@ -1,10 +0,0 @@ -.MetaEdit { - display: flex; - flex: 1; - background-color: #f9fafb; - overflow: scroll; - flex-direction: column; - .MetaWrap { - margin: 4px 32px 20px 32px; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/FacebookPreview.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/FacebookPreview.tsx new file mode 100644 index 0000000000..13f5803f57 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/FacebookPreview.tsx @@ -0,0 +1,80 @@ +import { useEffect } from "react"; +import { Typography, Box, Stack } from "@mui/material"; +import { ImageRounded } from "@mui/icons-material"; +import { useLocation, useParams } from "react-router"; +import { useSelector } from "react-redux"; + +import { useDomain } from "../../../../../../../../shell/hooks/use-domain"; +import { AppState } from "../../../../../../../../shell/store/types"; +import { useImageURL } from "./useImageURL"; + +type FacebookPreviewProps = {}; +export const FacebookPreview = ({}: FacebookPreviewProps) => { + const [imageURL, setImageDimensions] = useImageURL(); + const { itemZUID, modelZUID } = useParams<{ + itemZUID: string; + modelZUID: string; + }>(); + const domain = useDomain(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const item = useSelector( + (state: AppState) => + state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] + ); + + useEffect(() => { + setImageDimensions({ height: 290, type: "fit" }); + }, []); + + return ( + + {!!imageURL ? ( + theme.palette.grey[100], + objectFit: "cover", + alignSelf: "center", + }} + height={290} + width="100%" + src={imageURL} + flexShrink={0} + /> + ) : ( + + + + )} + + + {domain.replace(/http:\/\/|https:\/\//gm, "")} + + + {item?.web?.metaTitle || "Meta Title"} + + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/GooglePreview.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/GooglePreview.tsx new file mode 100644 index 0000000000..37dcdb97ff --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/GooglePreview.tsx @@ -0,0 +1,173 @@ +import { Fragment, useMemo, useEffect } from "react"; +import { Box, Stack, Typography, Breadcrumbs } from "@mui/material"; +import { + MoreVertRounded, + ArrowForwardIosRounded, + ImageRounded, +} from "@mui/icons-material"; +import { useSelector } from "react-redux"; +import { useLocation, useParams } from "react-router"; + +import { useGetInstanceQuery } from "../../../../../../../../shell/services/accounts"; +import { InstanceAvatar } from "../../../../../../../../shell/components/global-sidebar/components/InstanceAvatar"; +import { useDomain } from "../../../../../../../../shell/hooks/use-domain"; +import { AppState } from "../../../../../../../../shell/store/types"; +import { useGetContentModelFieldsQuery } from "../../../../../../../../shell/services/instance"; +import { useImageURL } from "./useImageURL"; + +type GooglePreviewProps = {}; +export const GooglePreview = ({}: GooglePreviewProps) => { + const { modelZUID, itemZUID } = useParams<{ + modelZUID: string; + itemZUID: string; + }>(); + const { data: instance, isLoading: isLoadingInstance } = + useGetInstanceQuery(); + const domain = useDomain(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const [imageURL, setImageDimensions] = useImageURL(); + const { data: modelFields } = useGetContentModelFieldsQuery(modelZUID); + const items = useSelector((state: AppState) => state.content); + const item = items[isCreateItemPage ? `new:${modelZUID}` : itemZUID]; + const parent = items[item?.web?.parentZUID]; + + const fullPathArray = useMemo(() => { + let path: string[] = [domain]; + + if (parent) { + path = [ + ...path, + ...(parent.web?.path?.split("/") || []), + item?.web?.pathPart, + ]; + } else { + path = [...path, item?.web?.pathPart]; + } + + // Remove empty strings + return path.filter((i) => !!i); + }, [domain, parent, item?.web]); + + useEffect(() => { + setImageDimensions({ width: 82, height: 82 }); + }, []); + + return ( + + + + + + + {instance?.name} + + + + {fullPathArray.map((path, index) => ( + + + {path} + + {index < fullPathArray?.length - 1 && ( + + )} + + ))} + + theme.palette.text.secondary, + }} + /> + + + + + {item?.web?.metaTitle || "Meta Title"} + + + {item?.web?.metaDescription || "Meta Description"} + + + {!!imageURL ? ( + theme.palette.grey[100], + objectFit: "cover", + }} + width={82} + height={82} + src={imageURL} + flexShrink={0} + borderRadius={2} + /> + ) : ( + + + + )} + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/LinkedInPreview.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/LinkedInPreview.tsx new file mode 100644 index 0000000000..23c2292cd5 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/LinkedInPreview.tsx @@ -0,0 +1,80 @@ +import { useEffect } from "react"; +import { Typography, Box, Stack } from "@mui/material"; +import { ImageRounded } from "@mui/icons-material"; +import { useLocation, useParams } from "react-router"; +import { useSelector } from "react-redux"; + +import { useDomain } from "../../../../../../../../shell/hooks/use-domain"; +import { AppState } from "../../../../../../../../shell/store/types"; +import { useImageURL } from "./useImageURL"; + +type LinkedInPreviewProps = {}; +export const LinkedInPreview = ({}: LinkedInPreviewProps) => { + const [imageURL, setImageDimensions] = useImageURL(); + const { itemZUID, modelZUID } = useParams<{ + itemZUID: string; + modelZUID: string; + }>(); + const domain = useDomain(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const item = useSelector( + (state: AppState) => + state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] + ); + + useEffect(() => { + setImageDimensions({ height: 290, type: "fit" }); + }, []); + + return ( + + {!!imageURL ? ( + theme.palette.grey[100], + objectFit: "cover", + alignSelf: "center", + }} + height={290} + width="100%" + src={imageURL} + flexShrink={0} + /> + ) : ( + + + + )} + + + {item?.web?.metaTitle || "Meta Title"} + + + {domain.replace(/http:\/\/|https:\/\//gm, "")} + + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/TwitterPreview.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/TwitterPreview.tsx new file mode 100644 index 0000000000..ef4ae40e52 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/TwitterPreview.tsx @@ -0,0 +1,114 @@ +import { useEffect } from "react"; +import { Typography, Box, Stack } from "@mui/material"; +import { ImageRounded } from "@mui/icons-material"; +import { useLocation, useParams } from "react-router"; +import { useSelector } from "react-redux"; + +import { useDomain } from "../../../../../../../../shell/hooks/use-domain"; +import { AppState } from "../../../../../../../../shell/store/types"; +import { useImageURL } from "./useImageURL"; + +type TwitterPreviewProps = {}; +export const TwitterPreview = ({}: TwitterPreviewProps) => { + const [imageURL, setImageDimensions] = useImageURL(); + const { itemZUID, modelZUID } = useParams<{ + itemZUID: string; + modelZUID: string; + }>(); + const domain = useDomain(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const item = useSelector( + (state: AppState) => + state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] + ); + + useEffect(() => { + setImageDimensions({ width: 128, height: 128 }); + }, []); + + return ( + + {!!imageURL ? ( + theme.palette.grey[100], + objectFit: "cover", + }} + width={128} + height={128} + src={imageURL} + flexShrink={0} + /> + ) : ( + + + + )} + + + {domain.replace(/http:\/\/|https:\/\//gm, "")} + + + {item?.web?.metaTitle || "Meta Title"} + + + {item?.web?.metaDescription || "Meta Description"} + + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/index.tsx new file mode 100644 index 0000000000..67a3fa847c --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/index.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import { Tab, Tabs, Box } from "@mui/material"; +import { Google, Twitter, Facebook, LinkedIn } from "@mui/icons-material"; +import { GooglePreview } from "./GooglePreview"; +import { TwitterPreview } from "./TwitterPreview"; +import { FacebookPreview } from "./FacebookPreview"; +import { LinkedInPreview } from "./LinkedInPreview"; + +enum SocialMediaTab { + Google, + Twitter, + Facebook, + LinkedIn, +} +type SocialMediaPreviewProps = {}; +export const SocialMediaPreview = ({}: SocialMediaPreviewProps) => { + const [activeTab, setActiveTab] = useState( + SocialMediaTab.Google + ); + + return ( + <> + `2px solid ${theme?.palette?.border} `, + }} + > + setActiveTab(value)} + sx={{ + position: "relative", + top: "2px", + }} + > + } + iconPosition="start" + label="Google" + value={SocialMediaTab.Google} + /> + } + iconPosition="start" + label="Twitter (X)" + value={SocialMediaTab.Twitter} + /> + } + iconPosition="start" + label="Facebook" + value={SocialMediaTab.Facebook} + /> + } + iconPosition="start" + label="LinkedIn" + value={SocialMediaTab.LinkedIn} + /> + + + {activeTab === SocialMediaTab.Google && } + {activeTab === SocialMediaTab.Twitter && } + {activeTab === SocialMediaTab.Facebook && } + {activeTab === SocialMediaTab.LinkedIn && } + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/useImageURL.ts b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/useImageURL.ts new file mode 100644 index 0000000000..ca4e913d5b --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/useImageURL.ts @@ -0,0 +1,83 @@ +import { useMemo, useState } from "react"; +import { useSelector } from "react-redux"; +import { useLocation, useParams } from "react-router"; +import { useGetContentModelFieldsQuery } from "../../../../../../../../shell/services/instance"; +import { AppState } from "../../../../../../../../shell/store/types"; + +type ImageDimension = { + type?: "crop" | "fit"; + width?: number; + height?: number; +}; +type UseImageURLProps = [string, (imageDimensions: ImageDimension) => void]; +export const useImageURL: () => UseImageURLProps = () => { + const { modelZUID, itemZUID } = useParams<{ + modelZUID: string; + itemZUID: string; + }>(); + const { data: modelFields } = useGetContentModelFieldsQuery(modelZUID); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const item = useSelector( + (state: AppState) => + state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] + ); + const [imageDimensions, setImageDimensions] = useState({}); + + const imageURL: string = useMemo(() => { + if (!item?.data || !modelFields?.length) return; + + let matchedURL: string | null = null; + + if ("og_image" in item?.data) { + matchedURL = !!item?.data?.["og_image"] + ? (item?.data?.["og_image"] as string) + : null; + } else { + // Find possible image fields that can be used + const matchedFields = modelFields.filter( + (field) => + !field.deletedAt && + field.datatype === "images" && + field?.name !== "og_image" && + (field.label.toLowerCase().includes("image") || + field.name.toLocaleLowerCase().includes("image")) + ); + + if (matchedFields?.length) { + // Find the first matched field that already stores an image and make sure + // to find the first valid image in that field + matchedFields.forEach((field) => { + if (!matchedURL && !!item?.data?.[field.name]) { + matchedURL = String(item?.data?.[field.name])?.split(",")?.[0]; + } + }); + } + } + + if (matchedURL?.startsWith("3-")) { + const params = { + type: imageDimensions?.type || "crop", + ...(!!imageDimensions?.width && { + w: String(imageDimensions.width), + }), + ...(!!imageDimensions?.height && { + h: String(imageDimensions.height), + }), + }; + const url = new URL( + `${ + // @ts-ignore + CONFIG.SERVICE_MEDIA_RESOLVER + }/resolve/${matchedURL}/getimage/` + ); + url.search = new URLSearchParams(params)?.toString(); + + return url.toString(); + } else { + return matchedURL; + } + }, [item?.data, modelFields, imageDimensions]); + + return [imageURL, setImageDimensions]; +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.js deleted file mode 100644 index f75005249b..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.js +++ /dev/null @@ -1 +0,0 @@ -export { Meta } from "./Meta"; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx new file mode 100644 index 0000000000..386d07849d --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx @@ -0,0 +1,242 @@ +import { useState, useCallback, useMemo, useEffect } from "react"; +import { Stack, Box, Typography, ThemeProvider, Divider } from "@mui/material"; +import { theme } from "@zesty-io/material"; +import { useParams, useLocation } from "react-router"; +import { useSelector, useDispatch } from "react-redux"; + +import { ContentInsights } from "./ContentInsights"; +import { useGetContentModelQuery } from "../../../../../../../shell/services/instance"; +import { AppState } from "../../../../../../../shell/store/types"; +import { Error } from "../../../components/Editor/Field/FieldShell"; +import { fetchGlobalItem } from "../../../../../../../shell/store/content"; + +// Fields +import { MetaImage } from "./settings/MetaImage"; +import { CanonicalTag } from "./settings/CanonicalTag"; +import { ItemParent } from "./settings/ItemParent"; +import { ItemRoute } from "./settings/ItemRoute"; +import MetaDescription from "./settings/MetaDescription"; +import { MetaKeywords } from "./settings/MetaKeywords"; +import { MetaLinkText } from "./settings/MetaLinkText"; +import { MetaTitle } from "./settings/MetaTitle"; +import { SitemapPriority } from "./settings/SitemapPriority"; +import { cloneDeep } from "lodash"; +import { SocialMediaPreview } from "./SocialMediaPreview"; + +export const MaxLengths: Record = { + metaLinkText: 150, + metaTitle: 150, + metaDescription: 160, + metaKeywords: 255, +}; +const REQUIRED_FIELDS = [ + "metaTitle", + "metaDescription", + "parentZUID", + "pathPart", +] as const; + +type Errors = Record; +type MetaProps = { + isSaving: boolean; + onUpdateSEOErrors: (hasErrors: boolean) => void; +}; +export const Meta = ({ isSaving, onUpdateSEOErrors }: MetaProps) => { + const dispatch = useDispatch(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const { modelZUID, itemZUID } = useParams<{ + modelZUID: string; + itemZUID: string; + }>(); + const { data: model } = useGetContentModelQuery(modelZUID, { + skip: !modelZUID, + }); + const { meta, data, web } = useSelector( + (state: AppState) => + state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] + ); + const [errors, setErrors] = useState({}); + + // @ts-expect-error untyped + const siteName = useMemo(() => dispatch(fetchGlobalItem())?.site_name, []); + + const handleOnChange = useCallback( + (value, name) => { + if (!name) { + throw new Error("Input is missing name attribute"); + } + + const currentErrors = cloneDeep(errors); + + if (REQUIRED_FIELDS.includes(name)) { + currentErrors[name] = { + ...currentErrors?.[name], + MISSING_REQUIRED: !value, + }; + } + + if (MaxLengths[name]) { + currentErrors[name] = { + ...currentErrors?.[name], + EXCEEDING_MAXLENGTH: + value?.length > MaxLengths[name] + ? value?.length - MaxLengths[name] + : 0, + }; + } + + setErrors(currentErrors); + + dispatch({ + // The og_image is stored as an ordinary field item and not a SEO field item + type: name === "og_image" ? "SET_ITEM_DATA" : "SET_ITEM_WEB", + itemZUID: meta?.ZUID, + key: name, + value: value, + }); + }, + [meta?.ZUID, errors] + ); + + useEffect(() => { + if (isSaving) { + setErrors({}); + return; + } + }, [isSaving]); + + useEffect(() => { + const hasErrors = Object.values(errors) + ?.map((error) => { + return Object.values(error) ?? []; + }) + ?.flat() + .some((error) => !!error); + + onUpdateSEOErrors(hasErrors); + }, [errors]); + + return ( + + + + + + + SEO & Open Graph Settings + + + Specify this page's title and description. You can see how + they'll look in search engine results pages (SERPs) and social + media content in the preview on the right. + + + + + + + {model?.type !== "dataset" && web?.pathPart !== "zesty_home" && ( + + + + URL Settings + + + Define the URL of your web page + + + + { + setErrors({ + ...errors, + [name]: { + ...errors?.[name], + ...error, + }, + }); + }} + /> + + )} + + + + Advanced Settings + + + Optimize your content item's SEO further + + + {model?.type !== "dataset" && ( + <> + + {!!web && ( + + )} + + )} + + + + + {model?.type !== "dataset" && !isCreateItemPage && ( + + + + + + )} + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/CanonicalTag.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/CanonicalTag.js similarity index 96% rename from src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/CanonicalTag.js rename to src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/CanonicalTag.js index b6f5f4982e..2bcb1aebe5 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/CanonicalTag.js +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/CanonicalTag.js @@ -2,7 +2,7 @@ import { memo, useState } from "react"; import { TextField, Select, MenuItem } from "@mui/material"; -import { FieldShell } from "../../../../../components/Editor/Field/FieldShell"; +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; const CANONICAL_OPTS = [ { @@ -52,7 +52,7 @@ export const CanonicalTag = memo(function CanonicalTag(props) { settings={{ label: "Canonical Tag", }} - customTooltip="Canonical tags help search engines understand authoritative links and can help prevent duplicate content issues. Zesty.io auto creates tags on demand based on your settings." + customTooltip="Canonical tags help search engines understand authoritative links and can help prevent duplicate content issues. Zesty.io auto-creates tags on demand based on your settings." withInteractiveTooltip={false} > {zestyStore.getState().instance.settings.seo[ diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/CanonicalTag.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/CanonicalTag.less similarity index 100% rename from src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/CanonicalTag.less rename to src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/CanonicalTag.less diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemParent.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemParent.tsx new file mode 100644 index 0000000000..2d6ef2d1ad --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemParent.tsx @@ -0,0 +1,258 @@ +import { Autocomplete, TextField, ListItem } from "@mui/material"; +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { useDispatch, useSelector } from "react-redux"; +import { AppState } from "../../../../../../../../shell/store/types"; +import { + ContentItemWithDirtyAndPublishing, + ContentNavItem, +} from "../../../../../../../../shell/services/types"; +import { debounce, uniqBy } from "lodash"; +import { useEffect, useState } from "react"; +import { useLocation, useParams } from "react-router"; +import { notify } from "../../../../../../../../shell/store/notifications"; +import { searchItems } from "../../../../../../../../shell/store/content"; +import { useGetContentNavItemsQuery } from "../../../../../../../../shell/services/instance"; + +type ParentOption = { + value: string; + text: string; +}; +const getParentOptions = ( + currentItemLangID: number, + path: string, + items: Record +) => { + const options: ParentOption[] = Object.entries(items) + ?.reduce((acc, [itemZUID, itemData]) => { + if ( + itemZUID.slice(0, 3) !== "new" && // Exclude new items + itemData?.meta?.ZUID && // must have a ZUID + itemData?.web?.path && // must have a path + itemData?.web.path !== "/" && // Exclude homepage + itemData?.web.path !== path && // Exclude current item + itemData?.meta?.langID === currentItemLangID // display only relevant language options + ) { + acc.push({ + value: itemZUID, + text: itemData.web.path, + }); + } + + return acc; + }, []) + .sort((a, b) => { + if (a.text > b.text) { + return 1; + } else if (a.text < b.text) { + return -1; + } else { + return 0; + } + }); + + // Insert the home route + options.unshift({ + text: "/", + value: "0", // 0 = root level + }); + + return uniqBy(options, "value"); +}; + +const findNavParent = (zuid: string, nav: ContentNavItem[], count = 0): any => { + count++; + const navEntry = nav?.find((el: any) => el.ZUID === zuid); + if (navEntry) { + // This first item should be the model we are resolving for so + // continue on up the nav tree + if (count === 0) { + return findNavParent(navEntry.parentZUID, nav, count); + } else { + if (navEntry.type === "item") { + return navEntry.ZUID; + } else if (navEntry.parentZUID) { + return findNavParent(navEntry.parentZUID, nav, count); + } else { + return "0"; + } + } + } else { + return "0"; + } +}; + +type ItemParentProps = { + onChange: (value: string, name: string) => void; +}; +export const ItemParent = ({ onChange }: ItemParentProps) => { + const { modelZUID, itemZUID } = useParams<{ + modelZUID: string; + itemZUID: string; + }>(); + const dispatch = useDispatch(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const items = useSelector((state: AppState) => state.content); + const item = items[isCreateItemPage ? `new:${modelZUID}` : itemZUID]; + const { data: rawNavData } = useGetContentNavItemsQuery(); + const [selectedParent, setSelectedParent] = useState({ + value: "0", // "0" = root level route + text: "/", + }); + const [options, setOptions] = useState( + getParentOptions(item?.meta?.langID, item?.web?.path, items) + ); + const [isLoadingOptions, setIsLoadingOptions] = useState(false); + + const handleSearchOptions = debounce((filterTerm) => { + if (filterTerm) { + dispatch(searchItems(filterTerm)) + // @ts-expect-error untyped + .then((res) => { + setOptions( + getParentOptions(item?.meta?.langID, item?.web?.path, { + ...items, + // needs to reduce and converts this data as the same format of the items to + // prevent having an issue on having an itemZUID with an incorrect format + // the reason is that the item has a format of {[itemZUID]:data} + // while the res.data has a value of an array which cause the needs of converting + // the response to an object with a zuid as a key + ...res?.data.reduce( + ( + acc: Record, + curr: ContentItemWithDirtyAndPublishing + ) => { + return { ...acc, [curr.meta.ZUID]: curr }; + }, + {} + ), + }) + ); + }) + .finally(() => setIsLoadingOptions(false)); + } + }, 1000); + + useEffect(() => { + let { parentZUID } = item?.web; + const { ZUID: itemZUID } = item?.meta; + + // If it's a new item chase down the parentZUID within navigation + // This way we avoid an API request + if (itemZUID && itemZUID.slice(0, 3) === "new") { + const result = findNavParent(modelZUID, rawNavData); + + // change for preselection + parentZUID = result; + + // Update redux store so if the item is saved we know it's parent + onChange(parentZUID, "parentZUID"); + } + + // Try to preselect parent + if (parentZUID && parentZUID !== "0") { + const parentItem = items[parentZUID]; + if (parentItem?.meta?.ZUID && parentItem?.web?.path) { + setSelectedParent({ + value: parentItem.meta.ZUID, + text: parentItem.web.path, + }); + } else { + dispatch(searchItems(parentZUID)) + // @ts-expect-error untyped + .then((res) => { + if (res?.data) { + if (Array.isArray(res.data) && res.data.length) { + // Handles cases where the model's parent is the homepage. This is no longer possible for newly created models but + // there are some old models that still have the homepage as their parent models. + if (res.data[0]?.web?.path === "/") { + setSelectedParent({ + value: "0", // "0" = root level route + text: "/", + }); + } else { + setSelectedParent({ + value: res.data?.[0]?.meta?.ZUID, + text: res.data?.[0]?.web?.path, + }); + /** + * // HACK Because we pre-load all item publishings and store them in the same reducer as the `content` + * we can't use array length comparision to determine a new parent has been added. Also since updates to the item + * currently being edited cause a new `content` object to be created in it's reducer we can't use + * referential equality checks to determine re-rendering. This scenario causes either the parent to not be pre-selected + * or a performance issue. To work around this we maintain the `parents` state internal and add the new parent we load from the + * API to allow it to be pre-selected while avoiding re-renders on changes to this item. + */ + + setOptions( + getParentOptions(item?.meta?.langID, item?.web?.path, { + ...items, + [res.data[0].meta.ZUID]: res.data[0], + }) + ); + } + } else { + dispatch( + notify({ + kind: "warn", + heading: `Cannot Save: ${item?.web?.metaTitle}`, + message: `Page's Parent does not exist or has been deleted`, + }) + ); + } + } else { + dispatch( + notify({ + kind: "warn", + message: `API failed to return data. Try Again.`, + }) + ); + } + }); + } + } + }, []); + + return ( + + } + renderOption={(props, value) => ( + + {value.text} + + )} + getOptionLabel={(option) => option.text} + onInputChange={(_, filterTerm) => { + if (filterTerm !== "/") { + setIsLoadingOptions(!!filterTerm); + handleSearchOptions(filterTerm); + } + }} + onChange={(_, value) => { + // Always default to homepage when no parent is selected + setSelectedParent(value !== null ? value : { text: "/", value: "0" }); + onChange(value !== null ? value.value : "0", "parentZUID"); + }} + loading={isLoadingOptions} + sx={{ + "& .MuiOutlinedInput-root": { + padding: "2px", + }, + }} + /> + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx new file mode 100644 index 0000000000..b0c3e64efd --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx @@ -0,0 +1,199 @@ +import { ChangeEvent, useCallback, useEffect, useState } from "react"; +import { + TextField, + InputAdornment, + CircularProgress, + Typography, +} from "@mui/material"; +import { useDispatch, useSelector } from "react-redux"; +import { useLocation, useParams } from "react-router"; +import { debounce } from "lodash"; +import { CheckCircleRounded, CancelRounded } from "@mui/icons-material"; + +import { withCursorPosition } from "../../../../../../../../shell/components/withCursorPosition"; +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { searchItems } from "../../../../../../../../shell/store/content"; +import { notify } from "../../../../../../../../shell/store/notifications"; +import { AppState } from "../../../../../../../../shell/store/types"; +import { ContentItemWithDirtyAndPublishing } from "../../../../../../../../shell/services/types"; +import { useDomain } from "../../../../../../../../shell/hooks/use-domain"; +import { Error } from "../../../../components/Editor/Field/FieldShell"; +import { hasErrors } from "./util"; + +const TextFieldWithCursorPosition = withCursorPosition(TextField); + +type ItemRouteProps = { + onChange: (value: string, name: string) => void; + error: Error; + onUpdateErrors: (name: string, error: Error) => void; +}; +export const ItemRoute = ({ + onChange, + error, + onUpdateErrors, +}: ItemRouteProps) => { + const dispatch = useDispatch(); + const { itemZUID, modelZUID } = useParams<{ + itemZUID: string; + modelZUID: string; + }>(); + const domain = useDomain(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const items = useSelector((state: AppState) => state.content); + const item = items[isCreateItemPage ? `new:${modelZUID}` : itemZUID]; + const [pathPart, setPathPart] = useState(item?.web?.pathPart); + const [isLoading, setIsLoading] = useState(false); + const [isUnique, setIsUnique] = useState(true); + + const parent = items[item?.web?.parentZUID]; + + const validate = useCallback( + debounce((path) => { + if (!path) { + setIsUnique(false); + return; + } + + const fullPath = parent ? `${parent.web?.path}${path}/` : `/${path}/`; + + setIsLoading(true); + + return ( + dispatch(searchItems(fullPath)) + // @ts-expect-error untyped + .then((res) => { + if (res?.data) { + if (Array.isArray(res.data) && res.data.length) { + // check list of partial matches for exact path match + const matches = res.data.filter( + (_item: ContentItemWithDirtyAndPublishing) => { + /** + * Exclude currently viewed item zuid, as it's currently saved path would match. + * Check if other results have a matching path, if so then it is already taken and + * can not be used. + * Result paths come with leading and trailing slashes + */ + return ( + _item.meta.ZUID !== item?.meta?.ZUID && + _item.web.path === fullPath + ); + } + ); + + setIsUnique(!matches.length); + onUpdateErrors("pathPart", { + CUSTOM_ERROR: !!matches.length + ? "This URL Path Part is already taken. Please enter a new different URL Path part." + : "", + }); + } else { + setIsUnique(true); + onUpdateErrors("pathPart", { + CUSTOM_ERROR: "", + }); + } + } else { + dispatch( + notify({ + kind: "warn", + message: `API failed to return data ${res.status}`, + }) + ); + } + }) + .finally(() => setIsLoading(false)) + ); + }, 1000), + [parent] + ); + + const handleInputChange = (evt: ChangeEvent) => { + // All URLs are lowercased + // Replace ampersand characters with 'and' + // Only allow alphanumeric characters + const path = evt.target.value + .trim() + .toLowerCase() + .replace(/\&/g, "and") + .replace(/[^a-zA-Z0-9]/g, "-"); + + validate(path); + setPathPart(path); + + onChange(path, "pathPart"); + }; + + // Revalidate when parent path changes + useEffect(() => { + validate(pathPart); + }, [parent?.web, pathPart]); + + useEffect(() => { + setPathPart(item?.web?.pathPart || ""); + }, [item?.web]); + + return ( + + + ), + }} + helperText={ + !!pathPart && + isUnique && ( + + {domain} + {parent ? parent.web?.path + pathPart : `/${pathPart}`} + + ) + } + error={hasErrors(error)} + /> + + ); +}; + +type AdornmentProps = { + isLoading: boolean; + isUnique: boolean; +}; +const Adornment = ({ isLoading, isUnique }: AdornmentProps) => { + if (isLoading) { + return ( + + + + ); + } + + return ( + + {isUnique ? ( + + ) : ( + + )} + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx new file mode 100644 index 0000000000..48da1becb8 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx @@ -0,0 +1,94 @@ +import { useState, useEffect } from "react"; +import { connect, useDispatch } from "react-redux"; +import { TextField, Box } from "@mui/material"; + +import { notify } from "../../../../../../../../shell/store/notifications"; +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { MaxLengths } from ".."; +import { hasErrors } from "./util"; +import { Error } from "../../../../components/Editor/Field/FieldShell"; + +type MetaDescriptionProps = { + value: string; + onChange: (value: string, name: string) => void; + error: Error; +}; +export default connect()(function MetaDescription({ + value, + onChange, + error, +}: MetaDescriptionProps) { + const dispatch = useDispatch(); + const [contentValidationError, setContentValidationError] = useState(""); + + useEffect(() => { + if (value) { + let message = ""; + + if (!(value.indexOf("\u0152") === -1)) { + message = + "Found OE ligature. These special characters are not allowed in meta descriptions."; + } else if (!(value.indexOf("\u0153") === -1)) { + message = + "Found oe ligature. These special characters are not allowed in meta descriptions."; + } else if (!(value.indexOf("\xAB") === -1)) { + message = + "Found << character. These special characters are not allowed in meta descriptions."; + } else if (!(value.indexOf("\xBB") === -1)) { + message = + "Found >> character. These special characters are not allowed in meta descriptions."; + } else if (/[\u201C\u201D\u201E]/.test(value)) { + message = + "Found Microsoft smart double quotes and apostrophe. These special characters are not allowed in meta descriptions."; + } else if (/[\u2018\u2019\u201A]/.test(value)) { + message = + "Found Microsoft Smart single quotes and apostrophe. These special characters are not allowed in meta descriptions."; + } + + setContentValidationError(message); + } + }, [value]); + + if (contentValidationError) { + dispatch( + notify({ + kind: "warn", + message: contentValidationError, + }) + ); + } + + return ( + + + onChange(evt.target.value, "metaDescription")} + multiline + rows={6} + error={hasErrors(error) || !!contentValidationError} + /> + + + ); +}); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaImage.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaImage.tsx new file mode 100644 index 0000000000..29b0502328 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaImage.tsx @@ -0,0 +1,281 @@ +import { useState, useEffect, useMemo, useRef } from "react"; +import { Dialog, IconButton, Stack } from "@mui/material"; +import { LoadingButton } from "@mui/lab"; +import { AddRounded, Close, EditRounded } from "@mui/icons-material"; +import { MemoryRouter, useLocation, useParams } from "react-router"; +import { useDispatch, useSelector } from "react-redux"; + +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { AppState } from "../../../../../../../../shell/store/types"; +import { + useCreateContentModelFieldMutation, + useGetContentModelFieldsQuery, + useUndeleteContentModelFieldMutation, +} from "../../../../../../../../shell/services/instance"; +import { fetchItem } from "../../../../../../../../shell/store/content"; +import { + FieldTypeMedia, + MediaItem, +} from "../../../../components/FieldTypeMedia"; +import { MediaApp } from "../../../../../../../media/src/app"; + +type MetaImageProps = { + onChange: (value: string, name: string) => void; +}; +export const MetaImage = ({ onChange }: MetaImageProps) => { + const dispatch = useDispatch(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const { modelZUID, itemZUID } = useParams<{ + modelZUID: string; + itemZUID: string; + }>(); + const item = useSelector( + (state: AppState) => + state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] + ); + const fieldTypeMedia = useRef(null); + const { data: modelFields } = useGetContentModelFieldsQuery(modelZUID); + const [ + createContentModelField, + { + isLoading: isCreatingOgImageField, + isSuccess: isOgImageFieldCreated, + error: ogImageFieldCreationError, + }, + ] = useCreateContentModelFieldMutation(); + const [ + undeleteContentModelField, + { isLoading: isUndeletingField, isSuccess: isFieldUndeleted }, + ] = useUndeleteContentModelFieldMutation(); + const [imageModal, setImageModal] = useState(null); + const [autoOpenMediaBrowser, setAutoOpenMediaBrowser] = useState(false); + + const isBynderSessionValid = + localStorage.getItem("cvrt") && localStorage.getItem("cvad"); + + const usableTemporaryMetaImage = useMemo(() => { + if (modelFields?.length) { + const matchedFields = modelFields.filter( + (field) => + !field.deletedAt && + field.datatype === "images" && + field?.name !== "og_image" && + (field.label.toLowerCase().includes("image") || + field.name.toLocaleLowerCase().includes("image")) + ); + let image = ""; + + // Find the first matched field that already stores an image + matchedFields?.forEach((field) => { + if (!image && !!item?.data?.[field.name]) { + image = String(item?.data?.[field.name]); + } + }); + + return image?.split(",")?.[0]; + } + }, [modelFields, item]); + + const handleCreateOgImageField = () => { + const existingOgImageField = modelFields?.find( + (field) => field.name === "og_image" + ); + + if (!!existingOgImageField && !!existingOgImageField.deletedAt) { + // If the og_image field already exists in the model but was deactivated, reactivate it + undeleteContentModelField({ + modelZUID, + fieldZUID: existingOgImageField.ZUID, + }); + } else { + // If the model has no og_image field yet, create it + createContentModelField({ + modelZUID, + body: { + contentModelZUID: modelZUID, + datatype: "images", + description: + "This field allows you to set an open graph image via the SEO tab. An Open Graph (OG) image is an image that appears on a social media post when a web page is shared.", + label: "Meta Image", + name: "og_image", + required: false, + settings: { + defaultValue: null, + group_id: "", + limit: 1, + list: false, + }, + sort: modelFields?.length, // Adds it to the end of the current model's field list + }, + }); + } + }; + + useEffect(() => { + if ( + (!isCreatingOgImageField && isOgImageFieldCreated) || + (!isUndeletingField && isFieldUndeleted) + ) { + // Initiate the empty og_image field + onChange(null, "og_image"); + } + }, [ + isOgImageFieldCreated, + isCreatingOgImageField, + isUndeletingField, + isFieldUndeleted, + ]); + + useEffect(() => { + // Automatically opens the media browser when the og_image field has no value + if (autoOpenMediaBrowser && "og_image" in item?.data) { + if (!item?.data?.["og_image"]) { + fieldTypeMedia.current?.triggerOpenMediaBrowser(); + } + setAutoOpenMediaBrowser(false); + } + }, [item?.data, autoOpenMediaBrowser]); + + // If there is already a field named og_image + if ("og_image" in item?.data) { + const ogImageValue = item?.data?.["og_image"]; + + return ( + <> + + { + setImageModal(opts); + }} + onChange={onChange} + lockedToGroupId={null} + settings={{ + fileExtensions: [ + ".png", + ".jpg", + ".jpeg", + ".svg", + ".gif", + ".tif", + ".webp", + ], + fileExtensionsErrorMessage: + "Only files with the following extensions are allowed: .png, .jpg, .jpeg, .svg, .gif, .tif, .webp", + }} + /> + + {imageModal && ( + + setImageModal(null)} + > + setImageModal(null)} + > + + + { + imageModal.callback(images); + setImageModal(null); + }} + isReplace={imageModal.isReplace} + /> + + + )} + + ); + } + + // If there is a media field with an API ID containing the word "image" and is storing a file + if (!("og_image" in item?.data) && !!usableTemporaryMetaImage) { + return ( + + + + } + variant="outlined" + sx={{ width: "fit-content" }} + onClick={() => { + handleCreateOgImageField(); + setAutoOpenMediaBrowser(true); + }} + > + Customize Image + + + + ); + } + + // If no image field + return ( + + } + variant="outlined" + sx={{ width: "fit-content", mt: 0.75 }} + onClick={handleCreateOgImageField} + > + Add Meta Image + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaKeywords.tsx similarity index 59% rename from src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.js rename to src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaKeywords.tsx index 30f238dde3..85856fefb1 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.js +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaKeywords.tsx @@ -1,17 +1,23 @@ import { memo } from "react"; -import { TextField } from "@mui/material"; +import { TextField, Box } from "@mui/material"; -import { FieldShell } from "../../../../../components/Editor/Field/FieldShell"; -import { MaxLengths } from "../ItemSettings"; -import styles from "./MetaKeywords.less"; +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { MaxLengths } from ".."; +import { Error } from "../../../../components/Editor/Field/FieldShell"; + +type MetaKeywordsProps = { + value: string; + onChange: (value: string, name: string) => void; + error: Error; +}; export const MetaKeywords = memo(function MetaKeywords({ - meta_keywords, + value, onChange, - errors, -}) { + error, +}: MetaKeywordsProps) { return ( -
+ onChange(evt.target.value, "metaKeywords")} /> -
+
); }); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaLinkText.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaLinkText.tsx new file mode 100644 index 0000000000..17dc9a1f5a --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaLinkText.tsx @@ -0,0 +1,40 @@ +import { memo } from "react"; + +import { TextField, Box } from "@mui/material"; +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { MaxLengths } from ".."; +import { Error } from "../../../../components/Editor/Field/FieldShell"; + +type MetaLinkTextProps = { + value: string; + onChange: (value: string, name: string) => void; + error: Error; +}; +export const MetaLinkText = memo(function MetaLinkText({ + value, + onChange, + error, +}: MetaLinkTextProps) { + return ( + + + onChange(evt.target.value, "metaLinkText")} + /> + + + ); +}); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx new file mode 100644 index 0000000000..02de2c9ed9 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx @@ -0,0 +1,44 @@ +import { memo } from "react"; + +import { TextField, Box } from "@mui/material"; + +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { MaxLengths } from ".."; +import { hasErrors } from "./util"; +import { Error } from "../../../../components/Editor/Field/FieldShell"; + +type MetaTitleProps = { + value: string; + onChange: (value: string, name: string) => void; + error: Error; +}; +export const MetaTitle = memo(function MetaTitle({ + value, + onChange, + error, +}: MetaTitleProps) { + return ( + + + onChange(evt.target.value, "metaTitle")} + error={hasErrors(error)} + /> + + + ); +}); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/SitemapPriority.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/SitemapPriority.js similarity index 91% rename from src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/SitemapPriority.js rename to src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/SitemapPriority.js index 1f0f13f8e4..0a7ce62d9f 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/SitemapPriority.js +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/SitemapPriority.js @@ -2,8 +2,9 @@ import { memo } from "react"; import { Select, MenuItem } from "@mui/material"; -import { FieldShell } from "../../../../../components/Editor/Field/FieldShell"; +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; import styles from "./SitemapPriority.less"; + export const SitemapPriority = memo(function SitemapPriority(props) { return (
@@ -11,7 +12,7 @@ export const SitemapPriority = memo(function SitemapPriority(props) { settings={{ label: "Sitemap Priority", }} - customTooltip="Sitemap priority helps search engines understand how often they should crawl pages on your site." + customTooltip="Sitemap priority helps search engines understand how often they should crawl the pages on your site." withInteractiveTooltip={false} > - handleMode(evt.target.value, "canonicalTagMode") - } - value={CANONICAL_OPTS[mode] && CANONICAL_OPTS[mode].value} - size="small" + option.value === mode)} fullWidth - > - {CANONICAL_OPTS.map((opt) => ( - - {opt.text} - - ))} - + renderInput={(params) => } + onChange={(_, value) => { + handleMode(value ? value.value : 1, "canonicalTagMode"); + }} + /> {mode == "2" ? (
diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemParent.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemParent.tsx index 2d6ef2d1ad..a9cb8bd91b 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemParent.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemParent.tsx @@ -1,4 +1,4 @@ -import { Autocomplete, TextField, ListItem } from "@mui/material"; +import { Box, Autocomplete, TextField, ListItem } from "@mui/material"; import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; import { useDispatch, useSelector } from "react-redux"; import { AppState } from "../../../../../../../../shell/store/types"; @@ -214,45 +214,49 @@ export const ItemParent = ({ onChange }: ItemParentProps) => { }, []); return ( - - } - renderOption={(props, value) => ( - - {value.text} - - )} - getOptionLabel={(option) => option.text} - onInputChange={(_, filterTerm) => { - if (filterTerm !== "/") { - setIsLoadingOptions(!!filterTerm); - handleSearchOptions(filterTerm); - } + + { - // Always default to homepage when no parent is selected - setSelectedParent(value !== null ? value : { text: "/", value: "0" }); - onChange(value !== null ? value.value : "0", "parentZUID"); - }} - loading={isLoadingOptions} - sx={{ - "& .MuiOutlinedInput-root": { - padding: "2px", - }, - }} - /> - + customTooltip="Set what page, this content item's page will be nested under. This impacts automatically generated navigation and the URL structure for this page." + withInteractiveTooltip={false} + errors={{}} + > + } + renderOption={(props, value) => ( + + {value.text} + + )} + getOptionLabel={(option) => option.text} + onInputChange={(_, filterTerm) => { + if (filterTerm !== "/") { + setIsLoadingOptions(!!filterTerm); + handleSearchOptions(filterTerm); + } + }} + onChange={(_, value) => { + // Always default to homepage when no parent is selected + setSelectedParent( + value !== null ? value : { text: "/", value: "0" } + ); + onChange(value !== null ? value.value : "0", "parentZUID"); + }} + loading={isLoadingOptions} + sx={{ + "& .MuiOutlinedInput-root": { + padding: "2px", + }, + }} + /> + + ); }; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx index b27e2a4423..806649a69c 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx @@ -4,6 +4,7 @@ import { InputAdornment, CircularProgress, Typography, + Box, } from "@mui/material"; import { useDispatch, useSelector } from "react-redux"; import { useLocation, useParams } from "react-router"; @@ -133,40 +134,42 @@ export const ItemRoute = ({ }, [item?.web]); return ( - - - ), + + - {domain} - {parent ? parent.web?.path + pathPart : `/${pathPart}`} - - ) - } - error={hasErrors(error)} - /> - + customTooltip="Also known as a URL slug, it is the last part of the URL address that serves as a unique identifier of the page. They must be unique within your instance, lowercased, and cannot contain non alphanumeric characters. This helps ensure you create SEO friendly structured and crawlabale URLs." + withInteractiveTooltip={false} + errors={error} + > + + ), + }} + helperText={ + !!pathPart && + isUnique && ( + + {domain} + {parent ? parent.web?.path + pathPart : `/${pathPart}`} + + ) + } + error={hasErrors(error)} + /> + + ); }; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx index 48da1becb8..bbf389656f 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx @@ -59,7 +59,7 @@ export default connect()(function MetaDescription({ } return ( - + onChange(evt.target.value, "metaDescription")} multiline - rows={6} + rows={3} error={hasErrors(error) || !!contentValidationError} /> diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaImage.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaImage.tsx index 3bdbae2e20..40f5ee4817 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaImage.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaImage.tsx @@ -12,15 +12,14 @@ import { useGetContentModelFieldsQuery, useUndeleteContentModelFieldMutation, } from "../../../../../../../../shell/services/instance"; -import { fetchItem } from "../../../../../../../../shell/store/content"; import { FieldTypeMedia, MediaItem, } from "../../../../components/FieldTypeMedia"; import { MediaApp } from "../../../../../../../media/src/app"; import { useLazyGetFileQuery } from "../../../../../../../../shell/services/mediaManager"; -import { isIS } from "@mui/x-date-pickers-pro"; import { fileExtension } from "../../../../../../../media/src/app/utils/fileUtils"; +import { fetchFields } from "../../../../../../../../shell/store/fields"; type MetaImageProps = { onChange: (value: string, name: string) => void; @@ -90,7 +89,10 @@ export const MetaImage = ({ onChange }: MetaImageProps) => { }, [modelFields, item?.data]); useEffect(() => { - if (!contentImages?.length) return; + if (!contentImages?.length) { + setTemporaryMetaImageURL(null); + return; + } let validImages = contentImages.map(async (value) => { const isZestyMediaFile = value.startsWith("3-"); @@ -114,7 +116,7 @@ export const MetaImage = ({ onChange }: MetaImageProps) => { Promise.all(validImages).then((data) => { setTemporaryMetaImageURL(data?.[0]); }); - }, [contentImages, temporaryMetaImageURL]); + }, [JSON.stringify(contentImages), temporaryMetaImageURL]); const handleCreateOgImageField = () => { const existingOgImageField = modelFields?.find( @@ -160,6 +162,7 @@ export const MetaImage = ({ onChange }: MetaImageProps) => { ) { // Initiate the empty og_image field onChange(null, "og_image"); + dispatch(fetchFields(modelZUID)); } }, [ isOgImageFieldCreated, @@ -203,7 +206,13 @@ export const MetaImage = ({ onChange }: MetaImageProps) => { openMediaBrowser={(opts) => { setImageModal(opts); }} - onChange={onChange} + onChange={(value: string, name: string) => { + if (!value) { + setShowOGImageField(false); + } + + onChange(value, name); + }} lockedToGroupId={null} settings={{ fileExtensions: [ diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaKeywords.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaKeywords.tsx index 85856fefb1..6aa050b170 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaKeywords.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaKeywords.tsx @@ -17,7 +17,7 @@ export const MetaKeywords = memo(function MetaKeywords({ error, }: MetaKeywordsProps) { return ( - + onChange(evt.target.value, "metaKeywords")} /> diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx index 02de2c9ed9..1fbd3671f0 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx @@ -18,7 +18,7 @@ export const MetaTitle = memo(function MetaTitle({ error, }: MetaTitleProps) { return ( - + { return ( - + onChange(evt.target.value, "og_description")} error={hasErrors(error)} /> diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGTitle.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGTitle.tsx index ede6d3ee32..a5935a0024 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGTitle.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGTitle.tsx @@ -14,7 +14,7 @@ type OGTitleProps = { }; export const OGTitle = ({ value, onChange, error, field }: OGTitleProps) => { return ( - + @@ -15,29 +66,22 @@ export const SitemapPriority = memo(function SitemapPriority(props) { customTooltip="Sitemap priority helps search engines understand how often they should crawl the pages on your site." withInteractiveTooltip={false} > - + renderInput={(params) => } + onChange={(_, value) => { + props.onChange(value ? value.value : -1.0, "sitemapPriority"); + }} + />
); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCDescription.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCDescription.tsx index 02a821cd9b..5084e76406 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCDescription.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCDescription.tsx @@ -19,7 +19,7 @@ export const TCDescription = ({ field, }: TCDescriptionProps) => { return ( - + onChange(evt.target.value, "tc_description")} error={hasErrors(error)} /> diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCTitle.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCTitle.tsx index 34c6cb118c..c649dd9ea4 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCTitle.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCTitle.tsx @@ -14,7 +14,7 @@ type TCTitleProps = { }; export const TCTitle = ({ value, onChange, error, field }: TCTitleProps) => { return ( - + void; + avatarSx?: SxProps; } export const InstanceAvatar: FC = ({ canUpdateAvatar = true, onFaviconModalOpen, + avatarSx, }) => { const ui = useSelector((state: AppState) => state.ui); const dispatch = useDispatch(); @@ -65,6 +67,7 @@ export const InstanceAvatar: FC = ({ height: 32, width: 32, backgroundColor: faviconURL ? "common.white" : "info.main", + ...avatarSx, }} > {(!faviconURL && instance?.name[0]?.toUpperCase()) || "A"} From 104efea6ab533d768537d742a2db543e3239e4d6 Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Tue, 24 Sep 2024 23:56:44 +0800 Subject: [PATCH 37/44] [Content] SEO tab VQA 2 updates (#2965) Resolves vqa comments: - https://github.com/zesty-io/manager-ui/pull/2933#issuecomment-2367123416 - https://github.com/zesty-io/manager-ui/pull/2933#issuecomment-2368331892 --- package-lock.json | 14 +- package.json | 2 +- .../src/app/components/Editor/Editor.js | 16 +- .../src/app/components/Editor/FieldError.tsx | 212 ++++++++++-------- .../PendingEditsModal/PendingEditsModal.tsx | 1 + .../src/app/views/ItemCreate/ItemCreate.tsx | 30 ++- .../src/app/views/ItemEdit/Content/Content.js | 11 +- .../src/app/views/ItemEdit/ItemEdit.js | 23 +- .../Meta/ContentInsights/WordCount.tsx | 1 - .../Meta/SocialMediaPreview/GooglePreview.tsx | 42 ++-- .../src/app/views/ItemEdit/Meta/index.tsx | 38 +++- .../Meta/settings/MetaDescription.tsx | 51 +---- .../views/ItemEdit/Meta/settings/OGTitle.tsx | 2 +- .../views/ItemEdit/Meta/settings/TCTitle.tsx | 2 +- .../app/views/ItemEdit/Meta/settings/util.ts | 26 +++ .../ItemEditHeader/ItemEditHeaderActions.tsx | 6 +- 16 files changed, 274 insertions(+), 203 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4435c8d046..e54452f580 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,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.3", + "@zesty-io/material": "^0.15.4", "chart.js": "^3.8.0", "chartjs-adapter-moment": "^1.0.1", "chartjs-plugin-datalabels": "^2.0.0", @@ -3911,9 +3911,9 @@ } }, "node_modules/@zesty-io/material": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.3.tgz", - "integrity": "sha512-zJOagsYexWOq1Bw+L0X1nSKTL+aZkB7MZPCqW53uqAwnKMZ3mejpsOjJwvzoYtm+6oJ6RHQJu0xmNbPXCcjLKA==", + "version": "0.15.4", + "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.4.tgz", + "integrity": "sha512-+0QOEmd4B/JIEz1YSk0fE0v0Fl+Siyc2kwUYQJCz1ZATqBHG/INyUxz/kOXdStQFFOeGlElJQDvRYDc1IcDdmw==", "dependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", @@ -18396,9 +18396,9 @@ } }, "@zesty-io/material": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.3.tgz", - "integrity": "sha512-zJOagsYexWOq1Bw+L0X1nSKTL+aZkB7MZPCqW53uqAwnKMZ3mejpsOjJwvzoYtm+6oJ6RHQJu0xmNbPXCcjLKA==", + "version": "0.15.4", + "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.4.tgz", + "integrity": "sha512-+0QOEmd4B/JIEz1YSk0fE0v0Fl+Siyc2kwUYQJCz1ZATqBHG/INyUxz/kOXdStQFFOeGlElJQDvRYDc1IcDdmw==", "requires": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", diff --git a/package.json b/package.json index 0a1397272c..3188010ab3 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.3", + "@zesty-io/material": "^0.15.4", "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 8cb0e51986..085d999727 100644 --- a/src/apps/content-editor/src/app/components/Editor/Editor.js +++ b/src/apps/content-editor/src/app/components/Editor/Editor.js @@ -275,11 +275,21 @@ export default memo(function Editor({ } if (firstContentField && firstContentField.name === name) { + // Remove tags and replace MS smart quotes with regular quotes + const cleanedValue = value + ?.replace(/<[^>]*>/g, "") + ?.replaceAll(/[\u2018\u2019\u201A]/gm, "'") + ?.replaceAll("’", "'") + ?.replaceAll(/[\u201C\u201D\u201E]/gm, '"') + ?.replaceAll("“", '"') + ?.replaceAll("”", '"') + ?.slice(0, 160); + dispatch({ type: "SET_ITEM_WEB", itemZUID, key: "metaDescription", - value: value.replace(/<[^>]*>/g, "").slice(0, 160), + value: cleanedValue, }); if ("og_description" in metaFields) { @@ -287,7 +297,7 @@ export default memo(function Editor({ type: "SET_ITEM_DATA", itemZUID, key: "og_description", - value: value.replace(/<[^>]*>/g, "").slice(0, 160), + value: cleanedValue, }); } @@ -296,7 +306,7 @@ export default memo(function Editor({ type: "SET_ITEM_DATA", itemZUID, key: "tc_description", - value: value.replace(/<[^>]*>/g, "").slice(0, 160), + value: cleanedValue, }); } } 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 dd57ebd61a..dfb8a3b700 100644 --- a/src/apps/content-editor/src/app/components/Editor/FieldError.tsx +++ b/src/apps/content-editor/src/app/components/Editor/FieldError.tsx @@ -1,4 +1,10 @@ -import { useMemo, useRef, useEffect } from "react"; +import { + useMemo, + useRef, + useEffect, + forwardRef, + useImperativeHandle, +} from "react"; import { Stack, Typography, Box, ThemeProvider } from "@mui/material"; import DangerousRoundedIcon from "@mui/icons-material/DangerousRounded"; import { theme } from "@zesty-io/material"; @@ -64,97 +70,115 @@ const getErrorMessage = (errors: Error) => { return errorMessages; }; -export const FieldError = ({ errors, fields }: FieldErrorProps) => { - const errorContainerEl = useRef(null); - - // Scroll to the errors on mount - useEffect(() => { - errorContainerEl?.current?.scrollIntoView({ - behavior: "smooth", - block: "center", - inline: "center", - }); - }, []); - - const fieldErrors = useMemo(() => { - const errorMap = Object.entries(errors)?.map(([name, errorDetails]) => { - const errorMessages = getErrorMessage(errorDetails); - - const fieldData = fields?.find((field) => field.name === name); - - return { - label: - fieldData?.label || - SEO_FIELD_LABELS[name as keyof typeof SEO_FIELD_LABELS], - errorMessages, - sort: fieldData?.sort, - ZUID: fieldData?.ZUID || name, - }; - }); - - return errorMap.sort((a, b) => a.sort - b.sort); - }, [errors, fields]); - - const fieldsWithErrors = fieldErrors?.filter( - (error) => error.errorMessages.length > 0 - ); - - const handleErrorClick = (fieldZUID: string) => { - const fieldElement = document.getElementById(fieldZUID); - fieldElement?.scrollIntoView({ behavior: "smooth" }); - }; - - return ( - - - - - Item cannot be saved due to invalid field values. - - - Please correct the following {fieldsWithErrors?.length} field - {fieldsWithErrors?.length > 1 && "s"} before saving: - - - {fieldErrors?.map((error, index) => { - if (error.errorMessages.length > 0) { - return ( - - handleErrorClick(error.ZUID)} - > - {error.label} - - {error.errorMessages.length === 1 ? ( - - {error.errorMessages[0]} - ) : ( - - {error.errorMessages.map((msg, idx) => ( -
  • {msg}
  • - ))} +export const FieldError = forwardRef( + ({ errors, fields }: FieldErrorProps, ref) => { + const errorContainerEl = useRef(null); + + useImperativeHandle( + ref, + () => { + return { + scrollToErrors() { + errorContainerEl?.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "center", + }); + }, + }; + }, + [errorContainerEl] + ); + + // Scroll to the errors on mount + useEffect(() => { + errorContainerEl?.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "center", + }); + }, []); + + const fieldErrors = useMemo(() => { + const errorMap = Object.entries(errors)?.map(([name, errorDetails]) => { + const errorMessages = getErrorMessage(errorDetails); + + const fieldData = fields?.find((field) => field.name === name); + + return { + label: + fieldData?.label || + SEO_FIELD_LABELS[name as keyof typeof SEO_FIELD_LABELS], + errorMessages, + sort: fieldData?.sort, + ZUID: fieldData?.ZUID || name, + }; + }); + + return errorMap.sort((a, b) => a.sort - b.sort); + }, [errors, fields]); + + const fieldsWithErrors = fieldErrors?.filter( + (error) => error.errorMessages.length > 0 + ); + + const handleErrorClick = (fieldZUID: string) => { + const fieldElement = document.getElementById(fieldZUID); + fieldElement?.scrollIntoView({ behavior: "smooth" }); + }; + + return ( + + + + + Item cannot be saved due to invalid field values. + + + Please correct the following {fieldsWithErrors?.length} field + {fieldsWithErrors?.length > 1 && "s"} before saving: + + + {fieldErrors?.map((error, index) => { + if (error.errorMessages.length > 0) { + return ( + + handleErrorClick(error.ZUID)} + > + {error.label} - )} - - ); - } - })} - - - - ); -}; + {error.errorMessages.length === 1 ? ( + - {error.errorMessages[0]} + ) : ( + + {error.errorMessages.map((msg, idx) => ( +
  • {msg}
  • + ))} +
    + )} +
    + ); + } + })} +
    +
    +
    + ); + } +); diff --git a/src/apps/content-editor/src/app/components/PendingEditsModal/PendingEditsModal.tsx b/src/apps/content-editor/src/app/components/PendingEditsModal/PendingEditsModal.tsx index d0d789e270..f55257c2c4 100644 --- a/src/apps/content-editor/src/app/components/PendingEditsModal/PendingEditsModal.tsx +++ b/src/apps/content-editor/src/app/components/PendingEditsModal/PendingEditsModal.tsx @@ -51,6 +51,7 @@ export default memo(function PendingEditsModal(props: PendingEditsModalProps) { answer(true); }) .catch((err) => { + console.error(err); // @ts-ignore answer(false); }) 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 2f1e47da01..cccc677394 100644 --- a/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx +++ b/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx @@ -84,6 +84,7 @@ export const ItemCreate = () => { // const [hasSEOErrors, setHasSEOErrors] = useState(false); const [SEOErrors, setSEOErrors] = useState({}); const metaRef = useRef(null); + const fieldErrorRef = useRef(null); const [ createPublishing, @@ -182,7 +183,10 @@ export const ItemCreate = () => { setSaveClicked(true); metaRef.current?.validateMetaFields?.(); - if (hasErrors || hasSEOErrors) return; + if (hasErrors || hasSEOErrors) { + fieldErrorRef.current?.scrollToErrors?.(); + return; + } setSaving(true); @@ -266,6 +270,7 @@ export const ItemCreate = () => { setFieldErrors(errors); // scroll to required field + fieldErrorRef.current?.scrollToErrors?.(); } if (res.error) { @@ -382,12 +387,15 @@ export const ItemCreate = () => { direction="row" gap={4} > - + {saveClicked && (hasErrors || hasSEOErrors) && ( - + + + )} { /> - - + + {model?.type !== "dataset" && } diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Content/Content.js b/src/apps/content-editor/src/app/views/ItemEdit/Content/Content.js index fea87575c6..7af50192b2 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Content/Content.js +++ b/src/apps/content-editor/src/app/views/ItemEdit/Content/Content.js @@ -73,10 +73,13 @@ export default function Content(props) { > {props.saveClicked && props.hasErrors && ( - + + + )} state.content[itemZUID]); const items = useSelector((state) => state.content); const model = useSelector((state) => state.models[modelZUID]); @@ -292,11 +293,17 @@ export default function ItemEdit() { async function save() { setSaveClicked(true); - if (hasErrors || hasSEOErrors || metaRef.current?.validateMetaFields?.()) - return; - - setSaving(true); try { + if ( + hasErrors || + hasSEOErrors || + metaRef.current?.validateMetaFields?.() + ) { + throw new Error(`Cannot Save: ${item.web.metaTitle}`); + } + + setSaving(true); + // Skip content item fields validation when in the meta tab since this // means that the user only wants to update the meta fields const res = await dispatch( @@ -397,12 +404,12 @@ export default function ItemEdit() { // fetch new draft history dispatch(fetchAuditTrailDrafting(itemZUID)); } catch (err) { - console.error(err); // we need to set the item to dirty again because the save failed dispatch({ type: "MARK_ITEM_DIRTY", itemZUID, }); + fieldErrorRef.current?.scrollToErrors?.(); throw new Error(err); } finally { if (isMounted.current) { @@ -473,7 +480,7 @@ export default function ItemEdit() { sx={{ display: "flex", flexDirection: "column", height: "100%" }} > save().catch((err) => console.error(err))} saving={saving} hasError={Object.keys(fieldErrors)?.length} headerTitle={headerTitle} @@ -516,6 +523,7 @@ export default function ItemEdit() { saveClicked && hasSEOErrors && ( @@ -585,7 +593,7 @@ export default function ItemEdit() { item={item} items={items} user={user} - onSave={save} + onSave={() => save().catch((err) => console.error(err))} dispatch={dispatch} loading={loading} saving={saving} @@ -596,6 +604,7 @@ export default function ItemEdit() { fieldErrors={fieldErrors} hasErrors={hasErrors} activeFields={activeFields} + fieldErrorRef={fieldErrorRef} /> )} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx index 4478d5b120..509527f0f3 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx @@ -13,7 +13,6 @@ export const WordCount = ({ return ( { {instance?.name} - - + {fullPathArray.map((path, index) => ( - - {path} - + {path} {index < fullPathArray?.length - 1 && ( - + + › + )} ))} - + theme.palette.text.secondary, }} /> - + - + {!!errorComponent && errorComponent} @@ -372,8 +393,9 @@ export const Meta = forwardRef( /> - {model?.type !== "dataset" && !isCreateItemPage && ( + {!isCreateItemPage && ( - - - + + {model?.type !== "dataset" && ( + <> + + + + )} + + )} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx index bbf389656f..31f1007ec5 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx @@ -18,46 +18,6 @@ export default connect()(function MetaDescription({ onChange, error, }: MetaDescriptionProps) { - const dispatch = useDispatch(); - const [contentValidationError, setContentValidationError] = useState(""); - - useEffect(() => { - if (value) { - let message = ""; - - if (!(value.indexOf("\u0152") === -1)) { - message = - "Found OE ligature. These special characters are not allowed in meta descriptions."; - } else if (!(value.indexOf("\u0153") === -1)) { - message = - "Found oe ligature. These special characters are not allowed in meta descriptions."; - } else if (!(value.indexOf("\xAB") === -1)) { - message = - "Found << character. These special characters are not allowed in meta descriptions."; - } else if (!(value.indexOf("\xBB") === -1)) { - message = - "Found >> character. These special characters are not allowed in meta descriptions."; - } else if (/[\u201C\u201D\u201E]/.test(value)) { - message = - "Found Microsoft smart double quotes and apostrophe. These special characters are not allowed in meta descriptions."; - } else if (/[\u2018\u2019\u201A]/.test(value)) { - message = - "Found Microsoft Smart single quotes and apostrophe. These special characters are not allowed in meta descriptions."; - } - - setContentValidationError(message); - } - }, [value]); - - if (contentValidationError) { - dispatch( - notify({ - kind: "warn", - message: contentValidationError, - }) - ); - } - return ( onChange(evt.target.value, "metaDescription")} multiline rows={3} - error={hasErrors(error) || !!contentValidationError} + error={hasErrors(error)} /> diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGTitle.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGTitle.tsx index a5935a0024..fb28d0323f 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGTitle.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGTitle.tsx @@ -18,7 +18,7 @@ export const OGTitle = ({ value, onChange, error, field }: OGTitleProps) => { { { return Object.values(errors).some((error) => !!error); }; + +export const validateMetaDescription = (value: string) => { + let message = ""; + + if (!(value.indexOf("\u0152") === -1)) { + message = + "Found OE ligature. These special characters are not allowed in meta descriptions."; + } else if (!(value.indexOf("\u0153") === -1)) { + message = + "Found oe ligature. These special characters are not allowed in meta descriptions."; + } else if (!(value.indexOf("\xAB") === -1)) { + message = + "Found << character. These special characters are not allowed in meta descriptions."; + } else if (!(value.indexOf("\xBB") === -1)) { + message = + "Found >> character. These special characters are not allowed in meta descriptions."; + } else if (/[\u201C\u201D\u201E]/.test(value)) { + message = + "Found Microsoft Smart double quotes and/or apostrophe. These special characters are not allowed in meta descriptions as it may lead to incorrect rendering, where characters show up as � or other odd symbols. Please use straight quotes (' and \") instead."; + } else if (/[\u2018\u2019\u201A]/.test(value)) { + message = + "Found Microsoft Smart single quotes and/or apostrophe. These special characters are not allowed in meta descriptions as it may lead to incorrect rendering, where characters show up as � or other odd symbols. Please use straight quotes (' and \") instead."; + } + + return message; +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index c42ce7fd7f..07c43a29fd 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -229,7 +229,7 @@ export const ItemEditHeaderActions = ({ onClick={() => { onSave(); }} - loading={saving && !publishAfterSave} + loading={saving} disabled={!canUpdate} id="SaveItemButton" > @@ -314,7 +314,7 @@ export const ItemEditHeaderActions = ({ setIsConfirmPublishModalOpen(true); } }} - loading={publishing || publishAfterSave || isFetching} + loading={publishing || saving || isFetching} color="success" variant="contained" id="PublishButton" @@ -332,7 +332,7 @@ export const ItemEditHeaderActions = ({ onClick={(e) => { setPublishMenu(e.currentTarget); }} - disabled={publishing || publishAfterSave || isFetching} + disabled={publishing || saving || isFetching} data-cy="PublishMenuButton" > From 088c5c81711206380bfd07a9e5334e7b944f868c Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Wed, 25 Sep 2024 08:25:16 +0800 Subject: [PATCH 38/44] [Chore] Bump material version (#2970) [Chore] Bump material version --- package-lock.json | 14 +++++++------- package.json | 2 +- .../src/app/views/ItemEdit/Meta/index.tsx | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index e54452f580..7e2341f4d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,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.4", + "@zesty-io/material": "^0.15.5", "chart.js": "^3.8.0", "chartjs-adapter-moment": "^1.0.1", "chartjs-plugin-datalabels": "^2.0.0", @@ -3911,9 +3911,9 @@ } }, "node_modules/@zesty-io/material": { - "version": "0.15.4", - "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.4.tgz", - "integrity": "sha512-+0QOEmd4B/JIEz1YSk0fE0v0Fl+Siyc2kwUYQJCz1ZATqBHG/INyUxz/kOXdStQFFOeGlElJQDvRYDc1IcDdmw==", + "version": "0.15.5", + "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.5.tgz", + "integrity": "sha512-/xSfR3FjmAW9wKSJthXaxyekkysl6i7naF19PP5XIycxEQWMLN8BT7Cvy/ihi99m7anLl8sNzswax4daHTRHkA==", "dependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", @@ -18396,9 +18396,9 @@ } }, "@zesty-io/material": { - "version": "0.15.4", - "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.4.tgz", - "integrity": "sha512-+0QOEmd4B/JIEz1YSk0fE0v0Fl+Siyc2kwUYQJCz1ZATqBHG/INyUxz/kOXdStQFFOeGlElJQDvRYDc1IcDdmw==", + "version": "0.15.5", + "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.5.tgz", + "integrity": "sha512-/xSfR3FjmAW9wKSJthXaxyekkysl6i7naF19PP5XIycxEQWMLN8BT7Cvy/ihi99m7anLl8sNzswax4daHTRHkA==", "requires": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", diff --git a/package.json b/package.json index 3188010ab3..a7c32655a8 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.4", + "@zesty-io/material": "^0.15.5", "chart.js": "^3.8.0", "chartjs-adapter-moment": "^1.0.1", "chartjs-plugin-datalabels": "^2.0.0", diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx index 812b760c83..d3924bbb30 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx @@ -263,13 +263,13 @@ export const Meta = forwardRef( gap={4} bgcolor="grey.50" pt={2.5} + mb={isCreateItemPage ? 4 : 0} px={isCreateItemPage ? 0 : 4} color="text.primary" sx={{ scrollbarWidth: "none", overflowY: "auto", }} - height="100%" > From 3fa1041490ef69af3d5507f8db5c4162422f7ebe Mon Sep 17 00:00:00 2001 From: Andres Galindo Date: Wed, 25 Sep 2024 15:14:32 -0700 Subject: [PATCH 39/44] Filter out fields that are not set as listed (#2954) Co-authored-by: Stuart Runyan --- .../content-editor/src/app/views/ItemList/ItemListTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx index 08f89f3277..377fa7dc30 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx @@ -306,7 +306,7 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { result = [ ...result, ...fields - ?.filter((field) => !field.deletedAt) + ?.filter((field) => !field.deletedAt && field?.settings?.list) ?.map((field) => ({ field: field.name, headerName: field.label, From f7064e2e802c4fd4a907a4f9c37d2ebc721cfe2b Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Fri, 27 Sep 2024 07:19:21 +0800 Subject: [PATCH 40/44] [Content] AI assistant update for SEO and content item fields (#2960) - Updated UI for the AI assistant - Added AI assistant buttons for certain SEO fields - Added AI-assisted SEO metadata flow ### Dependencies Requires the following material-ui theme changes: https://github.com/zesty-io/material/pull/107 Requires the following backend change: https://github.com/zesty-io/gcp-cf/pull/170 ### Preview SEO ![image](https://github.com/user-attachments/assets/978d2f17-3609-40c4-8720-3ddf8cd64f03) ![image](https://github.com/user-attachments/assets/d4e46a27-f4c9-43cf-8379-1f54cedb1beb) Content item ![image](https://github.com/user-attachments/assets/6ec0fa3d-33cd-4063-afcd-0b162dc0b675) ![image](https://github.com/user-attachments/assets/90553cad-ea17-45be-b80a-fa984248c378) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Andres Galindo Co-authored-by: Stuart Runyan Co-authored-by: Allen Pigar <50983144+allenpigar@users.noreply.github.com> --- cypress/e2e/content/actions.spec.js | 63 ++ public/images/openai-badge.svg | 13 + .../src/app/components/Editor/Field/Field.tsx | 10 +- .../src/app/views/ItemCreate/ItemCreate.tsx | 49 +- .../Meta/AIGeneratorParameterProvider.tsx | 31 + .../src/app/views/ItemEdit/Meta/index.tsx | 186 ++++- .../Meta/settings/MetaDescription.tsx | 25 +- .../ItemEdit/Meta/settings/MetaTitle.tsx | 29 +- src/shell/components/withAi/AIGenerator.tsx | 758 +++++++++++++++--- src/shell/components/withAi/index.tsx | 295 ++++--- 10 files changed, 1201 insertions(+), 258 deletions(-) create mode 100644 public/images/openai-badge.svg create mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/AIGeneratorParameterProvider.tsx diff --git a/cypress/e2e/content/actions.spec.js b/cypress/e2e/content/actions.spec.js index 14e5e86dbb..49e151c509 100644 --- a/cypress/e2e/content/actions.spec.js +++ b/cypress/e2e/content/actions.spec.js @@ -213,6 +213,9 @@ describe("Actions in content editor", () => { }); cy.get("input[name=title]", { timeout: 5000 }).click().type(timestamp); + + cy.getBySelector("ManualMetaFlow").click(); + cy.getBySelector("metaDescription") .find("textarea") .first() @@ -271,4 +274,64 @@ describe("Actions in content editor", () => { // }).should("exist"); // // cy.contains("The item has been purged from the CDN cache", { timeout: 5000 }).should("exist"); // }); + + it("Creates a new content item using AI-generated data", () => { + cy.waitOn("/v1/content/models*", () => { + cy.waitOn("/v1/content/models/*/fields?showDeleted=true", () => { + cy.visit("/content/6-a1a600-k0b6f0/new"); + }); + }); + + cy.intercept("/ai").as("ai"); + cy.wait(5000); + + // Generate AI content for single line text + cy.get("#12-0c3934-8dz720").find("[data-cy='AIOpen']").click(); + cy.getBySelector("AITopicField").type("biking"); + cy.getBySelector("AIAudienceField").type("young adults"); + cy.getBySelector("AIGenerate").click(); + + cy.wait("@ai"); + + cy.getBySelector("AIApprove").click(); + + // Generate AI content for wysiwyg + cy.get("#12-717920-6z46t7").find("[data-cy='AIOpen']").click(); + cy.getBySelector("AITopicField").type("biking"); + cy.getBySelector("AIAudienceField").type("young adults"); + cy.getBySelector("AIGenerate").click(); + + cy.wait("@ai"); + + cy.getBySelector("AIApprove").click(); + + // Select AI-assisted metadata generation flow + cy.getBySelector("ManualMetaFlow").click(); + + // Generate AI content for meta title + cy.getBySelector("metaTitle").find("input").clear(); + cy.getBySelector("metaTitle").find("[data-cy='AIOpen']").click(); + cy.getBySelector("AIGenerate").click(); + + cy.wait("@ai"); + + cy.getBySelector("AISuggestion1").click(); + cy.getBySelector("AIApprove").click(); + + // Generate AI content for meta description + cy.getBySelector("metaDescription") + .find("textarea[name='metaDescription']") + .clear({ force: true }); + cy.getBySelector("metaDescription").find("[data-cy='AIOpen']").click(); + cy.getBySelector("AIGenerate").click(); + + cy.wait("@ai"); + + cy.getBySelector("AISuggestion1").click(); + cy.getBySelector("AIApprove").click(); + + cy.getBySelector("CreateItemSaveButton").click(); + + cy.contains("Created Item", { timeout: 5000 }).should("exist"); + }); }); diff --git a/public/images/openai-badge.svg b/public/images/openai-badge.svg new file mode 100644 index 0000000000..cc72d9c62e --- /dev/null +++ b/public/images/openai-badge.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + 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 64c2e47f12..460793a4a8 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 @@ -294,6 +294,7 @@ export const Field = ({ minLength={minLength} errors={errors} aiType="text" + value={value} > setEditorType(value)} + value={value} > { } message="Creating New Item" > - +
    { setFieldErrors(errors); }} /> - { setSEOErrors(errors); @@ -440,11 +434,44 @@ export const ItemCreate = () => { width="40%" maxWidth={620} > - {model?.type !== "dataset" && } + {model?.type !== "dataset" && ( + <> + + + + )} - + {isScheduleDialogOpen && !isLoadingNewItem && ( , + Dispatch | null> +]; +export const AIGeneratorParameterContext = + createContext([{}, () => {}]); + +type AIGeneratorParameterProviderProps = { + children?: React.ReactNode; +}; + +// This context provider is used to temporarily store the ai parameters +// used when generating a meta title is then used to prefill the +// meta description ai parameters during the AI-assisted metadata flow +export const AIGeneratorParameterProvider = ({ + children, +}: AIGeneratorParameterProviderProps) => { + const [AIGeneratorParameters, updateAIGeneratorParameters] = useState | null>(null); + return ( + + {children} + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx index d3924bbb30..f35b68d99c 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx @@ -5,11 +5,23 @@ import { useEffect, forwardRef, useImperativeHandle, + useRef, } from "react"; -import { Stack, Box, Typography, ThemeProvider, Divider } from "@mui/material"; -import { theme } from "@zesty-io/material"; +import { + Stack, + Box, + Typography, + ThemeProvider, + Divider, + ListItemIcon, + ListItemText, + ListItemButton, +} from "@mui/material"; +import { Brain, theme } from "@zesty-io/material"; import { useParams, useLocation } from "react-router"; import { useSelector, useDispatch } from "react-redux"; +import { keyframes } from "@mui/system"; +import { EditRounded } from "@mui/icons-material"; import { cloneDeep } from "lodash"; import { ContentInsights } from "./ContentInsights"; @@ -20,6 +32,7 @@ import { import { AppState } from "../../../../../../../shell/store/types"; import { Error } from "../../../components/Editor/Field/FieldShell"; import { fetchGlobalItem } from "../../../../../../../shell/store/content"; +import { AIGeneratorParameterProvider } from "./AIGeneratorParameterProvider"; import { ContentModelField, Web, @@ -43,6 +56,34 @@ import { TCTitle } from "./settings/TCTitle"; import { TCDescription } from "./settings/TCDescription"; import { FieldError } from "../../../components/Editor/FieldError"; +const rotateAnimation = keyframes` + 0% { + background-position: 0% 0%; + } + 100% { + background-position: 0% 100%; + } +`; +const FlowType = { + AIGenerated: "ai-generated", + Manual: "manual", +} as const; +const flowButtons = [ + { + flowType: FlowType.AIGenerated, + icon: , + primaryText: "Yes, improve with AI Meta Data Assistant", + secondaryText: + "Our AI will scan your content and generate your meta data for you", + }, + { + flowType: FlowType.Manual, + icon: , + primaryText: "No, I will improve and edit it myself", + secondaryText: + "Perfect if you already know what you want your Meta Data to be", + }, +]; export const MaxLengths: Record = { metaLinkText: 150, metaTitle: 150, @@ -90,6 +131,10 @@ export const Meta = forwardRef( (state: AppState) => state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] ); + const [flowType, setFlowType] = + useState(null); + const metaDescriptionButtonRef = useRef(null); + const metaTitleButtonRef = useRef(null); // @ts-expect-error untyped const siteName = useMemo(() => dispatch(fetchGlobalItem())?.site_name, []); @@ -234,6 +279,11 @@ export const Meta = forwardRef( } setTimeout(() => { + // Makes sure that the user sees the error blurbs on each + // field when in the create item page + if (isCreateItemPage) { + setFlowType(FlowType.Manual); + } onUpdateSEOErrors(currentErrors); }); @@ -244,6 +294,9 @@ export const Meta = forwardRef( ?.flat() .some((error) => !!error); }, + triggerAIGeneratedFlow() { + setFlowType(FlowType.AIGenerated); + }, }; }, [errors, web, model, metaFields, data] @@ -256,6 +309,93 @@ export const Meta = forwardRef( } }, [isSaving]); + useEffect(() => { + if (!isCreateItemPage) return; + + if (flowType === FlowType.AIGenerated) { + metaTitleButtonRef.current?.triggerAIButton?.(); + } + }, [flowType, isCreateItemPage]); + + if (isCreateItemPage && flowType === null) { + return ( + + + + + + Would you like to improve your Meta Title & Description? + + + Our AI Assistant will scan your content and improve your meta + title and description to help improve search engine + visibility.{" "} + + + {flowButtons.map((data) => ( + setFlowType(data.flowType)} + sx={{ + borderRadius: 2, + border: 1, + borderColor: "border", + backgroundColor: "common.white", + py: 2, + }} + > + {data.icon} + + {data.primaryText} + + } + disableTypography + sx={{ my: 0 }} + secondary={ + + {data.secondaryText} + + } + /> + + ))} + + + + ); + } + return ( - - + + { + if (flowType === FlowType.AIGenerated) { + setFlowType(FlowType.Manual); + } + }} + onAIMetaTitleInserted={() => { + // Scroll to and open the meta description ai generator to continue + // with the AI-assisted flow + if (flowType === FlowType.AIGenerated) { + metaDescriptionButtonRef.current?.triggerAIButton?.(); + } + }} + /> + { + if (flowType === FlowType.AIGenerated) { + setFlowType(FlowType.Manual); + } + }} + /> + {"og_title" in metaFields && ( void; error: Error; + onResetFlowType: () => void; + aiButtonRef?: MutableRefObject; }; export default connect()(function MetaDescription({ value, onChange, error, + onResetFlowType, + aiButtonRef, }: MetaDescriptionProps) { return ( - ) => { + onChange(evt.target.value, "metaDescription"); + onResetFlowType?.(); + }} + onResetFlowType={() => { + onResetFlowType?.(); + }} > - + ); }); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx index 1fbd3671f0..f9819fc304 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { ChangeEvent, memo, MutableRefObject } from "react"; import { TextField, Box } from "@mui/material"; @@ -6,20 +6,32 @@ import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; import { MaxLengths } from ".."; import { hasErrors } from "./util"; import { Error } from "../../../../components/Editor/Field/FieldShell"; +import { withAI } from "../../../../../../../../shell/components/withAi"; + +const AIFieldShell = withAI(FieldShell); type MetaTitleProps = { value: string; onChange: (value: string, name: string) => void; error: Error; + saveMetaTitleParameters?: boolean; + onResetFlowType: () => void; + onAIMetaTitleInserted?: () => void; + aiButtonRef?: MutableRefObject; }; export const MetaTitle = memo(function MetaTitle({ value, onChange, error, + saveMetaTitleParameters, + onResetFlowType, + onAIMetaTitleInserted, + aiButtonRef, }: MetaTitleProps) { return ( - ) => { + onChange(evt.target.value, "metaTitle"); + onAIMetaTitleInserted?.(); + }} + saveMetaTitleParameters={saveMetaTitleParameters} + onResetFlowType={() => { + onResetFlowType?.(); + }} > onChange(evt.target.value, "metaTitle")} error={hasErrors(error)} /> - + ); }); diff --git a/src/shell/components/withAi/AIGenerator.tsx b/src/shell/components/withAi/AIGenerator.tsx index 6306cf650e..9655b19b4e 100644 --- a/src/shell/components/withAi/AIGenerator.tsx +++ b/src/shell/components/withAi/AIGenerator.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useRef, useMemo, useContext } from "react"; import { Button, Box, @@ -10,49 +10,178 @@ import { MenuItem, Autocomplete, CircularProgress, + Stack, + InputAdornment, + Tooltip, + alpha, + ListItemButton, } from "@mui/material"; -import CloseIcon from "@mui/icons-material/Close"; import StopRoundedIcon from "@mui/icons-material/StopRounded"; import CheckRoundedIcon from "@mui/icons-material/CheckRounded"; import RefreshRoundedIcon from "@mui/icons-material/RefreshRounded"; -import { useAiGenerationMutation } from "../../services/cloudFunctions"; -import { useGetLangsMappingQuery } from "../../services/instance"; +import LanguageRoundedIcon from "@mui/icons-material/LanguageRounded"; +import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; import { Brain } from "@zesty-io/material"; -import { useDispatch } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; +import { useLocation, useParams } from "react-router"; + import { notify } from "../../store/notifications"; +import openAIBadge from "../../../../public/images/openai-badge.svg"; +import { FieldTypeNumber } from "../FieldTypeNumber"; +import { useAiGenerationMutation } from "../../services/cloudFunctions"; +import { + useGetContentModelFieldsQuery, + useGetLangsMappingQuery, +} from "../../services/instance"; +import { AppState } from "../../store/types"; +import { AIGeneratorParameterContext } from "../../../apps/content-editor/src/app/views/ItemEdit/Meta/AIGeneratorParameterProvider"; + +const DEFAULT_LIMITS: Record = { + text: 150, + paragraph: 3, + word: 1500, + description: 160, + title: 150, +}; +const TONE_OPTIONS = { + intriguing: "Intriguing - Curious, mysterious, and thought-provoking", + professional: "Professional - Serious, formal, and authoritative", + playful: "Playful - Fun, light-hearted, and whimsical", + sensational: "Sensational - Bold, dramatic, and attention-grabbing", + succint: "Succinct - Clear, factual, with no hyperbole", +}; +// description and title are used for seo meta title & description +type AIType = "text" | "paragraph" | "description" | "title" | "word"; interface Props { onApprove: (data: string) => void; onClose: () => void; - aiType: string; + aiType: AIType; label: string; + saveMetaTitleParameters?: boolean; } -export const AIGenerator = ({ onApprove, onClose, aiType, label }: Props) => { +export const AIGenerator = ({ + onApprove, + onClose, + aiType, + label, + saveMetaTitleParameters, +}: Props) => { const dispatch = useDispatch(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const { modelZUID, itemZUID } = useParams<{ + modelZUID: string; + itemZUID: string; + }>(); + const item = useSelector( + (state: AppState) => + state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] + ); + const { data: fields } = useGetContentModelFieldsQuery(modelZUID, { + skip: !modelZUID, + }); const [topic, setTopic] = useState(""); - const [limit, setLimit] = useState(aiType === "text" ? "150" : "3"); + const [audienceDescription, setAudienceDescription] = useState(""); + const [tone, setTone] = useState("professional"); + const [keywords, setKeywords] = useState(""); + const [limit, setLimit] = useState(DEFAULT_LIMITS[aiType]); + const [selectedContent, setSelectedContent] = useState(null); const request = useRef(null); const [language, setLanguage] = useState({ label: "English (United States)", value: "en-US", }); - const [data, setData] = useState(""); + const [data, setData] = useState([]); + const [parameters, updateParameters] = useContext( + AIGeneratorParameterContext + ); const { data: langMappings } = useGetLangsMappingQuery(); const [aiGenerate, { isLoading, isError, data: aiResponse }] = useAiGenerationMutation(); + const allTextFieldContent = useMemo(() => { + // This is really only needed for seo meta title & description + // so we skip it for other types + if ( + (aiType !== "title" && aiType !== "description") || + !fields?.length || + !Object.keys(item?.data)?.length + ) + return ""; + + const textFieldTypes = [ + "text", + "wysiwyg_basic", + "wysiwyg_advanced", + "article_writer", + "markdown", + "textarea", + ]; + + return fields.reduce((accu, curr) => { + if (!curr.deletedAt && textFieldTypes.includes(curr.datatype)) { + return (accu = `${accu} ${item.data[curr.name] || ""}`); + } + + return accu; + }, ""); + }, [fields, item?.data]); + const handleGenerate = () => { - request.current = aiGenerate({ - type: aiType, - length: limit, - phrase: topic, - lang: language.value, - }); + if (aiType === "description" || aiType === "title") { + request.current = aiGenerate({ + type: aiType, + lang: language.value, + tone, + audience: audienceDescription, + content: allTextFieldContent, + keywords, + }); + } else { + request.current = aiGenerate({ + type: aiType, + length: limit, + phrase: topic, + lang: language.value, + tone, + audience: audienceDescription, + }); + } }; + useEffect(() => { + if (aiType === "title") { + updateParameters?.(null); + } + + if (aiType === "description") { + if (parameters) { + // Auto fill data of the ai generator for the meta description + // using the previously saved parameters used in the meta title. + // This is used during the AI-assisted metadata creation flow. + setTone( + (parameters.tone as keyof typeof TONE_OPTIONS) || "professional" + ); + setAudienceDescription(parameters.audience || ""); + setKeywords(parameters.keywords || ""); + setLanguage( + languageOptions?.find( + (language) => language.value === parameters.lang + ) || { + label: "English (United States)", + value: "en-US", + } + ); + } + + updateParameters?.(null); + } + }, [aiType]); + useEffect(() => { if (isError) { dispatch( @@ -66,7 +195,13 @@ export const AIGenerator = ({ onApprove, onClose, aiType, label }: Props) => { useEffect(() => { if (aiResponse?.data) { - setData(aiResponse.data); + // For description and title, response will be a stringified array + if (aiType === "description" || aiType === "title") { + const responseArr = JSON.parse(aiResponse.data); + setData(responseArr); + } else { + setData([aiResponse.data]); + } } }, [aiResponse]); @@ -77,127 +212,487 @@ export const AIGenerator = ({ onApprove, onClose, aiType, label }: Props) => { }) ); + // Loading if (isLoading) { return ( - - - - + + + + + + + + Generating + {aiType === "title" + ? " Title" + : aiType === "description" + ? " Description" + : " Content"} + + + Our AI assistant is generating your + {aiType === "title" + ? " meta title " + : aiType === "description" + ? " meta description " + : " content "} + based on your parameters + + + + + ); + } + + // Meta Title and Meta Description field types + if (aiType === "title" || aiType === "description") { + return ( + + + + + theme.palette.common.white }} /> + + + + + + {!!data?.length ? "Select" : "Generate"} Meta{" "} + {aiType === "title" ? "Title" : "Description"} + + + {!!data?.length + ? `Select 1 out of the 3 Meta ${ + aiType === "title" ? "Titles" : "Descriptions" + } our AI has generated for you.` + : `Our AI will scan your content and generate your meta ${ + aiType === "title" ? "title" : "description" + } for you based on your parameters set below`} + + + + + {!!data?.length ? ( + + {data.map((value, index) => ( + setSelectedContent(index)} + sx={{ + borderRadius: 2, + border: 1, + borderColor: "border", + backgroundColor: "common.white", + p: 2, + flexDirection: "column", + alignItems: "flex-start", + + "&.Mui-selected": { + borderColor: "primary.main", + }, + }} + > + + OPTION {index + 1} + + + {String(value)} + + + ))} + + ) : ( + + + Describe your Audience + setAudienceDescription(evt.target.value)} + placeholder="e.g. Freelancers, Designers, ....." + fullWidth + /> + + + + Keywords to Include (separated by commas) + + setKeywords(evt.target.value)} + placeholder="e.g. Hikes, snow" + fullWidth + /> + + + + Tone + + + + + + + + + Language + + + + + + option.value === value.value + } + onChange={(event, value) => setLanguage(value)} + value={language as any} + options={languageOptions} + renderInput={(params: any) => ( + + + + ), + }} + /> + )} + slotProps={{ + paper: { + sx: { + maxHeight: 300, + }, + }, + }} + /> + + + )} - - Generating Content - - - + + {!!data?.length ? ( + + + + + ) : ( + + )} + + ); } + // Content item field types return ( - - + `1px solid ${theme.palette.border}`, - }} + p={2.5} + gap={1.5} + bgcolor="background.paper" + borderRadius="2px 2px 0 0" > - - - - {data ? "Your Content is Generated!" : "Generate Content"} + + + theme.palette.common.white }} /> + + + + + + {!!data?.length ? "Your Content is Generated!" : "Generate Content"} - - - - - + + {!!data?.length + ? "Our AI assistant can make mistakes. Please check important info." + : "Use our AI assistant to write content for you"} + + + `1px solid ${theme.palette.border}`, + overflowY: "auto", + borderTop: 1, + borderBottom: 1, + borderColor: "border", }} > - {data ? ( + {!!data?.length ? ( - {label} + Generated Content setData(event.target.value)} + value={data[0]} + onChange={(event) => setData([event.target.value])} multiline - rows={8} + rows={15} fullWidth /> ) : ( - <> - Topic - - Describe what you want the AI to write for you - - setTopic(event.target.value)} - placeholder={`e.g. "Hikes in Washington"`} - multiline - rows={2} - fullWidth - /> - - - {aiType === "text" && ( - <> - Character Limit - setLimit(event.target.value)} - fullWidth - /> - - )} - {aiType === "paragraph" && ( - <> - Paragraph Limit - - - )} + + + Topic + setTopic(event.target.value)} + placeholder={`e.g. Hikes in Washington`} + multiline + rows={3} + fullWidth + /> + + + Describe your Audience + setAudienceDescription(evt.target.value)} + placeholder="e.g. Freelancers, Designers, ....." + fullWidth + /> + + + + Tone + + + + + + + + + + + {aiType === "text" && "Character"} + {aiType === "paragraph" && "Word"} Limit + + + + + + setLimit(value)} + hasError={false} + /> - - Language + + + Language + + + + option.value === value.value } @@ -205,19 +700,44 @@ export const AIGenerator = ({ onApprove, onClose, aiType, label }: Props) => { value={language as any} options={languageOptions} renderInput={(params: any) => ( - + + + + ), + }} + /> )} + slotProps={{ + paper: { + sx: { + maxHeight: 300, + }, + }, + }} /> - - + + )} - + - {data ? ( + {!!data?.length ? ( ) : ( @@ -251,6 +771,6 @@ export const AIGenerator = ({ onApprove, onClose, aiType, label }: Props) => { )} - + ); }; diff --git a/src/shell/components/withAi/index.tsx b/src/shell/components/withAi/index.tsx index ce93f8a519..25318991f6 100644 --- a/src/shell/components/withAi/index.tsx +++ b/src/shell/components/withAi/index.tsx @@ -1,13 +1,24 @@ -import { memo, useMemo } from "react"; -import { Popover, IconButton } from "@mui/material"; +import { useRef, forwardRef, useImperativeHandle } from "react"; +import { Popover, Button, IconButton, alpha } from "@mui/material"; import { Brain, theme } from "@zesty-io/material"; import { ThemeProvider } from "@mui/material/styles"; import { ComponentType, MouseEvent, useState } from "react"; -import { AIGenerator } from "./AIGenerator"; import { useSelector } from "react-redux"; -import { AppState } from "../../store/types"; +import { keyframes } from "@mui/system"; import moment from "moment-timezone"; + +import { AppState } from "../../store/types"; import instanceZUID from "../../../utility/instanceZUID"; +import { AIGenerator } from "./AIGenerator"; + +const rotateAnimation = keyframes` + 0% { + background-position: 0% 0%; + } + 100% { + background-position: 0% 100%; + } +`; // This date is used determine if the AI feature is enabled const enabledDate = "2023-01-13"; @@ -26,109 +37,179 @@ const paragraphFormat = (text: string) => { .join("

    ")}

    `; }; -export const withAI = (WrappedComponent: ComponentType) => (props: any) => { - const instanceCreatedAt = useSelector( - (state: AppState) => state.instance.createdAt - ); - const isEnabled = - moment(instanceCreatedAt).isSameOrAfter(moment(enabledDate)) || - enabledZUIDs.includes(instanceZUID); - const [anchorEl, setAnchorEl] = useState(null); - const [focused, setFocused] = useState(false); - const [key, setKey] = useState(0); - - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - - const handleClose = () => { - setAnchorEl(null); - }; - - const handleApprove = (generatedText: string) => { - if ( - props.datatype === "article_writer" || - props.datatype === "markdown" || - props.datatype === "wysiwyg_advanced" || - props.datatype === "wysiwyg_basic" - ) { - props.onChange( - `${props.value || ""}${ - props.datatype === "markdown" - ? generatedText - : paragraphFormat(generatedText) - }`, - props.name, - props.datatype +export const withAI = (WrappedComponent: ComponentType) => + forwardRef((props: any, ref) => { + const instanceCreatedAt = useSelector( + (state: AppState) => state.instance.createdAt + ); + const isEnabled = + moment(instanceCreatedAt).isSameOrAfter(moment(enabledDate)) || + enabledZUIDs.includes(instanceZUID); + const [anchorEl, setAnchorEl] = useState(null); + const [focused, setFocused] = useState(false); + const [key, setKey] = useState(0); + const aiButtonRef = useRef(null); + + useImperativeHandle( + ref, + () => { + return { + triggerAIButton() { + if (!anchorEl) { + aiButtonRef.current?.scrollIntoView({ behavior: "smooth" }); + + // Makes sure that the popup is placed correctly after + // the scrollIntoView function is ran + setTimeout(() => { + aiButtonRef.current?.click(); + }, 500); + } + }, + }; + }, + [] + ); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + // Reset the meta details flow type + props.onResetFlowType?.(); + setAnchorEl(null); + }; + + const handleApprove = (generatedText: string) => { + if ( + props.datatype === "article_writer" || + props.datatype === "markdown" || + props.datatype === "wysiwyg_advanced" || + props.datatype === "wysiwyg_basic" + ) { + props.onChange( + `${props.value || ""}${ + props.datatype === "markdown" + ? generatedText + : paragraphFormat(generatedText) + }`, + props.name, + props.datatype + ); + // Force re-render after appending generated AI text due to uncontrolled component + setKey(key + 1); + } else { + props.onChange( + { target: { value: `${props.value || ""}${generatedText}` } }, + props.name + ); + } + }; + + if (isEnabled) { + return ( + <> + + +
    + } + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + /> + + + + + + ); - // Force re-render after appending generated AI text due to uncontrolled component - setKey(key + 1); } else { - props.onChange( - { target: { value: `${props.value}${generatedText}` } }, - props.name - ); + return ; } - }; - - if (isEnabled) { - return ( - <> - - - focused - ? "primary.main" - : `${theme.palette.action.active}`, - }, - "svg:hover": { - color: "primary.main", - }, - }} - onClick={(event: MouseEvent) => { - const target = event.target as HTMLElement; - if (target.nodeName === "svg" || target.nodeName === "path") { - handleClick(event); - } - }} - size="xxsmall" - > - - - - } - onFocus={() => setFocused(true)} - onBlur={() => setFocused(false)} - /> - - - - - - - ); - } else { - return ; - } -}; + }); From 1f711168788dc39459dc40a6e300f4e808ff07e2 Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Fri, 27 Sep 2024 14:35:16 +0800 Subject: [PATCH 41/44] [Content] SEO Tab width and card header text update (#2982) Fixes #2974 ### Preview ![Screen Shot 2024-09-27 at 13 53 15](https://github.com/user-attachments/assets/5bc58fbd-dbbe-436c-914f-78c754da20a4) ![Screen Shot 2024-09-27 at 13 53 58](https://github.com/user-attachments/assets/add617b3-65f1-4399-a343-833a6e8c088d) --- .../src/app/views/ItemCreate/ItemCreate.tsx | 10 +++++----- .../views/ItemEdit/Meta/ContentInsights/WordCount.tsx | 2 +- .../src/app/views/ItemEdit/Meta/index.tsx | 11 +++++------ .../src/app/views/ItemEdit/Meta/settings/util.ts | 2 ++ 4 files changed, 13 insertions(+), 12 deletions(-) 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 44661ce419..2a2f8efe35 100644 --- a/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx +++ b/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx @@ -379,15 +379,16 @@ export const ItemCreate = () => { isLoading={saving || isPublishing || isLoadingNewItem} isDirty={item?.dirty} /> - - + {saveClicked && (hasErrors || hasSEOErrors) && ( { position="sticky" top={0} alignSelf="flex-start" - width="40%" maxWidth={620} > {model?.type !== "dataset" && ( @@ -470,7 +470,7 @@ export const ItemCreate = () => { )} - + {isScheduleDialogOpen && !isLoadingNewItem && ( - Non Common Words + Non Filler Words {totalUniqueNonCommonWords} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx index f35b68d99c..083c50c2ca 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx @@ -398,8 +398,9 @@ export const Meta = forwardRef( return ( - - + {!!errorComponent && errorComponent} @@ -557,8 +558,6 @@ export const Meta = forwardRef( {!isCreateItemPage && ( )} - + ); } diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/util.ts b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/util.ts index 10d385d7b0..0aa2242464 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/util.ts +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/util.ts @@ -9,6 +9,8 @@ export const hasErrors = (errors: Error) => { export const validateMetaDescription = (value: string) => { let message = ""; + if (!value) return message; + if (!(value.indexOf("\u0152") === -1)) { message = "Found OE ligature. These special characters are not allowed in meta descriptions."; From 37e0ee5a9274cf327107d6302029fb77353dbd18 Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Tue, 1 Oct 2024 17:05:30 +0800 Subject: [PATCH 42/44] Remove meta desc validation on dataset items (#2988) Resolves #2984 Co-authored-by: Andres --- .../src/app/views/ItemEdit/Meta/index.tsx | 19 ++++++++++++------- .../Meta/settings/MetaDescription.tsx | 4 +++- src/shell/store/content.js | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx index 083c50c2ca..47038b28d7 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx @@ -94,12 +94,6 @@ export const MaxLengths: Record = { tc_title: 150, tc_description: 160, }; -const REQUIRED_FIELDS = [ - "metaTitle", - "metaDescription", - "parentZUID", - "pathPart", -]; export const DYNAMIC_META_FIELD_NAMES = [ "og_title", "og_description", @@ -162,6 +156,16 @@ export const Meta = forwardRef( return {}; }, [fields]); + const REQUIRED_FIELDS = useMemo(() => { + const fields = ["metaTitle", "parentZUID", "pathPart"]; + + if (model?.type !== "dataset") { + fields.push("metaDescription"); + } + + return fields; + }, [model]); + const handleOnChange = useCallback( (value, name) => { if (!name) { @@ -264,7 +268,7 @@ export const Meta = forwardRef( // Validate meta description value const metaDescriptionError = validateMetaDescription( - web.metaDescription + web.metaDescription || "" ); currentErrors.metaDescription = { @@ -455,6 +459,7 @@ export const Meta = forwardRef( setFlowType(FlowType.Manual); } }} + required={REQUIRED_FIELDS.includes("metaDescription")} /> diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx index 6ddaf5aec7..d350544c20 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx @@ -18,6 +18,7 @@ type MetaDescriptionProps = { error: Error; onResetFlowType: () => void; aiButtonRef?: MutableRefObject; + required: boolean; }; export default connect()(function MetaDescription({ value, @@ -25,6 +26,7 @@ export default connect()(function MetaDescription({ error, onResetFlowType, aiButtonRef, + required, }: MetaDescriptionProps) { return ( @@ -32,7 +34,7 @@ export default connect()(function MetaDescription({ ref={aiButtonRef} settings={{ label: "Meta Description", - required: true, + required, }} customTooltip="This description appears as text snippet below the title in search engine and social media previews. The ideal length for a meta description is 50 to 160 characters." withInteractiveTooltip={false} diff --git a/src/shell/store/content.js b/src/shell/store/content.js index 2f5f468d34..401358d1bb 100644 --- a/src/shell/store/content.js +++ b/src/shell/store/content.js @@ -561,7 +561,7 @@ export function createItem({ modelZUID, itemZUID, skipPathPartValidation }) { }); const hasMissingRequiredSEOFields = skipPathPartValidation - ? !item?.web?.metaTitle || !item?.web?.metaDescription + ? !item?.web?.metaTitle : !item?.web?.metaTitle || !item?.web?.metaDescription || !item?.web?.pathPart; From fc4c24d87f47d95498fb92bca13ba4f44aefa6f9 Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Wed, 2 Oct 2024 14:57:42 +0800 Subject: [PATCH 43/44] [Content] Resolve VQA comments for the AI updates (#2990) Resolves comments on https://github.com/zesty-io/manager-ui/pull/2981#issuecomment-2383306431 --- .../src/app/components/Editor/Field/Field.tsx | 4 + .../src/app/views/ItemCreate/ItemCreate.tsx | 59 +-- .../src/app/views/ItemEdit/ItemEdit.js | 94 ++-- .../Meta/AIGeneratorParameterProvider.tsx | 31 -- .../src/app/views/ItemEdit/Meta/index.tsx | 73 ++-- .../Meta/settings/MetaDescription.tsx | 4 + .../ItemEdit/Meta/settings/MetaTitle.tsx | 2 +- src/shell/components/withAi/AIGenerator.tsx | 410 +++++++++++------- .../components/withAi/AIGeneratorProvider.tsx | 68 +++ src/shell/components/withAi/index.tsx | 23 +- 10 files changed, 477 insertions(+), 291 deletions(-) delete mode 100644 src/apps/content-editor/src/app/views/ItemEdit/Meta/AIGeneratorParameterProvider.tsx create mode 100644 src/shell/components/withAi/AIGeneratorProvider.tsx 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 460793a4a8..59ddaff608 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 @@ -282,6 +282,7 @@ export const Field = ({ case "text": return ( { /> )} - { - setFieldErrors(errors); - }} - /> - { - setSEOErrors(errors); - }} - isSaving={saving} - ref={metaRef} - errors={SEOErrors} - /> + + { + setFieldErrors(errors); + }} + /> + { + setSEOErrors(errors); + }} + isSaving={saving} + ref={metaRef} + errors={SEOErrors} + /> + state.headTags, @@ -477,7 +478,12 @@ export default function ItemEdit() { > save().catch((err) => console.error(err))} @@ -512,24 +518,26 @@ export default function ItemEdit() { exact path="/content/:modelZUID/:itemZUID/meta" render={() => ( - { - setSEOErrors(errors); - }} - isSaving={saving} - errors={SEOErrors} - errorComponent={ - saveClicked && - hasSEOErrors && ( - - ) - } - /> + + { + setSEOErrors(errors); + }} + isSaving={saving} + errors={SEOErrors} + errorComponent={ + saveClicked && + hasSEOErrors && ( + + ) + } + /> + )} /> ( - save().catch((err) => console.error(err))} - dispatch={dispatch} - loading={loading} - saving={saving} - saveClicked={saveClicked} - onUpdateFieldErrors={(errors) => { - setFieldErrors(errors); - }} - fieldErrors={fieldErrors} - hasErrors={hasErrors} - activeFields={activeFields} - fieldErrorRef={fieldErrorRef} - /> + + + save().catch((err) => console.error(err)) + } + dispatch={dispatch} + loading={loading} + saving={saving} + saveClicked={saveClicked} + onUpdateFieldErrors={(errors) => { + setFieldErrors(errors); + }} + fieldErrors={fieldErrors} + hasErrors={hasErrors} + activeFields={activeFields} + fieldErrorRef={fieldErrorRef} + /> + )} /> diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/AIGeneratorParameterProvider.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/AIGeneratorParameterProvider.tsx deleted file mode 100644 index d782e53718..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/AIGeneratorParameterProvider.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Dispatch, createContext, useReducer, useState } from "react"; - -type AIGeneratorParameterContextType = [ - Record, - Dispatch | null> -]; -export const AIGeneratorParameterContext = - createContext([{}, () => {}]); - -type AIGeneratorParameterProviderProps = { - children?: React.ReactNode; -}; - -// This context provider is used to temporarily store the ai parameters -// used when generating a meta title is then used to prefill the -// meta description ai parameters during the AI-assisted metadata flow -export const AIGeneratorParameterProvider = ({ - children, -}: AIGeneratorParameterProviderProps) => { - const [AIGeneratorParameters, updateAIGeneratorParameters] = useState | null>(null); - return ( - - {children} - - ); -}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx index 47038b28d7..38525faa07 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx @@ -32,7 +32,6 @@ import { import { AppState } from "../../../../../../../shell/store/types"; import { Error } from "../../../components/Editor/Field/FieldShell"; import { fetchGlobalItem } from "../../../../../../../shell/store/content"; -import { AIGeneratorParameterProvider } from "./AIGeneratorParameterProvider"; import { ContentModelField, Web, @@ -404,9 +403,10 @@ export const Meta = forwardRef( - - { - if (flowType === FlowType.AIGenerated) { - setFlowType(FlowType.Manual); - } - }} - onAIMetaTitleInserted={() => { - // Scroll to and open the meta description ai generator to continue - // with the AI-assisted flow - if (flowType === FlowType.AIGenerated) { - metaDescriptionButtonRef.current?.triggerAIButton?.(); - } - }} - /> - { - if (flowType === FlowType.AIGenerated) { - setFlowType(FlowType.Manual); - } - }} - required={REQUIRED_FIELDS.includes("metaDescription")} - /> - + { + if (flowType === FlowType.AIGenerated) { + console.log("reset on meta title"); + setFlowType(FlowType.Manual); + } + }} + onAIMetaTitleInserted={() => { + // Scroll to and open the meta description ai generator to continue + // with the AI-assisted flow + if (flowType === FlowType.AIGenerated) { + metaDescriptionButtonRef.current?.triggerAIButton?.(); + } + }} + /> + { + if (flowType === FlowType.AIGenerated) { + console.log("reset on meta description"); + setFlowType(FlowType.Manual); + } + }} + isAIAssistedFlow={flowType === FlowType.AIGenerated} + required={REQUIRED_FIELDS.includes("metaDescription")} + /> {"og_title" in metaFields && ( void; aiButtonRef?: MutableRefObject; + isAIAssistedFlow: boolean; required: boolean; }; export default connect()(function MetaDescription({ @@ -26,11 +27,13 @@ export default connect()(function MetaDescription({ error, onResetFlowType, aiButtonRef, + isAIAssistedFlow, required, }: MetaDescriptionProps) { return ( { onResetFlowType?.(); }} + isAIAssistedFlow={isAIAssistedFlow} > { onResetFlowType?.(); }} diff --git a/src/shell/components/withAi/AIGenerator.tsx b/src/shell/components/withAi/AIGenerator.tsx index 9655b19b4e..4b72c46f14 100644 --- a/src/shell/components/withAi/AIGenerator.tsx +++ b/src/shell/components/withAi/AIGenerator.tsx @@ -1,4 +1,11 @@ -import { useEffect, useState, useRef, useMemo, useContext } from "react"; +import { + useEffect, + useState, + useRef, + useMemo, + useContext, + useReducer, +} from "react"; import { Button, Box, @@ -34,7 +41,7 @@ import { useGetLangsMappingQuery, } from "../../services/instance"; import { AppState } from "../../store/types"; -import { AIGeneratorParameterContext } from "../../../apps/content-editor/src/app/views/ItemEdit/Meta/AIGeneratorParameterProvider"; +import { AIGeneratorContext } from "./AIGeneratorProvider"; const DEFAULT_LIMITS: Record = { text: 150, @@ -43,22 +50,50 @@ const DEFAULT_LIMITS: Record = { description: 160, title: 150, }; -const TONE_OPTIONS = { - intriguing: "Intriguing - Curious, mysterious, and thought-provoking", - professional: "Professional - Serious, formal, and authoritative", - playful: "Playful - Fun, light-hearted, and whimsical", - sensational: "Sensational - Bold, dramatic, and attention-grabbing", - succint: "Succinct - Clear, factual, with no hyperbole", +export const TONE_OPTIONS = [ + { + value: "intriguing", + label: "Intriguing - Curious, mysterious, and thought-provoking", + }, + { + value: "professional", + label: "Professional - Serious, formal, and authoritative", + }, + { value: "playful", label: "Playful - Fun, light-hearted, and whimsical" }, + { + value: "sensational", + label: "Sensational - Bold, dramatic, and attention-grabbing", + }, + { value: "succint", label: "Succinct - Clear, factual, with no hyperbole" }, +] as const; +export type ToneOption = + | "intriguing" + | "professional" + | "playful" + | "sensational" + | "succint"; + +type FieldData = { + topic?: string; + audienceDescription: string; + tone: ToneOption; + keywords?: string; + limit?: number; + language: { + label: string; + value: string; + }; }; // description and title are used for seo meta title & description type AIType = "text" | "paragraph" | "description" | "title" | "word"; interface Props { onApprove: (data: string) => void; - onClose: () => void; + onClose: (reason: "close" | "insert") => void; aiType: AIType; label: string; - saveMetaTitleParameters?: boolean; + fieldZUID: string; + isAIAssistedFlow: boolean; } export const AIGenerator = ({ @@ -66,11 +101,13 @@ export const AIGenerator = ({ onClose, aiType, label, - saveMetaTitleParameters, + fieldZUID, + isAIAssistedFlow, }: Props) => { const dispatch = useDispatch(); const location = useLocation(); const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const [hasFieldError, setHasFieldError] = useState(false); const { modelZUID, itemZUID } = useParams<{ modelZUID: string; itemZUID: string; @@ -82,23 +119,35 @@ export const AIGenerator = ({ const { data: fields } = useGetContentModelFieldsQuery(modelZUID, { skip: !modelZUID, }); - const [topic, setTopic] = useState(""); - const [audienceDescription, setAudienceDescription] = useState(""); - const [tone, setTone] = useState("professional"); - const [keywords, setKeywords] = useState(""); - const [limit, setLimit] = useState(DEFAULT_LIMITS[aiType]); const [selectedContent, setSelectedContent] = useState(null); const request = useRef(null); - const [language, setLanguage] = useState({ - label: "English (United States)", - value: "en-US", - }); - - const [data, setData] = useState([]); - const [parameters, updateParameters] = useContext( - AIGeneratorParameterContext + const [fieldData, updateFieldData] = useReducer( + (state: FieldData, action: Partial) => { + return { + ...state, + ...action, + }; + }, + { + topic: "", + audienceDescription: "", + tone: "professional", + keywords: "", + limit: DEFAULT_LIMITS[aiType], + language: { + label: "English (United States)", + value: "en-US", + }, + } ); + const [data, setData] = useState([]); const { data: langMappings } = useGetLangsMappingQuery(); + const [ + lastOpenedZUID, + updateLastOpenedZUID, + parameterData, + updateParameterData, + ] = useContext(AIGeneratorContext); const [aiGenerate, { isLoading, isError, data: aiResponse }] = useAiGenerationMutation(); @@ -135,52 +184,57 @@ export const AIGenerator = ({ if (aiType === "description" || aiType === "title") { request.current = aiGenerate({ type: aiType, - lang: language.value, - tone, - audience: audienceDescription, + lang: fieldData.language.value, + tone: fieldData.tone, + audience: fieldData.audienceDescription, content: allTextFieldContent, - keywords, + keywords: fieldData.keywords, }); } else { - request.current = aiGenerate({ - type: aiType, - length: limit, - phrase: topic, - lang: language.value, - tone, - audience: audienceDescription, - }); + if (fieldData.topic) { + request.current = aiGenerate({ + type: aiType, + length: fieldData.limit, + phrase: fieldData.topic, + lang: fieldData.language.value, + tone: fieldData.tone, + audience: fieldData.audienceDescription, + }); + } else { + setHasFieldError(true); + } } }; useEffect(() => { - if (aiType === "title") { - updateParameters?.(null); - } + // Used to automatically popuplate the data if they reopened the AI Generator + // on the same field or if the current field is the metaDescription field and + // is currently going through the AI assisted flow + if ( + lastOpenedZUID === fieldZUID || + (isAIAssistedFlow && fieldZUID === "metaDescription") + ) { + try { + const key = + isAIAssistedFlow && fieldZUID === "metaDescription" + ? "metaTitle" + : fieldZUID; + const { topic, audienceDescription, tone, keywords, limit, language } = + parameterData[key]; - if (aiType === "description") { - if (parameters) { - // Auto fill data of the ai generator for the meta description - // using the previously saved parameters used in the meta title. - // This is used during the AI-assisted metadata creation flow. - setTone( - (parameters.tone as keyof typeof TONE_OPTIONS) || "professional" - ); - setAudienceDescription(parameters.audience || ""); - setKeywords(parameters.keywords || ""); - setLanguage( - languageOptions?.find( - (language) => language.value === parameters.lang - ) || { - label: "English (United States)", - value: "en-US", - } - ); + updateFieldData({ + topic, + audienceDescription, + tone, + ...(!!limit && { limit: limit }), + language, + keywords, + }); + } catch (err) { + console.error(err); } - - updateParameters?.(null); } - }, [aiType]); + }, [parameterData, lastOpenedZUID, isAIAssistedFlow]); useEffect(() => { if (isError) { @@ -197,10 +251,21 @@ export const AIGenerator = ({ if (aiResponse?.data) { // For description and title, response will be a stringified array if (aiType === "description" || aiType === "title") { - const responseArr = JSON.parse(aiResponse.data); - setData(responseArr); + try { + const responseArr = JSON.parse(aiResponse.data); + + if (Array.isArray(responseArr)) { + const cleanedResponse = responseArr.map((response) => + response?.replace(/^"(.*)"$/, "$1") + ); + + setData(cleanedResponse); + } + } catch (err) { + console.error("Error parsing AI response: ", err); + } } else { - setData([aiResponse.data]); + setData([aiResponse.data.replace(/^"(.*)"$/, "$1")]); } } }, [aiResponse]); @@ -212,10 +277,35 @@ export const AIGenerator = ({ }) ); + const handleClose = (reason: "close" | "insert") => { + // Temporarily save all the inputs when closing the popup so + // that if they reopen it again, we can repopulate the fields + updateLastOpenedZUID(fieldZUID); + updateParameterData({ + [fieldZUID]: { + topic: fieldData.topic, + limit: fieldData.limit, + language: fieldData.language, + tone: fieldData.tone, + audienceDescription: fieldData.audienceDescription, + keywords: fieldData.keywords, + }, + }); + + // Reason is used to determine if the AI assisted flow will be cancelled + // or not + onClose(reason); + }; + // Loading if (isLoading) { return ( - + - + Generating {aiType === "title" - ? " Title" + ? " Meta Title" : aiType === "description" - ? " Description" + ? " Meta Description" : " Content"} - Our AI assistant is generating your {aiType === "title" - ? " meta title " + ? "Our AI assistant is scanning your content and generating your meta title " : aiType === "description" - ? " meta description " - : " content "} + ? "Our AI assistant is scanning your content and generating your meta description " + : "Our AI assistant is generating your content "} based on your parameters {!!data?.length ? ( @@ -502,19 +608,8 @@ export const AIGenerator = ({ variant="contained" onClick={() => { if (selectedContent !== null) { - // Save the meta title ai parameters to memory so it can be - // shared to the meta description ai popup during the AI-assisted - // metadata creation flow - if (saveMetaTitleParameters) { - updateParameters?.({ - lang: language.value, - tone, - audience: audienceDescription, - keywords, - }); - } onApprove(data[selectedContent]); - onClose(); + handleClose("insert"); } }} sx={{ ml: 2 }} @@ -550,7 +645,7 @@ export const AIGenerator = ({ - Topic + Topic * setTopic(event.target.value)} + value={fieldData.topic} + onChange={(event) => { + if (!!event.target.value) { + setHasFieldError(false); + } + + updateFieldData({ topic: event.target.value }); + }} placeholder={`e.g. Hikes in Washington`} multiline rows={3} fullWidth + error={hasFieldError} + helperText={ + hasFieldError && + "This is field is required. Please enter a value." + } /> Describe your Audience setAudienceDescription(evt.target.value)} + value={fieldData.audienceDescription} + onChange={(evt) => + updateFieldData({ audienceDescription: evt.target.value }) + } placeholder="e.g. Freelancers, Designers, ....." fullWidth /> @@ -643,21 +751,22 @@ export const AIGenerator = ({ - + onChange={(_, value) => updateFieldData({ tone: value.value })} + value={TONE_OPTIONS.find( + (option) => option.value === fieldData.tone + )} + options={TONE_OPTIONS} + renderInput={(params: any) => ( + + )} + /> - + @@ -676,8 +785,8 @@ export const AIGenerator = ({ setLimit(value)} + value={fieldData.limit} + onChange={(value) => updateFieldData({ limit: value })} hasError={false} /> @@ -696,8 +805,10 @@ export const AIGenerator = ({ isOptionEqualToValue={(option: any, value: any) => option.value === value.value } - onChange={(event, value) => setLanguage(value)} - value={language as any} + onChange={(event, value) => + updateFieldData({ language: value }) + } + value={fieldData.language as any} options={languageOptions} renderInput={(params: any) => ( - + + ), }} @@ -728,13 +839,19 @@ export const AIGenerator = ({ - {!!data?.length ? ( @@ -752,7 +869,7 @@ export const AIGenerator = ({ variant="contained" onClick={() => { onApprove(data[0]); - onClose(); + handleClose("insert"); }} sx={{ ml: 2 }} startIcon={} @@ -765,7 +882,6 @@ export const AIGenerator = ({ data-cy="AIGenerate" variant="contained" onClick={handleGenerate} - disabled={!topic} > Generate diff --git a/src/shell/components/withAi/AIGeneratorProvider.tsx b/src/shell/components/withAi/AIGeneratorProvider.tsx new file mode 100644 index 0000000000..454832ce17 --- /dev/null +++ b/src/shell/components/withAi/AIGeneratorProvider.tsx @@ -0,0 +1,68 @@ +import { + Dispatch, + createContext, + useReducer, + useState, + ReactNode, +} from "react"; + +import { ToneOption } from "./AIGenerator"; + +type ParameterData = { + topic?: string; + audienceDescription: string; + tone: ToneOption; + keywords?: string; + limit?: number; + language: { + label: string; + value: string; + }; +}; +type AIGeneratorContextType = [ + string | null, + Dispatch, + Record, + Dispatch> +]; + +export const AIGeneratorContext = createContext([ + null, + () => {}, + null, + () => {}, +]); + +type AIGeneratorProviderProps = { + children?: ReactNode; +}; +export const AIGeneratorProvider = ({ children }: AIGeneratorProviderProps) => { + const [lastOpenedItem, setLastOpenedItem] = useState(null); + // const [parameterData, setParameterData] = useState>(null); + const [parameterData, updateParameterData] = useReducer( + ( + state: Record, + action: Record + ) => { + const newState = { + ...state, + ...action, + }; + return newState; + }, + {} + ); + + return ( + + {children} + + ); +}; diff --git a/src/shell/components/withAi/index.tsx b/src/shell/components/withAi/index.tsx index 25318991f6..2681deee23 100644 --- a/src/shell/components/withAi/index.tsx +++ b/src/shell/components/withAi/index.tsx @@ -9,7 +9,7 @@ import moment from "moment-timezone"; import { AppState } from "../../store/types"; import instanceZUID from "../../../utility/instanceZUID"; -import { AIGenerator } from "./AIGenerator"; +import { AIGenerator, TONE_OPTIONS } from "./AIGenerator"; const rotateAnimation = keyframes` 0% { @@ -74,9 +74,14 @@ export const withAI = (WrappedComponent: ComponentType) => setAnchorEl(event.currentTarget); }; - const handleClose = () => { - // Reset the meta details flow type - props.onResetFlowType?.(); + const handleClose = (reason: "close" | "insert") => { + if ( + reason === "close" || + (reason === "insert" && props.ZUID === "metaDescription") + ) { + // Reset the meta details flow type + props.onResetFlowType?.(); + } setAnchorEl(null); }; @@ -176,7 +181,10 @@ export const withAI = (WrappedComponent: ComponentType) => horizontal: "right", }} elevation={24} - onClose={handleClose} + onClose={() => { + console.log("closing ai generator"); + handleClose("close"); + }} slotProps={{ paper: { sx: { @@ -199,11 +207,12 @@ export const withAI = (WrappedComponent: ComponentType) => }} > handleClose(reason)} aiType={props.aiType} label={props.label} - saveMetaTitleParameters={props.saveMetaTitleParameters} + isAIAssistedFlow={props.isAIAssistedFlow} /> From dcf4278f07adc7b1041d85604e973174d86f8ced Mon Sep 17 00:00:00 2001 From: Nar -- <28705606+finnar-bin@users.noreply.github.com> Date: Thu, 3 Oct 2024 11:10:10 +0800 Subject: [PATCH 44/44] [Chore] Resolve dev/stage conflicts (#2995) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Andres Galindo Co-authored-by: Stuart Runyan Co-authored-by: Allen Pigar <50983144+allenpigar@users.noreply.github.com>