mirror of
https://github.com/IRBorisov/ConceptPortal.git
synced 2025-06-26 13:00:39 +03:00
Add FolderTree UI
This commit is contained in:
parent
e81e53e7d5
commit
9e1c08910d
|
@ -31,7 +31,10 @@ export { RiMenuFoldFill as IconMenuFold } from 'react-icons/ri';
|
|||
export { RiMenuUnfoldFill as IconMenuUnfold } from 'react-icons/ri';
|
||||
export { LuMoon as IconDarkTheme } from 'react-icons/lu';
|
||||
export { LuSun as IconLightTheme } from 'react-icons/lu';
|
||||
export { FaRegFolder as IconFolder } from 'react-icons/fa';
|
||||
export { LuFolderTree as IconFolderTree } from 'react-icons/lu';
|
||||
export { FaRegFolder as IconFolder } from 'react-icons/fa6';
|
||||
export { FaRegFolderOpen as IconFolderOpened } from 'react-icons/fa6';
|
||||
export { FaRegFolderClosed as IconFolderClosed } from 'react-icons/fa6';
|
||||
export { LuLightbulb as IconHelp } from 'react-icons/lu';
|
||||
export { LuLightbulbOff as IconHelpOff } from 'react-icons/lu';
|
||||
export { RiPushpinFill as IconPin } from 'react-icons/ri';
|
||||
|
|
|
@ -5,7 +5,7 @@ import DataTable, { createColumnHelper, IConditionalStyle } from '@/components/u
|
|||
import SearchBar from '@/components/ui/SearchBar';
|
||||
import { useLibrary } from '@/context/LibraryContext';
|
||||
import { useConceptOptions } from '@/context/OptionsContext';
|
||||
import { ILibraryItem, LibraryItemID } from '@/models/library';
|
||||
import { ILibraryItem, LibraryItemID, LibraryItemType } from '@/models/library';
|
||||
import { ILibraryFilter } from '@/models/miscellaneous';
|
||||
|
||||
import FlexColumn from '../ui/FlexColumn';
|
||||
|
@ -32,7 +32,8 @@ function PickSchema({ id, initialFilter = '', rows = 4, value, onSelectValue }:
|
|||
|
||||
useLayoutEffect(() => {
|
||||
setFilter({
|
||||
query: filterText
|
||||
query: filterText,
|
||||
type: LibraryItemType.RSFORM
|
||||
});
|
||||
}, [filterText]);
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
DataCallback,
|
||||
|
@ -14,7 +14,8 @@ import {
|
|||
postRSFormFromFile
|
||||
} from '@/app/backendAPI';
|
||||
import { ErrorData } from '@/components/info/InfoError';
|
||||
import { ILibraryItem, LibraryItemID } from '@/models/library';
|
||||
import { FolderTree } from '@/models/FolderTree';
|
||||
import { ILibraryItem, LibraryItemID, LocationHead } from '@/models/library';
|
||||
import { ILibraryCreateData } from '@/models/library';
|
||||
import { matchLibraryItem, matchLibraryItemLocation } from '@/models/libraryAPI';
|
||||
import { ILibraryFilter } from '@/models/miscellaneous';
|
||||
|
@ -28,10 +29,17 @@ import { useConceptOptions } from './OptionsContext';
|
|||
interface ILibraryContext {
|
||||
items: ILibraryItem[];
|
||||
templates: ILibraryItem[];
|
||||
folders: FolderTree;
|
||||
|
||||
loading: boolean;
|
||||
loadingError: ErrorData;
|
||||
setLoadingError: (error: ErrorData) => void;
|
||||
|
||||
processing: boolean;
|
||||
error: ErrorData;
|
||||
setError: (error: ErrorData) => void;
|
||||
processingError: ErrorData;
|
||||
setProcessingError: (error: ErrorData) => void;
|
||||
|
||||
reloadItems: (callback?: () => void) => void;
|
||||
|
||||
applyFilter: (params: ILibraryFilter) => ILibraryItem[];
|
||||
retrieveTemplate: (templateID: LibraryItemID, callback: (schema: IRSForm) => void) => void;
|
||||
|
@ -64,15 +72,31 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
|
|||
const [templates, setTemplates] = useState<ILibraryItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [error, setError] = useState<ErrorData>(undefined);
|
||||
const [loadingError, setLoadingError] = useState<ErrorData>(undefined);
|
||||
const [processingError, setProcessingError] = useState<ErrorData>(undefined);
|
||||
const [cachedTemplates, setCachedTemplates] = useState<IRSForm[]>([]);
|
||||
|
||||
const folders = useMemo(() => {
|
||||
const result = new FolderTree(items.map(item => item.location));
|
||||
result.addPath(LocationHead.USER, 0);
|
||||
result.addPath(LocationHead.COMMON, 0);
|
||||
result.addPath(LocationHead.LIBRARY, 0);
|
||||
result.addPath(LocationHead.PROJECTS, 0);
|
||||
return result;
|
||||
}, [items]);
|
||||
|
||||
const applyFilter = useCallback(
|
||||
(filter: ILibraryFilter) => {
|
||||
let result = items;
|
||||
if (filter.head) {
|
||||
if (!filter.folderMode && filter.head) {
|
||||
result = result.filter(item => item.location.startsWith(filter.head!));
|
||||
}
|
||||
if (filter.folderMode && filter.folder) {
|
||||
result = result.filter(item => item.location == filter.folder);
|
||||
}
|
||||
if (filter.type) {
|
||||
result = result.filter(item => item.item_type === filter.type);
|
||||
}
|
||||
if (filter.isVisible !== undefined) {
|
||||
result = result.filter(item => filter.isVisible === item.visible);
|
||||
}
|
||||
|
@ -85,12 +109,12 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
|
|||
if (filter.isEditor !== undefined) {
|
||||
result = result.filter(item => filter.isEditor == user?.editor.includes(item.id));
|
||||
}
|
||||
if (!filter.folderMode && filter.path) {
|
||||
result = result.filter(item => matchLibraryItemLocation(item, filter.path!));
|
||||
}
|
||||
if (filter.query) {
|
||||
result = result.filter(item => matchLibraryItem(item, filter.query!));
|
||||
}
|
||||
if (filter.path) {
|
||||
result = result.filter(item => matchLibraryItemLocation(item, filter.path!));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[items, user]
|
||||
|
@ -103,11 +127,11 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
|
|||
callback(cached);
|
||||
return;
|
||||
}
|
||||
setError(undefined);
|
||||
setProcessingError(undefined);
|
||||
getRSFormDetails(String(templateID), '', {
|
||||
showError: true,
|
||||
setLoading: setProcessing,
|
||||
onError: setError,
|
||||
onError: setProcessingError,
|
||||
onSuccess: data => {
|
||||
const schema = new RSFormLoader(data).produceRSForm();
|
||||
setCachedTemplates(prev => [...prev, schema]);
|
||||
|
@ -121,12 +145,12 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
|
|||
const reloadItems = useCallback(
|
||||
(callback?: () => void) => {
|
||||
setItems([]);
|
||||
setError(undefined);
|
||||
setLoadingError(undefined);
|
||||
if (user?.is_staff && adminMode) {
|
||||
getAdminLibrary({
|
||||
setLoading: setLoading,
|
||||
showError: true,
|
||||
onError: setError,
|
||||
onError: setLoadingError,
|
||||
onSuccess: newData => {
|
||||
setItems(newData);
|
||||
if (callback) callback();
|
||||
|
@ -136,7 +160,7 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
|
|||
getLibrary({
|
||||
setLoading: setLoading,
|
||||
showError: true,
|
||||
onError: setError,
|
||||
onError: setLoadingError,
|
||||
onSuccess: newData => {
|
||||
setItems(newData);
|
||||
if (callback) callback();
|
||||
|
@ -150,6 +174,8 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
|
|||
const reloadTemplates = useCallback(() => {
|
||||
setTemplates([]);
|
||||
getTemplates({
|
||||
setLoading: setLoading,
|
||||
onError: setLoadingError,
|
||||
showError: true,
|
||||
onSuccess: newData => setTemplates(newData)
|
||||
});
|
||||
|
@ -190,13 +216,13 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
|
|||
}
|
||||
if (callback) callback(newSchema);
|
||||
});
|
||||
setError(undefined);
|
||||
setProcessingError(undefined);
|
||||
if (data.file) {
|
||||
postRSFormFromFile({
|
||||
data: data,
|
||||
showError: true,
|
||||
setLoading: setProcessing,
|
||||
onError: setError,
|
||||
onError: setProcessingError,
|
||||
onSuccess: onSuccess
|
||||
});
|
||||
} else {
|
||||
|
@ -204,7 +230,7 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
|
|||
data: data,
|
||||
showError: true,
|
||||
setLoading: setProcessing,
|
||||
onError: setError,
|
||||
onError: setProcessingError,
|
||||
onSuccess: onSuccess
|
||||
});
|
||||
}
|
||||
|
@ -214,11 +240,11 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
|
|||
|
||||
const destroyItem = useCallback(
|
||||
(target: LibraryItemID, callback?: () => void) => {
|
||||
setError(undefined);
|
||||
setProcessingError(undefined);
|
||||
deleteLibraryItem(String(target), {
|
||||
showError: true,
|
||||
setLoading: setProcessing,
|
||||
onError: setError,
|
||||
onError: setProcessingError,
|
||||
onSuccess: () =>
|
||||
reloadItems(() => {
|
||||
if (user && user.subscriptions.includes(target)) {
|
||||
|
@ -231,7 +257,7 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
|
|||
})
|
||||
});
|
||||
},
|
||||
[setError, reloadItems, user]
|
||||
[reloadItems, user]
|
||||
);
|
||||
|
||||
const cloneItem = useCallback(
|
||||
|
@ -239,12 +265,12 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
|
|||
if (!user) {
|
||||
return;
|
||||
}
|
||||
setError(undefined);
|
||||
setProcessingError(undefined);
|
||||
postCloneLibraryItem(String(target), {
|
||||
data: data,
|
||||
showError: true,
|
||||
setLoading: setProcessing,
|
||||
onError: setError,
|
||||
onError: setProcessingError,
|
||||
onSuccess: newSchema =>
|
||||
reloadItems(() => {
|
||||
if (user && !user.subscriptions.includes(newSchema.id)) {
|
||||
|
@ -254,18 +280,26 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
|
|||
})
|
||||
});
|
||||
},
|
||||
[reloadItems, setError, user]
|
||||
[reloadItems, user]
|
||||
);
|
||||
|
||||
return (
|
||||
<LibraryContext.Provider
|
||||
value={{
|
||||
items,
|
||||
folders,
|
||||
templates,
|
||||
|
||||
loading,
|
||||
loadingError,
|
||||
setLoadingError,
|
||||
|
||||
processing,
|
||||
error,
|
||||
setError,
|
||||
processingError,
|
||||
setProcessingError,
|
||||
|
||||
reloadItems,
|
||||
|
||||
applyFilter,
|
||||
createItem,
|
||||
cloneItem,
|
||||
|
|
|
@ -296,8 +296,7 @@ export const RSFormState = ({ itemID, versionID, children }: RSFormStateProps) =
|
|||
onError: setProcessingError,
|
||||
onSuccess: () => {
|
||||
schema.location = newLocation;
|
||||
library.localUpdateItem(schema);
|
||||
if (callback) callback();
|
||||
library.reloadItems(callback);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
47
rsconcept/frontend/src/models/FolderTree.test.ts
Normal file
47
rsconcept/frontend/src/models/FolderTree.test.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { FolderTree } from './FolderTree';
|
||||
|
||||
// TODO: test FolderNode and FolderTree exhaustively
|
||||
|
||||
describe('Testing Tree construction', () => {
|
||||
test('empty Tree should be empty', () => {
|
||||
const tree = new FolderTree();
|
||||
expect(tree.roots.size).toBe(0);
|
||||
});
|
||||
|
||||
test('constructing from paths', () => {
|
||||
const tree = new FolderTree(['/S', '/S/project1/123', '/U']);
|
||||
expect(tree.roots.size).toBe(2);
|
||||
expect(tree.roots.get('S')?.children.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testing Tree editing', () => {
|
||||
test('add invalid path', () => {
|
||||
const tree = new FolderTree();
|
||||
expect(() => tree.addPath('invalid')).toThrow(Error);
|
||||
});
|
||||
|
||||
test('add valid path', () => {
|
||||
const tree = new FolderTree();
|
||||
const node = tree.addPath('/S/test');
|
||||
expect(node.getPath()).toBe('/S/test');
|
||||
expect(node.filesInside).toBe(1);
|
||||
expect(node.filesTotal).toBe(1);
|
||||
|
||||
expect(node.parent?.getPath()).toBe('/S');
|
||||
expect(node.parent?.filesInside).toBe(0);
|
||||
expect(node.parent?.filesTotal).toBe(1);
|
||||
});
|
||||
|
||||
test('incrementing counter', () => {
|
||||
const tree = new FolderTree();
|
||||
const node1 = tree.addPath('/S/test', 0);
|
||||
expect(node1.filesInside).toBe(0);
|
||||
expect(node1.filesTotal).toBe(0);
|
||||
|
||||
const node2 = tree.addPath('/S/test', 2);
|
||||
expect(node1).toBe(node2);
|
||||
expect(node2.filesInside).toBe(2);
|
||||
expect(node2.filesTotal).toBe(2);
|
||||
});
|
||||
});
|
157
rsconcept/frontend/src/models/FolderTree.ts
Normal file
157
rsconcept/frontend/src/models/FolderTree.ts
Normal file
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* Module: Folder tree data structure. Does not support deletions.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents single node of a {@link FolderTree}.
|
||||
*/
|
||||
export class FolderNode {
|
||||
rank: number = 0;
|
||||
text: string;
|
||||
children: Map<string, FolderNode>;
|
||||
parent: FolderNode | undefined;
|
||||
|
||||
filesInside: number = 0;
|
||||
filesTotal: number = 0;
|
||||
|
||||
constructor(text: string, parent?: FolderNode) {
|
||||
this.text = text;
|
||||
this.parent = parent;
|
||||
this.children = new Map();
|
||||
if (parent) {
|
||||
this.rank = parent.rank + 1;
|
||||
}
|
||||
}
|
||||
|
||||
addChild(text: string): FolderNode {
|
||||
const node = new FolderNode(text, this);
|
||||
this.children.set(text, node);
|
||||
return node;
|
||||
}
|
||||
|
||||
hasPredecessor(target: FolderNode): boolean {
|
||||
if (this.parent === target) {
|
||||
return true;
|
||||
} else if (!this.parent) {
|
||||
return false;
|
||||
}
|
||||
let node = this.parent;
|
||||
while (node.parent) {
|
||||
if (node.parent === target) {
|
||||
return true;
|
||||
}
|
||||
node = node.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
incrementFiles(count: number = 1): void {
|
||||
this.filesInside = this.filesInside + count;
|
||||
this.incrementTotal(count);
|
||||
}
|
||||
|
||||
incrementTotal(count: number = 1): void {
|
||||
this.filesTotal = this.filesTotal + count;
|
||||
if (this.parent) {
|
||||
this.parent.incrementTotal(count);
|
||||
}
|
||||
}
|
||||
|
||||
getPath(): string {
|
||||
const suffix = this.text ? `/${this.text}` : '';
|
||||
if (!this.parent) {
|
||||
return suffix;
|
||||
} else {
|
||||
return this.parent.getPath() + suffix;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a FolderTree.
|
||||
*
|
||||
*/
|
||||
export class FolderTree {
|
||||
roots: Map<string, FolderNode> = new Map();
|
||||
|
||||
constructor(arr?: string[]) {
|
||||
arr?.forEach(path => this.addPath(path));
|
||||
}
|
||||
|
||||
at(path: string): FolderNode | undefined {
|
||||
let parse = ChopPathHead(path);
|
||||
if (!this.roots.has(parse.head)) {
|
||||
return undefined;
|
||||
}
|
||||
let node = this.roots.get(parse.head)!;
|
||||
while (parse.tail !== '') {
|
||||
parse = ChopPathHead(parse.tail);
|
||||
if (!node.children.has(parse.head)) {
|
||||
return undefined;
|
||||
}
|
||||
node = node.children.get(parse.head)!;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
getTree(): FolderNode[] {
|
||||
const result: FolderNode[] = [];
|
||||
this.roots.forEach(root => this.visitNode(root, result));
|
||||
return result;
|
||||
}
|
||||
|
||||
private visitNode(target: FolderNode, result: FolderNode[]) {
|
||||
result.push(target);
|
||||
target.children.forEach(child => this.visitNode(child, result));
|
||||
}
|
||||
|
||||
addPath(path: string, filesCount: number = 1): FolderNode {
|
||||
let parse = ChopPathHead(path);
|
||||
if (!parse.head) {
|
||||
throw Error(`Invalid path ${path}`);
|
||||
}
|
||||
let node = this.roots.has(parse.head) ? this.roots.get(parse.head)! : this.addNode(parse.head);
|
||||
while (parse.tail !== '') {
|
||||
parse = ChopPathHead(parse.tail);
|
||||
if (node.children.has(parse.head)) {
|
||||
node = node.children.get(parse.head)!;
|
||||
} else {
|
||||
node = this.addNode(parse.head, node);
|
||||
}
|
||||
}
|
||||
node.incrementFiles(filesCount);
|
||||
return node;
|
||||
}
|
||||
|
||||
private addNode(text: string, parent?: FolderNode): FolderNode {
|
||||
if (parent === undefined) {
|
||||
const newNode = new FolderNode(text);
|
||||
this.roots.set(text, newNode);
|
||||
return newNode;
|
||||
} else {
|
||||
return parent.addChild(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========= Internals =======
|
||||
function ChopPathHead(path: string) {
|
||||
if (!path || path.at(0) !== '/') {
|
||||
return {
|
||||
head: '',
|
||||
tail: ''
|
||||
};
|
||||
}
|
||||
const slash = path.indexOf('/', 1);
|
||||
if (slash === -1) {
|
||||
return {
|
||||
head: path.substring(1),
|
||||
tail: ''
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
head: path.substring(1, slash),
|
||||
tail: path.substring(slash)
|
||||
};
|
||||
}
|
||||
}
|
|
@ -26,9 +26,9 @@ export enum AccessPolicy {
|
|||
*/
|
||||
export enum LocationHead {
|
||||
USER = '/U',
|
||||
LIBRARY = '/L',
|
||||
COMMON = '/S',
|
||||
PROJECTS = '/P'
|
||||
PROJECTS = '/P',
|
||||
LIBRARY = '/L'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
* Module: Miscellaneous frontend model types. Future targets for refactoring aimed at extracting modules.
|
||||
*/
|
||||
|
||||
import { LocationHead } from './library';
|
||||
import { LibraryItemType, LocationHead } from './library';
|
||||
|
||||
/**
|
||||
* Represents graph dependency mode.
|
||||
|
@ -141,10 +141,15 @@ export enum CstMatchMode {
|
|||
* Represents Library filter parameters.
|
||||
*/
|
||||
export interface ILibraryFilter {
|
||||
type?: LibraryItemType;
|
||||
query?: string;
|
||||
|
||||
path?: string;
|
||||
head?: LocationHead;
|
||||
|
||||
folderMode?: boolean;
|
||||
folder?: string;
|
||||
|
||||
isVisible?: boolean;
|
||||
isOwned?: boolean;
|
||||
isSubscribed?: boolean;
|
||||
|
|
|
@ -30,7 +30,7 @@ import { information } from '@/utils/labels';
|
|||
function FormCreateItem() {
|
||||
const router = useConceptNavigation();
|
||||
const { user } = useAuth();
|
||||
const { createItem, error, setError, processing } = useLibrary();
|
||||
const { createItem, processingError, setProcessingError, processing } = useLibrary();
|
||||
|
||||
const [itemType, setItemType] = useState(LibraryItemType.RSFORM);
|
||||
const [title, setTitle] = useState('');
|
||||
|
@ -50,8 +50,8 @@ function FormCreateItem() {
|
|||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setError(undefined);
|
||||
}, [title, alias, setError]);
|
||||
setProcessingError(undefined);
|
||||
}, [title, alias, setProcessingError]);
|
||||
|
||||
function handleCancel() {
|
||||
if (router.canBack()) {
|
||||
|
@ -194,7 +194,7 @@ function FormCreateItem() {
|
|||
<SubmitButton text='Создать схему' loading={processing} className='min-w-[10rem]' disabled={!isValid} />
|
||||
<Button text='Отмена' className='min-w-[10rem]' onClick={() => handleCancel()} />
|
||||
</div>
|
||||
{error ? <InfoError error={error} /> : null}
|
||||
{processingError ? <InfoError error={processingError} /> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
139
rsconcept/frontend/src/pages/LibraryPage/LibraryFolders.tsx
Normal file
139
rsconcept/frontend/src/pages/LibraryPage/LibraryFolders.tsx
Normal file
|
@ -0,0 +1,139 @@
|
|||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { IconFolder, IconFolderClosed, IconFolderOpened, IconFolderTree } from '@/components/Icons';
|
||||
import { CProps } from '@/components/props';
|
||||
import MiniButton from '@/components/ui/MiniButton';
|
||||
import { FolderNode, FolderTree } from '@/models/FolderTree';
|
||||
import { animateSideAppear, animateSideView } from '@/styling/animations';
|
||||
import { globals, prefixes } from '@/utils/constants';
|
||||
import { describeFolderNode, labelFolderNode } from '@/utils/labels';
|
||||
|
||||
interface LibraryTableProps {
|
||||
folders: FolderTree;
|
||||
currentFolder: string;
|
||||
setFolder: React.Dispatch<React.SetStateAction<string>>;
|
||||
toggleFolderMode: () => void;
|
||||
}
|
||||
|
||||
function LibraryFolders({ folders, currentFolder, setFolder, toggleFolderMode }: LibraryTableProps) {
|
||||
const activeNode = useMemo(() => folders.at(currentFolder), [folders, currentFolder]);
|
||||
|
||||
const items = useMemo(() => folders.getTree(), [folders]);
|
||||
const [folded, setFolded] = useState<FolderNode[]>([]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setFolded(items.filter(item => item !== activeNode && (!activeNode || !activeNode.hasPredecessor(item))));
|
||||
}, [items, activeNode]);
|
||||
|
||||
const onFoldItem = useCallback(
|
||||
(target: FolderNode, showChildren: boolean) => {
|
||||
setFolded(prev =>
|
||||
items.filter(item => {
|
||||
if (item === target) {
|
||||
return !showChildren;
|
||||
}
|
||||
if (!showChildren && item.hasPredecessor(target)) {
|
||||
return true;
|
||||
} else {
|
||||
return prev.includes(item);
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
[items]
|
||||
);
|
||||
|
||||
const handleSetValue = useCallback(
|
||||
(event: CProps.EventMouse, target: FolderNode) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setFolder(target.getPath());
|
||||
},
|
||||
[setFolder]
|
||||
);
|
||||
|
||||
const handleClickFold = useCallback(
|
||||
(event: CProps.EventMouse, target: FolderNode, showChildren: boolean) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onFoldItem(target, showChildren);
|
||||
},
|
||||
[onFoldItem]
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className='flex flex-col text:xs sm:text-sm'
|
||||
initial={{ ...animateSideView.initial }}
|
||||
animate={{ ...animateSideView.animate }}
|
||||
exit={{ ...animateSideView.exit }}
|
||||
>
|
||||
<div className='h-[2.08rem] flex justify-end pr-1'>
|
||||
<MiniButton
|
||||
icon={<IconFolderTree size='1.25rem' className='icon-green' />}
|
||||
title='Режим: проводник'
|
||||
onClick={toggleFolderMode}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'max-w-[10rem] sm:max-w-[15rem] min-w-[10rem] sm:min-w-[15rem]',
|
||||
'flex flex-col',
|
||||
'cc-scroll-y'
|
||||
)}
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{items.map((item, index) =>
|
||||
!item.parent || !folded.includes(item.parent) ? (
|
||||
<motion.div
|
||||
tabIndex={-1}
|
||||
key={`${prefixes.folders_list}${index}`}
|
||||
className={clsx(
|
||||
'min-h-[2.0825rem] sm:min-h-[2.3125rem]',
|
||||
'pr-3 flex items-center gap-2',
|
||||
'cc-scroll-row',
|
||||
'clr-hover',
|
||||
'cursor-pointer',
|
||||
activeNode === item && 'clr-selected'
|
||||
)}
|
||||
style={{ paddingLeft: `${(item.rank > 5 ? 5 : item.rank) * 0.5 + 0.5}rem` }}
|
||||
data-tooltip-id={globals.tooltip}
|
||||
data-tooltip-html={describeFolderNode(item)}
|
||||
onClick={event => handleSetValue(event, item)}
|
||||
initial={{ ...animateSideAppear.initial }}
|
||||
animate={{ ...animateSideAppear.animate }}
|
||||
exit={{ ...animateSideAppear.exit }}
|
||||
>
|
||||
{item.children.size > 0 ? (
|
||||
<MiniButton
|
||||
noPadding
|
||||
noHover
|
||||
icon={
|
||||
folded.includes(item) ? (
|
||||
<IconFolderClosed size='1rem' className='icon-primary' />
|
||||
) : (
|
||||
<IconFolderOpened size='1rem' className='icon-green' />
|
||||
)
|
||||
}
|
||||
onClick={event => handleClickFold(event, item, folded.includes(item))}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<IconFolder size='1rem' />
|
||||
</div>
|
||||
)}
|
||||
<div className='self-center'>{labelFolderNode(item)}</div>
|
||||
</motion.div>
|
||||
) : null
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LibraryFolders;
|
|
@ -1,5 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import DataLoader from '@/components/wrap/DataLoader';
|
||||
|
@ -11,6 +12,7 @@ import { ILibraryFilter } from '@/models/miscellaneous';
|
|||
import { storage } from '@/utils/constants';
|
||||
import { toggleTristateFlag } from '@/utils/utils';
|
||||
|
||||
import LibraryFolders from './LibraryFolders';
|
||||
import LibraryTable from './LibraryTable';
|
||||
import SearchPanel from './SearchPanel';
|
||||
|
||||
|
@ -23,6 +25,8 @@ function LibraryPage() {
|
|||
const [path, setPath] = useState('');
|
||||
|
||||
const [head, setHead] = useLocalStorage<LocationHead | undefined>(storage.librarySearchHead, undefined);
|
||||
const [folderMode, setFolderMode] = useLocalStorage<boolean>(storage.librarySearchFolderMode, true);
|
||||
const [folder, setFolder] = useLocalStorage<string>(storage.librarySearchFolder, '');
|
||||
const [isVisible, setIsVisible] = useLocalStorage<boolean | undefined>(storage.librarySearchVisible, true);
|
||||
const [isSubscribed, setIsSubscribed] = useLocalStorage<boolean | undefined>(
|
||||
storage.librarySearchSubscribed,
|
||||
|
@ -39,9 +43,11 @@ function LibraryPage() {
|
|||
isEditor: user ? isEditor : undefined,
|
||||
isOwned: user ? isOwned : undefined,
|
||||
isSubscribed: user ? isSubscribed : undefined,
|
||||
isVisible: user ? isVisible : true
|
||||
isVisible: user ? isVisible : true,
|
||||
folderMode: folderMode,
|
||||
folder: folder
|
||||
}),
|
||||
[head, path, query, isEditor, isOwned, isSubscribed, isVisible, user]
|
||||
[head, path, query, isEditor, isOwned, isSubscribed, isVisible, user, folderMode, folder]
|
||||
);
|
||||
|
||||
const hasCustomFilter = useMemo(
|
||||
|
@ -52,7 +58,8 @@ function LibraryPage() {
|
|||
filter.isEditor !== undefined ||
|
||||
filter.isOwned !== undefined ||
|
||||
filter.isSubscribed !== undefined ||
|
||||
filter.isVisible !== true,
|
||||
filter.isVisible !== true ||
|
||||
!!filter.folder,
|
||||
[filter]
|
||||
);
|
||||
|
||||
|
@ -64,6 +71,7 @@ function LibraryPage() {
|
|||
const toggleOwned = useCallback(() => setIsOwned(prev => toggleTristateFlag(prev)), [setIsOwned]);
|
||||
const toggleSubscribed = useCallback(() => setIsSubscribed(prev => toggleTristateFlag(prev)), [setIsSubscribed]);
|
||||
const toggleEditor = useCallback(() => setIsEditor(prev => toggleTristateFlag(prev)), [setIsEditor]);
|
||||
const toggleFolderMode = useCallback(() => setFolderMode(prev => !prev), [setFolderMode]);
|
||||
|
||||
const resetFilter = useCallback(() => {
|
||||
setQuery('');
|
||||
|
@ -73,23 +81,26 @@ function LibraryPage() {
|
|||
setIsSubscribed(undefined);
|
||||
setIsOwned(undefined);
|
||||
setIsEditor(undefined);
|
||||
}, [setHead, setIsVisible, setIsSubscribed, setIsOwned, setIsEditor]);
|
||||
setFolder('');
|
||||
}, [setHead, setIsVisible, setIsSubscribed, setIsOwned, setIsEditor, setFolder]);
|
||||
|
||||
const view = useMemo(
|
||||
() => (
|
||||
<LibraryTable
|
||||
resetQuery={resetFilter} // prettier: split lines
|
||||
items={items}
|
||||
folderMode={folderMode}
|
||||
toggleFolderMode={toggleFolderMode}
|
||||
/>
|
||||
),
|
||||
[resetFilter, items]
|
||||
[resetFilter, items, folderMode, toggleFolderMode]
|
||||
);
|
||||
|
||||
return (
|
||||
<DataLoader
|
||||
id='library-page' // prettier: split lines
|
||||
isLoading={library.loading}
|
||||
error={library.error}
|
||||
error={library.loadingError}
|
||||
hasNoData={library.items.length === 0}
|
||||
>
|
||||
<SearchPanel
|
||||
|
@ -111,8 +122,23 @@ function LibraryPage() {
|
|||
isEditor={isEditor}
|
||||
toggleEditor={toggleEditor}
|
||||
resetFilter={resetFilter}
|
||||
folderMode={folderMode}
|
||||
toggleFolderMode={toggleFolderMode}
|
||||
/>
|
||||
{view}
|
||||
|
||||
<div className='flex'>
|
||||
<AnimatePresence>
|
||||
{folderMode ? (
|
||||
<LibraryFolders
|
||||
currentFolder={folder} // prettier: split-lines
|
||||
setFolder={setFolder}
|
||||
folders={library.folders}
|
||||
toggleFolderMode={toggleFolderMode}
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{view}
|
||||
</div>
|
||||
</DataLoader>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useLayoutEffect, useMemo, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import { urls } from '@/app/urls';
|
||||
|
@ -9,6 +10,7 @@ import BadgeLocation from '@/components/info/BadgeLocation';
|
|||
import { CProps } from '@/components/props';
|
||||
import DataTable, { createColumnHelper, IConditionalStyle, VisibilityState } from '@/components/ui/DataTable';
|
||||
import FlexColumn from '@/components/ui/FlexColumn';
|
||||
import MiniButton from '@/components/ui/MiniButton';
|
||||
import TextURL from '@/components/ui/TextURL';
|
||||
import { useConceptNavigation } from '@/context/NavigationContext';
|
||||
import { useConceptOptions } from '@/context/OptionsContext';
|
||||
|
@ -21,11 +23,13 @@ import { storage } from '@/utils/constants';
|
|||
interface LibraryTableProps {
|
||||
items: ILibraryItem[];
|
||||
resetQuery: () => void;
|
||||
folderMode: boolean;
|
||||
toggleFolderMode: () => void;
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<ILibraryItem>();
|
||||
|
||||
function LibraryTable({ items, resetQuery }: LibraryTableProps) {
|
||||
function LibraryTable({ items, resetQuery, folderMode, toggleFolderMode }: LibraryTableProps) {
|
||||
const router = useConceptNavigation();
|
||||
const intl = useIntl();
|
||||
const { getUserLabel } = useUsers();
|
||||
|
@ -50,22 +54,42 @@ function LibraryTable({ items, resetQuery }: LibraryTableProps) {
|
|||
});
|
||||
}, [windowSize]);
|
||||
|
||||
const handleToggleFolder = useCallback(
|
||||
(event: CProps.EventMouse) => {
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleFolderMode();
|
||||
}
|
||||
},
|
||||
[toggleFolderMode]
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor('location', {
|
||||
id: 'location',
|
||||
header: () => (
|
||||
<div className='pl-2 max-h-[1rem] translate-y-[-0.125rem]'>
|
||||
<IconFolder size='1.25rem' className='clr-text-controls' />
|
||||
</div>
|
||||
),
|
||||
size: 50,
|
||||
minSize: 50,
|
||||
maxSize: 50,
|
||||
enableSorting: true,
|
||||
cell: props => <BadgeLocation location={props.getValue()} />,
|
||||
sortingFn: 'text'
|
||||
}),
|
||||
...(folderMode
|
||||
? []
|
||||
: [
|
||||
columnHelper.accessor('location', {
|
||||
id: 'location',
|
||||
header: () => (
|
||||
<MiniButton
|
||||
noHover
|
||||
noPadding
|
||||
className='pl-2 max-h-[1rem] translate-y-[-0.125rem]'
|
||||
onClick={handleToggleFolder}
|
||||
titleHtml='Ctrl + клик для переключения </br>в режим папок'
|
||||
icon={<IconFolder size='1.25rem' className='clr-text-controls' />}
|
||||
/>
|
||||
),
|
||||
size: 50,
|
||||
minSize: 50,
|
||||
maxSize: 50,
|
||||
enableSorting: true,
|
||||
cell: props => <BadgeLocation location={props.getValue()} />,
|
||||
sortingFn: 'text'
|
||||
})
|
||||
]),
|
||||
columnHelper.accessor('alias', {
|
||||
id: 'alias',
|
||||
header: 'Шифр',
|
||||
|
@ -116,7 +140,7 @@ function LibraryTable({ items, resetQuery }: LibraryTableProps) {
|
|||
sortDescFirst: true
|
||||
})
|
||||
],
|
||||
[intl, getUserLabel, windowSize]
|
||||
[intl, getUserLabel, windowSize, handleToggleFolder, folderMode]
|
||||
);
|
||||
|
||||
const tableHeight = useMemo(() => calculateHeight('2.2rem'), [calculateHeight]);
|
||||
|
@ -139,7 +163,7 @@ function LibraryTable({ items, resetQuery }: LibraryTableProps) {
|
|||
columns={columns}
|
||||
data={items}
|
||||
headPosition='0'
|
||||
className='text-xs sm:text-sm cc-scroll-y'
|
||||
className={clsx('text-xs sm:text-sm cc-scroll-y', { 'border-l border-b': folderMode })}
|
||||
style={{ maxHeight: tableHeight }}
|
||||
noDataComponent={
|
||||
<FlexColumn className='p-3 items-center min-h-[6rem]'>
|
||||
|
|
|
@ -4,8 +4,9 @@ import clsx from 'clsx';
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { LocationIcon, SubscribeIcon, VisibilityIcon } from '@/components/DomainIcons';
|
||||
import { IconEditor, IconFilterReset, IconFolder, IconOwner } from '@/components/Icons';
|
||||
import { IconEditor, IconFilterReset, IconFolder, IconFolderTree, IconOwner } from '@/components/Icons';
|
||||
import BadgeHelp from '@/components/info/BadgeHelp';
|
||||
import { CProps } from '@/components/props';
|
||||
import Dropdown from '@/components/ui/Dropdown';
|
||||
import DropdownButton from '@/components/ui/DropdownButton';
|
||||
import MiniButton from '@/components/ui/MiniButton';
|
||||
|
@ -32,6 +33,9 @@ interface SearchPanelProps {
|
|||
head: LocationHead | undefined;
|
||||
setHead: React.Dispatch<React.SetStateAction<LocationHead | undefined>>;
|
||||
|
||||
folderMode: boolean;
|
||||
toggleFolderMode: () => void;
|
||||
|
||||
isVisible: boolean | undefined;
|
||||
toggleVisible: () => void;
|
||||
isOwned: boolean | undefined;
|
||||
|
@ -55,6 +59,9 @@ function SearchPanel({
|
|||
head,
|
||||
setHead,
|
||||
|
||||
folderMode,
|
||||
toggleFolderMode,
|
||||
|
||||
isVisible,
|
||||
toggleVisible,
|
||||
isOwned,
|
||||
|
@ -76,6 +83,22 @@ function SearchPanel({
|
|||
[headMenu, setHead]
|
||||
);
|
||||
|
||||
const handleToggleFolder = useCallback(() => {
|
||||
headMenu.hide();
|
||||
toggleFolderMode();
|
||||
}, [headMenu, toggleFolderMode]);
|
||||
|
||||
const handleFolderClick = useCallback(
|
||||
(event: CProps.EventMouse) => {
|
||||
if (event.ctrlKey) {
|
||||
toggleFolderMode();
|
||||
} else {
|
||||
headMenu.toggle();
|
||||
}
|
||||
},
|
||||
[headMenu, toggleFolderMode]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
|
@ -134,58 +157,70 @@ function SearchPanel({
|
|||
value={query}
|
||||
onChange={setQuery}
|
||||
/>
|
||||
{!folderMode ? (
|
||||
<div ref={headMenu.ref} className='flex items-center h-full py-1 select-none'>
|
||||
<SelectorButton
|
||||
transparent
|
||||
className='h-full rounded-lg'
|
||||
titleHtml={(head ? describeLocationHead(head) : 'Выберите каталог') + '<br/>Ctrl + клик - Проводник'}
|
||||
hideTitle={headMenu.isOpen}
|
||||
icon={
|
||||
head ? (
|
||||
<LocationIcon value={head} size='1.25rem' />
|
||||
) : (
|
||||
<IconFolder size='1.25rem' className='clr-text-controls' />
|
||||
)
|
||||
}
|
||||
onClick={handleFolderClick}
|
||||
text={head ?? '//'}
|
||||
/>
|
||||
|
||||
<div ref={headMenu.ref} className='flex items-center h-full py-1 select-none'>
|
||||
<SelectorButton
|
||||
transparent
|
||||
className='h-full rounded-lg'
|
||||
title={head ? describeLocationHead(head) : 'Выберите каталог'}
|
||||
hideTitle={headMenu.isOpen}
|
||||
icon={
|
||||
head ? (
|
||||
<LocationIcon value={head} size='1.25rem' />
|
||||
) : (
|
||||
<IconFolder size='1.25rem' className='clr-text-controls' />
|
||||
)
|
||||
}
|
||||
onClick={headMenu.toggle}
|
||||
text={head ?? '//'}
|
||||
<Dropdown isOpen={headMenu.isOpen} stretchLeft className='z-modalTooltip'>
|
||||
<DropdownButton className='w-[10rem]' onClick={() => handleChange(undefined)}>
|
||||
<div className='inline-flex items-center gap-3'>
|
||||
<IconFolder size='1rem' className='clr-text-controls' />
|
||||
<span>отображать все</span>
|
||||
</div>
|
||||
</DropdownButton>
|
||||
{Object.values(LocationHead).map((head, index) => {
|
||||
return (
|
||||
<DropdownButton
|
||||
className='w-[10rem]'
|
||||
key={`${prefixes.location_head_list}${index}`}
|
||||
onClick={() => handleChange(head)}
|
||||
title={describeLocationHead(head)}
|
||||
>
|
||||
<div className='inline-flex items-center gap-3'>
|
||||
<LocationIcon value={head} size='1rem' />
|
||||
{labelLocationHead(head)}
|
||||
</div>
|
||||
</DropdownButton>
|
||||
);
|
||||
})}
|
||||
<DropdownButton
|
||||
className='w-[10rem]'
|
||||
title='переключение в режим выбора папок'
|
||||
onClick={handleToggleFolder}
|
||||
>
|
||||
<div className='inline-flex items-center gap-3'>
|
||||
<IconFolderTree size='1rem' className='clr-text-controls' />
|
||||
<span>проводник...</span>
|
||||
</div>
|
||||
</DropdownButton>
|
||||
</Dropdown>
|
||||
</div>
|
||||
) : null}
|
||||
{!folderMode ? (
|
||||
<SearchBar
|
||||
id='path_search'
|
||||
placeholder='Путь'
|
||||
noIcon
|
||||
noBorder
|
||||
className='min-w-[4.5rem] sm:min-w-[5rem]'
|
||||
value={path}
|
||||
onChange={setPath}
|
||||
/>
|
||||
|
||||
<Dropdown isOpen={headMenu.isOpen} stretchLeft className='z-modalTooltip'>
|
||||
<DropdownButton className='w-[10rem]' onClick={() => handleChange(undefined)}>
|
||||
<div className='inline-flex items-center gap-3'>
|
||||
<IconFolder size='1rem' className='clr-text-controls' />
|
||||
<span>отображать все</span>
|
||||
</div>
|
||||
</DropdownButton>
|
||||
{Object.values(LocationHead).map((head, index) => {
|
||||
return (
|
||||
<DropdownButton
|
||||
className='w-[10rem]'
|
||||
key={`${prefixes.location_head_list}${index}`}
|
||||
onClick={() => handleChange(head)}
|
||||
title={describeLocationHead(head)}
|
||||
>
|
||||
<div className='inline-flex items-center gap-3'>
|
||||
<LocationIcon value={head} size='1rem' />
|
||||
{labelLocationHead(head)}
|
||||
</div>
|
||||
</DropdownButton>
|
||||
);
|
||||
})}
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<SearchBar
|
||||
id='path_search'
|
||||
placeholder='Путь'
|
||||
noIcon
|
||||
noBorder
|
||||
className='min-w-[4.5rem] sm:min-w-[5rem]'
|
||||
value={path}
|
||||
onChange={setPath}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<Overlay position='top-[-0.75rem] right-0'>
|
||||
<BadgeHelp
|
||||
|
|
|
@ -59,7 +59,7 @@ function EditorLibraryItem({ item, isModified, controller }: EditorLibraryItemPr
|
|||
/>
|
||||
</Overlay>
|
||||
) : null}
|
||||
<LabeledValue className='sm:mb-1 text-ellipsis' label='Путь' text={item?.location ?? ''} />
|
||||
<LabeledValue className='max-w-[30rem] sm:mb-1 text-ellipsis' label='Путь' text={item?.location ?? ''} />
|
||||
|
||||
{accessLevel >= UserLevel.OWNER ? (
|
||||
<Overlay position='top-[-0.5rem] left-[5.5rem] cc-icons'>
|
||||
|
|
|
@ -54,7 +54,7 @@ function EditorRSFormCard({ isModified, onDestroy, setIsModified }: EditorRSForm
|
|||
onKeyDown={handleInput}
|
||||
className={clsx('sm:w-fit sm:max-w-fit max-w-[32rem]', 'mx-auto ', 'flex flex-col sm:flex-row px-6')}
|
||||
>
|
||||
<FlexColumn>
|
||||
<FlexColumn className='flex-shrink'>
|
||||
<FormRSForm id={globals.library_item_editor} isModified={isModified} setIsModified={setIsModified} />
|
||||
<EditorLibraryItem item={schema} isModified={isModified} controller={controller} />
|
||||
</FlexColumn>
|
||||
|
|
|
@ -94,6 +94,8 @@ export const storage = {
|
|||
rseditShowControls: 'rsedit.show_controls',
|
||||
|
||||
librarySearchHead: 'library.search.head',
|
||||
librarySearchFolderMode: 'library.search.folder_mode',
|
||||
librarySearchFolder: 'library.search.folder',
|
||||
librarySearchVisible: 'library.search.visible',
|
||||
librarySearchOwned: 'library.search.owned',
|
||||
librarySearchSubscribed: 'library.search.subscribed',
|
||||
|
@ -144,6 +146,7 @@ export const prefixes = {
|
|||
policy_list: 'policy_list_',
|
||||
library_filters_list: 'library_filters_list_',
|
||||
location_head_list: 'location_head_list_',
|
||||
folders_list: 'folders_list_',
|
||||
topic_list: 'topic_list_',
|
||||
topic_item: 'topic_item_',
|
||||
library_list: 'library_list_',
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
* Description is a long description used in tooltips.
|
||||
*/
|
||||
import { GraphLayout } from '@/components/ui/GraphUI';
|
||||
import { FolderNode } from '@/models/FolderTree';
|
||||
import { GramData, Grammeme, ReferenceType } from '@/models/language';
|
||||
import { AccessPolicy, LibraryItemType, LocationHead } from '@/models/library';
|
||||
import { CstMatchMode, DependencyMode, GraphColoring, GraphSizing, HelpTopic } from '@/models/miscellaneous';
|
||||
|
@ -820,6 +821,20 @@ export function describeAccessMode(mode: UserLevel): string {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves label for {@link FolderNode}.
|
||||
*/
|
||||
export function labelFolderNode(node: FolderNode): string {
|
||||
return node.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves description for {@link FolderNode}.
|
||||
*/
|
||||
export function describeFolderNode(node: FolderNode): string {
|
||||
return `${node.filesInside} | ${node.filesTotal}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves label for {@link AccessPolicy}.
|
||||
*/
|
||||
|
|
Loading…
Reference in New Issue
Block a user