diff --git a/rsconcept/backend/apps/rsform/tests/t_views.py b/rsconcept/backend/apps/rsform/tests/t_views.py index 328bfddc..26539855 100644 --- a/rsconcept/backend/apps/rsform/tests/t_views.py +++ b/rsconcept/backend/apps/rsform/tests/t_views.py @@ -15,6 +15,10 @@ from apps.rsform.views import ( ) +def _response_contains(response, schema: RSForm) -> bool: + return any(x for x in response.data if x['id'] == schema.id) + + class TestConstituentaAPI(APITestCase): def setUp(self): self.factory = APIRequestFactory() @@ -369,3 +373,29 @@ class TestFunctionalViews(APITestCase): response = parse_expression(request) self.assertEqual(response.status_code, 400) self.assertIsInstance(response.data['expression'][0], ErrorDetail) + + +class TestLibraryAPI(APITestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = User.objects.create(username='UserTest') + self.client = APIClient() + self.client.force_authenticate(user=self.user) + self.rsform_owned: RSForm = RSForm.objects.create(title='Test', alias='T1', owner=self.user) + self.rsform_unowned: RSForm = RSForm.objects.create(title='Test2', alias='T2') + self.rsform_common: RSForm = RSForm.objects.create(title='Test3', alias='T3', is_common=True) + + def test_retrieve_common(self): + self.client.logout() + response = self.client.get('/api/library/') + self.assertEqual(response.status_code, 200) + self.assertTrue(_response_contains(response, self.rsform_common)) + self.assertFalse(_response_contains(response, self.rsform_unowned)) + self.assertFalse(_response_contains(response, self.rsform_owned)) + + def test_retrieve_owned(self): + response = self.client.get('/api/library/') + self.assertEqual(response.status_code, 200) + self.assertTrue(_response_contains(response, self.rsform_common)) + self.assertFalse(_response_contains(response, self.rsform_unowned)) + self.assertTrue(_response_contains(response, self.rsform_owned)) diff --git a/rsconcept/backend/apps/rsform/urls.py b/rsconcept/backend/apps/rsform/urls.py index 56456d50..e52ca68e 100644 --- a/rsconcept/backend/apps/rsform/urls.py +++ b/rsconcept/backend/apps/rsform/urls.py @@ -7,6 +7,7 @@ rsform_router = routers.SimpleRouter() rsform_router.register(r'rsforms', views.RSFormViewSet) urlpatterns = [ + path('library/', views.LibraryView.as_view(), name='library'), path('constituents//', views.ConstituentAPIView.as_view(), name='constituenta-detail'), path('rsforms/import-trs/', views.TrsImportView.as_view()), path('rsforms/create-detailed/', views.create_rsform), diff --git a/rsconcept/backend/apps/rsform/views.py b/rsconcept/backend/apps/rsform/views.py index 05327850..8eba85e7 100644 --- a/rsconcept/backend/apps/rsform/views.py +++ b/rsconcept/backend/apps/rsform/views.py @@ -1,6 +1,7 @@ import json from django.http import HttpResponse from django_filters.rest_framework import DjangoFilterBackend +from django.db.models import Q from rest_framework import views, viewsets, filters, generics, permissions from rest_framework.decorators import action from rest_framework.response import Response @@ -12,6 +13,21 @@ from . import serializers from . import utils +class LibraryView(generics.ListAPIView): + ''' + Get list of rsforms available for active user. + ''' + permission_classes = (permissions.AllowAny,) + serializer_class = serializers.RSFormSerializer + + def get_queryset(self): + user = self.request.user + if not user.is_anonymous: + return models.RSForm.objects.filter(Q(is_common=True) | Q(owner=user)) + else: + return models.RSForm.objects.filter(is_common=True) + + class ConstituentAPIView(generics.RetrieveUpdateAPIView): queryset = models.Constituenta.objects.all() serializer_class = serializers.ConstituentaSerializer diff --git a/rsconcept/frontend/src/components/Navigation/Navigation.tsx b/rsconcept/frontend/src/components/Navigation/Navigation.tsx index 22aecf90..a23d79f1 100644 --- a/rsconcept/frontend/src/components/Navigation/Navigation.tsx +++ b/rsconcept/frontend/src/components/Navigation/Navigation.tsx @@ -39,7 +39,7 @@ function Navigation () {
- +
{user && } diff --git a/rsconcept/frontend/src/components/Navigation/TopSearch.tsx b/rsconcept/frontend/src/components/Navigation/TopSearch.tsx index 154d8a43..2887308a 100644 --- a/rsconcept/frontend/src/components/Navigation/TopSearch.tsx +++ b/rsconcept/frontend/src/components/Navigation/TopSearch.tsx @@ -1,12 +1,24 @@ +import { useNavigate } from 'react-router-dom'; + +import { useNavSearch } from '../../context/NavSearchContext'; import { MagnifyingGlassIcon } from '../Icons'; -interface TopSearchProps { - placeholder: string -} +function TopSearch() { + const navigate = useNavigate(); + const { query, setQuery } = useNavSearch(); -function TopSearch({ placeholder }: TopSearchProps) { + function handleKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Enter') { + const url = new URL(window.location.href); + if (!url.href.includes('/library')) { + event.preventDefault(); + navigate('/library?filter=query'); + } + } + } + return ( -
+
@@ -15,12 +27,14 @@ function TopSearch({ placeholder }: TopSearchProps) { type='text' name='email' id='topbar-search' + value={query} className='text-sm block w-full pl-10 p-2.5 text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-600 dark:border-gray-400 dark:placeholder-gray-200 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500' - placeholder={placeholder} - // onChange={} + placeholder='Поиск схемы...' + onChange={data => setQuery(data.target.value)} + onKeyDown={handleKeyDown} />
- +
); } diff --git a/rsconcept/frontend/src/context/LibraryContext.tsx b/rsconcept/frontend/src/context/LibraryContext.tsx new file mode 100644 index 00000000..baaa9377 --- /dev/null +++ b/rsconcept/frontend/src/context/LibraryContext.tsx @@ -0,0 +1,76 @@ +import { createContext, useCallback, useContext, useEffect, useState } from 'react'; + +import { ErrorInfo } from '../components/BackendError'; +import { getLibrary } from '../utils/backendAPI'; +import { ILibraryFilter, IRSFormMeta, matchRSFormMeta } from '../utils/models'; + +interface ILibraryContext { + items: IRSFormMeta[] + loading: boolean + error: ErrorInfo + setError: (error: ErrorInfo) => void + + reload: () => void + filter: (params: ILibraryFilter) => IRSFormMeta[] +} + +const LibraryContext = createContext(null) +export const useLibrary = (): ILibraryContext => { + const context = useContext(LibraryContext); + if (context == null) { + throw new Error( + 'useLibrary has to be used within ' + ); + } + return context; +} + +interface LibraryStateProps { + children: React.ReactNode +} + +export const LibraryState = ({ children }: LibraryStateProps) => { + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(false); + const [error, setError] = useState(undefined); + + const filter = useCallback( + (params: ILibraryFilter) => { + let result = items; + if (params.ownedBy) { + result = result.filter(schema => schema.owner === params.ownedBy); + } + if (params.is_common !== undefined) { + result = result.filter(schema => schema.is_common === params.is_common); + } + if (params.queryMeta) { + result = result.filter(schema => matchRSFormMeta(params.queryMeta!, schema)); + } + return result; + }, [items]); + + const reload = useCallback( + () => { + setItems([]); + setError(undefined); + getLibrary({ + setLoading: setLoading, + showError: true, + onError: (error) => setError(error), + onSuccess: newData => { setItems(newData); } + }); + }, []); + + useEffect(() => { + reload(); + }, [reload]) + + return ( + + { children } + + ); +} diff --git a/rsconcept/frontend/src/context/NavSearchContext.tsx b/rsconcept/frontend/src/context/NavSearchContext.tsx new file mode 100644 index 00000000..917ba484 --- /dev/null +++ b/rsconcept/frontend/src/context/NavSearchContext.tsx @@ -0,0 +1,38 @@ +import { createContext, useCallback, useContext, useState } from 'react'; + +interface INavSearchContext { + query: string + setQuery: (value: string) => void + cleanQuery: () => void +} + +const NavSearchContext = createContext(null); +export const useNavSearch = () => { + const context = useContext(NavSearchContext); + if (!context) { + throw new Error( + 'useNavSearch has to be used within ' + ); + } + return context; +} + +interface NavSearchStateProps { + children: React.ReactNode +} + +export const NavSearchState = ({ children }: NavSearchStateProps) => { + const [query, setQuery] = useState(''); + + const cleanQuery = useCallback(() => setQuery(''), []); + + return ( + + {children} + + ); +} diff --git a/rsconcept/frontend/src/context/ThemeContext.tsx b/rsconcept/frontend/src/context/ThemeContext.tsx index feebb907..69ad107a 100644 --- a/rsconcept/frontend/src/context/ThemeContext.tsx +++ b/rsconcept/frontend/src/context/ThemeContext.tsx @@ -45,9 +45,9 @@ export const ThemeState = ({ children }: ThemeStateProps) => { return ( { setDarkMode(prev => !prev); }, + toggleDarkMode: () => setDarkMode(prev => !prev), noNavigation, - toggleNoNavigation: () => { setNoNavigation(prev => !prev); } + toggleNoNavigation: () => setNoNavigation(prev => !prev) }}> {children} diff --git a/rsconcept/frontend/src/context/UsersContext.tsx b/rsconcept/frontend/src/context/UsersContext.tsx index d7b236fa..3c783540 100644 --- a/rsconcept/frontend/src/context/UsersContext.tsx +++ b/rsconcept/frontend/src/context/UsersContext.tsx @@ -27,7 +27,7 @@ interface UsersStateProps { export const UsersState = ({ children }: UsersStateProps) => { const [users, setUsers] = useState([]) - const getUserLabel = (userID: number | null) => { + function getUserLabel(userID: number | null) { const user = users.find(({ id }) => id === userID) if (!user) { return (userID ? userID.toString() : 'Отсутствует'); @@ -47,18 +47,17 @@ export const UsersState = ({ children }: UsersStateProps) => { } const reload = useCallback( - () => { - getActiveUsers({ - showError: true, - onError: () => { setUsers([]); }, - onSuccess: newData => { setUsers(newData); } - }); - }, [setUsers] - ) + () => { + getActiveUsers({ + showError: true, + onError: () => { setUsers([]); }, + onSuccess: newData => { setUsers(newData); } + }); + }, [setUsers]); useEffect(() => { reload(); - }, [reload]) + }, [reload]); return ( ([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(undefined); - - const loadList = useCallback((filter: RSFormsFilter) => { - getRSForms(filter, { - showError: true, - setLoading, - onError: error => { setError(error); }, - onSuccess: newData => { setRSForms(newData); } - }); - }, []); - - return { rsforms, error, loading, loadList }; -} diff --git a/rsconcept/frontend/src/main.tsx b/rsconcept/frontend/src/main.tsx index 064d8486..16f7f0a5 100644 --- a/rsconcept/frontend/src/main.tsx +++ b/rsconcept/frontend/src/main.tsx @@ -9,6 +9,8 @@ import { BrowserRouter } from 'react-router-dom'; import App from './App.tsx' import ErrorFallback from './components/ErrorFallback.tsx'; import { AuthState } from './context/AuthContext.tsx'; +import { LibraryState } from './context/LibraryContext.tsx'; +import { NavSearchState } from './context/NavSearchContext.tsx'; import { ThemeState } from './context/ThemeContext.tsx'; import { UsersState } from './context/UsersContext.tsx'; import { initBackend } from './utils/backendAPI.ts'; @@ -34,11 +36,17 @@ ReactDOM.createRoot(document.getElementById('root')!).render( > + + + + + + diff --git a/rsconcept/frontend/src/pages/LibraryPage/index.tsx b/rsconcept/frontend/src/pages/LibraryPage/index.tsx index a66ee266..bad79ee0 100644 --- a/rsconcept/frontend/src/pages/LibraryPage/index.tsx +++ b/rsconcept/frontend/src/pages/LibraryPage/index.tsx @@ -1,32 +1,47 @@ -import { useEffect } from 'react'; +import { useLayoutEffect, useState } from 'react'; import { useLocation } from 'react-router-dom'; import BackendError from '../../components/BackendError' import { Loader } from '../../components/Common/Loader' import { useAuth } from '../../context/AuthContext'; -import { FilterType, type RSFormsFilter, useRSForms } from '../../hooks/useRSForms' +import { useLibrary } from '../../context/LibraryContext'; +import { useNavSearch } from '../../context/NavSearchContext'; +import { ILibraryFilter, IRSFormMeta } from '../../utils/models'; import ViewLibrary from './ViewLibrary'; function LibraryPage() { const search = useLocation().search; + const { query, cleanQuery } = useNavSearch(); const { user } = useAuth(); - const { rsforms, error, loading, loadList } = useRSForms(); - - useEffect(() => { - const filterQuery = new URLSearchParams(search).get('filter'); - const type = (!user || !filterQuery ? FilterType.COMMON : filterQuery as FilterType); - const filter: RSFormsFilter = { type }; - if (type === FilterType.PERSONAL) { - filter.data = user?.id; + const library = useLibrary(); + + const [ filterParams, setFilterParams ] = useState({}); + const [ items, setItems ] = useState([]); + + useLayoutEffect(() => { + const filterType = new URLSearchParams(search).get('filter'); + if (filterType === 'common') { + setFilterParams({ + is_common: true + }); + } else if (filterType === 'personal' && user) { + setFilterParams({ + ownedBy: user.id! + }); } - loadList(filter); - }, [search, user, loadList]); + }, [user, search, cleanQuery]); + + useLayoutEffect(() => { + const filter = filterParams; + filterParams.queryMeta = query ? query: undefined; + setItems(library.filter(filter)); + }, [query, library, filterParams]); return (
- { loading && } - { error && } - { !loading && rsforms && } + { library.loading && } + { library.error && } + { !library.loading && library.items && }
); } diff --git a/rsconcept/frontend/src/utils/backendAPI.ts b/rsconcept/frontend/src/utils/backendAPI.ts index b110709c..8d364431 100644 --- a/rsconcept/frontend/src/utils/backendAPI.ts +++ b/rsconcept/frontend/src/utils/backendAPI.ts @@ -2,7 +2,6 @@ import axios, { AxiosRequestConfig } from 'axios' import { toast } from 'react-toastify' import { type ErrorInfo } from '../components/BackendError' -import { FilterType, RSFormsFilter } from '../hooks/useRSForms' import { config } from './constants' import { IConstituentaList, IConstituentaMeta, @@ -65,7 +64,7 @@ export function getAuth(request: FrontPull) { export function postLogin(request: FrontPush) { AxiosPost({ title: 'Login', - endpoint: `/users/api/login`, + endpoint: '/users/api/login', request: request }); } @@ -73,7 +72,7 @@ export function postLogin(request: FrontPush) { export function postLogout(request: FrontAction) { AxiosPost({ title: 'Logout', - endpoint: `/users/api/logout`, + endpoint: '/users/api/logout', request: request }); } @@ -81,7 +80,7 @@ export function postLogout(request: FrontAction) { export function postSignup(request: FrontExchange) { AxiosPost({ title: 'Register user', - endpoint: `/users/api/signup`, + endpoint: '/users/api/signup', request: request }); } @@ -89,7 +88,7 @@ export function postSignup(request: FrontExchange export function getProfile(request: FrontPull) { AxiosGet({ title: 'Current user profile', - endpoint: `/users/api/profile`, + endpoint: '/users/api/profile', request: request }); } @@ -97,7 +96,7 @@ export function getProfile(request: FrontPull) { export function patchProfile(request: FrontExchange) { AxiosPatch({ title: 'Current user profile', - endpoint: `/users/api/profile`, + endpoint: '/users/api/profile', request: request }); } @@ -105,19 +104,15 @@ export function patchProfile(request: FrontExchange) { AxiosGet({ title: 'Active users list', - endpoint: `/users/api/active-users`, + endpoint: '/users/api/active-users', request: request }); } -export function getRSForms(filter: RSFormsFilter, request: FrontPull) { - const endpoint = - filter.type === FilterType.PERSONAL - ? `/api/rsforms?owner=${filter.data as number}` - : `/api/rsforms?is_common=true`; +export function getLibrary(request: FrontPull) { AxiosGet({ - title: 'RSForms list', - endpoint: endpoint, + title: 'Available RSForms (Library) list', + endpoint: '/api/library/', request: request }); } @@ -125,7 +120,7 @@ export function getRSForms(filter: RSFormsFilter, request: FrontPull) { AxiosPost({ title: 'New RSForm', - endpoint: `/api/rsforms/create-detailed/`, + endpoint: '/api/rsforms/create-detailed/', request: request, options: { headers: { diff --git a/rsconcept/frontend/src/utils/models.ts b/rsconcept/frontend/src/utils/models.ts index e3bcbb5f..f09e7f1e 100644 --- a/rsconcept/frontend/src/utils/models.ts +++ b/rsconcept/frontend/src/utils/models.ts @@ -199,6 +199,13 @@ export interface IRSFormUploadData { fileName: string } +// ========== Library ===== +export interface ILibraryFilter { + ownedBy?: number + is_common?: boolean + queryMeta?: string +} + // ================ Misc types ================ // Constituenta edit mode export enum EditMode { @@ -312,13 +319,24 @@ export function matchConstituenta(query: string, target?: IConstituenta) { return false; } else if (target.alias.match(query)) { return true; - } else if (target.term?.resolved?.match(query)) { + } else if (target.term.resolved.match(query)) { return true; - } else if (target.definition?.formal.match(query)) { + } else if (target.definition.formal.match(query)) { return true; - } else if (target.definition?.text.resolved?.match(query)) { + } else if (target.definition.text.resolved.match(query)) { return true; - } else if (target.convention?.match(query)) { + } else if (target.convention.match(query)) { + return true; + } else { + return false; + } +} + +export function matchRSFormMeta(query: string, target: IRSFormMeta) { + const queryI = query.toUpperCase(); + if (target.alias.toUpperCase().match(queryI)) { + return true; + } else if (target.title.toUpperCase().match(queryI)) { return true; } else { return false;