mirror of
https://github.com/haiwen/seahub.git
synced 2025-09-13 13:50:07 +00:00
feat(metadata-views): support add folder (#7175)
This commit is contained in:
@@ -2,13 +2,16 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from 'rea
|
||||
import metadataAPI from '../api';
|
||||
import { Utils } from '../../utils/utils';
|
||||
import toaster from '../../components/toast';
|
||||
import Folder from '../model/metadata/folder';
|
||||
import { gettext } from '../../utils/constants';
|
||||
import { PRIVATE_FILE_TYPE } from '../../constants';
|
||||
import { FACE_RECOGNITION_VIEW_ID, VIEW_TYPE } from '../constants';
|
||||
import { FACE_RECOGNITION_VIEW_ID, VIEW_TYPE, VIEWS_TYPE_FOLDER, VIEWS_TYPE_VIEW } from '../constants';
|
||||
import { useMetadataStatus } from '../../hooks';
|
||||
import { updateFavicon } from '../utils/favicon';
|
||||
import { getViewName } from '../utils/view';
|
||||
|
||||
const CACHED_COLLAPSED_FOLDERS_PREFIX = 'sf-metadata-collapsed-folders';
|
||||
|
||||
// This hook provides content related to seahub interaction, such as whether to enable extended attributes, views data, etc.
|
||||
const MetadataContext = React.createContext(null);
|
||||
|
||||
@@ -16,13 +19,35 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
const [enableFaceRecognition, setEnableFaceRecognition] = useState(false);
|
||||
const [navigation, setNavigation] = useState([]);
|
||||
const [, setCount] = useState(0);
|
||||
const [idViewMap, setIdViewMap] = useState({});
|
||||
|
||||
const viewsMap = useRef({});
|
||||
const collapsedFoldersIds = useRef([]);
|
||||
const originalTitleRef = useRef(document.title);
|
||||
|
||||
const { enableMetadata, isBeingBuilt, setIsBeingBuilt } = useMetadataStatus();
|
||||
|
||||
const getCollapsedFolders = useCallback(() => {
|
||||
const strFoldedFolders = window.localStorage.getItem(`${CACHED_COLLAPSED_FOLDERS_PREFIX}-${repoID}`);
|
||||
const foldedFolders = strFoldedFolders && JSON.parse(strFoldedFolders);
|
||||
return Array.isArray(foldedFolders) ? foldedFolders : [];
|
||||
}, [repoID]);
|
||||
|
||||
const setCollapsedFolders = useCallback((collapsedFoldersIds) => {
|
||||
window.localStorage.setItem(`${CACHED_COLLAPSED_FOLDERS_PREFIX}-${repoID}`, JSON.stringify(collapsedFoldersIds));
|
||||
}, [repoID]);
|
||||
|
||||
const addViewIntoMap = useCallback((viewId, view) => {
|
||||
let updatedIdViewInMap = { ...idViewMap };
|
||||
updatedIdViewInMap[viewId] = view;
|
||||
setIdViewMap(updatedIdViewInMap);
|
||||
}, [idViewMap]);
|
||||
|
||||
const deleteViewFromMap = useCallback((viewId) => {
|
||||
let updatedIdViewInMap = { ...idViewMap };
|
||||
delete updatedIdViewInMap[viewId];
|
||||
setIdViewMap(updatedIdViewInMap);
|
||||
}, [idViewMap]);
|
||||
|
||||
// views
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@@ -30,9 +55,11 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi
|
||||
metadataAPI.listViews(repoID).then(res => {
|
||||
const { navigation, views } = res.data;
|
||||
if (Array.isArray(views)) {
|
||||
let idViewMap = {};
|
||||
views.forEach(view => {
|
||||
viewsMap.current[view._id] = { ...view, name: getViewName(view) };
|
||||
idViewMap[view._id] = { ...view, name: getViewName(view) };
|
||||
});
|
||||
setIdViewMap(idViewMap);
|
||||
}
|
||||
setNavigation(navigation);
|
||||
setLoading(false);
|
||||
@@ -45,7 +72,7 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi
|
||||
}
|
||||
hideMetadataView && hideMetadataView();
|
||||
setEnableFaceRecognition(false);
|
||||
viewsMap.current = {};
|
||||
setIdViewMap({});
|
||||
setNavigation([]);
|
||||
setLoading(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -64,6 +91,15 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi
|
||||
});
|
||||
}, [repoID, enableMetadata]);
|
||||
|
||||
const getFirstView = useCallback(() => {
|
||||
const firstViewNav = navigation.find(item => item.type === VIEWS_TYPE_VIEW);
|
||||
const firstView = firstViewNav ? idViewMap[firstViewNav._id] : null;
|
||||
if (!firstView && Object.keys(idViewMap).length > 0) {
|
||||
return idViewMap[Object.keys(idViewMap)[0]];
|
||||
}
|
||||
return firstView;
|
||||
}, [navigation, idViewMap]);
|
||||
|
||||
const selectView = useCallback((view, isSelected) => {
|
||||
if (isSelected) return;
|
||||
const node = {
|
||||
@@ -87,95 +123,284 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [repoID, selectMetadataView]);
|
||||
|
||||
const addView = useCallback((name, type, successCallback, failCallback) => {
|
||||
metadataAPI.addView(repoID, name, type).then(res => {
|
||||
const view = res.data.view;
|
||||
let newNavigation = navigation.slice(0);
|
||||
newNavigation.push({ _id: view._id, type: 'view' });
|
||||
viewsMap.current[view._id] = { ...view, name: getViewName(view) };
|
||||
useEffect(() => {
|
||||
collapsedFoldersIds.current = getCollapsedFolders();
|
||||
}, [getCollapsedFolders]);
|
||||
|
||||
const collapseFolder = useCallback((folderId) => {
|
||||
let updatedCollapsedFoldersIds = getCollapsedFolders();
|
||||
if (updatedCollapsedFoldersIds.includes(folderId)) {
|
||||
return;
|
||||
}
|
||||
updatedCollapsedFoldersIds.push(folderId);
|
||||
setCollapsedFolders(updatedCollapsedFoldersIds);
|
||||
}, [getCollapsedFolders, setCollapsedFolders]);
|
||||
|
||||
const expandFolder = useCallback((folderId) => {
|
||||
let updatedCollapsedFoldersIds = getCollapsedFolders();
|
||||
if (!updatedCollapsedFoldersIds.includes(folderId)) {
|
||||
return;
|
||||
}
|
||||
updatedCollapsedFoldersIds = updatedCollapsedFoldersIds.filter((collapsedFolderId) => collapsedFolderId !== folderId);
|
||||
setCollapsedFolders(updatedCollapsedFoldersIds);
|
||||
}, [getCollapsedFolders, setCollapsedFolders]);
|
||||
|
||||
const addFolder = useCallback((name, successCallback, failCallback) => {
|
||||
metadataAPI.addFolder(repoID, name).then(res => {
|
||||
let newNavigation = [...navigation];
|
||||
const folder = new Folder(res.data.folder);
|
||||
newNavigation.push(folder);
|
||||
setNavigation(newNavigation);
|
||||
selectView(view);
|
||||
successCallback && successCallback();
|
||||
}).catch(error => {
|
||||
failCallback && failCallback(error);
|
||||
});
|
||||
}, [navigation, repoID, viewsMap, selectView]);
|
||||
}, [repoID, navigation]);
|
||||
|
||||
const duplicateView = useCallback((viewId) => {
|
||||
metadataAPI.duplicateView(repoID, viewId).then(res => {
|
||||
const view = res.data.view;
|
||||
let newNavigation = navigation.slice(0);
|
||||
newNavigation.push({ _id: view._id, type: 'view' });
|
||||
viewsMap.current[view._id] = view;
|
||||
const modifyFolder = useCallback((folderId, updates, successCallback, failCallback) => {
|
||||
metadataAPI.modifyFolder(repoID, folderId, updates).then(res => {
|
||||
let newNavigation = [...navigation];
|
||||
let folderIndex = newNavigation.findIndex((nav) => nav._id === folderId && nav.type === VIEWS_TYPE_FOLDER);
|
||||
if (folderIndex < 0) {
|
||||
return;
|
||||
}
|
||||
const validUpdates = { ...updates };
|
||||
delete validUpdates._id;
|
||||
delete validUpdates.type;
|
||||
delete validUpdates.children;
|
||||
let updatedFolder = newNavigation[folderIndex];
|
||||
updatedFolder = Object.assign({}, updatedFolder, validUpdates);
|
||||
newNavigation[folderIndex] = updatedFolder;
|
||||
setNavigation(newNavigation);
|
||||
selectView(view);
|
||||
successCallback && successCallback();
|
||||
}).catch(error => {
|
||||
failCallback && failCallback(error);
|
||||
});
|
||||
}, [repoID, navigation]);
|
||||
|
||||
const deleteFolder = useCallback((folderId) => {
|
||||
metadataAPI.deleteFolder(repoID, folderId).then(res => {
|
||||
let newNavigation = [...navigation];
|
||||
let folderIndex = newNavigation.findIndex((nav) => nav._id === folderId && nav.type === VIEWS_TYPE_FOLDER);
|
||||
if (folderIndex < 0) {
|
||||
return;
|
||||
}
|
||||
const viewsInFolder = newNavigation[folderIndex].children;
|
||||
newNavigation.splice(folderIndex, 1);
|
||||
if (viewsInFolder.length > 0) {
|
||||
newNavigation.push(...viewsInFolder);
|
||||
}
|
||||
setNavigation(newNavigation);
|
||||
}).catch((error => {
|
||||
const errorMsg = Utils.getErrorMsg(error);
|
||||
toaster.danger(errorMsg);
|
||||
}));
|
||||
}, [repoID, navigation]);
|
||||
|
||||
const addViewCallback = useCallback((view, folderId) => {
|
||||
const newViewNav = { _id: view._id, type: VIEWS_TYPE_VIEW };
|
||||
let newNavigation = [...navigation];
|
||||
if (folderId) {
|
||||
// add view into folder
|
||||
const folderIndex = newNavigation.findIndex((nav) => nav._id === folderId && nav.type === VIEWS_TYPE_FOLDER);
|
||||
if (folderIndex < 0) {
|
||||
return;
|
||||
}
|
||||
let updatedFolder = newNavigation[folderIndex];
|
||||
updatedFolder.children = Array.isArray(updatedFolder.children) ? updatedFolder.children : [];
|
||||
updatedFolder.children.push(newViewNav);
|
||||
} else {
|
||||
newNavigation.push(newViewNav);
|
||||
}
|
||||
const newView = { ...view, name: getViewName(view) };
|
||||
addViewIntoMap(newView._id, newView);
|
||||
setNavigation(newNavigation);
|
||||
selectView(newView);
|
||||
}, [navigation, addViewIntoMap, setNavigation, selectView]);
|
||||
|
||||
const addView = useCallback(({ folderId, name, type, successCallback, failCallback }) => {
|
||||
metadataAPI.addView(repoID, name, type, folderId).then(res => {
|
||||
const view = res.data.view;
|
||||
addViewCallback(view, folderId);
|
||||
successCallback && successCallback();
|
||||
}).catch(error => {
|
||||
failCallback && failCallback(error);
|
||||
});
|
||||
}, [repoID, addViewCallback]);
|
||||
|
||||
const duplicateView = useCallback(({ folderId, viewId }) => {
|
||||
metadataAPI.duplicateView(repoID, viewId, folderId).then(res => {
|
||||
const view = res.data.view;
|
||||
addViewCallback(view, folderId);
|
||||
}).catch(error => {
|
||||
const errorMsg = Utils.getErrorMsg(error);
|
||||
toaster.danger(errorMsg);
|
||||
});
|
||||
}, [navigation, repoID, viewsMap, selectView]);
|
||||
}, [repoID, addViewCallback]);
|
||||
|
||||
const deleteView = useCallback(({ folderId, viewId, isSelected }) => {
|
||||
metadataAPI.deleteView(repoID, viewId, folderId).then(res => {
|
||||
let newNavigation = [...navigation];
|
||||
let prevViewNav = null;
|
||||
if (folderId) {
|
||||
let folderIndex = newNavigation.findIndex((nav) => nav._id === folderId && nav.type === VIEWS_TYPE_FOLDER);
|
||||
if (folderIndex < 0) {
|
||||
return;
|
||||
}
|
||||
let updatedFolder = newNavigation[folderIndex];
|
||||
if (!Array.isArray(updatedFolder.children) || updatedFolder.children.length === 0) {
|
||||
return;
|
||||
}
|
||||
const currentViewIndex = updatedFolder.children.findIndex((viewNav) => viewNav._id === viewId);
|
||||
prevViewNav = updatedFolder.children[currentViewIndex - 1];
|
||||
updatedFolder.children = updatedFolder.children.filter(viewNav => viewNav._id !== viewId);
|
||||
} else {
|
||||
const currentViewIndex = newNavigation.findIndex(item => item._id === viewId);
|
||||
prevViewNav = newNavigation[currentViewIndex - 1];
|
||||
newNavigation = newNavigation.filter(nav => nav._id !== viewId);
|
||||
}
|
||||
|
||||
const deleteView = useCallback((viewId, isSelected) => {
|
||||
metadataAPI.deleteView(repoID, viewId).then(res => {
|
||||
const newNavigation = navigation.filter(item => item._id !== viewId);
|
||||
delete viewsMap.current[viewId];
|
||||
setNavigation(newNavigation);
|
||||
deleteViewFromMap(viewId);
|
||||
|
||||
// re-select the previous view
|
||||
if (isSelected) {
|
||||
const currentViewIndex = navigation.findIndex(item => item._id === viewId);
|
||||
const lastViewId = navigation[currentViewIndex - 1]._id;
|
||||
const lastView = viewsMap.current[lastViewId];
|
||||
selectView(lastView);
|
||||
let prevView = null;
|
||||
if (prevViewNav && prevViewNav.type === VIEWS_TYPE_VIEW) {
|
||||
prevView = idViewMap[prevViewNav._id];
|
||||
}
|
||||
if (!prevView) {
|
||||
prevView = getFirstView();
|
||||
}
|
||||
selectView(prevView);
|
||||
}
|
||||
}).catch((error => {
|
||||
const errorMsg = Utils.getErrorMsg(error);
|
||||
toaster.danger(errorMsg);
|
||||
}));
|
||||
}, [repoID, navigation, selectView, viewsMap]);
|
||||
}, [repoID, navigation, idViewMap, deleteViewFromMap, getFirstView, selectView]);
|
||||
|
||||
const updateView = useCallback((viewId, update, successCallback, failCallback) => {
|
||||
metadataAPI.modifyView(repoID, viewId, update).then(res => {
|
||||
const currentView = viewsMap.current[viewId];
|
||||
viewsMap.current[viewId] = { ...currentView, ...update };
|
||||
setCount(n => n + 1);
|
||||
const currentView = idViewMap[viewId];
|
||||
addViewIntoMap(viewId, { ...currentView, ...update });
|
||||
successCallback && successCallback();
|
||||
}).catch(error => {
|
||||
failCallback && failCallback(error);
|
||||
});
|
||||
}, [repoID, viewsMap]);
|
||||
}, [repoID, idViewMap, addViewIntoMap]);
|
||||
|
||||
const moveView = useCallback((sourceViewId, targetViewId) => {
|
||||
metadataAPI.moveView(repoID, sourceViewId, targetViewId).then(res => {
|
||||
const { navigation } = res.data;
|
||||
setNavigation(navigation);
|
||||
const moveView = useCallback(({ sourceViewId, sourceFolderId, targetViewId, targetFolderId }) => {
|
||||
if (
|
||||
(!sourceViewId && !sourceFolderId) // must drag view or folder
|
||||
|| (!targetViewId && !targetFolderId) // must move above to view/folder or move view into folder
|
||||
|| (sourceViewId === targetViewId && sourceFolderId === targetFolderId) // not changed
|
||||
|| (!sourceViewId && sourceFolderId && targetViewId && targetFolderId) // not allowed to drag folder into folder
|
||||
) {
|
||||
return;
|
||||
}
|
||||
metadataAPI.moveView(repoID, sourceViewId, sourceFolderId, targetViewId, targetFolderId).then(res => {
|
||||
let newNavigation = [...navigation];
|
||||
|
||||
// remove folder/view from old position
|
||||
let updatedSourceNavList = null;
|
||||
let sourceId = null;
|
||||
if (sourceFolderId) {
|
||||
if (sourceViewId) {
|
||||
// drag view from folder
|
||||
const sourceFolder = newNavigation.find((folder) => folder._id === sourceFolderId);
|
||||
updatedSourceNavList = sourceFolder && sourceFolder.children;
|
||||
sourceId = sourceViewId;
|
||||
} else {
|
||||
// drag folder
|
||||
updatedSourceNavList = newNavigation;
|
||||
sourceId = sourceFolderId;
|
||||
}
|
||||
} else if (sourceViewId) {
|
||||
// drag view outer of folders
|
||||
updatedSourceNavList = newNavigation;
|
||||
sourceId = sourceViewId;
|
||||
}
|
||||
|
||||
// invalid drag source
|
||||
if (!Array.isArray(updatedSourceNavList) || updatedSourceNavList.length === 0 || !sourceId) {
|
||||
return;
|
||||
}
|
||||
const movedNavIndex = updatedSourceNavList.findIndex((nav) => nav._id === sourceId);
|
||||
if (movedNavIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movedNav = updatedSourceNavList[movedNavIndex];
|
||||
updatedSourceNavList.splice(movedNavIndex, 1);
|
||||
|
||||
// insert folder/view into new position
|
||||
let updatedTargetNavList = newNavigation;
|
||||
if (targetFolderId && sourceViewId) {
|
||||
// move view into folder
|
||||
let targetFolder = newNavigation.find((folder) => folder._id === targetFolderId);
|
||||
if (!Array.isArray(targetFolder.children)) {
|
||||
targetFolder.children = [];
|
||||
}
|
||||
updatedTargetNavList = targetFolder.children;
|
||||
}
|
||||
|
||||
let targetNavIndex = -1;
|
||||
if (targetViewId) {
|
||||
// move folder/view above to view
|
||||
targetNavIndex = updatedTargetNavList.findIndex((nav) => nav._id === targetViewId);
|
||||
} else if (!sourceViewId && targetFolderId) {
|
||||
// move folder above to folder
|
||||
targetNavIndex = updatedTargetNavList.findIndex((nav) => nav._id === targetFolderId);
|
||||
}
|
||||
|
||||
if (targetNavIndex > -1) {
|
||||
updatedTargetNavList.splice(targetNavIndex, 0, movedNav); // move above to the target folder/view
|
||||
} else {
|
||||
updatedTargetNavList.push(movedNav); // move into navigation or folder
|
||||
}
|
||||
|
||||
setNavigation(newNavigation);
|
||||
}).catch(error => {
|
||||
const errorMsg = Utils.getErrorMsg(error);
|
||||
toaster.danger(errorMsg);
|
||||
});
|
||||
}, [repoID]);
|
||||
}, [repoID, navigation]);
|
||||
|
||||
const updateEnableFaceRecognition = useCallback((newValue) => {
|
||||
if (newValue === enableFaceRecognition) return;
|
||||
if (newValue) {
|
||||
toaster.success(gettext('Recognizing portraits. Please refresh the page later.'));
|
||||
addView('_people', VIEW_TYPE.FACE_RECOGNITION, () => {}, () => {});
|
||||
addView({ name: '_people', type: VIEW_TYPE.FACE_RECOGNITION });
|
||||
} else {
|
||||
if (viewsMap.current[FACE_RECOGNITION_VIEW_ID]) {
|
||||
if (idViewMap[FACE_RECOGNITION_VIEW_ID]) {
|
||||
let isSelected = false;
|
||||
if (currentPath.includes('/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/')) {
|
||||
const currentViewId = currentPath.split('/').pop();
|
||||
isSelected = currentViewId === FACE_RECOGNITION_VIEW_ID;
|
||||
}
|
||||
deleteView(FACE_RECOGNITION_VIEW_ID, isSelected);
|
||||
const folders = navigation.filter((nav) => nav.type === VIEWS_TYPE_FOLDER);
|
||||
const targetFolder = folders.find((folder) => {
|
||||
const { children } = folder;
|
||||
if (Array.isArray(children) && children.length > 0) {
|
||||
const view = children.find((viewNav) => viewNav._id === FACE_RECOGNITION_VIEW_ID);
|
||||
if (view) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const folderId = targetFolder ? targetFolder._id : null;
|
||||
deleteView({ folderId, viewId: FACE_RECOGNITION_VIEW_ID, isSelected });
|
||||
}
|
||||
}
|
||||
setEnableFaceRecognition(newValue);
|
||||
}, [enableFaceRecognition, currentPath, addView, deleteView]);
|
||||
}, [enableFaceRecognition, currentPath, idViewMap, navigation, addView, deleteView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (isBeingBuilt) {
|
||||
const firstViewObject = navigation.find(item => item.type === 'view');
|
||||
const firstView = firstViewObject ? viewsMap.current[firstViewObject._id] : '';
|
||||
const firstView = getFirstView();
|
||||
if (firstView) {
|
||||
selectView(firstView);
|
||||
}
|
||||
@@ -186,7 +411,7 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi
|
||||
if (!urlParams.has('view')) return;
|
||||
const viewID = urlParams.get('view');
|
||||
if (viewID) {
|
||||
const lastOpenedView = viewsMap.current[viewID] || '';
|
||||
const lastOpenedView = idViewMap[viewID] || '';
|
||||
if (lastOpenedView) {
|
||||
selectView(lastOpenedView);
|
||||
return;
|
||||
@@ -195,8 +420,7 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi
|
||||
window.history.pushState({ url: url, path: '' }, '', url);
|
||||
}
|
||||
|
||||
const firstViewObject = navigation.find(item => item.type === 'view');
|
||||
const firstView = firstViewObject ? viewsMap.current[firstViewObject._id] : '';
|
||||
const firstView = getFirstView();
|
||||
if (firstView) {
|
||||
selectView(firstView);
|
||||
}
|
||||
@@ -206,7 +430,7 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi
|
||||
useEffect(() => {
|
||||
if (!currentPath.includes('/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES + '/')) return;
|
||||
const currentViewId = currentPath.split('/').pop();
|
||||
const currentView = viewsMap.current[currentViewId];
|
||||
const currentView = idViewMap[currentViewId];
|
||||
if (currentView) {
|
||||
document.title = `${currentView.name} - Seafile`;
|
||||
updateFavicon(currentView.type);
|
||||
@@ -214,7 +438,7 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi
|
||||
}
|
||||
document.title = originalTitleRef.current;
|
||||
updateFavicon('default');
|
||||
}, [currentPath, viewsMap]);
|
||||
}, [currentPath, idViewMap]);
|
||||
|
||||
return (
|
||||
<MetadataContext.Provider value={{
|
||||
@@ -223,7 +447,13 @@ export const MetadataProvider = ({ repoID, currentPath, repoInfo, hideMetadataVi
|
||||
isBeingBuilt,
|
||||
setIsBeingBuilt,
|
||||
navigation,
|
||||
viewsMap: viewsMap.current,
|
||||
collapsedFoldersIds: collapsedFoldersIds.current,
|
||||
idViewMap,
|
||||
collapseFolder,
|
||||
expandFolder,
|
||||
addFolder,
|
||||
modifyFolder,
|
||||
deleteFolder,
|
||||
selectView,
|
||||
addView,
|
||||
duplicateView,
|
||||
|
Reference in New Issue
Block a user