F: Implement location editing + showSubfolders
This commit is contained in:
parent
118c5459f3
commit
37d022a030
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
@ -200,6 +200,7 @@
|
||||||
"Пакулина",
|
"Пакулина",
|
||||||
"пересинтез",
|
"пересинтез",
|
||||||
"Персиц",
|
"Персиц",
|
||||||
|
"подпапках",
|
||||||
"Присакарь",
|
"Присакарь",
|
||||||
"ПРОКСИМА",
|
"ПРОКСИМА",
|
||||||
"Родоструктурная",
|
"Родоструктурная",
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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'],
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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`,
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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' /> развернутая папка
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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',
|
||||||
|
|
||||||
|
|
|
@ -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}`,
|
||||||
|
|
Loading…
Reference in New Issue
Block a user