Multiple UI improvements for RSForm edit

This commit is contained in:
IRBorisov 2023-08-02 18:24:17 +03:00
parent 6bb034ae51
commit 47564c9d91
13 changed files with 470 additions and 245 deletions

View File

@ -1,3 +1,5 @@
import { useRef } from 'react';
import Label from './Label'; import Label from './Label';
export interface CheckboxProps { export interface CheckboxProps {
@ -11,21 +13,34 @@ export interface CheckboxProps {
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
} }
// TODO: implement disabled={disabled}
function Checkbox({ id, required, disabled, tooltip, label, widthClass = 'w-full', value, onChange }: CheckboxProps) { function Checkbox({ id, required, disabled, tooltip, label, widthClass = 'w-full', value, onChange }: CheckboxProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const cursor = disabled ? 'cursor-not-allowed' : 'cursor-pointer';
function handleLabelClick(event: React.MouseEvent<HTMLLabelElement, MouseEvent>): void {
event.preventDefault();
if (!disabled) {
inputRef.current?.click();
}
}
return ( return (
<div className={'flex gap-2 [&:not(:first-child)]:mt-3 ' + widthClass} title={tooltip}> <div className={'flex gap-2 [&:not(:first-child)]:mt-3 ' + widthClass} title={tooltip}>
<input id={id} type='checkbox' <input id={id} type='checkbox' ref={inputRef}
className='relative cursor-pointer disabled:cursor-not-allowed peer w-4 h-4 shrink-0 mt-0.5 border rounded-sm appearance-none clr-checkbox' className={`relative peer w-4 h-4 shrink-0 mt-0.5 border rounded-sm appearance-none clr-checkbox ${cursor}`}
required={required} required={required}
disabled={disabled} disabled={disabled}
checked={value} checked={value}
onChange={onChange} onChange={onChange}
/> />
{ label && <Label { label &&
<Label
className={`${cursor}`}
text={label} text={label}
required={required} required={required}
htmlFor={id} htmlFor={id}
onClick={handleLabelClick}
/>} />}
<svg <svg
className='absolute hidden w-3 h-3 mt-1 ml-0.5 text-white pointer-events-none peer-checked:block' className='absolute hidden w-3 h-3 mt-1 ml-0.5 text-white pointer-events-none peer-checked:block'

View File

@ -1,16 +1,17 @@
interface LabelProps { import { LabelHTMLAttributes } from 'react';
interface LabelProps
extends Omit<React.DetailedHTMLProps<LabelHTMLAttributes<HTMLLabelElement>, HTMLLabelElement>, 'children'> {
text: string text: string
htmlFor?: string
required?: boolean required?: boolean
title?: string
} }
function Label({ text, htmlFor, required = false, title }: LabelProps) { function Label({ text, required, title, className, ...props }: LabelProps) {
return ( return (
<label <label
className='text-sm font-semibold' className={`${className} text-sm font-semibold`}
htmlFor={htmlFor}
title={ (required && !title) ? 'обязательное поле' : title } title={ (required && !title) ? 'обязательное поле' : title }
{...props}
> >
{text + (required ? '*' : '')} {text + (required ? '*' : '')}
</label> </label>

View File

@ -9,15 +9,13 @@ import {
patchRSForm, patchRSForm,
patchUploadTRS, postClaimRSForm, postCloneRSForm,postNewConstituenta} from '../utils/backendAPI' patchUploadTRS, postClaimRSForm, postCloneRSForm,postNewConstituenta} from '../utils/backendAPI'
import { import {
IConstituenta, IConstituentaList, IConstituentaMeta, ICstCreateData, IConstituentaList, IConstituentaMeta, ICstCreateData,
ICstMovetoData, ICstUpdateData, IRSForm, IRSFormCreateData, IRSFormData, IRSFormMeta, IRSFormUpdateData, IRSFormUploadData ICstMovetoData, ICstUpdateData, IRSForm, IRSFormCreateData, IRSFormData, IRSFormMeta, IRSFormUpdateData, IRSFormUploadData
} from '../utils/models' } from '../utils/models'
import { useAuth } from './AuthContext' import { useAuth } from './AuthContext'
interface IRSFormContext { interface IRSFormContext {
schema?: IRSForm schema?: IRSForm
activeCst?: IConstituenta
activeID?: number
error: ErrorInfo error: ErrorInfo
loading: boolean loading: boolean
@ -30,7 +28,6 @@ interface IRSFormContext {
isTracking: boolean isTracking: boolean
isForceAdmin: boolean isForceAdmin: boolean
setActiveID: React.Dispatch<React.SetStateAction<number | undefined>>
toggleForceAdmin: () => void toggleForceAdmin: () => void
toggleReadonly: () => void toggleReadonly: () => void
toggleTracking: () => void toggleTracking: () => void
@ -69,7 +66,6 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
const { user } = useAuth() const { user } = useAuth()
const { schema, reload, error, setError, setSchema, loading } = useRSFormDetails({ target: schemaID }) const { schema, reload, error, setError, setSchema, loading } = useRSFormDetails({ target: schemaID })
const [processing, setProcessing] = useState(false) const [processing, setProcessing] = useState(false)
const [activeID, setActiveID] = useState<number | undefined>(undefined)
const [isForceAdmin, setIsForceAdmin] = useState(false) const [isForceAdmin, setIsForceAdmin] = useState(false)
const [isReadonly, setIsReadonly] = useState(false) const [isReadonly, setIsReadonly] = useState(false)
@ -84,11 +80,6 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
) )
}, [user?.is_staff, isReadonly, isForceAdmin, isOwned, loading]) }, [user?.is_staff, isReadonly, isForceAdmin, isOwned, loading])
const activeCst = useMemo(
() => {
return schema?.items?.find((cst) => cst.id === activeID)
}, [schema?.items, activeID])
const isTracking = useMemo( const isTracking = useMemo(
() => { () => {
return true return true
@ -272,7 +263,6 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
<RSFormContext.Provider value={{ <RSFormContext.Provider value={{
schema, schema,
error, loading, processing, error, loading, processing,
activeID, activeCst, setActiveID,
isForceAdmin, isReadonly, isOwned, isEditable, isForceAdmin, isReadonly, isOwned, isEditable,
isClaimable, isTracking, isClaimable, isTracking,
toggleForceAdmin: () => { setIsForceAdmin(prev => !prev) }, toggleForceAdmin: () => { setIsForceAdmin(prev => !prev) },

View File

@ -0,0 +1,62 @@
import { useMemo, useState } from 'react';
import Checkbox from '../../components/Common/Checkbox';
import Modal from '../../components/Common/Modal';
import { useRSForm } from '../../context/RSFormContext';
import { getCstLabel } from '../../utils/staticUI';
interface DlgDeleteCstProps {
hideWindow: () => void
selected: number[]
onDelete: (items: number[]) => void
}
function DlgDeleteCst({ hideWindow, selected, onDelete }: DlgDeleteCstProps) {
const { schema } = useRSForm();
const [ expandOut, setExpandOut ] = useState(false);
const expansion: number[] = useMemo(() => schema?.graph.expandOutputs(selected) ?? [], [selected, schema?.graph]);
function handleSubmit() {
hideWindow();
if (expandOut) {
onDelete(selected.concat(expansion));
} else {
onDelete(selected);
}
}
return (
<Modal
title='Удаление конституент'
hideWindow={hideWindow}
canSubmit={true}
submitText={expandOut ? 'Удалить с зависимыми' : 'Удалить'}
onSubmit={handleSubmit}
>
<div className='max-w-[60vw] min-w-[20rem]'>
<p>Выбраны к удалению: <b>{selected.length}</b></p>
<div className='px-3 border h-[9rem] overflow-y-auto whitespace-nowrap'>
{selected.map(id => {
const cst = schema!.items.find(cst => cst.id === id);
return (cst && <p>{getCstLabel(cst)}</p>);
})}
</div>
<p className='mt-4'>Зависимые конституенты: <b>{expansion.length}</b></p>
<div className='px-3 border h-[9rem] overflow-y-auto whitespace-nowrap'>
{expansion.map(id => {
const cst = schema!.items.find(cst => cst.id === id);
return (cst && <p>{getCstLabel(cst)}</p>);
})}
</div>
<Checkbox
label='Удалить зависимые конституенты'
value={expandOut}
onChange={data => setExpandOut(data.target.checked)}
/>
</div>
</Modal>
);
}
export default DlgDeleteCst;

View File

@ -1,5 +1,4 @@
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { useLayoutEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import MiniButton from '../../components/Common/MiniButton'; import MiniButton from '../../components/Common/MiniButton';
@ -11,19 +10,21 @@ import { type CstType, EditMode, ICstUpdateData, SyntaxTree } from '../../utils/
import { getCstTypeLabel } from '../../utils/staticUI'; import { getCstTypeLabel } from '../../utils/staticUI';
import EditorRSExpression from './EditorRSExpression'; import EditorRSExpression from './EditorRSExpression';
import ViewSideConstituents from './elements/ViewSideConstituents'; import ViewSideConstituents from './elements/ViewSideConstituents';
import { RSTabsList } from './RSTabs';
interface EditorConstituentaProps { interface EditorConstituentaProps {
activeID?: number
onOpenEdit: (cstID: number) => void
onShowAST: (expression: string, ast: SyntaxTree) => void onShowAST: (expression: string, ast: SyntaxTree) => void
onShowCreateCst: (selectedID: number | undefined, type: CstType | undefined) => void onCreateCst: (selectedID: number | undefined, type: CstType | undefined) => void
onDeleteCst: (selected: number[], callback?: (items: number[]) => void) => void
} }
function EditorConstituenta({ onShowAST, onShowCreateCst }: EditorConstituentaProps) { function EditorConstituenta({ activeID, onShowAST, onCreateCst, onOpenEdit, onDeleteCst }: EditorConstituentaProps) {
const navigate = useNavigate(); const { schema, processing, isEditable, cstUpdate } = useRSForm();
const { const activeCst = useMemo(
activeCst, activeID, schema, setActiveID, processing, isEditable, () => {
cstDelete, cstUpdate return schema?.items?.find((cst) => cst.id === activeID);
} = useRSForm(); }, [schema?.items, activeID]);
const [isModified, setIsModified] = useState(false); const [isModified, setIsModified] = useState(false);
const [editMode, setEditMode] = useState(EditMode.TEXT); const [editMode, setEditMode] = useState(EditMode.TEXT);
@ -38,12 +39,6 @@ function EditorConstituenta({ onShowAST, onShowCreateCst }: EditorConstituentaPr
const isEnabled = useMemo(() => activeCst && isEditable, [activeCst, isEditable]); const isEnabled = useMemo(() => activeCst && isEditable, [activeCst, isEditable]);
useLayoutEffect(() => {
if (schema && schema?.items.length > 0) {
setActiveID((prev) => (prev ?? schema.items[0].id ?? undefined));
}
}, [schema, setActiveID]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!activeCst) { if (!activeCst) {
setIsModified(false); setIsModified(false);
@ -71,8 +66,7 @@ function EditorConstituenta({ onShowAST, onShowCreateCst }: EditorConstituentaPr
} }
}, [activeCst]); }, [activeCst]);
const handleSubmit = function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if (!activeID || processing) { if (!activeID || processing) {
return; return;
@ -86,40 +80,29 @@ function EditorConstituenta({ onShowAST, onShowCreateCst }: EditorConstituentaPr
term_raw: term term_raw: term
}; };
cstUpdate(data, () => { toast.success('Изменения сохранены'); }); cstUpdate(data, () => { toast.success('Изменения сохранены'); });
}; }
const handleDelete = useCallback( function handleDelete() {
() => { if (!schema || !activeID) {
if (!activeID || !schema?.items || !window.confirm('Вы уверены, что хотите удалить конституенту?')) {
return; return;
} }
const data = { onDeleteCst([activeID]);
items: [{ id: activeID }]
} }
const index = schema.items.findIndex((cst) => cst.id === activeID);
let newActive: number | undefined = undefined
if (index !== -1 && index + 1 < schema.items.length) {
newActive = schema.items[index + 1].id;
}
cstDelete(data, () => toast.success('Конституента удалена'));
if (newActive) navigate(`/rsforms/${schema.id}?tab=${RSTabsList.CST_EDIT}&active=${newActive}`);
}, [activeID, schema, cstDelete, navigate]);
const handleAddNew = useCallback( function handleCreateCst() {
() => {
if (!activeID || !schema) { if (!activeID || !schema) {
return; return;
} }
onShowCreateCst(activeID, activeCst?.cstType); onCreateCst(activeID, activeCst?.cstType);
}, [activeID, activeCst?.cstType, schema, onShowCreateCst]); }
const handleRename = useCallback(() => { function handleRename() {
toast.info('Переименование в разработке'); toast.info('Переименование в разработке');
}, []); }
const handleChangeType = useCallback(() => { function handleChangeType() {
toast.info('Изменение типа в разработке'); toast.info('Изменение типа в разработке');
}, []); }
return ( return (
<div className='flex items-start w-full gap-2'> <div className='flex items-start w-full gap-2'>
@ -158,7 +141,7 @@ function EditorConstituenta({ onShowAST, onShowCreateCst }: EditorConstituentaPr
<MiniButton <MiniButton
tooltip='Создать конституенты после данной' tooltip='Создать конституенты после данной'
disabled={!isEnabled} disabled={!isEnabled}
onClick={handleAddNew} onClick={handleCreateCst}
icon={<SmallPlusIcon size={5} color={isEnabled ? 'text-green' : ''} />} icon={<SmallPlusIcon size={5} color={isEnabled ? 'text-green' : ''} />}
/> />
<MiniButton <MiniButton
@ -220,7 +203,11 @@ function EditorConstituenta({ onShowAST, onShowCreateCst }: EditorConstituentaPr
/> />
</div> </div>
</form> </form>
<ViewSideConstituents expression={expression}/> <ViewSideConstituents
expression={expression}
activeID={activeID}
onOpenEdit={onOpenEdit}
/>
</div> </div>
); );
} }

View File

@ -13,56 +13,32 @@ import { CstType, IConstituenta, ICstMovetoData } from '../../utils/models'
import { getCstTypePrefix, getCstTypeShortcut, getTypeLabel, mapStatusInfo } from '../../utils/staticUI'; import { getCstTypePrefix, getCstTypeShortcut, getTypeLabel, mapStatusInfo } from '../../utils/staticUI';
interface EditorItemsProps { interface EditorItemsProps {
onOpenEdit: (cst: IConstituenta) => void onOpenEdit: (cstID: number) => void
onShowCreateCst: (selectedID: number | undefined, type: CstType | undefined, skipDialog?: boolean) => void onCreateCst: (selectedID: number | undefined, type: CstType | undefined, skipDialog?: boolean) => void
onDeleteCst: (selected: number[], callback: (items: number[]) => void) => void
} }
function EditorItems({ onOpenEdit, onShowCreateCst }: EditorItemsProps) { function EditorItems({ onOpenEdit, onCreateCst, onDeleteCst }: EditorItemsProps) {
const { const { schema, isEditable, cstMoveTo, resetAliases } = useRSForm();
schema, isEditable,
cstDelete, cstMoveTo, resetAliases
} = useRSForm();
const { noNavigation } = useConceptTheme(); const { noNavigation } = useConceptTheme();
const [selected, setSelected] = useState<number[]>([]); const [selected, setSelected] = useState<number[]>([]);
const nothingSelected = useMemo(() => selected.length === 0, [selected]); const nothingSelected = useMemo(() => selected.length === 0, [selected]);
const [toggledClearRows, setToggledClearRows] = useState(false); const [toggledClearRows, setToggledClearRows] = useState(false);
const handleRowClicked = useCallback(
(cst: IConstituenta, event: React.MouseEvent<Element, MouseEvent>) => {
if (event.altKey) {
onOpenEdit(cst);
}
}, [onOpenEdit]);
const handleSelectionChange = useCallback(
({ selectedRows }: {
allSelected: boolean
selectedCount: number
selectedRows: IConstituenta[]
}) => {
setSelected(selectedRows.map(cst => cst.id));
}, [setSelected]);
// Delete selected constituents // Delete selected constituents
const handleDelete = useCallback(() => { function handleDelete() {
if (!schema?.items || !window.confirm('Вы уверены, что хотите удалить выбранные конституенты?')) { if (!schema) {
return; return;
} }
const data = { onDeleteCst(selected, () => {
items: selected.map(id => { return { id }; })
}
const deletedNames = selected.map(id => schema.items?.find(cst => cst.id === id)?.alias).join(', ');
cstDelete(data, () => {
toast.success(`Конституенты удалены: ${deletedNames}`);
setToggledClearRows(prev => !prev); setToggledClearRows(prev => !prev);
setSelected([]); setSelected([]);
}); });
}, [selected, schema?.items, cstDelete]); }
// Move selected cst up // Move selected cst up
const handleMoveUp = useCallback( function handleMoveUp() {
() => {
if (!schema?.items || selected.length === 0) { if (!schema?.items || selected.length === 0) {
return; return;
} }
@ -80,11 +56,10 @@ function EditorItems({ onOpenEdit, onShowCreateCst }: EditorItemsProps) {
move_to: target move_to: target
} }
cstMoveTo(data); cstMoveTo(data);
}, [selected, schema?.items, cstMoveTo]); }
// Move selected cst down // Move selected cst down
const handleMoveDown = useCallback( function handleMoveDown() {
() => {
if (!schema?.items || selected.length === 0) { if (!schema?.items || selected.length === 0) {
return; return;
} }
@ -106,16 +81,15 @@ function EditorItems({ onOpenEdit, onShowCreateCst }: EditorItemsProps) {
move_to: target move_to: target
} }
cstMoveTo(data); cstMoveTo(data);
}, [selected, schema?.items, cstMoveTo]); }
// Generate new names for all constituents // Generate new names for all constituents
const handleReindex = useCallback(() => { function handleReindex() {
resetAliases(() => toast.success('Переиндексация конституент успешна')); resetAliases(() => toast.success('Переиндексация конституент успешна'));
}, [resetAliases]); }
// Add new constituent // Add new constituenta
const handleAddNew = useCallback( function handleCreateCst(type?: CstType){
(type?: CstType) => {
if (!schema) { if (!schema) {
return; return;
} }
@ -124,8 +98,8 @@ function EditorItems({ onOpenEdit, onShowCreateCst }: EditorItemsProps) {
return Math.max(position, prev); return Math.max(position, prev);
}, -1); }, -1);
const insert_where = selectedPosition >= 0 ? schema.items[selectedPosition].id : undefined; const insert_where = selectedPosition >= 0 ? schema.items[selectedPosition].id : undefined;
onShowCreateCst(insert_where, type, type !== undefined); onCreateCst(insert_where, type, type !== undefined);
}, [schema, onShowCreateCst, selected]); }
// Implement hotkeys for working with constituents table // Implement hotkeys for working with constituents table
function handleTableKey(event: React.KeyboardEvent<HTMLDivElement>) { function handleTableKey(event: React.KeyboardEvent<HTMLDivElement>) {
@ -154,18 +128,34 @@ function EditorItems({ onOpenEdit, onShowCreateCst }: EditorItemsProps) {
} }
} }
switch (key) { switch (key) {
case '1': handleAddNew(CstType.BASE); return true; case '1': handleCreateCst(CstType.BASE); return true;
case '2': handleAddNew(CstType.STRUCTURED); return true; case '2': handleCreateCst(CstType.STRUCTURED); return true;
case '3': handleAddNew(CstType.TERM); return true; case '3': handleCreateCst(CstType.TERM); return true;
case '4': handleAddNew(CstType.AXIOM); return true; case '4': handleCreateCst(CstType.AXIOM); return true;
case 'q': handleAddNew(CstType.FUNCTION); return true; case 'q': handleCreateCst(CstType.FUNCTION); return true;
case 'w': handleAddNew(CstType.PREDICATE); return true; case 'w': handleCreateCst(CstType.PREDICATE); return true;
case '5': handleAddNew(CstType.CONSTANT); return true; case '5': handleCreateCst(CstType.CONSTANT); return true;
case '6': handleAddNew(CstType.THEOREM); return true; case '6': handleCreateCst(CstType.THEOREM); return true;
} }
return false; return false;
} }
const handleRowClicked = useCallback(
(cst: IConstituenta, event: React.MouseEvent<Element, MouseEvent>) => {
if (event.altKey) {
onOpenEdit(cst.id);
}
}, [onOpenEdit]);
const handleSelectionChange = useCallback(
({ selectedRows }: {
allSelected: boolean
selectedCount: number
selectedRows: IConstituenta[]
}) => {
setSelected(selectedRows.map(cst => cst.id));
}, [setSelected]);
const columns = useMemo(() => const columns = useMemo(() =>
[ [
{ {
@ -300,7 +290,7 @@ function EditorItems({ onOpenEdit, onShowCreateCst }: EditorItemsProps) {
tooltip='Новая конституента' tooltip='Новая конституента'
icon={<SmallPlusIcon color='text-green' size={6}/>} icon={<SmallPlusIcon color='text-green' size={6}/>}
dense dense
onClick={() => { handleAddNew(); }} onClick={() => handleCreateCst()}
/> />
{(Object.values(CstType)).map( {(Object.values(CstType)).map(
(typeStr) => { (typeStr) => {
@ -309,7 +299,7 @@ function EditorItems({ onOpenEdit, onShowCreateCst }: EditorItemsProps) {
text={`${getCstTypePrefix(type)}`} text={`${getCstTypePrefix(type)}`}
tooltip={getCstTypeShortcut(type)} tooltip={getCstTypeShortcut(type)}
dense dense
onClick={() => { handleAddNew(type); }} onClick={() => handleCreateCst(type)}
/>; />;
})} })}
<div id='items-table-help'> <div id='items-table-help'>
@ -359,7 +349,7 @@ function EditorItems({ onOpenEdit, onShowCreateCst }: EditorItemsProps) {
selectableRows selectableRows
selectableRowsHighlight selectableRowsHighlight
onSelectedRowsChange={handleSelectionChange} onSelectedRowsChange={handleSelectionChange}
onRowDoubleClicked={onOpenEdit} onRowDoubleClicked={cst => onOpenEdit(cst.id)}
onRowClicked={handleRowClicked} onRowClicked={handleRowClicked}
clearSelectedRows={toggledClearRows} clearSelectedRows={toggledClearRows}
dense dense

View File

@ -30,7 +30,7 @@ function EditorTermGraph() {
const result: GraphEdge[] = []; const result: GraphEdge[] = [];
let edgeID = 1; let edgeID = 1;
schema?.graph.nodes.forEach(source => { schema?.graph.nodes.forEach(source => {
source.adjacent.forEach(target => { source.outputs.forEach(target => {
result.push({ result.push({
id: String(edgeID), id: String(edgeID),
source: String(source.id), source: String(source.id),

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useState } from 'react'; import { useCallback, useLayoutEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { TabList, TabPanel, Tabs } from 'react-tabs'; import { TabList, TabPanel, Tabs } from 'react-tabs';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@ -7,12 +7,12 @@ import BackendError from '../../components/BackendError';
import ConceptTab from '../../components/Common/ConceptTab'; import ConceptTab from '../../components/Common/ConceptTab';
import { Loader } from '../../components/Common/Loader'; import { Loader } from '../../components/Common/Loader';
import { useRSForm } from '../../context/RSFormContext'; import { useRSForm } from '../../context/RSFormContext';
import useLocalStorage from '../../hooks/useLocalStorage'; import { prefixes, TIMEOUT_UI_REFRESH } from '../../utils/constants';
import { prefixes, timeout_updateUI } from '../../utils/constants'; import { CstType, ICstCreateData, SyntaxTree } from '../../utils/models';
import { CstType,type IConstituenta, ICstCreateData, SyntaxTree } from '../../utils/models';
import { createAliasFor } from '../../utils/staticUI'; import { createAliasFor } from '../../utils/staticUI';
import DlgCloneRSForm from './DlgCloneRSForm'; import DlgCloneRSForm from './DlgCloneRSForm';
import DlgCreateCst from './DlgCreateCst'; import DlgCreateCst from './DlgCreateCst';
import DlgDeleteCst from './DlgDeleteCst';
import DlgShowAST from './DlgShowAST'; import DlgShowAST from './DlgShowAST';
import DlgUploadRSForm from './DlgUploadRSForm'; import DlgUploadRSForm from './DlgUploadRSForm';
import EditorConstituenta from './EditorConstituenta'; import EditorConstituenta from './EditorConstituenta';
@ -31,73 +31,63 @@ export enum RSTabsList {
function RSTabs() { function RSTabs() {
const navigate = useNavigate(); const navigate = useNavigate();
const { setActiveID, activeID, error, schema, loading, cstCreate } = useRSForm(); const search = useLocation().search;
const {
error, schema, loading,
cstCreate, cstDelete
} = useRSForm();
const [activeTab, setActiveTab] = useLocalStorage('rsform_edit_tab', RSTabsList.CARD); const [activeTab, setActiveTab] = useState(RSTabsList.CARD);
const [activeID, setActiveID] = useState<number | undefined>(undefined)
const [init, setInit] = useState(false);
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);
const [showClone, setShowClone] = useState(false); const [showClone, setShowClone] = useState(false);
const [syntaxTree, setSyntaxTree] = useState<SyntaxTree>([]); const [syntaxTree, setSyntaxTree] = useState<SyntaxTree>([]);
const [expression, setExpression] = useState(''); const [expression, setExpression] = useState('');
const [showAST, setShowAST] = useState(false); const [showAST, setShowAST] = useState(false);
const [afterDelete, setAfterDelete] = useState<((items: number[]) => void) | undefined>(undefined);
const [toBeDeleted, setToBeDeleted] = useState<number[]>([]);
const [showDeleteCst, setShowDeleteCst] = useState(false);
const [defaultType, setDefaultType] = useState<CstType | undefined>(undefined); const [defaultType, setDefaultType] = useState<CstType | undefined>(undefined);
const [insertWhere, setInsertWhere] = useState<number | undefined>(undefined); const [insertWhere, setInsertWhere] = useState<number | undefined>(undefined);
const [showCreateCst, setShowCreateCst] = useState(false); const [showCreateCst, setShowCreateCst] = useState(false);
useLayoutEffect(() => { useLayoutEffect(() => {
if (schema) { if (schema) {
const url = new URL(window.location.href);
const activeQuery = url.searchParams.get('active');
const activeCst = schema.items.find((cst) => cst.id === Number(activeQuery));
setActiveID(activeCst?.id);
setInit(true);
const oldTitle = document.title const oldTitle = document.title
document.title = schema.title document.title = schema.title
return () => { return () => {
document.title = oldTitle document.title = oldTitle
} }
} }
}, [setActiveID, schema, schema?.title, setInit]); }, [schema]);
useEffect(() => { useLayoutEffect(() => {
const url = new URL(window.location.href); const activeTab = Number(new URLSearchParams(search).get('tab')) ?? RSTabsList.CARD;
const tabQuery = url.searchParams.get('tab'); const cstQuery = new URLSearchParams(search).get('active');
setActiveTab(Number(tabQuery) || RSTabsList.CARD); setActiveTab(activeTab);
}, [setActiveTab]); setActiveID(Number(cstQuery) ?? (schema && schema?.items.length > 0 && schema?.items[0]));
}, [search, setActiveTab, setActiveID, schema]);
useEffect(() => {
if (init) {
const url = new URL(window.location.href);
const currentActive = url.searchParams.get('active');
const currentTab = url.searchParams.get('tab');
const saveHistory = activeTab === RSTabsList.CST_EDIT && currentActive !== String(activeID);
if (currentTab !== String(activeTab)) {
url.searchParams.set('tab', String(activeTab));
}
if (activeID) {
if (currentActive !== String(activeID)) {
url.searchParams.set('active', String(activeID));
}
} else {
url.searchParams.delete('active');
}
if (saveHistory) {
window.history.pushState(null, '', url.toString());
} else {
window.history.replaceState(null, '', url.toString());
}
}
}, [activeTab, activeID, init]);
function onSelectTab(index: number) { function onSelectTab(index: number) {
setActiveTab(index); navigateTo(index, activeID);
} }
const handleAddNew = useCallback( const navigateTo = useCallback(
(tab: RSTabsList, activeID?: number) => {
if (activeID) {
navigate(`/rsforms/${schema!.id}?tab=${tab}&active=${activeID}`, {
replace: tab === activeTab && tab !== RSTabsList.CST_EDIT
});
} else {
navigate(`/rsforms/${schema!.id}?tab=${tab}`);
}
}, [navigate, schema, activeTab]);
const handleCreateCst = useCallback(
(type: CstType, selectedCst?: number) => { (type: CstType, selectedCst?: number) => {
if (!schema?.items) { if (!schema?.items) {
return; return;
@ -109,32 +99,68 @@ function RSTabs() {
} }
cstCreate(data, newCst => { cstCreate(data, newCst => {
toast.success(`Конституента добавлена: ${newCst.alias}`); toast.success(`Конституента добавлена: ${newCst.alias}`);
navigate(`/rsforms/${schema.id}?tab=${activeTab}&active=${newCst.id}`); navigateTo(activeTab, newCst.id);
if (activeTab === RSTabsList.CST_EDIT || activeTab == RSTabsList.CST_LIST) { if (activeTab === RSTabsList.CST_EDIT || activeTab == RSTabsList.CST_LIST) {
setTimeout(() => { setTimeout(() => {
const element = document.getElementById(`${prefixes.cst_list}${newCst.alias}`); const element = document.getElementById(`${prefixes.cst_list}${newCst.alias}`);
if (element) { if (element) {
element.scrollIntoView({ element.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
block: "end", block: 'end',
inline: "nearest" inline: 'nearest'
}); });
} }
}, timeout_updateUI); }, TIMEOUT_UI_REFRESH);
} }
}); });
}, [schema, cstCreate, insertWhere, navigate, activeTab]); }, [schema, cstCreate, insertWhere, navigateTo, activeTab]);
const onShowCreateCst = useCallback( const promptCreateCst = useCallback(
(selectedID: number | undefined, type: CstType | undefined, skipDialog?: boolean) => { (selectedID: number | undefined, type: CstType | undefined, skipDialog?: boolean) => {
if (skipDialog && type) { if (skipDialog && type) {
handleAddNew(type, selectedID); handleCreateCst(type, selectedID);
} else { } else {
setDefaultType(type); setDefaultType(type);
setInsertWhere(selectedID); setInsertWhere(selectedID);
setShowCreateCst(true); setShowCreateCst(true);
} }
}, [handleAddNew]); }, [handleCreateCst]);
const handleDeleteCst = useCallback(
(deleted: number[]) => {
if (!schema) {
return;
}
const data = {
items: deleted.map(id => { return { id: id }; })
};
let activeIndex = schema.items.findIndex(cst => cst.id === activeID);
cstDelete(data, () => {
const deletedNames = deleted.map(id => schema.items.find(cst => cst.id === id)?.alias).join(', ');
toast.success(`Конституенты удалены: ${deletedNames}`);
if (deleted.length === schema.items.length) {
navigateTo(RSTabsList.CST_LIST);
}
if (activeIndex) {
while (activeIndex < schema.items.length && deleted.find(id => id === schema.items[activeIndex].id)) {
++activeIndex;
}
navigateTo(activeTab, schema.items[activeIndex].id);
}
if (afterDelete) afterDelete(deleted);
});
}, [afterDelete, cstDelete, schema, activeID, activeTab, navigateTo]);
const promptDeleteCst = useCallback(
(selected: number[], callback?: (items: number[]) => void) => {
setAfterDelete(() => (
(items: number[]) => {
if (callback) callback(items);
}));
setToBeDeleted(selected);
setShowDeleteCst(true)
}, []);
const onShowAST = useCallback( const onShowAST = useCallback(
(expression: string, ast: SyntaxTree) => { (expression: string, ast: SyntaxTree) => {
@ -143,32 +169,42 @@ function RSTabs() {
setShowAST(true); setShowAST(true);
}, []); }, []);
const onEditCst = useCallback( const onOpenCst = useCallback(
(cst: IConstituenta) => { (cstID: number) => {
setActiveID(cst.id); navigateTo(RSTabsList.CST_EDIT, cstID)
setActiveTab(RSTabsList.CST_EDIT) }, [navigateTo]);
}, [setActiveID, setActiveTab]);
return ( return (
<div className='w-full'> <div className='w-full'>
{ loading && <Loader /> } { loading && <Loader /> }
{ error && <BackendError error={error} />} { error && <BackendError error={error} />}
{ schema && !loading && { schema && !loading && <>
<> {showUpload &&
{showUpload && <DlgUploadRSForm hideWindow={() => { setShowUpload(false); }}/>} <DlgUploadRSForm
{showClone && <DlgCloneRSForm hideWindow={() => { setShowClone(false); }}/>} hideWindow={() => setShowUpload(false)}
/>}
{showClone &&
<DlgCloneRSForm
hideWindow={() => setShowClone(false)}
/>}
{showAST && {showAST &&
<DlgShowAST <DlgShowAST
expression={expression} expression={expression}
syntaxTree={syntaxTree} syntaxTree={syntaxTree}
hideWindow={() => { setShowAST(false); }} hideWindow={() => setShowAST(false)}
/>} />}
{showCreateCst && {showCreateCst &&
<DlgCreateCst <DlgCreateCst
hideWindow={() => { setShowCreateCst(false); }} hideWindow={() => setShowCreateCst(false)}
onCreate={handleAddNew} onCreate={handleCreateCst}
defaultType={defaultType} defaultType={defaultType}
/>} />}
{showDeleteCst &&
<DlgDeleteCst
hideWindow={() => setShowDeleteCst(false)}
onDelete={handleDeleteCst}
selected={toBeDeleted}
/>}
<Tabs <Tabs
selectedIndex={activeTab} selectedIndex={activeTab}
onSelect={onSelectTab} onSelect={onSelectTab}
@ -195,19 +231,28 @@ function RSTabs() {
</TabPanel> </TabPanel>
<TabPanel className='w-full'> <TabPanel className='w-full'>
<EditorItems onOpenEdit={onEditCst} onShowCreateCst={onShowCreateCst} /> <EditorItems
onOpenEdit={onOpenCst}
onCreateCst={promptCreateCst}
onDeleteCst={promptDeleteCst}
/>
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<EditorConstituenta onShowAST={onShowAST} onShowCreateCst={onShowCreateCst} /> <EditorConstituenta
activeID={activeID}
onOpenEdit={onOpenCst}
onShowAST={onShowAST}
onCreateCst={promptCreateCst}
onDeleteCst={promptDeleteCst}
/>
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<EditorTermGraph /> <EditorTermGraph />
</TabPanel> </TabPanel>
</Tabs> </Tabs>
</> </>}
}
</div>); </div>);
} }

View File

@ -7,16 +7,18 @@ import { useConceptTheme } from '../../../context/ThemeContext';
import useLocalStorage from '../../../hooks/useLocalStorage'; import useLocalStorage from '../../../hooks/useLocalStorage';
import { prefixes } from '../../../utils/constants'; import { prefixes } from '../../../utils/constants';
import { CstType, extractGlobals,type IConstituenta, matchConstituenta } from '../../../utils/models'; import { CstType, extractGlobals,type IConstituenta, matchConstituenta } from '../../../utils/models';
import { getMockConstituenta, mapStatusInfo } from '../../../utils/staticUI'; import { getCstDescription, getMockConstituenta, mapStatusInfo } from '../../../utils/staticUI';
import ConstituentaTooltip from './ConstituentaTooltip'; import ConstituentaTooltip from './ConstituentaTooltip';
interface ViewSideConstituentsProps { interface ViewSideConstituentsProps {
expression: string expression: string
activeID?: number
onOpenEdit: (cstID: number) => void
} }
function ViewSideConstituents({ expression }: ViewSideConstituentsProps) { function ViewSideConstituents({ expression, activeID, onOpenEdit }: ViewSideConstituentsProps) {
const { darkMode } = useConceptTheme(); const { darkMode } = useConceptTheme();
const { schema, setActiveID, activeID } = useRSForm(); const { schema } = useRSForm();
const [filteredData, setFilteredData] = useState<IConstituenta[]>(schema?.items ?? []); const [filteredData, setFilteredData] = useState<IConstituenta[]>(schema?.items ?? []);
const [filterText, setFilterText] = useLocalStorage('side-filter-text', '') const [filterText, setFilterText] = useLocalStorage('side-filter-text', '')
const [onlyExpression, setOnlyExpression] = useLocalStorage('side-filter-flag', false); const [onlyExpression, setOnlyExpression] = useLocalStorage('side-filter-flag', false);
@ -46,14 +48,16 @@ function ViewSideConstituents({ expression }: ViewSideConstituentsProps) {
const handleRowClicked = useCallback( const handleRowClicked = useCallback(
(cst: IConstituenta, event: React.MouseEvent<Element, MouseEvent>) => { (cst: IConstituenta, event: React.MouseEvent<Element, MouseEvent>) => {
if (event.altKey && cst.id > 0) { if (event.altKey && cst.id > 0) {
setActiveID(cst.id); onOpenEdit(cst.id);
} }
}, [setActiveID]); }, [onOpenEdit]);
const handleDoubleClick = useCallback( const handleDoubleClick = useCallback(
(cst: IConstituenta) => { (cst: IConstituenta) => {
if (cst.id > 0) setActiveID(cst.id); if (cst.id > 0) {
}, [setActiveID]); onOpenEdit(cst.id);
}
}, [onOpenEdit]);
const conditionalRowStyles = useMemo(() => const conditionalRowStyles = useMemo(() =>
[ [
@ -99,8 +103,7 @@ function ViewSideConstituents({ expression }: ViewSideConstituentsProps) {
{ {
name: 'Описание', name: 'Описание',
id: 'description', id: 'description',
selector: (cst: IConstituenta) => selector: (cst: IConstituenta) => getCstDescription(cst),
cst.term.resolved || cst.definition.text.resolved || cst.definition.formal || cst.convention,
minWidth: '350px', minWidth: '350px',
wrap: true, wrap: true,
conditionalCellStyles: [ conditionalCellStyles: [

View File

@ -14,4 +14,33 @@ describe('Testing Graph constuction', () => {
graph.addEdge(13, 38); graph.addEdge(13, 38);
expect([... graph.nodes.keys()]).toStrictEqual([13, 37, 38]); expect([... graph.nodes.keys()]).toStrictEqual([13, 37, 38]);
}); });
test('creating from array', () => {
const graph = new Graph([[1, 2], [3], [4, 1]]);
expect([... graph.nodes.keys()]).toStrictEqual([1, 2, 3, 4]);
expect([... graph.nodes.get(1)!.outputs]).toStrictEqual([2]);
});
});
describe('Testing Graph queries', () => {
test('expand outputs', () => {
const graph = new Graph([[1, 2], [2, 3], [2, 5], [5, 6], [6, 1], [7]]);
expect(graph.expandOutputs([])).toStrictEqual([]);
expect(graph.expandOutputs([3])).toStrictEqual([]);
expect(graph.expandOutputs([7])).toStrictEqual([]);
expect(graph.expandOutputs([2, 5])).toStrictEqual([3, 6, 1]);
});
test('expand into unique array', () => {
const graph = new Graph([[1, 2], [1, 3], [2, 5], [3, 5]]);
expect(graph.expandOutputs([1])).toStrictEqual([2, 3 ,5]);
});
test('expand inputs', () => {
const graph = new Graph([[1, 2], [2, 3], [2, 5], [5, 6], [6, 1], [7]]);
expect(graph.expandInputs([])).toStrictEqual([]);
expect(graph.expandInputs([7])).toStrictEqual([]);
expect(graph.expandInputs([6])).toStrictEqual([5, 2, 1]);
});
}); });

View File

@ -1,30 +1,49 @@
// Graph class with basic comparison. Does not work for objects // ======== ID based fast Graph implementation =============
export class GraphNode { export class GraphNode {
id: number; id: number;
adjacent: number[]; outputs: number[];
inputs: number[];
constructor(id: number) { constructor(id: number) {
this.id = id; this.id = id;
this.adjacent = []; this.outputs = [];
this.inputs = [];
} }
addAdjacent(node: number): void { addOutput(node: number): void {
this.adjacent.push(node); this.outputs.push(node);
} }
removeAdjacent(target: number): number | null { addInput(node: number): void {
const index = this.adjacent.findIndex(node => node === target); this.inputs.push(node);
if (index > -1) {
return this.adjacent.splice(index, 1)[0];
} }
return null;
removeInput(target: number): number | null {
const index = this.inputs.findIndex(node => node === target);
return index > -1 ? this.inputs.splice(index, 1)[0] : null;
}
removeOutput(target: number): number | null {
const index = this.outputs.findIndex(node => node === target);
return index > -1 ? this.outputs.splice(index, 1)[0] : null;
} }
} }
export class Graph { export class Graph {
nodes: Map<number, GraphNode> = new Map(); nodes: Map<number, GraphNode> = new Map();
constructor() {} constructor(arr?: number[][]) {
if (!arr) {
return;
}
arr.forEach(edge => {
if (edge.length == 1) {
this.addNode(edge[0]);
} else {
this.addEdge(edge[0], edge[1]);
}
});
}
addNode(target: number): GraphNode { addNode(target: number): GraphNode {
let node = this.nodes.get(target); let node = this.nodes.get(target);
@ -40,8 +59,9 @@ export class Graph {
if (!nodeToRemove) { if (!nodeToRemove) {
return null; return null;
} }
this.nodes.forEach((node) => { this.nodes.forEach(node => {
node.removeAdjacent(nodeToRemove.id); node.removeInput(nodeToRemove.id);
node.removeOutput(nodeToRemove.id);
}); });
this.nodes.delete(target); this.nodes.delete(target);
return nodeToRemove; return nodeToRemove;
@ -50,38 +70,99 @@ export class Graph {
addEdge(source: number, destination: number): void { addEdge(source: number, destination: number): void {
const sourceNode = this.addNode(source); const sourceNode = this.addNode(source);
const destinationNode = this.addNode(destination); const destinationNode = this.addNode(destination);
sourceNode.addAdjacent(destinationNode.id); sourceNode.addOutput(destinationNode.id);
destinationNode.addInput(sourceNode.id);
} }
removeEdge(source: number, destination: number): void { removeEdge(source: number, destination: number): void {
const sourceNode = this.nodes.get(source); const sourceNode = this.nodes.get(source);
const destinationNode = this.nodes.get(destination); const destinationNode = this.nodes.get(destination);
if (sourceNode && destinationNode) { if (sourceNode && destinationNode) {
sourceNode.removeAdjacent(destination); sourceNode.removeOutput(destination);
destinationNode.removeInput(source);
} }
} }
expandOutputs(origin: number[]): number[] {
const result: number[] = [];
const marked = new Map<number, boolean>();
origin.forEach(id => marked.set(id, true));
origin.forEach(id => {
const node = this.nodes.get(id);
if (node) {
node.outputs.forEach(child => {
if (!marked.get(child) && !result.find(id => id === child)) {
result.push(child);
}
});
}
});
let position = 0;
while (position < result.length) {
const node = this.nodes.get(result[position]);
if (node && !marked.get(node.id)) {
marked.set(node.id, true);
node.outputs.forEach(child => {
if (!marked.get(child) && !result.find(id => id === child)) {
result.push(child);
}
});
}
position += 1;
}
return result;
}
expandInputs(origin: number[]): number[] {
const result: number[] = [];
const marked = new Map<number, boolean>();
origin.forEach(id => marked.set(id, true));
origin.forEach(id => {
const node = this.nodes.get(id);
if (node) {
node.inputs.forEach(child => {
if (!marked.get(child) && !result.find(id => id === child)) {
result.push(child);
}
});
}
});
let position = 0;
while (position < result.length) {
const node = this.nodes.get(result[position]);
if (node && !marked.get(node.id)) {
marked.set(node.id, true);
node.inputs.forEach(child => {
if (!marked.get(child) && !result.find(id => id === child)) {
result.push(child);
}
});
}
position += 1;
}
return result;
}
visitDFS(visitor: (node: GraphNode) => void) { visitDFS(visitor: (node: GraphNode) => void) {
const visited: Map<number, boolean> = new Map(); const visited: Map<number, boolean> = new Map();
this.nodes.forEach(node => { this.nodes.forEach(node => {
if (!visited.has(node.id)) { if (!visited.has(node.id)) {
this.depthFirstSearchAux(node, visited, visitor); this.depthFirstSearch(node, visited, visitor);
} }
}); });
} }
private depthFirstSearchAux(node: GraphNode, visited: Map<number, boolean>, visitor: (node: GraphNode) => void): void { private depthFirstSearch(
if (!node) { node: GraphNode,
return; visited: Map<number, boolean>,
} visitor: (node: GraphNode) => void)
: void {
visited.set(node.id, true); visited.set(node.id, true);
visitor(node); visitor(node);
node.outputs.forEach((item) => {
node.adjacent.forEach((item) => {
if (!visited.has(item)) { if (!visited.has(item)) {
const childNode = this.nodes.get(item); const childNode = this.nodes.get(item)!;
if (childNode) this.depthFirstSearchAux(childNode, visited, visitor); this.depthFirstSearch(childNode, visited, visitor);
} }
}); });
} }

View File

@ -8,7 +8,7 @@ const dev = {
}; };
export const config = process.env.NODE_ENV === 'production' ? prod : dev; export const config = process.env.NODE_ENV === 'production' ? prod : dev;
export const timeout_updateUI = 100; export const TIMEOUT_UI_REFRESH = 100;
export const urls = { export const urls = {
concept: 'https://www.acconcept.ru/', concept: 'https://www.acconcept.ru/',

View File

@ -14,7 +14,7 @@ export interface IStatusInfo {
tooltip: string tooltip: string
} }
export function getTypeLabel(cst: IConstituenta) { export function getTypeLabel(cst: IConstituenta): string {
if (cst.parse?.typification) { if (cst.parse?.typification) {
return cst.parse.typification; return cst.parse.typification;
} }
@ -24,6 +24,28 @@ export function getTypeLabel(cst: IConstituenta) {
return 'Логический'; return 'Логический';
} }
export function getCstDescription(cst: IConstituenta): string {
if (cst.cstType === CstType.STRUCTURED) {
return (
cst.term.resolved || cst.term.raw ||
cst.definition.text.resolved || cst.definition.text.raw ||
cst.convention ||
cst.definition.formal
);
} else {
return (
cst.term.resolved || cst.term.raw ||
cst.definition.text.resolved || cst.definition.text.raw ||
cst.definition.formal ||
cst.convention
);
}
}
export function getCstLabel(cst: IConstituenta) {
return `${cst.alias}: ${getCstDescription(cst)}`;
}
export function getCstTypePrefix(type: CstType) { export function getCstTypePrefix(type: CstType) {
switch (type) { switch (type) {
case CstType.BASE: return 'X'; case CstType.BASE: return 'X';