1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-03 07:55:36 +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 === '') {
return null;
}
if (index === (pathList.length - 1)) {
if (item === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES) {
return (
<Fragment key={index}>
<span className="path-split">/</span>
<span className="path-item">{gettext('File extended properties')}</span>
</Fragment>
);
}
if (index === pathList.length - 2 && item === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES) {
return (
<Fragment key={index}>
<span className="path-split">/</span>
<span className="path-item">{gettext('File extended properties')}</span>
</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 (
<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 className="tree-view tree metadata-tree-view">
<div className="tree-node">
<div className="children">
{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>
</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}