F: Implement location editing + showSubfolders

This commit is contained in:
Ivan 2024-08-21 16:49:04 +03:00
parent 118c5459f3
commit 37d022a030
21 changed files with 270 additions and 39 deletions

View File

@ -200,6 +200,7 @@
"Пакулина", "Пакулина",
"пересинтез", "пересинтез",
"Персиц", "Персиц",
"подпапках",
"Присакарь", "Присакарь",
"ПРОКСИМА", "ПРОКСИМА",
"Родоструктурная", "Родоструктурная",

View File

@ -1,6 +1,6 @@
''' REST API: Serializers. ''' ''' REST API: Serializers. '''
from .basics import AccessPolicySerializer, LocationSerializer from .basics import AccessPolicySerializer, LocationSerializer, RenameLocationSerializer
from .data_access import ( from .data_access import (
LibraryItemBaseSerializer, LibraryItemBaseSerializer,
LibraryItemCloneSerializer, LibraryItemCloneSerializer,

View File

@ -19,6 +19,24 @@ class LocationSerializer(serializers.Serializer):
return attrs return attrs
class RenameLocationSerializer(serializers.Serializer):
''' Serializer: rename location. '''
target = serializers.CharField(max_length=500)
new_location = serializers.CharField(max_length=500)
def validate(self, attrs):
attrs = super().validate(attrs)
if not validate_location(attrs['target']):
raise serializers.ValidationError({
'target': msg.invalidLocation()
})
if not validate_location(attrs['target']):
raise serializers.ValidationError({
'new_location': msg.invalidLocation()
})
return attrs
class AccessPolicySerializer(serializers.Serializer): class AccessPolicySerializer(serializers.Serializer):
''' Serializer: Constituenta renaming. ''' ''' Serializer: Constituenta renaming. '''
access_policy = serializers.CharField() access_policy = serializers.CharField()

View File

@ -181,6 +181,42 @@ class TestLibraryViewset(EndpointTester):
self.unowned.refresh_from_db() self.unowned.refresh_from_db()
self.assertEqual(self.unowned.location, data['location']) self.assertEqual(self.unowned.location, data['location'])
@decl_endpoint('/api/library/rename-location', method='patch')
def test_rename_location(self):
self.owned.location = '/U/temp'
self.owned.save()
self.unowned.location = '/U/temp'
self.unowned.save()
owned2 = LibraryItem.objects.create(
title='Test3',
alias='T3',
owner=self.user,
location='/U/temp/123'
)
data = {
'target': '/U/temp',
'new_location': '/U/temp2'
}
self.executeBadData(data={})
self.executeBadData(data={'target:': '/U/temp'})
self.executeBadData(data={'new_location:': '/U/temp'})
self.executeBadData(data={'target:': 'invalid', 'new_location': '/U/temp'})
self.executeBadData(data={'target:': '/U/temp', 'new_location': 'invalid'})
self.executeOK(data=data)
self.owned.refresh_from_db()
self.unowned.refresh_from_db()
owned2.refresh_from_db()
self.assertEqual(self.owned.location, '/U/temp2')
self.assertEqual(self.unowned.location, '/U/temp')
self.assertEqual(owned2.location, '/U/temp2/123')
self.toggle_admin(True)
self.executeOK(data=data)
self.unowned.refresh_from_db()
self.assertEqual(self.unowned.location, '/U/temp2')
@decl_endpoint('/api/library/{item}/set-editors', method='patch') @decl_endpoint('/api/library/{item}/set-editors', method='patch')
def test_set_editors(self): def test_set_editors(self):
time_update = self.owned.time_update time_update = self.owned.time_update

View File

@ -82,7 +82,8 @@ class LibraryViewSet(viewsets.ModelViewSet):
access_level = permissions.ItemOwner access_level = permissions.ItemOwner
elif self.action in [ elif self.action in [
'create', 'create',
'clone' 'clone',
'rename_location'
]: ]:
access_level = permissions.GlobalUser access_level = permissions.GlobalUser
else: else:
@ -92,6 +93,42 @@ class LibraryViewSet(viewsets.ModelViewSet):
def _get_item(self) -> m.LibraryItem: def _get_item(self) -> m.LibraryItem:
return cast(m.LibraryItem, self.get_object()) return cast(m.LibraryItem, self.get_object())
@extend_schema(
summary='rename location',
tags=['Library'],
request=s.RenameLocationSerializer,
responses={
c.HTTP_200_OK: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=False, methods=['patch'], url_path='rename-location')
def rename_location(self, request: Request) -> HttpResponse:
''' Endpoint: Rename location. '''
serializer = s.RenameLocationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
target = serializer.validated_data['target']
new_location = serializer.validated_data['new_location']
if target == new_location:
return Response(status=c.HTTP_200_OK)
if new_location.startswith(m.LocationHead.LIBRARY) and not self.request.user.is_staff:
return Response(status=c.HTTP_403_FORBIDDEN)
with transaction.atomic():
changed: list[m.LibraryItem] = []
items = m.LibraryItem.objects \
.filter(Q(location=target) | Q(location__startswith=f'{target}/')) \
.only('location', 'owner_id')
for item in items:
if item.owner_id == self.request.user.pk or self.request.user.is_staff:
item.location = item.location.replace(target, new_location)
changed.append(item)
if changed:
m.LibraryItem.objects.bulk_update(changed, ['location'])
return Response(status=c.HTTP_200_OK)
@extend_schema( @extend_schema(
summary='clone item including contents', summary='clone item including contents',
tags=['Library'], tags=['Library'],

View File

@ -39,7 +39,7 @@ def can_edit_item(user, obj: Any) -> bool:
return True return True
item = _extract_item(obj) item = _extract_item(obj)
if item.owner == user: if item.owner_id == user.pk:
return True return True
if Editor.objects.filter( if Editor.objects.filter(

View File

@ -6,6 +6,7 @@ import {
ILibraryCreateData, ILibraryCreateData,
ILibraryItem, ILibraryItem,
ILibraryUpdateData, ILibraryUpdateData,
IRenameLocationData,
ITargetAccessPolicy, ITargetAccessPolicy,
ITargetLocation, ITargetLocation,
IVersionData IVersionData
@ -94,6 +95,13 @@ export function patchSetLocation(target: string, request: FrontPush<ITargetLocat
}); });
} }
export function patchRenameLocation(request: FrontPush<IRenameLocationData>) {
AxiosPatch({
endpoint: `/api/library/rename-location`,
request: request
});
}
export function patchSetEditors(target: string, request: FrontPush<ITargetUsers>) { export function patchSetEditors(target: string, request: FrontPush<ITargetUsers>) {
AxiosPatch({ AxiosPatch({
endpoint: `/api/library/${target}/set-editors`, endpoint: `/api/library/${target}/set-editors`,

View File

@ -24,6 +24,7 @@ import {
IconStatusIncalculable, IconStatusIncalculable,
IconStatusOK, IconStatusOK,
IconStatusUnknown, IconStatusUnknown,
IconSubfolders,
IconTemplates, IconTemplates,
IconTerm, IconTerm,
IconText, IconText,
@ -62,6 +63,14 @@ export function VisibilityIcon({ value, size = '1.25rem', className }: DomIconPr
} }
} }
export function SubfoldersIcon({ value, size = '1.25rem', className }: DomIconProps<boolean>) {
if (value) {
return <IconSubfolders size={size} className={className ?? 'clr-text-green'} />;
} else {
return <IconSubfolders size={size} className={className ?? 'clr-text-controls'} />;
}
}
export function LocationIcon({ value, size = '1.25rem', className }: DomIconProps<string>) { export function LocationIcon({ value, size = '1.25rem', className }: DomIconProps<string>) {
switch (value.substring(0, 2) as LocationHead) { switch (value.substring(0, 2) as LocationHead) {
case LocationHead.COMMON: case LocationHead.COMMON:

View File

@ -34,6 +34,8 @@ 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 { LuFolderTree as IconFolderTree } from 'react-icons/lu'; export { LuFolderTree as IconFolderTree } from 'react-icons/lu';
export { LuFolder as IconFolder } from 'react-icons/lu'; export { LuFolder as IconFolder } from 'react-icons/lu';
export { LuFolders as IconSubfolders } from 'react-icons/lu';
export { LuFolderEdit as IconFolderEdit } from 'react-icons/lu';
export { LuFolderOpen as IconFolderOpened } from 'react-icons/lu'; export { LuFolderOpen as IconFolderOpened } from 'react-icons/lu';
export { LuFolderClosed as IconFolderClosed } from 'react-icons/lu'; export { LuFolderClosed as IconFolderClosed } from 'react-icons/lu';
export { LuFolderDot as IconFolderEmpty } from 'react-icons/lu'; export { LuFolderDot as IconFolderEmpty } from 'react-icons/lu';

View File

@ -8,13 +8,14 @@ import {
getAdminLibrary, getAdminLibrary,
getLibrary, getLibrary,
getTemplates, getTemplates,
patchRenameLocation,
postCloneLibraryItem, postCloneLibraryItem,
postCreateLibraryItem postCreateLibraryItem
} from '@/backend/library'; } from '@/backend/library';
import { getRSFormDetails, postRSFormFromFile } from '@/backend/rsforms'; import { getRSFormDetails, postRSFormFromFile } from '@/backend/rsforms';
import { ErrorData } from '@/components/info/InfoError'; import { ErrorData } from '@/components/info/InfoError';
import { FolderTree } from '@/models/FolderTree'; import { FolderTree } from '@/models/FolderTree';
import { ILibraryItem, LibraryItemID, LocationHead } from '@/models/library'; import { ILibraryItem, IRenameLocationData, 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';
@ -45,6 +46,7 @@ interface ILibraryContext {
createItem: (data: ILibraryCreateData, callback?: DataCallback<ILibraryItem>) => void; createItem: (data: ILibraryCreateData, callback?: DataCallback<ILibraryItem>) => void;
cloneItem: (target: LibraryItemID, data: IRSFormCloneData, callback: DataCallback<IRSFormData>) => void; cloneItem: (target: LibraryItemID, data: IRSFormCloneData, callback: DataCallback<IRSFormData>) => void;
destroyItem: (target: LibraryItemID, callback?: () => void) => void; destroyItem: (target: LibraryItemID, callback?: () => void) => void;
renameLocation: (data: IRenameLocationData, callback?: () => void) => void;
localUpdateItem: (data: ILibraryItem) => void; localUpdateItem: (data: ILibraryItem) => void;
localUpdateTimestamp: (target: LibraryItemID) => void; localUpdateTimestamp: (target: LibraryItemID) => void;
@ -92,8 +94,14 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
result = result.filter(item => item.location.startsWith(filter.head!)); result = result.filter(item => item.location.startsWith(filter.head!));
} }
if (filter.folderMode && filter.location) { if (filter.folderMode && filter.location) {
if (filter.subfolders) {
result = result.filter(
item => item.location == filter.location || item.location.startsWith(filter.location! + '/')
);
} else {
result = result.filter(item => item.location == filter.location); result = result.filter(item => item.location == filter.location);
} }
}
if (filter.type) { if (filter.type) {
result = result.filter(item => item.item_type === filter.type); result = result.filter(item => item.item_type === filter.type);
} }
@ -270,6 +278,23 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
[reloadItems, user] [reloadItems, user]
); );
const renameLocation = useCallback(
(data: IRenameLocationData, callback?: () => void) => {
setProcessingError(undefined);
patchRenameLocation({
data: data,
showError: true,
setLoading: setProcessing,
onError: setProcessingError,
onSuccess: () =>
reloadItems(() => {
if (callback) callback();
})
});
},
[reloadItems, user]
);
return ( return (
<LibraryContext.Provider <LibraryContext.Provider
value={{ value={{
@ -290,6 +315,8 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
createItem, createItem,
cloneItem, cloneItem,
destroyItem, destroyItem,
renameLocation,
retrieveTemplate, retrieveTemplate,
localUpdateItem, localUpdateItem,
localUpdateTimestamp localUpdateTimestamp

View File

@ -45,7 +45,7 @@ function DlgChangeLocation({ hideWindow, initial, onChangeLocation }: DlgChangeL
onSubmit={() => onChangeLocation(location)} onSubmit={() => onChangeLocation(location)}
className={clsx('w-[35rem]', 'pb-3 px-6 flex gap-3')} className={clsx('w-[35rem]', 'pb-3 px-6 flex gap-3')}
> >
<div className='flex flex-col gap-2 w-[7rem] h-min'> <div className='flex flex-col gap-2 min-w-[7rem] h-min'>
<Label className='select-none' text='Корень' /> <Label className='select-none' text='Корень' />
<SelectLocationHead <SelectLocationHead
value={head} // prettier: split-lines value={head} // prettier: split-lines

View File

@ -132,6 +132,14 @@ export interface ITargetLocation {
location: string; location: string;
} }
/**
* Represents update data for renaming Location.
*/
export interface IRenameLocationData {
target: string;
new_location: string;
}
/** /**
* Represents data, used for creating {@link IRSForm}. * Represents data, used for creating {@link IRSForm}.
*/ */

View File

@ -180,6 +180,7 @@ export interface ILibraryFilter {
query?: string; query?: string;
folderMode?: boolean; folderMode?: boolean;
subfolders?: boolean;
path?: string; path?: string;
head?: LocationHead; head?: LocationHead;
location?: string; location?: string;

View File

@ -2,14 +2,17 @@
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import DataLoader from '@/components/wrap/DataLoader'; import DataLoader from '@/components/wrap/DataLoader';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { useLibrary } from '@/context/LibraryContext'; import { useLibrary } from '@/context/LibraryContext';
import DlgChangeLocation from '@/dialogs/DlgChangeLocation';
import useLocalStorage from '@/hooks/useLocalStorage'; import useLocalStorage from '@/hooks/useLocalStorage';
import { ILibraryItem, LocationHead } from '@/models/library'; import { ILibraryItem, IRenameLocationData, LocationHead } from '@/models/library';
import { ILibraryFilter } from '@/models/miscellaneous'; import { ILibraryFilter } from '@/models/miscellaneous';
import { storage } from '@/utils/constants'; import { storage } from '@/utils/constants';
import { information } from '@/utils/labels';
import { toggleTristateFlag } from '@/utils/utils'; import { toggleTristateFlag } from '@/utils/utils';
import TableLibraryItems from './TableLibraryItems'; import TableLibraryItems from './TableLibraryItems';
@ -26,14 +29,12 @@ function LibraryPage() {
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 [folderMode, setFolderMode] = useLocalStorage<boolean>(storage.librarySearchFolderMode, true);
const [subfolders, setSubfolders] = useLocalStorage<boolean>(storage.librarySearchSubfolders, false);
const [location, setLocation] = useLocalStorage<string>(storage.librarySearchLocation, ''); const [location, setLocation] = useLocalStorage<string>(storage.librarySearchLocation, '');
const [isVisible, setIsVisible] = useLocalStorage<boolean | undefined>(storage.librarySearchVisible, true); const [isVisible, setIsVisible] = useLocalStorage<boolean | undefined>(storage.librarySearchVisible, true);
const [isSubscribed, setIsSubscribed] = useLocalStorage<boolean | undefined>(
storage.librarySearchSubscribed,
undefined
);
const [isOwned, setIsOwned] = useLocalStorage<boolean | undefined>(storage.librarySearchEditor, undefined); const [isOwned, setIsOwned] = useLocalStorage<boolean | undefined>(storage.librarySearchEditor, undefined);
const [isEditor, setIsEditor] = useLocalStorage<boolean | undefined>(storage.librarySearchEditor, undefined); const [isEditor, setIsEditor] = useLocalStorage<boolean | undefined>(storage.librarySearchEditor, undefined);
const [showRenameLocation, setShowRenameLocation] = useState(false);
const filter: ILibraryFilter = useMemo( const filter: ILibraryFilter = useMemo(
() => ({ () => ({
@ -42,12 +43,12 @@ function LibraryPage() {
query: query, query: query,
isEditor: user ? isEditor : undefined, isEditor: user ? isEditor : undefined,
isOwned: user ? isOwned : undefined, isOwned: user ? isOwned : undefined,
isSubscribed: user ? isSubscribed : undefined,
isVisible: user ? isVisible : true, isVisible: user ? isVisible : true,
folderMode: folderMode, folderMode: folderMode,
subfolders: subfolders,
location: location location: location
}), }),
[head, path, query, isEditor, isOwned, isSubscribed, isVisible, user, folderMode, location] [head, path, query, isEditor, isOwned, isVisible, user, folderMode, location, subfolders]
); );
const hasCustomFilter = useMemo( const hasCustomFilter = useMemo(
@ -70,17 +71,35 @@ function LibraryPage() {
const toggleOwned = useCallback(() => setIsOwned(prev => toggleTristateFlag(prev)), [setIsOwned]); const toggleOwned = useCallback(() => setIsOwned(prev => toggleTristateFlag(prev)), [setIsOwned]);
const toggleEditor = useCallback(() => setIsEditor(prev => toggleTristateFlag(prev)), [setIsEditor]); const toggleEditor = useCallback(() => setIsEditor(prev => toggleTristateFlag(prev)), [setIsEditor]);
const toggleFolderMode = useCallback(() => setFolderMode(prev => !prev), [setFolderMode]); const toggleFolderMode = useCallback(() => setFolderMode(prev => !prev), [setFolderMode]);
const toggleSubfolders = useCallback(() => setSubfolders(prev => !prev), [setSubfolders]);
const resetFilter = useCallback(() => { const resetFilter = useCallback(() => {
setQuery(''); setQuery('');
setPath(''); setPath('');
setHead(undefined); setHead(undefined);
setIsVisible(true); setIsVisible(true);
setIsSubscribed(undefined);
setIsOwned(undefined); setIsOwned(undefined);
setIsEditor(undefined); setIsEditor(undefined);
setLocation(''); setLocation('');
}, [setHead, setIsVisible, setIsSubscribed, setIsOwned, setIsEditor, setLocation]); }, [setHead, setIsVisible, setIsOwned, setIsEditor, setLocation]);
const promptRenameLocation = useCallback(() => {
setShowRenameLocation(true);
}, []);
const handleRenameLocation = useCallback(
(newLocation: string) => {
const data: IRenameLocationData = {
target: location,
new_location: newLocation
};
library.renameLocation(data, () => {
setLocation(newLocation);
toast.success(information.locationRenamed);
});
},
[location, library]
);
const viewLibrary = useMemo( const viewLibrary = useMemo(
() => ( () => (
@ -99,11 +118,14 @@ function LibraryPage() {
<ViewSideLocation <ViewSideLocation
active={location} active={location}
setActive={setLocation} setActive={setLocation}
subfolders={subfolders}
folderTree={library.folders} folderTree={library.folders}
toggleFolderMode={toggleFolderMode} toggleFolderMode={toggleFolderMode}
toggleSubfolders={toggleSubfolders}
onRenameLocation={promptRenameLocation}
/> />
), ),
[location, library.folders, setLocation, toggleFolderMode] [location, library.folders, setLocation, toggleFolderMode, subfolders]
); );
return ( return (
@ -113,6 +135,13 @@ function LibraryPage() {
error={library.loadingError} error={library.loadingError}
hasNoData={library.items.length === 0} hasNoData={library.items.length === 0}
> >
{showRenameLocation ? (
<DlgChangeLocation
initial={location}
onChangeLocation={handleRenameLocation}
hideWindow={() => setShowRenameLocation(false)}
/>
) : null}
<ToolbarSearch <ToolbarSearch
total={library.items.length ?? 0} total={library.items.length ?? 0}
filtered={items.length} filtered={items.length}

View File

@ -103,7 +103,9 @@ function ToolbarSearch({
'clr-input' 'clr-input'
)} )}
> >
<div className={clsx('px-3 pt-1 self-center', 'min-w-[5.5rem]', 'select-none', 'whitespace-nowrap')}> <div
className={clsx('px-3 pt-1 self-center', 'min-w-[6rem] sm:min-w-[7rem]', 'select-none', 'whitespace-nowrap')}
>
{filtered} из {total} {filtered} из {total}
</div> </div>

View File

@ -3,11 +3,14 @@ import { motion } from 'framer-motion';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { IconFolderTree } from '@/components/Icons'; import { SubfoldersIcon } from '@/components/DomainIcons';
import { IconFolderEdit, IconFolderTree } from '@/components/Icons';
import BadgeHelp from '@/components/info/BadgeHelp'; import BadgeHelp from '@/components/info/BadgeHelp';
import { CProps } from '@/components/props'; import { CProps } from '@/components/props';
import SelectLocation from '@/components/select/SelectLocation'; import SelectLocation from '@/components/select/SelectLocation';
import MiniButton from '@/components/ui/MiniButton'; import MiniButton from '@/components/ui/MiniButton';
import { useAuth } from '@/context/AuthContext';
import { useLibrary } from '@/context/LibraryContext';
import useWindowSize from '@/hooks/useWindowSize'; import useWindowSize from '@/hooks/useWindowSize';
import { FolderNode, FolderTree } from '@/models/FolderTree'; import { FolderNode, FolderTree } from '@/models/FolderTree';
import { HelpTopic } from '@/models/miscellaneous'; import { HelpTopic } from '@/models/miscellaneous';
@ -17,12 +20,25 @@ import { information } from '@/utils/labels';
interface ViewSideLocationProps { interface ViewSideLocationProps {
folderTree: FolderTree; folderTree: FolderTree;
subfolders: boolean;
active: string; active: string;
setActive: React.Dispatch<React.SetStateAction<string>>; setActive: React.Dispatch<React.SetStateAction<string>>;
toggleFolderMode: () => void; toggleFolderMode: () => void;
toggleSubfolders: () => void;
onRenameLocation: () => void;
} }
function ViewSideLocation({ folderTree, active, setActive: setActive, toggleFolderMode }: ViewSideLocationProps) { function ViewSideLocation({
folderTree,
active,
subfolders,
setActive: setActive,
toggleFolderMode,
toggleSubfolders,
onRenameLocation
}: ViewSideLocationProps) {
const { user } = useAuth();
const { items } = useLibrary();
const windowSize = useWindowSize(); const windowSize = useWindowSize();
const handleClickFolder = useCallback( const handleClickFolder = useCallback(
(event: CProps.EventMouse, target: FolderNode) => { (event: CProps.EventMouse, target: FolderNode) => {
@ -40,6 +56,18 @@ function ViewSideLocation({ folderTree, active, setActive: setActive, toggleFold
[setActive] [setActive]
); );
const canRename = useMemo(() => {
if (active.length <= 3 || !user) {
return false;
}
if (user.is_staff) {
return true;
}
const owned = items.filter(item => item.owner == user.id);
const located = owned.filter(item => item.location == active || item.location.startsWith(`${active}/`));
return located.length !== 0;
}, [active, user, items]);
const animations = useMemo(() => animateSideMinWidth(windowSize.isSmall ? '10rem' : '15rem'), [windowSize]); const animations = useMemo(() => animateSideMinWidth(windowSize.isSmall ? '10rem' : '15rem'), [windowSize]);
return ( return (
@ -56,12 +84,21 @@ function ViewSideLocation({ folderTree, active, setActive: setActive, toggleFold
offset={5} offset={5}
place='right-start' place='right-start'
/> />
<div className='cc-icons'>
<MiniButton
icon={<IconFolderEdit size='1.25rem' className='icon-primary' />}
titleHtml='<b>Редактирование пути</b><br/>Перемещаются только Ваши схемы<br/>в указанной папке (и подпапках)'
onClick={onRenameLocation}
disabled={!canRename}
/>
<MiniButton title='Вложенные папки' icon={<SubfoldersIcon value={subfolders} />} onClick={toggleSubfolders} />
<MiniButton <MiniButton
icon={<IconFolderTree size='1.25rem' className='icon-green' />} icon={<IconFolderTree size='1.25rem' className='icon-green' />}
title='Переключение в режим Поиск' title='Переключение в режим Поиск'
onClick={toggleFolderMode} onClick={toggleFolderMode}
/> />
</div> </div>
</div>
<SelectLocation <SelectLocation
value={active} value={active}
folderTree={folderTree} folderTree={folderTree}

View File

@ -2,6 +2,7 @@ import {
IconFilterReset, IconFilterReset,
IconFolder, IconFolder,
IconFolderClosed, IconFolderClosed,
IconFolderEdit,
IconFolderEmpty, IconFolderEmpty,
IconFolderOpened, IconFolderOpened,
IconFolderTree, IconFolderTree,
@ -10,9 +11,12 @@ import {
IconSearch, IconSearch,
IconShow, IconShow,
IconSortAsc, IconSortAsc,
IconSortDesc IconSortDesc,
IconSubfolders
} from '@/components/Icons'; } from '@/components/Icons';
import LinkTopic from '@/components/ui/LinkTopic';
import { useConceptOptions } from '@/context/ConceptOptionsContext'; import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { HelpTopic } from '@/models/miscellaneous';
function HelpLibrary() { function HelpLibrary() {
const { colors } = useConceptOptions(); const { colors } = useConceptOptions();
@ -20,8 +24,10 @@ function HelpLibrary() {
<div> <div>
<h1>Библиотека схем</h1> <h1>Библиотека схем</h1>
<p> <p>
В библиотеке собраны <IconRSForm size='1rem' className='inline-icon' /> системы определений (КС) <br />и В библиотеке собраны <IconRSForm size='1rem' className='inline-icon' />{' '}
<IconOSS size='1rem' className='inline-icon' /> операционные схемы синтеза (ОСС). <LinkTopic text='концептуальные схемы' topic={HelpTopic.CC_SYSTEM} /> (КС) <br />и
<IconOSS size='1rem' className='inline-icon' />{' '}
<LinkTopic text='операционные схемы синтеза' topic={HelpTopic.CC_OSS} /> (ОСС).
</p> </p>
<li> <li>
@ -51,20 +57,26 @@ function HelpLibrary() {
</li> </li>
<h2>Режим: Проводник</h2> <h2>Режим: Проводник</h2>
<li>клик по папке отображает справа файлы в ней</li> <li>
<IconFolderEdit size='1rem' className='inline-icon' /> переименовать выбранную
</li>
<li>
<IconSubfolders size='1rem' className='inline-icon icon-green' /> схемы во вложенных папках
</li>
<li>клик по папке отображает справа схемы в ней</li>
<li>Ctrl + клик по папке копирует путь в буфер обмена</li> <li>Ctrl + клик по папке копирует путь в буфер обмена</li>
<li>клик по иконке сворачивает/разворачивает вложенные</li> <li>клик по иконке сворачивает/разворачивает вложенные</li>
<li> <li>
<IconFolderEmpty size='1rem' className='inline-icon clr-text-default' /> папка без файлов <IconFolderEmpty size='1rem' className='inline-icon clr-text-default' /> папка без схем
</li> </li>
<li> <li>
<IconFolderEmpty size='1rem' className='inline-icon' /> папка с вложенными без файлов <IconFolderEmpty size='1rem' className='inline-icon' /> папка с вложенными без схем
</li> </li>
<li> <li>
<IconFolder size='1rem' className='inline-icon' /> папка без вложенных <IconFolder size='1rem' className='inline-icon' /> папка без вложенных
</li> </li>
<li> <li>
<IconFolderClosed size='1rem' className='inline-icon' /> папка с вложенными и файлами <IconFolderClosed size='1rem' className='inline-icon' /> папка с вложенными и схемами
</li> </li>
<li> <li>
<IconFolderOpened size='1rem' className='inline-icon icon-green' /> развернутая папка <IconFolderOpened size='1rem' className='inline-icon icon-green' /> развернутая папка

View File

@ -9,6 +9,10 @@
--toastify-color-dark: var(--cd-bg-60); --toastify-color-dark: var(--cd-bg-60);
} }
.cm-tooltip {
z-index: 100;
}
.cm-editor { .cm-editor {
resize: vertical; resize: vertical;
overflow-y: auto; overflow-y: auto;

View File

@ -3,7 +3,7 @@
*/ */
import { syntaxTree } from '@codemirror/language'; import { syntaxTree } from '@codemirror/language';
import { NodeType, Tree, TreeCursor } from '@lezer/common'; import { NodeType, Tree, TreeCursor } from '@lezer/common';
import { EditorState, ReactCodeMirrorRef, SelectionRange } from '@uiw/react-codemirror'; import { EditorState, ReactCodeMirrorRef, SelectionRange, TooltipView } from '@uiw/react-codemirror';
import clsx from 'clsx'; import clsx from 'clsx';
import { ReferenceTokens } from '@/components/RefsInput/parse'; import { ReferenceTokens } from '@/components/RefsInput/parse';
@ -165,10 +165,9 @@ export function findReferenceAt(pos: number, state: EditorState) {
/** /**
* Create DOM tooltip for {@link Constituenta}. * Create DOM tooltip for {@link Constituenta}.
*/ */
export function domTooltipConstituenta(cst?: IConstituenta, canClick?: boolean) { export function domTooltipConstituenta(cst?: IConstituenta, canClick?: boolean): TooltipView {
const dom = document.createElement('div'); const dom = document.createElement('div');
dom.className = clsx( dom.className = clsx(
'z-topmost',
'max-h-[25rem] max-w-[25rem] min-w-[10rem]', 'max-h-[25rem] max-w-[25rem] min-w-[10rem]',
'dense', 'dense',
'p-2', 'p-2',
@ -183,6 +182,8 @@ export function domTooltipConstituenta(cst?: IConstituenta, canClick?: boolean)
dom.appendChild(text); dom.appendChild(text);
} else { } else {
const alias = document.createElement('p'); const alias = document.createElement('p');
alias.className = 'font-math';
alias.style.overflowWrap = 'anywhere';
alias.innerHTML = `<b>${cst.alias}:</b> ${labelCstTypification(cst)}`; alias.innerHTML = `<b>${cst.alias}:</b> ${labelCstTypification(cst)}`;
dom.appendChild(alias); dom.appendChild(alias);
@ -244,10 +245,9 @@ export function domTooltipEntityReference(
cst: IConstituenta | undefined, cst: IConstituenta | undefined,
colors: IColorTheme, colors: IColorTheme,
canClick?: boolean canClick?: boolean
) { ): TooltipView {
const dom = document.createElement('div'); const dom = document.createElement('div');
dom.className = clsx( dom.className = clsx(
'z-topmost',
'max-h-[25rem] max-w-[25rem] min-w-[10rem]', 'max-h-[25rem] max-w-[25rem] min-w-[10rem]',
'dense', 'dense',
'p-2 flex flex-col', 'p-2 flex flex-col',
@ -303,10 +303,9 @@ export function domTooltipSyntacticReference(
ref: ISyntacticReference, ref: ISyntacticReference,
masterRef: string | undefined, masterRef: string | undefined,
canClick?: boolean canClick?: boolean
) { ): TooltipView {
const dom = document.createElement('div'); const dom = document.createElement('div');
dom.className = clsx( dom.className = clsx(
'z-topmost',
'max-h-[25rem] max-w-[25rem] min-w-[10rem]', 'max-h-[25rem] max-w-[25rem] min-w-[10rem]',
'dense', 'dense',
'p-2 flex flex-col', 'p-2 flex flex-col',

View File

@ -108,10 +108,10 @@ export const storage = {
librarySearchHead: 'library.search.head', librarySearchHead: 'library.search.head',
librarySearchFolderMode: 'library.search.folder_mode', librarySearchFolderMode: 'library.search.folder_mode',
librarySearchSubfolders: 'library.search.subfolders',
librarySearchLocation: 'library.search.location', librarySearchLocation: 'library.search.location',
librarySearchVisible: 'library.search.visible', librarySearchVisible: 'library.search.visible',
librarySearchOwned: 'library.search.owned', librarySearchOwned: 'library.search.owned',
librarySearchSubscribed: 'library.search.subscribed',
librarySearchEditor: 'library.search.editor', librarySearchEditor: 'library.search.editor',
libraryPagination: 'library.pagination', libraryPagination: 'library.pagination',

View File

@ -931,6 +931,7 @@ export const information = {
moveComplete: 'Перемещение завершено', moveComplete: 'Перемещение завершено',
linkReady: 'Ссылка скопирована', linkReady: 'Ссылка скопирована',
versionRestored: 'Загрузка версии завершена', versionRestored: 'Загрузка версии завершена',
locationRenamed: 'Ваши схемы перемещены',
cloneComplete: (alias: string) => `Копия создана: ${alias}`, cloneComplete: (alias: string) => `Копия создана: ${alias}`,
addedConstituents: (count: number) => `Добавлены конституенты: ${count}`, addedConstituents: (count: number) => `Добавлены конституенты: ${count}`,