Implement Schema search

This commit is contained in:
IRBorisov 2023-08-01 20:14:03 +03:00
parent f4af39e62e
commit a8bbb2b63c
14 changed files with 265 additions and 87 deletions

View File

@ -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): class TestConstituentaAPI(APITestCase):
def setUp(self): def setUp(self):
self.factory = APIRequestFactory() self.factory = APIRequestFactory()
@ -369,3 +373,29 @@ class TestFunctionalViews(APITestCase):
response = parse_expression(request) response = parse_expression(request)
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertIsInstance(response.data['expression'][0], ErrorDetail) 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))

View File

@ -7,6 +7,7 @@ rsform_router = routers.SimpleRouter()
rsform_router.register(r'rsforms', views.RSFormViewSet) rsform_router.register(r'rsforms', views.RSFormViewSet)
urlpatterns = [ urlpatterns = [
path('library/', views.LibraryView.as_view(), name='library'),
path('constituents/<int:pk>/', views.ConstituentAPIView.as_view(), name='constituenta-detail'), path('constituents/<int:pk>/', views.ConstituentAPIView.as_view(), name='constituenta-detail'),
path('rsforms/import-trs/', views.TrsImportView.as_view()), path('rsforms/import-trs/', views.TrsImportView.as_view()),
path('rsforms/create-detailed/', views.create_rsform), path('rsforms/create-detailed/', views.create_rsform),

View File

@ -1,6 +1,7 @@
import json import json
from django.http import HttpResponse from django.http import HttpResponse
from django_filters.rest_framework import DjangoFilterBackend 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 import views, viewsets, filters, generics, permissions
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
@ -12,6 +13,21 @@ from . import serializers
from . import utils 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): class ConstituentAPIView(generics.RetrieveUpdateAPIView):
queryset = models.Constituenta.objects.all() queryset = models.Constituenta.objects.all()
serializer_class = serializers.ConstituentaSerializer serializer_class = serializers.ConstituentaSerializer

View File

@ -39,7 +39,7 @@ function Navigation () {
<div className='pr-6 pl-2 py-2.5 h-[4rem] flex items-center justify-between border-b-2 clr-nav rounded-none'> <div className='pr-6 pl-2 py-2.5 h-[4rem] flex items-center justify-between border-b-2 clr-nav rounded-none'>
<div className='flex items-start justify-start '> <div className='flex items-start justify-start '>
<Logo title='КонцептПортал' /> <Logo title='КонцептПортал' />
<TopSearch placeholder='Поиск схемы...' /> <TopSearch />
</div> </div>
<div className='flex items-center'> <div className='flex items-center'>
{user && <UserTools/>} {user && <UserTools/>}

View File

@ -1,12 +1,24 @@
import { useNavigate } from 'react-router-dom';
import { useNavSearch } from '../../context/NavSearchContext';
import { MagnifyingGlassIcon } from '../Icons'; import { MagnifyingGlassIcon } from '../Icons';
interface TopSearchProps { function TopSearch() {
placeholder: string const navigate = useNavigate();
const { query, setQuery } = useNavSearch();
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
const url = new URL(window.location.href);
if (!url.href.includes('/library')) {
event.preventDefault();
navigate('/library?filter=query');
}
}
} }
function TopSearch({ placeholder }: TopSearchProps) {
return ( return (
<form action='#' method='GET' className='hidden md:block md:pl-2'> <div className='hidden md:block md:pl-2'>
<div className='relative md:w-96'> <div className='relative md:w-96'>
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'> <div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
<MagnifyingGlassIcon /> <MagnifyingGlassIcon />
@ -15,12 +27,14 @@ function TopSearch({ placeholder }: TopSearchProps) {
type='text' type='text'
name='email' name='email'
id='topbar-search' 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' 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} placeholder='Поиск схемы...'
// onChange={} onChange={data => setQuery(data.target.value)}
onKeyDown={handleKeyDown}
/> />
</div> </div>
</form> </div>
); );
} }

View File

@ -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<ILibraryContext | null>(null)
export const useLibrary = (): ILibraryContext => {
const context = useContext(LibraryContext);
if (context == null) {
throw new Error(
'useLibrary has to be used within <LibraryState.Provider>'
);
}
return context;
}
interface LibraryStateProps {
children: React.ReactNode
}
export const LibraryState = ({ children }: LibraryStateProps) => {
const [items, setItems] = useState<IRSFormMeta[]>([])
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ErrorInfo>(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 (
<LibraryContext.Provider value={{
items, loading, error, setError,
reload, filter
}}>
{ children }
</LibraryContext.Provider>
);
}

View File

@ -0,0 +1,38 @@
import { createContext, useCallback, useContext, useState } from 'react';
interface INavSearchContext {
query: string
setQuery: (value: string) => void
cleanQuery: () => void
}
const NavSearchContext = createContext<INavSearchContext | null>(null);
export const useNavSearch = () => {
const context = useContext(NavSearchContext);
if (!context) {
throw new Error(
'useNavSearch has to be used within <NavSearchState.Provider>'
);
}
return context;
}
interface NavSearchStateProps {
children: React.ReactNode
}
export const NavSearchState = ({ children }: NavSearchStateProps) => {
const [query, setQuery] = useState('');
const cleanQuery = useCallback(() => setQuery(''), []);
return (
<NavSearchContext.Provider value={{
query,
setQuery,
cleanQuery
}}>
{children}
</NavSearchContext.Provider>
);
}

View File

@ -45,9 +45,9 @@ export const ThemeState = ({ children }: ThemeStateProps) => {
return ( return (
<ThemeContext.Provider value={{ <ThemeContext.Provider value={{
darkMode, darkMode,
toggleDarkMode: () => { setDarkMode(prev => !prev); }, toggleDarkMode: () => setDarkMode(prev => !prev),
noNavigation, noNavigation,
toggleNoNavigation: () => { setNoNavigation(prev => !prev); } toggleNoNavigation: () => setNoNavigation(prev => !prev)
}}> }}>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>

View File

@ -27,7 +27,7 @@ interface UsersStateProps {
export const UsersState = ({ children }: UsersStateProps) => { export const UsersState = ({ children }: UsersStateProps) => {
const [users, setUsers] = useState<IUserInfo[]>([]) const [users, setUsers] = useState<IUserInfo[]>([])
const getUserLabel = (userID: number | null) => { function getUserLabel(userID: number | null) {
const user = users.find(({ id }) => id === userID) const user = users.find(({ id }) => id === userID)
if (!user) { if (!user) {
return (userID ? userID.toString() : 'Отсутствует'); return (userID ? userID.toString() : 'Отсутствует');
@ -53,12 +53,11 @@ export const UsersState = ({ children }: UsersStateProps) => {
onError: () => { setUsers([]); }, onError: () => { setUsers([]); },
onSuccess: newData => { setUsers(newData); } onSuccess: newData => { setUsers(newData); }
}); });
}, [setUsers] }, [setUsers]);
)
useEffect(() => { useEffect(() => {
reload(); reload();
}, [reload]) }, [reload]);
return ( return (
<UsersContext.Provider value={{ <UsersContext.Provider value={{

View File

@ -1,32 +0,0 @@
import { useCallback, useState } from 'react'
import { type ErrorInfo } from '../components/BackendError';
import { getRSForms } from '../utils/backendAPI';
import { IRSFormMeta } from '../utils/models'
export enum FilterType {
PERSONAL = 'personal',
COMMON = 'common'
}
export interface RSFormsFilter {
type: FilterType
data?: number | null
}
export function useRSForms() {
const [rsforms, setRSForms] = useState<IRSFormMeta[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ErrorInfo>(undefined);
const loadList = useCallback((filter: RSFormsFilter) => {
getRSForms(filter, {
showError: true,
setLoading,
onError: error => { setError(error); },
onSuccess: newData => { setRSForms(newData); }
});
}, []);
return { rsforms, error, loading, loadList };
}

View File

@ -9,6 +9,8 @@ import { BrowserRouter } from 'react-router-dom';
import App from './App.tsx' import App from './App.tsx'
import ErrorFallback from './components/ErrorFallback.tsx'; import ErrorFallback from './components/ErrorFallback.tsx';
import { AuthState } from './context/AuthContext.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 { ThemeState } from './context/ThemeContext.tsx';
import { UsersState } from './context/UsersContext.tsx'; import { UsersState } from './context/UsersContext.tsx';
import { initBackend } from './utils/backendAPI.ts'; import { initBackend } from './utils/backendAPI.ts';
@ -34,11 +36,17 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
> >
<IntlProvider locale='ru' defaultLocale='ru'> <IntlProvider locale='ru' defaultLocale='ru'>
<ThemeState> <ThemeState>
<NavSearchState>
<AuthState> <AuthState>
<UsersState> <UsersState>
<LibraryState>
<App /> <App />
</LibraryState>
</UsersState> </UsersState>
</AuthState> </AuthState>
</NavSearchState>
</ThemeState> </ThemeState>
</IntlProvider> </IntlProvider>
</ErrorBoundary> </ErrorBoundary>

View File

@ -1,32 +1,47 @@
import { useEffect } from 'react'; import { useLayoutEffect, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import BackendError from '../../components/BackendError' import BackendError from '../../components/BackendError'
import { Loader } from '../../components/Common/Loader' import { Loader } from '../../components/Common/Loader'
import { useAuth } from '../../context/AuthContext'; 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'; import ViewLibrary from './ViewLibrary';
function LibraryPage() { function LibraryPage() {
const search = useLocation().search; const search = useLocation().search;
const { query, cleanQuery } = useNavSearch();
const { user } = useAuth(); const { user } = useAuth();
const { rsforms, error, loading, loadList } = useRSForms(); const library = useLibrary();
useEffect(() => { const [ filterParams, setFilterParams ] = useState<ILibraryFilter>({});
const filterQuery = new URLSearchParams(search).get('filter'); const [ items, setItems ] = useState<IRSFormMeta[]>([]);
const type = (!user || !filterQuery ? FilterType.COMMON : filterQuery as FilterType);
const filter: RSFormsFilter = { type }; useLayoutEffect(() => {
if (type === FilterType.PERSONAL) { const filterType = new URLSearchParams(search).get('filter');
filter.data = user?.id; if (filterType === 'common') {
setFilterParams({
is_common: true
});
} else if (filterType === 'personal' && user) {
setFilterParams({
ownedBy: user.id!
});
} }
loadList(filter); }, [user, search, cleanQuery]);
}, [search, user, loadList]);
useLayoutEffect(() => {
const filter = filterParams;
filterParams.queryMeta = query ? query: undefined;
setItems(library.filter(filter));
}, [query, library, filterParams]);
return ( return (
<div className='w-full'> <div className='w-full'>
{ loading && <Loader /> } { library.loading && <Loader /> }
{ error && <BackendError error={error} />} { library.error && <BackendError error={library.error} />}
{ !loading && rsforms && <ViewLibrary schemas={rsforms} /> } { !library.loading && library.items && <ViewLibrary schemas={items} /> }
</div> </div>
); );
} }

View File

@ -2,7 +2,6 @@ import axios, { AxiosRequestConfig } from 'axios'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { type ErrorInfo } from '../components/BackendError' import { type ErrorInfo } from '../components/BackendError'
import { FilterType, RSFormsFilter } from '../hooks/useRSForms'
import { config } from './constants' import { config } from './constants'
import { import {
IConstituentaList, IConstituentaMeta, IConstituentaList, IConstituentaMeta,
@ -65,7 +64,7 @@ export function getAuth(request: FrontPull<ICurrentUser>) {
export function postLogin(request: FrontPush<IUserLoginData>) { export function postLogin(request: FrontPush<IUserLoginData>) {
AxiosPost({ AxiosPost({
title: 'Login', title: 'Login',
endpoint: `/users/api/login`, endpoint: '/users/api/login',
request: request request: request
}); });
} }
@ -73,7 +72,7 @@ export function postLogin(request: FrontPush<IUserLoginData>) {
export function postLogout(request: FrontAction) { export function postLogout(request: FrontAction) {
AxiosPost({ AxiosPost({
title: 'Logout', title: 'Logout',
endpoint: `/users/api/logout`, endpoint: '/users/api/logout',
request: request request: request
}); });
} }
@ -81,7 +80,7 @@ export function postLogout(request: FrontAction) {
export function postSignup(request: FrontExchange<IUserSignupData, IUserProfile>) { export function postSignup(request: FrontExchange<IUserSignupData, IUserProfile>) {
AxiosPost({ AxiosPost({
title: 'Register user', title: 'Register user',
endpoint: `/users/api/signup`, endpoint: '/users/api/signup',
request: request request: request
}); });
} }
@ -89,7 +88,7 @@ export function postSignup(request: FrontExchange<IUserSignupData, IUserProfile>
export function getProfile(request: FrontPull<IUserProfile>) { export function getProfile(request: FrontPull<IUserProfile>) {
AxiosGet({ AxiosGet({
title: 'Current user profile', title: 'Current user profile',
endpoint: `/users/api/profile`, endpoint: '/users/api/profile',
request: request request: request
}); });
} }
@ -97,7 +96,7 @@ export function getProfile(request: FrontPull<IUserProfile>) {
export function patchProfile(request: FrontExchange<IUserUpdateData, IUserProfile>) { export function patchProfile(request: FrontExchange<IUserUpdateData, IUserProfile>) {
AxiosPatch({ AxiosPatch({
title: 'Current user profile', title: 'Current user profile',
endpoint: `/users/api/profile`, endpoint: '/users/api/profile',
request: request request: request
}); });
} }
@ -105,19 +104,15 @@ export function patchProfile(request: FrontExchange<IUserUpdateData, IUserProfil
export function getActiveUsers(request: FrontPull<IUserInfo[]>) { export function getActiveUsers(request: FrontPull<IUserInfo[]>) {
AxiosGet({ AxiosGet({
title: 'Active users list', title: 'Active users list',
endpoint: `/users/api/active-users`, endpoint: '/users/api/active-users',
request: request request: request
}); });
} }
export function getRSForms(filter: RSFormsFilter, request: FrontPull<IRSFormMeta[]>) { export function getLibrary(request: FrontPull<IRSFormMeta[]>) {
const endpoint =
filter.type === FilterType.PERSONAL
? `/api/rsforms?owner=${filter.data as number}`
: `/api/rsforms?is_common=true`;
AxiosGet({ AxiosGet({
title: 'RSForms list', title: 'Available RSForms (Library) list',
endpoint: endpoint, endpoint: '/api/library/',
request: request request: request
}); });
} }
@ -125,7 +120,7 @@ export function getRSForms(filter: RSFormsFilter, request: FrontPull<IRSFormMeta
export function postNewRSForm(request: FrontExchange<IRSFormCreateData, IRSFormMeta>) { export function postNewRSForm(request: FrontExchange<IRSFormCreateData, IRSFormMeta>) {
AxiosPost({ AxiosPost({
title: 'New RSForm', title: 'New RSForm',
endpoint: `/api/rsforms/create-detailed/`, endpoint: '/api/rsforms/create-detailed/',
request: request, request: request,
options: { options: {
headers: { headers: {

View File

@ -199,6 +199,13 @@ export interface IRSFormUploadData {
fileName: string fileName: string
} }
// ========== Library =====
export interface ILibraryFilter {
ownedBy?: number
is_common?: boolean
queryMeta?: string
}
// ================ Misc types ================ // ================ Misc types ================
// Constituenta edit mode // Constituenta edit mode
export enum EditMode { export enum EditMode {
@ -312,13 +319,24 @@ export function matchConstituenta(query: string, target?: IConstituenta) {
return false; return false;
} else if (target.alias.match(query)) { } else if (target.alias.match(query)) {
return true; return true;
} else if (target.term?.resolved?.match(query)) { } else if (target.term.resolved.match(query)) {
return true; return true;
} else if (target.definition?.formal.match(query)) { } else if (target.definition.formal.match(query)) {
return true; return true;
} else if (target.definition?.text.resolved?.match(query)) { } else if (target.definition.text.resolved.match(query)) {
return true; 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; return true;
} else { } else {
return false; return false;