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 ( { 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 && ( { Edit Model - { - history.push(codePath); - }} - > - - - - Edit Template - + {!isDataset && ( + { + history.push(codePath); + }} + > + + + + Edit Template + + )} { : 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]; diff --git a/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx b/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx index a41e038880..7e5eb773c1 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx @@ -49,7 +49,9 @@ export type FieldNames = | "regexRestrictPattern" | "regexRestrictErrorMessage" | "minValue" - | "maxValue"; + | "maxValue" + | "fileExtensions" + | "fileExtensionsErrorMessage"; type FieldType = | "input" | "checkbox" diff --git a/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx b/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx index 18223ec1a1..eb6b8140f3 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx @@ -4,14 +4,22 @@ import { Checkbox, Typography, Stack, + InputLabel, + Autocomplete, + TextField, + Chip, } from "@mui/material"; -import { FormValue } from "./views/FieldForm"; +import { Errors, FormValue } from "./views/FieldForm"; import { CustomGroup } from "../hooks/useMediaRules"; - import { InputField } from "./FieldFormInput"; import { FieldFormInput, FieldNames } from "./FieldFormInput"; +import { useEffect, useState } from "react"; + +type MediaFieldName = Extract< + FieldNames, + "limit" | "group_id" | "fileExtensions" +>; -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[];