Add FolderTree UI

This commit is contained in:
IRBorisov 2024-06-19 22:10:15 +03:00
parent e81e53e7d5
commit 9e1c08910d
17 changed files with 603 additions and 115 deletions

View File

@ -31,7 +31,10 @@ export { RiMenuFoldFill as IconMenuFold } from 'react-icons/ri';
export { RiMenuUnfoldFill as IconMenuUnfold } from 'react-icons/ri'; export { RiMenuUnfoldFill as IconMenuUnfold } from 'react-icons/ri';
export { LuMoon as IconDarkTheme } from 'react-icons/lu'; export { LuMoon as IconDarkTheme } from 'react-icons/lu';
export { LuSun as IconLightTheme } 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 { LuLightbulb as IconHelp } from 'react-icons/lu';
export { LuLightbulbOff as IconHelpOff } from 'react-icons/lu'; export { LuLightbulbOff as IconHelpOff } from 'react-icons/lu';
export { RiPushpinFill as IconPin } from 'react-icons/ri'; export { RiPushpinFill as IconPin } from 'react-icons/ri';

View File

@ -5,7 +5,7 @@ import DataTable, { createColumnHelper, IConditionalStyle } from '@/components/u
import SearchBar from '@/components/ui/SearchBar'; import SearchBar from '@/components/ui/SearchBar';
import { useLibrary } from '@/context/LibraryContext'; import { useLibrary } from '@/context/LibraryContext';
import { useConceptOptions } from '@/context/OptionsContext'; import { useConceptOptions } from '@/context/OptionsContext';
import { ILibraryItem, LibraryItemID } from '@/models/library'; import { ILibraryItem, LibraryItemID, LibraryItemType } from '@/models/library';
import { ILibraryFilter } from '@/models/miscellaneous'; import { ILibraryFilter } from '@/models/miscellaneous';
import FlexColumn from '../ui/FlexColumn'; import FlexColumn from '../ui/FlexColumn';
@ -32,7 +32,8 @@ function PickSchema({ id, initialFilter = '', rows = 4, value, onSelectValue }:
useLayoutEffect(() => { useLayoutEffect(() => {
setFilter({ setFilter({
query: filterText query: filterText,
type: LibraryItemType.RSFORM
}); });
}, [filterText]); }, [filterText]);

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { import {
DataCallback, DataCallback,
@ -14,7 +14,8 @@ import {
postRSFormFromFile postRSFormFromFile
} from '@/app/backendAPI'; } from '@/app/backendAPI';
import { ErrorData } from '@/components/info/InfoError'; 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 { ILibraryCreateData } from '@/models/library';
import { matchLibraryItem, matchLibraryItemLocation } from '@/models/libraryAPI'; import { matchLibraryItem, matchLibraryItemLocation } from '@/models/libraryAPI';
import { ILibraryFilter } from '@/models/miscellaneous'; import { ILibraryFilter } from '@/models/miscellaneous';
@ -28,10 +29,17 @@ import { useConceptOptions } from './OptionsContext';
interface ILibraryContext { interface ILibraryContext {
items: ILibraryItem[]; items: ILibraryItem[];
templates: ILibraryItem[]; templates: ILibraryItem[];
folders: FolderTree;
loading: boolean; loading: boolean;
loadingError: ErrorData;
setLoadingError: (error: ErrorData) => void;
processing: boolean; processing: boolean;
error: ErrorData; processingError: ErrorData;
setError: (error: ErrorData) => void; setProcessingError: (error: ErrorData) => void;
reloadItems: (callback?: () => void) => void;
applyFilter: (params: ILibraryFilter) => ILibraryItem[]; applyFilter: (params: ILibraryFilter) => ILibraryItem[];
retrieveTemplate: (templateID: LibraryItemID, callback: (schema: IRSForm) => void) => void; retrieveTemplate: (templateID: LibraryItemID, callback: (schema: IRSForm) => void) => void;
@ -64,15 +72,31 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
const [templates, setTemplates] = useState<ILibraryItem[]>([]); const [templates, setTemplates] = useState<ILibraryItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [processing, setProcessing] = 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 [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( const applyFilter = useCallback(
(filter: ILibraryFilter) => { (filter: ILibraryFilter) => {
let result = items; let result = items;
if (filter.head) { if (!filter.folderMode && filter.head) {
result = result.filter(item => item.location.startsWith(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) { if (filter.isVisible !== undefined) {
result = result.filter(item => filter.isVisible === item.visible); result = result.filter(item => filter.isVisible === item.visible);
} }
@ -85,12 +109,12 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
if (filter.isEditor !== undefined) { if (filter.isEditor !== undefined) {
result = result.filter(item => filter.isEditor == user?.editor.includes(item.id)); 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) { if (filter.query) {
result = result.filter(item => matchLibraryItem(item, filter.query!)); result = result.filter(item => matchLibraryItem(item, filter.query!));
} }
if (filter.path) {
result = result.filter(item => matchLibraryItemLocation(item, filter.path!));
}
return result; return result;
}, },
[items, user] [items, user]
@ -103,11 +127,11 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
callback(cached); callback(cached);
return; return;
} }
setError(undefined); setProcessingError(undefined);
getRSFormDetails(String(templateID), '', { getRSFormDetails(String(templateID), '', {
showError: true, showError: true,
setLoading: setProcessing, setLoading: setProcessing,
onError: setError, onError: setProcessingError,
onSuccess: data => { onSuccess: data => {
const schema = new RSFormLoader(data).produceRSForm(); const schema = new RSFormLoader(data).produceRSForm();
setCachedTemplates(prev => [...prev, schema]); setCachedTemplates(prev => [...prev, schema]);
@ -121,12 +145,12 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
const reloadItems = useCallback( const reloadItems = useCallback(
(callback?: () => void) => { (callback?: () => void) => {
setItems([]); setItems([]);
setError(undefined); setLoadingError(undefined);
if (user?.is_staff && adminMode) { if (user?.is_staff && adminMode) {
getAdminLibrary({ getAdminLibrary({
setLoading: setLoading, setLoading: setLoading,
showError: true, showError: true,
onError: setError, onError: setLoadingError,
onSuccess: newData => { onSuccess: newData => {
setItems(newData); setItems(newData);
if (callback) callback(); if (callback) callback();
@ -136,7 +160,7 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
getLibrary({ getLibrary({
setLoading: setLoading, setLoading: setLoading,
showError: true, showError: true,
onError: setError, onError: setLoadingError,
onSuccess: newData => { onSuccess: newData => {
setItems(newData); setItems(newData);
if (callback) callback(); if (callback) callback();
@ -150,6 +174,8 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
const reloadTemplates = useCallback(() => { const reloadTemplates = useCallback(() => {
setTemplates([]); setTemplates([]);
getTemplates({ getTemplates({
setLoading: setLoading,
onError: setLoadingError,
showError: true, showError: true,
onSuccess: newData => setTemplates(newData) onSuccess: newData => setTemplates(newData)
}); });
@ -190,13 +216,13 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
} }
if (callback) callback(newSchema); if (callback) callback(newSchema);
}); });
setError(undefined); setProcessingError(undefined);
if (data.file) { if (data.file) {
postRSFormFromFile({ postRSFormFromFile({
data: data, data: data,
showError: true, showError: true,
setLoading: setProcessing, setLoading: setProcessing,
onError: setError, onError: setProcessingError,
onSuccess: onSuccess onSuccess: onSuccess
}); });
} else { } else {
@ -204,7 +230,7 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
data: data, data: data,
showError: true, showError: true,
setLoading: setProcessing, setLoading: setProcessing,
onError: setError, onError: setProcessingError,
onSuccess: onSuccess onSuccess: onSuccess
}); });
} }
@ -214,11 +240,11 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
const destroyItem = useCallback( const destroyItem = useCallback(
(target: LibraryItemID, callback?: () => void) => { (target: LibraryItemID, callback?: () => void) => {
setError(undefined); setProcessingError(undefined);
deleteLibraryItem(String(target), { deleteLibraryItem(String(target), {
showError: true, showError: true,
setLoading: setProcessing, setLoading: setProcessing,
onError: setError, onError: setProcessingError,
onSuccess: () => onSuccess: () =>
reloadItems(() => { reloadItems(() => {
if (user && user.subscriptions.includes(target)) { if (user && user.subscriptions.includes(target)) {
@ -231,7 +257,7 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
}) })
}); });
}, },
[setError, reloadItems, user] [reloadItems, user]
); );
const cloneItem = useCallback( const cloneItem = useCallback(
@ -239,12 +265,12 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
if (!user) { if (!user) {
return; return;
} }
setError(undefined); setProcessingError(undefined);
postCloneLibraryItem(String(target), { postCloneLibraryItem(String(target), {
data: data, data: data,
showError: true, showError: true,
setLoading: setProcessing, setLoading: setProcessing,
onError: setError, onError: setProcessingError,
onSuccess: newSchema => onSuccess: newSchema =>
reloadItems(() => { reloadItems(() => {
if (user && !user.subscriptions.includes(newSchema.id)) { if (user && !user.subscriptions.includes(newSchema.id)) {
@ -254,18 +280,26 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
}) })
}); });
}, },
[reloadItems, setError, user] [reloadItems, user]
); );
return ( return (
<LibraryContext.Provider <LibraryContext.Provider
value={{ value={{
items, items,
folders,
templates, templates,
loading, loading,
loadingError,
setLoadingError,
processing, processing,
error, processingError,
setError, setProcessingError,
reloadItems,
applyFilter, applyFilter,
createItem, createItem,
cloneItem, cloneItem,

View File

@ -296,8 +296,7 @@ export const RSFormState = ({ itemID, versionID, children }: RSFormStateProps) =
onError: setProcessingError, onError: setProcessingError,
onSuccess: () => { onSuccess: () => {
schema.location = newLocation; schema.location = newLocation;
library.localUpdateItem(schema); library.reloadItems(callback);
if (callback) callback();
} }
}); });
}, },

View 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);
});
});

View 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)
};
}
}

View File

@ -26,9 +26,9 @@ export enum AccessPolicy {
*/ */
export enum LocationHead { export enum LocationHead {
USER = '/U', USER = '/U',
LIBRARY = '/L',
COMMON = '/S', COMMON = '/S',
PROJECTS = '/P' PROJECTS = '/P',
LIBRARY = '/L'
} }
/** /**

View File

@ -2,7 +2,7 @@
* Module: Miscellaneous frontend model types. Future targets for refactoring aimed at extracting modules. * 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. * Represents graph dependency mode.
@ -141,10 +141,15 @@ export enum CstMatchMode {
* Represents Library filter parameters. * Represents Library filter parameters.
*/ */
export interface ILibraryFilter { export interface ILibraryFilter {
type?: LibraryItemType;
query?: string; query?: string;
path?: string; path?: string;
head?: LocationHead; head?: LocationHead;
folderMode?: boolean;
folder?: string;
isVisible?: boolean; isVisible?: boolean;
isOwned?: boolean; isOwned?: boolean;
isSubscribed?: boolean; isSubscribed?: boolean;

View File

@ -30,7 +30,7 @@ import { information } from '@/utils/labels';
function FormCreateItem() { function FormCreateItem() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const { user } = useAuth(); const { user } = useAuth();
const { createItem, error, setError, processing } = useLibrary(); const { createItem, processingError, setProcessingError, processing } = useLibrary();
const [itemType, setItemType] = useState(LibraryItemType.RSFORM); const [itemType, setItemType] = useState(LibraryItemType.RSFORM);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
@ -50,8 +50,8 @@ function FormCreateItem() {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => { useEffect(() => {
setError(undefined); setProcessingError(undefined);
}, [title, alias, setError]); }, [title, alias, setProcessingError]);
function handleCancel() { function handleCancel() {
if (router.canBack()) { if (router.canBack()) {
@ -194,7 +194,7 @@ function FormCreateItem() {
<SubmitButton text='Создать схему' loading={processing} className='min-w-[10rem]' disabled={!isValid} /> <SubmitButton text='Создать схему' loading={processing} className='min-w-[10rem]' disabled={!isValid} />
<Button text='Отмена' className='min-w-[10rem]' onClick={() => handleCancel()} /> <Button text='Отмена' className='min-w-[10rem]' onClick={() => handleCancel()} />
</div> </div>
{error ? <InfoError error={error} /> : null} {processingError ? <InfoError error={processingError} /> : null}
</form> </form>
); );
} }

View 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;

View File

@ -1,5 +1,6 @@
'use client'; 'use client';
import { AnimatePresence } from 'framer-motion';
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import DataLoader from '@/components/wrap/DataLoader'; import DataLoader from '@/components/wrap/DataLoader';
@ -11,6 +12,7 @@ import { ILibraryFilter } from '@/models/miscellaneous';
import { storage } from '@/utils/constants'; import { storage } from '@/utils/constants';
import { toggleTristateFlag } from '@/utils/utils'; import { toggleTristateFlag } from '@/utils/utils';
import LibraryFolders from './LibraryFolders';
import LibraryTable from './LibraryTable'; import LibraryTable from './LibraryTable';
import SearchPanel from './SearchPanel'; import SearchPanel from './SearchPanel';
@ -23,6 +25,8 @@ function LibraryPage() {
const [path, setPath] = useState(''); const [path, setPath] = useState('');
const [head, setHead] = useLocalStorage<LocationHead | undefined>(storage.librarySearchHead, undefined); 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 [isVisible, setIsVisible] = useLocalStorage<boolean | undefined>(storage.librarySearchVisible, true);
const [isSubscribed, setIsSubscribed] = useLocalStorage<boolean | undefined>( const [isSubscribed, setIsSubscribed] = useLocalStorage<boolean | undefined>(
storage.librarySearchSubscribed, storage.librarySearchSubscribed,
@ -39,9 +43,11 @@ function LibraryPage() {
isEditor: user ? isEditor : undefined, isEditor: user ? isEditor : undefined,
isOwned: user ? isOwned : undefined, isOwned: user ? isOwned : undefined,
isSubscribed: user ? isSubscribed : 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( const hasCustomFilter = useMemo(
@ -52,7 +58,8 @@ function LibraryPage() {
filter.isEditor !== undefined || filter.isEditor !== undefined ||
filter.isOwned !== undefined || filter.isOwned !== undefined ||
filter.isSubscribed !== undefined || filter.isSubscribed !== undefined ||
filter.isVisible !== true, filter.isVisible !== true ||
!!filter.folder,
[filter] [filter]
); );
@ -64,6 +71,7 @@ function LibraryPage() {
const toggleOwned = useCallback(() => setIsOwned(prev => toggleTristateFlag(prev)), [setIsOwned]); const toggleOwned = useCallback(() => setIsOwned(prev => toggleTristateFlag(prev)), [setIsOwned]);
const toggleSubscribed = useCallback(() => setIsSubscribed(prev => toggleTristateFlag(prev)), [setIsSubscribed]); const toggleSubscribed = useCallback(() => setIsSubscribed(prev => toggleTristateFlag(prev)), [setIsSubscribed]);
const toggleEditor = useCallback(() => setIsEditor(prev => toggleTristateFlag(prev)), [setIsEditor]); const toggleEditor = useCallback(() => setIsEditor(prev => toggleTristateFlag(prev)), [setIsEditor]);
const toggleFolderMode = useCallback(() => setFolderMode(prev => !prev), [setFolderMode]);
const resetFilter = useCallback(() => { const resetFilter = useCallback(() => {
setQuery(''); setQuery('');
@ -73,23 +81,26 @@ function LibraryPage() {
setIsSubscribed(undefined); setIsSubscribed(undefined);
setIsOwned(undefined); setIsOwned(undefined);
setIsEditor(undefined); setIsEditor(undefined);
}, [setHead, setIsVisible, setIsSubscribed, setIsOwned, setIsEditor]); setFolder('');
}, [setHead, setIsVisible, setIsSubscribed, setIsOwned, setIsEditor, setFolder]);
const view = useMemo( const view = useMemo(
() => ( () => (
<LibraryTable <LibraryTable
resetQuery={resetFilter} // prettier: split lines resetQuery={resetFilter} // prettier: split lines
items={items} items={items}
folderMode={folderMode}
toggleFolderMode={toggleFolderMode}
/> />
), ),
[resetFilter, items] [resetFilter, items, folderMode, toggleFolderMode]
); );
return ( return (
<DataLoader <DataLoader
id='library-page' // prettier: split lines id='library-page' // prettier: split lines
isLoading={library.loading} isLoading={library.loading}
error={library.error} error={library.loadingError}
hasNoData={library.items.length === 0} hasNoData={library.items.length === 0}
> >
<SearchPanel <SearchPanel
@ -111,8 +122,23 @@ function LibraryPage() {
isEditor={isEditor} isEditor={isEditor}
toggleEditor={toggleEditor} toggleEditor={toggleEditor}
resetFilter={resetFilter} resetFilter={resetFilter}
folderMode={folderMode}
toggleFolderMode={toggleFolderMode}
/> />
<div className='flex'>
<AnimatePresence>
{folderMode ? (
<LibraryFolders
currentFolder={folder} // prettier: split-lines
setFolder={setFolder}
folders={library.folders}
toggleFolderMode={toggleFolderMode}
/>
) : null}
</AnimatePresence>
{view} {view}
</div>
</DataLoader> </DataLoader>
); );
} }

View File

@ -1,6 +1,7 @@
'use client'; '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 { useIntl } from 'react-intl';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
@ -9,6 +10,7 @@ import BadgeLocation from '@/components/info/BadgeLocation';
import { CProps } from '@/components/props'; import { CProps } from '@/components/props';
import DataTable, { createColumnHelper, IConditionalStyle, VisibilityState } from '@/components/ui/DataTable'; import DataTable, { createColumnHelper, IConditionalStyle, VisibilityState } from '@/components/ui/DataTable';
import FlexColumn from '@/components/ui/FlexColumn'; import FlexColumn from '@/components/ui/FlexColumn';
import MiniButton from '@/components/ui/MiniButton';
import TextURL from '@/components/ui/TextURL'; import TextURL from '@/components/ui/TextURL';
import { useConceptNavigation } from '@/context/NavigationContext'; import { useConceptNavigation } from '@/context/NavigationContext';
import { useConceptOptions } from '@/context/OptionsContext'; import { useConceptOptions } from '@/context/OptionsContext';
@ -21,11 +23,13 @@ import { storage } from '@/utils/constants';
interface LibraryTableProps { interface LibraryTableProps {
items: ILibraryItem[]; items: ILibraryItem[];
resetQuery: () => void; resetQuery: () => void;
folderMode: boolean;
toggleFolderMode: () => void;
} }
const columnHelper = createColumnHelper<ILibraryItem>(); const columnHelper = createColumnHelper<ILibraryItem>();
function LibraryTable({ items, resetQuery }: LibraryTableProps) { function LibraryTable({ items, resetQuery, folderMode, toggleFolderMode }: LibraryTableProps) {
const router = useConceptNavigation(); const router = useConceptNavigation();
const intl = useIntl(); const intl = useIntl();
const { getUserLabel } = useUsers(); const { getUserLabel } = useUsers();
@ -50,14 +54,33 @@ function LibraryTable({ items, resetQuery }: LibraryTableProps) {
}); });
}, [windowSize]); }, [windowSize]);
const handleToggleFolder = useCallback(
(event: CProps.EventMouse) => {
if (event.ctrlKey) {
event.preventDefault();
event.stopPropagation();
toggleFolderMode();
}
},
[toggleFolderMode]
);
const columns = useMemo( const columns = useMemo(
() => [ () => [
...(folderMode
? []
: [
columnHelper.accessor('location', { columnHelper.accessor('location', {
id: 'location', id: 'location',
header: () => ( header: () => (
<div className='pl-2 max-h-[1rem] translate-y-[-0.125rem]'> <MiniButton
<IconFolder size='1.25rem' className='clr-text-controls' /> noHover
</div> 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, size: 50,
minSize: 50, minSize: 50,
@ -65,7 +88,8 @@ function LibraryTable({ items, resetQuery }: LibraryTableProps) {
enableSorting: true, enableSorting: true,
cell: props => <BadgeLocation location={props.getValue()} />, cell: props => <BadgeLocation location={props.getValue()} />,
sortingFn: 'text' sortingFn: 'text'
}), })
]),
columnHelper.accessor('alias', { columnHelper.accessor('alias', {
id: 'alias', id: 'alias',
header: 'Шифр', header: 'Шифр',
@ -116,7 +140,7 @@ function LibraryTable({ items, resetQuery }: LibraryTableProps) {
sortDescFirst: true sortDescFirst: true
}) })
], ],
[intl, getUserLabel, windowSize] [intl, getUserLabel, windowSize, handleToggleFolder, folderMode]
); );
const tableHeight = useMemo(() => calculateHeight('2.2rem'), [calculateHeight]); const tableHeight = useMemo(() => calculateHeight('2.2rem'), [calculateHeight]);
@ -139,7 +163,7 @@ function LibraryTable({ items, resetQuery }: LibraryTableProps) {
columns={columns} columns={columns}
data={items} data={items}
headPosition='0' 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 }} style={{ maxHeight: tableHeight }}
noDataComponent={ noDataComponent={
<FlexColumn className='p-3 items-center min-h-[6rem]'> <FlexColumn className='p-3 items-center min-h-[6rem]'>

View File

@ -4,8 +4,9 @@ import clsx from 'clsx';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { LocationIcon, SubscribeIcon, VisibilityIcon } from '@/components/DomainIcons'; 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 BadgeHelp from '@/components/info/BadgeHelp';
import { CProps } from '@/components/props';
import Dropdown from '@/components/ui/Dropdown'; import Dropdown from '@/components/ui/Dropdown';
import DropdownButton from '@/components/ui/DropdownButton'; import DropdownButton from '@/components/ui/DropdownButton';
import MiniButton from '@/components/ui/MiniButton'; import MiniButton from '@/components/ui/MiniButton';
@ -32,6 +33,9 @@ interface SearchPanelProps {
head: LocationHead | undefined; head: LocationHead | undefined;
setHead: React.Dispatch<React.SetStateAction<LocationHead | undefined>>; setHead: React.Dispatch<React.SetStateAction<LocationHead | undefined>>;
folderMode: boolean;
toggleFolderMode: () => void;
isVisible: boolean | undefined; isVisible: boolean | undefined;
toggleVisible: () => void; toggleVisible: () => void;
isOwned: boolean | undefined; isOwned: boolean | undefined;
@ -55,6 +59,9 @@ function SearchPanel({
head, head,
setHead, setHead,
folderMode,
toggleFolderMode,
isVisible, isVisible,
toggleVisible, toggleVisible,
isOwned, isOwned,
@ -76,6 +83,22 @@ function SearchPanel({
[headMenu, setHead] [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 ( return (
<div <div
className={clsx( className={clsx(
@ -134,12 +157,12 @@ function SearchPanel({
value={query} value={query}
onChange={setQuery} onChange={setQuery}
/> />
{!folderMode ? (
<div ref={headMenu.ref} className='flex items-center h-full py-1 select-none'> <div ref={headMenu.ref} className='flex items-center h-full py-1 select-none'>
<SelectorButton <SelectorButton
transparent transparent
className='h-full rounded-lg' className='h-full rounded-lg'
title={head ? describeLocationHead(head) : 'Выберите каталог'} titleHtml={(head ? describeLocationHead(head) : 'Выберите каталог') + '<br/>Ctrl + клик - Проводник'}
hideTitle={headMenu.isOpen} hideTitle={headMenu.isOpen}
icon={ icon={
head ? ( head ? (
@ -148,7 +171,7 @@ function SearchPanel({
<IconFolder size='1.25rem' className='clr-text-controls' /> <IconFolder size='1.25rem' className='clr-text-controls' />
) )
} }
onClick={headMenu.toggle} onClick={handleFolderClick}
text={head ?? '//'} text={head ?? '//'}
/> />
@ -174,9 +197,20 @@ function SearchPanel({
</DropdownButton> </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> </Dropdown>
</div> </div>
) : null}
{!folderMode ? (
<SearchBar <SearchBar
id='path_search' id='path_search'
placeholder='Путь' placeholder='Путь'
@ -186,6 +220,7 @@ function SearchPanel({
value={path} value={path}
onChange={setPath} onChange={setPath}
/> />
) : null}
</div> </div>
<Overlay position='top-[-0.75rem] right-0'> <Overlay position='top-[-0.75rem] right-0'>
<BadgeHelp <BadgeHelp

View File

@ -59,7 +59,7 @@ function EditorLibraryItem({ item, isModified, controller }: EditorLibraryItemPr
/> />
</Overlay> </Overlay>
) : null} ) : 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 ? ( {accessLevel >= UserLevel.OWNER ? (
<Overlay position='top-[-0.5rem] left-[5.5rem] cc-icons'> <Overlay position='top-[-0.5rem] left-[5.5rem] cc-icons'>

View File

@ -54,7 +54,7 @@ function EditorRSFormCard({ isModified, onDestroy, setIsModified }: EditorRSForm
onKeyDown={handleInput} onKeyDown={handleInput}
className={clsx('sm:w-fit sm:max-w-fit max-w-[32rem]', 'mx-auto ', 'flex flex-col sm:flex-row px-6')} 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} /> <FormRSForm id={globals.library_item_editor} isModified={isModified} setIsModified={setIsModified} />
<EditorLibraryItem item={schema} isModified={isModified} controller={controller} /> <EditorLibraryItem item={schema} isModified={isModified} controller={controller} />
</FlexColumn> </FlexColumn>

View File

@ -94,6 +94,8 @@ export const storage = {
rseditShowControls: 'rsedit.show_controls', rseditShowControls: 'rsedit.show_controls',
librarySearchHead: 'library.search.head', librarySearchHead: 'library.search.head',
librarySearchFolderMode: 'library.search.folder_mode',
librarySearchFolder: 'library.search.folder',
librarySearchVisible: 'library.search.visible', librarySearchVisible: 'library.search.visible',
librarySearchOwned: 'library.search.owned', librarySearchOwned: 'library.search.owned',
librarySearchSubscribed: 'library.search.subscribed', librarySearchSubscribed: 'library.search.subscribed',
@ -144,6 +146,7 @@ export const prefixes = {
policy_list: 'policy_list_', policy_list: 'policy_list_',
library_filters_list: 'library_filters_list_', library_filters_list: 'library_filters_list_',
location_head_list: 'location_head_list_', location_head_list: 'location_head_list_',
folders_list: 'folders_list_',
topic_list: 'topic_list_', topic_list: 'topic_list_',
topic_item: 'topic_item_', topic_item: 'topic_item_',
library_list: 'library_list_', library_list: 'library_list_',

View File

@ -5,6 +5,7 @@
* Description is a long description used in tooltips. * Description is a long description used in tooltips.
*/ */
import { GraphLayout } from '@/components/ui/GraphUI'; import { GraphLayout } from '@/components/ui/GraphUI';
import { FolderNode } from '@/models/FolderTree';
import { GramData, Grammeme, ReferenceType } from '@/models/language'; import { GramData, Grammeme, ReferenceType } from '@/models/language';
import { AccessPolicy, LibraryItemType, LocationHead } from '@/models/library'; import { AccessPolicy, LibraryItemType, LocationHead } from '@/models/library';
import { CstMatchMode, DependencyMode, GraphColoring, GraphSizing, HelpTopic } from '@/models/miscellaneous'; 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}. * Retrieves label for {@link AccessPolicy}.
*/ */