diff --git a/frontend/src/metadata/views/gallery/context-menu/index.css b/frontend/src/metadata/components/context-menu/index.css similarity index 100% rename from frontend/src/metadata/views/gallery/context-menu/index.css rename to frontend/src/metadata/components/context-menu/index.css diff --git a/frontend/src/metadata/components/context-menu/index.js b/frontend/src/metadata/components/context-menu/index.js new file mode 100644 index 0000000000..f2c38f983d --- /dev/null +++ b/frontend/src/metadata/components/context-menu/index.js @@ -0,0 +1,114 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import PropTypes from 'prop-types'; + +import './index.css'; + +const ContextMenu = ({ options, boundaryCoordinates, onOptionClick, ignoredTriggerElements }) => { + const menuRef = useRef(null); + const [visible, setVisible] = useState(false); + const [position, setPosition] = useState({ top: 0, left: 0 }); + + const handleHide = useCallback((event) => { + if (menuRef.current && !menuRef.current.contains(event.target)) { + setVisible(false); + } + }, [menuRef]); + + const getMenuPosition = useCallback((x = 0, y = 0) => { + let menuStyles = { + top: y, + left: x + }; + if (!menuRef.current) return menuStyles; + const rect = menuRef.current.getBoundingClientRect(); + const { top: boundaryTop, right: boundaryRight, bottom: boundaryBottom, left: boundaryLeft } = boundaryCoordinates || {}; + menuStyles.top = menuStyles.top - boundaryTop; + menuStyles.left = menuStyles.left - boundaryLeft; + + if (y + rect.height > boundaryBottom - 10) { + menuStyles.top -= rect.height; + } + if (x + rect.width > boundaryRight) { + menuStyles.left -= rect.width; + } + if (menuStyles.top < 0) { + menuStyles.top = rect.bottom > boundaryBottom ? (boundaryBottom - 10 - rect.height) / 2 : 0; + } + if (menuStyles.left < 0) { + menuStyles.left = rect.width < boundaryRight ? (boundaryRight - rect.width) / 2 : 0; + } + return menuStyles; + }, [boundaryCoordinates]); + + const handleOptionClick = useCallback((event, option) => { + event.stopPropagation(); + onOptionClick(option); + setVisible(false); + }, [onOptionClick]); + + useEffect(() => { + const handleShow = (event) => { + event.preventDefault(); + if (menuRef.current && menuRef.current.contains(event.target)) return; + + if (ignoredTriggerElements && !ignoredTriggerElements.some(target => event.target.closest(target))) { + return; + } + + setVisible(true); + const position = getMenuPosition(event.clientX, event.clientY); + setPosition(position); + }; + + document.addEventListener('contextmenu', handleShow); + + return () => { + document.removeEventListener('contextmenu', handleShow); + }; + }, [getMenuPosition, ignoredTriggerElements]); + + useEffect(() => { + if (visible) { + document.addEventListener('mousedown', handleHide); + } else { + document.removeEventListener('mousedown', handleHide); + } + + return () => { + document.removeEventListener('mousedown', handleHide); + }; + }, [visible, handleHide]); + + if (!visible) return null; + if (options.length === 0) return null; + + return ( +
+ {options.map((option, index) => ( + + ))} +
+ ); +}; + +ContextMenu.propTypes = { + options: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + })).isRequired, + boundaryCoordinates: PropTypes.object, + ignoredTriggerElements: PropTypes.array, + onOptionClick: PropTypes.func.isRequired, +}; + +export default ContextMenu; diff --git a/frontend/src/metadata/components/dialog/rename-dialog/index.js b/frontend/src/metadata/components/dialog/rename-dialog/index.js new file mode 100644 index 0000000000..c24478505b --- /dev/null +++ b/frontend/src/metadata/components/dialog/rename-dialog/index.js @@ -0,0 +1,85 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Button, Modal, ModalHeader, Input, ModalBody, ModalFooter, Alert } from 'reactstrap'; +import PropTypes from 'prop-types'; +import { gettext } from '../../../../utils/constants'; +import { validateName } from '../../../../utils/utils'; +import { isEnter } from '../../../utils/hotkey'; + +const RenameDialog = ({ isDir, oldName, onSubmit, onCancel }) => { + const [newName, setNewName] = useState(''); + const [errMessage, setErrMessage] = useState(''); + const [isSubmitBtnActive, setIsSubmitBtnActive] = useState(false); + const newInput = useRef(null); + + const handleChange = (e) => { + const value = e.target.value.trim(); + setNewName(value); + setIsSubmitBtnActive(!!value); + }; + + const handleSubmit = () => { + const { isValid, errMessage } = validateName(newName); + if (!isValid) { + setErrMessage(errMessage); + return; + } + + onSubmit(newName); + }; + + const handleKeyDown = (e) => { + if (isEnter(e)) { + handleSubmit(); + } + }; + + const onAfterModelOpened = () => { + if (!newInput.current) return; + newInput.current.focus(); + if (!isDir) { + const endIndex = oldName.lastIndexOf('.'); + if (endIndex !== -1) { + newInput.current.setSelectionRange(0, endIndex, 'forward'); + } else { + newInput.current.setSelectionRange(0, oldName.length, 'forward'); + } + } else { + newInput.current.setSelectionRange(0, -1); + } + }; + + useEffect(() => { + setNewName(oldName); + }, [oldName]); + + return ( + + + {isDir ? gettext('Rename Folder') : gettext('Rename File')} + + +

{isDir ? gettext('New folder name') : gettext('New file name')}

+ + {errMessage && {errMessage}} +
+ + + + +
+ ); +}; + +RenameDialog.propTypes = { + isDir: PropTypes.bool.isRequired, + oldName: PropTypes.string.isRequired, + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, +}; + +export default RenameDialog; diff --git a/frontend/src/metadata/utils/open-file.js b/frontend/src/metadata/utils/file.js similarity index 68% rename from frontend/src/metadata/utils/open-file.js rename to frontend/src/metadata/utils/file.js index 18310c0586..23548432e5 100644 --- a/frontend/src/metadata/utils/open-file.js +++ b/frontend/src/metadata/utils/file.js @@ -2,6 +2,7 @@ import { getFileNameFromRecord, getParentDirFromRecord } from './cell'; import { checkIsDir } from './row'; import { Utils } from '../../utils/utils'; import { siteRoot } from '../../utils/constants'; +import URLDecorator from '../../utils/url-decorator'; const FILE_TYPE = { FOLDER: 'folder', @@ -99,3 +100,34 @@ export const openFile = (repoID, record, _openImage = () => {}) => { } }; +export const openInNewTab = (repoID, record) => { + if (!record) return; + const isDir = checkIsDir(record); + const parentDir = getParentDirFromRecord(record); + const fileName = getFileNameFromRecord(record); + const url = isDir + ? window.location.origin + window.location.pathname + Utils.encodePath(Utils.joinPath(parentDir, fileName)) + : `${siteRoot}lib/${repoID}/file${Utils.encodePath(Utils.joinPath(parentDir, fileName))}`; + + window.open(url, '_blank'); +}; + +export const openParentFolder = (record) => { + if (!record) return; + let parentDir = getParentDirFromRecord(record); + if (window.location.pathname.endsWith('/')) { + parentDir = parentDir.slice(1); + } + const url = window.location.origin + window.location.pathname + Utils.encodePath(parentDir); + window.open(url, '_blank'); +}; + +export const downloadFile = (repoID, record) => { + if (!repoID || !record) return; + if (checkIsDir(record)) return; + const parentDir = _getParentDir(record); + const name = getFileNameFromRecord(record); + const direntPath = Utils.joinPath(parentDir, name); + const url = URLDecorator.getUrl({ type: 'download_file_url', repoID: repoID, filePath: direntPath }); + location.href = url; +}; diff --git a/frontend/src/metadata/utils/row/core.js b/frontend/src/metadata/utils/row/core.js index f69e806ee6..9eb530a8ac 100644 --- a/frontend/src/metadata/utils/row/core.js +++ b/frontend/src/metadata/utils/row/core.js @@ -26,7 +26,7 @@ const updateTableRowsWithRowsData = (tables, tableId, recordsData = []) => { }); }; -export const checkIsDir = (record) => { +const checkIsDir = (record) => { if (!record) return false; const isDir = record[PRIVATE_COLUMN_KEY.IS_DIR]; if (typeof isDir === 'string') { @@ -38,4 +38,5 @@ export const checkIsDir = (record) => { export { isTableRows, updateTableRowsWithRowsData, + checkIsDir, }; diff --git a/frontend/src/metadata/views/gallery/content.js b/frontend/src/metadata/views/gallery/content.js index ab0790ee04..145cd1b868 100644 --- a/frontend/src/metadata/views/gallery/content.js +++ b/frontend/src/metadata/views/gallery/content.js @@ -16,7 +16,7 @@ const Content = ({ onImageSelect, onImageClick, onImageDoubleClick, - onImageRightClick + onContextMenu }) => { const containerRef = useRef(null); const imageRef = useRef(null); @@ -136,7 +136,7 @@ const Content = ({ size={size} onClick={(e) => onImageClick(e, img)} onDoubleClick={(e) => onImageDoubleClick(e, img)} - onContextMenu={(e) => onImageRightClick(e, img)} + onContextMenu={(e) => onContextMenu(e, img)} /> ); }); @@ -144,7 +144,7 @@ const Content = ({ ); - }, [overScan, columns, size, imageHeight, mode, selectedImageIds, onImageClick, onImageDoubleClick, onImageRightClick]); + }, [overScan, columns, size, imageHeight, mode, selectedImageIds, onImageClick, onImageDoubleClick, onContextMenu]); if (!Array.isArray(groups) || groups.length === 0) { return ; @@ -191,7 +191,7 @@ Content.propTypes = { onImageSelect: PropTypes.func.isRequired, onImageClick: PropTypes.func.isRequired, onImageDoubleClick: PropTypes.func.isRequired, - onImageRightClick: PropTypes.func.isRequired, + onContextMenu: PropTypes.func.isRequired, }; export default Content; diff --git a/frontend/src/metadata/views/gallery/context-menu/index.js b/frontend/src/metadata/views/gallery/context-menu/index.js index 6660ca6448..f3e0edcaed 100644 --- a/frontend/src/metadata/views/gallery/context-menu/index.js +++ b/frontend/src/metadata/views/gallery/context-menu/index.js @@ -1,139 +1,104 @@ -import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import React, { useMemo, useCallback, useState } from 'react'; import PropTypes from 'prop-types'; -import { gettext } from '../../../../utils/constants'; -import './index.css'; +import ContextMenu from '../../../components/context-menu'; +import { gettext, useGoFileserver, fileServerRoot } from '../../../../utils/constants'; +import { getRowById } from '../../../utils/table'; +import { useMetadataView } from '../../../hooks/metadata-view'; +import { downloadFile } from '../../../utils/file'; +import ZipDownloadDialog from '../../../../components/dialog/zip-download-dialog'; +import metadataAPI from '../../../api'; +import toaster from '../../../../components/toast'; +import { Utils } from '../../../../utils/utils'; +import ModalPortal from '../../../../components/modal-portal'; -const OPERATION = { +const CONTEXT_MENU_KEY = { DOWNLOAD: 'download', DELETE: 'delete', }; -const ContextMenu = ({ getContentRect, getContainerRect, onDownload, onDelete }) => { - const menuRef = useRef(null); - const [visible, setVisible] = useState(false); - const [position, setPosition] = useState({ top: 0, left: 0 }); +const GalleryContextMenu = ({ selectedImages, boundaryCoordinates, onDelete }) => { + const [isZipDialogOpen, setIsZipDialogOpen] = useState(false); + + const { metadata } = useMetadataView(); + const repoID = window.sfMetadataContext.getSetting('repoID'); + const checkCanDeleteRow = window.sfMetadataContext.checkCanDeleteRow(); const options = useMemo(() => { - if (!visible) return []; - return [ - { value: OPERATION.DOWNLOAD, label: gettext('Download') }, - { value: OPERATION.DELETE, label: gettext('Delete') } - ]; - }, [visible]); - - const handleHide = useCallback((event) => { - if (menuRef.current && !menuRef.current.contains(event.target)) { - setVisible(false); + let validOptions = [{ value: CONTEXT_MENU_KEY.DOWNLOAD, label: gettext('Download') }]; + if (checkCanDeleteRow) { + validOptions.push({ value: CONTEXT_MENU_KEY.DELETE, label: selectedImages.length > 1 ? gettext('Delete') : gettext('Delete file') }); } - }, [menuRef]); + return validOptions; + }, [checkCanDeleteRow, selectedImages]); - const handleOptionClick = useCallback((event, option) => { - event.stopPropagation(); + const closeZipDialog = () => { + setIsZipDialogOpen(false); + }; + + const handleDownload = useCallback(() => { + if (!selectedImages.length) return; + if (selectedImages.length === 1) { + const image = selectedImages[0]; + const record = getRowById(metadata, image.id); + downloadFile(repoID, record); + return; + } + if (!useGoFileserver) { + setIsZipDialogOpen(true); + return; + } + const dirents = selectedImages.map(image => { + const value = image.path === '/' ? image.name : `${image.path}/${image.name}`; + return value; + }); + metadataAPI.zipDownload(repoID, '/', dirents).then((res) => { + const zipToken = res.data['zip_token']; + location.href = `${fileServerRoot}zip/${zipToken}`; + }).catch(error => { + const errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + }, [repoID, metadata, selectedImages]); + + const handleOptionClick = useCallback(option => { switch (option.value) { - case OPERATION.DOWNLOAD: { - onDownload && onDownload(); + case 'download': + handleDownload(); break; - } - case OPERATION.DELETE: { - onDelete && onDelete(); + case 'delete': + onDelete(selectedImages); break; - } - default: { + default: break; - } } - setVisible(false); - }, [onDownload, onDelete]); - - const getMenuPosition = useCallback((x = 0, y = 0) => { - let menuStyles = { - top: y, - left: x - }; - if (!menuRef.current) return menuStyles; - const rect = menuRef.current.getBoundingClientRect(); - const containerRect = getContainerRect(); - const { right: innerWidth, bottom: innerHeight } = getContentRect(); - menuStyles.top = menuStyles.top - containerRect.top; - menuStyles.left = menuStyles.left - containerRect.left; - - if (y + rect.height > innerHeight - 10) { - menuStyles.top -= rect.height; - } - if (x + rect.width > innerWidth) { - menuStyles.left -= rect.width; - } - if (menuStyles.top < 0) { - menuStyles.top = rect.bottom > innerHeight ? (innerHeight - 10 - rect.height) / 2 : 0; - } - if (menuStyles.left < 0) { - menuStyles.left = rect.width < innerWidth ? (innerWidth - rect.width) / 2 : 0; - } - return menuStyles; - }, [getContentRect, getContainerRect]); - - useEffect(() => { - const handleShow = (event) => { - event.preventDefault(); - if (menuRef.current && menuRef.current.contains(event.target)) return; - - if (event.target.tagName.toLowerCase() !== 'img') { - return; - } - - setVisible(true); - - const position = getMenuPosition(event.clientX, event.clientY); - setPosition(position); - }; - - document.addEventListener('contextmenu', handleShow); - - return () => { - document.removeEventListener('contextmenu', handleShow); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (visible) { - document.addEventListener('mousedown', handleHide); - } else { - document.removeEventListener('mousedown', handleHide); - } - - return () => { - document.removeEventListener('mousedown', handleHide); - }; - }, [visible, handleHide]); - - if (!visible) return null; - if (options.length === 0) return null; + }, [selectedImages, handleDownload, onDelete]); return ( -
- {options.map((option, index) => ( - - ))} -
+ <> + + {isZipDialogOpen && ( + + image.path === '/' ? image.name : `${image.path}/${image.name}`)} + toggleDialog={closeZipDialog} + /> + + )} + ); }; -ContextMenu.propTypes = { - getContentRect: PropTypes.func.isRequired, - getContainerRect: PropTypes.func.isRequired, - onDownload: PropTypes.func, +GalleryContextMenu.propTypes = { + selectedImages: PropTypes.array, + boundaryCoordinates: PropTypes.object, onDelete: PropTypes.func, }; -export default ContextMenu; +export default GalleryContextMenu; diff --git a/frontend/src/metadata/views/gallery/main.js b/frontend/src/metadata/views/gallery/main.js index 0bb6516bb0..26148a3188 100644 --- a/frontend/src/metadata/views/gallery/main.js +++ b/frontend/src/metadata/views/gallery/main.js @@ -1,21 +1,17 @@ import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'; import PropTypes from 'prop-types'; import { CenteredLoading } from '@seafile/sf-metadata-ui-component'; -import metadataAPI from '../../api'; -import URLDecorator from '../../../utils/url-decorator'; -import toaster from '../../../components/toast'; import Content from './content'; -import ContextMenu from './context-menu'; import ImageDialog from '../../../components/dialog/image-dialog'; -import ZipDownloadDialog from '../../../components/dialog/zip-download-dialog'; import ModalPortal from '../../../components/modal-portal'; import { useMetadataView } from '../../hooks/metadata-view'; import { Utils } from '../../../utils/utils'; import { getDateDisplayString, getFileNameFromRecord, getParentDirFromRecord } from '../../utils/cell'; -import { siteRoot, fileServerRoot, useGoFileserver, thumbnailSizeForGrid, thumbnailSizeForOriginal } from '../../../utils/constants'; +import { siteRoot, fileServerRoot, thumbnailSizeForGrid, thumbnailSizeForOriginal } from '../../../utils/constants'; import { EVENT_BUS_TYPE, PRIVATE_COLUMN_KEY, GALLERY_DATE_MODE, DATE_TAG_HEIGHT, GALLERY_IMAGE_GAP } from '../../constants'; import { getRowById } from '../../utils/table'; import { getEventClassName } from '../../utils/common'; +import GalleryContextmenu from './context-menu'; import './index.css'; @@ -28,7 +24,6 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore }) => { const [overScan, setOverScan] = useState({ top: 0, bottom: 0 }); const [mode, setMode] = useState(GALLERY_DATE_MODE.DAY); const [isImagePopupOpen, setIsImagePopupOpen] = useState(false); - const [isZipDialogOpen, setIsZipDialogOpen] = useState(false); const [imageIndex, setImageIndex] = useState(0); const [selectedImages, setSelectedImages] = useState([]); @@ -267,7 +262,7 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore }) => { setIsImagePopupOpen(true); }, [imageItems]); - const handleRightClick = useCallback((event, image) => { + const handleContextMenu = useCallback((event, image) => { event.preventDefault(); const index = imageItems.findIndex(item => item.id === image.id); if (isNaN(index) || index === -1) return; @@ -291,46 +286,17 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore }) => { setSelectedImages(selectedImages); }, []); - const closeImagePopup = () => { + const closeImagePopup = useCallback(() => { setIsImagePopupOpen(false); - }; + }, []); - const handleDownload = useCallback(() => { - if (!selectedImages.length) return; - if (selectedImages.length === 1) { - const image = selectedImages[0]; - let direntPath = image.path === '/' ? image.name : Utils.joinPath(image.path, image.name); - let url = URLDecorator.getUrl({ type: 'download_file_url', repoID: repoID, filePath: direntPath }); - location.href = url; - return; - } - if (!useGoFileserver) { - setIsZipDialogOpen(true); - return; - } - const dirents = selectedImages.map(image => { - const value = image.path === '/' ? image.name : `${image.path}/${image.name}`; - return value; - }); - metadataAPI.zipDownload(repoID, '/', dirents).then((res) => { - const zipToken = res.data['zip_token']; - location.href = `${fileServerRoot}zip/${zipToken}`; - }).catch(error => { - const errMessage = Utils.getErrorMsg(error); - toaster.danger(errMessage); - }); - }, [repoID, selectedImages]); - - const handleDelete = useCallback(() => { + const handleDeleteSelectedImages = useCallback((selectedImages) => { if (!selectedImages.length) return; onDelete(selectedImages, () => { + updateCurrentDirent(); setSelectedImages([]); }); - }, [selectedImages, onDelete]); - - const closeZipDialog = () => { - setIsZipDialogOpen(false); - }; + }, [onDelete, updateCurrentDirent]); const handleClickOutside = useCallback((event) => { const className = getEventClassName(event); @@ -385,7 +351,7 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore }) => { onImageSelect={handleImageSelection} onImageClick={handleClick} onImageDoubleClick={handleDoubleClick} - onImageRightClick={handleRightClick} + onContextMenu={handleContextMenu} /> {isLoadingMore &&
@@ -395,11 +361,10 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore }) => { )}
- containerRef.current.getBoundingClientRect()} - getContainerRect={() => containerRef.current.getBoundingClientRect()} - onDownload={handleDownload} - onDelete={handleDelete} + {isImagePopupOpen && ( @@ -413,16 +378,6 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore }) => { /> )} - {isZipDialogOpen && ( - - image.path === '/' ? image.name : `${image.path}/${image.name}`)} - toggleDialog={closeZipDialog} - /> - - )} ); }; diff --git a/frontend/src/metadata/views/kanban/boards/board/card/index.js b/frontend/src/metadata/views/kanban/boards/board/card/index.js index 418db7e3f2..f6bf966557 100644 --- a/frontend/src/metadata/views/kanban/boards/board/card/index.js +++ b/frontend/src/metadata/views/kanban/boards/board/card/index.js @@ -17,6 +17,7 @@ const Card = ({ displayColumns, onOpenFile, onSelectCard, + onContextMenu, }) => { const titleValue = getCellValueByColumn(record, titleColumn); @@ -41,6 +42,7 @@ const Card = ({ data-id={record._id} className={classnames('sf-metadata-kanban-card', { 'selected': isSelected })} onClick={handleClickCard} + onContextMenu={onContextMenu} > {titleColumn && (
@@ -82,6 +84,7 @@ Card.propTypes = { displayColumns: PropTypes.array, onOpenFile: PropTypes.func.isRequired, onSelectCard: PropTypes.func.isRequired, + onContextMenu: PropTypes.func.isRequired, }; export default Card; diff --git a/frontend/src/metadata/views/kanban/boards/board/index.js b/frontend/src/metadata/views/kanban/boards/board/index.js index c05f8da0cb..f5253a4f48 100644 --- a/frontend/src/metadata/views/kanban/boards/board/index.js +++ b/frontend/src/metadata/views/kanban/boards/board/index.js @@ -28,6 +28,7 @@ const Board = ({ onOpenFile, onSelectCard, updateDragging, + onContextMenu, }) => { const [isDraggingOver, setDraggingOver] = useState(false); const boardName = useMemo(() => `sf_metadata_kanban_board_${board.key}`, [board]); @@ -80,7 +81,8 @@ const Board = ({ {board.children.map((cardKey) => { const record = getRowById(metadata, cardKey); if (!record) return null; - const isSelected = selectedCard === getRecordIdFromRecord(record); + const recordId = getRecordIdFromRecord(record); + const isSelected = selectedCard === recordId; const CardElement = ( onContextMenu(e, recordId)} /> ); if (readonly) return CardElement; @@ -124,6 +127,7 @@ Board.propTypes = { onOpenFile: PropTypes.func.isRequired, onSelectCard: PropTypes.func.isRequired, updateDragging: PropTypes.func.isRequired, + onContextMenu: PropTypes.func, }; export default Board; diff --git a/frontend/src/metadata/views/kanban/boards/index.js b/frontend/src/metadata/views/kanban/boards/index.js index 31b34e3985..b083ce55fa 100644 --- a/frontend/src/metadata/views/kanban/boards/index.js +++ b/frontend/src/metadata/views/kanban/boards/index.js @@ -10,16 +10,17 @@ import { checkIsPredefinedOption, getCellValueByColumn, isValidCellValue, getRec getFileNameFromRecord, getParentDirFromRecord } from '../../../utils/cell'; import { getColumnOptions, getColumnOriginName } from '../../../utils/column'; +import { openFile } from '../../../utils/file'; +import { checkIsDir } from '../../../utils/row'; import AddBoard from '../add-board'; import EmptyTip from '../../../../components/empty-tip'; import Board from './board'; import ImagePreviewer from '../../../components/cell-formatter/image-previewer'; -import { openFile } from '../../../utils/open-file'; -import { checkIsDir } from '../../../utils/row'; +import ContextMenu from '../context-menu'; import './index.css'; -const Boards = ({ modifyRecord, modifyColumnData, onCloseSettings }) => { +const Boards = ({ modifyRecord, deleteRecords, modifyColumnData, onCloseSettings }) => { const [haveFreezed, setHaveFreezed] = useState(false); const [isImagePreviewerVisible, setImagePreviewerVisible] = useState(false); const [selectedCard, setSelectedCard] = useState(''); @@ -31,6 +32,9 @@ const Boards = ({ modifyRecord, modifyColumnData, onCloseSettings }) => { const { isDirentDetailShow, metadata, store, updateCurrentDirent, showDirentDetail } = useMetadataView(); const { collaborators } = useCollaborators(); + const repoID = window.sfMetadataContext.getSetting('repoID'); + const repoInfo = window.sfMetadataContext.getSetting('repoInfo'); + const groupByColumn = useMemo(() => { const groupByColumnKey = metadata.view.settings[KANBAN_SETTINGS_KEYS.GROUP_BY_COLUMN_KEY]; return metadata.key_column_map[groupByColumnKey]; @@ -216,6 +220,24 @@ const Boards = ({ modifyRecord, modifyColumnData, onCloseSettings }) => { setDragging(isDragging); }, []); + const onContextMenu = useCallback((event, recordId) => { + event.preventDefault(); + setSelectedCard(recordId); + }, []); + + const onDeleteRecords = useCallback((recordIds) => { + deleteRecords(recordIds, { + success_callback: () => { + setSelectedCard(null); + updateCurrentDirent(); + }, + }); + }, [deleteRecords, updateCurrentDirent]); + + const onRename = useCallback((rowId, updates, oldRowData, originalUpdates, originalOldRowData, { success_callback }) => { + modifyRecord(rowId, updates, oldRowData, originalUpdates, originalOldRowData, { success_callback }); + }, [modifyRecord]); + useEffect(() => { if (!isDirentDetailShow) { setSelectedCard(null); @@ -223,50 +245,57 @@ const Boards = ({ modifyRecord, modifyColumnData, onCloseSettings }) => { }, [isDirentDetailShow]); const isEmpty = boards.length === 0; - const repoID = window.sfMetadataContext.getSetting('repoID'); - const repoInfo = window.sfMetadataContext.getSetting('repoInfo'); return ( -
-
- {isEmpty && ()} - {!isEmpty && ( - <> - {boards.map((board, index) => { - return ( - - ); - })} - - )} - {!readonly && ()} + <> +
+
+ {isEmpty && ()} + {!isEmpty && ( + <> + {boards.map((board, index) => { + return ( + + ); + })} + + )} + {!readonly && ()} +
+ {isImagePreviewerVisible && ( { closeImagePopup={closeImagePreviewer} /> )} -
+ ); }; diff --git a/frontend/src/metadata/views/kanban/context-menu/index.js b/frontend/src/metadata/views/kanban/context-menu/index.js new file mode 100644 index 0000000000..9e92ef450d --- /dev/null +++ b/frontend/src/metadata/views/kanban/context-menu/index.js @@ -0,0 +1,163 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import { ModalPortal } from '@seafile/sf-metadata-ui-component'; +import ContextMenu from '../../../components/context-menu'; +import { getRowById } from '../../../utils/table'; +import { checkIsDir } from '../../../utils/row'; +import { getFileNameFromRecord, getParentDirFromRecord } from '../../../utils/cell'; +import { gettext, useGoFileserver, fileServerRoot } from '../../../../utils/constants'; +import { openInNewTab, openParentFolder, downloadFile } from '../../../utils/file'; +import { useMetadataView } from '../../../hooks/metadata-view'; +import { PRIVATE_COLUMN_KEY } from '../../../constants'; +import RenameDialog from '../../../components/dialog/rename-dialog'; +import { Utils } from '../../../../utils/utils'; +import toaster from '../../../../components/toast'; +import metadataAPI from '../../../api'; +import ZipDownloadDialog from '../../../../components/dialog/zip-download-dialog'; + +const CONTEXT_MENU_KEY = { + OPEN_IN_NEW_TAB: 'open_in_new_tab', + OPEN_PARENT_FOLDER: 'open_parent_folder', + DOWNLOAD: 'download', + DELETE: 'delete', + RENAME: 'rename', +}; + +const KanbanContextMenu = ({ boundaryCoordinates, selectedCard, onDelete, onRename }) => { + const [isRenameDialogShow, setIsRenameDialogShow] = useState(false); + const [isZipDialogOpen, setIsZipDialogOpen] = useState(false); + + const { metadata } = useMetadataView(); + + const selectedRecord = useMemo(() => getRowById(metadata, selectedCard), [metadata, selectedCard]); + const isDir = useMemo(() => checkIsDir(selectedRecord), [selectedRecord]); + const oldName = useMemo(() => getFileNameFromRecord(selectedRecord), [selectedRecord]); + const parentDir = useMemo(() => getParentDirFromRecord(selectedRecord), [selectedRecord]); + + const repoID = window.sfMetadataContext.getSetting('repoID'); + const checkCanDeleteRow = window.sfMetadataContext.checkCanDeleteRow(); + const canModifyRow = window.sfMetadataContext.canModifyRow(); + + const options = useMemo(() => { + let validOptions = [ + { value: CONTEXT_MENU_KEY.OPEN_IN_NEW_TAB, label: isDir ? gettext('Open folder in new tab') : gettext('Open file in new tab') }, + { value: CONTEXT_MENU_KEY.OPEN_PARENT_FOLDER, label: gettext('Open parent folder') }, + { value: CONTEXT_MENU_KEY.DOWNLOAD, label: gettext('Download') }, + ]; + if (checkCanDeleteRow) { + validOptions.push({ value: CONTEXT_MENU_KEY.DELETE, label: isDir ? gettext('Delete folder') : gettext('Delete file') }); + } + if (canModifyRow) { + validOptions.push({ value: CONTEXT_MENU_KEY.RENAME, label: isDir ? gettext('Rename folder') : gettext('Rename file') }); + } + + return validOptions; + }, [isDir, checkCanDeleteRow, canModifyRow]); + + const closeZipDialog = useCallback(() => { + setIsZipDialogOpen(false); + }, []); + + const openRenameDialog = useCallback(() => { + setIsRenameDialogShow(true); + }, []); + + const handleRename = useCallback((newName) => { + if (!selectedCard) return; + const record = getRowById(metadata, selectedCard); + if (!record) return; + + const oldName = getFileNameFromRecord(record); + const updates = { [PRIVATE_COLUMN_KEY.FILE_NAME]: newName }; + const oldRowData = { [PRIVATE_COLUMN_KEY.FILE_NAME]: oldName }; + onRename(selectedCard, updates, oldRowData, updates, oldRowData, { + success_callback: () => setIsRenameDialogShow(false), + }); + }, [metadata, selectedCard, onRename]); + + const handelDownload = useCallback((record) => { + if (!isDir) { + downloadFile(repoID, record); + return; + } + if (!useGoFileserver) { + setIsZipDialogOpen(true); + return; + } + const fileName = getFileNameFromRecord(record); + metadataAPI.zipDownload(repoID, parentDir, [fileName]).then((res) => { + const zipToken = res.data['zip_token']; + location.href = `${fileServerRoot}zip/${zipToken}`; + }).catch(error => { + const errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + }, [repoID, isDir, parentDir]); + + const handleOptionClick = useCallback((option) => { + if (!selectedCard) return; + const record = getRowById(metadata, selectedCard); + if (!record) return; + + switch (option.value) { + case CONTEXT_MENU_KEY.OPEN_IN_NEW_TAB: { + openInNewTab(repoID, record); + break; + } + case CONTEXT_MENU_KEY.OPEN_PARENT_FOLDER: { + openParentFolder(record); + break; + } + case CONTEXT_MENU_KEY.DOWNLOAD: { + handelDownload(record); + break; + } + case CONTEXT_MENU_KEY.DELETE: { + onDelete([selectedCard]); + break; + } + case CONTEXT_MENU_KEY.RENAME: { + openRenameDialog(); + break; + } + default: { + break; + } + } + }, [metadata, repoID, selectedCard, onDelete, openRenameDialog, handelDownload]); + + return ( + <> + + {isRenameDialogShow && ( + + setIsRenameDialogShow(false)} + /> + + )} + {isZipDialogOpen && ( + + + + )} + + ); +}; + +KanbanContextMenu.propTypes = { + boundaryCoordinates: PropTypes.object, + selectedCard: PropTypes.string, + onDelete: PropTypes.func, + onRename: PropTypes.func, +}; + +export default KanbanContextMenu; diff --git a/frontend/src/metadata/views/kanban/index.js b/frontend/src/metadata/views/kanban/index.js index b91e921f96..2ba184639b 100644 --- a/frontend/src/metadata/views/kanban/index.js +++ b/frontend/src/metadata/views/kanban/index.js @@ -4,32 +4,101 @@ import { EVENT_BUS_TYPE } from '../../constants'; import toaster from '../../../components/toast'; import Boards from './boards'; import Settings from './settings'; +import { getRowById } from '../../utils/table'; +import { getFileNameFromRecord, getParentDirFromRecord } from '../../utils/cell'; +import { Utils, validateName } from '../../../utils/utils'; +import { gettext } from '../../../utils/constants'; import './index.css'; const Kanban = () => { const [isShowSettings, setShowSettings] = useState(false); - const { metadata, store } = useMetadataView(); + const { metadata, store, renameFileCallback, deleteFilesCallback } = useMetadataView(); const columns = useMemo(() => metadata.view.columns, [metadata.view.columns]); - const modifyRecord = useCallback((rowId, updates, oldRowData, originalUpdates, originalOldRowData) => { + const modifyRecord = useCallback((rowId, updates, oldRowData, originalUpdates, originalOldRowData, { success_callback }) => { const rowIds = [rowId]; const idRowUpdates = { [rowId]: updates }; const idOriginalRowUpdates = { [rowId]: originalUpdates }; const idOldRowData = { [rowId]: oldRowData }; const idOriginalOldRowData = { [rowId]: originalOldRowData }; - store.modifyRecords(rowIds, idRowUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData, false, false, { + const isRename = store.checkIsRenameFileOperator(rowIds, idOriginalRowUpdates); + let newName = null; + if (isRename) { + const rowId = rowIds[0]; + const row = getRowById(metadata, rowId); + const rowUpdates = idOriginalRowUpdates[rowId]; + const { _parent_dir, _name } = row; + newName = getFileNameFromRecord(rowUpdates); + const { isValid, errMessage } = validateName(newName); + if (!isValid) { + toaster.danger(errMessage); + return; + } + if (newName === _name) { + return; + } + if (store.checkDuplicatedName(newName, _parent_dir)) { + let errMessage = gettext('The name "{name}" is already taken. Please choose a different name.'); + errMessage = errMessage.replace('{name}', Utils.HTMLescape(newName)); + toaster.danger(errMessage); + return; + } + } + store.modifyRecords(rowIds, idRowUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData, false, isRename, { fail_callback: (error) => { error && toaster.danger(error); }, - success_callback: () => { + success_callback: (operation) => { + if (operation.is_rename) { + const rowId = operation.row_ids[0]; + const row = getRowById(metadata, rowId); + const rowUpdates = operation.id_original_row_updates[rowId]; + const oldRow = operation.id_original_old_row_data[rowId]; + const parentDir = getParentDirFromRecord(row); + const oldName = getFileNameFromRecord(oldRow); + const path = Utils.joinPath(parentDir, oldName); + const newName = getFileNameFromRecord(rowUpdates); + renameFileCallback(path, newName); + success_callback && success_callback(); + } const eventBus = window.sfMetadataContext.eventBus; eventBus.dispatch(EVENT_BUS_TYPE.LOCAL_RECORD_DETAIL_CHANGED, rowId, updates); }, }); - }, [store]); + }, [store, metadata, renameFileCallback]); + + const deleteRecords = useCallback((recordsIds, { success_callback }) => { + if (!Array.isArray(recordsIds) || recordsIds.length === 0) return; + let paths = []; + let fileNames = []; + recordsIds.forEach((recordId) => { + const record = getRowById(metadata, recordId); + const { _parent_dir, _name } = record || {}; + if (_parent_dir && _name) { + const path = Utils.joinPath(_parent_dir, _name); + paths.push(path); + fileNames.push(_name); + } + }); + store.deleteRecords(recordsIds, { + fail_callback: (error) => { + toaster.danger(error); + }, + success_callback: () => { + deleteFilesCallback(paths, fileNames); + let msg = fileNames.length > 1 + ? gettext('Successfully deleted {name} and {n} other items') + : gettext('Successfully deleted {name}'); + msg = msg.replace('{name}', fileNames[0]) + .replace('{n}', fileNames.length - 1); + toaster.success(msg); + success_callback && success_callback(); + }, + }); + }, [metadata, store, deleteFilesCallback]); const modifySettings = useCallback((newSettings) => { store.modifySettings(newSettings); @@ -54,18 +123,25 @@ const Kanban = () => { }, [isShowSettings]); return ( -
- -
- {isShowSettings && ( - - )} +
+
+ +
+ {isShowSettings && ( + + )} +
); diff --git a/frontend/src/metadata/views/table/context-menu/index.js b/frontend/src/metadata/views/table/context-menu/index.js index 4e2f89df42..cae7d7f9be 100644 --- a/frontend/src/metadata/views/table/context-menu/index.js +++ b/frontend/src/metadata/views/table/context-menu/index.js @@ -1,7 +1,7 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import PropTypes from 'prop-types'; import toaster from '../../../../components/toast'; -import { gettext, siteRoot } from '../../../../utils/constants'; +import { gettext } from '../../../../utils/constants'; import { Utils } from '../../../../utils/utils'; import { useMetadataView } from '../../../hooks/metadata-view'; import { useMetadataStatus } from '../../../../hooks'; @@ -12,6 +12,7 @@ import { getFileNameFromRecord, getParentDirFromRecord, getFileObjIdFromRecord, getRecordIdFromRecord, } from '../../../utils/cell'; import FileTagsDialog from '../../../components/dialog/file-tags-dialog'; +import { openInNewTab, openParentFolder } from '../../../utils/file'; const OPERATION = { CLEAR_SELECTED: 'clear-selected', @@ -175,32 +176,6 @@ const ContextMenu = (props) => { } }, [menuRef, visible]); - const onOpenFileInNewTab = useCallback((record) => { - const repoID = window.sfMetadataStore.repoId; - const isFolder = checkIsDir(record); - const parentDir = getParentDirFromRecord(record); - const fileName = getFileNameFromRecord(record); - - const url = isFolder ? - window.location.origin + window.location.pathname + Utils.encodePath(Utils.joinPath(parentDir, fileName)) : - `${siteRoot}lib/${repoID}/file${Utils.encodePath(Utils.joinPath(parentDir, fileName))}`; - - window.open(url, '_blank'); - }, []); - - const onOpenParentFolder = useCallback((event, record) => { - event.preventDefault(); - event.stopPropagation(); - let parentDir = getParentDirFromRecord(record); - - if (window.location.pathname.endsWith('/')) { - parentDir = parentDir.slice(1); - } - - const url = window.location.origin + window.location.pathname + Utils.encodePath(parentDir); - window.open(url, '_blank'); - }, []); - const generateDescription = useCallback((record) => { const descriptionColumnKey = PRIVATE_COLUMN_KEY.FILE_DESCRIPTION; let path = ''; @@ -325,17 +300,18 @@ const ContextMenu = (props) => { const handleOptionClick = useCallback((event, option) => { event.stopPropagation(); + const repoID = window.sfMetadataStore.repoId; switch (option.value) { case OPERATION.OPEN_IN_NEW_TAB: { const { record } = option; - if (!record) break; - onOpenFileInNewTab(record); + openInNewTab(repoID, record); break; } case OPERATION.OPEN_PARENT_FOLDER: { + event.preventDefault(); + event.stopPropagation(); const { record } = option; - if (!record) break; - onOpenParentFolder(event, record); + openParentFolder(record); break; } case OPERATION.COPY_SELECTED: { @@ -413,7 +389,7 @@ const ContextMenu = (props) => { } } setVisible(false); - }, [onOpenFileInNewTab, onOpenParentFolder, onCopySelected, onClearSelected, generateDescription, imageCaption, ocr, deleteRecords, toggleDeleteFolderDialog, selectNone, updateFileDetails, toggleFileTagsRecord]); + }, [onCopySelected, onClearSelected, generateDescription, imageCaption, ocr, deleteRecords, toggleDeleteFolderDialog, selectNone, updateFileDetails, toggleFileTagsRecord]); const getMenuPosition = useCallback((x = 0, y = 0) => { let menuStyles = { diff --git a/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/file-name-operation-btn/index.js b/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/file-name-operation-btn/index.js index a71c0811a9..64c4de8b5f 100644 --- a/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/file-name-operation-btn/index.js +++ b/frontend/src/metadata/views/table/table-main/records/record/cell/operation-btn/file-name-operation-btn/index.js @@ -5,7 +5,7 @@ import { IconBtn } from '@seafile/sf-metadata-ui-component'; import { gettext } from '../../../../../../../../../utils/constants'; import { EVENT_BUS_TYPE as METADATA_EVENT_BUS_TYPE, EDITOR_TYPE } from '../../../../../../../../constants'; import { checkIsDir } from '../../../../../../../../utils/row'; -import { openFile } from '../../../../../../../../utils/open-file'; +import { openFile } from '../../../../../../../../utils/file'; import './index.css'; diff --git a/frontend/src/tag/views/tag-files/tag-file/index.js b/frontend/src/tag/views/tag-files/tag-file/index.js index 3a1176c6fc..d4c15821d3 100644 --- a/frontend/src/tag/views/tag-files/tag-file/index.js +++ b/frontend/src/tag/views/tag-files/tag-file/index.js @@ -9,7 +9,7 @@ import { getParentDirFromRecord, getRecordIdFromRecord, getFileNameFromRecord, g getFileMTimeFromRecord, getTagsFromRecord, getFilePathByRecord, } from '../../../../metadata/utils/cell'; import { Utils } from '../../../../utils/utils'; -import { openFile } from '../../../../metadata/utils/open-file'; +import { openFile } from '../../../../metadata/utils/file'; import './index.css';