Improve RSForm and Constituenta UI

includeing RSForm list filtering
This commit is contained in:
IRBorisov 2023-07-16 20:25:55 +03:00
parent db547b9f61
commit 555784b0b1
18 changed files with 252 additions and 47 deletions

View File

@ -3,6 +3,7 @@ import { config } from './constants'
import { ErrorInfo } from './components/BackendError' import { ErrorInfo } from './components/BackendError'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { ICurrentUser, IRSForm, IUserInfo, IUserProfile } from './models' import { ICurrentUser, IRSForm, IUserInfo, IUserProfile } from './models'
import { FilterType, RSFormsFilter } from './hooks/useRSForms'
export type BackendCallback = (response: AxiosResponse) => void; export type BackendCallback = (response: AxiosResponse) => void;
@ -69,10 +70,17 @@ export async function getActiveUsers(request?: IFrontRequest) {
}); });
} }
export async function getRSForms(request?: IFrontRequest) { export async function getRSForms(filter: RSFormsFilter, request?: IFrontRequest) {
let endpoint: string = ''
if (filter.type === FilterType.PERSONAL) {
endpoint = `${config.url.BASE}rsforms?owner=${filter.data!}`
} else {
endpoint = `${config.url.BASE}rsforms?is_common=true`
}
AxiosGet<IRSForm[]>({ AxiosGet<IRSForm[]>({
title: `RSForms list`, title: `RSForms list`,
endpoint: `${config.url.BASE}rsforms/`, endpoint: endpoint,
request: request request: request
}); });
} }

View File

@ -6,7 +6,7 @@ interface CheckboxProps {
required?: boolean required?: boolean
disabled?: boolean disabled?: boolean
widthClass?: string widthClass?: string
value?: any value?: boolean
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
} }
@ -17,7 +17,7 @@ function Checkbox({id, required, disabled, label, widthClass='w-full', value, on
className='relative cursor-pointer peer w-4 h-4 shrink-0 mt-0.5 bg-white border rounded-sm appearance-none dark:bg-gray-900 checked:bg-blue-700 dark:checked:bg-orange-500' className='relative cursor-pointer peer w-4 h-4 shrink-0 mt-0.5 bg-white border rounded-sm appearance-none dark:bg-gray-900 checked:bg-blue-700 dark:checked:bg-orange-500'
required={required} required={required}
disabled={disabled} disabled={disabled}
value={value} checked={value}
onChange={onChange} onChange={onChange}
/> />
<Label <Label

View File

@ -1,6 +1,6 @@
interface SubmitButtonProps { interface SubmitButtonProps {
text: string text: string
loading: boolean loading?: boolean
disabled?: boolean disabled?: boolean
} }

View File

@ -21,7 +21,7 @@ function TextInput({id, type, required, label, disabled, placeholder, widthClass
htmlFor={id} htmlFor={id}
/> />
<input id={id} <input id={id}
className={'px-3 py-2 mt-2 leading-tight border shadow dark:bg-gray-800 '+ widthClass} className={'px-3 py-2 mt-2 leading-tight border shadow dark:bg-gray-800 truncate hover:text-clip '+ widthClass}
required={required} required={required}
type={type} type={type}
placeholder={placeholder} placeholder={placeholder}

View File

@ -10,7 +10,7 @@ function UserTools() {
}; };
const navigateMyWork = () => { const navigateMyWork = () => {
navigate('/rsforms?filter=owned'); navigate('/rsforms?filter=personal');
}; };
return ( return (

View File

@ -14,7 +14,7 @@ interface IRSFormContext {
isEditable: boolean isEditable: boolean
isClaimable: boolean isClaimable: boolean
setActive: (cst: IConstituenta) => void setActive: (cst: IConstituenta | undefined) => void
reload: () => void reload: () => void
upload: (data: any, callback?: BackendCallback) => void upload: (data: any, callback?: BackendCallback) => void
destroy: (callback: BackendCallback) => void destroy: (callback: BackendCallback) => void
@ -51,12 +51,6 @@ export const RSFormState = ({ id, children }: RSFormStateProps) => {
const isEditable = useMemo(() => (user?.id === schema?.owner || user?.is_staff || false), [user, schema]); const isEditable = useMemo(() => (user?.id === schema?.owner || user?.is_staff || false), [user, schema]);
const isClaimable = useMemo(() => (user?.id !== schema?.owner || false), [user, schema]); const isClaimable = useMemo(() => (user?.id !== schema?.owner || false), [user, schema]);
useEffect(() => {
if (schema?.items && schema?.items.length > 0) {
setActive(schema?.items[0]);
}
}, [schema])
async function upload(data: any, callback?: BackendCallback) { async function upload(data: any, callback?: BackendCallback) {
setError(undefined); setError(undefined);
patchRSForm(id, { patchRSForm(id, {

View File

@ -1,15 +1,25 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useState } from 'react'
import { IRSForm } from '../models' import { IRSForm } from '../models'
import { ErrorInfo } from '../components/BackendError'; import { ErrorInfo } from '../components/BackendError';
import { getRSForms } from '../backendAPI'; import { getRSForms } from '../backendAPI';
export enum FilterType {
PERSONAL = 'personal',
COMMON = 'common'
}
export interface RSFormsFilter {
type: FilterType
data?: any
}
export function useRSForms() { export function useRSForms() {
const [rsforms, setRSForms] = useState<IRSForm[]>([]); const [rsforms, setRSForms] = useState<IRSForm[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<ErrorInfo>(undefined); const [error, setError] = useState<ErrorInfo>(undefined);
const fetchData = useCallback(async () => { const loadList = useCallback(async (filter: RSFormsFilter) => {
getRSForms({ getRSForms(filter, {
showError: true, showError: true,
setLoading: setLoading, setLoading: setLoading,
onError: error => setError(error), onError: error => setError(error),
@ -17,9 +27,5 @@ export function useRSForms() {
}); });
}, []); }, []);
useEffect(() => { return { rsforms, error, loading, loadList };
fetchData();
}, [fetchData])
return { rsforms, error, loading };
} }

View File

@ -3,7 +3,6 @@ import { IUserProfile } from '../models'
import { ErrorInfo } from '../components/BackendError' import { ErrorInfo } from '../components/BackendError'
import { getProfile } from '../backendAPI' import { getProfile } from '../backendAPI'
export function useUserProfile() { export function useUserProfile() {
const [user, setUser] = useState<IUserProfile | undefined>(undefined); const [user, setUser] = useState<IUserProfile | undefined>(undefined);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);

View File

@ -68,7 +68,7 @@ export enum ParsingStatus {
// Constituenta data // Constituenta data
export interface IConstituenta { export interface IConstituenta {
entityUID: number entityUID: number
alias: boolean alias: string
cstType: CstType cstType: CstType
convention?: string convention?: string
term?: { term?: {
@ -139,4 +139,17 @@ export function GetErrLabel(cst: IConstituenta) {
return 'св-во'; return 'св-во';
} }
return 'ОК'; return 'ОК';
}
export function GetCstTypeLabel(type: CstType) {
switch(type) {
case CstType.BASE: return 'Базисное множество';
case CstType.CONSTANT: return 'Константное множество';
case CstType.STRUCTURED: return 'Родовая структура';
case CstType.AXIOM: return 'Аксиома';
case CstType.TERM: return 'Терм';
case CstType.FUNCTION: return 'Терм-функция';
case CstType.PREDICATE: return 'Предикат-функция';
case CstType.THEOREM: return 'Теорема';
}
} }

View File

@ -30,7 +30,7 @@ function LoginPage() {
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => { const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if (!loading) { if (!loading) {
login(username, password, () => { navigate('/rsforms?filter=owned'); }); login(username, password, () => { navigate('/rsforms?filter=personal'); });
} }
}; };
@ -56,7 +56,7 @@ function LoginPage() {
<div className='flex items-center justify-between mt-4'> <div className='flex items-center justify-between mt-4'>
<SubmitButton text='Вход' loading={loading}/> <SubmitButton text='Вход' loading={loading}/>
<TextURL text='Восстановить пароль...' href='restore-password' /> <TextURL text='Восстановить пароль...' href='/restore-password' />
</div> </div>
<div className='mt-2'> <div className='mt-2'>
<TextURL text='Нет аккаунта? Зарегистрируйтесь...' href='/signup' /> <TextURL text='Нет аккаунта? Зарегистрируйтесь...' href='/signup' />

View File

@ -80,7 +80,7 @@ function RSFormCreatePage() {
/> />
<Checkbox id='common' label='Общедоступная схема' <Checkbox id='common' label='Общедоступная схема'
value={common} value={common}
onChange={event => setCommon(event.target.value === 'true')} onChange={event => setCommon(event.target.checked)}
/> />
<FileInput id='trs' label='Загрузить *.trs' <FileInput id='trs' label='Загрузить *.trs'
acceptType='.trs' acceptType='.trs'

View File

@ -1,14 +1,122 @@
import Card from '../../components/Common/Card'; import { useCallback, useEffect, useState } from 'react';
import PrettyJson from '../../components/Common/PrettyJSON'; import PrettyJson from '../../components/Common/PrettyJSON';
import { useRSForm } from '../../context/RSFormContext'; import { useRSForm } from '../../context/RSFormContext';
import { GetCstTypeLabel } from '../../models';
import { toast } from 'react-toastify';
import TextArea from '../../components/Common/TextArea';
import ExpressionEditor from './ExpressionEditor';
import SubmitButton from '../../components/Common/SubmitButton';
function ConstituentEditor() { function ConstituentEditor() {
const { active } = useRSForm(); const { active, schema, setActive, isEditable } = useRSForm();
const [alias, setAlias] = useState('');
const [type, setType] = useState('');
const [term, setTerm] = useState('');
const [textDefinition, setTextDefinition] = useState('');
const [expression, setExpression] = useState('');
const [convention, setConvention] = useState('');
useEffect(() => {
if (!active && schema?.items && schema?.items.length > 0) {
setActive(schema?.items[0]);
}
}, [schema, setActive, active])
useEffect(() => {
if (active) {
setAlias(active.alias);
setType(GetCstTypeLabel(active.cstType));
setTerm(active.term?.raw || '');
setTextDefinition(active.definition?.text?.raw || '');
setExpression(active.definition?.formal || '');
}
}, [active]);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
// if (!processing) {
// const data = {
// 'title': title,
// 'alias': alias,
// 'comment': comment,
// 'is_common': common,
// };
// upload(data, () => {
// toast.success('Изменения сохранены');
// reload();
// });
// }
};
const handleRename = useCallback(() => {
toast.info('Переименование в разработке');
}, []);
const handleChangeType = useCallback(() => {
toast.info('Изменение типа в разработке');
}, []);
return ( return (
<Card> <div className='flex items-start w-full gap-2'>
{active && <PrettyJson data={active}/>} <form onSubmit={handleSubmit} className='flex-grow min-w-[50rem] px-4 py-2 border'>
</Card> <div className='flex items-center justify-between gap-1'>
<span className='mr-12'>
<label
title='Переименовать конституенту'
className='font-semibold underline cursor-pointer'
onClick={handleRename}
>
ID
</label>
<b className='ml-2'>{alias}</b>
</span>
<span>
<label
title='Изменить тип конституенты'
className='font-semibold underline cursor-pointer'
onClick={handleChangeType}
>
Тип
</label>
<span className='ml-2'>{type}</span>
</span>
</div>
<TextArea id='term' label='Термин'
placeholder='Схемный или предметный термин, обозначающий данное понятие или утверждение'
rows={2}
value={term}
disabled={!isEditable}
onChange={event => setTerm(event.target.value)}
/>
<ExpressionEditor id='expression' label='Формальное выражение'
placeholder='Родоструктурное выражение, задающее формальное определение'
value={expression}
disabled={!isEditable}
onChange={event => setExpression(event.target.value)}
/>
<TextArea id='definition' label='Текстовое определение'
placeholder='Лингвистическая интерпретация формального выражения'
rows={4}
value={textDefinition}
disabled={!isEditable}
onChange={event => setTextDefinition(event.target.value)}
/>
<TextArea id='convention' label='Конвенция / Комментарий'
placeholder='Договоренность об интерпретации неопределяемых понятий или комментарий к производному понятию'
rows={4}
value={convention}
disabled={!isEditable}
onChange={event => setConvention(event.target.value)}
/>
<div className='flex items-center justify-between gap-1 py-2 mt-2'>
<SubmitButton text='Сохранить изменения' disabled={!isEditable} />
</div>
</form>
<PrettyJson data={active || ''} />
</div>
); );
} }

View File

@ -0,0 +1,35 @@
import Label from '../../components/Common/Label';
import { useRSForm } from '../../context/RSFormContext';
interface ExpressionEditorProps {
id: string
label: string
disabled?: boolean
placeholder?: string
value: any
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void
}
function ExpressionEditor({id, label, disabled, placeholder, value, onChange}: ExpressionEditorProps) {
const { schema } = useRSForm();
return (
<div className='flex flex-col items-start [&:not(:first-child)]:mt-3 w-full'>
<Label
text={label}
required={false}
htmlFor={id}
/>
<textarea id='comment'
className='w-full px-3 py-2 mt-2 leading-tight border shadow dark:bg-gray-800'
rows={6}
placeholder={placeholder}
value={value}
onChange={onChange}
disabled={disabled}
/>
</div>
);
}
export default ExpressionEditor;

View File

@ -49,7 +49,7 @@ function RSFormCard() {
if (window.confirm('Вы уверены, что хотите удалить данную схему?')) { if (window.confirm('Вы уверены, что хотите удалить данную схему?')) {
destroy(() => { destroy(() => {
toast.success('Схема удалена'); toast.success('Схема удалена');
navigate('/rsforms?filter=owned'); navigate('/rsforms?filter=personal');
}); });
} }
}, [destroy, navigate]); }, [destroy, navigate]);
@ -91,7 +91,7 @@ function RSFormCard() {
<Checkbox id='common' label='Общедоступная схема' <Checkbox id='common' label='Общедоступная схема'
value={common} value={common}
disabled={!isEditable} disabled={!isEditable}
onChange={event => setCommon(event.target.value === 'true')} onChange={event => setCommon(event.target.checked)}
/> />
<div className='flex items-center justify-between gap-1 py-2 mt-2'> <div className='flex items-center justify-between gap-1 py-2 mt-2'>

View File

@ -1,5 +1,6 @@
import { useRSForm } from '../../context/RSFormContext'; import { useRSForm } from '../../context/RSFormContext';
import Card from '../../components/Common/Card'; import Card from '../../components/Common/Card';
import PrettyJson from '../../components/Common/PrettyJSON';
function RSFormStats() { function RSFormStats() {
const { schema } = useRSForm(); const { schema } = useRSForm();
@ -10,6 +11,7 @@ function RSFormStats() {
<label className='font-semibold'>Всего конституент:</label> <label className='font-semibold'>Всего конституент:</label>
<span className='ml-2'>{schema!.items!.length}</span> <span className='ml-2'>{schema!.items!.length}</span>
</div> </div>
<PrettyJson data={schema || ''}/>
</Card> </Card>
); );
} }

View File

@ -2,30 +2,55 @@ import { Tabs, TabList, TabPanel } from 'react-tabs';
import ConstituentsTable from './ConstituentsTable'; import ConstituentsTable from './ConstituentsTable';
import { IConstituenta } from '../../models'; import { IConstituenta } from '../../models';
import { useRSForm } from '../../context/RSFormContext'; import { useRSForm } from '../../context/RSFormContext';
import { useState } from 'react'; import { useEffect } from 'react';
import ConceptTab from '../../components/Common/ConceptTab'; import ConceptTab from '../../components/Common/ConceptTab';
import RSFormCard from './RSFormCard'; import RSFormCard from './RSFormCard';
import { Loader } from '../../components/Common/Loader'; import { Loader } from '../../components/Common/Loader';
import BackendError from '../../components/BackendError'; import BackendError from '../../components/BackendError';
import ConstituentEditor from './ConstituentEditor'; import ConstituentEditor from './ConstituentEditor';
import RSFormStats from './RSFormStats'; import RSFormStats from './RSFormStats';
import useLocalStorage from '../../hooks/useLocalStorage';
import { useLocation } from 'react-router-dom';
enum RSFormTabs { enum TabsList {
CARD = 0, CARD = 0,
CST_LIST = 1, CST_LIST = 1,
CST_EDIT = 2 CST_EDIT = 2
} }
function RSFormEditor() { function RSFormTabs() {
const { setActive, error, schema, loading } = useRSForm(); const { setActive, active, error, schema, loading } = useRSForm();
const [tabIndex, setTabIndex] = useState(RSFormTabs.CARD); const [tabIndex, setTabIndex] = useLocalStorage('rsform_edit_tab', TabsList.CARD);
const search = useLocation().search;
const onEditCst = (cst: IConstituenta) => { const onEditCst = (cst: IConstituenta) => {
console.log(`Set active cst: ${cst.alias}`); console.log(`Set active cst: ${cst.alias}`);
setActive(cst); setActive(cst);
setTabIndex(RSFormTabs.CST_EDIT) setTabIndex(TabsList.CST_EDIT)
}; };
const onSelectTab = (index: number) => {
setTabIndex(index);
};
useEffect(() => {
const tabQuery = new URLSearchParams(search).get('tab');
const activeQuery = new URLSearchParams(search).get('active');
const activeCst = schema?.items?.find((cst) => cst.entityUID === Number(activeQuery)) || undefined;
setTabIndex(Number(tabQuery) || TabsList.CARD);
setActive(activeCst);
}, [search, setTabIndex, setActive, schema?.items]);
useEffect(() => {
if (schema) {
let url = `/rsforms/${schema.id}?tab=${tabIndex}`
if (active) {
url = url + `&active=${active.entityUID}`
}
window.history.replaceState(null, '', url);
}
}, [tabIndex, active, schema]);
return ( return (
<div className='container w-full'> <div className='container w-full'>
{ loading && <Loader /> } { loading && <Loader /> }
@ -33,7 +58,7 @@ function RSFormEditor() {
{ schema && !loading && { schema && !loading &&
<Tabs <Tabs
selectedIndex={tabIndex} selectedIndex={tabIndex}
onSelect={(index) => setTabIndex(index)} onSelect={onSelectTab}
defaultFocus={true} defaultFocus={true}
selectedTabClassName='font-bold' selectedTabClassName='font-bold'
> >
@ -43,7 +68,7 @@ function RSFormEditor() {
<ConceptTab>Редактор</ConceptTab> <ConceptTab>Редактор</ConceptTab>
</TabList> </TabList>
<TabPanel className='flex items-start w-full gap-2 '> <TabPanel className='flex items-start w-full gap-2'>
<RSFormCard /> <RSFormCard />
<RSFormStats /> <RSFormStats />
</TabPanel> </TabPanel>
@ -60,4 +85,4 @@ function RSFormEditor() {
</div>); </div>);
} }
export default RSFormEditor; export default RSFormTabs;

View File

@ -1,12 +1,12 @@
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { RSFormState } from '../../context/RSFormContext'; import { RSFormState } from '../../context/RSFormContext';
import RSFormEditor from './RSFormEditor'; import RSFormTabs from './RSFormTabs';
function RSFormPage() { function RSFormPage() {
const { id } = useParams(); const { id } = useParams();
return ( return (
<RSFormState id={id || ''}> <RSFormState id={id || ''}>
<RSFormEditor /> <RSFormTabs />
</RSFormState> </RSFormState>
); );
} }

View File

@ -1,10 +1,25 @@
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 { useRSForms } from '../../hooks/useRSForms' import { FilterType, RSFormsFilter, useRSForms } from '../../hooks/useRSForms'
import RSFormsTable from './RSFormsTable'; import RSFormsTable from './RSFormsTable';
import { useEffect } from 'react';
import { useAuth } from '../../context/AuthContext';
function RSFormsPage() { function RSFormsPage() {
const { rsforms, error, loading } = useRSForms(); const search = useLocation().search;
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);
let filter: RSFormsFilter = {type: type};
if (type === FilterType.PERSONAL) {
filter.data = user?.id;
}
loadList(filter);
}, [search, user, loadList]);
return ( return (
<div className='container'> <div className='container'>