diff --git a/frontend/src/components/cur-dir-path/dir-path.js b/frontend/src/components/cur-dir-path/dir-path.js index b415f36432..02c767368b 100644 --- a/frontend/src/components/cur-dir-path/dir-path.js +++ b/frontend/src/components/cur-dir-path/dir-path.js @@ -62,16 +62,25 @@ class DirPath extends React.Component { if (item === '') { return null; } - if (index === (pathList.length - 1)) { - if (item === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES) { - return ( - - / - {gettext('File extended properties')} - - ); - } + if (index === pathList.length - 2 && item === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES) { + return ( + + / + {gettext('File extended properties')} + + ); + } + if (index === pathList.length - 1 && pathList[pathList.length - 2] === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES) { + return ( + + / + {item} + + ); + } + + if (index === (pathList.length - 1)) { return ( / @@ -129,7 +138,7 @@ class DirPath extends React.Component { const { currentPath } = this.props; const path = currentPath[currentPath.length - 1] === '/' ? currentPath.slice(0, currentPath.length - 1) : currentPath; const pathList = path.split('/'); - return pathList[pathList.length - 1] === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES; + return pathList[pathList.length - 2] === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES; }; render() { diff --git a/frontend/src/components/cur-dir-path/dir-tool.js b/frontend/src/components/cur-dir-path/dir-tool.js index 4c0d978903..d684f0b4e8 100644 --- a/frontend/src/components/cur-dir-path/dir-tool.js +++ b/frontend/src/components/cur-dir-path/dir-tool.js @@ -23,6 +23,7 @@ const propTypes = { sortBy: PropTypes.string, sortOrder: PropTypes.string, sortItems: PropTypes.func, + metadataViewId: PropTypes.string, }; class DirTool extends React.Component { @@ -97,9 +98,9 @@ class DirTool extends React.Component { render() { const menuItems = this.getMenu(); const { isDropdownMenuOpen } = this.state; - const { repoID, currentMode, currentPath, sortBy, sortOrder } = this.props; + const { repoID, currentMode, currentPath, sortBy, sortOrder, metadataViewId } = this.props; const propertiesText = TextTranslation.PROPERTIES.value; - const isFileExtended = currentPath === '/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES; + const isFileExtended = currentPath.startsWith('/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/'); const sortOptions = this.sortOptions.map(item => { return { @@ -111,7 +112,7 @@ class DirTool extends React.Component { if (isFileExtended) { return (
- +
); } diff --git a/frontend/src/components/cur-dir-path/index.js b/frontend/src/components/cur-dir-path/index.js index 5df14e8207..122990ca5b 100644 --- a/frontend/src/components/cur-dir-path/index.js +++ b/frontend/src/components/cur-dir-path/index.js @@ -37,6 +37,7 @@ const propTypes = { filePermission: PropTypes.string, repoTags: PropTypes.array.isRequired, onFileTagChanged: PropTypes.func.isRequired, + metadataViewId: PropTypes.string, }; class CurDirPath extends React.Component { @@ -96,6 +97,7 @@ class CurDirPath extends React.Component { sortBy={this.props.sortBy} sortOrder={this.props.sortOrder} sortItems={this.props.sortItems} + metadataViewId={this.props.metadataViewId} />} {!isDesktop && this.props.direntList.length > 0 && } diff --git a/frontend/src/components/dir-view-mode/dir-column-file.js b/frontend/src/components/dir-view-mode/dir-column-file.js index 28285e74d7..c47eb884c5 100644 --- a/frontend/src/components/dir-view-mode/dir-column-file.js +++ b/frontend/src/components/dir-view-mode/dir-column-file.js @@ -13,6 +13,7 @@ const propTypes = { isFileLoadedErr: PropTypes.bool.isRequired, filePermission: PropTypes.string, content: PropTypes.string, + metadataViewId: PropTypes.string, lastModified: PropTypes.string, latestContributor: PropTypes.string, onLinkClick: PropTypes.func.isRequired, @@ -52,13 +53,14 @@ class DirColumnFile extends React.Component { } if (this.props.content === '__sf-metadata') { + const { repoID, currentRepoInfo, metadataViewId } = this.props; window.sfMetadata = { siteRoot, lang, mediaUrl, }; - return (); + return (); } return ( diff --git a/frontend/src/components/dir-view-mode/dir-column-view.js b/frontend/src/components/dir-view-mode/dir-column-view.js index cd5595ab6e..749293dc15 100644 --- a/frontend/src/components/dir-view-mode/dir-column-view.js +++ b/frontend/src/components/dir-view-mode/dir-column-view.js @@ -37,6 +37,7 @@ const propTypes = { hash: PropTypes.string, filePermission: PropTypes.string, content: PropTypes.string, + metadataViewId: PropTypes.string, lastModified: PropTypes.string, latestContributor: PropTypes.string, onLinkClick: PropTypes.func.isRequired, @@ -192,6 +193,7 @@ class DirColumnView extends React.Component { isFileLoadedErr={this.props.isFileLoadedErr} filePermission={this.props.filePermission} content={this.props.content} + metadataViewId={this.props.metadataViewId} currentRepoInfo={this.props.currentRepoInfo} lastModified={this.props.lastModified} latestContributor={this.props.latestContributor} diff --git a/frontend/src/components/dir-view-mode/dir-views.js b/frontend/src/components/dir-view-mode/dir-views.js index 567ff35df2..725299adc4 100644 --- a/frontend/src/components/dir-view-mode/dir-views.js +++ b/frontend/src/components/dir-view-mode/dir-views.js @@ -67,7 +67,7 @@ const DirViews = ({ userPerm, repoID, currentPath, onNodeClick }) => { moreOperations={moreOperations} moreOperationClick={moreOperationClick} > - {!loading && metadataStatus && ()} + {!loading && metadataStatus && ()} {showMetadataStatusManagementDialog && ( { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/views/'; + return this.req.get(url); + }; + + addView = (repoID, name) => { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/views/'; + const params = { name }; + return this._sendPostRequest(url, params, { headers: { 'Content-type': 'application/json' } }); + }; + + modifyView = (repoID, viewId, viewData) => { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/views/'; + const params = { + view_id: viewId, + view_data: viewData, + }; + return this.req.put(url, params); + }; + + deleteView = (repoID, viewId) => { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/views/'; + const params = { + view_id: viewId, + }; + return this.req.delete(url, params); + }; + + moveView = (repoID, viewId, targetViewId) => { + const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/move-views/'; + const params = { + view_id: viewId, + target_view_id: targetViewId, + }; + return this._sendPostRequest(url, params, { headers: { 'Content-type': 'application/json' } }); + }; } const metadataAPI = new MetadataManagerAPI(); diff --git a/frontend/src/metadata/metadata-tree-view/index.css b/frontend/src/metadata/metadata-tree-view/index.css index 59d2817313..b6b985f187 100644 --- a/frontend/src/metadata/metadata-tree-view/index.css +++ b/frontend/src/metadata/metadata-tree-view/index.css @@ -26,3 +26,33 @@ width: 14px; line-height: 1.5; } + +.metadata-tree-view .sf-metadata-add-view { + border-top: none; + height: 28px; + padding: 2px 0 2px 28.8px; + 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; +} diff --git a/frontend/src/metadata/metadata-tree-view/index.js b/frontend/src/metadata/metadata-tree-view/index.js index d86bf1639d..4fd7e22ee6 100644 --- a/frontend/src/metadata/metadata-tree-view/index.js +++ b/frontend/src/metadata/metadata-tree-view/index.js @@ -1,17 +1,43 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import PropTypes from 'prop-types'; -import classnames from 'classnames'; import { gettext } from '../../utils/constants'; -import Icon from '../../components/icon'; import { PRIVATE_FILE_TYPE } from '../../constants'; +import metadataAPI from '../api'; +import { Utils } from '../../utils/utils'; +import toaster from '../../components/toast'; +import ViewItem from './view-item'; +import NameDialog from './name-dialog'; import './index.css'; +import { CustomizeAddTool } from '@seafile/sf-metadata-ui-component'; -const MetadataTreeView = ({ repoID, currentPath, onNodeClick }) => { - const node = useMemo(() => { - return { +const MetadataTreeView = ({ userPerm, repoID, currentPath, onNodeClick }) => { + const canAdd = useMemo(() => { + if (userPerm !== 'rw' && userPerm !== 'admin') return false; + return true; + }, [userPerm]); + const [views, setViews] = useState([]); + const [showAddViewDialog, setSowAddViewDialog] = useState(false); + const [, setState] = useState(0); + const viewsMap = useRef({}); + + useEffect(() => { + metadataAPI.listViews(repoID).then(res => { + const { navigation, views } = res.data; + Array.isArray(views) && views.forEach(view => { + viewsMap.current[view._id] = view; + }); + setViews(navigation); + }).catch(error => { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + }); + }, []); + + const onClick = useCallback((view) => { + const node = { children: [], - path: '/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES, + path: '/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/' + view.name, isExpanded: false, isLoaded: true, isPreload: true, @@ -24,48 +50,105 @@ const MetadataTreeView = ({ repoID, currentPath, onNodeClick }) => { }, parentNode: {}, key: repoID, + view_id: view._id, }; + onNodeClick(node); + }, [onNodeClick]); + + const openAddView = useCallback(() => { + setSowAddViewDialog(true); + }, []); + + const closeAddView = useCallback(() => { + setSowAddViewDialog(false); + }, []); + + const addView = useCallback((name, failCallback) => { + metadataAPI.addView(repoID, name).then(res => { + const view = res.data.view; + let newViews = views.slice(0); + newViews.push({ _id: view._id, type: 'view' }); + viewsMap.current[view._id] = view; + setSowAddViewDialog(false); + setViews(newViews); + onClick(view); + }).catch(error => { + failCallback && failCallback(error); + }); + }, [views, repoID, viewsMap, onClick]); + + const onDeleteView = useCallback((viewId, isSelected) => { + metadataAPI.deleteView(repoID, viewId).then(res => { + const currentViewIndex = views.findIndex(item => item.id === viewId); + const newViews = views.filter(item => item.id === viewId); + delete viewsMap.current[viewId]; + setViews(newViews); + if (isSelected) { + const lastViewId = views[currentViewIndex - 1].id; + const lastView = viewsMap.current[lastViewId]; + onNodeClick(lastView); + } + }).catch((error => { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + })); + }, [views, onClick, viewsMap]); + + const onUpdateView = useCallback((viewId, update, successCallback, failCallback) => { + metadataAPI.modifyView(repoID, viewId, update).then(res => { + successCallback && successCallback(); + const currentView = viewsMap.current[viewId]; + viewsMap.current[viewId] = { ...currentView, ...update }; + setState(n => n + 1); + }).catch(error => { + failCallback && failCallback(error); + }); + }, [repoID, viewsMap]); + + const onMoveView = useCallback((sourceViewId, targetViewId) => { + metadataAPI.moveView(repoID, sourceViewId, targetViewId).then(res => { + const { navigation } = res.data; + setViews(navigation); + }).catch(error => { + const errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + }); }, [repoID]); - const [highlight, setHighlight] = useState(false); - - const onMouseEnter = useCallback(() => { - setHighlight(true); - }, []); - - const onMouseOver = useCallback(() => { - setHighlight(true); - }, []); - - const onMouseLeave = useCallback(() => { - setHighlight(false); - }, []); return ( -
-
-
-
onNodeClick(node)} - > -
{gettext('File extended properties')}
-
-
- -
-
+ <> +
+
+
+ {views.map((item, index) => { + if (item.type !== 'view') return null; + const view = viewsMap.current[item._id]; + const viewPath = '/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/' + view.name; + const isSelected = currentPath === viewPath; + return ( + onDeleteView(view._id, isSelected)} + onUpdate={(update, successCallback, failCallback) => onUpdateView(view._id, update, successCallback, failCallback)} + onMove={onMoveView} + />); + })} + {canAdd && ()}
-
+ {showAddViewDialog && ()} + ); }; MetadataTreeView.propTypes = { + userPerm: PropTypes.string, repoID: PropTypes.string.isRequired, currentPath: PropTypes.string, onNodeClick: PropTypes.func, diff --git a/frontend/src/metadata/metadata-tree-view/name-dialog/index.css b/frontend/src/metadata/metadata-tree-view/name-dialog/index.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/metadata/metadata-tree-view/name-dialog/index.js b/frontend/src/metadata/metadata-tree-view/name-dialog/index.js new file mode 100644 index 0000000000..bdd7cec3b0 --- /dev/null +++ b/frontend/src/metadata/metadata-tree-view/name-dialog/index.js @@ -0,0 +1,96 @@ +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 { gettext } from '../../metadata-view/utils'; +import { Utils } from '../../../utils/utils'; + +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, (error) => { + const errorMsg = Utils.getErrorMsg(error); + setErrorMessage(errorMsg); + setSubmitting(false); + }); + }, [validate, name, onSubmit]); + + const onHotKey = useCallback((event) => { + if (event.keyCode === KeyCodes.Enter) { + event.preventDefault(); + } + }, [submit]); + + useEffect(() => { + document.addEventListener('keydown', onHotKey); + return () => { + document.removeEventListener('keydown', onHotKey); + }; + }, [onHotKey]); + + return ( + + {title} + +
+ + + + + +
+ {errorMessage && {errorMessage}} +
+ + + + +
+ ); +}; + +NameDialog.propTypes = { + value: PropTypes.string, + title: PropTypes.string.isRequired, + onSubmit: PropTypes.func.isRequired, + onToggle: PropTypes.func.isRequired, +}; + +export default NameDialog; diff --git a/frontend/src/metadata/metadata-tree-view/view-item/index.css b/frontend/src/metadata/metadata-tree-view/view-item/index.css new file mode 100644 index 0000000000..930a4b43ce --- /dev/null +++ b/frontend/src/metadata/metadata-tree-view/view-item/index.css @@ -0,0 +1,4 @@ +.metadata-tree-view .sf-dropdown-toggle { + display: inline-block; + transform: rotate(90deg); +} diff --git a/frontend/src/metadata/metadata-tree-view/view-item/index.js b/frontend/src/metadata/metadata-tree-view/view-item/index.js new file mode 100644 index 0000000000..4bc4f50b17 --- /dev/null +++ b/frontend/src/metadata/metadata-tree-view/view-item/index.js @@ -0,0 +1,179 @@ +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 NameDialog from '../name-dialog'; + +import './index.css'; +import { Utils } from '../../../utils/utils'; + +const ViewItem = ({ + canDelete, + userPerm, + isSelected, + view, + onClick, + onDelete, + onUpdate, + onMove, +}) => { + const [highlight, setHighlight] = useState(false); + const [freeze, setFreeze] = useState(false); + const [isShowRenameDialog, setRenameDialogShow] = useState(false); + const [isDropShow, setDropShow] = useState(false); + const canUpdate = useMemo(() => { + if (userPerm !== 'rw' && userPerm !== 'admin') return false; + return true; + }, [userPerm]); + const canDrop = useMemo(() => { + if (Utils.isIEBrower() || !canUpdate) return false; + return true; + }, [canUpdate]); + const operations = useMemo(() => { + if (!canUpdate) return []; + let value = [ + { key: 'rename', value: gettext('Rename') }, + ]; + if (canDelete) { + value.push({ key: 'delete', value: gettext('Delete') }); + } + return value; + }, [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); + }, []); + + const unfreezeItem = useCallback(() => { + setFreeze(false); + setHighlight(false); + }, []); + + const operationClick = useCallback((operationKey) => { + if (operationKey === 'rename') { + setRenameDialogShow(true); + return; + } + + if (operationKey === 'delete') { + onDelete(); + return; + } + }, [onDelete, view]); + + const closeRenameDialog = useCallback(() => { + setRenameDialogShow(false); + }, []); + + const renameView = useCallback((name, failCallback) => { + onUpdate({ name }, () => { + setRenameDialogShow(false); + }, failCallback); + }, [onUpdate]); + + const onDragStart = useCallback((event) => { + if (!canDrop) return false; + const dragData = JSON.stringify({ type: 'sf-metadata-view', view_id: view._id }); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('applicaiton/drag-sf-metadata-view-info', dragData); + }, [canDrop, view]); + + const onDragEnter = useCallback((event) => { + if (!canDrop) return false; + setDropShow(true); + }, [canDrop, view]); + + const onDragLeave = useCallback(() => { + if (!canDrop) return false; + setDropShow(false); + }, [canDrop, view]); + + const onDragMove = useCallback(() => { + if (!canDrop) return false; + }, [canDrop]); + + const onDrop = useCallback((event) => { + if (!canDrop) return false; + event.stopPropagation(); + setDropShow(false); + + let dragData = event.dataTransfer.getData('applicaiton/drag-sf-metadata-view-info'); + 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]); + + return ( + <> +
onClick(view)} + > +
+ {view.name} +
+
+
+ +
+
+
+ {highlight && ( + operations} + onMenuItemClick={operationClick} + /> + )} +
+
+ {isShowRenameDialog && ( + + )} + + + ); +}; + +ViewItem.propTypes = { + canDelete: PropTypes.bool, + isSelected: PropTypes.bool, + view: PropTypes.object, + onClick: PropTypes.func, +}; + +export default ViewItem; diff --git a/frontend/src/metadata/metadata-view/components/view-toolbar/index.js b/frontend/src/metadata/metadata-view/components/view-toolbar/index.js index 2a20a672cd..f8a992ad6f 100644 --- a/frontend/src/metadata/metadata-view/components/view-toolbar/index.js +++ b/frontend/src/metadata/metadata-view/components/view-toolbar/index.js @@ -6,8 +6,7 @@ import { EVENT_BUS_TYPE } from '../../constants'; import './index.css'; -const ViewToolBar = () => { - const [isLoading, setLoading] = useState(true); +const ViewToolBar = ({ metadataViewId }) => { const [view, setView] = useState(null); const [collaborators, setCollaborators] = useState([]); @@ -41,28 +40,21 @@ const ViewToolBar = () => { }, []); useEffect(() => { + let unsubscribeViewChange; let timer = setInterval(() => { if (window.sfMetadataContext && window.sfMetadataStore.data) { timer && clearInterval(timer); timer = null; - setLoading(false); setView(window.sfMetadataStore.data.view); setCollaborators(window.sfMetadataStore?.collaborators || []); + unsubscribeViewChange = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.VIEW_CHANGED, viewChange); } }, 300); return () => { timer && clearInterval(timer); + unsubscribeViewChange && unsubscribeViewChange(); }; - }, []); - - useEffect(() => { - if (isLoading) return; - const unsubscribeViewChange = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.VIEW_CHANGED, viewChange); - return () => { - unsubscribeViewChange(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isLoading]); + }, [metadataViewId]); if (!view) return null; @@ -110,11 +102,7 @@ const ViewToolBar = () => { }; ViewToolBar.propTypes = { - view: PropTypes.object, - modifyFilters: PropTypes.func, - modifySorts: PropTypes.func, - modifyGroupbys: PropTypes.func, - modifyHiddenColumns: PropTypes.func, + metadataViewId: PropTypes.string, }; export default ViewToolBar; diff --git a/frontend/src/metadata/metadata-view/context.js b/frontend/src/metadata/metadata-view/context.js index d2c63d41c7..7a99dd588a 100644 --- a/frontend/src/metadata/metadata-view/context.js +++ b/frontend/src/metadata/metadata-view/context.js @@ -25,8 +25,8 @@ class Context { this.metadataAPI = metadataAPI; // init localStorage - const { repoID } = this.settings; - this.localStorage = new LocalStorage(`sf-metadata-${repoID}`); + const { repoID, viewID } = this.settings; + this.localStorage = new LocalStorage(`sf-metadata-${repoID}-${viewID}`); // init userService this.userService = new UserService({ mediaUrl, api: this.metadataAPI.listUserInfo }); @@ -71,6 +71,11 @@ class Context { return this.metadataAPI.getMetadata(repoID, params); }; + getViews = () => { + const repoID = this.settings['repoID']; + return this.metadataAPI.listViews(repoID); + }; + canModifyCell = (column) => { const { editable } = column; if (!editable) return false; @@ -117,6 +122,10 @@ class Context { return this.metadataAPI.modifyRecords(repoId, recordsData, isCopyPaste); }; + modifyView = (repoId, viewId, viewData) => { + return this.metadataAPI.modifyView(repoId, viewId, viewData); + }; + getRowsByIds = () => { // todo }; diff --git a/frontend/src/metadata/metadata-view/hooks/metadata.js b/frontend/src/metadata/metadata-view/hooks/metadata.js index 655ce25766..aeb4a3d4cf 100644 --- a/frontend/src/metadata/metadata-view/hooks/metadata.js +++ b/frontend/src/metadata/metadata-view/hooks/metadata.js @@ -10,6 +10,9 @@ const MetadataContext = React.createContext(null); export const MetadataProvider = ({ children, + repoID, + viewID, + currentRepoInfo, ...params }) => { const [isLoading, setLoading] = useState(true); @@ -31,12 +34,15 @@ export const MetadataProvider = ({ // init useEffect(() => { + setLoading(true); // init context const context = new Context(); window.sfMetadataContext = context; window.sfMetadataContext.init({ otherSettings: params }); - const repoId = window.sfMetadataContext.getSetting('repoID'); - storeRef.current = new Store({ context: window.sfMetadataContext, repoId }); + window.sfMetadataContext.setSetting('viewID', viewID); + window.sfMetadataContext.setSetting('repoID', repoID); + window.sfMetadataContext.setSetting('currentRepoInfo', currentRepoInfo); + storeRef.current = new Store({ context: window.sfMetadataContext, repoId: repoID, viewId: viewID }); window.sfMetadataStore = storeRef.current; storeRef.current.initStartIndex(); storeRef.current.loadData(PER_LOAD_NUMBER).then(() => { @@ -54,13 +60,14 @@ export const MetadataProvider = ({ return () => { window.sfMetadataContext.destroy(); + window.sfMetadataStore.destroy(); unsubscribeServerTableChanged(); unsubscribeTableChanged(); unsubscribeHandleTableError(); unsubscribeUpdateRows(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [repoID, viewID, currentRepoInfo]); return ( diff --git a/frontend/src/metadata/metadata-view/store/index.js b/frontend/src/metadata/metadata-view/store/index.js index d4d74a4d25..e4ebdb9032 100644 --- a/frontend/src/metadata/metadata-view/store/index.js +++ b/frontend/src/metadata/metadata-view/store/index.js @@ -3,7 +3,9 @@ import { getRowById, getRowsByIds, } from '../_basic'; -import { Operation, LOCAL_APPLY_OPERATION_TYPE, NEED_APPLY_AFTER_SERVER_OPERATION, OPERATION_TYPE, UNDO_OPERATION_TYPE } from './operations'; +import { Operation, LOCAL_APPLY_OPERATION_TYPE, NEED_APPLY_AFTER_SERVER_OPERATION, OPERATION_TYPE, UNDO_OPERATION_TYPE, + VIEW_OPERATION +} from './operations'; import { EVENT_BUS_TYPE, PER_LOAD_NUMBER } from '../constants'; import DataProcessor from './data-processor'; import ServerOperator from './server-operator'; @@ -14,6 +16,7 @@ class Store { constructor(props) { this.repoId = props.repoId; + this.viewId = props.viewId; this.data = null; this.context = props.context; this.startIndex = 0; @@ -21,26 +24,30 @@ class Store { this.undos = []; this.pendingOperations = []; this.isSendingOperation = false; - this.isTableReadonly = false; + this.isReadonly = false; this.serverOperator = new ServerOperator(); this.collaborators = []; } + destroy = () => { + this.viewId = ''; + this.data = null; + this.startIndex = 0; + this.redos = []; + this.undos = []; + this.pendingOperations = []; + this.isSendingOperation = false; + }; + initStartIndex = () => { this.startIndex = 0; }; - saveView = () => { - const { filters, sorts, gropbys, filter_conjunction } = this.data.view; - const view = { filters, sorts, gropbys, filter_conjunction }; - window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.VIEW_CHANGED, this.data.view); - this.context.localStorage.setItem('view', view); - }; - async loadData(limit = PER_LOAD_NUMBER) { const res = await this.context.getMetadata({ start: this.startIndex, limit }); - const view = this.context.localStorage.getItem('view'); const rows = res?.data?.results || []; + const viewRes = await this.context.getViews(); + const view = viewRes?.data?.views.find(v => v._id === this.viewId) || {}; const columns = normalizeColumns(res?.data?.metadata); let data = new Metadata({ rows, columns, view }); data.view.rows = data.row_ids; @@ -144,6 +151,10 @@ class Store { this.syncOperationOnData(operation); } + if (VIEW_OPERATION.includes(operation.op_type)) { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.VIEW_CHANGED, this.data.view); + } + operation.success_callback && operation.success_callback(); this.context.eventBus.dispatch(EVENT_BUS_TYPE.SERVER_TABLE_CHANGED); @@ -178,7 +189,7 @@ class Store { } undoOperation() { - if (this.isTableReadonly || this.undos.length === 0) return; + if (this.isReadonly || this.undos.length === 0) return; const lastOperation = this.undos.pop(); const lastInvertOperation = lastOperation.invert(); if (NEED_APPLY_AFTER_SERVER_OPERATION.includes(lastInvertOperation.op_type)) { @@ -195,7 +206,7 @@ class Store { } redoOperation() { - if (this.isTableReadonly || this.redos.length === 0) return; + if (this.isReadonly || this.redos.length === 0) return; let lastOperation = this.redos.pop(); if (NEED_APPLY_AFTER_SERVER_OPERATION.includes(lastOperation.op_type)) { this.applyOperation(lastOperation, { handleUndo: false, asyncUndoRedo: (operation) => { @@ -325,37 +336,33 @@ class Store { modifyFilters(filterConjunction, filters) { const type = OPERATION_TYPE.MODIFY_FILTERS; const operation = this.createOperation({ - type, filter_conjunction: filterConjunction, filters, + type, filter_conjunction: filterConjunction, filters, repo_id: this.repoId, view_id: this.viewId }); this.applyOperation(operation); - this.saveView(); } modifySorts(sorts) { const type = OPERATION_TYPE.MODIFY_SORTS; const operation = this.createOperation({ - type, sorts, + type, sorts, repo_id: this.repoId, view_id: this.viewId }); this.applyOperation(operation); - this.saveView(); } modifyGroupbys(groupbys) { const type = OPERATION_TYPE.MODIFY_GROUPBYS; const operation = this.createOperation({ - type, groupbys, + type, groupbys, repo_id: this.repoId, view_id: this.viewId }); this.applyOperation(operation); - this.saveView(); } modifyHiddenColumns(shown_column_keys) { const type = OPERATION_TYPE.MODIFY_HIDDEN_COLUMNS; const operation = this.createOperation({ - type, shown_column_keys + type, shown_column_keys, repo_id: this.repoId, view_id: this.viewId }); this.applyOperation(operation); - this.saveView(); } insertColumn = (name, type, { key, data }) => { diff --git a/frontend/src/metadata/metadata-view/store/operations/constants.js b/frontend/src/metadata/metadata-view/store/operations/constants.js index a41c69726c..2aa6e8c16e 100644 --- a/frontend/src/metadata/metadata-view/store/operations/constants.js +++ b/frontend/src/metadata/metadata-view/store/operations/constants.js @@ -20,10 +20,10 @@ export const OPERATION_ATTRIBUTES = { [OPERATION_TYPE.MODIFY_RECORDS]: ['repo_id', 'row_ids', 'id_row_updates', 'id_original_row_updates', 'id_old_row_data', 'id_original_old_row_data', 'is_copy_paste'], [OPERATION_TYPE.RESTORE_RECORDS]: ['repo_id', 'rows_data', 'original_rows', 'link_infos', 'upper_row_ids'], [OPERATION_TYPE.RELOAD_RECORDS]: ['repo_id', 'row_ids'], - [OPERATION_TYPE.MODIFY_FILTERS]: ['filter_conjunction', 'filters'], - [OPERATION_TYPE.MODIFY_SORTS]: ['sorts'], - [OPERATION_TYPE.MODIFY_GROUPBYS]: ['groupbys'], - [OPERATION_TYPE.MODIFY_HIDDEN_COLUMNS]: ['shown_column_keys'], + [OPERATION_TYPE.MODIFY_FILTERS]: ['repo_id', 'view_id', 'filter_conjunction', 'filters'], + [OPERATION_TYPE.MODIFY_SORTS]: ['repo_id', 'view_id', 'sorts'], + [OPERATION_TYPE.MODIFY_GROUPBYS]: ['repo_id', 'view_id', 'groupbys'], + [OPERATION_TYPE.MODIFY_HIDDEN_COLUMNS]: ['repo_id', 'view_id', 'shown_column_keys'], [OPERATION_TYPE.LOCK_RECORD_VIA_BUTTON]: ['repo_id', 'row_id', 'button_column_key'], [OPERATION_TYPE.MODIFY_RECORD_VIA_BUTTON]: ['repo_id', 'row_id', 'updates', 'old_row_data', 'original_updates', 'original_old_row_data', 'button_column_key'], [OPERATION_TYPE.INSERT_COLUMN]: ['repo_id', 'name', 'column_type', 'key', 'data'], @@ -38,10 +38,7 @@ export const UNDO_OPERATION_TYPE = [ // only apply operation on the local export const LOCAL_APPLY_OPERATION_TYPE = [ - OPERATION_TYPE.MODIFY_FILTERS, - OPERATION_TYPE.MODIFY_SORTS, - OPERATION_TYPE.MODIFY_GROUPBYS, - OPERATION_TYPE.MODIFY_HIDDEN_COLUMNS, + ]; // apply operation after exec operation on the server @@ -49,4 +46,15 @@ export const NEED_APPLY_AFTER_SERVER_OPERATION = [ OPERATION_TYPE.INSERT_COLUMN, OPERATION_TYPE.MODIFY_RECORD, OPERATION_TYPE.MODIFY_RECORDS, + OPERATION_TYPE.MODIFY_FILTERS, + OPERATION_TYPE.MODIFY_SORTS, + OPERATION_TYPE.MODIFY_GROUPBYS, + OPERATION_TYPE.MODIFY_HIDDEN_COLUMNS, +]; + +export const VIEW_OPERATION = [ + OPERATION_TYPE.MODIFY_FILTERS, + OPERATION_TYPE.MODIFY_SORTS, + OPERATION_TYPE.MODIFY_GROUPBYS, + OPERATION_TYPE.MODIFY_HIDDEN_COLUMNS, ]; diff --git a/frontend/src/metadata/metadata-view/store/operations/index.js b/frontend/src/metadata/metadata-view/store/operations/index.js index 9519e4fb31..89594dfbb3 100644 --- a/frontend/src/metadata/metadata-view/store/operations/index.js +++ b/frontend/src/metadata/metadata-view/store/operations/index.js @@ -8,6 +8,7 @@ export { UNDO_OPERATION_TYPE, LOCAL_APPLY_OPERATION_TYPE, NEED_APPLY_AFTER_SERVER_OPERATION, + VIEW_OPERATION, } from './constants'; export { diff --git a/frontend/src/metadata/metadata-view/store/server-operator.js b/frontend/src/metadata/metadata-view/store/server-operator.js index f84568d799..d99f086905 100644 --- a/frontend/src/metadata/metadata-view/store/server-operator.js +++ b/frontend/src/metadata/metadata-view/store/server-operator.js @@ -78,7 +78,42 @@ class ServerOperator { }); break; } - + case OPERATION_TYPE.MODIFY_FILTERS: { + const { repo_id, view_id, filter_conjunction, filters } = operation; + window.sfMetadataContext.modifyView(repo_id, view_id, { filters, filter_conjunction }).then(res => { + callback({ operation }); + }).catch(error => { + callback({ error: 'Failed_to_modify_filter' }); + }); + break; + } + case OPERATION_TYPE.MODIFY_SORTS: { + const { repo_id, view_id, sorts } = operation; + window.sfMetadataContext.modifyView(repo_id, view_id, { sorts }).then(res => { + callback({ operation }); + }).catch(error => { + callback({ error: 'Failed_to_modify_sort' }); + }); + break; + } + case OPERATION_TYPE.MODIFY_GROUPBYS: { + const { repo_id, view_id, groupbys } = operation; + window.sfMetadataContext.modifyView(repo_id, view_id, { groupbys }).then(res => { + callback({ operation }); + }).catch(error => { + callback({ error: 'Failed_to_modify_group' }); + }); + break; + } + case OPERATION_TYPE.MODIFY_HIDDEN_COLUMNS: { + const { repo_id, view_id, shown_column_keys } = operation; + window.sfMetadataContext.modifyView(repo_id, view_id, { shown_column_keys }).then(res => { + callback({ operation }); + }).catch(error => { + callback({ error: 'Failed_to_modify_hidden_columns' }); + }); + break; + } default: { break; } diff --git a/frontend/src/pages/lib-content-view/lib-content-container.js b/frontend/src/pages/lib-content-view/lib-content-container.js index 175ca2b2bd..66d23b2073 100644 --- a/frontend/src/pages/lib-content-view/lib-content-container.js +++ b/frontend/src/pages/lib-content-view/lib-content-container.js @@ -39,6 +39,7 @@ const propTypes = { isFileLoading: PropTypes.bool.isRequired, filePermission: PropTypes.string, content: PropTypes.string, + metadataViewId: PropTypes.string, lastModified: PropTypes.string, latestContributor: PropTypes.string, onLinkClick: PropTypes.func.isRequired, @@ -220,6 +221,7 @@ class LibContentContainer extends React.Component { filePermission={this.props.filePermission} onFileTagChanged={this.props.onToolbarFileTagChanged} repoTags={this.props.repoTags} + metadataViewId={this.props.metadataViewId} /> { + showFileMetadata = (filePath, viewId) => { + if (this.state.metadataViewId === viewId) return; const repoID = this.props.repoID; - this.setState({ path: filePath, isViewFile: true, isFileLoading: false, isFileLoadedErr: false, content: '__sf-metadata' }); + this.setState({ path: filePath, isViewFile: true, isFileLoading: false, isFileLoadedErr: false, content: '__sf-metadata', metadataViewId: viewId }); const repoInfo = this.state.currentRepoInfo; const url = siteRoot + 'library/' + repoID + '/' + encodeURIComponent(repoInfo.repo_name); window.history.pushState({ url: url, path: '' }, '', url); @@ -1718,7 +1720,7 @@ class LibContentView extends React.Component { } } else if (Utils.isFileMetadata(node?.object?.type)) { if (node.path !== this.state.path) { - this.showFileMetadata(node.path); + this.showFileMetadata(node.path, node.view_id || '0000'); } } else { let url = siteRoot + 'lib/' + repoID + '/file' + Utils.encodePath(node.path); @@ -2084,6 +2086,7 @@ class LibContentView extends React.Component { isFileLoadedErr={this.state.isFileLoadedErr} filePermission={this.state.filePermission} content={this.state.content} + metadataViewId={this.state.metadataViewId} lastModified={this.state.lastModified} latestContributor={this.state.latestContributor} onLinkClick={this.onLinkClick} diff --git a/seahub/api2/endpoints/metadata_manage.py b/seahub/api2/endpoints/metadata_manage.py index eccaa70143..1f87c026e3 100644 --- a/seahub/api2/endpoints/metadata_manage.py +++ b/seahub/api2/endpoints/metadata_manage.py @@ -7,7 +7,7 @@ from rest_framework.views import APIView from seahub.api2.utils import api_error, to_python_boolean from seahub.api2.throttling import UserRateThrottle from seahub.api2.authentication import TokenAuthentication -from seahub.repo_metadata.models import RepoMetadata +from seahub.repo_metadata.models import RepoMetadata, RepoMetadataViews from seahub.views import check_folder_permission from seahub.repo_metadata.utils import add_init_metadata_task, gen_unique_id from seahub.repo_metadata.metadata_server_api import MetadataServerAPI, list_metadata_records @@ -68,7 +68,7 @@ class MetadataManage(APIView): error_msg = f'The metadata module is enabled for repo {repo_id}.' return api_error(status.HTTP_409_CONFLICT, error_msg) - # recource check + # resource check repo = seafile_api.get_repo(repo_id) if not repo: error_msg = 'Library %s not found.' % repo_id @@ -87,6 +87,9 @@ class MetadataManage(APIView): try: task_id = add_init_metadata_task(params=params) + metadata_view = RepoMetadataViews.objects.filter(repo_id=repo_id).first() + if not metadata_view: + RepoMetadataViews.objects.add_view(repo_id, 'All files') except Exception as e: logger.error(e) return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') @@ -131,6 +134,7 @@ class MetadataManage(APIView): try: record.enabled = False record.save() + RepoMetadataViews.objects.filter(repo_id=repo_id).delete() except Exception as e: logger.error(e) error_msg = 'Internal Server Error' @@ -443,3 +447,243 @@ class MetadataColumns(APIView): return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) return Response({'column': column}) + + +class MetadataViews(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def get(self, request, repo_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}.' + 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) + + try: + metadata_views = RepoMetadataViews.objects.list_views(repo_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(metadata_views) + + def post(self, request, repo_id): + # Add a metadata view + view_name = request.data.get('name') + if not view_name: + error_msg = 'view name is invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + 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) + + try: + new_view = RepoMetadataViews.objects.add_view(repo_id, view_name) + 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': new_view}) + + + def put(self, request, repo_id): + # Update a metadata view, including rename, change filters and so on + # by a json data + view_id = request.data.get('view_id', None) + view_data = request.data.get('view_data', None) + if not view_id: + error_msg = 'view_id is invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + if not view_data: + error_msg = 'view_data is invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + 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) + + 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) + + try: + result = RepoMetadataViews.objects.update_view(repo_id, view_id, view_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): + # Update a metadata view, including rename, change filters and so on + # by a json data + view_id = request.data.get('view_id', None) + if not view_id: + error_msg = 'view_id is invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + 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) + + 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) + + try: + result = RepoMetadataViews.objects.delete_view(repo_id, view_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 MetadataViewsDetailView(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def get(self, request, repo_id, view_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}.' + 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) + + try: + view = RepoMetadataViews.objects.get_view(repo_id, view_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({'view': view}) + + +class MetadataViewsMoveView(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + 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) + + 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) + + 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 + 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) + + try: + results = RepoMetadataViews.objects.move_view(repo_id, view_id, target_view_id) + except Exception as e: + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response({'navigation': results['navigation']}) diff --git a/seahub/repo_metadata/models.py b/seahub/repo_metadata/models.py index 9da2857acb..4dc617a3a6 100644 --- a/seahub/repo_metadata/models.py +++ b/seahub/repo_metadata/models.py @@ -1,8 +1,32 @@ import logging +import json +import random +import string from django.db import models +from seahub.utils import get_no_duplicate_obj_name + + logger = logging.getLogger(__name__) + +def generate_random_string_lower_digits(length): + letters_and_digits = string.ascii_lowercase + string.digits + random_string = ''.join(random.choice(letters_and_digits) for i in range(length)) + return random_string + + +def generate_view_id(length, view_ids=None): + if not view_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: + break + + return new_id + class RepoMetadata(models.Model): repo_id = models.CharField(max_length=36, unique=True) @@ -12,3 +36,143 @@ class RepoMetadata(models.Model): class Meta: db_table = 'repo_metadata' + + +class RepoView(object): + + def __init__(self, name, view_ids=None): + self.name = name + self.view_json = {} + + self.init_view(view_ids) + + def init_view(self, view_ids=None): + self.view_json = { + "_id": generate_view_id(4, view_ids), + "table_id": '0001', # by default + "name": self.name, + "filters": [], + "sorts": [], + "groupbys": [], + "filter_conjunction": "And", + "hidden_columns": [], + } + + +class RepoMetadataViewsManager(models.Manager): + + def add_view(self, repo_id, view_name): + metadata_views = self.filter(repo_id=repo_id).first() + if not metadata_views: + new_view = RepoView(view_name) + view_json = new_view.view_json + view_id = view_json.get('_id') + view_details = { + 'views': [view_json], + 'navigation': [{'_id': view_id, 'type': 'view'}, ] + } + self.create( + repo_id=repo_id, + details=json.dumps(view_details) + ) + 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, exist_view_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'}) + metadata_views.details = json.dumps(view_details) + metadata_views.save() + return new_view.view_json + + def list_views(self, repo_id): + metadata_views = self.filter(repo_id=repo_id).first() + if not metadata_views: + return {'views': [], 'navigation': []} + return json.loads(metadata_views.details) + + def get_view(self, repo_id, view_id): + metadata_views = self.filter(repo_id=repo_id).first() + if not metadata_views: + return None + view_details = json.loads(metadata_views.details) + for v in view_details['views']: + if v.get('_id') == view_id: + return v + + def update_view(self, repo_id, view_id, view_dict): + metadata_views = self.filter(repo_id=repo_id).first() + view_dict.pop('_id', '') + if 'name' in view_dict: + exist_obj_names = metadata_views.view_names + 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']: + if v.get('_id') == view_id: + v.update(view_dict) + break + metadata_views.details = json.dumps(view_details) + metadata_views.save() + return json.loads(metadata_views.details) + + def delete_view(self, repo_id, view_id): + 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) + break + for v in view_details['navigation']: + if v.get('_id') == view_id: + view_details['navigation'].remove(v) + 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): + metadata_views = self.filter(repo_id=repo_id).first() + view_details = json.loads(metadata_views.details) + view_index = None + target_index = None + for i, view in enumerate(view_details['navigation']): + if view['_id'] == view_id: + view_index = i + if view['_id'] == target_view_id: + target_index = i + + if view_index is not None and target_index is not None: + if view_index < target_index: + view_to_move = view_details['navigation'][view_index] + view_details['navigation'].insert(target_index, view_to_move) + view_details['navigation'].pop(view_index) + else: + view_to_move = view_details['navigation'].pop(view_index) + view_details['navigation'].insert(target_index, view_to_move) + + metadata_views.details = json.dumps(view_details) + metadata_views.save() + return json.loads(metadata_views.details) + + +class RepoMetadataViews(models.Model): + repo_id = models.CharField(max_length=36, db_index=True) + details = models.TextField() + + objects = RepoMetadataViewsManager() + + class Meta: + db_table = 'repo_metadata_view' + + @property + def view_ids(self): + views = json.loads(self.details)['views'] + return [v.get('_id') for v in views] + + @property + def view_names(self): + views = json.loads(self.details)['views'] + return [v.get('name') for v in views] diff --git a/seahub/urls.py b/seahub/urls.py index 6b0ea4cee3..f2930351f3 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -207,7 +207,8 @@ from seahub.ai.apis import LibrarySdocIndexes, Search, LibrarySdocIndex, TaskSta from seahub.wiki2.views import wiki_view from seahub.api2.endpoints.wiki2 import Wikis2View, Wiki2View, Wiki2ConfigView, Wiki2PagesView, Wiki2PageView, Wiki2DuplicatePageView from seahub.api2.endpoints.subscription import SubscriptionView, SubscriptionPlansView, SubscriptionLogsView -from seahub.api2.endpoints.metadata_manage import MetadataRecords, MetadataManage, MetadataColumns, MetadataRecordInfo +from seahub.api2.endpoints.metadata_manage import MetadataRecords, MetadataManage, MetadataColumns, MetadataRecordInfo, \ + MetadataViews, MetadataViewsMoveView, MetadataViewsDetailView from seahub.api2.endpoints.user_list import UserListView @@ -1037,5 +1038,8 @@ if settings.ENABLE_METADATA_MANAGEMENT: re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/records/$', MetadataRecords.as_view(), name='api-v2.1-metadata-records'), re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/record/$', MetadataRecordInfo.as_view(), name='api-v2.1-metadata-record-info'), re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/columns/$', MetadataColumns.as_view(), name='api-v2.1-metadata-columns'), + re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/views/$', MetadataViews.as_view(), name='api-v2.1-metadata-views'), + re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/views/(?P[-0-9a-zA-Z]{4})/$', MetadataViewsDetailView.as_view(), name='api-v2.1-metadata-views-detail'), + re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/move-views/$', MetadataViewsMoveView.as_view(), name='api-v2.1-metadata-views-move'), ]