F: Implementing block UI pt1

This commit is contained in:
Ivan 2025-04-21 20:35:40 +03:00
parent 2ae9576384
commit 13914a04f9
33 changed files with 803 additions and 143 deletions

View File

@ -198,7 +198,9 @@ class UpdateOperationSerializer(serializers.Serializer):
'target': msg.operationNotInOSS() 'target': msg.operationNotInOSS()
}) })
if 'parent' in attrs['item_data'] and attrs['item_data']['parent'].oss_id != oss.pk: if 'parent' in attrs['item_data'] and \
attrs['item_data']['parent'] is not None and \
attrs['item_data']['parent'].oss_id != oss.pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'parent': msg.parentNotInOSS() 'parent': msg.parentNotInOSS()
}) })

View File

@ -9,7 +9,7 @@ import { useDialogsStore } from '@/stores/dialogs';
import { NavigationState } from './navigation/navigation-context'; import { NavigationState } from './navigation/navigation-context';
import { Footer } from './footer'; import { Footer } from './footer';
import { GlobalDialogs } from './global-dialogs'; import { GlobalDialogs } from './global-dialogs';
import { GlobalLoader } from './global-Loader'; import { GlobalLoader } from './global-loader1';
import { ToasterThemed } from './global-toaster'; import { ToasterThemed } from './global-toaster';
import { GlobalTooltips } from './global-tooltips'; import { GlobalTooltips } from './global-tooltips';
import { MutationErrors } from './mutation-errors'; import { MutationErrors } from './mutation-errors';

View File

@ -113,6 +113,11 @@ const DlgUploadRSForm = React.lazy(() =>
const DlgGraphParams = React.lazy(() => const DlgGraphParams = React.lazy(() =>
import('@/features/rsform/dialogs/dlg-graph-params').then(module => ({ default: module.DlgGraphParams })) import('@/features/rsform/dialogs/dlg-graph-params').then(module => ({ default: module.DlgGraphParams }))
); );
const DlgCreateBlock = React.lazy(() =>
import('@/features/oss/dialogs/dlg-create-block').then(module => ({
default: module.DlgCreateBlock
}))
);
export const GlobalDialogs = () => { export const GlobalDialogs = () => {
const active = useDialogsStore(state => state.active); const active = useDialogsStore(state => state.active);
@ -127,6 +132,8 @@ export const GlobalDialogs = () => {
return <DlgCreateCst />; return <DlgCreateCst />;
case DialogType.CREATE_OPERATION: case DialogType.CREATE_OPERATION:
return <DlgCreateOperation />; return <DlgCreateOperation />;
case DialogType.CREATE_BLOCK:
return <DlgCreateBlock />;
case DialogType.DELETE_CONSTITUENTA: case DialogType.DELETE_CONSTITUENTA:
return <DlgDeleteCst />; return <DlgDeleteCst />;
case DialogType.EDIT_EDITORS: case DialogType.EDIT_EDITORS:

View File

@ -1,3 +1,5 @@
import clsx from 'clsx';
import { useMutationErrors } from '@/backend/use-mutation-errors'; import { useMutationErrors } from '@/backend/use-mutation-errors';
import { Button } from '@/components/control'; import { Button } from '@/components/control';
import { DescribeError } from '@/components/info-error'; import { DescribeError } from '@/components/info-error';
@ -20,9 +22,23 @@ export function MutationErrors() {
return ( return (
<div className='cc-modal-wrapper'> <div className='cc-modal-wrapper'>
<ModalBackdrop onHide={resetErrors} /> <ModalBackdrop onHide={resetErrors} />
<div className='z-pop px-10 py-3 flex flex-col items-center border rounded-xl bg-background' role='alertdialog'> <div
className={clsx(
'z-pop', //
'flex flex-col px-10 py-3 items-center',
'border rounded-xl bg-background'
)}
role='alertdialog'
>
<h1 className='py-2 select-none'>Ошибка при обработке</h1> <h1 className='py-2 select-none'>Ошибка при обработке</h1>
<div className='px-3 flex flex-col text-destructive text-sm font-semibold select-text'> <div
className={clsx(
'max-h-[calc(100svh-8rem)] max-w-[calc(100svw-2rem)]',
'px-3 flex flex-col',
'text-destructive text-sm font-semibold select-text',
'overflow-auto'
)}
>
<DescribeError error={mutationErrors[0]} /> <DescribeError error={mutationErrors[0]} />
</div> </div>
<Button onClick={resetErrors} className='w-fit' text='Закрыть' /> <Button onClick={resetErrors} className='w-fit' text='Закрыть' />

View File

@ -70,6 +70,7 @@ export { LuGlasses as IconReader } from 'react-icons/lu';
export { TbBriefcase as IconBusiness } from 'react-icons/tb'; export { TbBriefcase as IconBusiness } from 'react-icons/tb';
export { VscLibrary as IconLibrary } from 'react-icons/vsc'; export { VscLibrary as IconLibrary } from 'react-icons/vsc';
export { BiBot as IconRobot } from 'react-icons/bi'; export { BiBot as IconRobot } from 'react-icons/bi';
export { TbGps as IconCoordinates } from 'react-icons/tb';
export { IoLibrary as IconLibrary2 } from 'react-icons/io5'; export { IoLibrary as IconLibrary2 } from 'react-icons/io5';
export { BiDiamond as IconTemplates } from 'react-icons/bi'; export { BiDiamond as IconTemplates } from 'react-icons/bi';
export { TbHexagons as IconOSS } from 'react-icons/tb'; export { TbHexagons as IconOSS } from 'react-icons/tb';

View File

@ -20,6 +20,7 @@ export function TabLabel({
titleHtml, titleHtml,
hideTitle, hideTitle,
className, className,
disabled,
role = 'tab', role = 'tab',
...otherProps ...otherProps
}: TabLabelProps) { }: TabLabelProps) {
@ -28,10 +29,12 @@ export function TabLabel({
className={clsx( className={clsx(
'min-w-20 h-full', 'min-w-20 h-full',
'px-2 py-1 flex justify-center', 'px-2 py-1 flex justify-center',
'cc-hover cc-animate-color duration-select', 'cc-animate-color duration-select',
'text-sm whitespace-nowrap font-controls', 'text-sm whitespace-nowrap font-controls',
'select-none hover:cursor-pointer', 'select-none',
'outline-hidden', 'outline-hidden',
!disabled && 'hover:cursor-pointer cc-hover',
disabled && 'text-muted-foreground',
className className
)} )}
tabIndex='-1' tabIndex='-1'
@ -40,6 +43,7 @@ export function TabLabel({
data-tooltip-content={title} data-tooltip-content={title}
data-tooltip-hidden={hideTitle} data-tooltip-hidden={hideTitle}
role={role} role={role}
disabled={disabled}
{...otherProps} {...otherProps}
> >
{label} {label}

View File

@ -20,6 +20,7 @@ import {
type IUpdateBlockDTO, type IUpdateBlockDTO,
type IUpdateInputDTO, type IUpdateInputDTO,
type IUpdateOperationDTO, type IUpdateOperationDTO,
schemaBlockCreatedResponse,
schemaConstituentaReference, schemaConstituentaReference,
schemaInputCreatedResponse, schemaInputCreatedResponse,
schemaOperationCreatedResponse, schemaOperationCreatedResponse,
@ -55,7 +56,7 @@ export const ossApi = {
createBlock: ({ itemID, data }: { itemID: number; data: ICreateBlockDTO }) => createBlock: ({ itemID, data }: { itemID: number; data: ICreateBlockDTO }) =>
axiosPost<ICreateBlockDTO, IBlockCreatedResponse>({ axiosPost<ICreateBlockDTO, IBlockCreatedResponse>({
schema: schemaOperationCreatedResponse, schema: schemaBlockCreatedResponse,
endpoint: `/api/oss/${itemID}/create-block`, endpoint: `/api/oss/${itemID}/create-block`,
request: { request: {
data: data, data: data,
@ -74,7 +75,7 @@ export const ossApi = {
deleteBlock: ({ itemID, data }: { itemID: number; data: IDeleteBlockDTO }) => deleteBlock: ({ itemID, data }: { itemID: number; data: IDeleteBlockDTO }) =>
axiosPatch<IDeleteBlockDTO, IOperationSchemaDTO>({ axiosPatch<IDeleteBlockDTO, IOperationSchemaDTO>({
schema: schemaOperationSchema, schema: schemaOperationSchema,
endpoint: `/api/oss/${itemID}/delete-operation`, endpoint: `/api/oss/${itemID}/delete-block`,
request: { request: {
data: data, data: data,
successMessage: infoMsg.operationDestroyed successMessage: infoMsg.operationDestroyed

View File

@ -7,12 +7,10 @@ import { type ILibraryItem } from '@/features/library';
import { Graph } from '@/models/graph'; import { Graph } from '@/models/graph';
import { type IBlock, type IOperation, type IOperationSchema, type IOperationSchemaStats } from '../models/oss'; import { type IBlock, type IOperation, type IOperationSchema, type IOperationSchemaStats } from '../models/oss';
import { BLOCK_NODE_MIN_HEIGHT, BLOCK_NODE_MIN_WIDTH } from '../pages/oss-page/editor-oss-graph/graph/block-node';
import { type IOperationSchemaDTO, OperationType } from './types'; import { type IOperationSchemaDTO, OperationType } from './types';
export const DEFAULT_BLOCK_WIDTH = 100;
export const DEFAULT_BLOCK_HEIGHT = 100;
/** Loads data into an {@link IOperationSchema} based on {@link IOperationSchemaDTO}. */ /** Loads data into an {@link IOperationSchema} based on {@link IOperationSchemaDTO}. */
export class OssLoader { export class OssLoader {
private oss: IOperationSchema; private oss: IOperationSchema;
@ -58,7 +56,7 @@ export class OssLoader {
this.blockByID.set(block.id, block); this.blockByID.set(block.id, block);
this.hierarchy.addNode(-block.id); this.hierarchy.addNode(-block.id);
if (block.parent) { if (block.parent) {
this.graph.addEdge(-block.parent, -block.id); this.hierarchy.addEdge(-block.parent, -block.id);
} }
}); });
} }
@ -92,8 +90,8 @@ export class OssLoader {
const geometry = this.oss.layout.blocks.find(item => item.id === block.id); const geometry = this.oss.layout.blocks.find(item => item.id === block.id);
block.x = geometry?.x ?? 0; block.x = geometry?.x ?? 0;
block.y = geometry?.y ?? 0; block.y = geometry?.y ?? 0;
block.width = geometry?.width ?? DEFAULT_BLOCK_WIDTH; block.width = geometry?.width ?? BLOCK_NODE_MIN_WIDTH;
block.height = geometry?.height ?? DEFAULT_BLOCK_HEIGHT; block.height = geometry?.height ?? BLOCK_NODE_MIN_HEIGHT;
}); });
} }

View File

@ -0,0 +1,156 @@
'use client';
import { useState } from 'react';
import { MiniButton } from '@/components/control';
import { createColumnHelper, DataTable } from '@/components/data-table';
import { IconMoveDown, IconMoveUp, IconRemove } from '@/components/icons';
import { ComboBox } from '@/components/input/combo-box';
import { type Styling } from '@/components/props';
import { cn } from '@/components/utils';
import { NoData } from '@/components/view';
import { labelOssItem } from '../labels';
import { type IBlock, type IOperation, type IOperationSchema } from '../models/oss';
import { isOperation } from '../models/oss-api';
const SELECTION_CLEAR_TIMEOUT = 1000;
interface PickMultiOperationProps extends Styling {
value: number[];
onChange: (newValue: number[]) => void;
schema: IOperationSchema;
rows?: number;
disallowBlocks?: boolean;
}
const columnHelper = createColumnHelper<IOperation | IBlock>();
export function PickContents({
rows,
schema,
value,
disallowBlocks,
onChange,
className,
...restProps
}: PickMultiOperationProps) {
const selectedItems = value
.map(itemID => (itemID > 0 ? schema.operationByID.get(itemID) : schema.blockByID.get(-itemID)))
.filter(item => item !== undefined);
const [lastSelected, setLastSelected] = useState<IOperation | IBlock | null>(null);
const items = [
...schema.operations.filter(item => !value.includes(item.id)),
...(disallowBlocks ? [] : schema.blocks.filter(item => !value.includes(-item.id)))
];
function handleDelete(operation: number) {
onChange(value.filter(item => item !== operation));
}
function handleSelect(target: IOperation | IBlock | null) {
if (target) {
setLastSelected(target);
onChange([...value, isOperation(target) ? target.id : -target.id]);
setTimeout(() => setLastSelected(null), SELECTION_CLEAR_TIMEOUT);
}
}
function handleMoveUp(operation: number) {
const index = value.indexOf(operation);
if (index > 0) {
const newSelected = [...value];
newSelected[index] = newSelected[index - 1];
newSelected[index - 1] = operation;
onChange(newSelected);
}
}
function handleMoveDown(operation: number) {
const index = value.indexOf(operation);
if (index < value.length - 1) {
const newSelected = [...value];
newSelected[index] = newSelected[index + 1];
newSelected[index + 1] = operation;
onChange(newSelected);
}
}
const columns = [
columnHelper.accessor(item => isOperation(item), {
id: 'type',
header: 'Тип',
size: 150,
minSize: 150,
maxSize: 150,
cell: props => <div>{isOperation(props.row.original) ? 'Операция' : 'Блок'}</div>
}),
columnHelper.accessor('title', {
id: 'title',
header: 'Название',
size: 1200,
minSize: 300,
maxSize: 1200,
cell: props => <div className='text-ellipsis'>{props.getValue()}</div>
}),
columnHelper.display({
id: 'actions',
size: 0,
cell: props => (
<div className='flex w-fit'>
<MiniButton
title='Удалить'
noHover
className='px-0'
icon={<IconRemove size='1rem' className='icon-red' />}
onClick={() => handleDelete(props.row.original.id)}
/>
<MiniButton
title='Переместить выше'
noHover
className='px-0'
icon={<IconMoveUp size='1rem' className='icon-primary' />}
onClick={() => handleMoveUp(props.row.original.id)}
/>
<MiniButton
title='Переместить ниже'
noHover
className='px-0'
icon={<IconMoveDown size='1rem' className='icon-primary' />}
onClick={() => handleMoveDown(props.row.original.id)}
/>
</div>
)
})
];
return (
<div className={cn('flex flex-col gap-1 border-t border-x rounded-md bg-input', className)} {...restProps}>
<ComboBox
noBorder
items={items}
value={lastSelected}
placeholder='Выберите операцию или блок'
idFunc={item => String(item.id)}
labelValueFunc={item => labelOssItem(item)}
labelOptionFunc={item => labelOssItem(item)}
onChange={handleSelect}
/>
<DataTable
dense
noFooter
rows={rows}
contentHeight='1.3rem'
className='cc-scroll-y text-sm select-none border-y rounded-b-md'
data={selectedItems}
columns={columns}
headPosition='0rem'
noDataComponent={
<NoData>
<p>Список пуст</p>
</NoData>
}
/>
</div>
);
}

View File

@ -0,0 +1,28 @@
import { ComboBox } from '@/components/input/combo-box';
import { type Styling } from '@/components/props';
import { type IBlock } from '../models/oss';
interface SelectBlockProps extends Styling {
id?: string;
value: IBlock | null;
onChange: (newValue: IBlock | null) => void;
items?: IBlock[];
placeholder?: string;
noBorder?: boolean;
popoverClassname?: string;
}
export function SelectBlock({ items, placeholder = 'Выберите блок', ...restProps }: SelectBlockProps) {
return (
<ComboBox
items={items}
placeholder={placeholder}
idFunc={block => String(block.id)}
labelValueFunc={block => block.title}
labelOptionFunc={block => block.title}
{...restProps}
/>
);
}

View File

@ -0,0 +1,107 @@
'use client';
import { useState } from 'react';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { HelpTopic } from '@/features/help';
import { ModalForm } from '@/components/modal';
import { TabLabel, TabList, TabPanel, Tabs } from '@/components/tabs';
import { useDialogsStore } from '@/stores/dialogs';
import { type ICreateBlockDTO, type IOssLayout, schemaCreateBlock } from '../../backend/types';
import { useCreateBlock } from '../../backend/use-create-block';
import { type IOperationSchema } from '../../models/oss';
import { calculateNewBlockPosition } from '../../models/oss-api';
import { BLOCK_NODE_MIN_HEIGHT, BLOCK_NODE_MIN_WIDTH } from '../../pages/oss-page/editor-oss-graph/graph/block-node';
import { TabBlockCard } from './tab-block-card';
import { TabBlockChildren } from './tab-block-children';
export interface DlgCreateBlockProps {
oss: IOperationSchema;
layout: IOssLayout;
initialInputs: number[];
defaultX: number;
defaultY: number;
onCreate?: (newID: number) => void;
}
export const TabID = {
CARD: 0,
CHILDREN: 1
} as const;
export type TabID = (typeof TabID)[keyof typeof TabID];
export function DlgCreateBlock() {
const { createBlock } = useCreateBlock();
const { oss, layout, initialInputs, onCreate, defaultX, defaultY } = useDialogsStore(
state => state.props as DlgCreateBlockProps
);
const methods = useForm<ICreateBlockDTO>({
resolver: zodResolver(schemaCreateBlock),
defaultValues: {
item_data: {
title: '',
description: '',
parent: null
},
position_x: defaultX,
position_y: defaultY,
width: BLOCK_NODE_MIN_WIDTH,
height: BLOCK_NODE_MIN_HEIGHT,
children_blocks: initialInputs.filter(id => id < 0).map(id => -id),
children_operations: initialInputs.filter(id => id > 0),
layout: layout
},
mode: 'onChange'
});
const title = useWatch({ control: methods.control, name: 'item_data.title' });
const [activeTab, setActiveTab] = useState<TabID>(TabID.CARD);
const isValid = !!title && !oss.blocks.some(block => block.title === title);
function onSubmit(data: ICreateBlockDTO) {
const rectangle = calculateNewBlockPosition(data, layout);
data.position_x = rectangle.x;
data.position_y = rectangle.y;
data.width = rectangle.width;
data.height = rectangle.height;
void createBlock({ itemID: oss.id, data: data }).then(response => onCreate?.(response.new_block.id));
}
return (
<ModalForm
header='Создание операции'
submitText='Создать'
canSubmit={isValid}
onSubmit={event => void methods.handleSubmit(onSubmit)(event)}
className='w-160 px-6 h-128'
helpTopic={HelpTopic.CC_OSS}
>
<Tabs
selectedTabClassName='cc-selected'
className='grid'
selectedIndex={activeTab}
onSelect={index => setActiveTab(index as TabID)}
>
<TabList className='z-pop mx-auto -mb-5 flex border divide-x rounded-none bg-secondary'>
<TabLabel title='Основные атрибуты блока' label='Карточка' />
<TabLabel title='Выбор вложенных узлов' label='Содержимое' />
</TabList>
<FormProvider {...methods}>
<TabPanel>
<TabBlockCard />
</TabPanel>
<TabPanel>
<TabBlockChildren />
</TabPanel>
</FormProvider>
</Tabs>
</ModalForm>
);
}

View File

@ -0,0 +1 @@
export { DlgCreateBlock } from './dlg-create-block';

View File

@ -0,0 +1,52 @@
'use client';
import { Controller, useFormContext } from 'react-hook-form';
import { TextArea, TextInput } from '@/components/input';
import { useDialogsStore } from '@/stores/dialogs';
import { type ICreateBlockDTO } from '../../backend/types';
import { SelectBlock } from '../../components/select-block';
import { type DlgCreateBlockProps } from './dlg-create-block';
export function TabBlockCard() {
const { oss } = useDialogsStore(state => state.props as DlgCreateBlockProps);
const {
register,
control,
formState: { errors }
} = useFormContext<ICreateBlockDTO>();
return (
<div className='cc-fade-in cc-column'>
<TextInput
id='operation_title' //
label='Название'
{...register('item_data.title')}
error={errors.item_data?.title}
/>
<Controller
name='item_data.parent'
control={control}
render={({ field }) => (
<SelectBlock
items={oss.blocks}
className='w-80'
value={field.value ? oss.blockByID.get(field.value) ?? null : null}
placeholder='Блок содержания не выбран'
onChange={value => field.onChange(value ? value.id : null)}
/>
)}
/>
<TextArea
id='operation_comment' //
label='Описание'
noResize
rows={3}
{...register('item_data.description')}
/>
</div>
);
}

View File

@ -0,0 +1,39 @@
'use client';
import { useFormContext, useWatch } from 'react-hook-form';
import { Label } from '@/components/input';
import { useDialogsStore } from '@/stores/dialogs';
import { type ICreateBlockDTO } from '../../backend/types';
import { PickContents } from '../../components/pick-contents';
import { type DlgCreateBlockProps } from './dlg-create-block';
export function TabBlockChildren() {
const { setValue, control } = useFormContext<ICreateBlockDTO>();
const { oss } = useDialogsStore(state => state.props as DlgCreateBlockProps);
const children_blocks = useWatch({ control, name: 'children_blocks' });
const children_operations = useWatch({ control, name: 'children_operations' });
const value = [...children_blocks.map(id => -id), ...children_operations];
function handleChangeSelected(newValue: number[]) {
setValue(
'children_blocks',
newValue.filter(id => id < 0).map(id => -id),
{ shouldValidate: true }
);
setValue(
'children_operations',
newValue.filter(id => id > 0),
{ shouldValidate: true }
);
}
return (
<div className='cc-fade-in cc-column'>
<Label text={`Выбор содержания: [ ${value.length} ]`} />
<PickContents schema={oss} value={value} onChange={newValue => handleChangeSelected(newValue)} rows={8} />
</div>
);
}

View File

@ -14,7 +14,7 @@ import { type ICreateOperationDTO, type IOssLayout, OperationType, schemaCreateO
import { useCreateOperation } from '../../backend/use-create-operation'; import { useCreateOperation } from '../../backend/use-create-operation';
import { describeOperationType, labelOperationType } from '../../labels'; import { describeOperationType, labelOperationType } from '../../labels';
import { type IOperationSchema } from '../../models/oss'; import { type IOperationSchema } from '../../models/oss';
import { calculateInsertPosition } from '../../models/oss-api'; import { calculateNewOperationPosition } from '../../models/oss-api';
import { TabInputOperation } from './tab-input-operation'; import { TabInputOperation } from './tab-input-operation';
import { TabSynthesisOperation } from './tab-synthesis-operation'; import { TabSynthesisOperation } from './tab-synthesis-operation';
@ -22,6 +22,7 @@ import { TabSynthesisOperation } from './tab-synthesis-operation';
export interface DlgCreateOperationProps { export interface DlgCreateOperationProps {
oss: IOperationSchema; oss: IOperationSchema;
layout: IOssLayout; layout: IOssLayout;
initialParent: number | null;
initialInputs: number[]; initialInputs: number[];
defaultX: number; defaultX: number;
defaultY: number; defaultY: number;
@ -35,9 +36,9 @@ export const TabID = {
export type TabID = (typeof TabID)[keyof typeof TabID]; export type TabID = (typeof TabID)[keyof typeof TabID];
export function DlgCreateOperation() { export function DlgCreateOperation() {
const { createOperation: operationCreate } = useCreateOperation(); const { createOperation } = useCreateOperation();
const { oss, layout, initialInputs, onCreate, defaultX, defaultY } = useDialogsStore( const { oss, layout, initialInputs, initialParent, onCreate, defaultX, defaultY } = useDialogsStore(
state => state.props as DlgCreateOperationProps state => state.props as DlgCreateOperationProps
); );
@ -50,7 +51,7 @@ export function DlgCreateOperation() {
title: '', title: '',
description: '', description: '',
result: null, result: null,
parent: null parent: initialParent
}, },
position_x: defaultX, position_x: defaultX,
position_y: defaultY, position_y: defaultY,
@ -65,13 +66,10 @@ export function DlgCreateOperation() {
const isValid = !!alias && !oss.operations.some(operation => operation.alias === alias); const isValid = !!alias && !oss.operations.some(operation => operation.alias === alias);
function onSubmit(data: ICreateOperationDTO) { function onSubmit(data: ICreateOperationDTO) {
const target = calculateInsertPosition(oss, data.arguments, layout, { const target = calculateNewOperationPosition(oss, data, layout);
x: defaultX,
y: defaultY
});
data.position_x = target.x; data.position_x = target.x;
data.position_y = target.y; data.position_y = target.y;
void operationCreate({ itemID: oss.id, data: data }).then(response => onCreate?.(response.new_operation.id)); void createOperation({ itemID: oss.id, data: data }).then(response => onCreate?.(response.new_operation.id));
} }
function handleSelectTab(newTab: TabID, last: TabID) { function handleSelectTab(newTab: TabID, last: TabID) {

View File

@ -12,6 +12,7 @@ import { Checkbox, Label, TextArea, TextInput } from '@/components/input';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { type ICreateOperationDTO } from '../../backend/types'; import { type ICreateOperationDTO } from '../../backend/types';
import { SelectBlock } from '../../components/select-block';
import { sortItemsForOSS } from '../../models/oss-api'; import { sortItemsForOSS } from '../../models/oss-api';
import { type DlgCreateOperationProps } from './dlg-create-operation'; import { type DlgCreateOperationProps } from './dlg-create-operation';
@ -61,6 +62,7 @@ export function TabInputOperation() {
error={errors.item_data?.title} error={errors.item_data?.title}
/> />
<div className='flex gap-6'> <div className='flex gap-6'>
<div className='grid gap-1'>
<TextInput <TextInput
id='operation_alias' // id='operation_alias' //
label='Сокращение' label='Сокращение'
@ -68,7 +70,19 @@ export function TabInputOperation() {
{...register('item_data.alias')} {...register('item_data.alias')}
error={errors.item_data?.alias} error={errors.item_data?.alias}
/> />
<Controller
name='item_data.parent'
control={control}
render={({ field }) => (
<SelectBlock
items={oss.blocks}
value={field.value ? oss.blockByID.get(field.value) ?? null : null}
placeholder='Блок содержания'
onChange={value => field.onChange(value ? value.id : null)}
/>
)}
/>
</div>
<TextArea <TextArea
id='operation_comment' // id='operation_comment' //
label='Описание' label='Описание'

View File

@ -5,6 +5,7 @@ import { useDialogsStore } from '@/stores/dialogs';
import { type ICreateOperationDTO } from '../../backend/types'; import { type ICreateOperationDTO } from '../../backend/types';
import { PickMultiOperation } from '../../components/pick-multi-operation'; import { PickMultiOperation } from '../../components/pick-multi-operation';
import { SelectBlock } from '../../components/select-block';
import { type DlgCreateOperationProps } from './dlg-create-operation'; import { type DlgCreateOperationProps } from './dlg-create-operation';
@ -26,14 +27,27 @@ export function TabSynthesisOperation() {
error={errors.item_data?.title} error={errors.item_data?.title}
/> />
<div className='flex gap-6'> <div className='flex gap-6'>
<div className='grid gap-1'>
<TextInput <TextInput
id='operation_alias' id='operation_alias' //
label='Сокращение' label='Сокращение'
className='w-64' className='w-64'
{...register('item_data.alias')} {...register('item_data.alias')}
error={errors.item_data?.alias} error={errors.item_data?.alias}
/> />
<Controller
name='item_data.parent'
control={control}
render={({ field }) => (
<SelectBlock
items={oss.blocks}
value={field.value ? oss.blockByID.get(field.value) ?? null : null}
placeholder='Блок содержания'
onChange={value => field.onChange(value ? value.id : null)}
/>
)}
/>
</div>
<TextArea <TextArea
id='operation_comment' id='operation_comment'
label='Описание' label='Описание'

View File

@ -21,7 +21,7 @@ export interface DlgDeleteOperationProps {
export function DlgDeleteOperation() { export function DlgDeleteOperation() {
const { oss, target, layout } = useDialogsStore(state => state.props as DlgDeleteOperationProps); const { oss, target, layout } = useDialogsStore(state => state.props as DlgDeleteOperationProps);
const { deleteOperation: operationDelete } = useDeleteOperation(); const { deleteOperation } = useDeleteOperation();
const { handleSubmit, control } = useForm<IDeleteOperationDTO>({ const { handleSubmit, control } = useForm<IDeleteOperationDTO>({
resolver: zodResolver(schemaDeleteOperation), resolver: zodResolver(schemaDeleteOperation),
@ -34,7 +34,7 @@ export function DlgDeleteOperation() {
}); });
function onSubmit(data: IDeleteOperationDTO) { function onSubmit(data: IDeleteOperationDTO) {
return operationDelete({ itemID: oss.id, data: data }); return deleteOperation({ itemID: oss.id, data: data });
} }
return ( return (

View File

@ -34,7 +34,7 @@ export type TabID = (typeof TabID)[keyof typeof TabID];
export function DlgEditOperation() { export function DlgEditOperation() {
const { oss, target, layout } = useDialogsStore(state => state.props as DlgEditOperationProps); const { oss, target, layout } = useDialogsStore(state => state.props as DlgEditOperationProps);
const { updateOperation: operationUpdate } = useUpdateOperation(); const { updateOperation } = useUpdateOperation();
const methods = useForm<IUpdateOperationDTO>({ const methods = useForm<IUpdateOperationDTO>({
resolver: zodResolver(schemaUpdateOperation), resolver: zodResolver(schemaUpdateOperation),
@ -58,7 +58,7 @@ export function DlgEditOperation() {
const [activeTab, setActiveTab] = useState<TabID>(TabID.CARD); const [activeTab, setActiveTab] = useState<TabID>(TabID.CARD);
function onSubmit(data: IUpdateOperationDTO) { function onSubmit(data: IUpdateOperationDTO) {
return operationUpdate({ itemID: oss.id, data }); return updateOperation({ itemID: oss.id, data });
} }
return ( return (
@ -78,13 +78,23 @@ export function DlgEditOperation() {
onSelect={index => setActiveTab(index as TabID)} onSelect={index => setActiveTab(index as TabID)}
> >
<TabList className='mb-3 mx-auto w-fit flex border divide-x rounded-none bg-secondary'> <TabList className='mb-3 mx-auto w-fit flex border divide-x rounded-none bg-secondary'>
<TabLabel title='Текстовые поля' label='Карточка' className='w-32' /> <TabLabel
{target.operation_type === OperationType.SYNTHESIS ? ( title='Текстовые поля' //
<TabLabel title='Выбор аргументов операции' label='Аргументы' className='w-32' /> label='Карточка'
) : null} className='w-32'
{target.operation_type === OperationType.SYNTHESIS ? ( />
<TabLabel titleHtml='Таблица отождествлений' label='Отождествления' className='w-32' /> <TabLabel
) : null} title='Выбор аргументов операции'
label='Аргументы'
className='w-32'
disabled={target.operation_type !== OperationType.SYNTHESIS}
/>
<TabLabel
titleHtml='Таблица отождествлений'
label='Отождествления'
className='w-32'
disabled={target.operation_type !== OperationType.SYNTHESIS}
/>
</TabList> </TabList>
<FormProvider {...methods}> <FormProvider {...methods}>

View File

@ -1,12 +1,18 @@
import { useFormContext } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form';
import { TextArea, TextInput } from '@/components/input'; import { TextArea, TextInput } from '@/components/input';
import { useDialogsStore } from '@/stores/dialogs';
import { type IUpdateOperationDTO } from '../../backend/types'; import { type IUpdateOperationDTO } from '../../backend/types';
import { SelectBlock } from '../../components/select-block';
import { type DlgEditOperationProps } from './dlg-edit-operation';
export function TabOperation() { export function TabOperation() {
const { oss } = useDialogsStore(state => state.props as DlgEditOperationProps);
const { const {
register, register,
control,
formState: { errors } formState: { errors }
} = useFormContext<IUpdateOperationDTO>(); } = useFormContext<IUpdateOperationDTO>();
@ -14,19 +20,32 @@ export function TabOperation() {
<div className='cc-fade-in cc-column'> <div className='cc-fade-in cc-column'>
<TextInput <TextInput
id='operation_title' id='operation_title'
label='Названиее' label='Название'
{...register('item_data.title')} {...register('item_data.title')}
error={errors.item_data?.title} error={errors.item_data?.title}
/> />
<div className='flex gap-6'> <div className='flex gap-6'>
<div className='grid gap-1'>
<TextInput <TextInput
id='operation_alias' id='operation_alias' //
label='Сокращение' label='Сокращение'
className='w-64' className='w-64'
{...register('item_data.alias')} {...register('item_data.alias')}
error={errors.item_data?.alias} error={errors.item_data?.alias}
/> />
<Controller
name='item_data.parent'
control={control}
render={({ field }) => (
<SelectBlock
items={oss.blocks}
value={field.value ? oss.blockByID.get(field.value) ?? null : null}
placeholder='Блок содержания'
onChange={value => field.onChange(value ? value.id : null)}
/>
)}
/>
</div>
<TextArea <TextArea
id='operation_comment' id='operation_comment'
label='Описание' label='Описание'

View File

@ -1,9 +1,13 @@
import { OperationType } from './backend/types'; import { OperationType } from './backend/types';
import { type ISubstitutionErrorDescription, SubstitutionErrorType } from './models/oss'; import {
type IOperation,
type IOssItem,
type ISubstitutionErrorDescription,
SubstitutionErrorType
} from './models/oss';
import { isOperation } from './models/oss-api';
/** /** Retrieves label for {@link OperationType}. */
* Retrieves label for {@link OperationType}.
*/
export function labelOperationType(itemType: OperationType): string { export function labelOperationType(itemType: OperationType): string {
// prettier-ignore // prettier-ignore
switch (itemType) { switch (itemType) {
@ -12,9 +16,7 @@ export function labelOperationType(itemType: OperationType): string {
} }
} }
/** /** Retrieves description for {@link OperationType}. */
* Retrieves description for {@link OperationType}.
*/
export function describeOperationType(itemType: OperationType): string { export function describeOperationType(itemType: OperationType): string {
// prettier-ignore // prettier-ignore
switch (itemType) { switch (itemType) {
@ -23,9 +25,7 @@ export function describeOperationType(itemType: OperationType): string {
} }
} }
/** /** Generates error description for {@link ISubstitutionErrorDescription}. */
* Generates error description for {@link ISubstitutionErrorDescription}.
*/
export function describeSubstitutionError(error: ISubstitutionErrorDescription): string { export function describeSubstitutionError(error: ISubstitutionErrorDescription): string {
switch (error.errorType) { switch (error.errorType) {
case SubstitutionErrorType.invalidIDs: case SubstitutionErrorType.invalidIDs:
@ -53,3 +53,12 @@ export function describeSubstitutionError(error: ISubstitutionErrorDescription):
} }
return 'UNKNOWN ERROR'; return 'UNKNOWN ERROR';
} }
/** Retrieves label for {@link IOssItem}. */
export function labelOssItem(item: IOssItem): string {
if (isOperation(item)) {
return `${(item as IOperation).alias}: ${item.title}`;
} else {
return `Блок: ${item.title}`;
}
}

View File

@ -22,22 +22,26 @@ import {
import { infoMsg } from '@/utils/labels'; import { infoMsg } from '@/utils/labels';
import { Graph } from '../../../models/graph'; import { Graph } from '../../../models/graph';
import { type IOssLayout } from '../backend/types'; import { type ICreateBlockDTO, type ICreateOperationDTO, type IOssLayout } from '../backend/types';
import { describeSubstitutionError } from '../labels'; import { describeSubstitutionError } from '../labels';
import { OPERATION_NODE_HEIGHT, OPERATION_NODE_WIDTH } from '../pages/oss-page/editor-oss-graph/graph/node-core';
import { type IOperationSchema, SubstitutionErrorType } from './oss'; import { type IOperationSchema, type IOssItem, SubstitutionErrorType } from './oss';
import { type Position2D } from './oss-layout'; import { type Position2D, type Rectangle2D } from './oss-layout';
export const GRID_SIZE = 10; // pixels - size of OSS grid export const GRID_SIZE = 10; // pixels - size of OSS grid
const MIN_DISTANCE = 20; // pixels - minimum distance between node centers const MIN_DISTANCE = 2 * GRID_SIZE; // pixels - minimum distance between nodes
const DISTANCE_X = 180; // pixels - insert x-distance between node centers const DISTANCE_X = 180; // pixels - insert x-distance between node centers
const DISTANCE_Y = 100; // pixels - insert y-distance between node centers const DISTANCE_Y = 100; // pixels - insert y-distance between node centers
const STARTING_SUB_INDEX = 900; // max semantic index for starting substitution const STARTING_SUB_INDEX = 900; // max semantic index for starting substitution
/** /** Checks if element is {@link IOperation} or {@link IBlock}. */
* Sorts library items relevant for the specified {@link IOperationSchema}. export function isOperation(item: IOssItem): boolean {
*/ return 'arguments' in item;
}
/** Sorts library items relevant for the specified {@link IOperationSchema}. */
export function sortItemsForOSS(oss: IOperationSchema, items: ILibraryItem[]): ILibraryItem[] { export function sortItemsForOSS(oss: IOperationSchema, items: ILibraryItem[]): ILibraryItem[] {
const result = items.filter(item => item.location === oss.location); const result = items.filter(item => item.location === oss.location);
for (const item of items) { for (const item of items) {
@ -60,9 +64,7 @@ export function sortItemsForOSS(oss: IOperationSchema, items: ILibraryItem[]): I
type CrossMapping = Map<number, AliasMapping>; type CrossMapping = Map<number, AliasMapping>;
/** /** Validator for Substitution table. */
* Validator for Substitution table.
*/
export class SubstitutionValidator { export class SubstitutionValidator {
public msg: string = ''; public msg: string = '';
public suggestions: ICstSubstitute[] = []; public suggestions: ICstSubstitute[] = [];
@ -476,22 +478,21 @@ export function getRelocateCandidates(
return addedCst.filter(cst => !unreachable.includes(cst.id)); return addedCst.filter(cst => !unreachable.includes(cst.id));
} }
/** /** Calculate insert position for a new {@link IOperation} */
* Calculate insert position for a new {@link IOperation} export function calculateNewOperationPosition(
*/
export function calculateInsertPosition(
oss: IOperationSchema, oss: IOperationSchema,
argumentsOps: number[], data: ICreateOperationDTO,
layout: IOssLayout, layout: IOssLayout
defaultPosition: Position2D
): Position2D { ): Position2D {
const result = defaultPosition; // TODO: check parent node
const result = { x: data.position_x, y: data.position_y };
const operations = layout.operations; const operations = layout.operations;
if (operations.length === 0) { if (operations.length === 0) {
return result; return result;
} }
if (argumentsOps.length === 0) { if (data.arguments.length === 0) {
let inputsPositions = operations.filter(pos => let inputsPositions = operations.filter(pos =>
oss.operations.find(operation => operation.arguments.length === 0 && operation.id === pos.id) oss.operations.find(operation => operation.arguments.length === 0 && operation.id === pos.id)
); );
@ -503,7 +504,7 @@ export function calculateInsertPosition(
result.x = maxX + DISTANCE_X; result.x = maxX + DISTANCE_X;
result.y = minY; result.y = minY;
} else { } else {
const argNodes = operations.filter(pos => argumentsOps.includes(pos.id)); const argNodes = operations.filter(pos => data.arguments.includes(pos.id));
const maxY = Math.max(...argNodes.map(node => node.y)); const maxY = Math.max(...argNodes.map(node => node.y));
const minX = Math.min(...argNodes.map(node => node.x)); const minX = Math.min(...argNodes.map(node => node.x));
const maxX = Math.max(...argNodes.map(node => node.x)); const maxX = Math.max(...argNodes.map(node => node.x));
@ -523,3 +524,51 @@ export function calculateInsertPosition(
} while (flagIntersect); } while (flagIntersect);
return result; return result;
} }
/** Calculate insert position for a new {@link IBlock} */
export function calculateNewBlockPosition(data: ICreateBlockDTO, layout: IOssLayout): Rectangle2D {
const block_nodes = data.children_blocks
.map(id => layout.blocks.find(block => block.id === -id))
.filter(node => !!node);
const operation_nodes = data.children_operations
.map(id => layout.operations.find(operation => operation.id === id))
.filter(node => !!node);
if (block_nodes.length === 0 && operation_nodes.length === 0) {
return { x: data.position_x, y: data.position_y, width: data.width, height: data.height };
}
let left = undefined;
let top = undefined;
let right = undefined;
let bottom = undefined;
for (const node of block_nodes) {
left = !left ? node.x - GRID_SIZE : Math.min(left, node.x - GRID_SIZE);
top = !top ? node.y - GRID_SIZE : Math.min(top, node.y - GRID_SIZE);
right = !right
? Math.max(left + data.width, node.x + node.width + GRID_SIZE)
: Math.max(right, node.x + node.width + GRID_SIZE);
bottom = !bottom
? Math.max(top + data.height, node.y + node.height + GRID_SIZE)
: Math.max(bottom, node.y + node.height + GRID_SIZE);
}
for (const node of operation_nodes) {
left = !left ? node.x - GRID_SIZE : Math.min(left, node.x - GRID_SIZE);
top = !top ? node.y - GRID_SIZE : Math.min(top, node.y - GRID_SIZE);
right = !right
? Math.max(left + data.width, node.x + OPERATION_NODE_WIDTH + GRID_SIZE)
: Math.max(right, node.x + OPERATION_NODE_WIDTH + GRID_SIZE);
bottom = !bottom
? Math.max(top + data.height, node.y + OPERATION_NODE_HEIGHT + GRID_SIZE)
: Math.max(bottom, node.y + OPERATION_NODE_HEIGHT + GRID_SIZE);
}
return {
x: left ?? data.position_x,
y: top ?? data.position_y,
width: right && left ? right - left : data.width,
height: bottom && top ? bottom - top : data.height
};
}

View File

@ -5,14 +5,18 @@ import { type Node } from 'reactflow';
import { type IBlock, type IOperation } from './oss'; import { type IBlock, type IOperation } from './oss';
/** /** Represents XY Position. */
* Represents XY Position.
*/
export interface Position2D { export interface Position2D {
x: number; x: number;
y: number; y: number;
} }
/** Represents XY Position and dimensions. */
export interface Rectangle2D extends Position2D {
width: number;
height: number;
}
/** Represents graph OSS node data. */ /** Represents graph OSS node data. */
export interface OssNode extends Node { export interface OssNode extends Node {
id: string; id: string;

View File

@ -52,6 +52,9 @@ export interface IOperationSchema extends IOperationSchemaDTO {
blockByID: Map<number, IBlock>; blockByID: Map<number, IBlock>;
} }
/** Represents item of OperationSchema. */
export type IOssItem = IOperation | IBlock;
/** Represents substitution error description. */ /** Represents substitution error description. */
export interface ISubstitutionErrorDescription { export interface ISubstitutionErrorDescription {
errorType: SubstitutionErrorType; errorType: SubstitutionErrorType;

View File

@ -23,6 +23,12 @@ export function OssStats({ className, stats }: OssStatsProps) {
<span>Всего</span> <span>Всего</span>
<span>{stats.count_all}</span> <span>{stats.count_all}</span>
</div> </div>
<ValueStats
id='count_block'
title='Блоки'
icon={<IconConceptBlock size='1.25rem' className='text-primary' />}
value={stats.count_block}
/>
<ValueStats <ValueStats
id='count_inputs' id='count_inputs'
title='Загрузка' title='Загрузка'
@ -35,12 +41,6 @@ export function OssStats({ className, stats }: OssStatsProps) {
icon={<IconSynthesis size='1.25rem' className='text-primary' />} icon={<IconSynthesis size='1.25rem' className='text-primary' />}
value={stats.count_synthesis} value={stats.count_synthesis}
/> />
<ValueStats
id='count_block'
title='Блоки'
icon={<IconConceptBlock size='1.25rem' className='text-primary' />}
value={stats.count_block}
/>
<ValueStats <ValueStats
id='count_schemas' id='count_schemas'

View File

@ -3,20 +3,37 @@
import { NodeResizeControl } from 'reactflow'; import { NodeResizeControl } from 'reactflow';
import clsx from 'clsx'; import clsx from 'clsx';
import { useOSSGraphStore } from '@/features/oss/stores/oss-graph';
import { IconResize } from '@/components/icons'; import { IconResize } from '@/components/icons';
import { type BlockInternalNode } from '../../../../models/oss-layout'; import { type BlockInternalNode } from '../../../../models/oss-layout';
import { useOssEdit } from '../../oss-edit-context'; import { useOssEdit } from '../../oss-edit-context';
export const BLOCK_NODE_MIN_WIDTH = 160;
export const BLOCK_NODE_MIN_HEIGHT = 100;
export function BlockNode(node: BlockInternalNode) { export function BlockNode(node: BlockInternalNode) {
const showCoordinates = useOSSGraphStore(state => state.showCoordinates);
const { selected, schema } = useOssEdit(); const { selected, schema } = useOssEdit();
const singleSelected = selected.length === 1 ? selected[0] : null; const singleSelected = selected.length === 1 ? selected[0] : null;
const isParent = !singleSelected ? false : schema.hierarchy.at(singleSelected)?.inputs.includes(node.data.block.id); const isParent = !singleSelected ? false : schema.hierarchy.at(singleSelected)?.inputs.includes(node.data.block.id);
return ( return (
<> <>
<NodeResizeControl minWidth={160} minHeight={100}> <NodeResizeControl minWidth={BLOCK_NODE_MIN_WIDTH} minHeight={BLOCK_NODE_MIN_HEIGHT}>
<IconResize size={8} className='absolute bottom-[2px] right-[2px]' /> <IconResize size={8} className='absolute bottom-[2px] right-[2px]' />
</NodeResizeControl> </NodeResizeControl>
{showCoordinates ? (
<div
className={clsx(
'absolute top-full mt-1 right-[1px]',
'text-[7px]/[8px] font-math',
'text-muted-foreground hover:text-foreground'
)}
>
{`X: ${node.xPos.toFixed(0)} Y: ${node.yPos.toFixed(0)}`}
</div>
) : null}
<div className={clsx('cc-node-block h-full w-full', isParent && 'border-primary')}> <div className={clsx('cc-node-block h-full w-full', isParent && 'border-primary')}>
<div <div
className={clsx( className={clsx(

View File

@ -2,6 +2,8 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { useOSSGraphStore } from '@/features/oss/stores/oss-graph';
import { IconConsolidation, IconRSForm } from '@/components/icons'; import { IconConsolidation, IconRSForm } from '@/components/icons';
import { Indicator } from '@/components/view'; import { Indicator } from '@/components/view';
import { globalIDs } from '@/utils/constants'; import { globalIDs } from '@/utils/constants';
@ -22,6 +24,7 @@ interface NodeCoreProps {
export function NodeCore({ node }: NodeCoreProps) { export function NodeCore({ node }: NodeCoreProps) {
const setHover = useOperationTooltipStore(state => state.setActiveOperation); const setHover = useOperationTooltipStore(state => state.setActiveOperation);
const showCoordinates = useOSSGraphStore(state => state.showCoordinates);
const hasFile = !!node.data.operation.result; const hasFile = !!node.data.operation.result;
const longLabel = node.data.label.length > LONG_LABEL_CHARS; const longLabel = node.data.label.length > LONG_LABEL_CHARS;
@ -47,6 +50,17 @@ export function NodeCore({ node }: NodeCoreProps) {
/> />
) : null} ) : null}
</div> </div>
{showCoordinates ? (
<div
className={clsx(
'absolute top-full mt-1 right-[1px]',
'text-[7px]/[8px] font-math',
'text-muted-foreground hover:text-foreground'
)}
>
{`X: ${node.xPos.toFixed(0)} Y: ${node.yPos.toFixed(0)}`}
</div>
) : null}
{node.data.operation.operation_type === OperationType.INPUT ? ( {node.data.operation.operation_type === OperationType.INPUT ? (
<div className='absolute top-[1px] right-1/2 translate-x-1/2 border-t w-[30px]' /> <div className='absolute top-[1px] right-1/2 translate-x-1/2 border-t w-[30px]' />

View File

@ -12,6 +12,7 @@ import {
useStoreApi useStoreApi
} from 'reactflow'; } from 'reactflow';
import { useDeleteBlock } from '@/features/oss/backend/use-delete-block';
import { type IOperationSchema } from '@/features/oss/models/oss'; import { type IOperationSchema } from '@/features/oss/models/oss';
import { useMainHeight } from '@/stores/app-layout'; import { useMainHeight } from '@/stores/app-layout';
@ -60,11 +61,13 @@ export function OssFlow() {
const setHoverOperation = useOperationTooltipStore(state => state.setActiveOperation); const setHoverOperation = useOperationTooltipStore(state => state.setActiveOperation);
const showGrid = useOSSGraphStore(state => state.showGrid); const showGrid = useOSSGraphStore(state => state.showGrid);
const showCoordinates = useOSSGraphStore(state => state.showCoordinates);
const edgeAnimate = useOSSGraphStore(state => state.edgeAnimate); const edgeAnimate = useOSSGraphStore(state => state.edgeAnimate);
const edgeStraight = useOSSGraphStore(state => state.edgeStraight); const edgeStraight = useOSSGraphStore(state => state.edgeStraight);
const getLayout = useGetLayout(); const getLayout = useGetLayout();
const { updateLayout: updatePositions } = useUpdateLayout(); const { updateLayout } = useUpdateLayout();
const { deleteBlock } = useDeleteBlock();
const [nodes, setNodes, onNodesChange] = useNodesState([]); const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]);
@ -72,7 +75,10 @@ export function OssFlow() {
const [menuProps, setMenuProps] = useState<ContextMenuData>({ operation: null, cursorX: 0, cursorY: 0 }); const [menuProps, setMenuProps] = useState<ContextMenuData>({ operation: null, cursorX: 0, cursorY: 0 });
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [mouseCoords, setMouseCoords] = useState<Position2D>({ x: 0, y: 0 });
const showCreateOperation = useDialogsStore(state => state.showCreateOperation); const showCreateOperation = useDialogsStore(state => state.showCreateOperation);
const showCreateBlock = useDialogsStore(state => state.showCreateBlock);
const showDeleteOperation = useDialogsStore(state => state.showDeleteOperation); const showDeleteOperation = useDialogsStore(state => state.showDeleteOperation);
function onSelectionChange({ nodes }: { nodes: Node[] }) { function onSelectionChange({ nodes }: { nodes: Node[] }) {
@ -99,8 +105,10 @@ export function OssFlow() {
type: 'block', type: 'block',
data: { label: block.title, block: block }, data: { label: block.title, block: block },
position: computeRelativePosition(schema, { x: block.x, y: block.y }, block.parent), position: computeRelativePosition(schema, { x: block.x, y: block.y }, block.parent),
style: {
width: block.width, width: block.width,
height: block.height, height: block.height
},
parentId: block.parent ? `-${block.parent}` : undefined, parentId: block.parent ? `-${block.parent}` : undefined,
expandParent: true, expandParent: true,
extent: 'parent' as const, extent: 'parent' as const,
@ -135,7 +143,7 @@ export function OssFlow() {
}, [schema, setNodes, setEdges, toggleReset, edgeStraight, edgeAnimate, fitView]); }, [schema, setNodes, setEdges, toggleReset, edgeStraight, edgeAnimate, fitView]);
function handleSavePositions() { function handleSavePositions() {
void updatePositions({ itemID: schema.id, data: getLayout() }); void updateLayout({ itemID: schema.id, data: getLayout() });
} }
function handleCreateOperation() { function handleCreateOperation() {
@ -146,6 +154,20 @@ export function OssFlow() {
defaultY: targetPosition.y, defaultY: targetPosition.y,
layout: getLayout(), layout: getLayout(),
initialInputs: selected.filter(id => id > 0), initialInputs: selected.filter(id => id > 0),
initialParent: extractSingleBlock(selected),
onCreate: () =>
setTimeout(() => fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING }), PARAMETER.refreshTimeout)
});
}
function handleCreateBlock() {
const targetPosition = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
showCreateBlock({
oss: schema,
defaultX: targetPosition.x,
defaultY: targetPosition.y,
layout: getLayout(),
initialInputs: selected,
onCreate: () => onCreate: () =>
setTimeout(() => fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING }), PARAMETER.refreshTimeout) setTimeout(() => fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING }), PARAMETER.refreshTimeout)
}); });
@ -155,6 +177,7 @@ export function OssFlow() {
if (selected.length !== 1) { if (selected.length !== 1) {
return; return;
} }
if (selected[0] > 0) {
const operation = schema.operationByID.get(selected[0]); const operation = schema.operationByID.get(selected[0]);
if (!operation || !canDelete(operation)) { if (!operation || !canDelete(operation)) {
return; return;
@ -164,6 +187,13 @@ export function OssFlow() {
target: operation, target: operation,
layout: getLayout() layout: getLayout()
}); });
} else {
const block = schema.blockByID.get(-selected[0]);
if (!block) {
return;
}
void deleteBlock({ itemID: schema.id, data: { target: block.id, layout: getLayout() } });
}
} }
function handleContextMenu(event: React.MouseEvent<Element>, node: OssNode) { function handleContextMenu(event: React.MouseEvent<Element>, node: OssNode) {
@ -209,7 +239,11 @@ export function OssFlow() {
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyQ') { if ((event.ctrlKey || event.metaKey) && event.code === 'KeyQ') {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (event.shiftKey) {
handleCreateBlock();
} else {
handleCreateOperation(); handleCreateOperation();
}
return; return;
} }
if (event.key === 'Delete') { if (event.key === 'Delete') {
@ -220,11 +254,27 @@ export function OssFlow() {
} }
} }
function handleMouseMove(event: React.MouseEvent<HTMLDivElement>) {
const targetPosition = screenToFlowPosition({ x: event.clientX, y: event.clientY });
setMouseCoords(targetPosition);
}
return ( return (
<div tabIndex={-1} className='relative' onKeyDown={handleKeyDown}> <div
tabIndex={-1}
className='relative'
onKeyDown={handleKeyDown}
onMouseMove={showCoordinates ? handleMouseMove : undefined}
>
{showCoordinates ? (
<div className='absolute top-1 right-2 hover:bg-background backdrop-blur-xs text-sm font-math'>
{`X: ${mouseCoords.x.toFixed(0)} Y: ${mouseCoords.y.toFixed(0)}`}
</div>
) : null}
<ToolbarOssGraph <ToolbarOssGraph
className='absolute z-pop top-8 right-1/2 translate-x-1/2' className='absolute z-pop top-8 right-1/2 translate-x-1/2'
onCreate={handleCreateOperation} onCreateOperation={handleCreateOperation}
onCreateBlock={handleCreateBlock}
onDelete={handleDeleteSelected} onDelete={handleDeleteSelected}
onResetPositions={() => setToggleReset(prev => !prev)} onResetPositions={() => setToggleReset(prev => !prev)}
/> />
@ -274,3 +324,8 @@ function computeRelativePosition(schema: IOperationSchema, position: Position2D,
y: position.y - parentBlock.y y: position.y - parentBlock.y
}; };
} }
function extractSingleBlock(selected: number[]): number | null {
const blocks = selected.filter(id => id < 0);
return blocks.length === 1 ? -blocks[0] : null;
}

View File

@ -11,6 +11,8 @@ import { MiniButton } from '@/components/control';
import { import {
IconAnimation, IconAnimation,
IconAnimationOff, IconAnimationOff,
IconConceptBlock,
IconCoordinates,
IconDestroy, IconDestroy,
IconEdit2, IconEdit2,
IconExecute, IconExecute,
@ -37,13 +39,15 @@ import { VIEW_PADDING } from './oss-flow';
import { useGetLayout } from './use-get-layout'; import { useGetLayout } from './use-get-layout';
interface ToolbarOssGraphProps extends Styling { interface ToolbarOssGraphProps extends Styling {
onCreate: () => void; onCreateOperation: () => void;
onCreateBlock: () => void;
onDelete: () => void; onDelete: () => void;
onResetPositions: () => void; onResetPositions: () => void;
} }
export function ToolbarOssGraph({ export function ToolbarOssGraph({
onCreate, onCreateOperation,
onCreateBlock,
onDelete, onDelete,
onResetPositions, onResetPositions,
className, className,
@ -53,12 +57,15 @@ export function ToolbarOssGraph({
const isProcessing = useMutatingOss(); const isProcessing = useMutatingOss();
const { fitView } = useReactFlow(); const { fitView } = useReactFlow();
const selectedOperation = selected.length !== 1 ? null : schema.operationByID.get(selected[0]) ?? null; const selectedOperation = selected.length !== 1 ? null : schema.operationByID.get(selected[0]) ?? null;
const selectedBlock = selected.length !== 1 ? null : schema.blockByID.get(-selected[0]) ?? null;
const getLayout = useGetLayout(); const getLayout = useGetLayout();
const showGrid = useOSSGraphStore(state => state.showGrid); const showGrid = useOSSGraphStore(state => state.showGrid);
const showCoordinates = useOSSGraphStore(state => state.showCoordinates);
const edgeAnimate = useOSSGraphStore(state => state.edgeAnimate); const edgeAnimate = useOSSGraphStore(state => state.edgeAnimate);
const edgeStraight = useOSSGraphStore(state => state.edgeStraight); const edgeStraight = useOSSGraphStore(state => state.edgeStraight);
const toggleShowGrid = useOSSGraphStore(state => state.toggleShowGrid); const toggleShowGrid = useOSSGraphStore(state => state.toggleShowGrid);
const toggleShowCoordinates = useOSSGraphStore(state => state.toggleShowCoordinates);
const toggleEdgeAnimate = useOSSGraphStore(state => state.toggleEdgeAnimate); const toggleEdgeAnimate = useOSSGraphStore(state => state.toggleEdgeAnimate);
const toggleEdgeStraight = useOSSGraphStore(state => state.toggleEdgeStraight); const toggleEdgeStraight = useOSSGraphStore(state => state.toggleEdgeStraight);
@ -174,6 +181,12 @@ export function ToolbarOssGraph({
} }
onClick={toggleEdgeAnimate} onClick={toggleEdgeAnimate}
/> />
<MiniButton
title={showCoordinates ? 'Координаты: вкл' : 'Координаты: выкл'}
aria-label='Переключатель видимости координат (для отладки)'
icon={<IconCoordinates size='1.25rem' className={showCoordinates ? 'icon-green' : 'icon-primary'} />}
onClick={toggleShowCoordinates}
/>
<BadgeHelp topic={HelpTopic.UI_OSS_GRAPH} contentClass='sm:max-w-160' offset={4} /> <BadgeHelp topic={HelpTopic.UI_OSS_GRAPH} contentClass='sm:max-w-160' offset={4} />
</div> </div>
{isMutable ? ( {isMutable ? (
@ -185,11 +198,18 @@ export function ToolbarOssGraph({
onClick={handleSavePositions} onClick={handleSavePositions}
disabled={isProcessing} disabled={isProcessing}
/> />
<MiniButton
titleHtml={prepareTooltip('Новый блок', 'Ctrl + Shift + Q')}
aria-label='Новый блок'
icon={<IconConceptBlock size='1.25rem' className='icon-green' />}
onClick={onCreateBlock}
disabled={isProcessing}
/>
<MiniButton <MiniButton
titleHtml={prepareTooltip('Новая операция', 'Ctrl + Q')} titleHtml={prepareTooltip('Новая операция', 'Ctrl + Q')}
aria-label='Новая операция' aria-label='Новая операция'
icon={<IconNewItem size='1.25rem' className='icon-green' />} icon={<IconNewItem size='1.25rem' className='icon-green' />}
onClick={onCreate} onClick={onCreateOperation}
disabled={isProcessing} disabled={isProcessing}
/> />
<MiniButton <MiniButton
@ -210,7 +230,11 @@ export function ToolbarOssGraph({
aria-label='Удалить выбранную' aria-label='Удалить выбранную'
icon={<IconDestroy size='1.25rem' className='icon-red' />} icon={<IconDestroy size='1.25rem' className='icon-red' />}
onClick={onDelete} onClick={onDelete}
disabled={selected.length !== 1 || isProcessing || !selectedOperation || !canDelete(selectedOperation)} disabled={
isProcessing ||
(!selectedOperation && !selectedBlock) ||
(!!selectedOperation && !canDelete(selectedOperation))
}
/> />
</div> </div>
) : null} ) : null}

View File

@ -1,12 +1,13 @@
import { type Node, useReactFlow } from 'reactflow'; import { type Node, useReactFlow } from 'reactflow';
import { DEFAULT_BLOCK_HEIGHT, DEFAULT_BLOCK_WIDTH } from '@/features/oss/backend/oss-loader';
import { type IOssLayout } from '@/features/oss/backend/types'; import { type IOssLayout } from '@/features/oss/backend/types';
import { type IOperationSchema } from '@/features/oss/models/oss'; import { type IOperationSchema } from '@/features/oss/models/oss';
import { type Position2D } from '@/features/oss/models/oss-layout'; import { type Position2D } from '@/features/oss/models/oss-layout';
import { useOssEdit } from '../oss-edit-context'; import { useOssEdit } from '../oss-edit-context';
import { BLOCK_NODE_MIN_HEIGHT, BLOCK_NODE_MIN_WIDTH } from './graph/block-node';
export function useGetLayout() { export function useGetLayout() {
const { getNodes } = useReactFlow(); const { getNodes } = useReactFlow();
const { schema } = useOssEdit(); const { schema } = useOssEdit();
@ -26,8 +27,8 @@ export function useGetLayout() {
.map(node => ({ .map(node => ({
id: -Number(node.id), id: -Number(node.id),
...computeAbsolutePosition(node, schema, nodeById), ...computeAbsolutePosition(node, schema, nodeById),
width: node.width ?? DEFAULT_BLOCK_WIDTH, width: node.width ?? BLOCK_NODE_MIN_WIDTH,
height: node.height ?? DEFAULT_BLOCK_HEIGHT height: node.height ?? BLOCK_NODE_MIN_HEIGHT
})) }))
}; };
}; };

View File

@ -5,6 +5,9 @@ interface OSSGraphStore {
showGrid: boolean; showGrid: boolean;
toggleShowGrid: () => void; toggleShowGrid: () => void;
showCoordinates: boolean;
toggleShowCoordinates: () => void;
edgeAnimate: boolean; edgeAnimate: boolean;
toggleEdgeAnimate: () => void; toggleEdgeAnimate: () => void;
@ -18,6 +21,9 @@ export const useOSSGraphStore = create<OSSGraphStore>()(
showGrid: false, showGrid: false,
toggleShowGrid: () => set(state => ({ showGrid: !state.showGrid })), toggleShowGrid: () => set(state => ({ showGrid: !state.showGrid })),
showCoordinates: false,
toggleShowCoordinates: () => set(state => ({ showCoordinates: !state.showCoordinates })),
edgeAnimate: false, edgeAnimate: false,
toggleEdgeAnimate: () => set(state => ({ edgeAnimate: !state.edgeAnimate })), toggleEdgeAnimate: () => set(state => ({ edgeAnimate: !state.edgeAnimate })),

View File

@ -6,6 +6,7 @@ import { type DlgCreateVersionProps } from '@/features/library/dialogs/dlg-creat
import { type DlgEditEditorsProps } from '@/features/library/dialogs/dlg-edit-editors/dlg-edit-editors'; import { type DlgEditEditorsProps } from '@/features/library/dialogs/dlg-edit-editors/dlg-edit-editors';
import { type DlgEditVersionsProps } from '@/features/library/dialogs/dlg-edit-versions/dlg-edit-versions'; import { type DlgEditVersionsProps } from '@/features/library/dialogs/dlg-edit-versions/dlg-edit-versions';
import { type DlgChangeInputSchemaProps } from '@/features/oss/dialogs/dlg-change-input-schema'; import { type DlgChangeInputSchemaProps } from '@/features/oss/dialogs/dlg-change-input-schema';
import { type DlgCreateBlockProps } from '@/features/oss/dialogs/dlg-create-block/dlg-create-block';
import { type DlgCreateOperationProps } from '@/features/oss/dialogs/dlg-create-operation/dlg-create-operation'; import { type DlgCreateOperationProps } from '@/features/oss/dialogs/dlg-create-operation/dlg-create-operation';
import { type DlgDeleteOperationProps } from '@/features/oss/dialogs/dlg-delete-operation'; import { type DlgDeleteOperationProps } from '@/features/oss/dialogs/dlg-delete-operation';
import { type DlgEditOperationProps } from '@/features/oss/dialogs/dlg-edit-operation/dlg-edit-operation'; import { type DlgEditOperationProps } from '@/features/oss/dialogs/dlg-edit-operation/dlg-edit-operation';
@ -26,28 +27,36 @@ import { type DlgUploadRSFormProps } from '@/features/rsform/dialogs/dlg-upload-
/** Represents global dialog. */ /** Represents global dialog. */
export const DialogType = { export const DialogType = {
CONSTITUENTA_TEMPLATE: 1, CONSTITUENTA_TEMPLATE: 1,
CREATE_CONSTITUENTA: 2, SUBSTITUTE_CONSTITUENTS: 2,
CREATE_OPERATION: 3,
DELETE_CONSTITUENTA: 4, CREATE_VERSION: 3,
EDIT_EDITORS: 5,
EDIT_OPERATION: 6, CREATE_CONSTITUENTA: 4,
EDIT_REFERENCE: 7, DELETE_CONSTITUENTA: 5,
EDIT_VERSIONS: 8, RENAME_CONSTITUENTA: 6,
EDIT_WORD_FORMS: 9,
INLINE_SYNTHESIS: 10, CREATE_BLOCK: 7,
SHOW_AST: 11,
SHOW_TYPE_GRAPH: 12, CREATE_OPERATION: 8,
CHANGE_INPUT_SCHEMA: 13, EDIT_OPERATION: 9,
CHANGE_LOCATION: 14, DELETE_OPERATION: 10,
CLONE_LIBRARY_ITEM: 15, CHANGE_INPUT_SCHEMA: 11,
CREATE_VERSION: 16, RELOCATE_CONSTITUENTS: 12,
DELETE_OPERATION: 17,
GRAPH_PARAMETERS: 18, CLONE_LIBRARY_ITEM: 13,
RELOCATE_CONSTITUENTS: 19, UPLOAD_RSFORM: 14,
RENAME_CONSTITUENTA: 20, EDIT_EDITORS: 15,
EDIT_VERSIONS: 16,
CHANGE_LOCATION: 17,
EDIT_REFERENCE: 18,
EDIT_WORD_FORMS: 19,
INLINE_SYNTHESIS: 20,
SHOW_QR_CODE: 21, SHOW_QR_CODE: 21,
SUBSTITUTE_CONSTITUENTS: 22, SHOW_AST: 22,
UPLOAD_RSFORM: 23 SHOW_TYPE_GRAPH: 23,
GRAPH_PARAMETERS: 24
} as const; } as const;
export type DialogType = (typeof DialogType)[keyof typeof DialogType]; export type DialogType = (typeof DialogType)[keyof typeof DialogType];
@ -62,6 +71,7 @@ interface DialogsStore {
showCstTemplate: (props: DlgCstTemplateProps) => void; showCstTemplate: (props: DlgCstTemplateProps) => void;
showCreateCst: (props: DlgCreateCstProps) => void; showCreateCst: (props: DlgCreateCstProps) => void;
showCreateBlock: (props: DlgCreateBlockProps) => void;
showCreateOperation: (props: DlgCreateOperationProps) => void; showCreateOperation: (props: DlgCreateOperationProps) => void;
showDeleteCst: (props: DlgDeleteCstProps) => void; showDeleteCst: (props: DlgDeleteCstProps) => void;
showEditEditors: (props: DlgEditEditorsProps) => void; showEditEditors: (props: DlgEditEditorsProps) => void;
@ -98,6 +108,7 @@ export const useDialogsStore = create<DialogsStore>()(set => ({
showCstTemplate: props => set({ active: DialogType.CONSTITUENTA_TEMPLATE, props: props }), showCstTemplate: props => set({ active: DialogType.CONSTITUENTA_TEMPLATE, props: props }),
showCreateCst: props => set({ active: DialogType.CREATE_CONSTITUENTA, props: props }), showCreateCst: props => set({ active: DialogType.CREATE_CONSTITUENTA, props: props }),
showCreateOperation: props => set({ active: DialogType.CREATE_OPERATION, props: props }), showCreateOperation: props => set({ active: DialogType.CREATE_OPERATION, props: props }),
showCreateBlock: props => set({ active: DialogType.CREATE_BLOCK, props: props }),
showDeleteCst: props => set({ active: DialogType.DELETE_CONSTITUENTA, props: props }), showDeleteCst: props => set({ active: DialogType.DELETE_CONSTITUENTA, props: props }),
showEditEditors: props => set({ active: DialogType.EDIT_EDITORS, props: props }), showEditEditors: props => set({ active: DialogType.EDIT_EDITORS, props: props }),
showEditOperation: props => set({ active: DialogType.EDIT_OPERATION, props: props }), showEditOperation: props => set({ active: DialogType.EDIT_OPERATION, props: props }),