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