1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-02 15:38:15 +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,8 +62,7 @@ class DirPath extends React.Component {
if (item === '') {
return null;
}
if (index === (pathList.length - 1)) {
if (item === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES) {
if (index === pathList.length - 2 && item === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES) {
return (
<Fragment key={index}>
<span className="path-split">/</span>
@@ -72,6 +71,16 @@ class DirPath extends React.Component {
);
}
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 (
<Fragment key={index}>
<span className="path-split">/</span>
@@ -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() {

View File

@@ -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 (
<div className="d-flex">
<MetadataViewToolBar />
<MetadataViewToolBar metadataViewId={metadataViewId} />
</div>
);
}

View File

@@ -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 &&
<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,
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 (<SeafileMetadata repoID={this.props.repoID} currentRepoInfo={this.props.currentRepoInfo} />);
return (<SeafileMetadata repoID={repoID} currentRepoInfo={currentRepoInfo} viewID={metadataViewId} />);
}
return (

View File

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

View File

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

View File

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

View File

@@ -117,6 +117,43 @@ class MetadataManagerAPI {
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();

View File

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

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 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 (
<>
<div className="tree-view tree metadata-tree-view">
<div className="tree-node">
<div className="children">
<div
className={classnames('tree-node-inner text-nowrap', { 'tree-node-inner-hover': highlight, 'tree-node-hight-light': currentPath === node.path })}
title={gettext('File extended properties')}
onMouseEnter={onMouseEnter}
onMouseOver={onMouseOver}
onMouseLeave={onMouseLeave}
onClick={() => onNodeClick(node)}
>
<div className="tree-node-text">{gettext('File extended properties')}</div>
<div className="left-icon">
<div className="tree-node-icon">
<Icon symbol="table" className="metadata-views-icon" />
</div>
</div>
</div>
{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 (
<ViewItem
key={view._id}
canDelete={index !== 0}
isSelected={isSelected}
userPerm={userPerm}
view={view}
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>
{showAddViewDialog && (<NameDialog title={gettext('Add view')} onSubmit={addView} onToggle={closeAddView} />)}
</>
);
};
MetadataTreeView.propTypes = {
userPerm: PropTypes.string,
repoID: PropTypes.string.isRequired,
currentPath: PropTypes.string,
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';
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;

View File

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

View File

@@ -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 (
<MetadataContext.Provider value={{ isLoading, metadata, store: storeRef.current }}>

View File

@@ -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 }) => {

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.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,
];

View File

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

View File

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

View File

@@ -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}
/>
<ToolbarForSelectedDirents
repoID={this.props.repoID}
@@ -275,6 +277,7 @@ class LibContentContainer extends React.Component {
hash={this.props.hash}
filePermission={this.props.filePermission}
content={this.props.content}
metadataViewId={this.props.metadataViewId}
lastModified={this.props.lastModified}
latestContributor={this.props.latestContributor}
onLinkClick={this.props.onLinkClick}

View File

@@ -85,6 +85,7 @@ class LibContentView extends React.Component {
asyncOperationType: 'move',
asyncOperationProgress: 0,
asyncOperatedFilesLength: 0,
metadataViewId: '0000',
};
this.oldonpopstate = window.onpopstate;
@@ -490,9 +491,10 @@ class LibContentView extends React.Component {
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;
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}

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.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']})

View File

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

View File

@@ -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<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/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'),
]