Refactoring: extract ui controller for RSEdit into context

This commit is contained in:
IRBorisov 2024-02-04 23:45:49 +03:00
parent 6ae6451236
commit 9f67e6128b
10 changed files with 647 additions and 700 deletions

View File

@ -5,9 +5,10 @@ import { useMemo, useState } from 'react';
import useLocalStorage from '@/hooks/useLocalStorage'; import useLocalStorage from '@/hooks/useLocalStorage';
import useWindowSize from '@/hooks/useWindowSize'; import useWindowSize from '@/hooks/useWindowSize';
import { CstType, IConstituenta, IRSForm } from '@/models/rsform'; import { IConstituenta } from '@/models/rsform';
import { globalIDs } from '@/utils/constants'; import { globalIDs } from '@/utils/constants';
import { useRSEdit } from '../RSEditContext';
import ViewConstituents from '../ViewConstituents'; import ViewConstituents from '../ViewConstituents';
import ConstituentaToolbar from './ConstituentaToolbar'; import ConstituentaToolbar from './ConstituentaToolbar';
import FormConstituenta from './FormConstituenta'; import FormConstituenta from './FormConstituenta';
@ -19,48 +20,20 @@ const UNFOLDED_HEIGHT = '59.1rem';
const SIDELIST_HIDE_THRESHOLD = 1100; // px const SIDELIST_HIDE_THRESHOLD = 1100; // px
interface EditorConstituentaProps { interface EditorConstituentaProps {
schema?: IRSForm;
isMutable: boolean;
activeCst?: IConstituenta; activeCst?: IConstituenta;
isModified: boolean; isModified: boolean;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>; setIsModified: React.Dispatch<React.SetStateAction<boolean>>;
onMoveUp: () => void;
onMoveDown: () => void;
onOpenEdit: (cstID: number) => void; onOpenEdit: (cstID: number) => void;
onClone: () => void;
onCreate: (type?: CstType) => void;
onRename: () => void;
onEditTerm: () => void;
onDelete: () => void;
} }
function EditorConstituenta({ function EditorConstituenta({ activeCst, isModified, setIsModified, onOpenEdit }: EditorConstituentaProps) {
schema, const controller = useRSEdit();
isMutable,
isModified,
setIsModified,
activeCst,
onMoveUp,
onMoveDown,
onOpenEdit,
onClone,
onCreate,
onRename,
onEditTerm,
onDelete
}: EditorConstituentaProps) {
const windowSize = useWindowSize(); const windowSize = useWindowSize();
const [showList, setShowList] = useLocalStorage('rseditor-show-list', true); const [showList, setShowList] = useLocalStorage('rseditor-show-list', true);
const [toggleReset, setToggleReset] = useState(false); const [toggleReset, setToggleReset] = useState(false);
const disabled = useMemo(() => !activeCst || !isMutable, [activeCst, isMutable]); const disabled = useMemo(() => !activeCst || !controller.isMutable, [activeCst, controller.isMutable]);
function handleCreate() {
onCreate(activeCst?.cst_type);
}
function handleInput(event: React.KeyboardEvent<HTMLDivElement>) { function handleInput(event: React.KeyboardEvent<HTMLDivElement>) {
if (disabled) { if (disabled) {
@ -92,7 +65,7 @@ function EditorConstituenta({
function processAltKey(code: string): boolean { function processAltKey(code: string): boolean {
switch (code) { switch (code) {
case 'KeyV': case 'KeyV':
onClone(); controller.cloneCst();
return true; return true;
} }
return false; return false;
@ -103,13 +76,13 @@ function EditorConstituenta({
<ConstituentaToolbar <ConstituentaToolbar
isMutable={!disabled} isMutable={!disabled}
isModified={isModified} isModified={isModified}
onMoveUp={onMoveUp} onMoveUp={controller.moveUp}
onMoveDown={onMoveDown} onMoveDown={controller.moveDown}
onSubmit={initiateSubmit} onSubmit={initiateSubmit}
onReset={() => setToggleReset(prev => !prev)} onReset={() => setToggleReset(prev => !prev)}
onDelete={onDelete} onDelete={controller.deleteCst}
onClone={onClone} onClone={controller.cloneCst}
onCreate={handleCreate} onCreate={() => controller.createCst(activeCst?.cst_type, false)}
/> />
<div tabIndex={-1} className='flex max-w-[95rem]' onKeyDown={handleInput}> <div tabIndex={-1} className='flex max-w-[95rem]' onKeyDown={handleInput}>
<FormConstituenta <FormConstituenta
@ -121,13 +94,13 @@ function EditorConstituenta({
toggleReset={toggleReset} toggleReset={toggleReset}
onToggleList={() => setShowList(prev => !prev)} onToggleList={() => setShowList(prev => !prev)}
setIsModified={setIsModified} setIsModified={setIsModified}
onEditTerm={onEditTerm} onEditTerm={controller.editTermForms}
onRename={onRename} onRename={controller.renameCst}
/> />
<AnimatePresence> <AnimatePresence>
{showList && windowSize.width && windowSize.width >= SIDELIST_HIDE_THRESHOLD ? ( {showList && windowSize.width && windowSize.width >= SIDELIST_HIDE_THRESHOLD ? (
<ViewConstituents <ViewConstituents
schema={schema} schema={controller.schema}
expression={activeCst?.definition_formal ?? ''} expression={activeCst?.definition_formal ?? ''}
baseHeight={UNFOLDED_HEIGHT} baseHeight={UNFOLDED_HEIGHT}
activeID={activeCst?.id} activeID={activeCst?.id}

View File

@ -7,32 +7,19 @@ import { useAuth } from '@/context/AuthContext';
import { useRSForm } from '@/context/RSFormContext'; import { useRSForm } from '@/context/RSFormContext';
import { globalIDs } from '@/utils/constants'; import { globalIDs } from '@/utils/constants';
import { useRSEdit } from '../RSEditContext';
import FormRSForm from './FormRSForm'; import FormRSForm from './FormRSForm';
import RSFormStats from './RSFormStats'; import RSFormStats from './RSFormStats';
import RSFormToolbar from './RSFormToolbar'; import RSFormToolbar from './RSFormToolbar';
interface EditorRSFormProps { interface EditorRSFormProps {
isModified: boolean; isModified: boolean;
isMutable: boolean;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>; setIsModified: React.Dispatch<React.SetStateAction<boolean>>;
onDestroy: () => void; onDestroy: () => void;
onClaim: () => void;
onShare: () => void;
onDownload: () => void;
onToggleSubscribe: () => void;
} }
function EditorRSForm({ function EditorRSForm({ isModified, onDestroy, setIsModified }: EditorRSFormProps) {
isModified, const { isMutable } = useRSEdit();
isMutable,
onDestroy,
onClaim,
onShare,
setIsModified,
onDownload,
onToggleSubscribe
}: EditorRSFormProps) {
const { schema, isClaimable, isSubscribed, processing } = useRSForm(); const { schema, isClaimable, isSubscribed, processing } = useRSForm();
const { user } = useAuth(); const { user } = useAuth();
@ -55,18 +42,13 @@ function EditorRSForm({
return ( return (
<> <>
<RSFormToolbar <RSFormToolbar
isMutable={isMutable}
processing={processing} processing={processing}
isSubscribed={isSubscribed} subscribed={isSubscribed}
modified={isModified} modified={isModified}
claimable={isClaimable} claimable={isClaimable}
anonymous={!user} anonymous={!user}
onSubmit={initiateSubmit} onSubmit={initiateSubmit}
onShare={onShare}
onDownload={onDownload}
onClaim={onClaim}
onDestroy={onDestroy} onDestroy={onDestroy}
onToggleSubscribe={onToggleSubscribe}
/> />
<div tabIndex={-1} className='flex flex-col sm:flex-row w-fit' onKeyDown={handleInput}> <div tabIndex={-1} className='flex flex-col sm:flex-row w-fit' onKeyDown={handleInput}>
<FlexColumn className='px-4 pb-2'> <FlexColumn className='px-4 pb-2'>

View File

@ -10,37 +10,29 @@ import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay'; import Overlay from '@/components/ui/Overlay';
import { HelpTopic } from '@/models/miscellaneous'; import { HelpTopic } from '@/models/miscellaneous';
interface RSFormToolbarProps { import { useRSEdit } from '../RSEditContext';
isMutable: boolean;
isSubscribed: boolean;
modified: boolean;
claimable: boolean;
anonymous: boolean;
processing: boolean;
interface RSFormToolbarProps {
modified: boolean;
subscribed: boolean;
anonymous: boolean;
claimable: boolean;
processing: boolean;
onSubmit: () => void; onSubmit: () => void;
onShare: () => void;
onDownload: () => void;
onClaim: () => void;
onDestroy: () => void; onDestroy: () => void;
onToggleSubscribe: () => void;
} }
function RSFormToolbar({ function RSFormToolbar({
isMutable,
modified, modified,
claimable,
anonymous, anonymous,
isSubscribed, subscribed,
onToggleSubscribe, claimable,
processing, processing,
onSubmit, onSubmit,
onShare,
onDownload,
onClaim,
onDestroy onDestroy
}: RSFormToolbarProps) { }: RSFormToolbarProps) {
const canSave = useMemo(() => modified && isMutable, [modified, isMutable]); const controller = useRSEdit();
const canSave = useMemo(() => modified && controller.isMutable, [modified, controller.isMutable]);
return ( return (
<Overlay position='top-1 right-1/2 translate-x-1/2' className='flex'> <Overlay position='top-1 right-1/2 translate-x-1/2' className='flex'>
<MiniButton <MiniButton
@ -52,37 +44,37 @@ function RSFormToolbar({
<MiniButton <MiniButton
title='Поделиться схемой' title='Поделиться схемой'
icon={<BiShareAlt size='1.25rem' className='clr-text-primary' />} icon={<BiShareAlt size='1.25rem' className='clr-text-primary' />}
onClick={onShare} onClick={controller.share}
/> />
<MiniButton <MiniButton
title='Скачать TRS файл' title='Скачать TRS файл'
icon={<BiDownload size='1.25rem' className='clr-text-primary' />} icon={<BiDownload size='1.25rem' className='clr-text-primary' />}
onClick={onDownload} onClick={controller.download}
/> />
<MiniButton <MiniButton
title={`Отслеживание ${isSubscribed ? 'включено' : 'выключено'}`} title={`Отслеживание ${subscribed ? 'включено' : 'выключено'}`}
disabled={anonymous || processing} disabled={anonymous || processing}
icon={ icon={
isSubscribed ? ( subscribed ? (
<FiBell size='1.25rem' className='clr-text-primary' /> <FiBell size='1.25rem' className='clr-text-primary' />
) : ( ) : (
<FiBellOff size='1.25rem' className='clr-text-controls' /> <FiBellOff size='1.25rem' className='clr-text-controls' />
) )
} }
style={{ outlineColor: 'transparent' }} style={{ outlineColor: 'transparent' }}
onClick={onToggleSubscribe} onClick={controller.toggleSubscribe}
/> />
<MiniButton <MiniButton
title='Стать владельцем' title='Стать владельцем'
icon={<LuCrown size='1.25rem' className={!claimable || anonymous ? '' : 'clr-text-success'} />} icon={<LuCrown size='1.25rem' className={!claimable || anonymous ? '' : 'clr-text-success'} />}
disabled={!claimable || anonymous || processing} disabled={!claimable || anonymous || processing}
onClick={onClaim} onClick={controller.claim}
/> />
<MiniButton <MiniButton
title='Удалить схему' title='Удалить схему'
disabled={!isMutable} disabled={!controller.isMutable}
onClick={onDestroy} onClick={onDestroy}
icon={<BiTrash size='1.25rem' className={isMutable ? 'clr-text-warning' : ''} />} icon={<BiTrash size='1.25rem' className={controller.isMutable ? 'clr-text-warning' : ''} />}
/> />
<HelpButton topic={HelpTopic.RSFORM} offset={4} /> <HelpButton topic={HelpTopic.RSFORM} offset={4} />
</Overlay> </Overlay>

View File

@ -4,57 +4,41 @@ import { useLayoutEffect, useState } from 'react';
import { type RowSelectionState } from '@/components/DataTable'; import { type RowSelectionState } from '@/components/DataTable';
import SelectedCounter from '@/components/SelectedCounter'; import SelectedCounter from '@/components/SelectedCounter';
import { CstType, IRSForm } from '@/models/rsform'; import { CstType } from '@/models/rsform';
import { useRSEdit } from '../RSEditContext';
import RSListToolbar from './RSListToolbar'; import RSListToolbar from './RSListToolbar';
import RSTable from './RSTable'; import RSTable from './RSTable';
interface EditorRSListProps { interface EditorRSListProps {
schema?: IRSForm;
isMutable: boolean;
selected: number[]; selected: number[];
setSelected: React.Dispatch<React.SetStateAction<number[]>>; setSelected: React.Dispatch<React.SetStateAction<number[]>>;
onMoveUp: () => void;
onMoveDown: () => void;
onOpenEdit: (cstID: number) => void; onOpenEdit: (cstID: number) => void;
onClone: () => void;
onCreate: (type?: CstType) => void;
onDelete: () => void;
} }
function EditorRSList({ function EditorRSList({ selected, setSelected, onOpenEdit }: EditorRSListProps) {
schema,
selected,
setSelected,
isMutable,
onMoveUp,
onMoveDown,
onOpenEdit,
onClone,
onCreate,
onDelete
}: EditorRSListProps) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const controller = useRSEdit();
useLayoutEffect(() => { useLayoutEffect(() => {
if (!schema || selected.length === 0) { if (!controller.schema || selected.length === 0) {
setRowSelection({}); setRowSelection({});
} else { } else {
const newRowSelection: RowSelectionState = {}; const newRowSelection: RowSelectionState = {};
schema.items.forEach((cst, index) => { controller.schema.items.forEach((cst, index) => {
newRowSelection[String(index)] = selected.includes(cst.id); newRowSelection[String(index)] = selected.includes(cst.id);
}); });
setRowSelection(newRowSelection); setRowSelection(newRowSelection);
} }
}, [selected, schema]); }, [selected, controller.schema]);
function handleRowSelection(updater: React.SetStateAction<RowSelectionState>) { function handleRowSelection(updater: React.SetStateAction<RowSelectionState>) {
if (!schema) { if (!controller.schema) {
setSelected([]); setSelected([]);
} else { } else {
const newRowSelection = typeof updater === 'function' ? updater(rowSelection) : updater; const newRowSelection = typeof updater === 'function' ? updater(rowSelection) : updater;
const newSelection: number[] = []; const newSelection: number[] = [];
schema?.items.forEach((cst, index) => { controller.schema.items.forEach((cst, index) => {
if (newRowSelection[String(index)] === true) { if (newRowSelection[String(index)] === true) {
newSelection.push(cst.id); newSelection.push(cst.id);
} }
@ -63,14 +47,13 @@ function EditorRSList({
} }
} }
// Implement hotkeys for working with constituents table
function handleTableKey(event: React.KeyboardEvent<HTMLDivElement>) { function handleTableKey(event: React.KeyboardEvent<HTMLDivElement>) {
if (!isMutable) { if (!controller.isMutable) {
return; return;
} }
if (event.key === 'Delete' && selected.length > 0) { if (event.key === 'Delete' && selected.length > 0) {
event.preventDefault(); event.preventDefault();
onDelete(); controller.deleteCst();
return; return;
} }
if (!event.altKey || event.shiftKey) { if (!event.altKey || event.shiftKey) {
@ -86,23 +69,23 @@ function EditorRSList({
if (selected.length > 0) { if (selected.length > 0) {
// prettier-ignore // prettier-ignore
switch (code) { switch (code) {
case 'ArrowUp': onMoveUp(); return true; case 'ArrowUp': controller.moveUp(); return true;
case 'ArrowDown': onMoveDown(); return true; case 'ArrowDown': controller.moveDown(); return true;
case 'KeyV': onClone(); return true; case 'KeyV': controller.cloneCst(); return true;
} }
} }
// prettier-ignore // prettier-ignore
switch (code) { switch (code) {
case 'Backquote': onCreate(); return true; case 'Backquote': controller.createCst(undefined, false); return true;
case 'Digit1': onCreate(CstType.BASE); return true; case 'Digit1': controller.createCst(CstType.BASE, true); return true;
case 'Digit2': onCreate(CstType.STRUCTURED); return true; case 'Digit2': controller.createCst(CstType.STRUCTURED, true); return true;
case 'Digit3': onCreate(CstType.TERM); return true; case 'Digit3': controller.createCst(CstType.TERM, true); return true;
case 'Digit4': onCreate(CstType.AXIOM); return true; case 'Digit4': controller.createCst(CstType.AXIOM, true); return true;
case 'KeyQ': onCreate(CstType.FUNCTION); return true; case 'KeyQ': controller.createCst(CstType.FUNCTION, true); return true;
case 'KeyW': onCreate(CstType.PREDICATE); return true; case 'KeyW': controller.createCst(CstType.PREDICATE, true); return true;
case 'Digit5': onCreate(CstType.CONSTANT); return true; case 'Digit5': controller.createCst(CstType.CONSTANT, true); return true;
case 'Digit6': onCreate(CstType.THEOREM); return true; case 'Digit6': controller.createCst(CstType.THEOREM, true); return true;
} }
return false; return false;
} }
@ -110,29 +93,21 @@ function EditorRSList({
return ( return (
<div tabIndex={-1} className='outline-none' onKeyDown={handleTableKey}> <div tabIndex={-1} className='outline-none' onKeyDown={handleTableKey}>
<SelectedCounter <SelectedCounter
totalCount={schema?.stats?.count_all ?? 0} totalCount={controller.schema?.stats?.count_all ?? 0}
selectedCount={selected.length} selectedCount={selected.length}
position='top-[0.3rem] left-2' position='top-[0.3rem] left-2'
/> />
<RSListToolbar <RSListToolbar selectedCount={selected.length} />
selectedCount={selected.length}
isMutable={isMutable}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
onClone={onClone}
onCreate={onCreate}
onDelete={onDelete}
/>
<div className='pt-[2.3rem] border-b' /> <div className='pt-[2.3rem] border-b' />
<RSTable <RSTable
items={schema?.items} items={controller.schema?.items}
selected={rowSelection} selected={rowSelection}
setSelected={handleRowSelection} setSelected={handleRowSelection}
onEdit={onOpenEdit} onEdit={onOpenEdit}
onCreateNew={onCreate} onCreateNew={() => controller.createCst(undefined, false)}
/> />
</div> </div>
); );

View File

@ -15,26 +15,14 @@ import { getCstTypePrefix } from '@/models/rsformAPI';
import { prefixes } from '@/utils/constants'; import { prefixes } from '@/utils/constants';
import { getCstTypeShortcut, labelCstType } from '@/utils/labels'; import { getCstTypeShortcut, labelCstType } from '@/utils/labels';
interface RSListToolbarProps { import { useRSEdit } from '../RSEditContext';
isMutable?: boolean;
selectedCount: number;
onMoveUp: () => void; interface RSListToolbarProps {
onMoveDown: () => void; selectedCount: number;
onDelete: () => void;
onClone: () => void;
onCreate: (type?: CstType) => void;
} }
function RSListToolbar({ function RSListToolbar({ selectedCount }: RSListToolbarProps) {
selectedCount, const controller = useRSEdit();
isMutable,
onMoveUp,
onMoveDown,
onDelete,
onClone,
onCreate
}: RSListToolbarProps) {
const insertMenu = useDropdown(); const insertMenu = useDropdown();
const nothingSelected = useMemo(() => selectedCount === 0, [selectedCount]); const nothingSelected = useMemo(() => selectedCount === 0, [selectedCount]);
@ -42,34 +30,43 @@ function RSListToolbar({
<Overlay position='top-1 right-1/2 translate-x-1/2' className='flex items-start'> <Overlay position='top-1 right-1/2 translate-x-1/2' className='flex items-start'>
<MiniButton <MiniButton
title='Переместить вверх [Alt + вверх]' title='Переместить вверх [Alt + вверх]'
icon={<BiUpvote size='1.25rem' className={isMutable && !nothingSelected ? 'clr-text-primary' : ''} />} icon={
disabled={!isMutable || nothingSelected} <BiUpvote size='1.25rem' className={controller.isMutable && !nothingSelected ? 'clr-text-primary' : ''} />
onClick={onMoveUp} }
disabled={!controller.isMutable || nothingSelected}
onClick={controller.moveUp}
/> />
<MiniButton <MiniButton
title='Переместить вниз [Alt + вниз]' title='Переместить вниз [Alt + вниз]'
icon={<BiDownvote size='1.25rem' className={isMutable && !nothingSelected ? 'clr-text-primary' : ''} />} icon={
disabled={!isMutable || nothingSelected} <BiDownvote size='1.25rem' className={controller.isMutable && !nothingSelected ? 'clr-text-primary' : ''} />
onClick={onMoveDown} }
disabled={!controller.isMutable || nothingSelected}
onClick={controller.moveDown}
/> />
<MiniButton <MiniButton
title='Клонировать конституенту [Alt + V]' title='Клонировать конституенту [Alt + V]'
icon={<BiDuplicate size='1.25rem' className={isMutable && selectedCount === 1 ? 'clr-text-success' : ''} />} icon={
disabled={!isMutable || selectedCount !== 1} <BiDuplicate
onClick={onClone} size='1.25rem'
className={controller.isMutable && selectedCount === 1 ? 'clr-text-success' : ''}
/>
}
disabled={!controller.isMutable || selectedCount !== 1}
onClick={controller.cloneCst}
/> />
<MiniButton <MiniButton
title='Добавить новую конституенту... [Alt + `]' title='Добавить новую конституенту... [Alt + `]'
icon={<BiPlusCircle size='1.25rem' className={isMutable ? 'clr-text-success' : ''} />} icon={<BiPlusCircle size='1.25rem' className={controller.isMutable ? 'clr-text-success' : ''} />}
disabled={!isMutable} disabled={!controller.isMutable}
onClick={() => onCreate()} onClick={() => controller.createCst(undefined, false)}
/> />
<div ref={insertMenu.ref}> <div ref={insertMenu.ref}>
<MiniButton <MiniButton
title='Добавить пустую конституенту' title='Добавить пустую конституенту'
hideTitle={insertMenu.isOpen} hideTitle={insertMenu.isOpen}
icon={<BiDownArrowCircle size='1.25rem' className={isMutable ? 'clr-text-success' : ''} />} icon={<BiDownArrowCircle size='1.25rem' className={controller.isMutable ? 'clr-text-success' : ''} />}
disabled={!isMutable} disabled={!controller.isMutable}
onClick={insertMenu.toggle} onClick={insertMenu.toggle}
/> />
<Dropdown isOpen={insertMenu.isOpen}> <Dropdown isOpen={insertMenu.isOpen}>
@ -77,7 +74,7 @@ function RSListToolbar({
<DropdownButton <DropdownButton
key={`${prefixes.csttype_list}${typeStr}`} key={`${prefixes.csttype_list}${typeStr}`}
text={`${getCstTypePrefix(typeStr as CstType)}1 — ${labelCstType(typeStr as CstType)}`} text={`${getCstTypePrefix(typeStr as CstType)}1 — ${labelCstType(typeStr as CstType)}`}
onClick={() => onCreate(typeStr as CstType)} onClick={() => controller.createCst(typeStr as CstType, true)}
title={getCstTypeShortcut(typeStr as CstType)} title={getCstTypeShortcut(typeStr as CstType)}
/> />
))} ))}
@ -85,9 +82,9 @@ function RSListToolbar({
</div> </div>
<MiniButton <MiniButton
title='Удалить выбранные [Delete]' title='Удалить выбранные [Delete]'
icon={<BiTrash size='1.25rem' className={isMutable && !nothingSelected ? 'clr-text-warning' : ''} />} icon={<BiTrash size='1.25rem' className={controller.isMutable && !nothingSelected ? 'clr-text-warning' : ''} />}
disabled={!isMutable || nothingSelected} disabled={!controller.isMutable || nothingSelected}
onClick={onDelete} onClick={controller.deleteCst}
/> />
<HelpButton topic={HelpTopic.CSTLIST} offset={5} /> <HelpButton topic={HelpTopic.CSTLIST} offset={5} />
</Overlay> </Overlay>

View File

@ -12,10 +12,11 @@ import { useConceptTheme } from '@/context/ThemeContext';
import DlgGraphParams from '@/dialogs/DlgGraphParams'; import DlgGraphParams from '@/dialogs/DlgGraphParams';
import useLocalStorage from '@/hooks/useLocalStorage'; import useLocalStorage from '@/hooks/useLocalStorage';
import { GraphColoringScheme, GraphFilterParams } from '@/models/miscellaneous'; import { GraphColoringScheme, GraphFilterParams } from '@/models/miscellaneous';
import { CstType, IRSForm } from '@/models/rsform'; import { CstType } from '@/models/rsform';
import { colorBgGraphNode } from '@/styling/color'; import { colorBgGraphNode } from '@/styling/color';
import { classnames, TIMEOUT_GRAPH_REFRESH } from '@/utils/constants'; import { classnames, TIMEOUT_GRAPH_REFRESH } from '@/utils/constants';
import { useRSEdit } from '../RSEditContext';
import GraphSidebar from './GraphSidebar'; import GraphSidebar from './GraphSidebar';
import GraphToolbar from './GraphToolbar'; import GraphToolbar from './GraphToolbar';
import TermGraph from './TermGraph'; import TermGraph from './TermGraph';
@ -23,24 +24,13 @@ import useGraphFilter from './useGraphFilter';
import ViewHidden from './ViewHidden'; import ViewHidden from './ViewHidden';
interface EditorTermGraphProps { interface EditorTermGraphProps {
isMutable: boolean;
selected: number[]; selected: number[];
schema?: IRSForm;
setSelected: React.Dispatch<React.SetStateAction<number[]>>; setSelected: React.Dispatch<React.SetStateAction<number[]>>;
onOpenEdit: (cstID: number) => void; onOpenEdit: (cstID: number) => void;
onCreate: (type: CstType, definition: string) => void;
onDelete: () => void;
} }
function EditorTermGraph({ function EditorTermGraph({ selected, setSelected, onOpenEdit }: EditorTermGraphProps) {
schema, const controller = useRSEdit();
selected,
setSelected,
isMutable,
onOpenEdit,
onCreate,
onDelete
}: EditorTermGraphProps) {
const { colors } = useConceptTheme(); const { colors } = useConceptTheme();
const [filterParams, setFilterParams] = useLocalStorage<GraphFilterParams>('graph_filter', { const [filterParams, setFilterParams] = useLocalStorage<GraphFilterParams>('graph_filter', {
@ -59,7 +49,7 @@ function EditorTermGraph({
allowTheorem: true allowTheorem: true
}); });
const [showParamsDialog, setShowParamsDialog] = useState(false); const [showParamsDialog, setShowParamsDialog] = useState(false);
const filtered = useGraphFilter(schema, filterParams); const filtered = useGraphFilter(controller.schema, filterParams);
const [hidden, setHidden] = useState<number[]>([]); const [hidden, setHidden] = useState<number[]>([]);
@ -72,32 +62,32 @@ function EditorTermGraph({
const [hoverID, setHoverID] = useState<number | undefined>(undefined); const [hoverID, setHoverID] = useState<number | undefined>(undefined);
const hoverCst = useMemo(() => { const hoverCst = useMemo(() => {
return schema?.items.find(cst => cst.id === hoverID); return controller.schema?.items.find(cst => cst.id === hoverID);
}, [schema?.items, hoverID]); }, [controller.schema?.items, hoverID]);
const [toggleResetView, setToggleResetView] = useState(false); const [toggleResetView, setToggleResetView] = useState(false);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!schema) { if (!controller.schema) {
return; return;
} }
const newDismissed: number[] = []; const newDismissed: number[] = [];
schema.items.forEach(cst => { controller.schema.items.forEach(cst => {
if (!filtered.nodes.has(cst.id)) { if (!filtered.nodes.has(cst.id)) {
newDismissed.push(cst.id); newDismissed.push(cst.id);
} }
}); });
setHidden(newDismissed); setHidden(newDismissed);
setHoverID(undefined); setHoverID(undefined);
}, [schema, filtered]); }, [controller.schema, filtered]);
const nodes: GraphNode[] = useMemo(() => { const nodes: GraphNode[] = useMemo(() => {
const result: GraphNode[] = []; const result: GraphNode[] = [];
if (!schema) { if (!controller.schema) {
return result; return result;
} }
filtered.nodes.forEach(node => { filtered.nodes.forEach(node => {
const cst = schema.items.find(cst => cst.id === node.id); const cst = controller.schema!.items.find(cst => cst.id === node.id);
if (cst) { if (cst) {
result.push({ result.push({
id: String(node.id), id: String(node.id),
@ -107,7 +97,7 @@ function EditorTermGraph({
} }
}); });
return result; return result;
}, [schema, coloringScheme, filtered.nodes, filterParams.noText, colors]); }, [controller.schema, coloringScheme, filtered.nodes, filterParams.noText, colors]);
const edges: GraphEdge[] = useMemo(() => { const edges: GraphEdge[] = useMemo(() => {
const result: GraphEdge[] = []; const result: GraphEdge[] = [];
@ -143,18 +133,18 @@ function EditorTermGraph({
} }
function handleCreateCst() { function handleCreateCst() {
if (!schema) { if (!controller.schema) {
return; return;
} }
const definition = selected.map(id => schema.items.find(cst => cst.id === id)!.alias).join(' '); const definition = selected.map(id => controller.schema!.items.find(cst => cst.id === id)!.alias).join(' ');
onCreate(selected.length === 0 ? CstType.BASE : CstType.TERM, definition); controller.createCst(selected.length === 0 ? CstType.BASE : CstType.TERM, false, definition);
} }
function handleDeleteCst() { function handleDeleteCst() {
if (!schema || selected.length === 0) { if (!controller.schema || selected.length === 0) {
return; return;
} }
onDelete(); controller.deleteCst();
} }
function handleChangeLayout(newLayout: LayoutTypes) { function handleChangeLayout(newLayout: LayoutTypes) {
@ -176,7 +166,7 @@ function EditorTermGraph({
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) { function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
// Hotkeys implementation // Hotkeys implementation
if (!isMutable) { if (!controller.isMutable) {
return; return;
} }
if (event.key === 'Delete') { if (event.key === 'Delete') {
@ -199,13 +189,13 @@ function EditorTermGraph({
<SelectedCounter <SelectedCounter
hideZero hideZero
totalCount={schema?.stats?.count_all ?? 0} totalCount={controller.schema?.stats?.count_all ?? 0}
selectedCount={selected.length} selectedCount={selected.length}
position='top-[0.3rem] left-0' position='top-[0.3rem] left-0'
/> />
<GraphToolbar <GraphToolbar
isMutable={isMutable} isMutable={controller.isMutable}
nothingSelected={nothingSelected} nothingSelected={nothingSelected}
is3D={is3D} is3D={is3D}
orbit={orbit} orbit={orbit}
@ -243,7 +233,7 @@ function EditorTermGraph({
<ViewHidden <ViewHidden
items={hidden} items={hidden}
selected={selected} selected={selected}
schema={schema!} schema={controller.schema}
coloringScheme={coloringScheme} coloringScheme={coloringScheme}
toggleSelection={toggleDismissed} toggleSelection={toggleDismissed}
onEdit={onOpenEdit} onEdit={onOpenEdit}

View File

@ -12,7 +12,7 @@ import { prefixes } from '@/utils/constants';
interface ViewHiddenProps { interface ViewHiddenProps {
items: number[]; items: number[];
selected: number[]; selected: number[];
schema: IRSForm; schema?: IRSForm;
coloringScheme: GraphColoringScheme; coloringScheme: GraphColoringScheme;
toggleSelection: (cstID: number) => void; toggleSelection: (cstID: number) => void;
@ -43,7 +43,7 @@ function ViewHidden({ items, selected, toggleSelection, schema, coloringScheme,
</p> </p>
<div className='flex flex-wrap justify-center gap-2 py-2 overflow-y-auto' style={{ maxHeight: dismissedHeight }}> <div className='flex flex-wrap justify-center gap-2 py-2 overflow-y-auto' style={{ maxHeight: dismissedHeight }}>
{items.map(cstID => { {items.map(cstID => {
const cst = schema.items.find(cst => cst.id === cstID)!; const cst = schema!.items.find(cst => cst.id === cstID)!;
const adjustedColoring = coloringScheme === 'none' ? 'status' : coloringScheme; const adjustedColoring = coloringScheme === 'none' ? 'status' : coloringScheme;
const id = `${prefixes.cst_hidden_list}${cst.alias}`; const id = `${prefixes.cst_hidden_list}${cst.alias}`;
return ( return (

View File

@ -0,0 +1,466 @@
'use client';
import axios from 'axios';
import { AnimatePresence } from 'framer-motion';
import fileDownload from 'js-file-download';
import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import InfoError, { ErrorData } from '@/components/InfoError';
import Loader from '@/components/ui/Loader';
import TextURL from '@/components/ui/TextURL';
import { useAccessMode } from '@/context/AccessModeContext';
import { useAuth } from '@/context/AuthContext';
import { useRSForm } from '@/context/RSFormContext';
import DlgCloneLibraryItem from '@/dialogs/DlgCloneLibraryItem';
import DlgConstituentaTemplate from '@/dialogs/DlgConstituentaTemplate';
import DlgCreateCst from '@/dialogs/DlgCreateCst';
import DlgDeleteCst from '@/dialogs/DlgDeleteCst';
import DlgEditWordForms from '@/dialogs/DlgEditWordForms';
import DlgRenameCst from '@/dialogs/DlgRenameCst';
import DlgUploadRSForm from '@/dialogs/DlgUploadRSForm';
import { UserAccessMode } from '@/models/miscellaneous';
import {
CstType,
IConstituenta,
IConstituentaMeta,
ICstCreateData,
ICstMovetoData,
ICstRenameData,
ICstUpdateData,
IRSForm,
TermForm
} from '@/models/rsform';
import { generateAlias } from '@/models/rsformAPI';
import { EXTEOR_TRS_FILE } from '@/utils/constants';
interface IRSEditContext {
schema?: IRSForm;
isMutable: boolean;
moveUp: () => void;
moveDown: () => void;
createCst: (type: CstType | undefined, skipDialog: boolean, definition?: string) => void;
renameCst: () => void;
cloneCst: () => void;
deleteCst: () => void;
editTermForms: () => void;
promptTemplate: () => void;
promptClone: () => void;
promptUpload: () => void;
claim: () => void;
share: () => void;
toggleSubscribe: () => void;
download: () => void;
reindex: () => void;
}
const RSEditContext = createContext<IRSEditContext | null>(null);
export const useRSEdit = () => {
const context = useContext(RSEditContext);
if (context === null) {
throw new Error('useRSEdit has to be used within <RSEditState.Provider>');
}
return context;
};
interface RSEditStateProps {
selected: number[];
isModified: boolean;
setSelected: React.Dispatch<React.SetStateAction<number[]>>;
activeCst?: IConstituenta;
onCreateCst?: (newCst: IConstituentaMeta) => void;
onDeleteCst?: (newActive?: number) => void;
children: React.ReactNode;
}
export const RSEditState = ({
selected,
setSelected,
activeCst,
isModified,
onCreateCst,
onDeleteCst,
children
}: RSEditStateProps) => {
const { user } = useAuth();
const { mode, setMode } = useAccessMode();
const model = useRSForm();
const isMutable = useMemo(() => {
return (
!model.loading &&
!model.processing &&
mode !== UserAccessMode.READER &&
((model.isOwned || (mode === UserAccessMode.ADMIN && user?.is_staff)) ?? false)
);
}, [user?.is_staff, mode, model.isOwned, model.loading, model.processing]);
const [showUpload, setShowUpload] = useState(false);
const [showClone, setShowClone] = useState(false);
const [showDeleteCst, setShowDeleteCst] = useState(false);
const [createInitialData, setCreateInitialData] = useState<ICstCreateData>();
const [showCreateCst, setShowCreateCst] = useState(false);
const [renameInitialData, setRenameInitialData] = useState<ICstRenameData>();
const [showRenameCst, setShowRenameCst] = useState(false);
const [showEditTerm, setShowEditTerm] = useState(false);
const [insertCstID, setInsertCstID] = useState<number | undefined>(undefined);
const [showTemplates, setShowTemplates] = useState(false);
useLayoutEffect(
() =>
setMode(prev => {
if (prev === UserAccessMode.ADMIN) {
return prev;
} else if (model.isOwned) {
return UserAccessMode.OWNER;
} else {
return UserAccessMode.READER;
}
}),
[model.schema, setMode, model.isOwned]
);
const handleCreateCst = useCallback(
(data: ICstCreateData) => {
if (!model.schema) {
return;
}
data.alias = data.alias || generateAlias(data.cst_type, model.schema);
model.cstCreate(data, newCst => {
toast.success(`Конституента добавлена: ${newCst.alias}`);
setSelected([newCst.id]);
if (onCreateCst) onCreateCst(newCst);
});
},
[model, setSelected, onCreateCst]
);
const handleRenameCst = useCallback(
(data: ICstRenameData) => {
model.cstRename(data, () => toast.success(`Переименование: ${renameInitialData!.alias} -> ${data.alias}`));
},
[model, renameInitialData]
);
const handleDeleteCst = useCallback(
(deleted: number[]) => {
if (!model.schema) {
return;
}
const data = {
items: deleted
};
const deletedNames = deleted.map(id => model.schema?.items.find(cst => cst.id === id)?.alias).join(', ');
const isEmpty = deleted.length === model.schema.items.length;
const nextActive = isEmpty ? undefined : getNextActiveOnDelete(activeCst?.id, model.schema.items, deleted);
model.cstDelete(data, () => {
toast.success(`Конституенты удалены: ${deletedNames}`);
setSelected(nextActive ? [nextActive] : []);
if (onDeleteCst) onDeleteCst(nextActive);
});
},
[model, activeCst, onDeleteCst, setSelected]
);
const handleSaveWordforms = useCallback(
(forms: TermForm[]) => {
if (!activeCst) {
return;
}
const data: ICstUpdateData = {
id: activeCst.id,
term_forms: forms
};
model.cstUpdate(data, () => toast.success('Изменения сохранены'));
},
[model, activeCst]
);
const moveUp = useCallback(() => {
if (!model.schema?.items || selected.length === 0) {
return;
}
const currentIndex = model.schema.items.reduce((prev, cst, index) => {
if (!selected.includes(cst.id)) {
return prev;
} else if (prev === -1) {
return index;
}
return Math.min(prev, index);
}, -1);
const target = Math.max(0, currentIndex - 1) + 1;
const data = {
items: selected,
move_to: target
};
model.cstMoveTo(data);
}, [model, selected]);
const moveDown = useCallback(() => {
if (!model.schema?.items || selected.length === 0) {
return;
}
let count = 0;
const currentIndex = model.schema.items.reduce((prev, cst, index) => {
if (!selected.includes(cst.id)) {
return prev;
} else {
count += 1;
if (prev === -1) {
return index;
}
return Math.max(prev, index);
}
}, -1);
const target = Math.min(model.schema.items.length - 1, currentIndex - count + 2) + 1;
const data: ICstMovetoData = {
items: selected,
move_to: target
};
model.cstMoveTo(data);
}, [model, selected]);
const createCst = useCallback(
(type: CstType | undefined, skipDialog: boolean, definition?: string) => {
const data: ICstCreateData = {
insert_after: activeCst?.id ?? null,
cst_type: type ?? activeCst?.cst_type ?? CstType.BASE,
alias: '',
term_raw: '',
definition_formal: definition ?? '',
definition_raw: '',
convention: '',
term_forms: []
};
if (skipDialog) {
handleCreateCst(data);
} else {
setCreateInitialData(data);
setShowCreateCst(true);
}
},
[activeCst, handleCreateCst]
);
const cloneCst = useCallback(() => {
if (!activeCst) {
return;
}
const data: ICstCreateData = {
insert_after: activeCst.id,
cst_type: activeCst.cst_type,
alias: '',
term_raw: activeCst.term_raw,
definition_formal: activeCst.definition_formal,
definition_raw: activeCst.definition_raw,
convention: activeCst.convention,
term_forms: activeCst.term_forms
};
handleCreateCst(data);
}, [activeCst, handleCreateCst]);
const renameCst = useCallback(() => {
if (!activeCst) {
return;
}
const data: ICstRenameData = {
id: activeCst.id,
alias: activeCst.alias,
cst_type: activeCst.cst_type
};
setRenameInitialData(data);
setShowRenameCst(true);
}, [activeCst]);
const editTermForms = useCallback(() => {
if (!activeCst) {
return;
}
if (isModified) {
if (!window.confirm('Присутствуют несохраненные изменения. Продолжить без их учета?')) {
return;
}
}
setShowEditTerm(true);
}, [isModified, activeCst]);
const reindex = useCallback(() => model.resetAliases(() => toast.success('Имена конституент обновлены')), [model]);
const promptTemplate = useCallback(() => {
setInsertCstID(activeCst?.id);
setShowTemplates(true);
}, [activeCst]);
const promptClone = useCallback(() => {
if (isModified) {
if (!window.confirm('Присутствуют несохраненные изменения. Продолжить без их учета?')) {
return;
}
}
setShowClone(true);
}, [isModified]);
const download = useCallback(() => {
if (isModified) {
if (!window.confirm('Присутствуют несохраненные изменения. Продолжить без их учета?')) {
return;
}
}
const fileName = (model.schema?.alias ?? 'Schema') + EXTEOR_TRS_FILE;
model.download((data: Blob) => {
try {
fileDownload(data, fileName);
} catch (error) {
console.error(error);
}
});
}, [model, isModified]);
const claim = useCallback(() => {
if (!window.confirm('Вы уверены, что хотите стать владельцем данной схемы?')) {
return;
}
model.claim(() => toast.success('Вы стали владельцем схемы'));
}, [model]);
const share = useCallback(() => {
const url = window.location.href + '&share';
navigator.clipboard
.writeText(url)
.then(() => toast.success(`Ссылка скопирована: ${url}`))
.catch(console.error);
}, []);
const toggleSubscribe = useCallback(() => {
if (model.isSubscribed) {
model.unsubscribe(() => toast.success('Отслеживание отключено'));
} else {
model.subscribe(() => toast.success('Отслеживание включено'));
}
}, [model]);
return (
<RSEditContext.Provider
value={{
schema: model.schema,
isMutable,
moveUp,
moveDown,
createCst,
cloneCst,
renameCst,
deleteCst: () => setShowDeleteCst(true),
editTermForms,
promptTemplate,
promptClone,
promptUpload: () => setShowUpload(true),
download,
claim,
share,
toggleSubscribe,
reindex
}}
>
{model.schema ? (
<AnimatePresence>
{showUpload ? <DlgUploadRSForm hideWindow={() => setShowUpload(false)} /> : null}
{showClone ? <DlgCloneLibraryItem base={model.schema} hideWindow={() => setShowClone(false)} /> : null}
{showCreateCst ? (
<DlgCreateCst
hideWindow={() => setShowCreateCst(false)}
onCreate={handleCreateCst}
schema={model.schema}
initial={createInitialData}
/>
) : null}
{showRenameCst && renameInitialData ? (
<DlgRenameCst
hideWindow={() => setShowRenameCst(false)}
onRename={handleRenameCst}
initial={renameInitialData}
/>
) : null}
{showDeleteCst ? (
<DlgDeleteCst
schema={model.schema}
hideWindow={() => setShowDeleteCst(false)}
onDelete={handleDeleteCst}
selected={selected}
/>
) : null}
{showEditTerm && activeCst ? (
<DlgEditWordForms
hideWindow={() => setShowEditTerm(false)}
onSave={handleSaveWordforms}
target={activeCst}
/>
) : null}
{showTemplates ? (
<DlgConstituentaTemplate
schema={model.schema}
hideWindow={() => setShowTemplates(false)}
insertAfter={insertCstID}
onCreate={handleCreateCst}
/>
) : null}
</AnimatePresence>
) : null}
{model.loading ? <Loader /> : null}
{model.error ? <ProcessError error={model.error} /> : null}
{model.schema && !model.loading ? children : null}
</RSEditContext.Provider>
);
};
// ====== Internals =========
function ProcessError({ error }: { error: ErrorData }): React.ReactElement {
if (axios.isAxiosError(error) && error.response && error.response.status === 404) {
return (
<div className='p-2 text-center'>
<p>Схема с указанным идентификатором отсутствует на портале.</p>
<TextURL text='Перейти в Библиотеку' href='/library' />
</div>
);
} else {
return <InfoError error={error} />;
}
}
function getNextActiveOnDelete(
activeID: number | undefined,
items: IConstituenta[],
deleted: number[]
): number | undefined {
if (items.length === deleted.length) {
return undefined;
}
let activeIndex = items.findIndex(cst => cst.id === activeID);
if (activeIndex === -1) {
return undefined;
}
while (activeIndex < items.length && deleted.find(id => id === items[activeIndex].id)) {
++activeIndex;
}
if (activeIndex >= items.length) {
activeIndex = items.length - 1;
while (activeIndex >= 0 && deleted.find(id => id === items[activeIndex].id)) {
--activeIndex;
}
}
return items[activeIndex].id;
}

View File

@ -1,49 +1,25 @@
'use client'; 'use client';
import axios from 'axios';
import clsx from 'clsx'; import clsx from 'clsx';
import { AnimatePresence } from 'framer-motion';
import fileDownload from 'js-file-download';
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { TabList, TabPanel, Tabs } from 'react-tabs'; import { TabList, TabPanel, Tabs } from 'react-tabs';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import AnimateFade from '@/components/AnimateFade'; import AnimateFade from '@/components/AnimateFade';
import InfoError, { ErrorData } from '@/components/InfoError';
import Loader from '@/components/ui/Loader';
import TabLabel from '@/components/ui/TabLabel'; import TabLabel from '@/components/ui/TabLabel';
import TextURL from '@/components/ui/TextURL';
import { useAccessMode } from '@/context/AccessModeContext';
import { useAuth } from '@/context/AuthContext';
import { useLibrary } from '@/context/LibraryContext'; import { useLibrary } from '@/context/LibraryContext';
import { useBlockNavigation, useConceptNavigation } from '@/context/NavigationContext'; import { useBlockNavigation, useConceptNavigation } from '@/context/NavigationContext';
import { useRSForm } from '@/context/RSFormContext'; import { useRSForm } from '@/context/RSFormContext';
import { useConceptTheme } from '@/context/ThemeContext'; import { useConceptTheme } from '@/context/ThemeContext';
import DlgCloneLibraryItem from '@/dialogs/DlgCloneLibraryItem';
import DlgConstituentaTemplate from '@/dialogs/DlgConstituentaTemplate';
import DlgCreateCst from '@/dialogs/DlgCreateCst';
import DlgDeleteCst from '@/dialogs/DlgDeleteCst';
import DlgEditWordForms from '@/dialogs/DlgEditWordForms';
import DlgRenameCst from '@/dialogs/DlgRenameCst';
import DlgUploadRSForm from '@/dialogs/DlgUploadRSForm';
import useQueryStrings from '@/hooks/useQueryStrings'; import useQueryStrings from '@/hooks/useQueryStrings';
import { UserAccessMode } from '@/models/miscellaneous'; import { IConstituenta, IConstituentaMeta } from '@/models/rsform';
import { import { prefixes, TIMEOUT_UI_REFRESH } from '@/utils/constants';
CstType,
IConstituenta,
ICstCreateData,
ICstMovetoData,
ICstRenameData,
ICstUpdateData,
TermForm
} from '@/models/rsform';
import { generateAlias } from '@/models/rsformAPI';
import { EXTEOR_TRS_FILE, prefixes, TIMEOUT_UI_REFRESH } from '@/utils/constants';
import EditorConstituenta from './EditorConstituenta'; import EditorConstituenta from './EditorConstituenta';
import EditorRSForm from './EditorRSForm'; import EditorRSForm from './EditorRSForm';
import EditorRSList from './EditorRSList'; import EditorRSList from './EditorRSList';
import EditorTermGraph from './EditorTermGraph'; import EditorTermGraph from './EditorTermGraph';
import { RSEditState } from './RSEditContext';
import RSTabsMenu from './RSTabsMenu'; import RSTabsMenu from './RSTabsMenu';
export enum RSTabID { export enum RSTabID {
@ -59,41 +35,13 @@ function RSTabs() {
const activeTab = (Number(query.get('tab')) ?? RSTabID.CARD) as RSTabID; const activeTab = (Number(query.get('tab')) ?? RSTabID.CARD) as RSTabID;
const cstQuery = query.get('active'); const cstQuery = query.get('active');
const { const { schema, loading } = useRSForm();
error,
schema,
loading,
processing,
isOwned,
claim,
download,
isSubscribed,
cstCreate,
cstDelete,
cstRename,
subscribe,
unsubscribe,
cstUpdate,
cstMoveTo,
resetAliases
} = useRSForm();
const { destroyItem } = useLibrary(); const { destroyItem } = useLibrary();
const { setNoFooter } = useConceptTheme(); const { setNoFooter } = useConceptTheme();
const { user } = useAuth();
const { mode, setMode } = useAccessMode();
const [isModified, setIsModified] = useState(false); const [isModified, setIsModified] = useState(false);
useBlockNavigation(isModified); useBlockNavigation(isModified);
const isMutable = useMemo(() => {
return (
!loading &&
!processing &&
mode !== UserAccessMode.READER &&
((isOwned || (mode === UserAccessMode.ADMIN && user?.is_staff)) ?? false)
);
}, [user?.is_staff, mode, isOwned, loading, processing]);
const [selected, setSelected] = useState<number[]>([]); const [selected, setSelected] = useState<number[]>([]);
const activeCst: IConstituenta | undefined = useMemo(() => { const activeCst: IConstituenta | undefined = useMemo(() => {
if (!schema || selected.length === 0) { if (!schema || selected.length === 0) {
@ -103,22 +51,6 @@ function RSTabs() {
} }
}, [schema, selected]); }, [schema, selected]);
const [showUpload, setShowUpload] = useState(false);
const [showClone, setShowClone] = useState(false);
const [showDeleteCst, setShowDeleteCst] = useState(false);
const [createInitialData, setCreateInitialData] = useState<ICstCreateData>();
const [showCreateCst, setShowCreateCst] = useState(false);
const [renameInitialData, setRenameInitialData] = useState<ICstRenameData>();
const [showRenameCst, setShowRenameCst] = useState(false);
const [showEditTerm, setShowEditTerm] = useState(false);
const [insertCstID, setInsertCstID] = useState<number | undefined>(undefined);
const [showTemplates, setShowTemplates] = useState(false);
useLayoutEffect(() => { useLayoutEffect(() => {
if (schema) { if (schema) {
const oldTitle = document.title; const oldTitle = document.title;
@ -145,20 +77,6 @@ function RSTabs() {
return () => setNoFooter(false); return () => setNoFooter(false);
}, [activeTab, cstQuery, setSelected, schema, setNoFooter, setIsModified]); }, [activeTab, cstQuery, setSelected, schema, setNoFooter, setIsModified]);
useLayoutEffect(
() =>
setMode(prev => {
if (prev === UserAccessMode.ADMIN) {
return prev;
} else if (isOwned) {
return UserAccessMode.OWNER;
} else {
return UserAccessMode.READER;
}
}),
[schema, setMode, isOwned]
);
const navigateTab = useCallback( const navigateTab = useCallback(
(tab: RSTabID, activeID?: number) => { (tab: RSTabID, activeID?: number) => {
if (!schema) { if (!schema) {
@ -184,166 +102,36 @@ function RSTabs() {
navigateTab(index, selected.length > 0 ? selected.at(-1) : undefined); navigateTab(index, selected.length > 0 ? selected.at(-1) : undefined);
} }
const handleCreateCst = useCallback( const onCreateCst = useCallback(
(data: ICstCreateData) => { (newCst: IConstituentaMeta) => {
if (!schema?.items) { navigateTab(activeTab, newCst.id);
return; if (activeTab === RSTabID.CST_EDIT || activeTab === RSTabID.CST_LIST) {
setTimeout(() => {
const element = document.getElementById(`${prefixes.cst_list}${newCst.alias}`);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest'
});
}
}, TIMEOUT_UI_REFRESH);
} }
data.alias = data.alias || generateAlias(data.cst_type, schema);
cstCreate(data, newCst => {
toast.success(`Конституента добавлена: ${newCst.alias}`);
setSelected([newCst.id]);
navigateTab(activeTab, newCst.id);
if (activeTab === RSTabID.CST_EDIT || activeTab === RSTabID.CST_LIST) {
setTimeout(() => {
const element = document.getElementById(`${prefixes.cst_list}${newCst.alias}`);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest'
});
}
}, TIMEOUT_UI_REFRESH);
}
});
}, },
[schema, cstCreate, navigateTab, activeTab] [activeTab, navigateTab]
); );
const promptCreateCst = useCallback( const onDeleteCst = useCallback(
(type: CstType | undefined, skipDialog: boolean, definition?: string) => { (newActive?: number) => {
const data: ICstCreateData = { if (!newActive) {
insert_after: activeCst?.id ?? null, navigateTab(RSTabID.CST_LIST);
cst_type: type ?? activeCst?.cst_type ?? CstType.BASE, } else if (activeTab === RSTabID.CST_EDIT) {
alias: '', navigateTab(activeTab, newActive);
term_raw: '',
definition_formal: definition ?? '',
definition_raw: '',
convention: '',
term_forms: []
};
if (skipDialog) {
handleCreateCst(data);
} else { } else {
setCreateInitialData(data); navigateTab(activeTab);
setShowCreateCst(true);
} }
}, },
[handleCreateCst, activeCst] [activeTab, navigateTab]
);
const handleCloneCst = useCallback(() => {
if (!activeCst) {
return;
}
const data: ICstCreateData = {
insert_after: activeCst.id,
cst_type: activeCst.cst_type,
alias: '',
term_raw: activeCst.term_raw,
definition_formal: activeCst.definition_formal,
definition_raw: activeCst.definition_raw,
convention: activeCst.convention,
term_forms: activeCst.term_forms
};
handleCreateCst(data);
}, [activeCst, handleCreateCst]);
const handleRenameCst = useCallback(
(data: ICstRenameData) => {
cstRename(data, () => toast.success(`Переименование: ${renameInitialData!.alias} -> ${data.alias}`));
},
[cstRename, renameInitialData]
);
const promptRenameCst = useCallback(() => {
if (!activeCst) {
return;
}
const data: ICstRenameData = {
id: activeCst.id,
alias: activeCst.alias,
cst_type: activeCst.cst_type
};
setRenameInitialData(data);
setShowRenameCst(true);
}, [activeCst]);
const onReindex = useCallback(() => resetAliases(() => toast.success('Имена конституент обновлены')), [resetAliases]);
// Move selected cst up
function handleMoveUp() {
if (!schema?.items || selected.length === 0) {
return;
}
const currentIndex = schema.items.reduce((prev, cst, index) => {
if (!selected.includes(cst.id)) {
return prev;
} else if (prev === -1) {
return index;
}
return Math.min(prev, index);
}, -1);
const target = Math.max(0, currentIndex - 1) + 1;
const data = {
items: selected,
move_to: target
};
cstMoveTo(data);
}
// Move selected cst down
function handleMoveDown() {
if (!schema?.items || selected.length === 0) {
return;
}
let count = 0;
const currentIndex = schema.items.reduce((prev, cst, index) => {
if (!selected.includes(cst.id)) {
return prev;
} else {
count += 1;
if (prev === -1) {
return index;
}
return Math.max(prev, index);
}
}, -1);
const target = Math.min(schema.items.length - 1, currentIndex - count + 2) + 1;
const data: ICstMovetoData = {
items: selected,
move_to: target
};
cstMoveTo(data);
}
const handleDeleteCst = useCallback(
(deleted: number[]) => {
if (!schema) {
return;
}
const data = {
items: deleted
};
const deletedNames = deleted.map(id => schema.items.find(cst => cst.id === id)?.alias).join(', ');
const isEmpty = deleted.length === schema.items.length;
const nextActive = isEmpty ? undefined : getNextActiveOnDelete(activeCst?.id, schema.items, deleted);
cstDelete(data, () => {
toast.success(`Конституенты удалены: ${deletedNames}`);
if (isEmpty) {
navigateTab(RSTabID.CST_LIST);
} else if (activeTab === RSTabID.CST_EDIT) {
navigateTab(activeTab, nextActive);
} else {
setSelected(nextActive ? [nextActive] : []);
navigateTab(activeTab);
}
});
},
[cstDelete, schema, activeTab, activeCst, navigateTab]
); );
const onOpenCst = useCallback( const onOpenCst = useCallback(
@ -364,132 +152,15 @@ function RSTabs() {
}); });
}, [schema, destroyItem, router]); }, [schema, destroyItem, router]);
const onClaimSchema = useCallback(() => {
if (!window.confirm('Вы уверены, что хотите стать владельцем данной схемы?')) {
return;
}
claim(() => toast.success('Вы стали владельцем схемы'));
}, [claim]);
const onShareSchema = useCallback(() => {
const url = window.location.href + '&share';
navigator.clipboard
.writeText(url)
.then(() => toast.success(`Ссылка скопирована: ${url}`))
.catch(console.error);
}, []);
const onShowTemplates = useCallback((selectedID?: number) => {
setInsertCstID(selectedID);
setShowTemplates(true);
}, []);
const onDownloadSchema = useCallback(() => {
if (isModified) {
if (!window.confirm('Присутствуют несохраненные изменения. Продолжить без их учета?')) {
return;
}
}
const fileName = (schema?.alias ?? 'Schema') + EXTEOR_TRS_FILE;
download((data: Blob) => {
try {
fileDownload(data, fileName);
} catch (error) {
console.error(error);
}
});
}, [schema?.alias, download, isModified]);
const promptClone = useCallback(() => {
if (isModified) {
if (!window.confirm('Присутствуют несохраненные изменения. Продолжить без их учета?')) {
return;
}
}
setShowClone(true);
}, [isModified]);
const handleToggleSubscribe = useCallback(() => {
if (isSubscribed) {
unsubscribe(() => toast.success('Отслеживание отключено'));
} else {
subscribe(() => toast.success('Отслеживание включено'));
}
}, [isSubscribed, subscribe, unsubscribe]);
const promptShowEditTerm = useCallback(() => {
if (!activeCst) {
return;
}
if (isModified) {
if (!window.confirm('Присутствуют несохраненные изменения. Продолжить без их учета?')) {
return;
}
}
setShowEditTerm(true);
}, [isModified, activeCst]);
const handleSaveWordforms = useCallback(
(forms: TermForm[]) => {
if (!activeCst) {
return;
}
const data: ICstUpdateData = {
id: activeCst.id,
term_forms: forms
};
cstUpdate(data, () => toast.success('Изменения сохранены'));
},
[cstUpdate, activeCst]
);
return ( return (
<> <RSEditState
<AnimatePresence> selected={selected}
{showUpload ? <DlgUploadRSForm hideWindow={() => setShowUpload(false)} /> : null} setSelected={setSelected}
{showClone ? <DlgCloneLibraryItem base={schema!} hideWindow={() => setShowClone(false)} /> : null} activeCst={activeCst}
{showCreateCst ? ( isModified={isModified}
<DlgCreateCst onCreateCst={onCreateCst}
hideWindow={() => setShowCreateCst(false)} onDeleteCst={onDeleteCst}
onCreate={handleCreateCst} >
schema={schema!}
initial={createInitialData}
/>
) : null}
{showRenameCst ? (
<DlgRenameCst
hideWindow={() => setShowRenameCst(false)}
onRename={handleRenameCst}
initial={renameInitialData!}
/>
) : null}
{showDeleteCst ? (
<DlgDeleteCst
schema={schema!}
hideWindow={() => setShowDeleteCst(false)}
onDelete={handleDeleteCst}
selected={selected}
/>
) : null}
{showEditTerm ? (
<DlgEditWordForms
hideWindow={() => setShowEditTerm(false)}
onSave={handleSaveWordforms}
target={activeCst!}
/>
) : null}
{showTemplates ? (
<DlgConstituentaTemplate
schema={schema!}
hideWindow={() => setShowTemplates(false)}
insertAfter={insertCstID}
onCreate={handleCreateCst}
/>
) : null}
</AnimatePresence>
{loading ? <Loader /> : null}
{error ? <ProcessError error={error} /> : null}
{schema && !loading ? ( {schema && !loading ? (
<Tabs <Tabs
selectedIndex={activeTab} selectedIndex={activeTab}
@ -499,17 +170,8 @@ function RSTabs() {
className='flex flex-col min-w-[45rem]' className='flex flex-col min-w-[45rem]'
> >
<TabList className={clsx('mx-auto', 'flex', 'border-b-2 border-x-2 divide-x-2')}> <TabList className={clsx('mx-auto', 'flex', 'border-b-2 border-x-2 divide-x-2')}>
<RSTabsMenu <RSTabsMenu onDestroy={onDestroySchema} />
isMutable={isMutable}
onTemplates={onShowTemplates}
onDownload={onDownloadSchema}
onDestroy={onDestroySchema}
onClaim={onClaimSchema}
onShare={onShareSchema}
onReindex={onReindex}
showCloneDialog={promptClone}
showUploadDialog={() => setShowUpload(true)}
/>
<TabLabel label='Карточка' title={`Название схемы: ${schema.title ?? ''}`} /> <TabLabel label='Карточка' title={`Название схемы: ${schema.title ?? ''}`} />
<TabLabel <TabLabel
label='Содержание' label='Содержание'
@ -522,106 +184,33 @@ function RSTabs() {
<AnimateFade> <AnimateFade>
<TabPanel forceRender style={{ display: activeTab === RSTabID.CARD ? '' : 'none' }}> <TabPanel forceRender style={{ display: activeTab === RSTabID.CARD ? '' : 'none' }}>
<EditorRSForm <EditorRSForm
isMutable={isMutable} isModified={isModified} // prettier: split lines
isModified={isModified}
setIsModified={setIsModified} setIsModified={setIsModified}
onToggleSubscribe={handleToggleSubscribe}
onDownload={onDownloadSchema}
onDestroy={onDestroySchema} onDestroy={onDestroySchema}
onClaim={onClaimSchema}
onShare={onShareSchema}
/> />
</TabPanel> </TabPanel>
<TabPanel forceRender style={{ display: activeTab === RSTabID.CST_LIST ? '' : 'none' }}> <TabPanel forceRender style={{ display: activeTab === RSTabID.CST_LIST ? '' : 'none' }}>
<EditorRSList <EditorRSList selected={selected} setSelected={setSelected} onOpenEdit={onOpenCst} />
schema={schema}
selected={selected}
setSelected={setSelected}
isMutable={isMutable}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
onOpenEdit={onOpenCst}
onClone={handleCloneCst}
onCreate={type => promptCreateCst(type, type !== undefined)}
onDelete={() => setShowDeleteCst(true)}
/>
</TabPanel> </TabPanel>
<TabPanel forceRender style={{ display: activeTab === RSTabID.CST_EDIT ? '' : 'none' }}> <TabPanel forceRender style={{ display: activeTab === RSTabID.CST_EDIT ? '' : 'none' }}>
<EditorConstituenta <EditorConstituenta
schema={schema}
isMutable={isMutable}
isModified={isModified} isModified={isModified}
setIsModified={setIsModified} setIsModified={setIsModified}
activeCst={activeCst} activeCst={activeCst}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
onOpenEdit={onOpenCst} onOpenEdit={onOpenCst}
onClone={handleCloneCst}
onCreate={type => promptCreateCst(type, false)}
onDelete={() => setShowDeleteCst(true)}
onRename={promptRenameCst}
onEditTerm={promptShowEditTerm}
/> />
</TabPanel> </TabPanel>
<TabPanel style={{ display: activeTab === RSTabID.TERM_GRAPH ? '' : 'none' }}> <TabPanel style={{ display: activeTab === RSTabID.TERM_GRAPH ? '' : 'none' }}>
<EditorTermGraph <EditorTermGraph selected={selected} setSelected={setSelected} onOpenEdit={onOpenCst} />
schema={schema}
selected={selected}
setSelected={setSelected}
isMutable={isMutable}
onOpenEdit={onOpenCst}
onCreate={(type, definition) => promptCreateCst(type, false, definition)}
onDelete={() => setShowDeleteCst(true)}
/>
</TabPanel> </TabPanel>
</AnimateFade> </AnimateFade>
</Tabs> </Tabs>
) : null} ) : null}
</> </RSEditState>
); );
} }
export default RSTabs; export default RSTabs;
// ====== Internals =========
function ProcessError({ error }: { error: ErrorData }): React.ReactElement {
if (axios.isAxiosError(error) && error.response && error.response.status === 404) {
return (
<div className='p-2 text-center'>
<p>Схема с указанным идентификатором отсутствует на портале.</p>
<TextURL text='Перейти в Библиотеку' href='/library' />
</div>
);
} else {
return <InfoError error={error} />;
}
}
function getNextActiveOnDelete(
activeID: number | undefined,
items: IConstituenta[],
deleted: number[]
): number | undefined {
if (items.length === deleted.length) {
return undefined;
}
let activeIndex = items.findIndex(cst => cst.id === activeID);
if (activeIndex === -1) {
return undefined;
}
while (activeIndex < items.length && deleted.find(id => id === items[activeIndex].id)) {
++activeIndex;
}
if (activeIndex >= items.length) {
activeIndex = items.length - 1;
while (activeIndex >= 0 && deleted.find(id => id === items[activeIndex].id)) {
--activeIndex;
}
}
return items[activeIndex].id;
}

View File

@ -26,31 +26,14 @@ import useDropdown from '@/hooks/useDropdown';
import { UserAccessMode } from '@/models/miscellaneous'; import { UserAccessMode } from '@/models/miscellaneous';
import { describeAccessMode, labelAccessMode } from '@/utils/labels'; import { describeAccessMode, labelAccessMode } from '@/utils/labels';
import { useRSEdit } from './RSEditContext';
interface RSTabsMenuProps { interface RSTabsMenuProps {
isMutable: boolean;
showUploadDialog: () => void;
showCloneDialog: () => void;
onDestroy: () => void; onDestroy: () => void;
onClaim: () => void;
onShare: () => void;
onDownload: () => void;
onReindex: () => void;
onTemplates: () => void;
} }
function RSTabsMenu({ function RSTabsMenu({ onDestroy }: RSTabsMenuProps) {
isMutable, const controller = useRSEdit();
showUploadDialog,
showCloneDialog,
onDestroy,
onShare,
onDownload,
onClaim,
onReindex,
onTemplates
}: RSTabsMenuProps) {
const router = useConceptNavigation(); const router = useConceptNavigation();
const { user } = useAuth(); const { user } = useAuth();
const { isOwned, isClaimable } = useRSForm(); const { isOwned, isClaimable } = useRSForm();
@ -63,7 +46,7 @@ function RSTabsMenu({
function handleClaimOwner() { function handleClaimOwner() {
editMenu.hide(); editMenu.hide();
onClaim(); controller.claim();
} }
function handleDelete() { function handleDelete() {
@ -73,32 +56,32 @@ function RSTabsMenu({
function handleDownload() { function handleDownload() {
schemaMenu.hide(); schemaMenu.hide();
onDownload(); controller.download();
} }
function handleUpload() { function handleUpload() {
schemaMenu.hide(); schemaMenu.hide();
showUploadDialog(); controller.promptUpload();
} }
function handleClone() { function handleClone() {
schemaMenu.hide(); schemaMenu.hide();
showCloneDialog(); controller.promptClone();
} }
function handleShare() { function handleShare() {
schemaMenu.hide(); schemaMenu.hide();
onShare(); controller.share();
} }
function handleReindex() { function handleReindex() {
editMenu.hide(); editMenu.hide();
onReindex(); controller.reindex();
} }
function handleTemplates() { function handleTemplates() {
editMenu.hide(); editMenu.hide();
onTemplates(); controller.promptTemplate();
} }
function handleChangeMode(newMode: UserAccessMode) { function handleChangeMode(newMode: UserAccessMode) {
@ -148,15 +131,15 @@ function RSTabsMenu({
onClick={handleDownload} onClick={handleDownload}
/> />
<DropdownButton <DropdownButton
disabled={!isMutable} disabled={!controller.isMutable}
text='Загрузить из Экстеора' text='Загрузить из Экстеора'
icon={<BiUpload size='1rem' className={isMutable ? 'clr-text-warning' : ''} />} icon={<BiUpload size='1rem' className={controller.isMutable ? 'clr-text-warning' : ''} />}
onClick={handleUpload} onClick={handleUpload}
/> />
<DropdownButton <DropdownButton
disabled={!isMutable} disabled={!controller.isMutable}
text='Удалить схему' text='Удалить схему'
icon={<BiTrash size='1rem' className={isMutable ? 'clr-text-warning' : ''} />} icon={<BiTrash size='1rem' className={controller.isMutable ? 'clr-text-warning' : ''} />}
onClick={handleDelete} onClick={handleDelete}
/> />
<DropdownButton <DropdownButton
@ -176,22 +159,22 @@ function RSTabsMenu({
hideTitle={editMenu.isOpen} hideTitle={editMenu.isOpen}
className='h-full' className='h-full'
style={{ outlineColor: 'transparent' }} style={{ outlineColor: 'transparent' }}
icon={<FiEdit size='1.25rem' className={isMutable ? 'clr-text-success' : 'clr-text-warning'} />} icon={<FiEdit size='1.25rem' className={controller.isMutable ? 'clr-text-success' : 'clr-text-warning'} />}
onClick={editMenu.toggle} onClick={editMenu.toggle}
/> />
<Dropdown isOpen={editMenu.isOpen}> <Dropdown isOpen={editMenu.isOpen}>
<DropdownButton <DropdownButton
disabled={!isMutable} disabled={!controller.isMutable}
text='Сброс имён' text='Сброс имён'
title='Присвоить порядковые имена и обновить выражения' title='Присвоить порядковые имена и обновить выражения'
icon={<BiAnalyse size='1rem' className={isMutable ? 'clr-text-primary' : ''} />} icon={<BiAnalyse size='1rem' className={controller.isMutable ? 'clr-text-primary' : ''} />}
onClick={handleReindex} onClick={handleReindex}
/> />
<DropdownButton <DropdownButton
disabled={!isMutable} disabled={!controller.isMutable}
text='Банк выражений' text='Банк выражений'
title='Создать конституенту из шаблона' title='Создать конституенту из шаблона'
icon={<BiDiamond size='1rem' className={isMutable ? 'clr-text-success' : ''} />} icon={<BiDiamond size='1rem' className={controller.isMutable ? 'clr-text-success' : ''} />}
onClick={handleTemplates} onClick={handleTemplates}
/> />
</Dropdown> </Dropdown>