From c1f8553ff4a04722090cefefc16075ca1656b374 Mon Sep 17 00:00:00 2001 From: IRBorisov <8611739+IRBorisov@users.noreply.github.com> Date: Wed, 19 Jun 2024 22:09:31 +0300 Subject: [PATCH] Add FolderTree UI --- rsconcept/frontend/src/components/Icons.tsx | 5 +- .../src/components/select/PickSchema.tsx | 5 +- .../frontend/src/context/LibraryContext.tsx | 84 +++++++--- .../frontend/src/context/RSFormContext.tsx | 3 +- .../frontend/src/models/FolderTree.test.ts | 47 ++++++ rsconcept/frontend/src/models/FolderTree.ts | 157 ++++++++++++++++++ rsconcept/frontend/src/models/library.ts | 4 +- .../frontend/src/models/miscellaneous.ts | 7 +- .../pages/CreateItemPage/FormCreateItem.tsx | 8 +- .../src/pages/LibraryPage/LibraryFolders.tsx | 139 ++++++++++++++++ .../src/pages/LibraryPage/LibraryPage.tsx | 40 ++++- .../src/pages/LibraryPage/LibraryTable.tsx | 60 +++++-- .../src/pages/LibraryPage/SearchPanel.tsx | 137 +++++++++------ .../EditorRSFormCard/EditorLibraryItem.tsx | 2 +- .../EditorRSFormCard/EditorRSFormCard.tsx | 2 +- rsconcept/frontend/src/utils/constants.ts | 3 + rsconcept/frontend/src/utils/labels.ts | 15 ++ 17 files changed, 603 insertions(+), 115 deletions(-) create mode 100644 rsconcept/frontend/src/models/FolderTree.test.ts create mode 100644 rsconcept/frontend/src/models/FolderTree.ts create mode 100644 rsconcept/frontend/src/pages/LibraryPage/LibraryFolders.tsx diff --git a/rsconcept/frontend/src/components/Icons.tsx b/rsconcept/frontend/src/components/Icons.tsx index 623c0bb1..202423d4 100644 --- a/rsconcept/frontend/src/components/Icons.tsx +++ b/rsconcept/frontend/src/components/Icons.tsx @@ -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'; diff --git a/rsconcept/frontend/src/components/select/PickSchema.tsx b/rsconcept/frontend/src/components/select/PickSchema.tsx index 0d1cb27a..6117235d 100644 --- a/rsconcept/frontend/src/components/select/PickSchema.tsx +++ b/rsconcept/frontend/src/components/select/PickSchema.tsx @@ -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]); diff --git a/rsconcept/frontend/src/context/LibraryContext.tsx b/rsconcept/frontend/src/context/LibraryContext.tsx index a93a4ce5..48e81743 100644 --- a/rsconcept/frontend/src/context/LibraryContext.tsx +++ b/rsconcept/frontend/src/context/LibraryContext.tsx @@ -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([]); const [loading, setLoading] = useState(false); const [processing, setProcessing] = useState(false); - const [error, setError] = useState(undefined); + const [loadingError, setLoadingError] = useState(undefined); + const [processingError, setProcessingError] = useState(undefined); const [cachedTemplates, setCachedTemplates] = useState([]); + 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 ( { schema.location = newLocation; - library.localUpdateItem(schema); - if (callback) callback(); + library.reloadItems(callback); } }); }, diff --git a/rsconcept/frontend/src/models/FolderTree.test.ts b/rsconcept/frontend/src/models/FolderTree.test.ts new file mode 100644 index 00000000..122ad08b --- /dev/null +++ b/rsconcept/frontend/src/models/FolderTree.test.ts @@ -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); + }); +}); diff --git a/rsconcept/frontend/src/models/FolderTree.ts b/rsconcept/frontend/src/models/FolderTree.ts new file mode 100644 index 00000000..ca9d6702 --- /dev/null +++ b/rsconcept/frontend/src/models/FolderTree.ts @@ -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; + 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 = 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) + }; + } +} diff --git a/rsconcept/frontend/src/models/library.ts b/rsconcept/frontend/src/models/library.ts index e0f5ec4c..6ad1abd9 100644 --- a/rsconcept/frontend/src/models/library.ts +++ b/rsconcept/frontend/src/models/library.ts @@ -26,9 +26,9 @@ export enum AccessPolicy { */ export enum LocationHead { USER = '/U', - LIBRARY = '/L', COMMON = '/S', - PROJECTS = '/P' + PROJECTS = '/P', + LIBRARY = '/L' } /** diff --git a/rsconcept/frontend/src/models/miscellaneous.ts b/rsconcept/frontend/src/models/miscellaneous.ts index 84a1d892..4ea90958 100644 --- a/rsconcept/frontend/src/models/miscellaneous.ts +++ b/rsconcept/frontend/src/models/miscellaneous.ts @@ -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; diff --git a/rsconcept/frontend/src/pages/CreateItemPage/FormCreateItem.tsx b/rsconcept/frontend/src/pages/CreateItemPage/FormCreateItem.tsx index b7aeec64..78eb2998 100644 --- a/rsconcept/frontend/src/pages/CreateItemPage/FormCreateItem.tsx +++ b/rsconcept/frontend/src/pages/CreateItemPage/FormCreateItem.tsx @@ -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(null); useEffect(() => { - setError(undefined); - }, [title, alias, setError]); + setProcessingError(undefined); + }, [title, alias, setProcessingError]); function handleCancel() { if (router.canBack()) { @@ -194,7 +194,7 @@ function FormCreateItem() {