1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-02 07:27:04 +00:00

repo-metadata-views (#6344)

* repo-metadata-views

* Update metadata_manage.py

* feat: add view

* feat: refresh view tool bar

* update

---------

Co-authored-by: 杨国璇 <ygx@192.168.124.14>
This commit is contained in:
Ranjiwei
2024-07-22 09:45:08 +08:00
committed by GitHub
parent 0facf2951e
commit 3b0cd4fcbe
26 changed files with 1030 additions and 111 deletions

View File

@@ -62,16 +62,25 @@ class DirPath extends React.Component {
if (item === '') { if (item === '') {
return null; return null;
} }
if (index === (pathList.length - 1)) { if (index === pathList.length - 2 && item === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES) {
if (item === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES) { return (
return ( <Fragment key={index}>
<Fragment key={index}> <span className="path-split">/</span>
<span className="path-split">/</span> <span className="path-item">{gettext('File extended properties')}</span>
<span className="path-item">{gettext('File extended properties')}</span> </Fragment>
</Fragment> );
); }
}
if (index === pathList.length - 1 && pathList[pathList.length - 2] === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES) {
return (
<Fragment key={index}>
<span className="path-split">/</span>
<span className="path-item">{item}</span>
</Fragment>
);
}
if (index === (pathList.length - 1)) {
return ( return (
<Fragment key={index}> <Fragment key={index}>
<span className="path-split">/</span> <span className="path-split">/</span>
@@ -129,7 +138,7 @@ class DirPath extends React.Component {
const { currentPath } = this.props; const { currentPath } = this.props;
const path = currentPath[currentPath.length - 1] === '/' ? currentPath.slice(0, currentPath.length - 1) : currentPath; const path = currentPath[currentPath.length - 1] === '/' ? currentPath.slice(0, currentPath.length - 1) : currentPath;
const pathList = path.split('/'); 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() { render() {

View File

@@ -23,6 +23,7 @@ const propTypes = {
sortBy: PropTypes.string, sortBy: PropTypes.string,
sortOrder: PropTypes.string, sortOrder: PropTypes.string,
sortItems: PropTypes.func, sortItems: PropTypes.func,
metadataViewId: PropTypes.string,
}; };
class DirTool extends React.Component { class DirTool extends React.Component {
@@ -97,9 +98,9 @@ class DirTool extends React.Component {
render() { render() {
const menuItems = this.getMenu(); const menuItems = this.getMenu();
const { isDropdownMenuOpen } = this.state; 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 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 => { const sortOptions = this.sortOptions.map(item => {
return { return {
@@ -111,7 +112,7 @@ class DirTool extends React.Component {
if (isFileExtended) { if (isFileExtended) {
return ( return (
<div className="d-flex"> <div className="d-flex">
<MetadataViewToolBar /> <MetadataViewToolBar metadataViewId={metadataViewId} />
</div> </div>
); );
} }

View File

@@ -37,6 +37,7 @@ const propTypes = {
filePermission: PropTypes.string, filePermission: PropTypes.string,
repoTags: PropTypes.array.isRequired, repoTags: PropTypes.array.isRequired,
onFileTagChanged: PropTypes.func.isRequired, onFileTagChanged: PropTypes.func.isRequired,
metadataViewId: PropTypes.string,
}; };
class CurDirPath extends React.Component { class CurDirPath extends React.Component {
@@ -96,6 +97,7 @@ class CurDirPath extends React.Component {
sortBy={this.props.sortBy} sortBy={this.props.sortBy}
sortOrder={this.props.sortOrder} sortOrder={this.props.sortOrder}
sortItems={this.props.sortItems} sortItems={this.props.sortItems}
metadataViewId={this.props.metadataViewId}
/>} />}
{!isDesktop && this.props.direntList.length > 0 && {!isDesktop && this.props.direntList.length > 0 &&
<span className="sf3-font sf3-font-sort action-icon" onClick={this.toggleSortOptionsDialog}></span>} <span className="sf3-font sf3-font-sort action-icon" onClick={this.toggleSortOptionsDialog}></span>}

View File

@@ -13,6 +13,7 @@ const propTypes = {
isFileLoadedErr: PropTypes.bool.isRequired, isFileLoadedErr: PropTypes.bool.isRequired,
filePermission: PropTypes.string, filePermission: PropTypes.string,
content: PropTypes.string, content: PropTypes.string,
metadataViewId: PropTypes.string,
lastModified: PropTypes.string, lastModified: PropTypes.string,
latestContributor: PropTypes.string, latestContributor: PropTypes.string,
onLinkClick: PropTypes.func.isRequired, onLinkClick: PropTypes.func.isRequired,
@@ -52,13 +53,14 @@ class DirColumnFile extends React.Component {
} }
if (this.props.content === '__sf-metadata') { if (this.props.content === '__sf-metadata') {
const { repoID, currentRepoInfo, metadataViewId } = this.props;
window.sfMetadata = { window.sfMetadata = {
siteRoot, siteRoot,
lang, lang,
mediaUrl, mediaUrl,
}; };
return (<SeafileMetadata repoID={this.props.repoID} currentRepoInfo={this.props.currentRepoInfo} />); return (<SeafileMetadata repoID={repoID} currentRepoInfo={currentRepoInfo} viewID={metadataViewId} />);
} }
return ( return (

View File

@@ -37,6 +37,7 @@ const propTypes = {
hash: PropTypes.string, hash: PropTypes.string,
filePermission: PropTypes.string, filePermission: PropTypes.string,
content: PropTypes.string, content: PropTypes.string,
metadataViewId: PropTypes.string,
lastModified: PropTypes.string, lastModified: PropTypes.string,
latestContributor: PropTypes.string, latestContributor: PropTypes.string,
onLinkClick: PropTypes.func.isRequired, onLinkClick: PropTypes.func.isRequired,
@@ -192,6 +193,7 @@ class DirColumnView extends React.Component {
isFileLoadedErr={this.props.isFileLoadedErr} isFileLoadedErr={this.props.isFileLoadedErr}
filePermission={this.props.filePermission} filePermission={this.props.filePermission}
content={this.props.content} content={this.props.content}
metadataViewId={this.props.metadataViewId}
currentRepoInfo={this.props.currentRepoInfo} currentRepoInfo={this.props.currentRepoInfo}
lastModified={this.props.lastModified} lastModified={this.props.lastModified}
latestContributor={this.props.latestContributor} latestContributor={this.props.latestContributor}

View File

@@ -67,7 +67,7 @@ const DirViews = ({ userPerm, repoID, currentPath, onNodeClick }) => {
moreOperations={moreOperations} moreOperations={moreOperations}
moreOperationClick={moreOperationClick} moreOperationClick={moreOperationClick}
> >
{!loading && metadataStatus && (<MetadataTreeView repoID={repoID} currentPath={currentPath} onNodeClick={onNodeClick} />)} {!loading && metadataStatus && (<MetadataTreeView userPerm={userPerm} repoID={repoID} currentPath={currentPath} onNodeClick={onNodeClick} />)}
</TreeSection> </TreeSection>
{showMetadataStatusManagementDialog && ( {showMetadataStatusManagementDialog && (
<MetadataStatusManagementDialog <MetadataStatusManagementDialog

View File

@@ -107,6 +107,7 @@ class TreeView extends React.Component {
return; return;
} }
let dragStartNodeData = e.dataTransfer.getData('applicaiton/drag-item-info'); let dragStartNodeData = e.dataTransfer.getData('applicaiton/drag-item-info');
if (!dragStartNodeData) return;
dragStartNodeData = JSON.parse(dragStartNodeData); dragStartNodeData = JSON.parse(dragStartNodeData);
let { nodeDirent, nodeParentPath, nodeRootPath } = dragStartNodeData; let { nodeDirent, nodeParentPath, nodeRootPath } = dragStartNodeData;

View File

@@ -117,6 +117,43 @@ class MetadataManagerAPI {
return this._sendPostRequest(url, params, { headers: { 'Content-type': 'application/json' } }); return this._sendPostRequest(url, params, { headers: { 'Content-type': 'application/json' } });
}; };
// view
listViews = (repoID) => {
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(); const metadataAPI = new MetadataManagerAPI();

View File

@@ -26,3 +26,33 @@
width: 14px; width: 14px;
line-height: 1.5; 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;
}

View File

@@ -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 PropTypes from 'prop-types';
import classnames from 'classnames';
import { gettext } from '../../utils/constants'; import { gettext } from '../../utils/constants';
import Icon from '../../components/icon';
import { PRIVATE_FILE_TYPE } from '../../constants'; 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 './index.css';
import { CustomizeAddTool } from '@seafile/sf-metadata-ui-component';
const MetadataTreeView = ({ repoID, currentPath, onNodeClick }) => { const MetadataTreeView = ({ userPerm, repoID, currentPath, onNodeClick }) => {
const node = useMemo(() => { const canAdd = useMemo(() => {
return { 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: [], children: [],
path: '/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES, path: '/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/' + view.name,
isExpanded: false, isExpanded: false,
isLoaded: true, isLoaded: true,
isPreload: true, isPreload: true,
@@ -24,48 +50,105 @@ const MetadataTreeView = ({ repoID, currentPath, onNodeClick }) => {
}, },
parentNode: {}, parentNode: {},
key: repoID, 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]); }, [repoID]);
const [highlight, setHighlight] = useState(false);
const onMouseEnter = useCallback(() => {
setHighlight(true);
}, []);
const onMouseOver = useCallback(() => {
setHighlight(true);
}, []);
const onMouseLeave = useCallback(() => {
setHighlight(false);
}, []);
return ( return (
<div className="tree-view tree metadata-tree-view"> <>
<div className="tree-node"> <div className="tree-view tree metadata-tree-view">
<div className="children"> <div className="tree-node">
<div <div className="children">
className={classnames('tree-node-inner text-nowrap', { 'tree-node-inner-hover': highlight, 'tree-node-hight-light': currentPath === node.path })} {views.map((item, index) => {
title={gettext('File extended properties')} if (item.type !== 'view') return null;
onMouseEnter={onMouseEnter} const view = viewsMap.current[item._id];
onMouseOver={onMouseOver} const viewPath = '/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/' + view.name;
onMouseLeave={onMouseLeave} const isSelected = currentPath === viewPath;
onClick={() => onNodeClick(node)} return (
> <ViewItem
<div className="tree-node-text">{gettext('File extended properties')}</div> key={view._id}
<div className="left-icon"> canDelete={index !== 0}
<div className="tree-node-icon"> isSelected={isSelected}
<Icon symbol="table" className="metadata-views-icon" /> userPerm={userPerm}
</div> view={view}
</div> onClick={onClick}
onDelete={() => onDeleteView(view._id, isSelected)}
onUpdate={(update, successCallback, failCallback) => onUpdateView(view._id, update, successCallback, failCallback)}
onMove={onMoveView}
/>);
})}
{canAdd && (<CustomizeAddTool className="sf-metadata-add-view" callBack={openAddView} footerName={gettext('Add view')} addIconClassName="sf-metadata-add-view-icon" />)}
</div> </div>
</div> </div>
</div> </div>
</div> {showAddViewDialog && (<NameDialog title={gettext('Add view')} onSubmit={addView} onToggle={closeAddView} />)}
</>
); );
}; };
MetadataTreeView.propTypes = { MetadataTreeView.propTypes = {
userPerm: PropTypes.string,
repoID: PropTypes.string.isRequired, repoID: PropTypes.string.isRequired,
currentPath: PropTypes.string, currentPath: PropTypes.string,
onNodeClick: PropTypes.func, onNodeClick: PropTypes.func,

View File

@@ -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 (
<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,4 @@
.metadata-tree-view .sf-dropdown-toggle {
display: inline-block;
transform: rotate(90deg);
}

View File

@@ -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 (
<>
<div
className={classnames('tree-node-inner text-nowrap', { 'tree-node-inner-hover': highlight, 'tree-node-hight-light': isSelected, 'tree-node-drop': isDropShow })}
title={gettext('File extended properties')}
onMouseEnter={onMouseEnter}
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
onClick={() => onClick(view)}
>
<div
className="tree-node-text"
draggable={canUpdate}
onDragStart={onDragStart}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragMove}
onDrop={onDrop}
>
{view.name}
</div>
<div className="left-icon">
<div className="tree-node-icon">
<Icon symbol="table" className="metadata-views-icon" />
</div>
</div>
<div className="right-icon">
{highlight && (
<ItemDropdownMenu
item={{ name: 'metadata-view' }}
toggleClass="sf3-font sf3-font-more"
freezeItem={freezeItem}
unfreezeItem={unfreezeItem}
getMenuList={() => operations}
onMenuItemClick={operationClick}
/>
)}
</div>
</div>
{isShowRenameDialog && (
<NameDialog title={gettext('Rename view')} value={view.name} onSubmit={renameView} onToggle={closeRenameDialog} />
)}
</>
);
};
ViewItem.propTypes = {
canDelete: PropTypes.bool,
isSelected: PropTypes.bool,
view: PropTypes.object,
onClick: PropTypes.func,
};
export default ViewItem;

View File

@@ -6,8 +6,7 @@ import { EVENT_BUS_TYPE } from '../../constants';
import './index.css'; import './index.css';
const ViewToolBar = () => { const ViewToolBar = ({ metadataViewId }) => {
const [isLoading, setLoading] = useState(true);
const [view, setView] = useState(null); const [view, setView] = useState(null);
const [collaborators, setCollaborators] = useState([]); const [collaborators, setCollaborators] = useState([]);
@@ -41,28 +40,21 @@ const ViewToolBar = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
let unsubscribeViewChange;
let timer = setInterval(() => { let timer = setInterval(() => {
if (window.sfMetadataContext && window.sfMetadataStore.data) { if (window.sfMetadataContext && window.sfMetadataStore.data) {
timer && clearInterval(timer); timer && clearInterval(timer);
timer = null; timer = null;
setLoading(false);
setView(window.sfMetadataStore.data.view); setView(window.sfMetadataStore.data.view);
setCollaborators(window.sfMetadataStore?.collaborators || []); setCollaborators(window.sfMetadataStore?.collaborators || []);
unsubscribeViewChange = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.VIEW_CHANGED, viewChange);
} }
}, 300); }, 300);
return () => { return () => {
timer && clearInterval(timer); timer && clearInterval(timer);
unsubscribeViewChange && unsubscribeViewChange();
}; };
}, []); }, [metadataViewId]);
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]);
if (!view) return null; if (!view) return null;
@@ -110,11 +102,7 @@ const ViewToolBar = () => {
}; };
ViewToolBar.propTypes = { ViewToolBar.propTypes = {
view: PropTypes.object, metadataViewId: PropTypes.string,
modifyFilters: PropTypes.func,
modifySorts: PropTypes.func,
modifyGroupbys: PropTypes.func,
modifyHiddenColumns: PropTypes.func,
}; };
export default ViewToolBar; export default ViewToolBar;

View File

@@ -25,8 +25,8 @@ class Context {
this.metadataAPI = metadataAPI; this.metadataAPI = metadataAPI;
// init localStorage // init localStorage
const { repoID } = this.settings; const { repoID, viewID } = this.settings;
this.localStorage = new LocalStorage(`sf-metadata-${repoID}`); this.localStorage = new LocalStorage(`sf-metadata-${repoID}-${viewID}`);
// init userService // init userService
this.userService = new UserService({ mediaUrl, api: this.metadataAPI.listUserInfo }); this.userService = new UserService({ mediaUrl, api: this.metadataAPI.listUserInfo });
@@ -71,6 +71,11 @@ class Context {
return this.metadataAPI.getMetadata(repoID, params); return this.metadataAPI.getMetadata(repoID, params);
}; };
getViews = () => {
const repoID = this.settings['repoID'];
return this.metadataAPI.listViews(repoID);
};
canModifyCell = (column) => { canModifyCell = (column) => {
const { editable } = column; const { editable } = column;
if (!editable) return false; if (!editable) return false;
@@ -117,6 +122,10 @@ class Context {
return this.metadataAPI.modifyRecords(repoId, recordsData, isCopyPaste); return this.metadataAPI.modifyRecords(repoId, recordsData, isCopyPaste);
}; };
modifyView = (repoId, viewId, viewData) => {
return this.metadataAPI.modifyView(repoId, viewId, viewData);
};
getRowsByIds = () => { getRowsByIds = () => {
// todo // todo
}; };

View File

@@ -10,6 +10,9 @@ const MetadataContext = React.createContext(null);
export const MetadataProvider = ({ export const MetadataProvider = ({
children, children,
repoID,
viewID,
currentRepoInfo,
...params ...params
}) => { }) => {
const [isLoading, setLoading] = useState(true); const [isLoading, setLoading] = useState(true);
@@ -31,12 +34,15 @@ export const MetadataProvider = ({
// init // init
useEffect(() => { useEffect(() => {
setLoading(true);
// init context // init context
const context = new Context(); const context = new Context();
window.sfMetadataContext = context; window.sfMetadataContext = context;
window.sfMetadataContext.init({ otherSettings: params }); window.sfMetadataContext.init({ otherSettings: params });
const repoId = window.sfMetadataContext.getSetting('repoID'); window.sfMetadataContext.setSetting('viewID', viewID);
storeRef.current = new Store({ context: window.sfMetadataContext, repoId }); 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; window.sfMetadataStore = storeRef.current;
storeRef.current.initStartIndex(); storeRef.current.initStartIndex();
storeRef.current.loadData(PER_LOAD_NUMBER).then(() => { storeRef.current.loadData(PER_LOAD_NUMBER).then(() => {
@@ -54,13 +60,14 @@ export const MetadataProvider = ({
return () => { return () => {
window.sfMetadataContext.destroy(); window.sfMetadataContext.destroy();
window.sfMetadataStore.destroy();
unsubscribeServerTableChanged(); unsubscribeServerTableChanged();
unsubscribeTableChanged(); unsubscribeTableChanged();
unsubscribeHandleTableError(); unsubscribeHandleTableError();
unsubscribeUpdateRows(); unsubscribeUpdateRows();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [repoID, viewID, currentRepoInfo]);
return ( return (
<MetadataContext.Provider value={{ isLoading, metadata, store: storeRef.current }}> <MetadataContext.Provider value={{ isLoading, metadata, store: storeRef.current }}>

View File

@@ -3,7 +3,9 @@ import {
getRowById, getRowById,
getRowsByIds, getRowsByIds,
} from '../_basic'; } 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 { EVENT_BUS_TYPE, PER_LOAD_NUMBER } from '../constants';
import DataProcessor from './data-processor'; import DataProcessor from './data-processor';
import ServerOperator from './server-operator'; import ServerOperator from './server-operator';
@@ -14,6 +16,7 @@ class Store {
constructor(props) { constructor(props) {
this.repoId = props.repoId; this.repoId = props.repoId;
this.viewId = props.viewId;
this.data = null; this.data = null;
this.context = props.context; this.context = props.context;
this.startIndex = 0; this.startIndex = 0;
@@ -21,26 +24,30 @@ class Store {
this.undos = []; this.undos = [];
this.pendingOperations = []; this.pendingOperations = [];
this.isSendingOperation = false; this.isSendingOperation = false;
this.isTableReadonly = false; this.isReadonly = false;
this.serverOperator = new ServerOperator(); this.serverOperator = new ServerOperator();
this.collaborators = []; this.collaborators = [];
} }
destroy = () => {
this.viewId = '';
this.data = null;
this.startIndex = 0;
this.redos = [];
this.undos = [];
this.pendingOperations = [];
this.isSendingOperation = false;
};
initStartIndex = () => { initStartIndex = () => {
this.startIndex = 0; 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) { async loadData(limit = PER_LOAD_NUMBER) {
const res = await this.context.getMetadata({ start: this.startIndex, limit }); const res = await this.context.getMetadata({ start: this.startIndex, limit });
const view = this.context.localStorage.getItem('view');
const rows = res?.data?.results || []; 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); const columns = normalizeColumns(res?.data?.metadata);
let data = new Metadata({ rows, columns, view }); let data = new Metadata({ rows, columns, view });
data.view.rows = data.row_ids; data.view.rows = data.row_ids;
@@ -144,6 +151,10 @@ class Store {
this.syncOperationOnData(operation); 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(); operation.success_callback && operation.success_callback();
this.context.eventBus.dispatch(EVENT_BUS_TYPE.SERVER_TABLE_CHANGED); this.context.eventBus.dispatch(EVENT_BUS_TYPE.SERVER_TABLE_CHANGED);
@@ -178,7 +189,7 @@ class Store {
} }
undoOperation() { undoOperation() {
if (this.isTableReadonly || this.undos.length === 0) return; if (this.isReadonly || this.undos.length === 0) return;
const lastOperation = this.undos.pop(); const lastOperation = this.undos.pop();
const lastInvertOperation = lastOperation.invert(); const lastInvertOperation = lastOperation.invert();
if (NEED_APPLY_AFTER_SERVER_OPERATION.includes(lastInvertOperation.op_type)) { if (NEED_APPLY_AFTER_SERVER_OPERATION.includes(lastInvertOperation.op_type)) {
@@ -195,7 +206,7 @@ class Store {
} }
redoOperation() { redoOperation() {
if (this.isTableReadonly || this.redos.length === 0) return; if (this.isReadonly || this.redos.length === 0) return;
let lastOperation = this.redos.pop(); let lastOperation = this.redos.pop();
if (NEED_APPLY_AFTER_SERVER_OPERATION.includes(lastOperation.op_type)) { if (NEED_APPLY_AFTER_SERVER_OPERATION.includes(lastOperation.op_type)) {
this.applyOperation(lastOperation, { handleUndo: false, asyncUndoRedo: (operation) => { this.applyOperation(lastOperation, { handleUndo: false, asyncUndoRedo: (operation) => {
@@ -325,37 +336,33 @@ class Store {
modifyFilters(filterConjunction, filters) { modifyFilters(filterConjunction, filters) {
const type = OPERATION_TYPE.MODIFY_FILTERS; const type = OPERATION_TYPE.MODIFY_FILTERS;
const operation = this.createOperation({ 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.applyOperation(operation);
this.saveView();
} }
modifySorts(sorts) { modifySorts(sorts) {
const type = OPERATION_TYPE.MODIFY_SORTS; const type = OPERATION_TYPE.MODIFY_SORTS;
const operation = this.createOperation({ const operation = this.createOperation({
type, sorts, type, sorts, repo_id: this.repoId, view_id: this.viewId
}); });
this.applyOperation(operation); this.applyOperation(operation);
this.saveView();
} }
modifyGroupbys(groupbys) { modifyGroupbys(groupbys) {
const type = OPERATION_TYPE.MODIFY_GROUPBYS; const type = OPERATION_TYPE.MODIFY_GROUPBYS;
const operation = this.createOperation({ const operation = this.createOperation({
type, groupbys, type, groupbys, repo_id: this.repoId, view_id: this.viewId
}); });
this.applyOperation(operation); this.applyOperation(operation);
this.saveView();
} }
modifyHiddenColumns(shown_column_keys) { modifyHiddenColumns(shown_column_keys) {
const type = OPERATION_TYPE.MODIFY_HIDDEN_COLUMNS; const type = OPERATION_TYPE.MODIFY_HIDDEN_COLUMNS;
const operation = this.createOperation({ const operation = this.createOperation({
type, shown_column_keys type, shown_column_keys, repo_id: this.repoId, view_id: this.viewId
}); });
this.applyOperation(operation); this.applyOperation(operation);
this.saveView();
} }
insertColumn = (name, type, { key, data }) => { insertColumn = (name, type, { key, data }) => {

View File

@@ -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.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.RESTORE_RECORDS]: ['repo_id', 'rows_data', 'original_rows', 'link_infos', 'upper_row_ids'],
[OPERATION_TYPE.RELOAD_RECORDS]: ['repo_id', 'row_ids'], [OPERATION_TYPE.RELOAD_RECORDS]: ['repo_id', 'row_ids'],
[OPERATION_TYPE.MODIFY_FILTERS]: ['filter_conjunction', 'filters'], [OPERATION_TYPE.MODIFY_FILTERS]: ['repo_id', 'view_id', 'filter_conjunction', 'filters'],
[OPERATION_TYPE.MODIFY_SORTS]: ['sorts'], [OPERATION_TYPE.MODIFY_SORTS]: ['repo_id', 'view_id', 'sorts'],
[OPERATION_TYPE.MODIFY_GROUPBYS]: ['groupbys'], [OPERATION_TYPE.MODIFY_GROUPBYS]: ['repo_id', 'view_id', 'groupbys'],
[OPERATION_TYPE.MODIFY_HIDDEN_COLUMNS]: ['shown_column_keys'], [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.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.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'], [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 // only apply operation on the local
export const LOCAL_APPLY_OPERATION_TYPE = [ 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 // 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.INSERT_COLUMN,
OPERATION_TYPE.MODIFY_RECORD, OPERATION_TYPE.MODIFY_RECORD,
OPERATION_TYPE.MODIFY_RECORDS, 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,
]; ];

View File

@@ -8,6 +8,7 @@ export {
UNDO_OPERATION_TYPE, UNDO_OPERATION_TYPE,
LOCAL_APPLY_OPERATION_TYPE, LOCAL_APPLY_OPERATION_TYPE,
NEED_APPLY_AFTER_SERVER_OPERATION, NEED_APPLY_AFTER_SERVER_OPERATION,
VIEW_OPERATION,
} from './constants'; } from './constants';
export { export {

View File

@@ -78,7 +78,42 @@ class ServerOperator {
}); });
break; 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: { default: {
break; break;
} }

View File

@@ -39,6 +39,7 @@ const propTypes = {
isFileLoading: PropTypes.bool.isRequired, isFileLoading: PropTypes.bool.isRequired,
filePermission: PropTypes.string, filePermission: PropTypes.string,
content: PropTypes.string, content: PropTypes.string,
metadataViewId: PropTypes.string,
lastModified: PropTypes.string, lastModified: PropTypes.string,
latestContributor: PropTypes.string, latestContributor: PropTypes.string,
onLinkClick: PropTypes.func.isRequired, onLinkClick: PropTypes.func.isRequired,
@@ -220,6 +221,7 @@ class LibContentContainer extends React.Component {
filePermission={this.props.filePermission} filePermission={this.props.filePermission}
onFileTagChanged={this.props.onToolbarFileTagChanged} onFileTagChanged={this.props.onToolbarFileTagChanged}
repoTags={this.props.repoTags} repoTags={this.props.repoTags}
metadataViewId={this.props.metadataViewId}
/> />
<ToolbarForSelectedDirents <ToolbarForSelectedDirents
repoID={this.props.repoID} repoID={this.props.repoID}
@@ -275,6 +277,7 @@ class LibContentContainer extends React.Component {
hash={this.props.hash} hash={this.props.hash}
filePermission={this.props.filePermission} filePermission={this.props.filePermission}
content={this.props.content} content={this.props.content}
metadataViewId={this.props.metadataViewId}
lastModified={this.props.lastModified} lastModified={this.props.lastModified}
latestContributor={this.props.latestContributor} latestContributor={this.props.latestContributor}
onLinkClick={this.props.onLinkClick} onLinkClick={this.props.onLinkClick}

View File

@@ -85,6 +85,7 @@ class LibContentView extends React.Component {
asyncOperationType: 'move', asyncOperationType: 'move',
asyncOperationProgress: 0, asyncOperationProgress: 0,
asyncOperatedFilesLength: 0, asyncOperatedFilesLength: 0,
metadataViewId: '0000',
}; };
this.oldonpopstate = window.onpopstate; this.oldonpopstate = window.onpopstate;
@@ -490,9 +491,10 @@ class LibContentView extends React.Component {
window.history.pushState({ url: url, path: filePath }, filePath, url); window.history.pushState({ url: url, path: filePath }, filePath, url);
}; };
showFileMetadata = (filePath) => { showFileMetadata = (filePath, viewId) => {
if (this.state.metadataViewId === viewId) return;
const repoID = this.props.repoID; 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 repoInfo = this.state.currentRepoInfo;
const url = siteRoot + 'library/' + repoID + '/' + encodeURIComponent(repoInfo.repo_name); const url = siteRoot + 'library/' + repoID + '/' + encodeURIComponent(repoInfo.repo_name);
window.history.pushState({ url: url, path: '' }, '', url); window.history.pushState({ url: url, path: '' }, '', url);
@@ -1718,7 +1720,7 @@ class LibContentView extends React.Component {
} }
} else if (Utils.isFileMetadata(node?.object?.type)) { } else if (Utils.isFileMetadata(node?.object?.type)) {
if (node.path !== this.state.path) { if (node.path !== this.state.path) {
this.showFileMetadata(node.path); this.showFileMetadata(node.path, node.view_id || '0000');
} }
} else { } else {
let url = siteRoot + 'lib/' + repoID + '/file' + Utils.encodePath(node.path); let url = siteRoot + 'lib/' + repoID + '/file' + Utils.encodePath(node.path);
@@ -2084,6 +2086,7 @@ class LibContentView extends React.Component {
isFileLoadedErr={this.state.isFileLoadedErr} isFileLoadedErr={this.state.isFileLoadedErr}
filePermission={this.state.filePermission} filePermission={this.state.filePermission}
content={this.state.content} content={this.state.content}
metadataViewId={this.state.metadataViewId}
lastModified={this.state.lastModified} lastModified={this.state.lastModified}
latestContributor={this.state.latestContributor} latestContributor={this.state.latestContributor}
onLinkClick={this.onLinkClick} onLinkClick={this.onLinkClick}

View File

@@ -7,7 +7,7 @@ from rest_framework.views import APIView
from seahub.api2.utils import api_error, to_python_boolean from seahub.api2.utils import api_error, to_python_boolean
from seahub.api2.throttling import UserRateThrottle from seahub.api2.throttling import UserRateThrottle
from seahub.api2.authentication import TokenAuthentication 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.views import check_folder_permission
from seahub.repo_metadata.utils import add_init_metadata_task, gen_unique_id 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 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}.' error_msg = f'The metadata module is enabled for repo {repo_id}.'
return api_error(status.HTTP_409_CONFLICT, error_msg) return api_error(status.HTTP_409_CONFLICT, error_msg)
# recource check # resource check
repo = seafile_api.get_repo(repo_id) repo = seafile_api.get_repo(repo_id)
if not repo: if not repo:
error_msg = 'Library %s not found.' % repo_id error_msg = 'Library %s not found.' % repo_id
@@ -87,6 +87,9 @@ class MetadataManage(APIView):
try: try:
task_id = add_init_metadata_task(params=params) 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: except Exception as e:
logger.error(e) logger.error(e)
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error') return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
@@ -131,6 +134,7 @@ class MetadataManage(APIView):
try: try:
record.enabled = False record.enabled = False
record.save() record.save()
RepoMetadataViews.objects.filter(repo_id=repo_id).delete()
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
error_msg = 'Internal Server Error' error_msg = 'Internal Server Error'
@@ -443,3 +447,243 @@ class MetadataColumns(APIView):
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
return Response({'column': column}) 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']})

View File

@@ -1,8 +1,32 @@
import logging import logging
import json
import random
import string
from django.db import models from django.db import models
from seahub.utils import get_no_duplicate_obj_name
logger = logging.getLogger(__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): class RepoMetadata(models.Model):
repo_id = models.CharField(max_length=36, unique=True) repo_id = models.CharField(max_length=36, unique=True)
@@ -12,3 +36,143 @@ class RepoMetadata(models.Model):
class Meta: class Meta:
db_table = 'repo_metadata' 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]

View File

@@ -207,7 +207,8 @@ from seahub.ai.apis import LibrarySdocIndexes, Search, LibrarySdocIndex, TaskSta
from seahub.wiki2.views import wiki_view from seahub.wiki2.views import wiki_view
from seahub.api2.endpoints.wiki2 import Wikis2View, Wiki2View, Wiki2ConfigView, Wiki2PagesView, Wiki2PageView, Wiki2DuplicatePageView 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.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 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<repo_id>[-0-9a-f]{36})/metadata/records/$', MetadataRecords.as_view(), name='api-v2.1-metadata-records'), re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/metadata/records/$', MetadataRecords.as_view(), name='api-v2.1-metadata-records'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/metadata/record/$', MetadataRecordInfo.as_view(), name='api-v2.1-metadata-record-info'), re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/metadata/record/$', MetadataRecordInfo.as_view(), name='api-v2.1-metadata-record-info'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/metadata/columns/$', MetadataColumns.as_view(), name='api-v2.1-metadata-columns'), re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/metadata/columns/$', MetadataColumns.as_view(), name='api-v2.1-metadata-columns'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/metadata/views/$', MetadataViews.as_view(), name='api-v2.1-metadata-views'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/metadata/views/(?P<view_id>[-0-9a-zA-Z]{4})/$', MetadataViewsDetailView.as_view(), name='api-v2.1-metadata-views-detail'),
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/metadata/move-views/$', MetadataViewsMoveView.as_view(), name='api-v2.1-metadata-views-move'),
] ]