1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-05 17:02:47 +00:00

feat(metadata-views): support add folder (#7175)

This commit is contained in:
Jerry Ren
2024-12-16 22:42:39 +08:00
committed by GitHub
parent c9c60b7a62
commit ae0d94e618
37 changed files with 2282 additions and 1213 deletions

View File

@@ -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 ? (
<Loading />
) : (
<div className="dir-content-nav" role="navigation" style={{ flex: (flex), userSelect: select }} onScroll={this.stopTreeScrollPropagation}>
{isTreeDataLoading ? <Loading /> : (
<>
<TreeSection
title={gettext('Files')}
moreKey={{ name: 'files' }}
moreOperations={this.state.operationList}
moreOperationClick={this.onMoreOperationClick}
isDisplayFiles={this.state.isDisplayFiles}
>
<TreeView
userPerm={userPerm}
isNodeMenuShow={this.isNodeMenuShow}
treeData={treeData}
currentPath={currentPath}
onNodeClick={this.onNodeClick}
onNodeExpanded={onNodeExpanded}
onNodeCollapse={onNodeCollapse}
onMenuItemClick={this.onMenuItemClick}
onFreezedItem={this.onFreezedItem}
onUnFreezedItem={this.onUnFreezedItem}
onItemMove={onItemMove}
currentRepoInfo={currentRepoInfo}
selectedDirentList={selectedDirentList}
onItemsMove={onItemsMove}
repoID={repoID}
getMenuContainerSize={getMenuContainerSize}
isDisplayFiles={this.state.isDisplayFiles}
/>
</TreeSection>
<DirViews repoID={repoID} currentPath={currentPath} userPerm={userPerm} currentRepoInfo={currentRepoInfo} />
<DirTags repoID={repoID} currentPath={currentPath} userPerm={userPerm} currentRepoInfo={currentRepoInfo} />
<DirOthers
<DirFiles
repoID={repoID}
currentPath={currentPath}
treeData={treeData}
userPerm={userPerm}
currentRepoInfo={currentRepoInfo}
direntList={this.props.direntList}
selectedDirentList={this.props.selectedDirentList}
currentNode={this.props.currentNode}
getMenuContainerSize={this.props.getMenuContainerSize}
onNodeClick={this.props.onNodeClick}
onNodeCollapse={this.props.onNodeCollapse}
onNodeExpanded={this.props.onNodeExpanded}
onRenameNode={this.props.onRenameNode}
onDeleteNode={this.props.onDeleteNode}
onAddFileNode={this.props.onAddFileNode}
onAddFolderNode={this.props.onAddFolderNode}
onItemCopy={this.props.onItemCopy}
onItemMove={this.props.onItemMove}
onItemsMove={this.props.onItemsMove}
updateDirent={this.props.updateDirent}
/>
<DirViews repoID={repoID} currentPath={currentPath} userPerm={userPerm} currentRepoInfo={currentRepoInfo} />
<DirTags repoID={repoID} currentPath={currentPath} userPerm={userPerm} currentRepoInfo={currentRepoInfo} />
<DirOthers repoID={repoID} userPerm={userPerm} currentRepoInfo={currentRepoInfo} />
</>
)}
</>
);
};
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 (
<>
<div className="dir-content-nav" role="navigation" style={{ flex: (flex), userSelect: select }} onScroll={this.stopTreeScrollPropagation}>
{this.renderContent()}
</div>
{this.state.isAddFolderDialogShow && (
<ModalPortal>
<CreateFolder
parentPath={this.state.opNode.path}
onAddFolder={this.onAddFolderNode}
checkDuplicatedName={this.checkDuplicatedName}
addFolderCancel={this.onAddFolderToggle}
/>
</ModalPortal>
)}
{this.state.isAddFileDialogShow && (
<ModalPortal>
<CreateFile
parentPath={this.state.opNode.path}
onAddFile={this.props.onAddFileNode}
checkDuplicatedName={this.checkDuplicatedName}
toggleDialog={this.onAddFileToggle}
/>
</ModalPortal>
)}
{this.state.isRenameDialogShow && (
<ModalPortal>
<Rename
currentNode={this.state.opNode}
onRename={this.onRenameNode}
checkDuplicatedName={this.checkDuplicatedName}
toggleCancel={this.onRenameToggle}
/>
</ModalPortal>
)}
{this.state.isCopyDialogShow && (
<ModalPortal>
<Copy
path={this.state.opNode.parentNode.path}
repoID={this.props.repoID}
dirent={this.state.opNode.object}
onItemCopy={this.props.onItemCopy}
repoEncrypted={repoEncrypted}
onCancelCopy={this.onCopyToggle}
isMultipleOperation={this.state.isMultipleOperation}
/>
</ModalPortal>
)}
{this.state.isMoveDialogShow && (
<ModalPortal>
<Move
path={this.state.opNode.parentNode.path}
repoID={this.props.repoID}
dirent={this.state.opNode.object}
onItemMove={this.props.onItemMove}
repoEncrypted={repoEncrypted}
onCancelMove={this.onMoveToggle}
isMultipleOperation={this.state.isMultipleOperation}
/>
</ModalPortal>
)}
{this.state.isNodeImagePopupOpen && (
<ModalPortal>
<ImageDialog
imageItems={this.state.imageNodeItems}
imageIndex={this.state.imageIndex}
closeImagePopup={this.closeNodeImagePopup}
moveToPrevImage={this.moveToPrevImage}
moveToNextImage={this.moveToNextImage}
onDeleteImage={this.deleteImage}
onRotateImage={this.rotateImage}
enableRotate={!repoEncrypted}
/>
</ModalPortal>
)}
</>
</div>
);
}
}

View File

@@ -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 = (
<div className="tree-section-header-operation tree-section-more-operation" key='tree-section-more-operation'>
<ItemDropdownMenu
{...props}
item={{ name: 'files' }}
toggleClass="sf3-font sf3-font-more"
menuStyle={isMobile ? { zIndex: 1050 } : {}}
isDisplayFiles={this.state.isDisplayFiles}
getMenuList={this.getMenuList}
onMenuItemClick={this.onMoreOperationClick}
/>
</div>
);
return [moreOperation];
};
render() {
const { repoID, currentRepoInfo } = this.props;
const repoEncrypted = currentRepoInfo.encrypted;
return (
<>
<TreeSection
title={gettext('Files')}
renderHeaderOperations={this.renderTreeSectionHeaderOperations}
>
<TreeView
repoID={repoID}
userPerm={this.props.userPerm}
treeData={this.props.treeData}
currentPath={this.props.currentPath}
currentRepoInfo={currentRepoInfo}
selectedDirentList={this.props.selectedDirentList}
isDisplayFiles={this.state.isDisplayFiles}
isNodeMenuShow={this.isNodeMenuShow}
onNodeClick={this.onNodeClick}
onMenuItemClick={this.onMenuItemClick}
getMenuContainerSize={this.props.getMenuContainerSize}
onNodeExpanded={this.props.onNodeExpanded}
onNodeCollapse={this.props.onNodeCollapse}
onItemMove={this.props.onItemMove}
onItemsMove={this.props.onItemsMove}
/>
</TreeSection>
{this.state.isAddFolderDialogShow && (
<ModalPortal>
<CreateFolder
parentPath={this.state.opNode.path}
onAddFolder={this.onAddFolderNode}
checkDuplicatedName={this.checkDuplicatedName}
addFolderCancel={this.onAddFolderToggle}
/>
</ModalPortal>
)}
{this.state.isAddFileDialogShow && (
<ModalPortal>
<CreateFile
parentPath={this.state.opNode.path}
onAddFile={this.props.onAddFileNode}
checkDuplicatedName={this.checkDuplicatedName}
toggleDialog={this.onAddFileToggle}
/>
</ModalPortal>
)}
{this.state.isRenameDialogShow && (
<ModalPortal>
<Rename
currentNode={this.state.opNode}
onRename={this.onRenameNode}
checkDuplicatedName={this.checkDuplicatedName}
toggleCancel={this.onRenameToggle}
/>
</ModalPortal>
)}
{this.state.isCopyDialogShow && (
<ModalPortal>
<Copy
repoID={repoID}
path={this.state.opNode.parentNode.path}
dirent={this.state.opNode.object}
onItemCopy={this.props.onItemCopy}
repoEncrypted={repoEncrypted}
onCancelCopy={this.onCopyToggle}
isMultipleOperation={this.state.isMultipleOperation}
/>
</ModalPortal>
)}
{this.state.isMoveDialogShow && (
<ModalPortal>
<Move
repoID={repoID}
path={this.state.opNode.parentNode.path}
dirent={this.state.opNode.object}
onItemMove={this.props.onItemMove}
repoEncrypted={repoEncrypted}
onCancelMove={this.onMoveToggle}
isMultipleOperation={this.state.isMultipleOperation}
/>
</ModalPortal>
)}
{this.state.isNodeImagePopupOpen && (
<ModalPortal>
<ImageDialog
imageItems={this.state.imageNodeItems}
imageIndex={this.state.imageIndex}
closeImagePopup={this.closeNodeImagePopup}
moveToPrevImage={this.moveToPrevImage}
moveToNextImage={this.moveToNextImage}
onDeleteImage={this.deleteImage}
onRotateImage={this.rotateImage}
enableRotate={!repoEncrypted}
/>
</ModalPortal>
)}
</>
);
}
}
DirFiles.propTypes = propTypes;
export default DirFiles;

View File

@@ -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;
}

View File

@@ -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(
<ViewsMoreOperations
key={'tree-section-more-operation'}
menuProps={menuProps}
/>
);
}
return operations;
};
return (
<>
<TreeSection title={gettext('Views')}>
<TreeSection
title={gettext('Views')}
renderHeaderOperations={renderTreeSectionHeaderOperations}
>
{!enableMetadata ? (
<ExtensionPrompts onExtendedProperties={onExtendedProperties} />
) : Array.isArray(navigation) && navigation.length > 0 ? (

View File

@@ -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: <Icon symbol={VIEW_TYPE_ICON[type] || VIEW_TYPE.TABLE} className="metadata-view-icon" />
};
});
};
export const getNewViewMenuItem = () => {
return {
...TextTranslation.ADD_VIEW,
subOpListHeader: gettext('New view'),
subOpList: getNewViewSubMenus(),
};
};

View File

@@ -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 (
<div className="tree-section-header-operation tree-section-more-operation">
<ItemDropdownMenu
{...menuProps}
item={{ name: 'views' }}
menuClassname="metadata-views-dropdown-menu"
toggleClass="sf3-font sf3-font-more"
menuStyle={isMobile ? { zIndex: 1050 } : {}}
getMenuList={getMoreOperationsMenus}
onMenuItemClick={clickMenu}
/>
</div>
);
};
export default ViewsMoreOperations;

View File

@@ -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 (
<Dropdown isOpen={this.state.isItemMenuShow} toggle={this.onDropdownToggleClick} className="vam">
<Dropdown direction='down' isOpen={this.state.isItemMenuShow} toggle={this.onDropdownToggleClick} className="vam">
<DropdownToggle
tag={tagName || 'i'}
role="button"
@@ -192,6 +194,10 @@ class ItemDropdownMenu extends React.Component {
<ModalPortal>
<DropdownMenu
style={menuStyle}
className={this.props.menuClassname}
positionFixed
flip={false}
modifiers={{ preventOverflow: { boundariesElement: document.body } }}
>
{menuList.map((menuItem, index) => {
if (menuItem === 'Divider') {
@@ -214,13 +220,21 @@ class ItemDropdownMenu extends React.Component {
<span className="mr-auto">{menuItem.value}</span>
<i className="sf3-font-down sf3-font rotate-270"></i>
</DropdownToggle>
<DropdownMenu>
<DropdownMenu
positionFixed
flip={false}
modifiers={{ preventOverflow: { boundariesElement: document.body } }}
>
{menuItem.subOpListHeader && <DropdownItem header>{menuItem.subOpListHeader}</DropdownItem>}
{menuItem.subOpList.map((item, index) => {
if (item == 'Divider') {
return <DropdownItem key={index} divider />;
} else {
return (
<DropdownItem key={index} data-toggle={item.key} onClick={this.onMenuItemClick} onKeyDown={this.onMenuItemKeyDown}>{item.value}</DropdownItem>
<DropdownItem key={index} data-toggle={item.key} onClick={this.onMenuItemClick} onKeyDown={this.onMenuItemKeyDown}>
{item.icon_dom || null}
<span>{item.value}</span>
</DropdownItem>
);
}
})}

View File

@@ -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 {

View File

@@ -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 (
<div className={classnames('tree-section', className)}>
<div
@@ -56,22 +60,7 @@ const TreeSection = ({ title, children, moreKey, moreOperations, moreOperationCl
>
<div className="tree-section-header-title">{title}</div>
<div className="tree-section-header-operations">
{validMoreOperations.length > 0 && (
<>
<div className="tree-section-header-operation tree-section-more-operation">
<ItemDropdownMenu
item={moreKey}
toggleClass="sf3-font sf3-font-more"
freezeItem={freezeItem}
unfreezeItem={unfreezeItem}
getMenuList={() => validMoreOperations}
onMenuItemClick={moreOperationClick}
menuStyle={isMobile ? { zIndex: 1050 } : {}}
isDisplayFiles={isDisplayFiles}
/>
</div>
</>
)}
{renderOperations()}
<div className="tree-section-header-operation" onClick={toggleShowChildren}>
<i className={`sf3-font sf3-font-down ${showChildren ? '' : 'rotate-90'}`}></i>
</div>
@@ -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;

View File

@@ -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' } });
};

View File

@@ -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}</>);
};

View File

@@ -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 (
<UncontrolledPopover
className='sf-metadata-addview-popover'
isOpen={true}
toggle={toggle}
target={target}
placement='right-start'
hideArrow={true}
fade={false}
boundariesElement={document.body}
>
<div ref={popoverRef}>
<div className='sf-metadata-addview-popover-header'>{gettext('New view')}</div>
<div className='sf-metadata-addview-popover-body'>
{VIEW_OPTIONS.map((item, index) => {
return (
<button key={index} className='dropdown-item sf-metadata-addview-popover-item' onClick={() => onOptionClick(item)}>
<div className="left-icon">
<Icon symbol={VIEW_TYPE_ICON[item.type] || 'table'} className='metadata-view-icon' />
</div>
<div>{translateLabel(item.type)}</div>
</button>
);
})}
</div>
</div>
</UncontrolledPopover>
);
};
AddView.propTypes = {
target: PropTypes.string.isRequired,
toggle: PropTypes.func.isRequired,
onOptionClick: PropTypes.func.isRequired,
};
export default AddView;

View File

@@ -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;
}

View File

@@ -1 +0,0 @@
export { default as AddView } from './add-view';

View File

@@ -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`;

View File

@@ -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',

View File

@@ -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',

View File

@@ -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, targetViewId) => {
metadataAPI.moveView(repoID, sourceViewId, targetViewId).then(res => {
const { navigation } = res.data;
setNavigation(navigation);
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];
// 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 (
<MetadataContext.Provider value={{
@@ -223,7 +447,13 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi
isBeingBuilt,
setIsBeingBuilt,
navigation,
viewsMap: viewsMap.current,
collapsedFoldersIds: collapsedFoldersIds.current,
idViewMap,
collapseFolder,
expandFolder,
addFolder,
modifyFolder,
deleteFolder,
selectView,
addView,
duplicateView,

View File

@@ -0,0 +1,313 @@
import React, { useCallback, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import ViewItem from './view';
import ItemDropdownMenu from '../../components/dropdown-menu/item-dropdown-menu';
import toaster from '../../components/toast';
import NewView from './new-view';
import InlineNameEditor from './inline-name-editor';
import { useMetadata } from '../hooks';
import { PRIVATE_FILE_TYPE } from '../../constants';
import { Utils, isMobile } from '../../utils/utils';
import TextTranslation from '../../utils/text-translation';
import { validateName } from '../utils/validate';
import { METADATA_VIEWS_DRAG_DATA_KEY, METADATA_VIEWS_KEY, TREE_NODE_LEFT_INDENT, VIEW_TYPE, VIEWS_TYPE_FOLDER, VIEWS_TYPE_VIEW } from '../constants';
import { getNewViewMenuItem, KEY_ADD_VIEW_MAP } from '../../components/dir-view-mode/dir-views/new-view-menu';
const ViewsFolder = ({
leftIndent, folder, currentPath, userPerm, canDeleteView, getFoldersNames, getMoveableFolders, generateNewViewDefaultName,
setDragMode, getDragMode, selectView, modifyView,
}) => {
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 (
<ViewItem
key={viewId}
folderId={folderId}
leftIndent={leftIndent + TREE_NODE_LEFT_INDENT}
canDelete={canDeleteView}
isSelected={isSelected}
userPerm={userPerm}
view={view}
getMoveableFolders={getMoveableFolders}
setDragMode={setDragMode}
getDragMode={getDragMode}
onClick={selectView}
onDelete={deleteViewFromFolder}
onCopy={duplicateViewFromFolder}
onUpdate={modifyView}
/>
);
});
};
return (
<div className="tree-node views-folder-wrapper">
<div
className={classnames('tree-node-inner views-folder-main text-nowrap', { 'tree-node-inner-hover': highlight, 'tree-node-drop': isDropShow })}
type="dir"
title={folderName}
onMouseEnter={onMouseEnter}
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
onClick={clickFolder}
>
<div
className="tree-node-text views-folder-name"
style={{ paddingLeft: leftIndent + 5 }}
draggable={canUpdate}
onDragStart={onDragStart}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragMove}
onDrop={onDrop}
>
{isRenaming ? (
<InlineNameEditor
name={folderName}
className="rename mt-0"
onSubmit={onConfirmRename}
/>
) : folderName}
</div>
<div className="left-icon" style={{ left: leftIndent - 40 }}>
<i className={classnames('folder-toggle-icon sf3-font sf3-font-down', { 'rotate-270': !expanded })}></i>
<span className="tree-node-icon">
<i className="sf3-font sf3-font-folder"></i>
</span>
</div>
<div className="right-icon">
{(highlight && folderMoreOperationMenus.length > 0) && (
<ItemDropdownMenu
item={{ name: 'metadata-folder' }}
menuClassname="metadata-views-dropdown-menu"
toggleClass="sf3-font sf3-font-more"
freezeItem={freezeItem}
unfreezeItem={unfreezeItem}
getMenuList={() => folderMoreOperationMenus}
onMenuItemClick={clickMenu}
menuStyle={isMobile ? { zIndex: 1050 } : {}}
/>
)}
</div>
</div>
<div className="children views-folder-children">
{expanded && renderViews()}
{newView && <NewView newView={newView} leftIndent={TREE_NODE_LEFT_INDENT * 3} closeNewView={closeNewView} addView={addViewIntoFolder} />}
</div>
</div>
);
};
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;

View File

@@ -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);
}

View File

@@ -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 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 handleInputChange = (event) => {
setInputValue(event.target.value);
};
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]);
}, [generateNewViewDefaultName]);
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);
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 (
<ViewsFolder
key={`metadata-views-folder-${folder._id}`}
leftIndent={TREE_NODE_LEFT_INDENT * 2}
folder={folder}
currentPath={currentPath}
userPerm={userPerm}
canDeleteView={canDeleteView}
getFoldersNames={getFoldersNames}
getMoveableFolders={getMoveableFolders}
generateNewViewDefaultName={generateNewViewDefaultName}
setDragMode={setDragMode}
getDragMode={getDragMode}
selectView={selectView}
modifyView={modifyView}
/>
);
};
const renderView = (view) => {
const viewPath = '/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/' + view._id;
const isSelected = currentPath === viewPath;
return (
<ViewItem
key={`metadata-views-folder-${view._id}`}
leftIndent={TREE_NODE_LEFT_INDENT * 2}
canDelete={canDeleteView}
isSelected={isSelected}
userPerm={userPerm}
view={view}
getMoveableFolders={getMoveableFolders}
setDragMode={setDragMode}
getDragMode={getDragMode}
onClick={selectView}
onDelete={handleDeleteView}
onCopy={handleDuplicateView}
onUpdate={modifyView}
/>
);
};
return (
<>
<div className="tree-view tree metadata-tree-view">
<div className="tree-node">
<div className="children">
{navigation.map((item, index) => {
const view = viewsMap[item._id];
const viewPath = '/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/' + view._id;
const isSelected = currentPath === viewPath;
return (
<ViewItem
key={view._id}
canDelete={index !== 0}
isSelected={isSelected}
userPerm={userPerm}
view={view}
onClick={(view) => 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 && (
<div className="tree-view-inner sf-metadata-view-form">
<div className="left-icon">
<Icon symbol={VIEW_TYPE_ICON[newView.type] || VIEW_TYPE.TABLE} className="metadata-views-icon" />
</div>
<Input
className="sf-metadata-view-input"
innerRef={inputRef}
value={inputValue}
onChange={handleInputChange}
autoFocus={true}
onBlur={handleInputSubmit}
onKeyDown={onKeyDown}
/>
</div>
)}
{canAdd && (
<div id="sf-metadata-view-popover">
<CustomizeAddTool
className="sf-metadata-add-view"
callBack={togglePopover}
footerName={gettext('Add view')}
addIconClassName="sf-metadata-add-view-icon"
/>
</div>
)}
</div>
<div className="tree-view tree metadata-tree-view">
<div className="tree-node">
<div className="children">
{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 ?
<NewView newView={newView} leftIndent={TREE_NODE_LEFT_INDENT * 2} closeNewView={closeNewView} addView={handleAddView} /> :
<NewFolder closeNewFolder={closeNewFolder} />
)}
</div>
</div>
{showAddViewPopover && (
<AddView
target='sf-metadata-view-popover'
toggle={togglePopover}
onOptionClick={handlePopoverOptionClick}
/>
)}
</>
</div>
);
};

View File

@@ -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 (
<Input
autoFocus
className={classnames('sf-metadata-view-input', className)}
innerRef={inputRef}
value={inputValue}
onChange={(handleInputChange)}
onBlur={handleInputSubmit}
onKeyDown={onKeyDown}
/>
);
});
InlineNameEditor.propTypes = {
name: PropTypes.string,
onSubmit: PropTypes.func,
};
export default InlineNameEditor;

View File

@@ -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 (
<Modal isOpen={true} toggle={onToggle} autoFocus={false} className="sf-metadata-view-name-dialog">
<ModalHeader toggle={onToggle}>{title}</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<Label>{gettext('Name')}</Label>
<Input autoFocus={true} value={name} onChange={onChange}/>
<Input style={{ display: 'none' }} />
</FormGroup>
</Form>
{errorMessage && <Alert color="danger" className="mt-2">{errorMessage}</Alert>}
</ModalBody>
<ModalFooter>
<Button color="secondary" disabled={isSubmitting} onClick={onToggle}>{gettext('Cancel')}</Button>
<Button color="primary" disabled={isSubmitting} onClick={submit}>{gettext('Submit')}</Button>
</ModalFooter>
</Modal>
);
};
NameDialog.propTypes = {
value: PropTypes.string,
title: PropTypes.string.isRequired,
onSubmit: PropTypes.func.isRequired,
onToggle: PropTypes.func.isRequired,
};
export default NameDialog;

View File

@@ -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 (
<div className="tree-node">
<div className="tree-node-inner tree-view-inner sf-metadata-view-form">
<div className="tree-node-text">
<InlineNameEditor
ref={editorRef}
onSubmit={handleInputSubmit}
/>
</div>
<div className="left-icon">
<i className="folder-toggle-icon sf3-font sf3-font-down"></i>
<span className="tree-node-icon"><i className="sf3-font sf3-font-folder"></i></span>
</div>
</div>
</div>
);
};
NewFolder.propTypes = {
closeNewView: PropTypes.func,
};
export default NewFolder;

View File

@@ -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 (
<div className="tree-node">
<div className="tree-node-inner tree-view-inner sf-metadata-view-form">
<div className="tree-node-text" style={{ paddingLeft: leftIndent }}>
<InlineNameEditor
ref={editorRef}
name={newView.default_name}
onSubmit={handleInputSubmit}
/>
</div>
<div className="left-icon" style={{ left: leftIndent - 40 }}>
<span className="tree-node-icon">
<Icon symbol={VIEW_TYPE_ICON[newViewType] || VIEW_TYPE.TABLE} className="metadata-views-icon" />
</span>
</div>
</div>
</div>
);
};
NewView.propTypes = {
newView: PropTypes.object,
leftIndent: PropTypes.number,
closeNewView: PropTypes.func,
};
export default NewView;

View File

@@ -1,4 +0,0 @@
.metadata-tree-view .sf-dropdown-toggle {
display: inline-block;
transform: rotate(90deg);
}

View File

@@ -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 { 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';
import './index.css';
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 { idViewMap, moveView } = useMetadata();
const { viewsMap } = 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: <i className="sf3-font sf3-font-folder"></i> })),
});
}
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 { 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 onChange = useCallback((e) => {
setInputValue(e.target.value);
}, []);
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 (
<>
<div className="tree-node">
<div
className={classnames('tree-node-inner text-nowrap', { 'tree-node-inner-hover': highlight, 'tree-node-hight-light': isSelected, 'tree-node-drop': isDropShow })}
title={view.name}
title={viewName}
onMouseEnter={onMouseEnter}
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
onClick={() => onClick(view)}
onClick={() => onClick(view, isSelected)}
>
<div
className="tree-node-text"
@@ -220,29 +211,27 @@ const ViewItem = ({
onDragLeave={onDragLeave}
onDragOver={onDragMove}
onDrop={onDrop}
style={{ paddingLeft: leftIndent + 5 }}
>
{isRenaming ? (
<Input
innerRef={inputRef}
className="sf-metadata-view-input mt-0"
value={inputValue}
onChange={onChange}
autoFocus={true}
onBlur={() => setRenaming(false)}
onKeyDown={onKeyDown}
<InlineNameEditor
name={viewName}
className="rename mt-0"
onSubmit={handleSubmit}
/>
) : view.name}
) : viewName}
</div>
<div className="left-icon">
<div className="left-icon" style={{ left: leftIndent - 40 }}>
<div className="tree-node-icon">
<Icon symbol={VIEW_TYPE_ICON[view.type] || 'table'} className="metadata-views-icon" />
</div>
</div>
{operations.length > 0 && (
<div className="right-icon" id={`metadata-view-dropdown-item-${view._id}`} >
<div className="right-icon" id={`metadata-view-dropdown-item-${viewId}`} >
{highlight && (
<ItemDropdownMenu
item={{ name: 'metadata-view' }}
menuClassname="metadata-views-dropdown-menu"
toggleClass="sf3-font sf3-font-more"
freezeItem={freezeItem}
unfreezeItem={unfreezeItem}
@@ -254,14 +243,19 @@ const ViewItem = ({
</div>
)}
</div>
</>
</div>
);
};
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,
};

View File

@@ -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 : [];
}
}

View File

@@ -6,4 +6,4 @@ export {
export {
isValidPosition,
} from './geolocation';
export { isValidViewName } from './view';
export { validateName } from './view';

View File

@@ -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') };
}

View File

@@ -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);
}

View File

@@ -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 (
<div className="tree-view tree metadata-tree-view">
<div className="tree-view tree metadata-tree-view metadata-tree-view-tag">
<div className="tree-node">
<div className="children">
{tags.slice(0, 20).map(tag => {

View File

@@ -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;

View File

@@ -681,6 +681,142 @@ class MetadataColumns(APIView):
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,)
@@ -706,15 +842,29 @@ class MetadataViews(APIView):
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}.'
@@ -731,7 +881,9 @@ class MetadataViews(APIView):
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'
@@ -739,7 +891,6 @@ class MetadataViews(APIView):
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
@@ -764,7 +915,7 @@ class MetadataViews(APIView):
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)
@@ -791,6 +942,7 @@ class MetadataViews(APIView):
# 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)
@@ -800,17 +952,22 @@ class MetadataViews(APIView):
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
@@ -822,7 +979,7 @@ class MetadataViews(APIView):
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'
@@ -838,6 +995,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)
@@ -847,17 +1005,21 @@ class MetadataViewsDuplicateView(APIView):
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
@@ -868,13 +1030,15 @@ class MetadataViewsDuplicateView(APIView):
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):
@@ -913,37 +1077,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
@@ -954,8 +1110,33 @@ class MetadataViewsMoveView(APIView):
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'

View File

@@ -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
@@ -77,19 +77,37 @@ class RepoMetadata(models.Model):
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": [],
@@ -104,7 +122,57 @@ class RepoView(object):
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
@@ -127,13 +195,22 @@ class RepoMetadataViewsManager(models.Manager):
)
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
@@ -157,7 +234,7 @@ class RepoMetadataViewsManager(models.Manager):
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']:
@@ -168,56 +245,125 @@ class RepoMetadataViewsManager(models.Manager):
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
navigation = view_details.get('navigation', [])
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)
# 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()
@@ -234,11 +380,27 @@ class RepoMetadataViews(models.Model):
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

View File

@@ -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, MetadataOCRManageView
@@ -11,6 +11,7 @@ urlpatterns = [
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<view_id>.+)/$', 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'),