diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorConstituenta/EditorConstituenta.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorConstituenta/EditorConstituenta.tsx index 9835f45d..bcd221dd 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorConstituenta/EditorConstituenta.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorConstituenta/EditorConstituenta.tsx @@ -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>; - - 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) { 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({ setToggleReset(prev => !prev)} - onDelete={onDelete} - onClone={onClone} - onCreate={handleCreate} + onDelete={controller.deleteCst} + onClone={controller.cloneCst} + onCreate={() => controller.createCst(activeCst?.cst_type, false)} />
setShowList(prev => !prev)} setIsModified={setIsModified} - onEditTerm={onEditTerm} - onRename={onRename} + onEditTerm={controller.editTermForms} + onRename={controller.renameCst} /> {showList && windowSize.width && windowSize.width >= SIDELIST_HIDE_THRESHOLD ? ( >; 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 ( <>
diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/RSFormToolbar.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/RSFormToolbar.tsx index f167ab4b..2abc275b 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/RSFormToolbar.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorRSForm/RSFormToolbar.tsx @@ -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 ( } - onClick={onShare} + onClick={controller.share} /> } - onClick={onDownload} + onClick={controller.download} /> ) : ( ) } style={{ outlineColor: 'transparent' }} - onClick={onToggleSubscribe} + onClick={controller.toggleSubscribe} /> } disabled={!claimable || anonymous || processing} - onClick={onClaim} + onClick={controller.claim} /> } + icon={} /> diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorRSList/EditorRSList.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorRSList/EditorRSList.tsx index a4e54374..e1e87200 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorRSList/EditorRSList.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorRSList/EditorRSList.tsx @@ -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>; - 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({}); + 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) { - 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) { - 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 (
- +
controller.createCst(undefined, false)} />
); diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorRSList/RSListToolbar.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorRSList/RSListToolbar.tsx index fcca1bdc..5f295319 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorRSList/RSListToolbar.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorRSList/RSListToolbar.tsx @@ -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({ } - disabled={!isMutable || nothingSelected} - onClick={onMoveUp} + icon={ + + } + disabled={!controller.isMutable || nothingSelected} + onClick={controller.moveUp} /> } - disabled={!isMutable || nothingSelected} - onClick={onMoveDown} + icon={ + + } + disabled={!controller.isMutable || nothingSelected} + onClick={controller.moveDown} /> } - disabled={!isMutable || selectedCount !== 1} - onClick={onClone} + icon={ + + } + disabled={!controller.isMutable || selectedCount !== 1} + onClick={controller.cloneCst} /> } - disabled={!isMutable} - onClick={() => onCreate()} + icon={} + disabled={!controller.isMutable} + onClick={() => controller.createCst(undefined, false)} />
} - disabled={!isMutable} + icon={} + disabled={!controller.isMutable} onClick={insertMenu.toggle} /> @@ -77,7 +74,7 @@ function RSListToolbar({ onCreate(typeStr as CstType)} + onClick={() => controller.createCst(typeStr as CstType, true)} title={getCstTypeShortcut(typeStr as CstType)} /> ))} @@ -85,9 +82,9 @@ function RSListToolbar({
} - disabled={!isMutable || nothingSelected} - onClick={onDelete} + icon={} + disabled={!controller.isMutable || nothingSelected} + onClick={controller.deleteCst} />
diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorTermGraph/EditorTermGraph.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorTermGraph/EditorTermGraph.tsx index 87c74013..218d94d9 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorTermGraph/EditorTermGraph.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorTermGraph/EditorTermGraph.tsx @@ -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>; 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('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([]); @@ -72,32 +62,32 @@ function EditorTermGraph({ const [hoverID, setHoverID] = useState(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) { // Hotkeys implementation - if (!isMutable) { + if (!controller.isMutable) { return; } if (event.key === 'Delete') { @@ -199,13 +189,13 @@ function EditorTermGraph({ void; @@ -43,7 +43,7 @@ function ViewHidden({ items, selected, toggleSelection, schema, coloringScheme,

{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 ( diff --git a/rsconcept/frontend/src/pages/RSFormPage/RSEditContext.tsx b/rsconcept/frontend/src/pages/RSFormPage/RSEditContext.tsx new file mode 100644 index 00000000..8d14c181 --- /dev/null +++ b/rsconcept/frontend/src/pages/RSFormPage/RSEditContext.tsx @@ -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(null); +export const useRSEdit = () => { + const context = useContext(RSEditContext); + if (context === null) { + throw new Error('useRSEdit has to be used within '); + } + return context; +}; + +interface RSEditStateProps { + selected: number[]; + isModified: boolean; + setSelected: React.Dispatch>; + 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(); + const [showCreateCst, setShowCreateCst] = useState(false); + + const [renameInitialData, setRenameInitialData] = useState(); + const [showRenameCst, setShowRenameCst] = useState(false); + + const [showEditTerm, setShowEditTerm] = useState(false); + + const [insertCstID, setInsertCstID] = useState(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 ( + setShowDeleteCst(true), + editTermForms, + + promptTemplate, + promptClone, + promptUpload: () => setShowUpload(true), + download, + claim, + share, + toggleSubscribe, + reindex + }} + > + {model.schema ? ( + + {showUpload ? setShowUpload(false)} /> : null} + {showClone ? setShowClone(false)} /> : null} + {showCreateCst ? ( + setShowCreateCst(false)} + onCreate={handleCreateCst} + schema={model.schema} + initial={createInitialData} + /> + ) : null} + {showRenameCst && renameInitialData ? ( + setShowRenameCst(false)} + onRename={handleRenameCst} + initial={renameInitialData} + /> + ) : null} + {showDeleteCst ? ( + setShowDeleteCst(false)} + onDelete={handleDeleteCst} + selected={selected} + /> + ) : null} + {showEditTerm && activeCst ? ( + setShowEditTerm(false)} + onSave={handleSaveWordforms} + target={activeCst} + /> + ) : null} + {showTemplates ? ( + setShowTemplates(false)} + insertAfter={insertCstID} + onCreate={handleCreateCst} + /> + ) : null} + + ) : null} + + {model.loading ? : null} + {model.error ? : null} + {model.schema && !model.loading ? children : null} + + ); +}; + +// ====== Internals ========= +function ProcessError({ error }: { error: ErrorData }): React.ReactElement { + if (axios.isAxiosError(error) && error.response && error.response.status === 404) { + return ( +
+

Схема с указанным идентификатором отсутствует на портале.

+ +
+ ); + } else { + return ; + } +} + +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; +} diff --git a/rsconcept/frontend/src/pages/RSFormPage/RSTabs.tsx b/rsconcept/frontend/src/pages/RSFormPage/RSTabs.tsx index 024b9476..05aed8d0 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/RSTabs.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/RSTabs.tsx @@ -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([]); 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(); - const [showCreateCst, setShowCreateCst] = useState(false); - - const [renameInitialData, setRenameInitialData] = useState(); - const [showRenameCst, setShowRenameCst] = useState(false); - - const [showEditTerm, setShowEditTerm] = useState(false); - - const [insertCstID, setInsertCstID] = useState(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 ( - <> - - {showUpload ? setShowUpload(false)} /> : null} - {showClone ? setShowClone(false)} /> : null} - {showCreateCst ? ( - setShowCreateCst(false)} - onCreate={handleCreateCst} - schema={schema!} - initial={createInitialData} - /> - ) : null} - {showRenameCst ? ( - setShowRenameCst(false)} - onRename={handleRenameCst} - initial={renameInitialData!} - /> - ) : null} - {showDeleteCst ? ( - setShowDeleteCst(false)} - onDelete={handleDeleteCst} - selected={selected} - /> - ) : null} - {showEditTerm ? ( - setShowEditTerm(false)} - onSave={handleSaveWordforms} - target={activeCst!} - /> - ) : null} - {showTemplates ? ( - setShowTemplates(false)} - insertAfter={insertCstID} - onCreate={handleCreateCst} - /> - ) : null} - - - {loading ? : null} - {error ? : null} + {schema && !loading ? ( - setShowUpload(true)} - /> + + - promptCreateCst(type, type !== undefined)} - onDelete={() => setShowDeleteCst(true)} - /> + promptCreateCst(type, false)} - onDelete={() => setShowDeleteCst(true)} - onRename={promptRenameCst} - onEditTerm={promptShowEditTerm} /> - promptCreateCst(type, false, definition)} - onDelete={() => setShowDeleteCst(true)} - /> + ) : null} - + ); } export default RSTabs; - -// ====== Internals ========= -function ProcessError({ error }: { error: ErrorData }): React.ReactElement { - if (axios.isAxiosError(error) && error.response && error.response.status === 404) { - return ( -
-

Схема с указанным идентификатором отсутствует на портале.

- -
- ); - } else { - return ; - } -} - -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; -} diff --git a/rsconcept/frontend/src/pages/RSFormPage/RSTabsMenu.tsx b/rsconcept/frontend/src/pages/RSFormPage/RSTabsMenu.tsx index 0de90e4d..362dd606 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/RSTabsMenu.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/RSTabsMenu.tsx @@ -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} /> } + icon={} onClick={handleUpload} /> } + icon={} onClick={handleDelete} /> } + icon={} onClick={editMenu.toggle} /> } + icon={} onClick={handleReindex} /> } + icon={} onClick={handleTemplates} />