diff --git a/package-lock.json b/package-lock.json index 73d29b28d9..4435c8d046 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,6 @@ "requires": true, "packages": { "": { - "name": "manager-ui", "version": "1.0.1", "license": "Commons Clause License Condition v1.0", "dependencies": { @@ -31,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.2", + "@zesty-io/material": "^0.15.3", "chart.js": "^3.8.0", "chartjs-adapter-moment": "^1.0.1", "chartjs-plugin-datalabels": "^2.0.0", @@ -3912,9 +3911,9 @@ } }, "node_modules/@zesty-io/material": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.2.tgz", - "integrity": "sha512-m5dLNBpZtPtXUlo57In+k2ldo8OtAUPLvjLATV7S4qC6GKVSZHpq4Sqvab3YZ8C2dskcHGTos4aOJicgI3/UKA==", + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.3.tgz", + "integrity": "sha512-zJOagsYexWOq1Bw+L0X1nSKTL+aZkB7MZPCqW53uqAwnKMZ3mejpsOjJwvzoYtm+6oJ6RHQJu0xmNbPXCcjLKA==", "dependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", @@ -18397,9 +18396,9 @@ } }, "@zesty-io/material": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.2.tgz", - "integrity": "sha512-m5dLNBpZtPtXUlo57In+k2ldo8OtAUPLvjLATV7S4qC6GKVSZHpq4Sqvab3YZ8C2dskcHGTos4aOJicgI3/UKA==", + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.3.tgz", + "integrity": "sha512-zJOagsYexWOq1Bw+L0X1nSKTL+aZkB7MZPCqW53uqAwnKMZ3mejpsOjJwvzoYtm+6oJ6RHQJu0xmNbPXCcjLKA==", "requires": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", diff --git a/package.json b/package.json index 8ec0d7b68e..0a1397272c 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.2", + "@zesty-io/material": "^0.15.3", "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/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/components/FieldTypeMedia.tsx b/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx index bec4d47bd3..713b146799 100644 --- a/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx +++ b/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx @@ -26,7 +26,7 @@ import { } from "@mui/icons-material"; import { alpha } from "@mui/material/styles"; import { CompactView, Modal, Login } from "@bynder/compact-view"; -import { Bynder } from "@zesty-io/material"; +import { Bynder, FileReplace } from "@zesty-io/material"; import { useGetBinsQuery, @@ -43,6 +43,8 @@ import styles from "../../../../media/src/app/components/Thumbnail/Loading.less" import cx from "classnames"; import { FileTypePreview } from "../../../../media/src/app/components/FileModal/FileTypePreview"; import { useGetInstanceSettingsQuery } from "../../../../../shell/services/instance"; +import { ReplaceFileModal } from "../../../../media/src/app/components/FileModal/ReplaceFileModal"; +import { showReportDialog } from "@sentry/react"; type FieldTypeMediaProps = { images: string[]; @@ -597,6 +599,7 @@ const MediaItem = ({ skip: imageZUID?.substr(0, 4) === "http", }); const [showRenameFileModal, setShowRenameFileModal] = useState(false); + const [isReplaceFileModalOpen, setIsReplaceFileModalOpen] = useState(false); const [isCopied, setIsCopied] = useState(false); const [isCopiedZuid, setIsCopiedZuid] = useState(false); const [newFilename, setNewFilename] = useState(""); @@ -757,6 +760,7 @@ const MediaItem = ({ )} @@ -794,7 +798,7 @@ const MediaItem = ({ )} {!isBynderAsset || (isBynderAsset && isBynderSessionValid) ? ( - + { @@ -861,6 +865,18 @@ const MediaItem = ({ Rename )} + { + event.stopPropagation(); + setAnchorEl(null); + setIsReplaceFileModalOpen(true); + }} + > + + + + Replace File + {!isURL && !isBynderAsset && ( { @@ -914,6 +930,13 @@ const MediaItem = ({ extension={fileExtension(data.filename)} /> )} + {isReplaceFileModalOpen && ( + setIsReplaceFileModalOpen(false)} + onCancel={() => setIsReplaceFileModalOpen(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 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/content-editor/src/app/views/ItemList/TableCells/DropdownCell.tsx b/src/apps/content-editor/src/app/views/ItemList/TableCells/DropdownCell.tsx index 13a40c5ced..706213a9be 100644 --- a/src/apps/content-editor/src/app/views/ItemList/TableCells/DropdownCell.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/TableCells/DropdownCell.tsx @@ -10,7 +10,14 @@ export const DropDownCell = ({ params }: { params: GridRenderCellParams }) => { const field = params.row.fieldData[params.field]; const handleChange = (value: any) => { setAnchorEl(null); - updateStagedChanges(params.row.id, params.field, value); + + if (value !== currVal) { + updateStagedChanges( + params.row.id, + params.field, + value === "Select" ? null : value + ); + } }; const currVal = @@ -60,7 +67,7 @@ export const DropDownCell = ({ params }: { params: GridRenderCellParams }) => { { - handleChange(null); + handleChange("Select"); }} sx={{ textWrap: "wrap", 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/Filters/FilterButton.tsx b/src/shell/components/Filters/FilterButton.tsx index 04f3874e6f..917d7d5e1e 100644 --- a/src/shell/components/Filters/FilterButton.tsx +++ b/src/shell/components/Filters/FilterButton.tsx @@ -1,6 +1,6 @@ import { FC } from "react"; import { Button, ButtonGroup, Typography } from "@mui/material"; -import ArrowDropDownOutlinedIcon from "@mui/icons-material/ArrowDropDownOutlined"; +import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded"; import CheckIcon from "@mui/icons-material/Check"; import CloseRoundedIcon from "@mui/icons-material/CloseRounded"; @@ -53,7 +53,7 @@ export const FilterButton: FC = ({ variant="outlined" size="small" color="inherit" - endIcon={} + endIcon={} onClick={onOpenMenu} data-cy={`${filterId}_default`} sx={{ 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()); };