diff --git a/rsconcept/frontend/src/components/select/PickMultiConstituenta.tsx b/rsconcept/frontend/src/components/select/PickMultiConstituenta.tsx index ef046649..e5fc818e 100644 --- a/rsconcept/frontend/src/components/select/PickMultiConstituenta.tsx +++ b/rsconcept/frontend/src/components/select/PickMultiConstituenta.tsx @@ -5,6 +5,7 @@ import { useLayoutEffect, useMemo, useState } from 'react'; import DataTable, { createColumnHelper, RowSelectionState } from '@/components/ui/DataTable'; import { useConceptOptions } from '@/context/ConceptOptionsContext'; +import { Graph } from '@/models/Graph'; import { CstMatchMode } from '@/models/miscellaneous'; import { ConstituentaID, IConstituenta, IRSForm } from '@/models/rsform'; import { isBasicConcept, matchConstituenta } from '@/models/rsformAPI'; @@ -17,7 +18,9 @@ import ToolbarGraphSelection from './ToolbarGraphSelection'; interface PickMultiConstituentaProps { id?: string; - schema?: IRSForm; + schema: IRSForm; + data: IConstituenta[]; + prefixID: string; rows?: number; @@ -27,12 +30,39 @@ interface PickMultiConstituentaProps { const columnHelper = createColumnHelper(); -function PickMultiConstituenta({ id, schema, prefixID, rows, selected, setSelected }: PickMultiConstituentaProps) { +function PickMultiConstituenta({ + id, + schema, + data, + prefixID, + rows, + selected, + setSelected +}: PickMultiConstituentaProps) { const { colors } = useConceptOptions(); const [rowSelection, setRowSelection] = useState({}); - const [filtered, setFiltered] = useState(schema?.items ?? []); + const [filtered, setFiltered] = useState(data); const [filterText, setFilterText] = useState(''); + const foldedGraph = useMemo(() => { + if (data.length === schema.items.length) { + return schema.graph; + } + const newGraph = new Graph(); + schema.graph.nodes.forEach(node => { + newGraph.addNode(node.id); + node.outputs.forEach(output => { + newGraph.addEdge(node.id, output); + }); + }); + schema.items + .filter(item => data.find(cst => cst.id === item.id) === undefined) + .forEach(item => { + newGraph.foldNode(item.id); + }); + return newGraph; + }, [schema.graph, data]); + useLayoutEffect(() => { if (filtered.length === 0) { setRowSelection({}); @@ -46,17 +76,17 @@ function PickMultiConstituenta({ id, schema, prefixID, rows, selected, setSelect }, [filtered, setRowSelection, selected]); useLayoutEffect(() => { - if (!schema || schema.items.length === 0) { + if (data.length === 0) { setFiltered([]); } else if (filterText) { - setFiltered(schema.items.filter(cst => matchConstituenta(cst, filterText, CstMatchMode.ALL))); + setFiltered(data.filter(cst => matchConstituenta(cst, filterText, CstMatchMode.ALL))); } else { - setFiltered(schema.items); + setFiltered(data); } - }, [filterText, schema?.items, schema]); + }, [filterText, data]); function handleRowSelection(updater: React.SetStateAction) { - if (!schema) { + if (!data) { setSelected([]); } else { const newRowSelection = typeof updater === 'function' ? updater(rowSelection) : updater; @@ -91,7 +121,7 @@ function PickMultiConstituenta({ id, schema, prefixID, rows, selected, setSelect
- Выбраны {selected.length} из {schema?.items.length ?? 0} + Выбраны {selected.length} из {data.length}
- {schema ? ( - isBasicConcept(schema.cstByID.get(cstID)?.cst_type)} - isOwned={cstID => !schema.cstByID.get(cstID)?.is_inherited} - setSelected={setSelected} - emptySelection={selected.length === 0} - className='w-fit' - /> - ) : null} + isBasicConcept(schema.cstByID.get(cstID)?.cst_type)} + isOwned={cstID => !schema.cstByID.get(cstID)?.is_inherited} + setSelected={setSelected} + emptySelection={selected.length === 0} + className='w-fit' + />
(undefined); - const [selected, setSelected] = useState([]); + const [selected, setSelected] = useState([]); const [substitutions, setSubstitutions] = useState([]); const source = useRSFormDetails({ target: donorID ? String(donorID) : undefined }); diff --git a/rsconcept/frontend/src/dialogs/DlgInlineSynthesis/TabConstituents.tsx b/rsconcept/frontend/src/dialogs/DlgInlineSynthesis/TabConstituents.tsx index 57f57c28..97a14a1f 100644 --- a/rsconcept/frontend/src/dialogs/DlgInlineSynthesis/TabConstituents.tsx +++ b/rsconcept/frontend/src/dialogs/DlgInlineSynthesis/TabConstituents.tsx @@ -17,13 +17,16 @@ interface TabConstituentsProps { function TabConstituents({ schema, error, loading, selected, setSelected }: TabConstituentsProps) { return ( - + {schema ? ( + + ) : null} ); } diff --git a/rsconcept/frontend/src/dialogs/DlgRelocateConstituents.tsx b/rsconcept/frontend/src/dialogs/DlgRelocateConstituents.tsx new file mode 100644 index 00000000..126f0497 --- /dev/null +++ b/rsconcept/frontend/src/dialogs/DlgRelocateConstituents.tsx @@ -0,0 +1,96 @@ +'use client'; + +import clsx from 'clsx'; +import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; + +import PickMultiConstituenta from '@/components/select/PickMultiConstituenta'; +import SelectLibraryItem from '@/components/select/SelectLibraryItem'; +import Modal, { ModalProps } from '@/components/ui/Modal'; +import DataLoader from '@/components/wrap/DataLoader'; +import { useLibrary } from '@/context/LibraryContext'; +import useRSFormDetails from '@/hooks/useRSFormDetails'; +import { ILibraryItem, LibraryItemID } from '@/models/library'; +import { ICstRelocateData, IOperation, IOperationSchema } from '@/models/oss'; +import { getRelocateCandidates } from '@/models/ossAPI'; +import { ConstituentaID } from '@/models/rsform'; +import { prefixes } from '@/utils/constants'; + +interface DlgRelocateConstituentsProps extends Pick { + oss: IOperationSchema; + target: IOperation; + onSubmit: (data: ICstRelocateData) => void; +} + +function DlgRelocateConstituents({ oss, hideWindow, target, onSubmit }: DlgRelocateConstituentsProps) { + const library = useLibrary(); + const schemas = useMemo(() => { + const node = oss.graph.at(target.id)!; + const ids: LibraryItemID[] = [ + ...node.inputs.map(id => oss.operationByID.get(id)!.result).filter(id => id !== null), + ...node.outputs.map(id => oss.operationByID.get(id)!.result).filter(id => id !== null) + ]; + return ids.map(id => library.items.find(item => item.id === id)).filter(item => item !== undefined); + }, [oss, library.items]); + + const [destination, setDestination] = useState(undefined); + const [selected, setSelected] = useState([]); + + const source = useRSFormDetails({ target: String(target.result!) }); + const filtered = useMemo(() => { + if (!source.schema || !destination) { + return []; + } + const destinationOperation = oss.items.find(item => item.result === destination.id); + return getRelocateCandidates(target.id, destinationOperation!.id, source.schema, oss); + }, [destination, source.schema?.items]); + + const isValid = useMemo(() => !!destination && selected.length > 0, [destination, selected]); + + useLayoutEffect(() => { + setSelected([]); + }, [destination]); + + const handleSelectDestination = useCallback((newValue: ILibraryItem | undefined) => { + setDestination(newValue); + }, []); + + const handleSubmit = useCallback(() => { + const data: ICstRelocateData = { + destination: target.result ?? 0, + items: [] + }; + onSubmit(data); + }, [target, onSubmit]); + + return ( + + + + {source.schema ? ( + + ) : null} + + + ); +} + +export default DlgRelocateConstituents; diff --git a/rsconcept/frontend/src/models/Graph.ts b/rsconcept/frontend/src/models/Graph.ts index 68383181..725e0d8f 100644 --- a/rsconcept/frontend/src/models/Graph.ts +++ b/rsconcept/frontend/src/models/Graph.ts @@ -86,30 +86,25 @@ export class Graph { return !!this.nodes.get(target); } - removeNode(target: number): GraphNode | null { - const nodeToRemove = this.nodes.get(target); - if (!nodeToRemove) { - return null; - } + removeNode(target: number): void { this.nodes.forEach(node => { - node.removeInput(nodeToRemove.id); - node.removeOutput(nodeToRemove.id); + node.removeInput(target); + node.removeOutput(target); }); this.nodes.delete(target); - return nodeToRemove; } - foldNode(target: number): GraphNode | null { + foldNode(target: number): void { const nodeToRemove = this.nodes.get(target); if (!nodeToRemove) { - return null; + return; } nodeToRemove.inputs.forEach(input => { nodeToRemove.outputs.forEach(output => { this.addEdge(input, output); }); }); - return this.removeNode(target); + this.removeNode(target); } removeIsolated(): GraphNode[] { @@ -124,6 +119,9 @@ export class Graph { } addEdge(source: number, destination: number): void { + if (this.hasEdge(source, destination)) { + return; + } const sourceNode = this.addNode(source); const destinationNode = this.addNode(destination); sourceNode.addOutput(destinationNode.id); diff --git a/rsconcept/frontend/src/models/oss.ts b/rsconcept/frontend/src/models/oss.ts index a768a547..761cd9c6 100644 --- a/rsconcept/frontend/src/models/oss.ts +++ b/rsconcept/frontend/src/models/oss.ts @@ -125,6 +125,14 @@ export interface ICstSubstituteData { substitutions: ICstSubstitute[]; } +/** + * Represents data, used relocating {@link IConstituenta}s between {@link ILibraryItem}s. + */ +export interface ICstRelocateData { + destination: LibraryItemID; + items: ConstituentaID[]; +} + /** * Represents substitution for multi synthesis table. */ diff --git a/rsconcept/frontend/src/models/ossAPI.ts b/rsconcept/frontend/src/models/ossAPI.ts index b3679fb8..d2b4b826 100644 --- a/rsconcept/frontend/src/models/ossAPI.ts +++ b/rsconcept/frontend/src/models/ossAPI.ts @@ -8,7 +8,7 @@ import { TextMatcher } from '@/utils/utils'; import { Graph } from './Graph'; import { ILibraryItem, LibraryItemID } from './library'; -import { ICstSubstitute, IOperation, IOperationSchema, SubstitutionErrorType } from './oss'; +import { ICstSubstitute, IOperation, IOperationSchema, OperationID, SubstitutionErrorType } from './oss'; import { ConstituentaID, CstClass, CstType, IConstituenta, IRSForm } from './rsform'; import { AliasMapping, ParsingStatus } from './rslang'; import { applyAliasMapping, applyTypificationMapping, extractGlobals, isSetTypification } from './rslangAPI'; @@ -424,3 +424,45 @@ export class SubstitutionValidator { return false; } } + +/** + * Filter relocate candidates from gives schema. + */ +export function getRelocateCandidates( + source: OperationID, + destination: OperationID, + schema: IRSForm, + oss: IOperationSchema +): IConstituenta[] { + const destinationSchema = oss.operationByID.get(destination)?.result; + if (!destinationSchema) { + return []; + } + const node = oss.graph.at(source); + if (!node) { + return []; + } + + const addedCst = schema.items.filter(item => !item.is_inherited); + if (node.outputs.includes(destination)) { + return addedCst; + } + + const unreachableBases: ConstituentaID[] = []; + for (const cst of schema.items.filter(item => item.is_inherited)) { + if (cst.parent_schema == destinationSchema) { + continue; + } + const parent = schema.inheritance.find(item => item.child === cst.id && item.child_source === cst.schema)?.parent; + if (parent) { + const original = oss.substitutions.find(sub => sub.substitution === parent)?.original; + if (original) { + continue; + // TODO: test if original schema is destination schema + } + } + unreachableBases.push(cst.id); + } + const unreachable = schema.graph.expandAllOutputs(unreachableBases); + return addedCst.filter(cst => !unreachable.includes(cst.id)); +} diff --git a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/NodeContextMenu.tsx b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/NodeContextMenu.tsx index 2e6fd822..054f2aed 100644 --- a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/NodeContextMenu.tsx +++ b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/NodeContextMenu.tsx @@ -2,7 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { IconConnect, IconDestroy, IconEdit2, IconExecute, IconNewRSForm, IconRSForm } from '@/components/Icons'; +import { + IconChild, + IconConnect, + IconDestroy, + IconEdit2, + IconExecute, + IconNewRSForm, + IconRSForm +} from '@/components/Icons'; import Dropdown from '@/components/ui/Dropdown'; import DropdownButton from '@/components/ui/DropdownButton'; import useClickedOutside from '@/hooks/useClickedOutside'; @@ -25,6 +33,7 @@ interface NodeContextMenuProps extends ContextMenuData { onEditSchema: (target: OperationID) => void; onEditOperation: (target: OperationID) => void; onExecuteOperation: (target: OperationID) => void; + onRelocateConstituents: (target: OperationID) => void; } function NodeContextMenu({ @@ -36,7 +45,8 @@ function NodeContextMenu({ onCreateInput, onEditSchema, onEditOperation, - onExecuteOperation + onExecuteOperation, + onRelocateConstituents }: NodeContextMenuProps) { const controller = useOssEdit(); const [isOpen, setIsOpen] = useState(false); @@ -100,6 +110,11 @@ function NodeContextMenu({ onExecuteOperation(operation.id); }; + const handleRelocateConstituents = () => { + handleHide(); + onRelocateConstituents(operation.id); + }; + return (
) : null} + {controller.isMutable && operation.result ? ( + } + disabled={controller.isProcessing} + onClick={handleRelocateConstituents} + /> + ) : null} + } diff --git a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/OssFlow.tsx b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/OssFlow.tsx index 37593193..ba12930c 100644 --- a/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/OssFlow.tsx +++ b/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/OssFlow.tsx @@ -197,6 +197,13 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) { handleExecuteOperation(controller.selected[0]); }, [controller, handleExecuteOperation]); + const handleRelocateConstituents = useCallback( + (target: OperationID) => { + controller.promptRelocateConstituents(target); + }, + [controller] + ); + const handleFitView = useCallback(() => { flow.fitView({ duration: PARAMETER.zoomDuration }); }, [flow]); @@ -376,6 +383,7 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) { onEditSchema={handleEditSchema} onEditOperation={handleEditOperation} onExecuteOperation={handleExecuteOperation} + onRelocateConstituents={handleRelocateConstituents} {...menuProps} /> ) : null} diff --git a/rsconcept/frontend/src/pages/OssPage/OssEditContext.tsx b/rsconcept/frontend/src/pages/OssPage/OssEditContext.tsx index cdc44105..01a4a6b7 100644 --- a/rsconcept/frontend/src/pages/OssPage/OssEditContext.tsx +++ b/rsconcept/frontend/src/pages/OssPage/OssEditContext.tsx @@ -17,10 +17,12 @@ import DlgCreateOperation from '@/dialogs/DlgCreateOperation'; import DlgDeleteOperation from '@/dialogs/DlgDeleteOperation'; import DlgEditEditors from '@/dialogs/DlgEditEditors'; import DlgEditOperation from '@/dialogs/DlgEditOperation'; +import DlgRelocateConstituents from '@/dialogs/DlgRelocateConstituents'; import { AccessPolicy, ILibraryItemEditor, LibraryItemID } from '@/models/library'; import { Position2D } from '@/models/miscellaneous'; import { calculateInsertPosition } from '@/models/miscellaneousAPI'; import { + ICstRelocateData, IOperationCreateData, IOperationDeleteData, IOperationPosition, @@ -74,6 +76,7 @@ export interface IOssEditContext extends ILibraryItemEditor { promptEditInput: (target: OperationID, positions: IOperationPosition[]) => void; promptEditOperation: (target: OperationID, positions: IOperationPosition[]) => void; executeOperation: (target: OperationID, positions: IOperationPosition[]) => void; + promptRelocateConstituents: (target: OperationID) => void; } const OssEditContext = createContext(null); @@ -110,6 +113,7 @@ export const OssEditState = ({ selected, setSelected, children }: React.PropsWit const [showEditInput, setShowEditInput] = useState(false); const [showEditOperation, setShowEditOperation] = useState(false); const [showDeleteOperation, setShowDeleteOperation] = useState(false); + const [showRelocateConstituents, setShowRelocateConstituents] = useState(false); const [showCreateOperation, setShowCreateOperation] = useState(false); const [insertPosition, setInsertPosition] = useState({ x: 0, y: 0 }); @@ -359,6 +363,23 @@ export const OssEditState = ({ selected, setSelected, children }: React.PropsWit [model] ); + const promptRelocateConstituents = useCallback( + (target: OperationID) => { + setTargetOperationID(target); + setShowRelocateConstituents(true); + }, + [model] + ); + + const handleRelocateConstituents = useCallback( + (data: ICstRelocateData) => { + // TODO: implement backed call + console.log(data); + toast.success('В разработке'); + }, + [model] + ); + return ( {model.schema ? ( @@ -438,6 +460,15 @@ export const OssEditState = ({ selected, setSelected, children }: React.PropsWit onSubmit={deleteOperation} /> ) : null} + {showRelocateConstituents ? ( + setShowRelocateConstituents(false)} + target={targetOperation!} + oss={model.schema} + onSubmit={handleRelocateConstituents} + /> + ) : null} + ) ) : null} diff --git a/rsconcept/frontend/src/pages/RSFormPage/ViewConstituents/ConstituentsSearch.tsx b/rsconcept/frontend/src/pages/RSFormPage/ViewConstituents/ConstituentsSearch.tsx index 2dd9c5b2..ed959584 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/ViewConstituents/ConstituentsSearch.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/ViewConstituents/ConstituentsSearch.tsx @@ -69,7 +69,7 @@ function ConstituentsSearch({ schema, activeID, activeExpression, dense, setFilt ); return ( -
+