diff --git a/frontend/src/components/dir-view-mode/dir-column-view.js b/frontend/src/components/dir-view-mode/dir-column-view.js index c98dc1f6ba..330485e27b 100644 --- a/frontend/src/components/dir-view-mode/dir-column-view.js +++ b/frontend/src/components/dir-view-mode/dir-column-view.js @@ -64,7 +64,9 @@ const propTypes = { deleteFilesCallback: PropTypes.func, renameFileCallback: PropTypes.func, onItemMove: PropTypes.func.isRequired, + moveFileCallback: PropTypes.func.isRequired, onItemCopy: PropTypes.func.isRequired, + copyFileCallback: PropTypes.func.isRequired, onItemConvert: PropTypes.func.isRequired, onDirentClick: PropTypes.func.isRequired, isAllItemSelected: PropTypes.bool.isRequired, @@ -205,6 +207,9 @@ class DirColumnView extends React.Component { viewID={this.props.viewId} deleteFilesCallback={this.props.deleteFilesCallback} renameFileCallback={this.props.renameFileCallback} + moveFileCallback={this.props.moveFileCallback} + copyFileCallback={this.props.copyFileCallback} + addFolder={this.props.onAddFolder} updateCurrentDirent={this.props.updateCurrentDirent} closeDirentDetail={this.props.closeDirentDetail} showDirentDetail={this.props.showDirentDetail} diff --git a/frontend/src/metadata/context.js b/frontend/src/metadata/context.js index 6657b00b10..ba03f85045 100644 --- a/frontend/src/metadata/context.js +++ b/frontend/src/metadata/context.js @@ -125,6 +125,12 @@ class Context { return true; }; + canDuplicateRow = () => { + if (this.permission === 'r') return false; + const viewId = this.getSetting('viewID'); + return viewId !== FACE_RECOGNITION_VIEW_ID; + }; + canModifyColumn = (column) => { if (this.permission === 'r') return false; const { editable } = column; diff --git a/frontend/src/metadata/hooks/metadata-view.js b/frontend/src/metadata/hooks/metadata-view.js index 72b5dee703..74e3a51c61 100644 --- a/frontend/src/metadata/hooks/metadata-view.js +++ b/frontend/src/metadata/hooks/metadata-view.js @@ -3,13 +3,14 @@ import React, { useContext, useEffect, useRef, useState, useCallback } from 'rea import toaster from '../../components/toast'; import Context from '../context'; import Store from '../store'; -import { EVENT_BUS_TYPE, PER_LOAD_NUMBER } from '../constants'; +import { EVENT_BUS_TYPE, PER_LOAD_NUMBER, PRIVATE_COLUMN_KEY } from '../constants'; import { Utils, validateName } from '../../utils/utils'; import { useMetadata } from './metadata'; import { useCollaborators } from './collaborators'; import { getRowById } from '../utils/table'; -import { getFileNameFromRecord, getParentDirFromRecord } from '../utils/cell'; +import { getFileNameFromRecord, getParentDirFromRecord, getRecordIdFromRecord, getUniqueFileName } from '../utils/cell'; import { gettext } from '../../utils/constants'; +import { checkIsDir } from '../utils/row'; const MetadataViewContext = React.createContext(null); @@ -19,12 +20,17 @@ export const MetadataViewProvider = ({ viewID, renameFileCallback, deleteFilesCallback, + moveFileCallback, + copyFileCallback, ...params }) => { const [isLoading, setLoading] = useState(true); const [metadata, setMetadata] = useState({ rows: [], columns: [], view: {} }); const [errorMessage, setErrorMessage] = useState(null); + const storeRef = useRef(null); + const delayReloadDataTimer = useRef(null); + const { collaborators } = useCollaborators(); const { isBeingBuilt, setIsBeingBuilt } = useMetadata(); @@ -45,6 +51,7 @@ export const MetadataViewProvider = ({ storeRef.current.reload(PER_LOAD_NUMBER).then(() => { setMetadata(storeRef.current.data); setLoading(false); + delayReloadDataTimer.current = null; }).catch(error => { const errorMsg = Utils.getErrorMsg(error); setErrorMessage(errorMsg); @@ -52,6 +59,13 @@ export const MetadataViewProvider = ({ }); }, []); + const delayReloadMetadata = useCallback(() => { + delayReloadDataTimer.current && clearTimeout(delayReloadDataTimer.current); + delayReloadDataTimer.current = setTimeout(() => { + reloadMetadata(); + }, 600); + }, [reloadMetadata]); + const modifyFilters = useCallback((filters, filterConjunction, basicFilters) => { storeRef.current.modifyFilters(filterConjunction, filters, basicFilters); }, [storeRef]); @@ -162,6 +176,78 @@ export const MetadataViewProvider = ({ modifyRecords(rowIds, idRowUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData, isCopyPaste, { success_callback, fail_callback }); }; + const moveRecord = (rowId, targetRepo, dirent, targetParentPath, sourceParentPath, isByDialog) => { + const targetRepoId = targetRepo.repo_id; + const row = getRowById(metadata, rowId); + const { rows } = metadata; + const isDir = checkIsDir(row); + const oldName = dirent.name; + const oldParentPath = Utils.joinPath(sourceParentPath, oldName); + + let needDeletedRowIds = []; + let updateRowIds = []; + let idRowUpdates = {}; + let idOldRowData = {}; + + if (repoID === targetRepoId) { + const newName = getUniqueFileName(rows, targetParentPath, oldName); + updateRowIds.push(rowId); + idRowUpdates[rowId] = { [PRIVATE_COLUMN_KEY.PARENT_DIR]: targetParentPath, [PRIVATE_COLUMN_KEY.FILE_NAME]: newName }; + idOldRowData[rowId] = { [PRIVATE_COLUMN_KEY.PARENT_DIR]: sourceParentPath, [PRIVATE_COLUMN_KEY.FILE_NAME]: oldName }; + if (isDir) { + const newPath = Utils.joinPath(targetParentPath, newName); + rows.forEach((row) => { + const parentDir = getParentDirFromRecord(row); + if (row && parentDir.startsWith(oldParentPath)) { + const updateRowId = getRecordIdFromRecord(row); + updateRowIds.push(updateRowId); + idRowUpdates[updateRowId] = { [PRIVATE_COLUMN_KEY.PARENT_DIR]: parentDir.replace(oldParentPath, newPath) }; + idOldRowData[updateRowId] = { [PRIVATE_COLUMN_KEY.PARENT_DIR]: parentDir }; + } + }); + } + } else { + needDeletedRowIds = [rowId]; + if (isDir) { + rows.forEach((row) => { + const parentDir = getParentDirFromRecord(row); + if (row && parentDir.startsWith(oldParentPath)) { + const id = getRecordIdFromRecord(row); + needDeletedRowIds.push(id); + } + }); + } + } + + storeRef.current.moveRecord(rowId, targetRepoId, dirent, targetParentPath, sourceParentPath, { + modify_row_ids: updateRowIds, + modify_id_row_updates: idRowUpdates, + modify_id_old_row_data: idOldRowData, + delete_row_ids: needDeletedRowIds, + }, { + success_callback: (operation) => { + moveFileCallback && moveFileCallback(repoID, targetRepo, dirent, targetParentPath, sourceParentPath, operation.task_id, isByDialog); + }, + fail_callback: (error) => { + error && toaster.danger(error); + } + }); + }; + + const duplicateRecord = (rowId, targetRepo, dirent, targetPath, nodeParentPath, isByDialog) => { + storeRef.current.duplicateRecord(rowId, targetRepo.repo_id, dirent, targetPath, nodeParentPath, { + success_callback: (operation) => { + copyFileCallback && copyFileCallback(repoID, targetRepo, dirent, targetPath, nodeParentPath, operation.task_id, isByDialog); + if (repoID === targetRepo.repo_id) { + delayReloadMetadata(); + } + }, + fail_callback: (error) => { + error && toaster.danger(error); + } + }); + }; + const renameColumn = useCallback((columnKey, newName, oldName) => { storeRef.current.renameColumn(columnKey, newName, oldName); }, [storeRef]); @@ -239,6 +325,7 @@ export const MetadataViewProvider = ({ unsubscribeModifyColumnOrder(); unsubscribeModifySettings(); unsubscribeLocalRecordChanged(); + delayReloadDataTimer.current && clearTimeout(delayReloadDataTimer.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [repoID, viewID]); @@ -261,6 +348,8 @@ export const MetadataViewProvider = ({ modifyRecords, deleteRecords, modifyRecord, + moveRecord, + duplicateRecord, renameColumn, deleteColumn, modifyColumnOrder, @@ -268,6 +357,7 @@ export const MetadataViewProvider = ({ modifyColumnWidth, insertColumn, updateFileTags, + addFolder: params.addFolder, }} > {children} diff --git a/frontend/src/metadata/store/data-processor.js b/frontend/src/metadata/store/data-processor.js index 5838744d1b..9367e305a5 100644 --- a/frontend/src/metadata/store/data-processor.js +++ b/frontend/src/metadata/store/data-processor.js @@ -39,7 +39,6 @@ class DataProcessor { } static updateSummaries(table, rows) { - // const tableRows = isTableRows(rows) ? rows : getRowsByIds(table, rows); // todo } @@ -109,7 +108,7 @@ class DataProcessor { // todo update sort and filter and ui change } - static updatePageDataWithDeleteRecords(deletedRowsIds, table) { + static updateDataWithDeleteRecords(deletedRowsIds, table) { const { available_columns, groupbys, groups, rows } = table.view; const idNeedDeletedMap = deletedRowsIds.reduce((currIdNeedDeletedMap, rowId) => ({ ...currIdNeedDeletedMap, [rowId]: true }), {}); table.view.rows = rows.filter(rowId => !idNeedDeletedMap[rowId]); @@ -228,7 +227,7 @@ class DataProcessor { } case OPERATION_TYPE.DELETE_RECORDS: { const { rows_ids } = operation; - this.updatePageDataWithDeleteRecords(rows_ids, table); + this.updateDataWithDeleteRecords(rows_ids, table); this.updateSummaries(); break; } @@ -255,6 +254,10 @@ class DataProcessor { this.updateSummaries(); break; } + case OPERATION_TYPE.MOVE_RECORD: { + this.run(table, { collaborators }); + break; + } case OPERATION_TYPE.MODIFY_GROUPBYS: { const { available_columns, groupbys, rows } = table.view; if (!isGroupView({ groupbys }, available_columns)) { diff --git a/frontend/src/metadata/store/index.js b/frontend/src/metadata/store/index.js index 9dbab8fe4f..7b04c8efdc 100644 --- a/frontend/src/metadata/store/index.js +++ b/frontend/src/metadata/store/index.js @@ -12,7 +12,7 @@ import LocalOperator from './local-operator'; import Metadata from '../model/metadata'; import { checkIsDir } from '../utils/row'; import { Utils } from '../../utils/utils'; -import { getFileNameFromRecord } from '../utils/cell'; +import { getFileNameFromRecord, checkDuplicatedName } from '../utils/cell'; class Store { @@ -300,16 +300,13 @@ class Store { } deleteRecords(rows_ids, { fail_callback, success_callback }) { + if (!Array.isArray(rows_ids) || rows_ids.length === 0) return; const type = OPERATION_TYPE.DELETE_RECORDS; - if (!Array.isArray(rows_ids) || rows_ids.length === 0) { - return; - } - const valid_rows_ids = Array.isArray(rows_ids) ? rows_ids.filter((rowId) => { + const valid_rows_ids = rows_ids.filter((rowId) => { const row = getRowById(this.data, rowId); return row && this.context.canModifyRow(row); - }) : []; - + }); // delete rows where parent dir is deleted const deletedDirsPaths = rows_ids.map((rowId) => { @@ -408,6 +405,39 @@ class Store { this.applyOperation(operation); } + moveRecord(row_id, target_repo_id, dirent, target_parent_path, source_parent_path, update_data, { success_callback, fail_callback }) { + const type = OPERATION_TYPE.MOVE_RECORD; + const operation = this.createOperation({ + type, + repo_id: this.repoId, + row_id, + target_repo_id, + dirent, + target_parent_path, + source_parent_path, + update_data, + success_callback, + fail_callback, + }); + this.applyOperation(operation); + } + + duplicateRecord(row_id, target_repo_id, dirent, target_parent_path, source_parent_path, { success_callback, fail_callback }) { + const type = OPERATION_TYPE.DUPLICATE_RECORD; + const operation = this.createOperation({ + type, + repo_id: this.repoId, + row_id, + target_repo_id, + dirent, + target_parent_path, + source_parent_path, + success_callback, + fail_callback, + }); + this.applyOperation(operation); + } + modifyFilters(filterConjunction, filters, basicFilters = []) { const type = OPERATION_TYPE.MODIFY_FILTERS; const operation = this.createOperation({ @@ -532,8 +562,7 @@ class Store { }; checkDuplicatedName = (name, parentDir) => { - const newPath = Utils.joinPath(parentDir, name); - return this.data.rows.some((row) => newPath === Utils.joinPath(row._parent_dir, row._name)); + return checkDuplicatedName(this.data.rows, parentDir, name); }; renamePeopleName = (peopleId, newName, oldName) => { diff --git a/frontend/src/metadata/store/operations/apply.js b/frontend/src/metadata/store/operations/apply.js index faf36a2f4a..ee43e4eedf 100644 --- a/frontend/src/metadata/store/operations/apply.js +++ b/frontend/src/metadata/store/operations/apply.js @@ -12,41 +12,50 @@ dayjs.extend(utc); export default function apply(data, operation) { const { op_type } = operation; + const updateDataByModifyRecords = ({ id_original_row_updates = {}, id_row_updates = {} } = {}) => { + const { rows } = data; + const modifyTime = dayjs().utc().format(UTC_FORMAT_DEFAULT); + const modifier = window.sfMetadataContext.getUsername(); + let updatedRows = [...rows]; + + rows.forEach((row, index) => { + const { _id: rowId } = row; + const originalRowUpdates = id_original_row_updates[rowId]; + const rowUpdates = id_row_updates[rowId]; + if (rowUpdates || originalRowUpdates) { + const updatedRow = Object.assign({}, row, rowUpdates, originalRowUpdates, { + '_mtime': modifyTime, + '_last_modifier': modifier, + }); + updatedRows[index] = updatedRow; + data.id_row_map[rowId] = updatedRow; + } + }); + + data.rows = updatedRows; + }; + + const updateDataByDeleteRecords = (deletedIds) => { + const idNeedDeletedMap = deletedIds.reduce((currIdNeedDeletedMap, rowId) => ({ ...currIdNeedDeletedMap, [rowId]: true }), {}); + data.rows = data.rows.filter((row) => !idNeedDeletedMap[row._id]); + + // delete rows in id_row_map + deletedIds.forEach(rowId => { + delete data.id_row_map[rowId]; + }); + + data.row_ids = data.row_ids.filter(row_id => !idNeedDeletedMap[row_id]); + }; + switch (op_type) { case OPERATION_TYPE.MODIFY_RECORDS: { const { id_original_row_updates, id_row_updates } = operation; - const { rows } = data; - const modifyTime = dayjs().utc().format(UTC_FORMAT_DEFAULT); - const modifier = window.sfMetadataContext.getUsername(); - let updatedRows = [...rows]; - - rows.forEach((row, index) => { - const { _id: rowId } = row; - const originalRowUpdates = id_original_row_updates[rowId]; - const rowUpdates = id_row_updates[rowId]; - if (rowUpdates || originalRowUpdates) { - const updatedRow = Object.assign({}, row, rowUpdates, originalRowUpdates, { - '_mtime': modifyTime, - '_last_modifier': modifier, - }); - updatedRows[index] = updatedRow; - data.id_row_map[rowId] = updatedRow; - } - }); - - data.rows = updatedRows; + updateDataByModifyRecords({ id_original_row_updates, id_row_updates }); return data; } case OPERATION_TYPE.DELETE_RECORDS: { const { rows_ids } = operation; - const idNeedDeletedMap = rows_ids.reduce((currIdNeedDeletedMap, rowId) => ({ ...currIdNeedDeletedMap, [rowId]: true }), {}); - data.rows = data.rows.filter((row) => !idNeedDeletedMap[row._id]); - - // delete rows in id_row_map - rows_ids.forEach(rowId => { - delete data.id_row_map[rowId]; - }); - + updateDataByDeleteRecords(rows_ids); return data; } case OPERATION_TYPE.RESTORE_RECORDS: { @@ -117,6 +126,23 @@ export default function apply(data, operation) { data.rows = updatedRows; return data; } + case OPERATION_TYPE.MOVE_RECORD: { + const { update_data } = operation; + const { + modify_row_ids: updateRowIds, + modify_id_row_updates: idRowUpdates, + delete_row_ids: deletedRowIds, + } = update_data; + + if (Array.isArray(updateRowIds) && updateRowIds.length > 0) { + updateDataByModifyRecords({ id_row_updates: idRowUpdates }); + } + + if (Array.isArray(deletedRowIds) && deletedRowIds.length > 0) { + updateDataByDeleteRecords(deletedRowIds); + } + return data; + } case OPERATION_TYPE.MODIFY_FILTERS: { const { filter_conjunction, filters, basic_filters } = operation; data.view.filter_conjunction = filter_conjunction; diff --git a/frontend/src/metadata/store/operations/constants.js b/frontend/src/metadata/store/operations/constants.js index 44512b97d4..ca25214fd5 100644 --- a/frontend/src/metadata/store/operations/constants.js +++ b/frontend/src/metadata/store/operations/constants.js @@ -1,16 +1,10 @@ export const OPERATION_TYPE = { - MODIFY_RECORDS: 'modify_records', - DELETE_RECORDS: 'delete_records', - RESTORE_RECORDS: 'restore_records', - RELOAD_RECORDS: 'reload_records', + // view MODIFY_FILTERS: 'modify_filters', MODIFY_SORTS: 'modify_sorts', MODIFY_GROUPBYS: 'modify_groupbys', MODIFY_HIDDEN_COLUMNS: 'modify_hidden_columns', - LOCK_RECORD_VIA_BUTTON: 'lock_record_via_button', - MODIFY_RECORD_VIA_BUTTON: 'modify_record_via_button', MODIFY_SETTINGS: 'modify_settings', - MODIFY_LOCAL_RECORD: 'modify_local_record', // column INSERT_COLUMN: 'insert_column', @@ -20,6 +14,17 @@ export const OPERATION_TYPE = { MODIFY_COLUMN_WIDTH: 'modify_column_width', MODIFY_COLUMN_ORDER: 'modify_column_order', + // record + MODIFY_RECORDS: 'modify_records', + DELETE_RECORDS: 'delete_records', + RESTORE_RECORDS: 'restore_records', + RELOAD_RECORDS: 'reload_records', + LOCK_RECORD_VIA_BUTTON: 'lock_record_via_button', + MODIFY_RECORD_VIA_BUTTON: 'modify_record_via_button', + MODIFY_LOCAL_RECORD: 'modify_local_record', + MOVE_RECORD: 'move_record', + DUPLICATE_RECORD: 'duplicate_record', + // face table RENAME_PEOPLE_NAME: 'rename_people_name', DELETE_PEOPLE_PHOTOS: 'delete_people_photos', @@ -41,6 +46,8 @@ export const OPERATION_ATTRIBUTES = { [OPERATION_TYPE.MODIFY_RECORDS]: ['repo_id', 'row_ids', 'id_row_updates', 'id_original_row_updates', 'id_old_row_data', 'id_original_old_row_data', 'is_copy_paste', 'is_rename', 'id_obj_id'], [OPERATION_TYPE.DELETE_RECORDS]: ['repo_id', 'rows_ids', 'deleted_rows'], [OPERATION_TYPE.RELOAD_RECORDS]: ['repo_id', 'row_ids'], + [OPERATION_TYPE.MOVE_RECORD]: ['repo_id', 'row_id', 'target_repo_id', 'dirent', 'target_parent_path', 'source_parent_path', 'update_data'], + [OPERATION_TYPE.DUPLICATE_RECORD]: ['repo_id', 'row_id', 'target_repo_id', 'dirent', 'target_parent_path', 'source_parent_path'], [OPERATION_TYPE.MODIFY_FILTERS]: ['repo_id', 'view_id', 'filter_conjunction', 'filters', 'basic_filters'], [OPERATION_TYPE.MODIFY_SORTS]: ['repo_id', 'view_id', 'sorts'], @@ -83,6 +90,8 @@ export const NEED_APPLY_AFTER_SERVER_OPERATION = [ OPERATION_TYPE.INSERT_COLUMN, OPERATION_TYPE.MODIFY_FILTERS, OPERATION_TYPE.MODIFY_SORTS, + OPERATION_TYPE.MOVE_RECORD, + OPERATION_TYPE.DUPLICATE_RECORD, ]; export const VIEW_OPERATION = [ diff --git a/frontend/src/metadata/store/server-operator.js b/frontend/src/metadata/store/server-operator.js index e93ca8b4e5..5b4d04d182 100644 --- a/frontend/src/metadata/store/server-operator.js +++ b/frontend/src/metadata/store/server-operator.js @@ -82,6 +82,30 @@ class ServerOperator { callback({ operation }); break; } + case OPERATION_TYPE.MOVE_RECORD: { + const { row_id, repo_id, target_repo_id, dirent, target_parent_path, source_parent_path } = operation; + seafileAPI.moveDir(repo_id, target_repo_id, target_parent_path, source_parent_path, dirent.name).then(res => { + operation.task_id = res.data.task_id || null; + callback({ operation }); + }).catch(error => { + const row = getRowById(data, row_id); + const isDir = checkIsDir(row); + callback({ error: isDir ? gettext('Failed to move folder') : gettext('Failed to move file') }); + }); + break; + } + case OPERATION_TYPE.DUPLICATE_RECORD: { + const { row_id, repo_id, target_repo_id, dirent, target_parent_path, source_parent_path } = operation; + seafileAPI.copyDir(repo_id, target_repo_id, target_parent_path, source_parent_path, dirent.name).then(res => { + operation.task_id = res.data.task_id || null; + callback({ operation }); + }).catch(error => { + const row = getRowById(data, row_id); + const isDir = checkIsDir(row); + callback({ error: isDir ? gettext('Failed to duplicate folder') : gettext('Failed to duplicate file') }); + }); + break; + } case OPERATION_TYPE.INSERT_COLUMN: { const { repo_id, name, column_type, column_key, data } = operation; window.sfMetadataContext.insertColumn(repo_id, name, column_type, { key: column_key, data }).then(res => { diff --git a/frontend/src/metadata/utils/cell/column/file-name.js b/frontend/src/metadata/utils/cell/column/file-name.js new file mode 100644 index 0000000000..0ca859671a --- /dev/null +++ b/frontend/src/metadata/utils/cell/column/file-name.js @@ -0,0 +1,17 @@ +import { Utils } from '../../../../utils/utils'; + +export const checkDuplicatedName = (rows, parentDir, fileName) => { + if (!Array.isArray(rows) || rows.length === 0 || !parentDir || !fileName) return false; + const newPath = Utils.joinPath(parentDir, fileName); + return rows.some((row) => newPath === Utils.joinPath(row._parent_dir, row._name)); +}; + +export const getUniqueFileName = (rows, parentDir, fileName) => { + const dotIndex = fileName.lastIndexOf('.'); + const fileType = dotIndex === -1 ? '' : fileName.slice(dotIndex + 1); + let newName = dotIndex === -1 ? fileName : fileName.slice(0, dotIndex); + while (checkDuplicatedName(rows, parentDir, dotIndex === -1 ? newName : `${newName}.${fileType}`)) { + newName = newName + ' (1)'; + } + return dotIndex === -1 ? newName : `${newName}.${fileType}`; +}; diff --git a/frontend/src/metadata/utils/cell/column/index.js b/frontend/src/metadata/utils/cell/column/index.js index 6e6a10f71d..30856f24ba 100644 --- a/frontend/src/metadata/utils/cell/column/index.js +++ b/frontend/src/metadata/utils/cell/column/index.js @@ -31,3 +31,7 @@ export { export { getTagsDisplayString, } from './tag'; +export { + checkDuplicatedName, + getUniqueFileName, +} from './file-name'; diff --git a/frontend/src/metadata/views/gallery/context-menu/index.js b/frontend/src/metadata/views/gallery/context-menu/index.js index bde8d065ec..ed79d0b26a 100644 --- a/frontend/src/metadata/views/gallery/context-menu/index.js +++ b/frontend/src/metadata/views/gallery/context-menu/index.js @@ -9,30 +9,47 @@ import metadataAPI from '../../../api'; import toaster from '../../../../components/toast'; import { Utils } from '../../../../utils/utils'; import ModalPortal from '../../../../components/modal-portal'; +import CopyDirent from '../../../../components/dialog/copy-dirent-dialog'; +import { Dirent } from '../../../../models'; const CONTEXT_MENU_KEY = { DOWNLOAD: 'download', DELETE: 'delete', + DUPLICATE: 'duplicate', }; -const GalleryContextMenu = ({ metadata, selectedImages, boundaryCoordinates, onDelete }) => { +const GalleryContextMenu = ({ metadata, selectedImages, boundaryCoordinates, onDelete, onDuplicate, addFolder }) => { const [isZipDialogOpen, setIsZipDialogOpen] = useState(false); + const [isCopyDialogOpen, setIsCopyDialogOpen] = useState(false); const repoID = window.sfMetadataContext.getSetting('repoID'); const checkCanDeleteRow = window.sfMetadataContext.checkCanDeleteRow(); + const canDuplicateRow = window.sfMetadataContext.canDuplicateRow(); const options = useMemo(() => { 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') }); } + if (canDuplicateRow && selectedImages.length === 1) { + validOptions.push({ value: CONTEXT_MENU_KEY.DUPLICATE, label: gettext('Duplicate') }); + } return validOptions; - }, [checkCanDeleteRow, selectedImages]); + }, [checkCanDeleteRow, canDuplicateRow, selectedImages]); const closeZipDialog = () => { setIsZipDialogOpen(false); }; + const toggleCopyDialog = useCallback(() => { + setIsCopyDialogOpen(!isCopyDialogOpen); + }, [isCopyDialogOpen]); + + const handleDuplicate = useCallback((destRepo, dirent, destPath, nodeParentPath, isByDialog) => { + const selectedImage = selectedImages[0]; + onDuplicate(selectedImage.id, destRepo, dirent, destPath, nodeParentPath, isByDialog); + }, [selectedImages, onDuplicate]); + const handleDownload = useCallback(() => { if (!selectedImages.length) return; if (selectedImages.length === 1) { @@ -66,10 +83,16 @@ const GalleryContextMenu = ({ metadata, selectedImages, boundaryCoordinates, onD case 'delete': onDelete(selectedImages); break; + case CONTEXT_MENU_KEY.DUPLICATE: + toggleCopyDialog(); + break; default: break; } - }, [selectedImages, handleDownload, onDelete]); + }, [selectedImages, handleDownload, onDelete, toggleCopyDialog]); + + const dirent = new Dirent({ name: selectedImages[0]?.name }); + const path = selectedImages[0]?.path; return ( <> @@ -89,6 +112,20 @@ const GalleryContextMenu = ({ metadata, selectedImages, boundaryCoordinates, onD /> )} + {isCopyDialogOpen && ( + + + + )} ); }; @@ -98,6 +135,8 @@ GalleryContextMenu.propTypes = { selectedImages: PropTypes.array, boundaryCoordinates: PropTypes.object, onDelete: PropTypes.func, + onDuplicate: PropTypes.func, + addFolder: PropTypes.func, }; export default GalleryContextMenu; diff --git a/frontend/src/metadata/views/gallery/index.js b/frontend/src/metadata/views/gallery/index.js index 04e430dec1..a929240322 100644 --- a/frontend/src/metadata/views/gallery/index.js +++ b/frontend/src/metadata/views/gallery/index.js @@ -10,7 +10,7 @@ import './index.css'; const Gallery = () => { const [isLoadingMore, setLoadingMore] = useState(false); - const { metadata, store, deleteRecords } = useMetadataView(); + const { metadata, store, deleteRecords, duplicateRecord, addFolder } = useMetadataView(); const onLoadMore = useCallback(async () => { if (isLoadingMore) return; @@ -43,7 +43,14 @@ const Gallery = () => { return (
-
+
); }; diff --git a/frontend/src/metadata/views/gallery/main.js b/frontend/src/metadata/views/gallery/main.js index f970c450aa..c9bd812083 100644 --- a/frontend/src/metadata/views/gallery/main.js +++ b/frontend/src/metadata/views/gallery/main.js @@ -6,9 +6,9 @@ import ImageDialog from '../../../components/dialog/image-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 { getDateDisplayString, getFileNameFromRecord, getParentDirFromRecord, getRecordIdFromRecord } from '../../utils/cell'; 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 { EVENT_BUS_TYPE, 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'; @@ -17,7 +17,7 @@ import './index.css'; const OVER_SCAN_ROWS = 20; -const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore }) => { +const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore, duplicateRecord, onAddFolder }) => { const [isFirstLoading, setFirstLoading] = useState(true); const [zoomGear, setZoomGear] = useState(0); const [containerWidth, setContainerWidth] = useState(0); @@ -65,7 +65,7 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore }) => { const firstSort = metadata.view.sorts[0]; let init = metadata.rows.filter(row => Utils.imageCheck(getFileNameFromRecord(row))) .reduce((_init, record) => { - const id = record[PRIVATE_COLUMN_KEY.ID]; + const id = getRecordIdFromRecord(record); const fileName = getFileNameFromRecord(record); const parentDir = getParentDirFromRecord(record); const path = Utils.encodePath(Utils.joinPath(parentDir, fileName)); @@ -368,6 +368,8 @@ const Main = ({ isLoadingMore, metadata, onDelete, onLoadMore }) => { selectedImages={selectedImages} boundaryCoordinates={containerRef?.current?.getBoundingClientRect() || {}} onDelete={handleDeleteSelectedImages} + onDuplicate={duplicateRecord} + addFolder={onAddFolder} /> {isImagePopupOpen && ( @@ -389,7 +391,9 @@ Main.propTypes = { isLoadingMore: PropTypes.bool, metadata: PropTypes.object, onDelete: PropTypes.func, - onLoadMore: PropTypes.func + onLoadMore: PropTypes.func, + duplicateRecord: PropTypes.func, + onAddFolder: PropTypes.func, }; export default Main; diff --git a/frontend/src/metadata/views/table/context-menu/index.js b/frontend/src/metadata/views/table/context-menu/index.js index cae7d7f9be..4fd6c0cc33 100644 --- a/frontend/src/metadata/views/table/context-menu/index.js +++ b/frontend/src/metadata/views/table/context-menu/index.js @@ -13,6 +13,9 @@ import { getFileNameFromRecord, getParentDirFromRecord, getFileObjIdFromRecord, } from '../../../utils/cell'; import FileTagsDialog from '../../../components/dialog/file-tags-dialog'; import { openInNewTab, openParentFolder } from '../../../utils/file'; +import DeleteFolderDialog from '../../../../components/dialog/delete-folder-dialog'; +import MoveDirent from '../../../../components/dialog/move-dirent-dialog'; +import { Dirent } from '../../../../models'; const OPERATION = { CLEAR_SELECTED: 'clear-selected', @@ -28,21 +31,27 @@ const OPERATION = { RENAME_FILE: 'rename-file', FILE_DETAIL: 'file-detail', FILE_DETAILS: 'file-details', + MOVE: 'move', }; -const ContextMenu = (props) => { - const { - isGroupView, selectedRange, selectedPosition, recordMetrics, recordGetterByIndex, onClearSelected, onCopySelected, updateRecords, - getTableContentRect, getTableCanvasContainerRect, deleteRecords, toggleDeleteFolderDialog, selectNone, updateFileTags, - } = props; +const ContextMenu = ({ + isGroupView, selectedRange, selectedPosition, recordMetrics, recordGetterByIndex, onClearSelected, onCopySelected, updateRecords, + getTableContentRect, getTableCanvasContainerRect, deleteRecords, selectNone, updateFileTags, moveRecord, addFolder +}) => { const menuRef = useRef(null); + const currentRecord = useRef(null); + const [visible, setVisible] = useState(false); const [position, setPosition] = useState({ top: 0, left: 0 }); const [fileTagsRecord, setFileTagsRecord] = useState(null); + const [deletedFolderPath, setDeletedFolderPath] = useState(''); + const [isMoveDialogShow, setMoveDialogShow] = useState(false); const { metadata } = useMetadataView(); const { enableOCR } = useMetadataStatus(); + const repoID = window.sfMetadataStore.repoId; + const checkCanModifyRow = (row) => { return window.sfMetadataContext.canModifyRow(row); }; @@ -56,6 +65,29 @@ const ContextMenu = (props) => { return records.filter(record => window.sfMetadataContext.checkCanDeleteRow(record)); }, []); + const toggleDeleteFolderDialog = useCallback((record) => { + if (deletedFolderPath) { + currentRecord.current = null; + setDeletedFolderPath(''); + return; + } + const parentDir = getParentDirFromRecord(record); + const fileName = getFileNameFromRecord(record); + currentRecord.current = record; + setDeletedFolderPath(Utils.joinPath(parentDir, fileName)); + }, [deletedFolderPath]); + + const toggleMoveDialog = useCallback((record) => { + currentRecord.current = record || null; + setMoveDialogShow(!isMoveDialogShow); + }, [isMoveDialogShow]); + + const deleteFolder = useCallback(() => { + if (!currentRecord.current) return; + const currentRecordId = getRecordIdFromRecord(currentRecord.current); + deleteRecords([currentRecordId]); + }, [deleteRecords]); + const options = useMemo(() => { if (!visible) return []; const permission = window.sfMetadataContext.getPermission(); @@ -162,6 +194,10 @@ const ContextMenu = (props) => { list.push({ value: OPERATION.RENAME_FILE, label: isFolder ? gettext('Rename folder') : gettext('Rename file'), record }); } + if (canModifyRow) { + list.push({ value: OPERATION.MOVE, label: isFolder ? gettext('Move folder') : gettext('Move file'), record }); + } + return list; }, [visible, isGroupView, selectedPosition, recordMetrics, selectedRange, metadata, recordGetterByIndex, checkIsDescribableDoc, enableOCR, getAbleDeleteRecords]); @@ -300,7 +336,6 @@ 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; @@ -384,12 +419,18 @@ const ContextMenu = (props) => { updateFileDetails([record]); break; } + case OPERATION.MOVE: { + const { record } = option; + if (!record) break; + toggleMoveDialog(record); + break; + } default: { break; } } setVisible(false); - }, [onCopySelected, onClearSelected, generateDescription, imageCaption, ocr, deleteRecords, toggleDeleteFolderDialog, selectNone, updateFileDetails, toggleFileTagsRecord]); + }, [repoID, onCopySelected, onClearSelected, generateDescription, imageCaption, ocr, deleteRecords, toggleDeleteFolderDialog, selectNone, updateFileDetails, toggleFileTagsRecord, toggleMoveDialog]); const getMenuPosition = useCallback((x = 0, y = 0) => { let menuStyles = { @@ -473,14 +514,35 @@ const ContextMenu = (props) => { ); }, [visible, options, position, handleOptionClick]); + const currentRecordId = getRecordIdFromRecord(currentRecord.current); + const fileName = getFileNameFromRecord(currentRecord.current); + return ( <> {renderMenu()} {fileTagsRecord && ( )} + {deletedFolderPath && ( + + )} + {isMoveDialogShow && ( + moveRecord(currentRecordId, ...params)} + onCancelMove={toggleMoveDialog} + onAddFolder={addFolder} + /> + )} - ); }; @@ -493,6 +555,8 @@ ContextMenu.propTypes = { getTableContentRect: PropTypes.func, recordGetterByIndex: PropTypes.func, deleteRecords: PropTypes.func, + moveRecord: PropTypes.func, + addFolder: PropTypes.func, }; export default ContextMenu; diff --git a/frontend/src/metadata/views/table/index.js b/frontend/src/metadata/views/table/index.js index dedf807d86..b4af4e3b98 100644 --- a/frontend/src/metadata/views/table/index.js +++ b/frontend/src/metadata/views/table/index.js @@ -25,7 +25,9 @@ const Table = () => { modifyColumnOrder, modifyColumnWidth, insertColumn, - updateFileTags + updateFileTags, + moveRecord, + addFolder } = useMetadataView(); const containerRef = useRef(null); @@ -172,6 +174,8 @@ const Table = () => { updateFileTags={updateFileTags} onGridKeyDown={onHotKey} onGridKeyUp={onHotKeyUp} + moveRecord={moveRecord} + addFolder={addFolder} /> ); diff --git a/frontend/src/metadata/views/table/table-main/records/index.js b/frontend/src/metadata/views/table/table-main/records/index.js index e06c121444..3b0ab7b41b 100644 --- a/frontend/src/metadata/views/table/table-main/records/index.js +++ b/frontend/src/metadata/views/table/table-main/records/index.js @@ -2,7 +2,6 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { HorizontalScrollbar } from '../../../../components/scrollbar'; import EmptyTip from '../../../../../components/empty-tip'; -import DeleteFolderDialog from '../../../../../components/dialog/delete-folder-dialog'; import Body from './body'; import GroupBody from './group-body'; import RecordsHeader from '../records-header'; @@ -11,7 +10,7 @@ import ContextMenu from '../../context-menu'; import { recalculate } from '../../../../utils/column'; import { getEventClassName } from '../../../../utils/common'; import { SEQUENCE_COLUMN_WIDTH, CANVAS_RIGHT_INTERVAL, GROUP_ROW_TYPE, EVENT_BUS_TYPE } from '../../../../constants'; -import { isMobile, Utils } from '../../../../../utils/utils'; +import { isMobile } from '../../../../../utils/utils'; import { isShiftKeyDown } from '../../../../utils/keyboard-utils'; import { gettext } from '../../../../../utils/constants'; import RecordMetrics from '../../utils/record-metrics'; @@ -43,7 +42,6 @@ class Records extends Component { }, selectedPosition: this.initPosition, ...initHorizontalScrollState, - deletedFolderPath: '', }; this.isWindows = isWindowsBrowser(); this.isWebkit = isWebkitBrowser(); @@ -623,23 +621,6 @@ class Records extends Component { return this.resultContainerRef.getBoundingClientRect(); }; - toggleDeleteFolderDialog = (record) => { - if (this.state.deletedFolderPath) { - this.deletedRecord = null; - this.setState({ deletedFolderPath: '' }); - } else { - const { _parent_dir, _name } = record; - const deletedFolderPath = Utils.joinPath(_parent_dir, _name); - this.deletedRecord = record; - this.setState({ deletedFolderPath: deletedFolderPath }); - } - }; - - deleteFolder = () => { - if (!this.deletedRecord) return; - this.props.deleteRecords([this.deletedRecord._id]); - }; - renderRecordsBody = ({ containerWidth }) => { const { isGroupView } = this.props; const { recordMetrics, columnMetrics, colOverScanStartIdx, colOverScanEndIdx } = this.state; @@ -651,10 +632,11 @@ class Records extends Component { contextMenu: ( ), hasSelectedRecord: this.hasSelectedRecord(), @@ -694,7 +676,7 @@ class Records extends Component { render() { const { recordIds, recordsCount, table, isGroupView, groupOffsetLeft, renameColumn, modifyColumnData, - deleteColumn, modifyColumnOrder, insertColumn, + deleteColumn, modifyColumnOrder, insertColumn } = this.props; const { recordMetrics, columnMetrics, selectedRange, colOverScanStartIdx, colOverScanEndIdx } = this.state; const { columns, totalWidth, lastFrozenColumnKey } = columnMetrics; @@ -763,14 +745,6 @@ class Records extends Component { getRecordsSummaries={() => { }} loadAll={this.props.loadAll} /> - {this.state.deletedFolderPath && ( - - )} ); } @@ -803,6 +777,8 @@ Records.propTypes = { modifyColumnWidth: PropTypes.func, modifyColumnOrder: PropTypes.func, getCopiedRecordsAndColumnsFromRange: PropTypes.func, + moveRecord: PropTypes.func, + addFolder: PropTypes.func, }; export default Records; diff --git a/frontend/src/pages/lib-content-view/lib-content-view.js b/frontend/src/pages/lib-content-view/lib-content-view.js index 7b8b9994bf..00e514caaf 100644 --- a/frontend/src/pages/lib-content-view/lib-content-view.js +++ b/frontend/src/pages/lib-content-view/lib-content-view.js @@ -1228,55 +1228,59 @@ class LibContentView extends React.Component { }; // list operations - onMoveItem = (destRepo, dirent, moveToDirentPath, nodeParentPath, byDialog = false) => { + moveItemsAjaxCallback = (repoID, targetRepo, dirent, moveToDirentPath, nodeParentPath, taskId, byDialog = false) => { this.updateCurrentNotExistDirent(dirent); + + const dirName = dirent.name; + const direntPath = Utils.joinPath(nodeParentPath, dirName); + if (repoID !== targetRepo.repo_id) { + this.setState({ + asyncCopyMoveTaskId: taskId, + asyncOperatedFilesLength: 1, + asyncOperationProgress: 0, + asyncOperationType: 'move', + isCopyMoveProgressDialogShow: true, + }, () => { + this.currentMoveItemName = dirName; + this.currentMoveItemPath = direntPath; + this.getAsyncCopyMoveProgress(dirName, direntPath); + }); + } + + if (this.state.isTreePanelShown) { + this.deleteTreeNode(direntPath); + } + + // 1. move to current repo + // 2. tow columns mode need update left tree + if (repoID === targetRepo.repo_id && this.state.isTreePanelShown) { + this.updateMoveCopyTreeNode(moveToDirentPath); + } + + this.moveDirent(direntPath, moveToDirentPath); + + // show tip message if move to current repo + if (repoID === targetRepo.repo_id) { + let message = gettext('Successfully moved {name}.'); + message = message.replace('{name}', dirName); + toaster.success(message); + } + + if (byDialog) { + this.updateRecentlyUsedRepos(targetRepo, moveToDirentPath); + } + }; + + onMoveItem = (destRepo, dirent, moveToDirentPath, nodeParentPath, byDialog = false) => { let repoID = this.props.repoID; // just for view list state let dirName = dirent.name; if (!nodeParentPath) { nodeParentPath = this.state.path; } - let direntPath = Utils.joinPath(nodeParentPath, dirName); seafileAPI.moveDir(repoID, destRepo.repo_id, moveToDirentPath, nodeParentPath, dirName).then(res => { - if (repoID !== destRepo.repo_id) { - this.setState({ - asyncCopyMoveTaskId: res.data.task_id, - asyncOperatedFilesLength: 1, - asyncOperationProgress: 0, - asyncOperationType: 'move', - isCopyMoveProgressDialogShow: true, - }, () => { - this.currentMoveItemName = dirName; - this.currentMoveItemPath = direntPath; - this.getAsyncCopyMoveProgress(dirName, direntPath); - }); - } - - if (this.state.isTreePanelShown) { - this.deleteTreeNode(direntPath); - } - - // 1. move to current repo - // 2. tow columns mode need update left tree - if (repoID === destRepo.repo_id && - this.state.isTreePanelShown) { - this.updateMoveCopyTreeNode(moveToDirentPath); - } - - this.moveDirent(direntPath, moveToDirentPath); - - // show tip message if move to current repo - if (repoID === destRepo.repo_id) { - let message = gettext('Successfully moved {name}.'); - message = message.replace('{name}', dirName); - toaster.success(message); - } - - if (byDialog) { - this.updateRecentlyUsedRepos(destRepo, moveToDirentPath); - } - + this.moveItemsAjaxCallback(repoID, destRepo, dirent, moveToDirentPath, nodeParentPath, res.data.task_id, byDialog); }).catch((error) => { if (!error.response.data.lib_need_decrypt) { let errMessage = Utils.getErrorMsg(error); @@ -1285,19 +1289,51 @@ class LibContentView extends React.Component { errMessage = errMessage.replace('{name}', dirName); } toaster.danger(errMessage); - } else { - this.setState({ - libNeedDecryptWhenMove: true, - destRepoWhenCopyMove: destRepo, - destDirentPathWhenCopyMove: moveToDirentPath, - copyMoveSingleItem: true, - srcDirentWhenCopyMove: dirent, - srcNodeParentPathWhenCopyMove: nodeParentPath, - }); + return; } + this.setState({ + libNeedDecryptWhenMove: true, + destRepoWhenCopyMove: destRepo, + destDirentPathWhenCopyMove: moveToDirentPath, + copyMoveSingleItem: true, + srcDirentWhenCopyMove: dirent, + srcNodeParentPathWhenCopyMove: nodeParentPath, + }); }); }; + copyItemsAjaxCallback = (repoID, targetRepo, dirent, copyToDirentPath, nodeParentPath, taskId, byDialog = false) => { + if (repoID !== targetRepo.repo_id) { + this.setState({ + asyncCopyMoveTaskId: taskId, + asyncOperatedFilesLength: 1, + asyncOperationProgress: 0, + asyncOperationType: 'copy', + isCopyMoveProgressDialogShow: true + }, () => { + this.getAsyncCopyMoveProgress(); + }); + return; + } + + if (this.state.isTreePanelShown) { + this.updateMoveCopyTreeNode(copyToDirentPath); + } + + if (copyToDirentPath === nodeParentPath && this.state.currentMode !== METADATA_MODE) { + this.loadDirentList(this.state.path); + } + + const dirName = dirent.name; + let message = gettext('Successfully copied %(name)s.'); + message = message.replace('%(name)s', dirName); + toaster.success(message); + + if (byDialog) { + this.updateRecentlyUsedRepos(targetRepo, copyToDirentPath); + } + }; + onCopyItem = (destRepo, dirent, copyToDirentPath, nodeParentPath, byDialog = false) => { let repoID = this.props.repoID; // just for view list state @@ -1307,36 +1343,7 @@ class LibContentView extends React.Component { } seafileAPI.copyDir(repoID, destRepo.repo_id, copyToDirentPath, nodeParentPath, dirName).then(res => { - - if (repoID !== destRepo.repo_id) { - this.setState({ - asyncCopyMoveTaskId: res.data.task_id, - asyncOperatedFilesLength: 1, - asyncOperationProgress: 0, - asyncOperationType: 'copy', - isCopyMoveProgressDialogShow: true - }, () => { - this.getAsyncCopyMoveProgress(); - }); - } - - if (repoID === destRepo.repo_id) { - if (this.state.isTreePanelShown) { - this.updateMoveCopyTreeNode(copyToDirentPath); - } - - if (copyToDirentPath === nodeParentPath) { - this.loadDirentList(this.state.path); - } - - let message = gettext('Successfully copied %(name)s.'); - message = message.replace('%(name)s', dirName); - toaster.success(message); - - if (byDialog) { - this.updateRecentlyUsedRepos(destRepo, copyToDirentPath); - } - } + this.copyItemsAjaxCallback(repoID, destRepo, dirent, copyToDirentPath, nodeParentPath, res.data.task_id || null, byDialog); }).catch((error) => { if (!error.response.data.lib_need_decrypt) { let errMessage = Utils.getErrorMsg(error); @@ -2348,7 +2355,9 @@ class LibContentView extends React.Component { deleteFilesCallback={this.deleteItemsAjaxCallback} renameFileCallback={this.renameItemAjaxCallback} onItemMove={this.onMoveItem} + moveFileCallback={this.moveItemsAjaxCallback} onItemCopy={this.onCopyItem} + copyFileCallback={this.copyItemsAjaxCallback} onItemConvert={this.onConvertItem} onDirentClick={this.onDirentClick} updateDirent={this.updateDirent}