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

View File

@ -7,32 +7,19 @@ import { useAuth } from '@/context/AuthContext';
import { useRSForm } from '@/context/RSFormContext';
import { globalIDs } from '@/utils/constants';
import { useRSEdit } from '../RSEditContext';
import FormRSForm from './FormRSForm';
import RSFormStats from './RSFormStats';
import RSFormToolbar from './RSFormToolbar';
interface EditorRSFormProps {
isModified: boolean;
isMutable: boolean;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>;
onDestroy: () => void;
onClaim: () => void;
onShare: () => void;
onDownload: () => void;
onToggleSubscribe: () => void;
}
function EditorRSForm({
isModified,
isMutable,
onDestroy,
onClaim,
onShare,
setIsModified,
onDownload,
onToggleSubscribe
}: EditorRSFormProps) {
function EditorRSForm({ isModified, onDestroy, setIsModified }: EditorRSFormProps) {
const { isMutable } = useRSEdit();
const { schema, isClaimable, isSubscribed, processing } = useRSForm();
const { user } = useAuth();
@ -55,18 +42,13 @@ function EditorRSForm({
return (
<>
<RSFormToolbar
isMutable={isMutable}
processing={processing}
isSubscribed={isSubscribed}
subscribed={isSubscribed}
modified={isModified}
claimable={isClaimable}
anonymous={!user}
onSubmit={initiateSubmit}
onShare={onShare}
onDownload={onDownload}
onClaim={onClaim}
onDestroy={onDestroy}
onToggleSubscribe={onToggleSubscribe}
/>
<div tabIndex={-1} className='flex flex-col sm:flex-row w-fit' onKeyDown={handleInput}>
<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 { HelpTopic } from '@/models/miscellaneous';
interface RSFormToolbarProps {
isMutable: boolean;
isSubscribed: boolean;
modified: boolean;
claimable: boolean;
anonymous: boolean;
processing: boolean;
import { useRSEdit } from '../RSEditContext';
interface RSFormToolbarProps {
modified: boolean;
subscribed: boolean;
anonymous: boolean;
claimable: boolean;
processing: boolean;
onSubmit: () => void;
onShare: () => void;
onDownload: () => void;
onClaim: () => void;
onDestroy: () => void;
onToggleSubscribe: () => void;
}
function RSFormToolbar({
isMutable,
modified,
claimable,
anonymous,
isSubscribed,
onToggleSubscribe,
subscribed,
claimable,
processing,
onSubmit,
onShare,
onDownload,
onClaim,
onDestroy
}: RSFormToolbarProps) {
const canSave = useMemo(() => modified && isMutable, [modified, isMutable]);
const controller = useRSEdit();
const canSave = useMemo(() => modified && controller.isMutable, [modified, controller.isMutable]);
return (
<Overlay position='top-1 right-1/2 translate-x-1/2' className='flex'>
<MiniButton
@ -52,37 +44,37 @@ function RSFormToolbar({
<MiniButton
title='Поделиться схемой'
icon={<BiShareAlt size='1.25rem' className='clr-text-primary' />}
onClick={onShare}
onClick={controller.share}
/>
<MiniButton
title='Скачать TRS файл'
icon={<BiDownload size='1.25rem' className='clr-text-primary' />}
onClick={onDownload}
onClick={controller.download}
/>
<MiniButton
title={`Отслеживание ${isSubscribed ? 'включено' : 'выключено'}`}
title={`Отслеживание ${subscribed ? 'включено' : 'выключено'}`}
disabled={anonymous || processing}
icon={
isSubscribed ? (
subscribed ? (
<FiBell size='1.25rem' className='clr-text-primary' />
) : (
<FiBellOff size='1.25rem' className='clr-text-controls' />
)
}
style={{ outlineColor: 'transparent' }}
onClick={onToggleSubscribe}
onClick={controller.toggleSubscribe}
/>
<MiniButton
title='Стать владельцем'
icon={<LuCrown size='1.25rem' className={!claimable || anonymous ? '' : 'clr-text-success'} />}
disabled={!claimable || anonymous || processing}
onClick={onClaim}
onClick={controller.claim}
/>
<MiniButton
title='Удалить схему'
disabled={!isMutable}
disabled={!controller.isMutable}
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} />
</Overlay>

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ import { prefixes } from '@/utils/constants';
interface ViewHiddenProps {
items: number[];
selected: number[];
schema: IRSForm;
schema?: IRSForm;
coloringScheme: GraphColoringScheme;
toggleSelection: (cstID: number) => void;
@ -43,7 +43,7 @@ function ViewHidden({ items, selected, toggleSelection, schema, coloringScheme,
</p>
<div className='flex flex-wrap justify-center gap-2 py-2 overflow-y-auto' style={{ maxHeight: dismissedHeight }}>
{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 id = `${prefixes.cst_hidden_list}${cst.alias}`;
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';
import axios from 'axios';
import clsx from 'clsx';
import { AnimatePresence } from 'framer-motion';
import fileDownload from 'js-file-download';
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { TabList, TabPanel, Tabs } from 'react-tabs';
import { toast } from 'react-toastify';
import AnimateFade from '@/components/AnimateFade';
import InfoError, { ErrorData } from '@/components/InfoError';
import Loader from '@/components/ui/Loader';
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 { useBlockNavigation, useConceptNavigation } from '@/context/NavigationContext';
import { useRSForm } from '@/context/RSFormContext';
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 { UserAccessMode } from '@/models/miscellaneous';
import {
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 { IConstituenta, IConstituentaMeta } from '@/models/rsform';
import { prefixes, TIMEOUT_UI_REFRESH } from '@/utils/constants';
import EditorConstituenta from './EditorConstituenta';
import EditorRSForm from './EditorRSForm';
import EditorRSList from './EditorRSList';
import EditorTermGraph from './EditorTermGraph';
import { RSEditState } from './RSEditContext';
import RSTabsMenu from './RSTabsMenu';
export enum RSTabID {
@ -59,41 +35,13 @@ function RSTabs() {
const activeTab = (Number(query.get('tab')) ?? RSTabID.CARD) as RSTabID;
const cstQuery = query.get('active');
const {
error,
schema,
loading,
processing,
isOwned,
claim,
download,
isSubscribed,
cstCreate,
cstDelete,
cstRename,
subscribe,
unsubscribe,
cstUpdate,
cstMoveTo,
resetAliases
} = useRSForm();
const { schema, loading } = useRSForm();
const { destroyItem } = useLibrary();
const { setNoFooter } = useConceptTheme();
const { user } = useAuth();
const { mode, setMode } = useAccessMode();
const [isModified, setIsModified] = useState(false);
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 activeCst: IConstituenta | undefined = useMemo(() => {
if (!schema || selected.length === 0) {
@ -103,22 +51,6 @@ function RSTabs() {
}
}, [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(() => {
if (schema) {
const oldTitle = document.title;
@ -145,20 +77,6 @@ function RSTabs() {
return () => setNoFooter(false);
}, [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(
(tab: RSTabID, activeID?: number) => {
if (!schema) {
@ -184,166 +102,36 @@ function RSTabs() {
navigateTab(index, selected.length > 0 ? selected.at(-1) : undefined);
}
const handleCreateCst = useCallback(
(data: ICstCreateData) => {
if (!schema?.items) {
return;
const onCreateCst = useCallback(
(newCst: IConstituentaMeta) => {
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);
}
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(
(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);
const onDeleteCst = useCallback(
(newActive?: number) => {
if (!newActive) {
navigateTab(RSTabID.CST_LIST);
} else if (activeTab === RSTabID.CST_EDIT) {
navigateTab(activeTab, newActive);
} else {
setCreateInitialData(data);
setShowCreateCst(true);
navigateTab(activeTab);
}
},
[handleCreateCst, activeCst]
);
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]
[activeTab, navigateTab]
);
const onOpenCst = useCallback(
@ -364,132 +152,15 @@ function RSTabs() {
});
}, [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 (
<>
<AnimatePresence>
{showUpload ? <DlgUploadRSForm hideWindow={() => setShowUpload(false)} /> : null}
{showClone ? <DlgCloneLibraryItem base={schema!} hideWindow={() => setShowClone(false)} /> : null}
{showCreateCst ? (
<DlgCreateCst
hideWindow={() => setShowCreateCst(false)}
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}
<RSEditState
selected={selected}
setSelected={setSelected}
activeCst={activeCst}
isModified={isModified}
onCreateCst={onCreateCst}
onDeleteCst={onDeleteCst}
>
{schema && !loading ? (
<Tabs
selectedIndex={activeTab}
@ -499,17 +170,8 @@ function RSTabs() {
className='flex flex-col min-w-[45rem]'
>
<TabList className={clsx('mx-auto', 'flex', 'border-b-2 border-x-2 divide-x-2')}>
<RSTabsMenu
isMutable={isMutable}
onTemplates={onShowTemplates}
onDownload={onDownloadSchema}
onDestroy={onDestroySchema}
onClaim={onClaimSchema}
onShare={onShareSchema}
onReindex={onReindex}
showCloneDialog={promptClone}
showUploadDialog={() => setShowUpload(true)}
/>
<RSTabsMenu onDestroy={onDestroySchema} />
<TabLabel label='Карточка' title={`Название схемы: ${schema.title ?? ''}`} />
<TabLabel
label='Содержание'
@ -522,106 +184,33 @@ function RSTabs() {
<AnimateFade>
<TabPanel forceRender style={{ display: activeTab === RSTabID.CARD ? '' : 'none' }}>
<EditorRSForm
isMutable={isMutable}
isModified={isModified}
isModified={isModified} // prettier: split lines
setIsModified={setIsModified}
onToggleSubscribe={handleToggleSubscribe}
onDownload={onDownloadSchema}
onDestroy={onDestroySchema}
onClaim={onClaimSchema}
onShare={onShareSchema}
/>
</TabPanel>
<TabPanel forceRender style={{ display: activeTab === RSTabID.CST_LIST ? '' : 'none' }}>
<EditorRSList
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)}
/>
<EditorRSList selected={selected} setSelected={setSelected} onOpenEdit={onOpenCst} />
</TabPanel>
<TabPanel forceRender style={{ display: activeTab === RSTabID.CST_EDIT ? '' : 'none' }}>
<EditorConstituenta
schema={schema}
isMutable={isMutable}
isModified={isModified}
setIsModified={setIsModified}
activeCst={activeCst}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
onOpenEdit={onOpenCst}
onClone={handleCloneCst}
onCreate={type => promptCreateCst(type, false)}
onDelete={() => setShowDeleteCst(true)}
onRename={promptRenameCst}
onEditTerm={promptShowEditTerm}
/>
</TabPanel>
<TabPanel style={{ display: activeTab === RSTabID.TERM_GRAPH ? '' : 'none' }}>
<EditorTermGraph
schema={schema}
selected={selected}
setSelected={setSelected}
isMutable={isMutable}
onOpenEdit={onOpenCst}
onCreate={(type, definition) => promptCreateCst(type, false, definition)}
onDelete={() => setShowDeleteCst(true)}
/>
<EditorTermGraph selected={selected} setSelected={setSelected} onOpenEdit={onOpenCst} />
</TabPanel>
</AnimateFade>
</Tabs>
) : null}
</>
</RSEditState>
);
}
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 { describeAccessMode, labelAccessMode } from '@/utils/labels';
import { useRSEdit } from './RSEditContext';
interface RSTabsMenuProps {
isMutable: boolean;
showUploadDialog: () => void;
showCloneDialog: () => void;
onDestroy: () => void;
onClaim: () => void;
onShare: () => void;
onDownload: () => void;
onReindex: () => void;
onTemplates: () => void;
}
function RSTabsMenu({
isMutable,
showUploadDialog,
showCloneDialog,
onDestroy,
onShare,
onDownload,
onClaim,
onReindex,
onTemplates
}: RSTabsMenuProps) {
function RSTabsMenu({ onDestroy }: RSTabsMenuProps) {
const controller = useRSEdit();
const router = useConceptNavigation();
const { user } = useAuth();
const { isOwned, isClaimable } = useRSForm();
@ -63,7 +46,7 @@ function RSTabsMenu({
function handleClaimOwner() {
editMenu.hide();
onClaim();
controller.claim();
}
function handleDelete() {
@ -73,32 +56,32 @@ function RSTabsMenu({
function handleDownload() {
schemaMenu.hide();
onDownload();
controller.download();
}
function handleUpload() {
schemaMenu.hide();
showUploadDialog();
controller.promptUpload();
}
function handleClone() {
schemaMenu.hide();
showCloneDialog();
controller.promptClone();
}
function handleShare() {
schemaMenu.hide();
onShare();
controller.share();
}
function handleReindex() {
editMenu.hide();
onReindex();
controller.reindex();
}
function handleTemplates() {
editMenu.hide();
onTemplates();
controller.promptTemplate();
}
function handleChangeMode(newMode: UserAccessMode) {
@ -148,15 +131,15 @@ function RSTabsMenu({
onClick={handleDownload}
/>
<DropdownButton
disabled={!isMutable}
disabled={!controller.isMutable}
text='Загрузить из Экстеора'
icon={<BiUpload size='1rem' className={isMutable ? 'clr-text-warning' : ''} />}
icon={<BiUpload size='1rem' className={controller.isMutable ? 'clr-text-warning' : ''} />}
onClick={handleUpload}
/>
<DropdownButton
disabled={!isMutable}
disabled={!controller.isMutable}
text='Удалить схему'
icon={<BiTrash size='1rem' className={isMutable ? 'clr-text-warning' : ''} />}
icon={<BiTrash size='1rem' className={controller.isMutable ? 'clr-text-warning' : ''} />}
onClick={handleDelete}
/>
<DropdownButton
@ -176,22 +159,22 @@ function RSTabsMenu({
hideTitle={editMenu.isOpen}
className='h-full'
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}
/>
<Dropdown isOpen={editMenu.isOpen}>
<DropdownButton
disabled={!isMutable}
disabled={!controller.isMutable}
text='Сброс имён'
title='Присвоить порядковые имена и обновить выражения'
icon={<BiAnalyse size='1rem' className={isMutable ? 'clr-text-primary' : ''} />}
icon={<BiAnalyse size='1rem' className={controller.isMutable ? 'clr-text-primary' : ''} />}
onClick={handleReindex}
/>
<DropdownButton
disabled={!isMutable}
disabled={!controller.isMutable}
text='Банк выражений'
title='Создать конституенту из шаблона'
icon={<BiDiamond size='1rem' className={isMutable ? 'clr-text-success' : ''} />}
icon={<BiDiamond size='1rem' className={controller.isMutable ? 'clr-text-success' : ''} />}
onClick={handleTemplates}
/>
</Dropdown>