diff --git a/frontend/src/components/dir-view-mode/dir-column-nav/index.js b/frontend/src/components/dir-view-mode/dir-column-nav/index.js index 40db9501f5e..4fb885d4076 100644 --- a/frontend/src/components/dir-view-mode/dir-column-nav/index.js +++ b/frontend/src/components/dir-view-mode/dir-column-nav/index.js @@ -1,33 +1,25 @@ import React from 'react'; import PropTypes from 'prop-types'; -import TreeView from '../../tree-view/tree-view'; import Loading from '../../loading'; -import ModalPortal from '../../modal-portal'; -import Rename from '../../dialog/rename-dialog'; -import Copy from '../../dialog/copy-dirent-dialog'; -import Move from '../../dialog/move-dirent-dialog'; -import CreateFolder from '../../dialog/create-folder-dialog'; -import CreateFile from '../../dialog/create-file-dialog'; -import ImageDialog from '../../dialog/image-dialog'; -import { fileServerRoot, gettext, siteRoot, thumbnailSizeForOriginal, thumbnailDefaultSize } from '../../../utils/constants'; -import { Utils } from '../../../utils/utils'; -import TextTranslation from '../../../utils/text-translation'; -import TreeSection from '../../tree-section'; +import DirFiles from '../dir-files'; import DirViews from '../dir-views'; import DirTags from '../dir-tags'; import DirOthers from '../dir-others'; -import imageAPI from '../../../utils/image-api'; -import { seafileAPI } from '../../../utils/seafile-api'; -import toaster from '../../toast'; import './index.css'; const propTypes = { currentPath: PropTypes.string.isRequired, userPerm: PropTypes.string.isRequired, + currentRepoInfo: PropTypes.object.isRequired, isTreeDataLoading: PropTypes.bool.isRequired, treeData: PropTypes.object.isRequired, + direntList: PropTypes.array, + selectedDirentList: PropTypes.array.isRequired, currentNode: PropTypes.object, + repoID: PropTypes.string.isRequired, + navRate: PropTypes.number, + inResizing: PropTypes.bool.isRequired, onNodeClick: PropTypes.func.isRequired, onNodeCollapse: PropTypes.func.isRequired, onNodeExpanded: PropTypes.func.isRequired, @@ -35,472 +27,57 @@ const propTypes = { onDeleteNode: PropTypes.func.isRequired, onAddFileNode: PropTypes.func.isRequired, onAddFolderNode: PropTypes.func.isRequired, - repoID: PropTypes.string.isRequired, - navRate: PropTypes.number, - inResizing: PropTypes.bool.isRequired, - currentRepoInfo: PropTypes.object.isRequired, onItemMove: PropTypes.func.isRequired, onItemCopy: PropTypes.func.isRequired, - selectedDirentList: PropTypes.array.isRequired, onItemsMove: PropTypes.func.isRequired, getMenuContainerSize: PropTypes.func, updateDirent: PropTypes.func, - direntList: PropTypes.array }; class DirColumnNav extends React.Component { - constructor(props) { - super(props); - this.state = { - opNode: null, - isAddFileDialogShow: false, - isAddFolderDialogShow: false, - isRenameDialogShow: false, - isNodeImagePopupOpen: false, - imageNodeItems: [], - imageIndex: 0, - isCopyDialogShow: false, - isMoveDialogShow: false, - isMultipleOperation: false, - operationList: [], - isDisplayFiles: localStorage.getItem('sf_display_files') === 'true' || false, - }; - this.isNodeMenuShow = true; - this.imageItemsSnapshot = []; - this.imageIndexSnapshot = 0; - } - - componentDidMount() { - this.initMenuList(); - } - - componentDidUpdate(prevProps) { - if (prevProps.direntList.length < this.props.direntList.length && this.state.isNodeImagePopupOpen) { - if (this.state.imageNodeItems.length === 0) { - this.setState({ - isNodeImagePopupOpen: false, - }); - } else { - this.setState({ - imageNodeItems: this.imageItemsSnapshot, - imageIndex: this.imageIndexSnapshot, - }); - } - } - } - - initMenuList = () => { - const menuList = this.getMenuList(); - this.setState({ operationList: menuList }); - }; - - getMenuList = () => { - let menuList = []; - menuList.push(TextTranslation.NEW_FOLDER); - menuList.push(TextTranslation.NEW_FILE); - menuList.push(TextTranslation.DISPLAY_FILES); - return menuList; - }; - - UNSAFE_componentWillReceiveProps(nextProps) { - this.setState({ opNode: nextProps.currentNode }); - } - - onNodeClick = (node) => { - this.setState({ opNode: node }); - if (Utils.imageCheck(node?.object?.name || '')) { - this.showNodeImagePopup(node); - return; - } - this.props.onNodeClick(node); - }; - - onMoreOperationClick = (operation) => { - this.onMenuItemClick(operation); - }; - - onMenuItemClick = (operation, node) => { - this.setState({ opNode: node }); - switch (operation) { - case 'New Folder': - if (!node) { - this.onAddFolderToggle('root'); - } else { - this.onAddFolderToggle(); - } - break; - case 'New File': - if (!node) { - this.onAddFileToggle('root'); - } else { - this.onAddFileToggle(); - } - break; - case 'Rename': - this.onRenameToggle(); - break; - case 'Delete': - this.onDeleteNode(node); - break; - case 'Copy': - this.onCopyToggle(); - break; - case 'Move': - this.onMoveToggle(); - break; - case 'Open in New Tab': - this.onOpenFile(node); - break; - case 'Display files': - this.onDisplayFilesToggle(); - break; - } - }; - - onAddFileToggle = (type) => { - if (type === 'root') { - let root = this.props.treeData.root; - this.setState({ - isAddFileDialogShow: !this.state.isAddFileDialogShow, - opNode: root, - }); - } else { - this.setState({ isAddFileDialogShow: !this.state.isAddFileDialogShow }); - } - }; - - onAddFolderToggle = (type) => { - if (type === 'root') { - let root = this.props.treeData.root; - this.setState({ - isAddFolderDialogShow: !this.state.isAddFolderDialogShow, - opNode: root, - }); - } else { - this.setState({ isAddFolderDialogShow: !this.state.isAddFolderDialogShow }); - } - }; - - onRenameToggle = () => { - this.setState({ isRenameDialogShow: !this.state.isRenameDialogShow }); - }; - - onCopyToggle = () => { - this.setState({ isCopyDialogShow: !this.state.isCopyDialogShow }); - }; - - onMoveToggle = () => { - this.setState({ isMoveDialogShow: !this.state.isMoveDialogShow }); - }; - - onAddFolderNode = (dirPath) => { - this.setState({ isAddFolderDialogShow: !this.state.isAddFolderDialogShow }); - this.props.onAddFolderNode(dirPath); - }; - - onRenameNode = (newName) => { - this.setState({ isRenameDialogShow: !this.state.isRenameDialogShow }); - let node = this.state.opNode; - this.props.onRenameNode(node, newName); - }; - - onDeleteNode = (node) => { - this.props.onDeleteNode(node); - }; - - onOpenFile = (node) => { - let newUrl = siteRoot + 'lib/' + this.props.repoID + '/file' + Utils.encodePath(node.path); - window.open(newUrl, '_blank'); - }; - - onDisplayFilesToggle = () => { - this.setState({ isDisplayFiles: !this.state.isDisplayFiles }, () => { - localStorage.setItem('sf_display_files', this.state.isDisplayFiles); - }); - }; - - checkDuplicatedName = (newName) => { - let node = this.state.opNode; - // root node to new node conditions: parentNode is null, - let parentNode = node.parentNode ? node.parentNode : node; - let childrenObject = parentNode.children.map(item => { - return item.object; - }); - let isDuplicated = childrenObject.some(object => { - return object.name === newName; - }); - return isDuplicated; - }; - - showNodeImagePopup = (node) => { - let childrenNode = node.parentNode.children; - let items = childrenNode.filter((item) => { - return Utils.imageCheck(item.object.name); - }); - let imageNames = items.map((item) => { - return item.object.name; - }); - this.setState({ - isNodeImagePopupOpen: true, - imageNodeItems: this.prepareImageItems(node), - imageIndex: imageNames.indexOf(node.object.name) - }); - }; - - prepareImageItems = (node) => { - let childrenNode = node.parentNode.children; - let items = childrenNode.filter((item) => { - return Utils.imageCheck(item.object.name); - }); - - const repoEncrypted = this.props.currentRepoInfo.encrypted; - const repoID = this.props.repoID; - let prepareItem = (item) => { - const name = item.object.name; - const path = Utils.encodePath(Utils.joinPath(node.parentNode.path, name)); - const fileExt = name.substr(name.lastIndexOf('.') + 1).toLowerCase(); - const isGIF = fileExt === 'gif'; - const src = `${siteRoot}repo/${repoID}/raw${path}`; - let thumbnail = ''; - if (repoEncrypted || isGIF) { - thumbnail = src; - } else { - thumbnail = `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForOriginal}${path}`; - } - return { - name, - src, - thumbnail, - 'url': `${siteRoot}lib/${repoID}/file${path}`, - 'node': items.find(item => item.path.split('/').pop() === name), - 'downloadURL': `${fileServerRoot}repos/${repoID}/files${path}/?op=download`, - }; - }; - - return items.map((item) => { return prepareItem(item); }); - }; - - closeNodeImagePopup = () => { - this.setState({ - isNodeImagePopupOpen: false - }); - }; - - moveToPrevImage = () => { - const imageItemsLength = this.state.imageNodeItems.length; - this.setState((prevState) => ({ - imageIndex: (prevState.imageIndex + imageItemsLength - 1) % imageItemsLength - })); - }; - - moveToNextImage = () => { - const imageItemsLength = this.state.imageNodeItems.length; - this.setState((prevState) => ({ - imageIndex: (prevState.imageIndex + 1) % imageItemsLength - })); - }; - - deleteImage = () => { - this.imageItemsSnapshot = this.state.imageNodeItems; - this.imageIndexSnapshot = this.state.imageIndex; - - if (this.state.imageNodeItems.length > this.state.imageIndex) { - this.props.onDeleteNode(this.state.imageNodeItems[this.state.imageIndex].node); - } - const imageNodeItems = this.state.imageNodeItems.filter((item, index) => index !== this.state.imageIndex); - - if (!imageNodeItems.length) { - this.setState({ - isNodeImagePopupOpen: false, - imageNodeItems: [], - imageIndex: 0 - }); - } else { - this.setState((prevState) => ({ - imageNodeItems: imageNodeItems, - imageIndex: (prevState.imageIndex + 1) % imageNodeItems.length, - })); - } - }; - - handleError = (error) => { - toaster.danger(Utils.getErrorMsg(error)); - }; - - rotateImage = (imageIndex, angle) => { - if (imageIndex >= 0 && angle !== 0) { - let { repoID } = this.props; - let imageName = this.state.imageNodeItems[imageIndex].name; - let path = this.state.opNode.path; - imageAPI.rotateImage(repoID, path, 360 - angle).then((res) => { - seafileAPI.createThumbnail(repoID, path, thumbnailDefaultSize).then((res) => { - // Generate a unique query parameter to bust the cache - const cacheBuster = new Date().getTime(); - const newThumbnailSrc = `${res.data.encoded_thumbnail_src}?t=${cacheBuster}`; - this.setState((prevState) => { - const updatedImageItems = [...prevState.imageNodeItems]; - updatedImageItems[imageIndex].src = newThumbnailSrc; - return { imageNodeItems: updatedImageItems }; - }); - // Update the thumbnail URL with the cache-busting query parameter - const item = this.props.direntList.find((item) => item.name === imageName); - this.props.updateDirent(item, 'encoded_thumbnail_src', newThumbnailSrc); - }).catch(error => { - this.handleError(error); - }); - }).catch(error => { - this.handleError(error); - }); - } - }; - stopTreeScrollPropagation = (e) => { e.stopPropagation(); }; - renderContent = () => { + render() { const { - isTreeDataLoading, - userPerm, - treeData, - currentPath, - onNodeExpanded, - onNodeCollapse, - onItemMove, - onItemsMove, - currentRepoInfo, - selectedDirentList, - repoID, - getMenuContainerSize, + isTreeDataLoading, userPerm, treeData, repoID, currentPath, currentRepoInfo, } = this.props; - + const flex = this.props.navRate ? '0 0 ' + this.props.navRate * 100 + '%' : '0 0 25%'; + const select = this.props.inResizing ? 'none' : ''; return ( - <> - {isTreeDataLoading ? ( - - ) : ( +
+ {isTreeDataLoading ? : ( <> - - - - - - - - )} - - ); - }; - - render() { - let flex = this.props.navRate ? '0 0 ' + this.props.navRate * 100 + '%' : '0 0 25%'; - const select = this.props.inResizing ? 'none' : ''; - const repoEncrypted = this.props.currentRepoInfo.encrypted; - return ( - <> -
- {this.renderContent()} -
- {this.state.isAddFolderDialogShow && ( - - - - )} - {this.state.isAddFileDialogShow && ( - - - - )} - {this.state.isRenameDialogShow && ( - - - - )} - {this.state.isCopyDialogShow && ( - - - - )} - {this.state.isMoveDialogShow && ( - - - - )} - {this.state.isNodeImagePopupOpen && ( - - - + + + + )} - +
); } } diff --git a/frontend/src/components/dir-view-mode/dir-files.js b/frontend/src/components/dir-view-mode/dir-files.js new file mode 100644 index 00000000000..1008c21ad7a --- /dev/null +++ b/frontend/src/components/dir-view-mode/dir-files.js @@ -0,0 +1,462 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import TreeView from '../tree-view/tree-view'; +import ModalPortal from '../modal-portal'; +import Rename from '../dialog/rename-dialog'; +import Copy from '../dialog/copy-dirent-dialog'; +import Move from '../dialog/move-dirent-dialog'; +import CreateFolder from '../dialog/create-folder-dialog'; +import CreateFile from '../dialog/create-file-dialog'; +import ImageDialog from '../dialog/image-dialog'; +import toaster from '../toast'; +import ItemDropdownMenu from '../dropdown-menu/item-dropdown-menu'; +import { fileServerRoot, gettext, siteRoot, thumbnailSizeForOriginal, thumbnailDefaultSize } from '../../utils/constants'; +import { isMobile, Utils } from '../../utils/utils'; +import TextTranslation from '../../utils/text-translation'; +import TreeSection from '../tree-section'; +import imageAPI from '../../utils/image-api'; +import { seafileAPI } from '../../utils/seafile-api'; + +const propTypes = { + repoID: PropTypes.string.isRequired, + currentPath: PropTypes.string.isRequired, + treeData: PropTypes.object.isRequired, + userPerm: PropTypes.string.isRequired, + currentRepoInfo: PropTypes.object.isRequired, + direntList: PropTypes.array, + selectedDirentList: PropTypes.array.isRequired, + currentNode: PropTypes.object, + getMenuContainerSize: PropTypes.func, + onNodeClick: PropTypes.func.isRequired, + onNodeCollapse: PropTypes.func.isRequired, + onNodeExpanded: PropTypes.func.isRequired, + onRenameNode: PropTypes.func.isRequired, + onDeleteNode: PropTypes.func.isRequired, + onAddFileNode: PropTypes.func.isRequired, + onAddFolderNode: PropTypes.func.isRequired, + onItemCopy: PropTypes.func.isRequired, + onItemMove: PropTypes.func.isRequired, + onItemsMove: PropTypes.func.isRequired, + updateDirent: PropTypes.func, +}; + +class DirFiles extends React.Component { + + constructor(props) { + super(props); + this.state = { + opNode: null, + isAddFileDialogShow: false, + isAddFolderDialogShow: false, + isRenameDialogShow: false, + isNodeImagePopupOpen: false, + imageNodeItems: [], + imageIndex: 0, + isCopyDialogShow: false, + isMoveDialogShow: false, + isMultipleOperation: false, + operationList: [], + isDisplayFiles: localStorage.getItem('sf_display_files') === 'true' || false, + }; + this.isNodeMenuShow = true; + this.imageItemsSnapshot = []; + this.imageIndexSnapshot = 0; + } + + componentDidUpdate(prevProps) { + if (prevProps.direntList.length < this.props.direntList.length && this.state.isNodeImagePopupOpen) { + if (this.state.imageNodeItems.length === 0) { + this.setState({ + isNodeImagePopupOpen: false, + }); + } else { + this.setState({ + imageNodeItems: this.imageItemsSnapshot, + imageIndex: this.imageIndexSnapshot, + }); + } + } + } + + UNSAFE_componentWillReceiveProps(nextProps) { + this.setState({ opNode: nextProps.currentNode }); + } + + getMenuList = () => { + return [ + TextTranslation.NEW_FOLDER, + TextTranslation.NEW_FILE, + TextTranslation.DISPLAY_FILES, + ]; + }; + + onNodeClick = (node) => { + this.setState({ opNode: node }); + if (Utils.imageCheck(node?.object?.name || '')) { + this.showNodeImagePopup(node); + return; + } + this.props.onNodeClick(node); + }; + + onMoreOperationClick = (operation) => { + this.onMenuItemClick(operation); + }; + + onMenuItemClick = (operation, node) => { + this.setState({ opNode: node }); + switch (operation) { + case 'New Folder': + if (!node) { + this.onAddFolderToggle('root'); + } else { + this.onAddFolderToggle(); + } + break; + case 'New File': + if (!node) { + this.onAddFileToggle('root'); + } else { + this.onAddFileToggle(); + } + break; + case 'Rename': + this.onRenameToggle(); + break; + case 'Delete': + this.onDeleteNode(node); + break; + case 'Copy': + this.onCopyToggle(); + break; + case 'Move': + this.onMoveToggle(); + break; + case 'Open in New Tab': + this.onOpenFile(node); + break; + case 'Display files': + this.onDisplayFilesToggle(); + break; + } + }; + + onAddFileToggle = (type) => { + if (type === 'root') { + let root = this.props.treeData.root; + this.setState({ + isAddFileDialogShow: !this.state.isAddFileDialogShow, + opNode: root, + }); + } else { + this.setState({ isAddFileDialogShow: !this.state.isAddFileDialogShow }); + } + }; + + onAddFolderToggle = (type) => { + if (type === 'root') { + let root = this.props.treeData.root; + this.setState({ + isAddFolderDialogShow: !this.state.isAddFolderDialogShow, + opNode: root, + }); + } else { + this.setState({ isAddFolderDialogShow: !this.state.isAddFolderDialogShow }); + } + }; + + onRenameToggle = () => { + this.setState({ isRenameDialogShow: !this.state.isRenameDialogShow }); + }; + + onCopyToggle = () => { + this.setState({ isCopyDialogShow: !this.state.isCopyDialogShow }); + }; + + onMoveToggle = () => { + this.setState({ isMoveDialogShow: !this.state.isMoveDialogShow }); + }; + + onAddFolderNode = (dirPath) => { + this.setState({ isAddFolderDialogShow: !this.state.isAddFolderDialogShow }); + this.props.onAddFolderNode(dirPath); + }; + + onRenameNode = (newName) => { + this.setState({ isRenameDialogShow: !this.state.isRenameDialogShow }); + let node = this.state.opNode; + this.props.onRenameNode(node, newName); + }; + + onDeleteNode = (node) => { + this.props.onDeleteNode(node); + }; + + onOpenFile = (node) => { + let newUrl = siteRoot + 'lib/' + this.props.repoID + '/file' + Utils.encodePath(node.path); + window.open(newUrl, '_blank'); + }; + + onDisplayFilesToggle = () => { + this.setState({ isDisplayFiles: !this.state.isDisplayFiles }, () => { + localStorage.setItem('sf_display_files', this.state.isDisplayFiles); + }); + }; + + checkDuplicatedName = (newName) => { + let node = this.state.opNode; + // root node to new node conditions: parentNode is null, + let parentNode = node.parentNode ? node.parentNode : node; + let childrenObject = parentNode.children.map(item => { + return item.object; + }); + let isDuplicated = childrenObject.some(object => { + return object.name === newName; + }); + return isDuplicated; + }; + + showNodeImagePopup = (node) => { + let childrenNode = node.parentNode.children; + let items = childrenNode.filter((item) => { + return Utils.imageCheck(item.object.name); + }); + let imageNames = items.map((item) => { + return item.object.name; + }); + this.setState({ + isNodeImagePopupOpen: true, + imageNodeItems: this.prepareImageItems(node), + imageIndex: imageNames.indexOf(node.object.name) + }); + }; + + prepareImageItems = (node) => { + let childrenNode = node.parentNode.children; + let items = childrenNode.filter((item) => { + return Utils.imageCheck(item.object.name); + }); + + const repoEncrypted = this.props.currentRepoInfo.encrypted; + const repoID = this.props.repoID; + let prepareItem = (item) => { + const name = item.object.name; + const path = Utils.encodePath(Utils.joinPath(node.parentNode.path, name)); + const fileExt = name.substr(name.lastIndexOf('.') + 1).toLowerCase(); + const isGIF = fileExt === 'gif'; + const src = `${siteRoot}repo/${repoID}/raw${path}`; + let thumbnail = ''; + if (repoEncrypted || isGIF) { + thumbnail = src; + } else { + thumbnail = `${siteRoot}thumbnail/${repoID}/${thumbnailSizeForOriginal}${path}`; + } + return { + name, + src, + thumbnail, + 'url': `${siteRoot}lib/${repoID}/file${path}`, + 'node': items.find(item => item.path.split('/').pop() === name), + 'downloadURL': `${fileServerRoot}repos/${repoID}/files${path}/?op=download`, + }; + }; + + return items.map((item) => { return prepareItem(item); }); + }; + + closeNodeImagePopup = () => { + this.setState({ + isNodeImagePopupOpen: false + }); + }; + + moveToPrevImage = () => { + const imageItemsLength = this.state.imageNodeItems.length; + this.setState((prevState) => ({ + imageIndex: (prevState.imageIndex + imageItemsLength - 1) % imageItemsLength + })); + }; + + moveToNextImage = () => { + const imageItemsLength = this.state.imageNodeItems.length; + this.setState((prevState) => ({ + imageIndex: (prevState.imageIndex + 1) % imageItemsLength + })); + }; + + deleteImage = () => { + this.imageItemsSnapshot = this.state.imageNodeItems; + this.imageIndexSnapshot = this.state.imageIndex; + + if (this.state.imageNodeItems.length > this.state.imageIndex) { + this.props.onDeleteNode(this.state.imageNodeItems[this.state.imageIndex].node); + } + const imageNodeItems = this.state.imageNodeItems.filter((item, index) => index !== this.state.imageIndex); + + if (!imageNodeItems.length) { + this.setState({ + isNodeImagePopupOpen: false, + imageNodeItems: [], + imageIndex: 0 + }); + } else { + this.setState((prevState) => ({ + imageNodeItems: imageNodeItems, + imageIndex: (prevState.imageIndex + 1) % imageNodeItems.length, + })); + } + }; + + handleError = (error) => { + toaster.danger(Utils.getErrorMsg(error)); + }; + + rotateImage = (imageIndex, angle) => { + if (imageIndex >= 0 && angle !== 0) { + let { repoID } = this.props; + let imageName = this.state.imageNodeItems[imageIndex].name; + let path = this.state.opNode.path; + imageAPI.rotateImage(repoID, path, 360 - angle).then((res) => { + seafileAPI.createThumbnail(repoID, path, thumbnailDefaultSize).then((res) => { + // Generate a unique query parameter to bust the cache + const cacheBuster = new Date().getTime(); + const newThumbnailSrc = `${res.data.encoded_thumbnail_src}?t=${cacheBuster}`; + this.setState((prevState) => { + const updatedImageItems = [...prevState.imageNodeItems]; + updatedImageItems[imageIndex].src = newThumbnailSrc; + return { imageNodeItems: updatedImageItems }; + }); + // Update the thumbnail URL with the cache-busting query parameter + const item = this.props.direntList.find((item) => item.name === imageName); + this.props.updateDirent(item, 'encoded_thumbnail_src', newThumbnailSrc); + }).catch(error => { + this.handleError(error); + }); + }).catch(error => { + this.handleError(error); + }); + } + }; + + renderTreeSectionHeaderOperations = (props) => { + const moreOperation = ( +
+ +
+ ); + return [moreOperation]; + }; + + render() { + const { repoID, currentRepoInfo } = this.props; + const repoEncrypted = currentRepoInfo.encrypted; + return ( + <> + + + + {this.state.isAddFolderDialogShow && ( + + + + )} + {this.state.isAddFileDialogShow && ( + + + + )} + {this.state.isRenameDialogShow && ( + + + + )} + {this.state.isCopyDialogShow && ( + + + + )} + {this.state.isMoveDialogShow && ( + + + + )} + {this.state.isNodeImagePopupOpen && ( + + + + )} + + ); + } +} + +DirFiles.propTypes = propTypes; + +export default DirFiles; diff --git a/frontend/src/components/dir-view-mode/dir-views/index.css b/frontend/src/components/dir-view-mode/dir-views/index.css new file mode 100644 index 00000000000..d5e5d6bb049 --- /dev/null +++ b/frontend/src/components/dir-view-mode/dir-views/index.css @@ -0,0 +1,29 @@ +.metadata-views-dropdown-menu .dropdown-header { + padding: 0 1rem; + font-weight: normal; + color: #666; +} + +.metadata-views-dropdown-menu .dropdown-header, +.metadata-views-dropdown-menu .dropdown-item { + width: 100%; + height: 2rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.metadata-views-dropdown-menu .metadata-view-icon { + display: flex; + align-items: center; + font-size: 1rem; + color: #666; + fill: #666; +} + +.metadata-views-dropdown-menu .dropdown-item:hover .metadata-view-icon, +.metadata-views-dropdown-menu .dropdown-item:focus .metadata-view-icon, +.metadata-views-dropdown-menu .dropdown-item:focus .sf3-font { + color: #fff; + fill: #fff; +} diff --git a/frontend/src/components/dir-view-mode/dir-views.js b/frontend/src/components/dir-view-mode/dir-views/index.js similarity index 62% rename from frontend/src/components/dir-view-mode/dir-views.js rename to frontend/src/components/dir-view-mode/dir-views/index.js index 71ca99af552..cb95ef0871b 100644 --- a/frontend/src/components/dir-view-mode/dir-views.js +++ b/frontend/src/components/dir-view-mode/dir-views/index.js @@ -1,17 +1,20 @@ import React, { useCallback, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; -import { gettext } from '../../utils/constants'; -import TreeSection from '../tree-section'; -import { MetadataTreeView, useMetadata } from '../../metadata'; -import ExtensionPrompts from './extension-prompts'; -import LibSettingsDialog, { TAB } from '../dialog/lib-settings'; -import { useMetadataStatus } from '../../hooks'; +import TreeSection from '../../tree-section'; +import ExtensionPrompts from '../extension-prompts'; +import LibSettingsDialog, { TAB } from '../../dialog/lib-settings'; +import ViewsMoreOperations from './views-more-operations'; +import { MetadataTreeView, useMetadata } from '../../../metadata'; +import { useMetadataStatus } from '../../../hooks'; +import { gettext } from '../../../utils/constants'; + +import './index.css'; const DirViews = ({ userPerm, repoID, currentPath, currentRepoInfo }) => { const enableMetadataManagement = useMemo(() => { if (currentRepoInfo.encrypted) return false; return window.app.pageOptions.enableMetadataManagement; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [window.app.pageOptions.enableMetadataManagement, currentRepoInfo]); const { navigation } = useMetadata(); @@ -30,9 +33,27 @@ const DirViews = ({ userPerm, repoID, currentPath, currentRepoInfo }) => { return null; } + const renderTreeSectionHeaderOperations = (menuProps) => { + const canAdd = userPerm === 'rw' || userPerm === 'admin'; + + let operations = []; + if (enableMetadata && canAdd) { + operations.push( + + ); + } + return operations; + }; + return ( <> - + {!enableMetadata ? ( ) : Array.isArray(navigation) && navigation.length > 0 ? ( diff --git a/frontend/src/components/dir-view-mode/dir-views/new-view-menu.js b/frontend/src/components/dir-view-mode/dir-views/new-view-menu.js new file mode 100644 index 00000000000..2b069143051 --- /dev/null +++ b/frontend/src/components/dir-view-mode/dir-views/new-view-menu.js @@ -0,0 +1,66 @@ +import React from 'react'; +import Icon from '../../icon'; +import TextTranslation from '../../../utils/text-translation'; +import { gettext } from '../../../utils/constants'; +import { VIEW_TYPE, VIEW_TYPE_ICON } from '../../../metadata/constants'; + +export const KEY_ADD_VIEW_MAP = { + ADD_FOLDER: 'ADD_FOLDER', + ADD_TABLE: 'ADD_TABLE', + ADD_GALLERY: 'ADD_GALLERY', + ADD_KANBAN: 'ADD_KANBAN', + ADD_MAP: 'ADD_MAP', +}; + +const ADD_VIEW_OPTIONS = [ + { + key: KEY_ADD_VIEW_MAP.ADD_TABLE, + type: VIEW_TYPE.TABLE, + }, + { + key: KEY_ADD_VIEW_MAP.ADD_GALLERY, + type: VIEW_TYPE.GALLERY, + }, + { + key: KEY_ADD_VIEW_MAP.ADD_KANBAN, + type: VIEW_TYPE.KANBAN, + }, + { + key: KEY_ADD_VIEW_MAP.ADD_MAP, + type: VIEW_TYPE.MAP, + }, +]; + +const translateLabel = (type) => { + switch (type) { + case VIEW_TYPE.TABLE: + return gettext('Table'); + case VIEW_TYPE.GALLERY: + return gettext('Gallery'); + case VIEW_TYPE.KANBAN: + return gettext('Kanban'); + case VIEW_TYPE.MAP: + return gettext('Map'); + default: + return type; + } +}; + +const getNewViewSubMenus = () => { + return ADD_VIEW_OPTIONS.map((option) => { + const { key, type } = option; + return { + key, + value: translateLabel(type), + icon_dom: + }; + }); +}; + +export const getNewViewMenuItem = () => { + return { + ...TextTranslation.ADD_VIEW, + subOpListHeader: gettext('New view'), + subOpList: getNewViewSubMenus(), + }; +}; diff --git a/frontend/src/components/dir-view-mode/dir-views/views-more-operations.js b/frontend/src/components/dir-view-mode/dir-views/views-more-operations.js new file mode 100644 index 00000000000..50933a42438 --- /dev/null +++ b/frontend/src/components/dir-view-mode/dir-views/views-more-operations.js @@ -0,0 +1,66 @@ +import React, { useCallback } from 'react'; +import ItemDropdownMenu from '../../dropdown-menu/item-dropdown-menu'; +import TextTranslation from '../../../utils/text-translation'; +import { isMobile } from '../../../utils/utils'; +import EventBus from '../../common/event-bus'; +import { EVENT_BUS_TYPE, VIEW_TYPE } from '../../../metadata/constants'; +import { getNewViewMenuItem, KEY_ADD_VIEW_MAP } from './new-view-menu'; + +const ViewsMoreOperations = ({ menuProps }) => { + const eventBus = EventBus.getInstance(); + + const addView = (viewType) => { + eventBus.dispatch(EVENT_BUS_TYPE.ADD_VIEW, { viewType }); + }; + + const clickMenu = (option) => { + switch (option) { + case TextTranslation.ADD_FOLDER.key: { + eventBus.dispatch(EVENT_BUS_TYPE.ADD_FOLDER); + return; + } + case KEY_ADD_VIEW_MAP.ADD_TABLE: { + addView(VIEW_TYPE.TABLE); + return; + } + case KEY_ADD_VIEW_MAP.ADD_GALLERY: { + addView(VIEW_TYPE.GALLERY); + return; + } + case KEY_ADD_VIEW_MAP.ADD_KANBAN: { + addView(VIEW_TYPE.KANBAN); + return; + } + case KEY_ADD_VIEW_MAP.ADD_MAP: { + addView(VIEW_TYPE.MAP); + return; + } + default: { + return; + } + } + }; + + const getMoreOperationsMenus = useCallback(() => { + return [ + TextTranslation.ADD_FOLDER, + getNewViewMenuItem(), + ]; + }, []); + + return ( +
+ +
+ ); +}; + +export default ViewsMoreOperations; diff --git a/frontend/src/components/dropdown-menu/item-dropdown-menu.js b/frontend/src/components/dropdown-menu/item-dropdown-menu.js index 967fc004993..c1384242356 100644 --- a/frontend/src/components/dropdown-menu/item-dropdown-menu.js +++ b/frontend/src/components/dropdown-menu/item-dropdown-menu.js @@ -112,7 +112,9 @@ class ItemDropdownMenu extends React.Component { onMenuItemClick = (event) => { let operation = Utils.getEventData(event, 'toggle') ?? event.currentTarget.getAttribute('data-toggle'); let item = this.props.item; + this.props.unfreezeItem(); this.props.onMenuItemClick(operation, event, item); + this.setState({ isItemMenuShow: false }); }; onDropDownMouseMove = () => { @@ -176,7 +178,7 @@ class ItemDropdownMenu extends React.Component { } return ( - + {menuList.map((menuItem, index) => { if (menuItem === 'Divider') { @@ -214,13 +220,21 @@ class ItemDropdownMenu extends React.Component { {menuItem.value} - + + {menuItem.subOpListHeader && {menuItem.subOpListHeader}} {menuItem.subOpList.map((item, index) => { if (item == 'Divider') { return ; } else { return ( - {item.value} + + {item.icon_dom || null} + {item.value} + ); } })} diff --git a/frontend/src/components/tree-section/index.css b/frontend/src/components/tree-section/index.css index f2c3f6f0e43..f4c0f8bed47 100644 --- a/frontend/src/components/tree-section/index.css +++ b/frontend/src/components/tree-section/index.css @@ -73,7 +73,11 @@ margin-left: 0; line-height: 1.5; font-size: 16px; - color: #999 !important; + color: #666 !important; +} + +.tree-section .tree-section-more-operation .dropdown .sf-dropdown-toggle.sf3-font-new { + font-size: 12px; } .tree-section .tree-section-header .tree-section-more-operation { diff --git a/frontend/src/components/tree-section/index.js b/frontend/src/components/tree-section/index.js index 911601fac94..55f2661a43d 100644 --- a/frontend/src/components/tree-section/index.js +++ b/frontend/src/components/tree-section/index.js @@ -1,21 +1,14 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import ItemDropdownMenu from '../dropdown-menu/item-dropdown-menu'; -import { isMobile } from '../../utils/utils'; import './index.css'; -const TreeSection = ({ title, children, moreKey, moreOperations, moreOperationClick, className, isDisplayFiles }) => { +const TreeSection = ({ title, children, renderHeaderOperations, className }) => { const [showChildren, setShowChildren] = useState(true); const [highlight, setHighlight] = useState(false); const [freeze, setFreeze] = useState(false); - const validMoreOperations = useMemo(() => { - if (!Array.isArray(moreOperations) || moreOperations.length === 0) return []; - return moreOperations.filter(operation => operation.key && operation.value); - }, [moreOperations]); - const toggleShowChildren = useCallback((event) => { event.stopPropagation(); event.nativeEvent.stopImmediatePropagation(); @@ -39,6 +32,7 @@ const TreeSection = ({ title, children, moreKey, moreOperations, moreOperationCl const freezeItem = useCallback(() => { setFreeze(true); + setHighlight(true); }, []); const unfreezeItem = useCallback(() => { @@ -46,6 +40,16 @@ const TreeSection = ({ title, children, moreKey, moreOperations, moreOperationCl setHighlight(false); }, []); + const renderOperations = useCallback(() => { + if (!renderHeaderOperations) { + return null; + } + return renderHeaderOperations({ + freezeItem, + unfreezeItem, + }); + }, [renderHeaderOperations, freezeItem, unfreezeItem]); + return (
{title}
- {validMoreOperations.length > 0 && ( - <> -
- validMoreOperations} - onMenuItemClick={moreOperationClick} - menuStyle={isMobile ? { zIndex: 1050 } : {}} - isDisplayFiles={isDisplayFiles} - /> -
- - )} + {renderOperations()}
@@ -88,12 +77,8 @@ const TreeSection = ({ title, children, moreKey, moreOperations, moreOperationCl TreeSection.propTypes = { title: PropTypes.any.isRequired, - moreOperations: PropTypes.array, children: PropTypes.any, - moreKey: PropTypes.object, - moreOperationClick: PropTypes.func, className: PropTypes.string, - isDisplayFiles: PropTypes.bool, }; export default TreeSection; diff --git a/frontend/src/metadata/api.js b/frontend/src/metadata/api.js index 53d8f0669db..abf0b4c513a 100644 --- a/frontend/src/metadata/api.js +++ b/frontend/src/metadata/api.js @@ -109,7 +109,28 @@ class MetadataManagerAPI { return this._sendPostRequest(url, params, { headers: { 'Content-type': 'application/json' } }); }; - // view + // views + addFolder = (repoID, name) => { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/folders/'; + const params = { name }; + return this._sendPostRequest(url, params, { headers: { 'Content-type': 'application/json' } }); + }; + + modifyFolder = (repoID, folder_id, folder_data) => { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/folders/'; + const params = { + folder_id, + folder_data, + }; + return this.req.put(url, params); + }; + + deleteFolder = (repoID, folder_id) => { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/folders/'; + const params = { folder_id }; + return this.req.delete(url, { data: params }); + }; + listViews = (repoID) => { const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/views/'; return this.req.get(url); @@ -120,7 +141,7 @@ class MetadataManagerAPI { return this.req.get(url); }; - addView = (repoID, name, type = 'table') => { + addView = (repoID, name, type = 'table', folder_id = '') => { const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/views/'; let params = { name, @@ -130,12 +151,18 @@ class MetadataManagerAPI { sorts: VIEW_TYPE_DEFAULT_SORTS[type], } }; + if (folder_id) { + params.folder_id = folder_id; + } return this._sendPostRequest(url, params, { headers: { 'Content-type': 'application/json' } }); }; - duplicateView = (repoID, viewId) => { + duplicateView = (repoID, viewId, folder_id = '') => { const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/duplicate-view/'; - const params = { view_id: viewId }; + let params = { view_id: viewId }; + if (folder_id) { + params.folder_id = folder_id; + } return this._sendPostRequest(url, params, { headers: { 'Content-type': 'application/json' } }); }; @@ -148,19 +175,22 @@ class MetadataManagerAPI { return this.req.put(url, params); }; - deleteView = (repoID, viewId) => { + deleteView = (repoID, viewId, folder_id = '') => { const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/views/'; - const params = { - view_id: viewId, - }; + let params = { view_id: viewId }; + if (folder_id) { + params.folder_id = folder_id; + } return this.req.delete(url, { data: params }); }; - moveView = (repoID, viewId, targetViewId) => { + moveView = (repoID, source_view_id, source_folder_id, target_view_id, target_folder_id) => { const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/move-views/'; const params = { - view_id: viewId, - target_view_id: targetViewId, + source_view_id, + source_folder_id, + target_view_id, + target_folder_id, }; return this._sendPostRequest(url, params, { headers: { 'Content-type': 'application/json' } }); }; diff --git a/frontend/src/metadata/components/metadata-view-name.js b/frontend/src/metadata/components/metadata-view-name.js index 1a2ee3b3718..b626ee7b4ec 100644 --- a/frontend/src/metadata/components/metadata-view-name.js +++ b/frontend/src/metadata/components/metadata-view-name.js @@ -3,9 +3,9 @@ import PropTypes from 'prop-types'; import { useMetadata } from '../hooks'; const MetadataViewName = ({ id }) => { - const { viewsMap } = useMetadata(); + const { idViewMap } = useMetadata(); if (!id) return null; - const view = viewsMap[id]; + const view = idViewMap[id]; if (!view) return null; return (<>{view.name}); }; diff --git a/frontend/src/metadata/components/popover/view-popover/add-view/index.js b/frontend/src/metadata/components/popover/view-popover/add-view/index.js deleted file mode 100644 index 165edd1bfe7..00000000000 --- a/frontend/src/metadata/components/popover/view-popover/add-view/index.js +++ /dev/null @@ -1,96 +0,0 @@ -import React, { useRef, useEffect, useCallback } from 'react'; -import { UncontrolledPopover } from 'reactstrap'; -import PropTypes from 'prop-types'; -import Icon from '../../../../../components/icon'; -import { gettext } from '../../../../../utils/constants'; -import { VIEW_TYPE, VIEW_TYPE_ICON } from '../../../../constants'; - -import '../index.css'; - -const VIEW_OPTIONS = [ - { - key: 'table', - type: VIEW_TYPE.TABLE, - }, { - key: 'gallery', - type: VIEW_TYPE.GALLERY, - }, { - key: 'kanban', - type: VIEW_TYPE.KANBAN, - }, { - key: 'map', - type: VIEW_TYPE.MAP, - } -]; - -const AddView = ({ target, toggle, onOptionClick }) => { - const popoverRef = useRef(null); - - const handleClickOutside = useCallback((event) => { - if (popoverRef.current && !popoverRef.current.contains(event.target)) { - toggle(event); - } - }, [toggle]); - - useEffect(() => { - if (popoverRef.current) { - document.addEventListener('click', handleClickOutside, true); - } - - return () => { - document.removeEventListener('click', handleClickOutside, true); - }; - }, [handleClickOutside]); - - const translateLabel = useCallback((type) => { - switch (type) { - case VIEW_TYPE.TABLE: - return gettext('Table'); - case VIEW_TYPE.GALLERY: - return gettext('Gallery'); - case VIEW_TYPE.KANBAN: - return gettext('Kanban'); - case VIEW_TYPE.MAP: - return gettext('Map'); - default: - return type; - } - }, []); - - return ( - -
-
{gettext('New view')}
-
- {VIEW_OPTIONS.map((item, index) => { - return ( - - ); - })} -
-
-
- ); -}; - -AddView.propTypes = { - target: PropTypes.string.isRequired, - toggle: PropTypes.func.isRequired, - onOptionClick: PropTypes.func.isRequired, -}; - -export default AddView; diff --git a/frontend/src/metadata/components/popover/view-popover/index.css b/frontend/src/metadata/components/popover/view-popover/index.css deleted file mode 100644 index fa320fee90e..00000000000 --- a/frontend/src/metadata/components/popover/view-popover/index.css +++ /dev/null @@ -1,51 +0,0 @@ -.sf-metadata-addview-popover .popover { - min-width: 280px; - padding: 0.5rem 0; -} - -.sf-metadata-view-form { - display: flex; - padding-left: 0.5rem; - gap: 0.5rem; - height: 28px; -} - -.sf-metadata-addview-popover .sf-metadata-addview-popover-header { - width: 100%; - height: 2rem; - padding: 0 1rem; - display: flex; - align-items: center; - justify-content: left; - color: #666; - opacity: 1; - font-size: 0.875rem; -} - -.sf-metadata-addview-popover -.sf-metadata-addview-popover-body { - width: 100%; - display: flex; - flex-direction: column; -} - -.dropdown-item.sf-metadata-addview-popover-item { - width: 100%; - height: 2rem; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.dropdown-item.sf-metadata-addview-popover-item .metadata-view-icon { - display: flex; - align-items: center; - font-size: 1rem; - color: #666; - fill: #666; -} - -.dropdown-item:hover .metadata-view-icon { - color: #fff; - fill: #fff; -} diff --git a/frontend/src/metadata/components/popover/view-popover/index.js b/frontend/src/metadata/components/popover/view-popover/index.js deleted file mode 100644 index b8760c19c66..00000000000 --- a/frontend/src/metadata/components/popover/view-popover/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as AddView } from './add-view'; diff --git a/frontend/src/metadata/components/view-details/index.js b/frontend/src/metadata/components/view-details/index.js index ebab1f6e109..8acc0b3577a 100644 --- a/frontend/src/metadata/components/view-details/index.js +++ b/frontend/src/metadata/components/view-details/index.js @@ -9,9 +9,9 @@ import { VIEW_TYPE } from '../../constants'; import './index.css'; const ViewDetails = ({ viewId, onClose }) => { - const { viewsMap } = useMetadata(); + const { idViewMap } = useMetadata(); - const view = useMemo(() => viewsMap[viewId], [viewId, viewsMap]); + const view = useMemo(() => idViewMap[viewId], [viewId, idViewMap]); const icon = useMemo(() => { const type = view.type; if (type === VIEW_TYPE.GALLERY) return `${mediaUrl}favicons/gallery.png`; diff --git a/frontend/src/metadata/constants/event-bus-type.js b/frontend/src/metadata/constants/event-bus-type.js index c36df1be29d..00507a4c07b 100644 --- a/frontend/src/metadata/constants/event-bus-type.js +++ b/frontend/src/metadata/constants/event-bus-type.js @@ -3,6 +3,10 @@ */ export const EVENT_BUS_TYPE = { + // folder/views + ADD_FOLDER: 'add_folder', + ADD_VIEW: 'add_view', + QUERY_COLLABORATORS: 'query_collaborators', QUERY_COLLABORATOR: 'query_collaborator', UPDATE_TABLE_ROWS: 'update_table_rows', diff --git a/frontend/src/metadata/constants/view.js b/frontend/src/metadata/constants/view.js index 84dc83d7d64..398d674978b 100644 --- a/frontend/src/metadata/constants/view.js +++ b/frontend/src/metadata/constants/view.js @@ -4,6 +4,16 @@ import { SORT_COLUMN_OPTIONS, GALLERY_SORT_COLUMN_OPTIONS, GALLERY_FIRST_SORT_CO GALLERY_SORT_PRIVATE_COLUMN_KEYS, GALLERY_FIRST_SORT_PRIVATE_COLUMN_KEYS, } from './sort'; +export const METADATA_VIEWS_KEY = 'sf-metadata-views'; + +export const METADATA_VIEWS_DRAG_DATA_KEY = 'application/drag-sf-metadata-views'; + +export const TREE_NODE_LEFT_INDENT = 20; + +export const VIEWS_TYPE_FOLDER = 'folder'; + +export const VIEWS_TYPE_VIEW = 'view'; + export const VIEW_TYPE = { TABLE: 'table', GALLERY: 'gallery', diff --git a/frontend/src/metadata/hooks/metadata.js b/frontend/src/metadata/hooks/metadata.js index 61778e8a9c0..b34ef9a955f 100644 --- a/frontend/src/metadata/hooks/metadata.js +++ b/frontend/src/metadata/hooks/metadata.js @@ -2,13 +2,16 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from 'rea import metadataAPI from '../api'; import { Utils } from '../../utils/utils'; import toaster from '../../components/toast'; +import Folder from '../model/metadata/folder'; import { gettext } from '../../utils/constants'; import { PRIVATE_FILE_TYPE } from '../../constants'; -import { FACE_RECOGNITION_VIEW_ID, VIEW_TYPE } from '../constants'; +import { FACE_RECOGNITION_VIEW_ID, VIEW_TYPE, VIEWS_TYPE_FOLDER, VIEWS_TYPE_VIEW } from '../constants'; import { useMetadataStatus } from '../../hooks'; import { updateFavicon } from '../utils/favicon'; import { getViewName } from '../utils/view'; +const CACHED_COLLAPSED_FOLDERS_PREFIX = 'sf-metadata-collapsed-folders'; + // This hook provides content related to seahub interaction, such as whether to enable extended attributes, views data, etc. const MetadataContext = React.createContext(null); @@ -16,13 +19,35 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi const [isLoading, setLoading] = useState(true); const [enableFaceRecognition, setEnableFaceRecognition] = useState(false); const [navigation, setNavigation] = useState([]); - const [, setCount] = useState(0); + const [idViewMap, setIdViewMap] = useState({}); - const viewsMap = useRef({}); + const collapsedFoldersIds = useRef([]); const originalTitleRef = useRef(document.title); const { enableMetadata, isBeingBuilt, setIsBeingBuilt } = useMetadataStatus(); + const getCollapsedFolders = useCallback(() => { + const strFoldedFolders = window.localStorage.getItem(`${CACHED_COLLAPSED_FOLDERS_PREFIX}-${repoID}`); + const foldedFolders = strFoldedFolders && JSON.parse(strFoldedFolders); + return Array.isArray(foldedFolders) ? foldedFolders : []; + }, [repoID]); + + const setCollapsedFolders = useCallback((collapsedFoldersIds) => { + window.localStorage.setItem(`${CACHED_COLLAPSED_FOLDERS_PREFIX}-${repoID}`, JSON.stringify(collapsedFoldersIds)); + }, [repoID]); + + const addViewIntoMap = useCallback((viewId, view) => { + let updatedIdViewInMap = { ...idViewMap }; + updatedIdViewInMap[viewId] = view; + setIdViewMap(updatedIdViewInMap); + }, [idViewMap]); + + const deleteViewFromMap = useCallback((viewId) => { + let updatedIdViewInMap = { ...idViewMap }; + delete updatedIdViewInMap[viewId]; + setIdViewMap(updatedIdViewInMap); + }, [idViewMap]); + // views useEffect(() => { setLoading(true); @@ -30,9 +55,11 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi metadataAPI.listViews(repoID).then(res => { const { navigation, views } = res.data; if (Array.isArray(views)) { + let idViewMap = {}; views.forEach(view => { - viewsMap.current[view._id] = { ...view, name: getViewName(view) }; + idViewMap[view._id] = { ...view, name: getViewName(view) }; }); + setIdViewMap(idViewMap); } setNavigation(navigation); setLoading(false); @@ -45,7 +72,7 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi } hideMetadataView && hideMetadataView(); setEnableFaceRecognition(false); - viewsMap.current = {}; + setIdViewMap({}); setNavigation([]); setLoading(false); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -64,6 +91,15 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi }); }, [repoID, enableMetadata]); + const getFirstView = useCallback(() => { + const firstViewNav = navigation.find(item => item.type === VIEWS_TYPE_VIEW); + const firstView = firstViewNav ? idViewMap[firstViewNav._id] : null; + if (!firstView && Object.keys(idViewMap).length > 0) { + return idViewMap[Object.keys(idViewMap)[0]]; + } + return firstView; + }, [navigation, idViewMap]); + const selectView = useCallback((view, isSelected) => { if (isSelected) return; const node = { @@ -87,95 +123,284 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi // eslint-disable-next-line react-hooks/exhaustive-deps }, [repoID, selectMetadataView]); - const addView = useCallback((name, type, successCallback, failCallback) => { - metadataAPI.addView(repoID, name, type).then(res => { - const view = res.data.view; - let newNavigation = navigation.slice(0); - newNavigation.push({ _id: view._id, type: 'view' }); - viewsMap.current[view._id] = { ...view, name: getViewName(view) }; + useEffect(() => { + collapsedFoldersIds.current = getCollapsedFolders(); + }, [getCollapsedFolders]); + + const collapseFolder = useCallback((folderId) => { + let updatedCollapsedFoldersIds = getCollapsedFolders(); + if (updatedCollapsedFoldersIds.includes(folderId)) { + return; + } + updatedCollapsedFoldersIds.push(folderId); + setCollapsedFolders(updatedCollapsedFoldersIds); + }, [getCollapsedFolders, setCollapsedFolders]); + + const expandFolder = useCallback((folderId) => { + let updatedCollapsedFoldersIds = getCollapsedFolders(); + if (!updatedCollapsedFoldersIds.includes(folderId)) { + return; + } + updatedCollapsedFoldersIds = updatedCollapsedFoldersIds.filter((collapsedFolderId) => collapsedFolderId !== folderId); + setCollapsedFolders(updatedCollapsedFoldersIds); + }, [getCollapsedFolders, setCollapsedFolders]); + + const addFolder = useCallback((name, successCallback, failCallback) => { + metadataAPI.addFolder(repoID, name).then(res => { + let newNavigation = [...navigation]; + const folder = new Folder(res.data.folder); + newNavigation.push(folder); setNavigation(newNavigation); - selectView(view); successCallback && successCallback(); }).catch(error => { failCallback && failCallback(error); }); - }, [navigation, repoID, viewsMap, selectView]); + }, [repoID, navigation]); - const duplicateView = useCallback((viewId) => { - metadataAPI.duplicateView(repoID, viewId).then(res => { - const view = res.data.view; - let newNavigation = navigation.slice(0); - newNavigation.push({ _id: view._id, type: 'view' }); - viewsMap.current[view._id] = view; + const modifyFolder = useCallback((folderId, updates, successCallback, failCallback) => { + metadataAPI.modifyFolder(repoID, folderId, updates).then(res => { + let newNavigation = [...navigation]; + let folderIndex = newNavigation.findIndex((nav) => nav._id === folderId && nav.type === VIEWS_TYPE_FOLDER); + if (folderIndex < 0) { + return; + } + const validUpdates = { ...updates }; + delete validUpdates._id; + delete validUpdates.type; + delete validUpdates.children; + let updatedFolder = newNavigation[folderIndex]; + updatedFolder = Object.assign({}, updatedFolder, validUpdates); + newNavigation[folderIndex] = updatedFolder; setNavigation(newNavigation); - selectView(view); + successCallback && successCallback(); + }).catch(error => { + failCallback && failCallback(error); + }); + }, [repoID, navigation]); + + const deleteFolder = useCallback((folderId) => { + metadataAPI.deleteFolder(repoID, folderId).then(res => { + let newNavigation = [...navigation]; + let folderIndex = newNavigation.findIndex((nav) => nav._id === folderId && nav.type === VIEWS_TYPE_FOLDER); + if (folderIndex < 0) { + return; + } + const viewsInFolder = newNavigation[folderIndex].children; + newNavigation.splice(folderIndex, 1); + if (viewsInFolder.length > 0) { + newNavigation.push(...viewsInFolder); + } + setNavigation(newNavigation); + }).catch((error => { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + })); + }, [repoID, navigation]); + + const addViewCallback = useCallback((view, folderId) => { + const newViewNav = { _id: view._id, type: VIEWS_TYPE_VIEW }; + let newNavigation = [...navigation]; + if (folderId) { + // add view into folder + const folderIndex = newNavigation.findIndex((nav) => nav._id === folderId && nav.type === VIEWS_TYPE_FOLDER); + if (folderIndex < 0) { + return; + } + let updatedFolder = newNavigation[folderIndex]; + updatedFolder.children = Array.isArray(updatedFolder.children) ? updatedFolder.children : []; + updatedFolder.children.push(newViewNav); + } else { + newNavigation.push(newViewNav); + } + const newView = { ...view, name: getViewName(view) }; + addViewIntoMap(newView._id, newView); + setNavigation(newNavigation); + selectView(newView); + }, [navigation, addViewIntoMap, setNavigation, selectView]); + + const addView = useCallback(({ folderId, name, type, successCallback, failCallback }) => { + metadataAPI.addView(repoID, name, type, folderId).then(res => { + const view = res.data.view; + addViewCallback(view, folderId); + successCallback && successCallback(); + }).catch(error => { + failCallback && failCallback(error); + }); + }, [repoID, addViewCallback]); + + const duplicateView = useCallback(({ folderId, viewId }) => { + metadataAPI.duplicateView(repoID, viewId, folderId).then(res => { + const view = res.data.view; + addViewCallback(view, folderId); }).catch(error => { const errorMsg = Utils.getErrorMsg(error); toaster.danger(errorMsg); }); - }, [navigation, repoID, viewsMap, selectView]); + }, [repoID, addViewCallback]); + + const deleteView = useCallback(({ folderId, viewId, isSelected }) => { + metadataAPI.deleteView(repoID, viewId, folderId).then(res => { + let newNavigation = [...navigation]; + let prevViewNav = null; + if (folderId) { + let folderIndex = newNavigation.findIndex((nav) => nav._id === folderId && nav.type === VIEWS_TYPE_FOLDER); + if (folderIndex < 0) { + return; + } + let updatedFolder = newNavigation[folderIndex]; + if (!Array.isArray(updatedFolder.children) || updatedFolder.children.length === 0) { + return; + } + const currentViewIndex = updatedFolder.children.findIndex((viewNav) => viewNav._id === viewId); + prevViewNav = updatedFolder.children[currentViewIndex - 1]; + updatedFolder.children = updatedFolder.children.filter(viewNav => viewNav._id !== viewId); + } else { + const currentViewIndex = newNavigation.findIndex(item => item._id === viewId); + prevViewNav = newNavigation[currentViewIndex - 1]; + newNavigation = newNavigation.filter(nav => nav._id !== viewId); + } - const deleteView = useCallback((viewId, isSelected) => { - metadataAPI.deleteView(repoID, viewId).then(res => { - const newNavigation = navigation.filter(item => item._id !== viewId); - delete viewsMap.current[viewId]; setNavigation(newNavigation); + deleteViewFromMap(viewId); + + // re-select the previous view if (isSelected) { - const currentViewIndex = navigation.findIndex(item => item._id === viewId); - const lastViewId = navigation[currentViewIndex - 1]._id; - const lastView = viewsMap.current[lastViewId]; - selectView(lastView); + let prevView = null; + if (prevViewNav && prevViewNav.type === VIEWS_TYPE_VIEW) { + prevView = idViewMap[prevViewNav._id]; + } + if (!prevView) { + prevView = getFirstView(); + } + selectView(prevView); } }).catch((error => { const errorMsg = Utils.getErrorMsg(error); toaster.danger(errorMsg); })); - }, [repoID, navigation, selectView, viewsMap]); + }, [repoID, navigation, idViewMap, deleteViewFromMap, getFirstView, selectView]); const updateView = useCallback((viewId, update, successCallback, failCallback) => { metadataAPI.modifyView(repoID, viewId, update).then(res => { - const currentView = viewsMap.current[viewId]; - viewsMap.current[viewId] = { ...currentView, ...update }; - setCount(n => n + 1); + const currentView = idViewMap[viewId]; + addViewIntoMap(viewId, { ...currentView, ...update }); successCallback && successCallback(); }).catch(error => { failCallback && failCallback(error); }); - }, [repoID, viewsMap]); + }, [repoID, idViewMap, addViewIntoMap]); + + const moveView = useCallback(({ sourceViewId, sourceFolderId, targetViewId, targetFolderId }) => { + if ( + (!sourceViewId && !sourceFolderId) // must drag view or folder + || (!targetViewId && !targetFolderId) // must move above to view/folder or move view into folder + || (sourceViewId === targetViewId && sourceFolderId === targetFolderId) // not changed + || (!sourceViewId && sourceFolderId && targetViewId && targetFolderId) // not allowed to drag folder into folder + ) { + return; + } + metadataAPI.moveView(repoID, sourceViewId, sourceFolderId, targetViewId, targetFolderId).then(res => { + let newNavigation = [...navigation]; - const moveView = useCallback((sourceViewId, targetViewId) => { - metadataAPI.moveView(repoID, sourceViewId, targetViewId).then(res => { - const { navigation } = res.data; - setNavigation(navigation); + // remove folder/view from old position + let updatedSourceNavList = null; + let sourceId = null; + if (sourceFolderId) { + if (sourceViewId) { + // drag view from folder + const sourceFolder = newNavigation.find((folder) => folder._id === sourceFolderId); + updatedSourceNavList = sourceFolder && sourceFolder.children; + sourceId = sourceViewId; + } else { + // drag folder + updatedSourceNavList = newNavigation; + sourceId = sourceFolderId; + } + } else if (sourceViewId) { + // drag view outer of folders + updatedSourceNavList = newNavigation; + sourceId = sourceViewId; + } + + // invalid drag source + if (!Array.isArray(updatedSourceNavList) || updatedSourceNavList.length === 0 || !sourceId) { + return; + } + const movedNavIndex = updatedSourceNavList.findIndex((nav) => nav._id === sourceId); + if (movedNavIndex < 0) { + return; + } + + const movedNav = updatedSourceNavList[movedNavIndex]; + updatedSourceNavList.splice(movedNavIndex, 1); + + // insert folder/view into new position + let updatedTargetNavList = newNavigation; + if (targetFolderId && sourceViewId) { + // move view into folder + let targetFolder = newNavigation.find((folder) => folder._id === targetFolderId); + if (!Array.isArray(targetFolder.children)) { + targetFolder.children = []; + } + updatedTargetNavList = targetFolder.children; + } + + let targetNavIndex = -1; + if (targetViewId) { + // move folder/view above to view + targetNavIndex = updatedTargetNavList.findIndex((nav) => nav._id === targetViewId); + } else if (!sourceViewId && targetFolderId) { + // move folder above to folder + targetNavIndex = updatedTargetNavList.findIndex((nav) => nav._id === targetFolderId); + } + + if (targetNavIndex > -1) { + updatedTargetNavList.splice(targetNavIndex, 0, movedNav); // move above to the target folder/view + } else { + updatedTargetNavList.push(movedNav); // move into navigation or folder + } + + setNavigation(newNavigation); }).catch(error => { const errorMsg = Utils.getErrorMsg(error); toaster.danger(errorMsg); }); - }, [repoID]); + }, [repoID, navigation]); const updateEnableFaceRecognition = useCallback((newValue) => { if (newValue === enableFaceRecognition) return; if (newValue) { toaster.success(gettext('Recognizing portraits. Please refresh the page later.')); - addView('_people', VIEW_TYPE.FACE_RECOGNITION, () => {}, () => {}); + addView({ name: '_people', type: VIEW_TYPE.FACE_RECOGNITION }); } else { - if (viewsMap.current[FACE_RECOGNITION_VIEW_ID]) { + if (idViewMap[FACE_RECOGNITION_VIEW_ID]) { let isSelected = false; if (currentPath.includes('/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/')) { const currentViewId = currentPath.split('/').pop(); isSelected = currentViewId === FACE_RECOGNITION_VIEW_ID; } - deleteView(FACE_RECOGNITION_VIEW_ID, isSelected); + const folders = navigation.filter((nav) => nav.type === VIEWS_TYPE_FOLDER); + const targetFolder = folders.find((folder) => { + const { children } = folder; + if (Array.isArray(children) && children.length > 0) { + const view = children.find((viewNav) => viewNav._id === FACE_RECOGNITION_VIEW_ID); + if (view) { + return true; + } + } + return false; + }); + const folderId = targetFolder ? targetFolder._id : null; + deleteView({ folderId, viewId: FACE_RECOGNITION_VIEW_ID, isSelected }); } } setEnableFaceRecognition(newValue); - }, [enableFaceRecognition, currentPath, addView, deleteView]); + }, [enableFaceRecognition, currentPath, idViewMap, navigation, addView, deleteView]); useEffect(() => { if (isLoading) return; if (isBeingBuilt) { - const firstViewObject = navigation.find(item => item.type === 'view'); - const firstView = firstViewObject ? viewsMap.current[firstViewObject._id] : ''; + const firstView = getFirstView(); if (firstView) { selectView(firstView); } @@ -186,7 +411,7 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi if (!urlParams.has('view')) return; const viewID = urlParams.get('view'); if (viewID) { - const lastOpenedView = viewsMap.current[viewID] || ''; + const lastOpenedView = idViewMap[viewID] || ''; if (lastOpenedView) { selectView(lastOpenedView); return; @@ -195,8 +420,7 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi window.history.pushState({ url: url, path: '' }, '', url); } - const firstViewObject = navigation.find(item => item.type === 'view'); - const firstView = firstViewObject ? viewsMap.current[firstViewObject._id] : ''; + const firstView = getFirstView(); if (firstView) { selectView(firstView); } @@ -206,7 +430,7 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi useEffect(() => { if (!currentPath.includes('/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/')) return; const currentViewId = currentPath.split('/').pop(); - const currentView = viewsMap.current[currentViewId]; + const currentView = idViewMap[currentViewId]; if (currentView) { document.title = `${currentView.name} - Seafile`; updateFavicon(currentView.type); @@ -214,7 +438,7 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi } document.title = originalTitleRef.current; updateFavicon('default'); - }, [currentPath, viewsMap]); + }, [currentPath, idViewMap]); return ( { + const { + idViewMap, collapsedFoldersIds, collapseFolder, expandFolder, modifyFolder, deleteFolder, + deleteView, duplicateView, addView, moveView, + } = useMetadata(); + const { _id: folderId, name: folderName, children } = folder || {}; + const [expanded, setExpanded] = useState(!collapsedFoldersIds.includes(folderId)); + const [highlight, setHighlight] = useState(false); + const [freeze, setFreeze] = useState(false); + const [isRenaming, setRenaming] = useState(false); + const [newView, setNewView] = useState(null); + const [isDropShow, setDropShow] = useState(false); + + const canUpdate = useMemo(() => { + if (userPerm !== 'rw' && userPerm !== 'admin') return false; + return true; + }, [userPerm]); + + const canDrop = useMemo(() => { + if (Utils.isIEBrowser() || !canUpdate) return false; + return true; + }, [canUpdate]); + + const folderMoreOperationMenus = useMemo(() => { + let menus = []; + if (canUpdate) { + menus.push( + getNewViewMenuItem(), + TextTranslation.RENAME, + TextTranslation.DELETE, + ); + } + return menus; + }, [canUpdate]); + + const onMouseEnter = useCallback(() => { + if (freeze) return; + setHighlight(true); + }, [freeze]); + + const onMouseOver = useCallback(() => { + if (freeze) return; + setHighlight(true); + }, [freeze]); + + const onMouseLeave = useCallback(() => { + if (freeze) return; + setHighlight(false); + }, [freeze]); + + const freezeItem = useCallback(() => { + setFreeze(true); + setHighlight(true); + }, []); + + const unfreezeItem = useCallback(() => { + setFreeze(false); + setHighlight(false); + }, []); + + const clickFolder = useCallback(() => { + if (expanded) { + collapseFolder(folderId); + } else { + expandFolder(folderId); + } + setExpanded(!expanded); + }, [expanded, folderId, collapseFolder, expandFolder]); + + const prepareAddView = useCallback((viewType) => { + setNewView({ key: viewType, type: viewType, default_name: generateNewViewDefaultName() }); + }, [generateNewViewDefaultName]); + + const clickMenu = useCallback((operationKey) => { + switch (operationKey) { + case KEY_ADD_VIEW_MAP.ADD_TABLE: { + prepareAddView(VIEW_TYPE.TABLE); + return; + } + case KEY_ADD_VIEW_MAP.ADD_GALLERY: { + prepareAddView(VIEW_TYPE.GALLERY); + return; + } + case KEY_ADD_VIEW_MAP.ADD_KANBAN: { + prepareAddView(VIEW_TYPE.KANBAN); + return; + } + case KEY_ADD_VIEW_MAP.ADD_MAP: { + prepareAddView(VIEW_TYPE.MAP); + return; + } + case TextTranslation.RENAME.key: { + setRenaming(true); + return; + } + case TextTranslation.DELETE.key: { + deleteFolder(folderId); + return; + } + default: { + return; + } + } + }, [prepareAddView, folderId, deleteFolder]); + + const onDragStart = useCallback((event) => { + if (!canDrop) return false; + const dragData = JSON.stringify({ type: METADATA_VIEWS_KEY, folder_id: folderId, mode: VIEWS_TYPE_FOLDER }); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData(METADATA_VIEWS_DRAG_DATA_KEY, dragData); + setDragMode(VIEWS_TYPE_FOLDER); + }, [canDrop, folderId, setDragMode]); + + const onDragEnter = useCallback((event) => { + if (!canDrop) { + // not allowed drag folder into folder + return false; + } + if (!canDrop) { + return false; + } + setDropShow(true); + }, [canDrop]); + + const onDragLeave = useCallback(() => { + if (!canDrop) return false; + setDropShow(false); + }, [canDrop]); + + const onDragMove = useCallback((event) => { + if (!canDrop) return false; + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }, [canDrop]); + + const onDrop = useCallback((event) => { + if (!canDrop) return false; + event.stopPropagation(); + setDropShow(false); + + let dragData = event.dataTransfer.getData(METADATA_VIEWS_DRAG_DATA_KEY); + if (!dragData) return; + dragData = JSON.parse(dragData); + if (dragData.type !== METADATA_VIEWS_KEY) return false; + const dragMode = getDragMode(); + const { view_id: sourceViewId, folder_id: sourceFolderId } = dragData; + if ((dragMode === VIEWS_TYPE_VIEW && !sourceViewId)) { + return; + } + moveView({ sourceViewId, sourceFolderId, targetFolderId: folderId }); + }, [canDrop, folderId, getDragMode, moveView]); + + const onConfirmRename = useCallback((name) => { + const foldersNames = getFoldersNames(); + const otherFoldersNames = foldersNames.filter((currFolderName) => currFolderName !== folderName); + const { isValid, message } = validateName(name, otherFoldersNames); + if (!isValid) { + toaster.danger(message); + return; + } + if (message === folderName) { + setRenaming(false); + return; + } + modifyFolder(folderId, { name: message }); + setRenaming(false); + }, [folderId, folderName, getFoldersNames, modifyFolder]); + + const closeNewView = useCallback(() => { + setNewView(null); + }, []); + + const addViewIntoFolder = useCallback((viewName, viewType) => { + addView({ folderId, name: viewName, type: viewType }); + }, [folderId, addView]); + + const deleteViewFromFolder = useCallback((viewId, isSelected) => { + deleteView({ folderId, viewId, isSelected }); + }, [folderId, deleteView]); + + const duplicateViewFromFolder = useCallback((viewId) => { + duplicateView({ folderId, viewId }); + }, [folderId, duplicateView]); + + const renderViews = () => { + if (!Array.isArray(children) || children.length === 0) { + return null; + } + return children.map((children) => { + const { _id: viewId, type: childType } = children || {}; + if (!viewId || childType !== VIEWS_TYPE_VIEW) { + return null; + } + + const view = idViewMap[viewId]; + if (!view) return null; + + const viewPath = '/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/' + viewId; + const isSelected = currentPath === viewPath; + return ( + + ); + }); + }; + + return ( +
+
+
+ {isRenaming ? ( + + ) : folderName} +
+
+ + + + +
+
+ {(highlight && folderMoreOperationMenus.length > 0) && ( + folderMoreOperationMenus} + onMenuItemClick={clickMenu} + menuStyle={isMobile ? { zIndex: 1050 } : {}} + /> + )} +
+
+
+ {expanded && renderViews()} + {newView && } +
+
+ ); +}; + +ViewsFolder.propTypes = { + leftIndent: PropTypes.number, + folder: PropTypes.object, + currentPath: PropTypes.string, + userPerm: PropTypes.string, + canDeleteView: PropTypes.bool, + getFoldersNames: PropTypes.func, + getMoveableFolders: PropTypes.func, + generateNewViewDefaultName: PropTypes.func, + setDragMode: PropTypes.func, + getDragMode: PropTypes.func, + selectView: PropTypes.func, + modifyView: PropTypes.func, +}; + +export default ViewsFolder; diff --git a/frontend/src/metadata/metadata-tree-view/index.css b/frontend/src/metadata/metadata-tree-view/index.css index 6eeabedcc4d..d64c648a360 100644 --- a/frontend/src/metadata/metadata-tree-view/index.css +++ b/frontend/src/metadata/metadata-tree-view/index.css @@ -1,26 +1,3 @@ -.metadata-tree-view .tree-node-inner { - height: 28px; - padding: 2px 0; -} - -.metadata-tree-view .tree-node-inner .left-icon { - top: 5px; - padding-left: 8px; -} - -.metadata-tree-view .tree-node-inner .tree-node-text { - padding-left: 28px; -} - -.metadata-tree-view .tree-node-icon { - height: 100%; - line-height: 1.5; - display: flex; - justify-content: center; - align-items: center; - transform: translateY(1px); -} - .metadata-tree-view .metadata-views-icon { height: 1rem; width: 1rem; @@ -28,45 +5,21 @@ color: #666; } -.metadata-tree-view .sf-metadata-add-view { - border-top: none; - height: 28px; - padding: 2px 0 2px 28px; - position: relative; -} - -.metadata-tree-view .sf-metadata-add-view:hover { - background-color: #f0f0f0; - border-radius: 0.25rem; -} - -.metadata-tree-view .sf-metadata-add-view .sf-metadata-add-view-icon { - position: absolute; - top: 8px; - left: 10px; - font-weight: 400; - fill: #666; -} - -.metadata-tree-view .sf-metadata-add-view .text-truncate { - display: inline-block; - font-size: 14px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 24px; - font-weight: 400; -} - .metadata-tree-view .sf-metadata-view-input { - width: 95%; height: 24px; - position: relative; font-size: 14px; - margin-top: 2px; box-shadow: none; } +.metadata-tree-view .sf-metadata-view-input.rename { + width: 95%; +} + .metadata-tree-view .metadata-views-icon { fill: #666; } + +.metadata-tree-view .sf-dropdown-toggle { + display: inline-block; + transform: rotate(90deg); +} diff --git a/frontend/src/metadata/metadata-tree-view/index.js b/frontend/src/metadata/metadata-tree-view/index.js index bb505c5be22..a2282bfc716 100644 --- a/frontend/src/metadata/metadata-tree-view/index.js +++ b/frontend/src/metadata/metadata-tree-view/index.js @@ -1,160 +1,173 @@ -import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; -import { Input } from 'reactstrap'; +import React, { useCallback, useEffect, useState, useMemo, useRef } from 'react'; import PropTypes from 'prop-types'; -import { CustomizeAddTool } from '@seafile/sf-metadata-ui-component'; -import toaster from '../../components/toast'; -import Icon from '../../components/icon'; -import ViewItem from './view-item'; -import { AddView } from '../components/popover/view-popover'; +import ViewsFolder from './folder'; +import ViewItem from './view'; +import NewFolder from './new-folder'; +import NewView from './new-view'; import { gettext } from '../../utils/constants'; import { useMetadata } from '../hooks'; import { PRIVATE_FILE_TYPE } from '../../constants'; -import { VIEW_TYPE, VIEW_TYPE_ICON } from '../constants'; -import { isValidViewName } from '../utils/validate'; -import { isEnter } from '../utils/hotkey'; +import { EVENT_BUS_TYPE, TREE_NODE_LEFT_INDENT, VIEWS_TYPE_FOLDER } from '../constants'; +import EventBus from '../../components/common/event-bus'; import './index.css'; const MetadataTreeView = ({ userPerm, currentPath }) => { - const canAdd = useMemo(() => { - if (userPerm !== 'rw' && userPerm !== 'admin') return false; - return true; - }, [userPerm]); - const [, setState] = useState(0); const { navigation, - viewsMap, + idViewMap, selectView, addView, duplicateView, deleteView, updateView, - moveView } = useMetadata(); - const [newView, setNewView] = useState(null); - const [showAddViewPopover, setShowAddViewPopover] = useState(false); const [showInput, setShowInput] = useState(false); - const [inputValue, setInputValue] = useState(''); + const [newView, setNewView] = useState(null); - const inputRef = useRef(null); + const eventBus = EventBus.getInstance(); - const onUpdateView = useCallback((viewId, update, successCallback, failCallback) => { - updateView(viewId, update, () => { - setState(n => n + 1); - successCallback && successCallback(); - }, failCallback); - }, [updateView]); + const dragMode = useRef(null); - const togglePopover = (event) => { - event.stopPropagation(); - setShowAddViewPopover(!showAddViewPopover); - }; + const setDragMode = useCallback((currDragMode) => { + dragMode.current = currDragMode; + }, []); + + const getDragMode = useCallback(() => { + return dragMode.current; + }, []); - const handleInputChange = (event) => { - setInputValue(event.target.value); + const canDeleteView = useMemo(() => { + return Object.keys(idViewMap).length > 1; + }, [idViewMap]); + + const getFolders = useCallback(() => { + return navigation.filter((nav) => nav.type === VIEWS_TYPE_FOLDER); + }, [navigation]); + + const getFoldersNames = useCallback(() => { + return getFolders().map((folder) => folder.name); + }, [getFolders]); + + const prepareAddFolder = () => { + setShowInput(true); }; - const handlePopoverOptionClick = useCallback((option) => { - setNewView(option); + const generateNewViewDefaultName = useCallback(() => { let newViewName = gettext('Untitled'); - const otherViewsName = Object.values(viewsMap).map(v => v.name); + const otherViewsName = Object.values(idViewMap).map(v => v.name); let i = 1; while (otherViewsName.includes(newViewName)) { newViewName = gettext('Untitled') + ' (' + (i++) + ')'; } - setInputValue(newViewName); + return newViewName; + }, [idViewMap]); + + const prepareAddView = useCallback(({ viewType }) => { + setNewView({ key: viewType, type: viewType, default_name: generateNewViewDefaultName() }); setShowInput(true); - setShowAddViewPopover(false); - }, [viewsMap]); - - const handleInputSubmit = useCallback((event) => { - event.preventDefault(); - event.stopPropagation(); - const viewNames = Object.values(viewsMap).map(v => v.name); - const { isValid, message } = isValidViewName(inputValue, viewNames); - if (!isValid) { - toaster.danger(message); - inputRef.current.focus(); - return; - } - addView(message, newView.type); + }, [generateNewViewDefaultName]); + + const closeNewView = useCallback(() => { setShowInput(false); - }, [inputValue, viewsMap, addView, newView]); + }, []); - const onKeyDown = useCallback((event) => { - if (isEnter(event)) { - handleInputSubmit(event); - } - }, [handleInputSubmit]); + const closeNewFolder = useCallback(() => { + setShowInput(false); + }, []); + + const modifyView = useCallback((viewId, update, successCallback, failCallback) => { + updateView(viewId, update, () => { + successCallback && successCallback(); + }, failCallback); + }, [updateView]); + + const getMoveableFolders = useCallback((currentFolderId) => { + const folders = getFolders(); + return folders.filter((folder) => folder._id !== currentFolderId); + }, [getFolders]); + + const handleAddView = useCallback((name, type) => { + addView({ name, type }); + }, [addView]); + + const handleDuplicateView = useCallback((viewId) => { + duplicateView({ viewId }); + }, [duplicateView]); + + const handleDeleteView = useCallback((viewId, isSelected) => { + deleteView({ viewId, isSelected }); + }, [deleteView]); useEffect(() => { - if (showInput && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [showInput]); + const unsubscribeAddFolder = eventBus.subscribe(EVENT_BUS_TYPE.ADD_FOLDER, prepareAddFolder); + const unsubscribeAddView = eventBus.subscribe(EVENT_BUS_TYPE.ADD_VIEW, prepareAddView); + return () => { + unsubscribeAddFolder(); + unsubscribeAddView(); + }; + }, [prepareAddView, eventBus]); + + const renderFolder = (folder) => { + return ( + + ); + }; + + const renderView = (view) => { + const viewPath = '/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/' + view._id; + const isSelected = currentPath === viewPath; + return ( + + ); + }; return ( - <> -
-
-
- {navigation.map((item, index) => { - const view = viewsMap[item._id]; - const viewPath = '/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/' + view._id; - const isSelected = currentPath === viewPath; - return ( - selectView(view, isSelected)} - onDelete={() => deleteView(view._id, isSelected)} - onCopy={() => duplicateView(view._id)} - onUpdate={(update, successCallback, failCallback) => onUpdateView(view._id, update, successCallback, failCallback)} - onMove={moveView} - /> - ); - })} - {showInput && ( -
-
- -
- -
- )} - {canAdd && ( -
- -
- )} -
+
+
+
+ {navigation.map((item, index) => { + if (item.type === VIEWS_TYPE_FOLDER) { + return renderFolder(item, index); + } + const view = idViewMap[item._id]; + return renderView(view, index); + })} + {showInput && (newView ? + : + + )}
- {showAddViewPopover && ( - - )} - +
); }; diff --git a/frontend/src/metadata/metadata-tree-view/inline-name-editor.js b/frontend/src/metadata/metadata-tree-view/inline-name-editor.js new file mode 100644 index 00000000000..98215ea4194 --- /dev/null +++ b/frontend/src/metadata/metadata-tree-view/inline-name-editor.js @@ -0,0 +1,58 @@ +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { Input } from 'reactstrap'; +import { isEnter } from '../utils/hotkey'; + +const InlineNameEditor = forwardRef(({ name, className, onSubmit }, ref) => { + const [inputValue, setInputValue] = useState(name || ''); + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, []); + + const handleInputChange = (event) => { + setInputValue(event.target.value); + }; + + const handleInputSubmit = useCallback((event) => { + event.preventDefault(); + event.stopPropagation(); + onSubmit(inputValue); + }, [inputValue, onSubmit]); + + const onKeyDown = useCallback((event) => { + if (isEnter(event)) { + handleInputSubmit(event); + } + }, [handleInputSubmit]); + + useImperativeHandle(ref, () => { + return { + inputRef, + }; + }, []); + + return ( + + ); +}); + +InlineNameEditor.propTypes = { + name: PropTypes.string, + onSubmit: PropTypes.func, +}; + +export default InlineNameEditor; diff --git a/frontend/src/metadata/metadata-tree-view/name-dialog/index.css b/frontend/src/metadata/metadata-tree-view/name-dialog/index.css deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/frontend/src/metadata/metadata-tree-view/name-dialog/index.js b/frontend/src/metadata/metadata-tree-view/name-dialog/index.js deleted file mode 100644 index ee90da98ac3..00000000000 --- a/frontend/src/metadata/metadata-tree-view/name-dialog/index.js +++ /dev/null @@ -1,99 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import PropTypes from 'prop-types'; -import { Modal, ModalHeader, ModalBody, ModalFooter, Form, FormGroup, Label, Input, Button, Alert } from 'reactstrap'; -import { KeyCodes } from '../../../constants'; -import { Utils } from '../../../utils/utils'; -import { gettext } from '../../../utils/constants'; - -const NameDialog = ({ value: oldName, title, onSubmit, onToggle }) => { - const [name, setName] = useState(oldName || ''); - const [errorMessage, setErrorMessage] = useState(''); - const [isSubmitting, setSubmitting] = useState(false); - - const onChange = useCallback((event) => { - const value = event.target.value; - if (value === name) return; - setName(value); - }, [name]); - - const validate = useCallback((name) => { - if (typeof name !== 'string') { - return { isValid: false, message: gettext('Name should be string') }; - } - name = name.trim(); - if (name === '') { - return { isValid: false, message: gettext('Name is required') }; - } - if (name.includes('/')) { - return { isValid: false, message: gettext('Name cannot contain slash') }; - } - if (name.includes('\\')) { - return { isValid: false, message: gettext('Name cannot contain backslash') }; - } - return { isValid: true, message: name }; - }, []); - - const submit = useCallback(() => { - setSubmitting(true); - const { isValid, message } = validate(name); - if (!isValid) { - setErrorMessage(message); - setSubmitting(false); - return; - } - if (message === oldName) { - onToggle(); - return; - } - onSubmit(message, () => { - onToggle(); - }, (error) => { - const errorMsg = Utils.getErrorMsg(error); - setErrorMessage(errorMsg); - setSubmitting(false); - }); - }, [validate, name, oldName, onToggle, onSubmit]); - - const onHotKey = useCallback((event) => { - if (event.keyCode === KeyCodes.Enter) { - event.preventDefault(); - submit(); - } - }, [submit]); - - useEffect(() => { - document.addEventListener('keydown', onHotKey); - return () => { - document.removeEventListener('keydown', onHotKey); - }; - }, [onHotKey]); - - return ( - - {title} - -
- - - - - -
- {errorMessage && {errorMessage}} -
- - - - -
- ); -}; - -NameDialog.propTypes = { - value: PropTypes.string, - title: PropTypes.string.isRequired, - onSubmit: PropTypes.func.isRequired, - onToggle: PropTypes.func.isRequired, -}; - -export default NameDialog; diff --git a/frontend/src/metadata/metadata-tree-view/new-folder.js b/frontend/src/metadata/metadata-tree-view/new-folder.js new file mode 100644 index 00000000000..4c9d2c638fd --- /dev/null +++ b/frontend/src/metadata/metadata-tree-view/new-folder.js @@ -0,0 +1,54 @@ +import React, { useCallback, useRef } from 'react'; +import PropTypes from 'prop-types'; +import toaster from '../../components/toast'; +import InlineNameEditor from './inline-name-editor'; +import { validateName } from '../utils/validate'; +import { useMetadata } from '../hooks'; +import { VIEWS_TYPE_FOLDER } from '../constants'; + +const NewFolder = ({ closeNewFolder }) => { + const { navigation, addFolder } = useMetadata(); + + const editorRef = useRef(null); + + const getFoldersNames = useCallback(() => { + return navigation.filter((nav) => nav.type === VIEWS_TYPE_FOLDER) + .map((folder) => folder.name); + }, [navigation]); + + const handleInputSubmit = useCallback((name) => { + const foldersNames = getFoldersNames(); + const { isValid, message } = validateName(name, foldersNames); + if (!isValid) { + toaster.danger(message); + const { inputRef } = editorRef.current || {}; + inputRef.current && inputRef.current.focus(); + return; + } + addFolder(message); + closeNewFolder(); + }, [addFolder, getFoldersNames, closeNewFolder]); + + return ( +
+
+
+ +
+
+ + +
+
+
+ ); +}; + +NewFolder.propTypes = { + closeNewView: PropTypes.func, +}; + +export default NewFolder; diff --git a/frontend/src/metadata/metadata-tree-view/new-view.js b/frontend/src/metadata/metadata-tree-view/new-view.js new file mode 100644 index 00000000000..e9f1b5ad117 --- /dev/null +++ b/frontend/src/metadata/metadata-tree-view/new-view.js @@ -0,0 +1,54 @@ +import React, { useCallback, useRef } from 'react'; +import PropTypes from 'prop-types'; +import toaster from '../../components/toast'; +import Icon from '../../components/icon'; +import InlineNameEditor from './inline-name-editor'; +import { useMetadata } from '../hooks'; +import { validateName } from '../utils/validate'; +import { VIEW_TYPE, VIEW_TYPE_ICON } from '../constants'; + +const NewView = ({ newView, leftIndent, closeNewView, addView }) => { + const { type: newViewType } = newView; + const { idViewMap } = useMetadata(); + const editorRef = useRef(null); + + const handleInputSubmit = useCallback((name) => { + const viewNames = Object.values(idViewMap).map(v => v.name); + const { isValid, message } = validateName(name, viewNames); + if (!isValid) { + toaster.danger(message); + const { inputRef } = editorRef.current || {}; + inputRef.current && inputRef.current.focus(); + return; + } + addView(message, newViewType); + closeNewView(); + }, [newViewType, idViewMap, addView, closeNewView]); + + return ( +
+
+
+ +
+
+ + + +
+
+
+ ); +}; + +NewView.propTypes = { + newView: PropTypes.object, + leftIndent: PropTypes.number, + closeNewView: PropTypes.func, +}; + +export default NewView; diff --git a/frontend/src/metadata/metadata-tree-view/view-item/index.css b/frontend/src/metadata/metadata-tree-view/view-item/index.css deleted file mode 100644 index 930a4b43ce8..00000000000 --- a/frontend/src/metadata/metadata-tree-view/view-item/index.css +++ /dev/null @@ -1,4 +0,0 @@ -.metadata-tree-view .sf-dropdown-toggle { - display: inline-block; - transform: rotate(90deg); -} diff --git a/frontend/src/metadata/metadata-tree-view/view-item/index.js b/frontend/src/metadata/metadata-tree-view/view.js similarity index 53% rename from frontend/src/metadata/metadata-tree-view/view-item/index.js rename to frontend/src/metadata/metadata-tree-view/view.js index 92d6fe2cdd6..34320ae6b06 100644 --- a/frontend/src/metadata/metadata-tree-view/view-item/index.js +++ b/frontend/src/metadata/metadata-tree-view/view.js @@ -1,41 +1,42 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Input } from 'reactstrap'; +import React, { useCallback, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import { gettext } from '../../../utils/constants'; -import Icon from '../../../components/icon'; -import ItemDropdownMenu from '../../../components/dropdown-menu/item-dropdown-menu'; -import { Utils, isMobile } from '../../../utils/utils'; -import { useMetadata } from '../../hooks'; -import { FACE_RECOGNITION_VIEW_ID, VIEW_TYPE_ICON } from '../../constants'; -import { isValidViewName } from '../../utils/validate'; -import { isEnter } from '../../utils/hotkey'; -import toaster from '../../../components/toast'; - -import './index.css'; +import { gettext } from '../../utils/constants'; +import Icon from '../../components/icon'; +import ItemDropdownMenu from '../../components/dropdown-menu/item-dropdown-menu'; +import toaster from '../../components/toast'; +import InlineNameEditor from './inline-name-editor'; +import { Utils, isMobile } from '../../utils/utils'; +import { useMetadata } from '../hooks'; +import { FACE_RECOGNITION_VIEW_ID, METADATA_VIEWS_DRAG_DATA_KEY, METADATA_VIEWS_KEY, VIEW_TYPE_ICON, VIEWS_TYPE_FOLDER, VIEWS_TYPE_VIEW } from '../constants'; +import { validateName } from '../utils/validate'; + +const MOVE_TO_FOLDER_PREFIX = 'move_to_folder_'; const ViewItem = ({ + leftIndent, canDelete, userPerm, isSelected, + folderId, view, + getMoveableFolders, + setDragMode, + getDragMode, onClick, onDelete, onCopy, onUpdate, - onMove, }) => { + const { _id: viewId, name: viewName } = view; const [highlight, setHighlight] = useState(false); const [freeze, setFreeze] = useState(false); const [isDropShow, setDropShow] = useState(false); const [isRenaming, setRenaming] = useState(false); - const [inputValue, setInputValue] = useState(view.name || ''); - - const inputRef = useRef(null); - const { viewsMap } = useMetadata(); + const { idViewMap, moveView } = useMetadata(); - const otherViewsName = Object.values(viewsMap).filter(v => v._id !== view._id).map(v => v.name); + const otherViewsName = Object.values(idViewMap).filter(v => v._id !== view._id).map(v => v.name); const canUpdate = useMemo(() => { if (userPerm !== 'rw' && userPerm !== 'admin') return false; @@ -49,18 +50,27 @@ const ViewItem = ({ const operations = useMemo(() => { if (!canUpdate) return []; - if (view._id === FACE_RECOGNITION_VIEW_ID) { + if (viewId === FACE_RECOGNITION_VIEW_ID) { return []; } let value = [ { key: 'rename', value: gettext('Rename') }, { key: 'duplicate', value: gettext('Duplicate') } ]; + + const moveableFolders = getMoveableFolders(folderId); + if (moveableFolders.length > 0) { + value.push({ + key: 'move', + value: gettext('Move'), + subOpList: moveableFolders.map((folder) => ({ key: `${MOVE_TO_FOLDER_PREFIX}${folder._id}`, value: folder.name, icon_dom: })), + }); + } if (canDelete) { value.push({ key: 'delete', value: gettext('Delete') }); } return value; - }, [view, canUpdate, canDelete]); + }, [folderId, viewId, canUpdate, canDelete, getMoveableFolders]); const onMouseEnter = useCallback(() => { if (freeze) return; @@ -79,6 +89,7 @@ const ViewItem = ({ const freezeItem = useCallback(() => { setFreeze(true); + setHighlight(true); }, []); const unfreezeItem = useCallback(() => { @@ -87,45 +98,60 @@ const ViewItem = ({ }, []); const operationClick = useCallback((operationKey) => { + if (operationKey.startsWith(MOVE_TO_FOLDER_PREFIX)) { + const targetFolderId = operationKey.split(MOVE_TO_FOLDER_PREFIX)[1]; + moveView({ sourceViewId: viewId, sourceFolderId: folderId, targetFolderId }); + return; + } if (operationKey === 'rename') { setRenaming(true); return; } if (operationKey === 'duplicate') { - onCopy(); + onCopy(viewId); return; } if (operationKey === 'delete') { - onDelete(); + onDelete(viewId, isSelected); return; } - }, [onDelete, onCopy]); + }, [folderId, viewId, isSelected, onDelete, onCopy, moveView]); const renameView = useCallback((name, failCallback) => { - onUpdate({ name }, () => { + onUpdate(viewId, { name }, () => { setRenaming(false); if (!isSelected) return; document.title = `${name} - Seafile`; }, (error) => { failCallback(error); if (!isSelected) return; - document.title = `${view.name} - Seafile`; + document.title = `${viewName} - Seafile`; }); - }, [isSelected, onUpdate, view.name]); + }, [isSelected, onUpdate, viewId, viewName]); const onDragStart = useCallback((event) => { if (!canDrop) return false; - const dragData = JSON.stringify({ type: 'sf-metadata-view', view_id: view._id }); + const dragData = JSON.stringify({ + type: METADATA_VIEWS_KEY, + mode: VIEWS_TYPE_VIEW, + view_id: viewId, + folder_id: folderId, + }); event.dataTransfer.effectAllowed = 'move'; - event.dataTransfer.setData('application/drag-sf-metadata-view', dragData); - }, [canDrop, view]); + event.dataTransfer.setData(METADATA_VIEWS_DRAG_DATA_KEY, dragData); + setDragMode(VIEWS_TYPE_VIEW); + }, [canDrop, viewId, folderId, setDragMode]); const onDragEnter = useCallback((event) => { - if (!canDrop) return false; + const dragMode = getDragMode(); + if (!canDrop || folderId && dragMode === VIEWS_TYPE_FOLDER) { + // not allowed drag folder into folder + return false; + } setDropShow(true); - }, [canDrop]); + }, [canDrop, folderId, getDragMode]); const onDragLeave = useCallback(() => { if (!canDrop) return false; @@ -139,78 +165,43 @@ const ViewItem = ({ }, [canDrop]); const onDrop = useCallback((event) => { - if (!canDrop) return false; + const dragMode = getDragMode(); + if (!canDrop || (folderId && dragMode === VIEWS_TYPE_FOLDER)) return false; event.stopPropagation(); setDropShow(false); - let dragData = event.dataTransfer.getData('application/drag-sf-metadata-view'); + let dragData = event.dataTransfer.getData(METADATA_VIEWS_DRAG_DATA_KEY); if (!dragData) return; dragData = JSON.parse(dragData); - if (dragData.type !== 'sf-metadata-view') return false; - if (!dragData.view_id) return; - onMove && onMove(dragData.view_id, view._id); - }, [canDrop, view, onMove]); - - const onChange = useCallback((e) => { - setInputValue(e.target.value); - }, []); + const { view_id: sourceViewId, folder_id: sourceFolderId } = dragData; + if ((dragMode === VIEWS_TYPE_VIEW && !sourceViewId) || (dragMode === VIEWS_TYPE_FOLDER && !sourceFolderId)) { + return; + } + moveView({ sourceViewId, sourceFolderId, targetViewId: viewId, targetFolderId: folderId }); + }, [canDrop, folderId, viewId, getDragMode, moveView]); - const handleSubmit = useCallback((event) => { - event.preventDefault(); - event.stopPropagation(); - const { isValid, message } = isValidViewName(inputValue, otherViewsName); + const handleSubmit = useCallback((name) => { + const { isValid, message } = validateName(name, otherViewsName); if (!isValid) { toaster.danger(message); return; } - if (message === view.name) { + if (message === viewName) { setRenaming(false); return; } renameView(message); - }, [view, inputValue, otherViewsName, renameView]); - - const onKeyDown = useCallback((event) => { - if (isEnter(event)) { - handleSubmit(event); - unfreezeItem(); - } - }, [handleSubmit, unfreezeItem]); - - useEffect(() => { - if (isRenaming && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [isRenaming]); - - useEffect(() => { - const handleClickOutside = (event) => { - if (inputRef.current && !inputRef.current.contains(event.target)) { - handleSubmit(event); - } - }; - - if (isRenaming) { - document.addEventListener('mousedown', handleClickOutside); - } else { - document.removeEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isRenaming, handleSubmit]); + }, [viewName, otherViewsName, renameView]); return ( - <> +
onClick(view)} + onClick={() => onClick(view, isSelected)} >
{isRenaming ? ( - setRenaming(false)} - onKeyDown={onKeyDown} + - ) : view.name} + ) : viewName}
-
+
{operations.length > 0 && ( -
+
{highlight && ( )}
- +
); }; ViewItem.propTypes = { + leftIndent: PropTypes.number, canDelete: PropTypes.bool, isSelected: PropTypes.bool, + folderId: PropTypes.string, view: PropTypes.object, + getMoveableFolders: PropTypes.func, + setDragMode: PropTypes.func, + getDragMode: PropTypes.func, onClick: PropTypes.func, }; diff --git a/frontend/src/metadata/model/metadata/folder.js b/frontend/src/metadata/model/metadata/folder.js new file mode 100644 index 00000000000..fd43bc508a0 --- /dev/null +++ b/frontend/src/metadata/model/metadata/folder.js @@ -0,0 +1,10 @@ +import { VIEWS_TYPE_FOLDER } from '../../constants'; + +export default class Folder { + constructor(object) { + this._id = object._id || ''; + this.name = object.name || ''; + this.type = object.type || VIEWS_TYPE_FOLDER; + this.children = Array.isArray(object.children) ? object.children : []; + } +} diff --git a/frontend/src/metadata/utils/validate/index.js b/frontend/src/metadata/utils/validate/index.js index de558529e68..1645126090c 100644 --- a/frontend/src/metadata/utils/validate/index.js +++ b/frontend/src/metadata/utils/validate/index.js @@ -6,4 +6,4 @@ export { export { isValidPosition, } from './geolocation'; -export { isValidViewName } from './view'; +export { validateName } from './view'; diff --git a/frontend/src/metadata/utils/validate/view.js b/frontend/src/metadata/utils/validate/view.js index 4f20712b13d..ef8e78dc01d 100644 --- a/frontend/src/metadata/utils/validate/view.js +++ b/frontend/src/metadata/utils/validate/view.js @@ -1,6 +1,6 @@ import { gettext } from '../../../utils/constants'; -export const isValidViewName = (name, names) => { +export const validateName = (name, names) => { if (typeof name !== 'string') { return { isValid: false, message: gettext('Name should be string') }; } diff --git a/frontend/src/tag/tags-tree-view/index.css b/frontend/src/tag/tags-tree-view/index.css new file mode 100644 index 00000000000..10dede9dca2 --- /dev/null +++ b/frontend/src/tag/tags-tree-view/index.css @@ -0,0 +1,17 @@ +.metadata-tree-view-tag .tree-node-inner .left-icon { + top: 5px; + padding-left: 8px; +} + +.metadata-tree-view-tag .tree-node-inner .tree-node-text { + padding-left: 28px; +} + +.metadata-tree-view-tag .tree-node-icon { + height: 100%; + line-height: 1.5; + display: flex; + justify-content: center; + align-items: center; + transform: translateY(1px); +} diff --git a/frontend/src/tag/tags-tree-view/index.js b/frontend/src/tag/tags-tree-view/index.js index 68f16b1cc6c..be69c3e162a 100644 --- a/frontend/src/tag/tags-tree-view/index.js +++ b/frontend/src/tag/tags-tree-view/index.js @@ -6,6 +6,8 @@ import { getTagId } from '../utils'; import { PRIVATE_FILE_TYPE } from '../../constants'; import AllTags from './all-tags'; +import './index.css'; + const TagsTreeView = ({ currentPath }) => { const { tagsData, selectTag } = useTags(); @@ -15,7 +17,7 @@ const TagsTreeView = ({ currentPath }) => { }, [tagsData]); return ( -
+
{tags.slice(0, 20).map(tag => { diff --git a/frontend/src/utils/text-translation.js b/frontend/src/utils/text-translation.js index 811b7411c98..5f8665a4619 100644 --- a/frontend/src/utils/text-translation.js +++ b/frontend/src/utils/text-translation.js @@ -3,187 +3,197 @@ import { gettext } from './constants'; // item --> '' : {key : '', value : gettext('')}; const TextTranslation = { // app-menu - 'NEW_FOLDER': { + NEW_FOLDER: { key: 'New Folder', value: gettext('New Folder') }, - 'NEW_FILE': { + NEW_FILE: { key: 'New File', value: gettext('New File') }, - 'NEW_MARKDOWN_FILE': { + NEW_MARKDOWN_FILE: { key: 'New Markdown File', value: gettext('New Markdown File') }, - 'NEW_EXCEL_FILE': { + NEW_EXCEL_FILE: { key: 'New Excel File', value: gettext('New Excel File') }, - 'NEW_POWERPOINT_FILE': { + NEW_POWERPOINT_FILE: { key: 'New PowerPoint File', value: gettext('New PowerPoint File') }, - 'NEW_WORD_FILE': { + NEW_WORD_FILE: { key: 'New Word File', value: gettext('New Word File') }, - 'NEW_SEADOC_FILE': { + NEW_SEADOC_FILE: { key: 'New SeaDoc File', value: gettext('New SeaDoc File') }, - 'SHARE': { + SHARE: { key: 'Share', value: gettext('Share') }, - 'DOWNLOAD': { + DOWNLOAD: { key: 'Download', value: gettext('Download') }, - 'DELETE': { + DELETE: { key: 'Delete', value: gettext('Delete') }, - 'RENAME': { + RENAME: { key: 'Rename', value: gettext('Rename') }, - 'MOVE': { + MOVE: { key: 'Move', value: gettext('Move') }, - 'COPY': { + COPY: { key: 'Copy', value: gettext('Copy') }, - 'PERMISSION': { + PERMISSION: { key: 'Permission', value: gettext('Permission') }, - 'DETAILS': { + DETAILS: { key: 'Details', value: gettext('Details') }, - 'OPEN_VIA_CLIENT': { + OPEN_VIA_CLIENT: { key: 'Open via Client', value: gettext('Open via Client') }, - 'LOCK': { + LOCK: { key: 'Lock', value: gettext('Lock') }, - 'UNLOCK': { + UNLOCK: { key: 'Unlock', value: gettext('Unlock') }, - 'FREEZE_DOCUMENT': { + FREEZE_DOCUMENT: { key: 'Freeze Document', value: gettext('Freeze Document') }, - 'UNFREEZE_DOCUMENT': { + UNFREEZE_DOCUMENT: { key: 'Unfreeze Document', value: gettext('Unfreeze Document') }, - 'CONVERT_AND_EXPORT': { + CONVERT_AND_EXPORT: { key: 'Convert & Export', value: gettext('Convert & Export') }, - 'CONVERT_TO_MARKDOWN': { + CONVERT_TO_MARKDOWN: { key: 'Convert to Markdown', value: gettext('Convert to Markdown') }, - 'CONVERT_TO_SDOC': { + CONVERT_TO_SDOC: { key: 'Convert to sdoc', value: gettext('Convert to sdoc') }, - 'CONVERT_TO_DOCX': { + CONVERT_TO_DOCX: { key: 'Convert to docx', value: gettext('Convert to docx') }, - 'EXPORT_DOCX': { + EXPORT_DOCX: { key: 'Export docx', value: gettext('Export as docx') }, - 'HISTORY': { + HISTORY: { key: 'History', value: gettext('History') }, - 'ACCESS_LOG': { + ACCESS_LOG: { key: 'Access Log', value: gettext('Access Log') }, - 'PROPERTIES': { + PROPERTIES: { key: 'Properties', value: gettext('Properties') }, - 'TAGS': { + TAGS: { key: 'Tags', value: gettext('Tags') }, - 'TRASH': { + TRASH: { key: 'Trash', value: gettext('Trash') }, - 'ONLYOFFICE_CONVERT': { + ONLYOFFICE_CONVERT: { key: 'Convert with ONLYOFFICE', value: gettext('Convert with ONLYOFFICE') }, - 'DISPLAY_FILES': { + DISPLAY_FILES: { key: 'Display files', value: gettext('Display files') }, - 'EXPORT_SDOC': { + EXPORT_SDOC: { key: 'Export sdoc', value: gettext('Export as zip') }, // repo operations - 'TRANSFER': { + TRANSFER: { key: 'Transfer', value: gettext('Transfer') }, - 'FOLDER_PERMISSION': { + FOLDER_PERMISSION: { key: 'Folder Permission', value: gettext('Folder Permission') }, - 'SHARE_ADMIN': { + SHARE_ADMIN: { key: 'Share Admin', value: gettext('Share Admin') }, - 'CHANGE_PASSWORD': { + CHANGE_PASSWORD: { key: 'Change Password', value: gettext('Change Password') }, - 'RESET_PASSWORD': { + RESET_PASSWORD: { key: 'Reset Password', value: gettext('Reset Password') }, - 'UNWATCH_FILE_CHANGES': { + UNWATCH_FILE_CHANGES: { key: 'Unwatch File Changes', value: gettext('Unwatch File Changes') }, - 'WATCH_FILE_CHANGES': { + WATCH_FILE_CHANGES: { key: 'Watch File Changes', value: gettext('Watch File Changes') }, - 'ADVANCED': { + ADVANCED: { key: 'advanced', value: gettext('Advanced') }, // advanced operations - 'API_TOKEN': { + API_TOKEN: { key: 'API Token', value: gettext('API Token') }, - 'LABEL_CURRENT_STATE': { + LABEL_CURRENT_STATE: { key: 'Label Current State', value: gettext('Label Current State') }, - 'UNSHARE': { + UNSHARE: { key: 'Unshare', value: gettext('Unshare') }, + + // metadata views + ADD_FOLDER: { + key: 'ADD_FOLDER', + value: gettext('Add Folder') + }, + ADD_VIEW: { + key: 'ADD_VIEW', + value: gettext('Add view') + } }; export default TextTranslation; diff --git a/seahub/repo_metadata/apis.py b/seahub/repo_metadata/apis.py index d8564d6835b..620224ad102 100644 --- a/seahub/repo_metadata/apis.py +++ b/seahub/repo_metadata/apis.py @@ -603,6 +603,142 @@ def delete(self, request, repo_id): return Response({'success': True}) +class MetadataFolders(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def post(self, request, repo_id): + # add metadata folder + folder_name = request.data.get('name') + + # check view name + if not folder_name: + return api_error(status.HTTP_400_BAD_REQUEST, 'folder_name is invalid') + + record = RepoMetadata.objects.filter(repo_id=repo_id).first() + if not record or not record.enabled: + error_msg = f'The metadata module is disabled for repo {repo_id}.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + repo = seafile_api.get_repo(repo_id) + if not repo: + error_msg = 'Library %s not found.' % repo_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + permission = check_folder_permission(request, repo_id, '/') + if permission != 'rw': + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + # check metadata_views + metadata_views = RepoMetadataViews.objects.filter(repo_id = repo_id).first() + if not metadata_views: + error_msg = 'The metadata views does not exists.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + try: + new_folder = RepoMetadataViews.objects.add_folder(repo_id, folder_name) + if not new_folder: + return api_error(status.HTTP_400_BAD_REQUEST, 'add folder failed') + except Exception as e: + logger.exception(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response({'folder': new_folder}) + + def put(self, request, repo_id): + # update metadata folder: name etc. + folder_id = request.data.get('folder_id', None) + folder_data = request.data.get('folder_data', None) + + # check folder_id + if not folder_id: + return api_error(status.HTTP_400_BAD_REQUEST, 'folder_id is invalid') + + # check folder_data + if not folder_data: + return api_error(status.HTTP_400_BAD_REQUEST, 'folder_data is invalid') + if folder_data.get('_id') or folder_data.get('type') or folder_data.get('children'): + return api_error(status.HTTP_400_BAD_REQUEST, 'folder_data is invalid') + + record = RepoMetadata.objects.filter(repo_id=repo_id).first() + if not record or not record.enabled: + error_msg = f'The metadata module is disabled for repo {repo_id}.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + repo = seafile_api.get_repo(repo_id) + if not repo: + error_msg = 'Library %s not found.' % repo_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + permission = check_folder_permission(request, repo_id, '/') + if permission != 'rw': + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + # check metadata_views + metadata_views = RepoMetadataViews.objects.filter(repo_id = repo_id).first() + if not metadata_views: + error_msg = 'The metadata views does not exists.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # check folder exist + if folder_id not in metadata_views.folders_ids: + return api_error(status.HTTP_400_BAD_REQUEST, 'folder %s does not exists.' % folder_id) + + try: + result = RepoMetadataViews.objects.update_folder(repo_id, folder_id, folder_data) + except Exception as e: + logger.exception(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response({'success': True}) + + def delete(self, request, repo_id): + # delete metadata folder by id + # check folder_id + folder_id = request.data.get('folder_id', None) + if not folder_id: + return api_error(status.HTTP_400_BAD_REQUEST, 'folder_id is invalid') + + record = RepoMetadata.objects.filter(repo_id=repo_id).first() + if not record or not record.enabled: + error_msg = f'The metadata module is disabled for repo {repo_id}.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + repo = seafile_api.get_repo(repo_id) + if not repo: + error_msg = 'Library %s not found.' % repo_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + permission = check_folder_permission(request, repo_id, '/') + if permission != 'rw': + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + # check metadata_views + metadata_views = RepoMetadataViews.objects.filter(repo_id = repo_id).first() + if not metadata_views: + error_msg = 'The metadata views does not exists.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # check folder exist + if folder_id not in metadata_views.folders_ids: + return api_error(status.HTTP_400_BAD_REQUEST, 'folder %s does not exists.' % folder_id) + + try: + result = RepoMetadataViews.objects.delete_folder(repo_id, folder_id) + except Exception as e: + logger.exception(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response({'success': True}) + + class MetadataViews(APIView): authentication_classes = (TokenAuthentication, SessionAuthentication) permission_classes = (IsAuthenticated,) @@ -628,15 +764,29 @@ def get(self, request, repo_id): return Response(metadata_views) + def post(self, request, repo_id): # Add a metadata view + metadata_views = RepoMetadataViews.objects.filter(repo_id=repo_id).first() view_name = request.data.get('name') + folder_id = request.data.get('folder_id', None) view_type = request.data.get('type', 'table') view_data = request.data.get('data', {}) + + # check view name if not view_name: error_msg = 'view name is invalid.' return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + # check folder exist + if folder_id: + if not metadata_views: + error_msg = 'The metadata views does not exists.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + if folder_id not in metadata_views.folders_ids: + return api_error(status.HTTP_400_BAD_REQUEST, 'folder %s does not exists' % folder_id) + record = RepoMetadata.objects.filter(repo_id=repo_id).first() if not record or not record.enabled: error_msg = f'The metadata module is disabled for repo {repo_id}.' @@ -653,7 +803,9 @@ def post(self, request, repo_id): return api_error(status.HTTP_403_FORBIDDEN, error_msg) try: - new_view = RepoMetadataViews.objects.add_view(repo_id, view_name, view_type, view_data) + new_view = RepoMetadataViews.objects.add_view(repo_id, view_name, view_type, view_data, folder_id) + if not new_view: + return api_error(status.HTTP_400_BAD_REQUEST, 'add view failed') except Exception as e: logger.exception(e) error_msg = 'Internal Server Error' @@ -661,7 +813,6 @@ def post(self, request, repo_id): return Response({'view': new_view}) - def put(self, request, repo_id): # Update a metadata view, including rename, change filters and so on # by a json data @@ -686,7 +837,7 @@ def put(self, request, repo_id): error_msg = 'The metadata views does not exists.' return api_error(status.HTTP_404_NOT_FOUND, error_msg) - if view_id not in views.view_ids: + if view_id not in views.views_ids: error_msg = 'view_id %s does not exists.' % view_id return api_error(status.HTTP_400_BAD_REQUEST, error_msg) @@ -713,6 +864,7 @@ def delete(self, request, repo_id): # Update a metadata view, including rename, change filters and so on # by a json data view_id = request.data.get('view_id', None) + folder_id = request.data.get('folder_id', None) if not view_id: error_msg = 'view_id is invalid.' return api_error(status.HTTP_400_BAD_REQUEST, error_msg) @@ -722,17 +874,22 @@ def delete(self, request, repo_id): error_msg = f'The metadata module is disabled for repo {repo_id}.' return api_error(status.HTTP_404_NOT_FOUND, error_msg) - views = RepoMetadataViews.objects.filter( + metadata_views = RepoMetadataViews.objects.filter( repo_id=repo_id ).first() - if not views: + if not metadata_views: error_msg = 'The metadata views does not exists.' return api_error(status.HTTP_404_NOT_FOUND, error_msg) - if view_id not in views.view_ids: + # check view exist + if view_id not in metadata_views.views_ids: error_msg = 'view_id %s does not exists.' % view_id return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + # check folder exist + if folder_id and folder_id not in metadata_views.folders_ids: + return api_error(status.HTTP_400_BAD_REQUEST, 'folder %s does not exists' % folder_id) + repo = seafile_api.get_repo(repo_id) if not repo: error_msg = 'Library %s not found.' % repo_id @@ -744,7 +901,7 @@ def delete(self, request, repo_id): return api_error(status.HTTP_403_FORBIDDEN, error_msg) try: - result = RepoMetadataViews.objects.delete_view(repo_id, view_id) + result = RepoMetadataViews.objects.delete_view(repo_id, view_id, folder_id) except Exception as e: logger.exception(e) error_msg = 'Internal Server Error' @@ -760,6 +917,7 @@ class MetadataViewsDuplicateView(APIView): def post(self, request, repo_id): view_id = request.data.get('view_id') + folder_id = request.data.get('folder_id', None) if not view_id: error_msg = 'view_id invalid' return api_error(status.HTTP_400_BAD_REQUEST, error_msg) @@ -769,17 +927,21 @@ def post(self, request, repo_id): error_msg = f'The metadata module is disabled for repo {repo_id}.' return api_error(status.HTTP_404_NOT_FOUND, error_msg) - views = RepoMetadataViews.objects.filter( + metadata_views = RepoMetadataViews.objects.filter( repo_id=repo_id ).first() - if not views: + if not metadata_views: error_msg = 'The metadata views does not exists.' return api_error(status.HTTP_404_NOT_FOUND, error_msg) - if view_id not in views.view_ids: + if view_id not in metadata_views.views_ids: error_msg = 'view_id %s does not exists.' % view_id return api_error(status.HTTP_404_NOT_FOUND, error_msg) + # check folder exist + if folder_id and folder_id not in metadata_views.folders_ids: + return api_error(status.HTTP_400_BAD_REQUEST, 'folder %s does not exists' % folder_id) + repo = seafile_api.get_repo(repo_id) if not repo: error_msg = 'Library %s not found.' % repo_id @@ -790,13 +952,15 @@ def post(self, request, repo_id): error_msg = 'Permission denied.' return api_error(status.HTTP_403_FORBIDDEN, error_msg) try: - result = RepoMetadataViews.objects.duplicate_view(repo_id, view_id) + new_view = RepoMetadataViews.objects.duplicate_view(repo_id, view_id, folder_id) + if not new_view: + return api_error(status.HTTP_400_BAD_REQUEST, 'duplicate view failed') except Exception as e: logger.exception(e) error_msg = 'Internal Server Error' return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) - return Response({'view': result}) + return Response({'view': new_view}) class MetadataViewsDetailView(APIView): @@ -835,37 +999,29 @@ class MetadataViewsMoveView(APIView): throttle_classes = (UserRateThrottle,) def post(self, request, repo_id): - # put view_id in front of target_view_id - view_id = request.data.get('view_id') - if not view_id: - error_msg = 'view_id is invalid.' - return api_error(status.HTTP_400_BAD_REQUEST, error_msg) - + # move view or folder to another position + source_view_id = request.data.get('source_view_id') + source_folder_id = request.data.get('source_folder_id') target_view_id = request.data.get('target_view_id') - if not target_view_id: - error_msg = 'target_view_id is invalid.' - return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + target_folder_id = request.data.get('target_folder_id') + + # must drag view or folder + if not source_view_id and not source_folder_id: + return api_error(status.HTTP_400_BAD_REQUEST, 'source_view_id and source_folder_id is invalid') + + # must move above to view/folder or move view into folder + if not target_view_id and not target_folder_id: + return api_error(status.HTTP_400_BAD_REQUEST, 'target_view_id and target_folder_id is invalid') + + # not allowed to drag folder into folder + if not source_view_id and source_folder_id and target_view_id and target_folder_id: + return api_error(status.HTTP_400_BAD_REQUEST, 'not allowed to drag folder into folder') record = RepoMetadata.objects.filter(repo_id=repo_id).first() if not record or not record.enabled: error_msg = f'The metadata module is disabled for repo {repo_id}.' return api_error(status.HTTP_404_NOT_FOUND, error_msg) - views = RepoMetadataViews.objects.filter( - repo_id=repo_id, - ).first() - if not views: - error_msg = 'The metadata views does not exists.' - return api_error(status.HTTP_404_NOT_FOUND, error_msg) - - if view_id not in views.view_ids: - error_msg = 'view_id %s does not exists.' % view_id - return api_error(status.HTTP_400_BAD_REQUEST, error_msg) - - if target_view_id not in views.view_ids: - error_msg = 'target_view_id %s does not exists.' % target_view_id - return api_error(status.HTTP_400_BAD_REQUEST, error_msg) - repo = seafile_api.get_repo(repo_id) if not repo: error_msg = 'Library %s not found.' % repo_id @@ -876,8 +1032,33 @@ def post(self, request, repo_id): error_msg = 'Permission denied.' return api_error(status.HTTP_403_FORBIDDEN, error_msg) + metadata_views = RepoMetadataViews.objects.filter( + repo_id=repo_id, + ).first() + if not metadata_views: + error_msg = 'The metadata views does not exists.' + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # check dragged view exist + if source_view_id and source_view_id not in metadata_views.views_ids: + return api_error(status.HTTP_400_BAD_REQUEST, 'source_view_id %s does not exists.' % source_view_id) + + # check dragged view exist + if source_folder_id and source_folder_id not in metadata_views.folders_ids: + return api_error(status.HTTP_400_BAD_REQUEST, 'source_view_id %s does not exists.' % source_view_id) + + # check target view exist + if target_view_id and target_view_id not in metadata_views.views_ids: + return api_error(status.HTTP_400_BAD_REQUEST, 'target_view_id %s does not exists.' % target_view_id) + + # check target view exist + if target_folder_id and target_folder_id not in metadata_views.folders_ids: + return api_error(status.HTTP_400_BAD_REQUEST, 'target_folder_id %s does not exists.' % target_folder_id) + try: - results = RepoMetadataViews.objects.move_view(repo_id, view_id, target_view_id) + results = RepoMetadataViews.objects.move_view(repo_id, source_view_id, source_folder_id, target_view_id, target_folder_id) + if not results: + return api_error(status.HTTP_400_BAD_REQUEST, 'move view or folder failed') except Exception as e: logger.error(e) error_msg = 'Internal Server Error' diff --git a/seahub/repo_metadata/models.py b/seahub/repo_metadata/models.py index 5f0dbf9f930..4215c956f09 100644 --- a/seahub/repo_metadata/models.py +++ b/seahub/repo_metadata/models.py @@ -18,16 +18,16 @@ def generate_random_string_lower_digits(length): return random_string -def generate_view_id(length, type, view_ids=None): +def generate_views_unique_id(length, type, folders_views_ids=None): if type == 'face_recognition': return FACE_RECOGNITION_VIEW_ID - if not view_ids: + if not folders_views_ids: return generate_random_string_lower_digits(length) while True: new_id = generate_random_string_lower_digits(length) - if new_id not in view_ids: + if new_id not in folders_views_ids: break return new_id @@ -76,19 +76,37 @@ class Meta: db_table = 'repo_metadata' +class RepoFolder(object): + + def __init__(self, name, children=[], folders_views_ids=None): + self.name = name + self.type = 'folder' + self.children = children + + self.init_folder(folders_views_ids) + + def init_folder(self, folders_views_ids=None): + self.folder_json = { + "_id": generate_views_unique_id(4, self.type, folders_views_ids), + "name": self.name, + "type": self.type, + "children": self.children + } + + class RepoView(object): - def __init__(self, name, type='table', view_data={}, view_ids=None): + def __init__(self, name, type='table', view_data={}, folders_views_ids=None): self.name = name self.type = type self.view_data = view_data self.view_json = {} - self.init_view(view_ids) + self.init_view(folders_views_ids) - def init_view(self, view_ids=None): + def init_view(self, folders_views_ids=None): self.view_json = { - "_id": generate_view_id(4, self.type, view_ids), + "_id": generate_views_unique_id(4, self.type, folders_views_ids), "table_id": '0001', # by default "name": self.name, "filters": [], @@ -103,7 +121,57 @@ def init_view(self, view_ids=None): class RepoMetadataViewsManager(models.Manager): - def add_view(self, repo_id, view_name, view_type='table', view_data={}): + def add_folder(self, repo_id, folder_name): + metadata_views = self.filter(repo_id=repo_id).first() + if not metadata_views: + return None + + view_details = json.loads(metadata_views.details) + navigation = view_details.get('navigation', []) + exist_folders_views_ids = metadata_views.folders_views_ids + new_folder = RepoFolder(folder_name, [], exist_folders_views_ids) + folder_json = new_folder.folder_json + navigation.append(folder_json) + metadata_views.details = json.dumps(view_details) + metadata_views.save() + return folder_json + + def update_folder(self, repo_id, folder_id, folder_dict): + metadata_views = self.filter(repo_id=repo_id).first() + folder_dict.pop('_id', '') + folder_dict.pop('type', '') + folder_dict.pop('children', '') + if 'name' in folder_dict: + exist_obj_names = metadata_views.folders_names + folder_dict['name'] = get_no_duplicate_obj_name(folder_dict['name'], exist_obj_names) + view_details = json.loads(metadata_views.details) + for folder in view_details['navigation']: + if folder.get('type', None) == 'folder' and folder.get('_id') == folder_id: + folder.update(folder_dict) + break + metadata_views.details = json.dumps(view_details) + metadata_views.save() + return json.loads(metadata_views.details) + + def delete_folder(self, repo_id, folder_id): + metadata_views = self.filter(repo_id=repo_id).first() + view_details = json.loads(metadata_views.details) + navigation = view_details.get('navigation', []) + views = view_details.get('views', []) + for folder in navigation: + if folder.get('_id') == folder_id: + # add views which in the folder into navigation + if folder.get('children'): + navigation.extend(folder.get('children')) + + # remove folder + navigation.remove(folder) + break + metadata_views.details = json.dumps(view_details) + metadata_views.save() + return json.loads(metadata_views.details) + + def add_view(self, repo_id, view_name, view_type='table', view_data={}, folder_id=None): metadata_views = self.filter(repo_id=repo_id).first() if not metadata_views: from seafevents.repo_metadata.constants import METADATA_TABLE @@ -126,13 +194,22 @@ def add_view(self, repo_id, view_name, view_type='table', view_data={}): ) else: view_details = json.loads(metadata_views.details) - view_name = get_no_duplicate_obj_name(view_name, metadata_views.view_names) - exist_view_ids = metadata_views.view_ids - new_view = RepoView(view_name, view_type, view_data, exist_view_ids) + navigation = view_details.get('navigation', []) + view_name = get_no_duplicate_obj_name(view_name, metadata_views.views_names) + exist_folders_views_ids = metadata_views.folders_views_ids + new_view = RepoView(view_name, view_type, view_data, exist_folders_views_ids) view_json = new_view.view_json view_id = view_json.get('_id') view_details['views'].append(view_json) - view_details['navigation'].append({'_id': view_id, 'type': 'view'}) + new_view_nav = { '_id': view_id, 'type': 'view' } + if folder_id: + folder = next((folder for folder in navigation if folder.get('_id') == folder_id), None) + if not folder: + return None + folderChildren = folder.get('children', []) + folderChildren.append(new_view_nav) + else: + navigation.append(new_view_nav) metadata_views.details = json.dumps(view_details) metadata_views.save() return new_view.view_json @@ -156,7 +233,7 @@ def update_view(self, repo_id, view_id, view_dict): metadata_views = self.filter(repo_id=repo_id).first() view_dict.pop('_id', '') if 'name' in view_dict: - exist_obj_names = metadata_views.view_names + exist_obj_names = metadata_views.views_names view_dict['name'] = get_no_duplicate_obj_name(view_dict['name'], exist_obj_names) view_details = json.loads(metadata_views.details) for v in view_details['views']: @@ -167,56 +244,125 @@ def update_view(self, repo_id, view_id, view_dict): metadata_views.save() return json.loads(metadata_views.details) - def duplicate_view(self, repo_id, view_id): + def duplicate_view(self, repo_id, view_id, folder_id=None): metadata_views = self.filter(repo_id=repo_id).first() view_details = json.loads(metadata_views.details) - exist_view_ids = metadata_views.view_ids - new_view_id = generate_view_id(4, exist_view_ids) + exist_folders_views_ids = metadata_views.folders_views_ids + new_view_id = generate_views_unique_id(4, exist_folders_views_ids) duplicate_view = next((copy.deepcopy(view) for view in view_details['views'] if view.get('_id') == view_id), None) + if not duplicate_view: + return None + duplicate_view['_id'] = new_view_id - view_name = get_no_duplicate_obj_name(duplicate_view['name'], metadata_views.view_names) + view_name = get_no_duplicate_obj_name(duplicate_view['name'], metadata_views.views_names) duplicate_view['name'] = view_name view_details['views'].append(duplicate_view) - view_details['navigation'].append({'_id': new_view_id, 'type': 'view'}) + navigation = view_details.get('navigation', []) + new_view_nav = {'_id': new_view_id, 'type': 'view'} + if folder_id: + # add duplicate_view into folder + folder = next((folder for folder in navigation if folder.get('_id') == folder_id), None) + if not folder: + return None + folderChildren = folder.get('children', []) + folderChildren.append(new_view_nav) + else: + navigation.append(new_view_nav) + metadata_views.details = json.dumps(view_details) metadata_views.save() return duplicate_view - def delete_view(self, repo_id, view_id): + def delete_view(self, repo_id, view_id, folder_id=None): metadata_views = self.filter(repo_id=repo_id).first() view_details = json.loads(metadata_views.details) - for v in view_details['views']: - if v.get('_id') == view_id: - view_details['views'].remove(v) + navigation = view_details.get('navigation', []) + views = view_details.get('views', []) + + for view in views: + if view.get('_id') == view_id: + views.remove(view) break - for v in view_details['navigation']: - if v.get('_id') == view_id: - view_details['navigation'].remove(v) + for nav_item in navigation: + # delete view from folder + if folder_id and nav_item.get('_id') == folder_id and nav_item.get('type') == 'folder' and nav_item.get('children'): + for child in nav_item.get('children'): + if child.get('_id') == view_id: + nav_item.get('children').remove(child) + break break + + # delete view not in folders + if nav_item.get('_id') == view_id: + navigation.remove(nav_item) + break + metadata_views.details = json.dumps(view_details) metadata_views.save() return json.loads(metadata_views.details) - def move_view(self, repo_id, view_id, target_view_id): + def move_view(self, repo_id, source_view_id, source_folder_id, target_view_id, target_folder_id): metadata_views = self.filter(repo_id=repo_id).first() view_details = json.loads(metadata_views.details) - view_index = None - target_index = None - for i, view in enumerate(view_details['navigation']): - if view['_id'] == view_id: - view_index = i - if view['_id'] == target_view_id: - target_index = i - - if view_index is not None and target_index is not None: - if view_index < target_index: - view_to_move = view_details['navigation'][view_index] - view_details['navigation'].insert(target_index, view_to_move) - view_details['navigation'].pop(view_index) + navigation = view_details.get('navigation', []) + + # find drag source + if source_folder_id: + if source_view_id: + # drag view from folder + dragged_id = source_view_id + source_folder = next((folder for folder in navigation if folder.get('_id') == source_folder_id), None) + if source_folder: + updated_source_nav_list = source_folder.get('children', []) else: - view_to_move = view_details['navigation'].pop(view_index) - view_details['navigation'].insert(target_index, view_to_move) + # drag folder + dragged_id = source_folder_id + updated_source_nav_list = navigation + elif source_view_id: + # drag view not in folders + dragged_id = source_view_id + updated_source_nav_list = navigation + + # invalid drag source + if not dragged_id or not updated_source_nav_list: + return None + drag_source = next((nav for nav in updated_source_nav_list if nav.get('_id') == dragged_id), None) + if not drag_source: + return None + + # remove drag source from navigation + updated_source_nav_list.remove(drag_source) + + # find drop target + updated_target_nav_list = navigation + if target_folder_id and source_view_id: + target_folder = next((folder for folder in navigation if folder.get('_id') == target_folder_id), None) + if target_folder: + updated_target_nav_list = target_folder.get('children', []) + + # drag source already exist + exist_drag_source = next((nav for nav in updated_target_nav_list if nav.get('_id') == drag_source.get('_id')), None) + if exist_drag_source: + return None + + # drop drag source to the target position + target_nav = None + if target_view_id: + # move folder/view above to view + target_nav = next((nav for nav in updated_target_nav_list if nav.get('_id') == target_view_id), None) + elif not source_view_id and target_folder_id: + # move folder above to folder + target_nav = next((nav for nav in updated_target_nav_list if nav.get('_id') == target_folder_id), None) + + insert_index = -1 + if target_nav: + insert_index = updated_target_nav_list.index(target_nav) + + if insert_index > -1: + updated_target_nav_list.insert(insert_index, drag_source) + else: + updated_target_nav_list.append(drag_source) metadata_views.details = json.dumps(view_details) metadata_views.save() @@ -233,11 +379,27 @@ class Meta: db_table = 'repo_metadata_view' @property - def view_ids(self): + def folders_ids(self): + metadata_views = json.loads(self.details) + navigation = metadata_views.get('navigation', []) + return [folder.get('_id') for folder in navigation if folder.get('type', None) == 'folder'] + + @property + def folders_names(self): + metadata_views = json.loads(self.details) + navigation = metadata_views.get('navigation', []) + return [folder.get('name') for folder in navigation if folder.get('type', None) == 'folder'] + + @property + def views_ids(self): views = json.loads(self.details)['views'] return [v.get('_id') for v in views] @property - def view_names(self): + def views_names(self): views = json.loads(self.details)['views'] return [v.get('name') for v in views] + + @property + def folders_views_ids(self): + return self.folders_ids + self.views_ids diff --git a/seahub/repo_metadata/urls.py b/seahub/repo_metadata/urls.py index 62d8f88e2ed..8c607a3f04f 100644 --- a/seahub/repo_metadata/urls.py +++ b/seahub/repo_metadata/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path from .apis import MetadataRecords, MetadataManage, MetadataColumns, MetadataRecordInfo, \ - MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView, FacesRecords, \ + MetadataFolders, MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView, MetadataViewsDuplicateView, FacesRecords, \ FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails, PeoplePhotos, MetadataTagsStatusManage, MetadataTags, \ MetadataFileTags, MetadataTagFiles, MetadataDetailsSettingsView @@ -11,6 +11,7 @@ re_path(r'^columns/$', MetadataColumns.as_view(), name='api-v2.1-metadata-columns'), # view + re_path(r'^folders/$', MetadataFolders.as_view(), name='api-v2.1-metadata-folders'), re_path(r'^views/$', MetadataViews.as_view(), name='api-v2.1-metadata-views'), re_path(r'^views/(?P.+)/$', MetadataViewsDetailView.as_view(), name='api-v2.1-metadata-views-detail'), re_path(r'^move-views/$', MetadataViewsMoveView.as_view(), name='api-v2.1-metadata-views-move'),