Fix UI bugs and styles + refactor contexts

This commit is contained in:
IRBorisov 2023-07-25 00:20:37 +03:00
parent 920a7baff4
commit 747ce126ae
20 changed files with 128 additions and 107 deletions

View File

@ -106,7 +106,8 @@ class RSForm(models.Model):
)
self._update_from_core()
self.save()
return Constituenta.objects.get(pk=result.pk)
result.refresh_from_db()
return result
@transaction.atomic
def move_cst(self, listCst: list['Constituenta'], target: int):

View File

@ -200,25 +200,26 @@ class TestRSFormViewset(APITestCase):
def test_delete_constituenta(self):
schema = self.rsform_owned
data = json.dumps({'items': [{'id': 1337}]})
response = self.client.post(f'/api/rsforms/{schema.id}/cst-multidelete/',
response = self.client.patch(f'/api/rsforms/{schema.id}/cst-multidelete/',
data=data, content_type='application/json')
self.assertEqual(response.status_code, 400)
x1 = Constituenta.objects.create(schema=schema, alias='X1', csttype='basic', order=1)
x2 = Constituenta.objects.create(schema=schema, alias='X2', csttype='basic', order=2)
data = json.dumps({'items': [{'id': x1.id}]})
response = self.client.post(f'/api/rsforms/{schema.id}/cst-multidelete/',
response = self.client.patch(f'/api/rsforms/{schema.id}/cst-multidelete/',
data=data, content_type='application/json')
x2.refresh_from_db()
schema.refresh_from_db()
self.assertEqual(response.status_code, 202)
self.assertEqual(len(response.data['items']), 1)
self.assertEqual(schema.constituents().count(), 1)
self.assertEqual(x2.alias, 'X2')
self.assertEqual(x2.order, 1)
x3 = Constituenta.objects.create(schema=self.rsform_unowned, alias='X1', csttype='basic', order=1)
data = json.dumps({'items': [{'id': x3.id}]})
response = self.client.post(f'/api/rsforms/{schema.id}/cst-multidelete/',
response = self.client.patch(f'/api/rsforms/{schema.id}/cst-multidelete/',
data=data, content_type='application/json')
self.assertEqual(response.status_code, 400)

View File

@ -69,14 +69,16 @@ class RSFormViewSet(viewsets.ModelViewSet):
response['Location'] = constituenta.get_absolute_url()
return response
@action(detail=True, methods=['post'], url_path='cst-multidelete')
@action(detail=True, methods=['patch'], url_path='cst-multidelete')
def cst_multidelete(self, request, pk):
''' Delete multiple constituents '''
schema: models.RSForm = self.get_object()
serializer = serializers.CstListSerlializer(data=request.data, context={'schema': schema})
serializer.is_valid(raise_exception=True)
schema.delete_cst(serializer.validated_data['constituents'])
return Response(status=202)
schema.refresh_from_db()
outSerializer = serializers.RSFormDetailsSerlializer(schema)
return Response(status=202, data=outSerializer.data)
@action(detail=True, methods=['patch'], url_path='cst-moveto')
def cst_moveto(self, request, pk):

View File

@ -7,7 +7,7 @@ interface CardProps {
function Card({title, widthClass='min-w-fit', children}: CardProps) {
return (
<div className={`border shadow-md py-2 clr-card px-6 ${widthClass}`}>
{ title && <h1 className='mb-2 text-xl font-bold'>{title}</h1> }
{ title && <h1 className='mb-2 text-xl font-bold whitespace-nowrap'>{title}</h1> }
{children}
</div>
);

View File

@ -1,5 +1,5 @@
import DataTable, { createTheme, TableProps } from 'react-data-table-component';
import { useTheme } from '../../context/ThemeContext';
import { useConceptTheme } from '../../context/ThemeContext';
export interface SelectionInfo<T> {
allSelected: boolean;
@ -38,7 +38,7 @@ createTheme('customDark', {
}, 'dark');
function DataTableThemed<T>({theme, ...props}: TableProps<T>) {
const { darkMode } = useTheme();
const { darkMode } = useConceptTheme();
return (
<DataTable<T>

View File

@ -6,35 +6,35 @@ import UserMenu from './UserMenu';
import { useAuth } from '../../context/AuthContext';
import UserTools from './UserTools';
import Logo from './Logo';
import { useState } from 'react';
import { useConceptTheme } from '../../context/ThemeContext';
function Navigation() {
const {user} = useAuth();
const navigate = useNavigate();
const [isActive, setActive] = useState(true);
const { noNavigation, toggleNoNavigation } = useConceptTheme();
const navigateCommon = () => navigate('/rsforms?filter=common');
const navigateHelp = () => navigate('/manuals');
return (
<nav className='sticky top-0 left-0 right-0 z-50'>
{isActive &&
{!noNavigation &&
<button
title='Скрыть навигацию'
className='absolute top-0 right-0 z-[60] w-[1.2rem] h-[4rem] border-b-2 border-l-2 clr-nav rounded-none'
onClick={() => setActive(!isActive)}
onClick={toggleNoNavigation}
>
<p>{'>'}</p><p>{'>'}</p>
</button>}
{!isActive &&
{noNavigation &&
<button
title='Показать навигацию'
className='absolute top-0 right-0 z-[60] w-[4rem] h-[1.6rem] border-b-2 border-l-2 clr-nav rounded-none'
onClick={() => setActive(!isActive)}
onClick={toggleNoNavigation}
>
{''}
</button>}
{isActive &&
{!noNavigation &&
<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 '>
<Logo title='КонцептПортал' />

View File

@ -1,9 +1,9 @@
import { useTheme } from '../../context/ThemeContext';
import { useConceptTheme } from '../../context/ThemeContext';
import { DarkThemeIcon, LightThemeIcon } from '../Icons';
import NavigationButton from './NavigationButton';
function ThemeSwitcher() {
const {darkMode, toggleDarkMode} = useTheme();
const {darkMode, toggleDarkMode} = useConceptTheme();
return (
<>
{darkMode && <NavigationButton icon={<LightThemeIcon />} description='Светлая тема' onClick={toggleDarkMode} />}

View File

@ -1,7 +1,7 @@
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import DropdownButton from '../Common/DropdownButton';
import { useTheme } from '../../context/ThemeContext';
import { useConceptTheme } from '../../context/ThemeContext';
import Dropdown from '../Common/Dropdown';
interface UserDropdownProps {
@ -9,7 +9,7 @@ interface UserDropdownProps {
}
function UserDropdown({hideDropdown}: UserDropdownProps) {
const {darkMode, toggleDarkMode} = useTheme();
const {darkMode, toggleDarkMode} = useConceptTheme();
const navigate = useNavigate();
const {user, logout} = useAuth();

View File

@ -1,8 +1,8 @@
import { ToastContainer, ToastContainerProps } from 'react-toastify';
import { useTheme } from '../context/ThemeContext';
import { useConceptTheme } from '../context/ThemeContext';
function ToasterThemed({theme, ...props}: ToastContainerProps) {
const { darkMode } = useTheme();
const { darkMode } = useConceptTheme();
return (
<ToastContainer

View File

@ -1,29 +1,30 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useState } from 'react';
import { ICurrentUser, IUserSignupData } from '../utils/models';
import { ErrorInfo } from '../components/BackendError';
import useLocalStorage from '../hooks/useLocalStorage';
import { getAuth, postLogin, postLogout, postSignup } from '../utils/backendAPI';
import { BackendCallback, getAuth, postLogin, postLogout, postSignup } from '../utils/backendAPI';
interface IAuthContext {
user: ICurrentUser | undefined
login: (username: string, password: string) => Promise<void>
logout: (onSuccess?: () => void) => Promise<void>
signup: (data: IUserSignupData) => Promise<void>
login: (username: string, password: string, callback?: BackendCallback) => Promise<void>
logout: (callback?: BackendCallback) => Promise<void>
signup: (data: IUserSignupData, callback?: BackendCallback) => Promise<void>
loading: boolean
error: ErrorInfo
setError: (error: ErrorInfo) => void
}
export const AuthContext = createContext<IAuthContext>({
user: undefined,
login: async () => {},
logout: async () => {},
signup: async () => {},
loading: false,
error: '',
setError: () => {}
});
const AuthContext = createContext<IAuthContext | null>(null);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error(
'useAuth has to be used within <AuthState.Provider>'
);
}
return context;
}
interface AuthStateProps {
children: React.ReactNode
@ -49,44 +50,49 @@ export const AuthState = ({ children }: AuthStateProps) => {
}, [setUser]
);
async function login(uname: string, pw: string, onSuccess?: () => void) {
async function login(uname: string, pw: string, callback?: BackendCallback) {
setError(undefined);
postLogin({
data: {username: uname, password: pw},
showError: true,
setLoading: setLoading,
onError: error => setError(error),
onSucccess: response => {
loadCurrentUser();
if(onSuccess) onSuccess();
onSucccess:
async (response) => {
await loadCurrentUser();
if(callback) callback(response);
}
});
}
async function logout() {
async function logout(callback?: BackendCallback) {
setError(undefined);
postLogout({
showError: true,
onSucccess: response => {
loadCurrentUser();
onSucccess:
async (response) => {
await loadCurrentUser();
if (callback) callback(response);
}
});
}
async function signup(data: IUserSignupData) {
async function signup(data: IUserSignupData, callback?: BackendCallback) {
setError(undefined);
postSignup({
data: data,
showError: true,
setLoading: setLoading,
onError: error => setError(error),
onSucccess: response => {
loadCurrentUser();
onSucccess:
async (response) => {
await loadCurrentUser();
if (callback) callback(response);
}
});
}
useEffect(() => {
useLayoutEffect(() => {
loadCurrentUser();
}, [loadCurrentUser])
@ -98,5 +104,3 @@ export const AuthState = ({ children }: AuthStateProps) => {
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);

View File

@ -6,7 +6,7 @@ import { useAuth } from './AuthContext';
import {
BackendCallback, deleteRSForm, getTRSFile,
patchConstituenta, patchMoveConstituenta, patchRSForm,
postClaimRSForm, postDeleteConstituenta, postNewConstituenta
postClaimRSForm, patchDeleteConstituenta, postNewConstituenta
} from '../utils/backendAPI';
import { toast } from 'react-toastify';
@ -175,17 +175,17 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
const cstDelete = useCallback(
async (data: any, callback?: BackendCallback) => {
setError(undefined);
postDeleteConstituenta(schemaID, {
patchDeleteConstituenta(schemaID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: error => setError(error),
onSucccess: async (response) => {
await reload();
setSchema(response.data);
if (callback) callback(response);
}
});
}, [schemaID, setError, reload]);
}, [schemaID, setError, setSchema]);
const cstMoveTo = useCallback(
async (data: any, callback?: BackendCallback) => {

View File

@ -1,16 +1,24 @@
import { createContext, useContext, useEffect } from 'react';
import { createContext, useContext, useEffect, useState } from 'react';
import useLocalStorage from '../hooks/useLocalStorage';
interface IThemeContext {
darkMode: boolean
noNavigation: boolean
toggleDarkMode: () => void
toggleNoNavigation: () => void
}
export const ThemeContext = createContext<IThemeContext>({
darkMode: true,
toggleDarkMode: () => {}
})
const ThemeContext = createContext<IThemeContext | null>(null);
export const useConceptTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error(
'useConceptTheme has to be used within <ThemeState.Provider>'
);
}
return context;
}
interface ThemeStateProps {
children: React.ReactNode
@ -18,6 +26,7 @@ interface ThemeStateProps {
export const ThemeState = ({ children }: ThemeStateProps) => {
const [darkMode, setDarkMode] = useLocalStorage('darkMode', false);
const [noNavigation, setNoNavigation] = useState(false);
const setDarkClass = (isDark: boolean) => {
const root = window.document.documentElement;
@ -29,21 +38,16 @@ export const ThemeState = ({ children }: ThemeStateProps) => {
root.setAttribute('data-color-scheme', !isDark ? 'light' : 'dark');
};
const toggleDarkMode = () => {
setDarkMode(!darkMode)
};
useEffect(() => {
setDarkClass(darkMode)
}, [darkMode]);
return (
<ThemeContext.Provider value={{
darkMode, toggleDarkMode
darkMode, toggleDarkMode: () => setDarkMode(prev => !prev),
noNavigation, toggleNoNavigation: () => setNoNavigation(prev => !prev),
}}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);

View File

@ -9,11 +9,16 @@ interface IUsersContext {
getUserLabel: (userID?: number) => string
}
export const UsersContext = createContext<IUsersContext>({
users: [],
reload: async () => {},
getUserLabel: () => ''
})
const UsersContext = createContext<IUsersContext | null>(null);
export const useUsers = () => {
const context = useContext(UsersContext);
if (!context) {
throw new Error(
'useUsers has to be used within <UsersState.Provider>'
);
}
return context;
}
interface UsersStateProps {
children: React.ReactNode
@ -64,5 +69,3 @@ export const UsersState = ({ children }: UsersStateProps) => {
</UsersContext.Provider>
);
}
export const useUsers = () => useContext(UsersContext);

View File

@ -30,8 +30,7 @@ function LoginPage() {
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!loading) {
login(username, password)
.then(() => navigate('/rsforms?filter=personal'));
login(username, password, () => navigate('/rsforms?filter=personal'));
}
};

View File

@ -64,7 +64,7 @@ function ConstituentEditor() {
'forms': activeCst?.term?.forms || [],
}
};
cstUpdate(data).then(() => toast.success('Изменения сохранены'));
cstUpdate(data, () => toast.success('Изменения сохранены'));
}
};
@ -74,13 +74,13 @@ function ConstituentEditor() {
return;
}
const data = {
'items': [activeID]
'items': [{'id': activeID}]
}
const index = schema.items.findIndex((cst) => cst.id === activeID);
if (index !== -1 && index + 1 < schema.items.length) {
setActiveID(schema.items[index + 1].id);
}
cstDelete(data).then(() => toast.success('Конституента удалена'));
cstDelete(data, () => toast.success('Конституента удалена'));
}, [activeID, schema, setActiveID, cstDelete]);
const handleAddNew = useCallback(

View File

@ -9,6 +9,7 @@ import Divider from '../../components/Common/Divider';
import { createAliasFor, getCstTypeLabel, getCstTypePrefix, getStatusInfo, getTypeLabel } from '../../utils/staticUI';
import CreateCstModal from './CreateCstModal';
import { AxiosResponse } from 'axios';
import { useConceptTheme } from '../../context/ThemeContext';
interface ConstituentsTableProps {
onOpenEdit: (cst: IConstituenta) => void
@ -19,6 +20,7 @@ function ConstituentsTable({onOpenEdit}: ConstituentsTableProps) {
schema, isEditable,
cstCreate, cstDelete, cstMoveTo
} = useRSForm();
const { noNavigation } = useConceptTheme();
const [selected, setSelected] = useState<number[]>([]);
const nothingSelected = useMemo(() => selected.length === 0, [selected]);
@ -71,7 +73,7 @@ function ConstituentsTable({onOpenEdit}: ConstituentsTableProps) {
'items': selected.map(id => { return {'id': id }; }),
'move_to': insertIndex
}
cstMoveTo(data).then(() => toast.info('Перемещение вверх ' + insertIndex));
cstMoveTo(data);
}, [selected, schema?.items, cstMoveTo]);
@ -81,20 +83,24 @@ function ConstituentsTable({onOpenEdit}: ConstituentsTableProps) {
if (!schema?.items || selected.length === 0) {
return;
}
let count = 0;
const currentIndex = schema.items.reduce((prev, cst, index) => {
if (selected.indexOf(cst.id) < 0) {
return prev;
} else if (prev === -1) {
} else {
count += 1;
if (prev === -1) {
return index;
}
return Math.max(prev, index);
}
}, -1);
const insertIndex = Math.min(schema.items.length - 1, currentIndex + 1) + 1
const insertIndex = Math.min(schema.items.length - 1, currentIndex - count + 2) + 1
const data = {
'items': selected.map(id => { return {'id': id }; }),
'move_to': insertIndex
}
cstMoveTo(data).then(() => toast.info('Перемещение вниз ' + insertIndex));
cstMoveTo(data);
}, [selected, schema?.items, cstMoveTo]);
// Generate new names for all constituents
@ -131,7 +137,6 @@ function ConstituentsTable({onOpenEdit}: ConstituentsTableProps) {
case 'ArrowUp': handleMoveUp(); return;
case 'ArrowDown': handleMoveDown(); return;
}
console.log(event);
}, [isEditable, selected, handleMoveUp, handleMoveDown]);
const columns = useMemo(() =>
@ -253,7 +258,10 @@ function ConstituentsTable({onOpenEdit}: ConstituentsTableProps) {
onCreate={handleAddNew}
/>
<div className='w-full'>
<div className='sticky top-[4rem] z-10 flex justify-start w-full gap-1 px-2 py-1 border-y items-center h-[2.2rem] clr-app'>
<div
className={'flex justify-start w-full gap-1 px-2 py-1 border-y items-center h-[2.2rem] clr-app'
+ (!noNavigation ? ' sticky z-10 top-[4rem]' : ' sticky z-10 top-[0rem]')}
>
<div className='mr-3 whitespace-nowrap'>Выбраны <span className='ml-2'><b>{selected.length}</b> из {schema?.stats?.count_all || 0}</span></div>
{isEditable && <div className='flex justify-start w-full gap-1'>
<Button

View File

@ -43,7 +43,7 @@ function RSFormCard() {
'comment': comment,
'is_common': common,
};
update(data).then(() => toast.success('Изменения сохранены'));
update(data, () => toast.success('Изменения сохранены'));
};
const handleDelete =

View File

@ -35,7 +35,7 @@ function RegisterPage() {
'first_name': firstName,
'last_name': lastName,
};
signup(data).then(() => setSuccess(true));
signup(data, () => setSuccess(true));
}
};

View File

@ -158,8 +158,8 @@ export async function postNewConstituenta(schema: string, request?: IFrontReques
});
}
export async function postDeleteConstituenta(schema: string, request?: IFrontRequest) {
AxiosPost({
export async function patchDeleteConstituenta(schema: string, request?: IFrontRequest) {
AxiosPatch<IRSForm>({
title: `Delete Constituents for RSForm id=${schema}: ${request?.data['items'].toString()}`,
endpoint: `${config.url.BASE}rsforms/${schema}/cst-multidelete/`,
request: request

View File

@ -207,7 +207,7 @@ export function getCstTypePrefix(type: CstType) {
case CstType.CONSTANT: return 'C';
case CstType.STRUCTURED: return 'S';
case CstType.AXIOM: return 'A';
case CstType.TERM: return 'T';
case CstType.TERM: return 'D';
case CstType.FUNCTION: return 'F';
case CstType.PREDICATE: return 'P';
case CstType.THEOREM: return 'T';
@ -259,17 +259,16 @@ export function extractGlobals(expression: string): Set<string> {
}
export function createAliasFor(type: CstType, schema: IRSForm): string {
let index = 1;
let prefix = getCstTypePrefix(type);
let name = prefix + index;
if (schema.items && schema.items.length > 0) {
for (let i = 0; i < schema.items.length; ++i) {
if (schema.items[i].alias === name) {
++index;
name = prefix + index;
i = 0;
if (!schema.items || schema.items.length <= 0) {
return `${prefix}1`;
}
const index = schema.items.reduce((prev, cst, index) => {
if (cst.cstType !== type) {
return prev;
}
}
return name;
index = Number(cst.alias.slice(1 - cst.alias.length)) + 1;
return Math.max(prev, index);
}, 1);
return `${prefix}${index}`;
}