F: Add side panel for schema preview pt1
Some checks failed
Frontend CI / build (22.x) (push) Waiting to run
Frontend CI / notify-failure (push) Blocked by required conditions
Backend CI / build (3.12) (push) Has been cancelled
Backend CI / notify-failure (push) Has been cancelled

This commit is contained in:
Ivan 2025-07-02 19:54:40 +03:00
parent ac38e9f4b5
commit 5c4149337b
13 changed files with 367 additions and 17 deletions

View File

@ -10,8 +10,9 @@ export const GlobalTooltips = () => {
float
id={globalIDs.tooltip}
layer='z-topmost'
place='right-start'
className='mt-8 max-w-80 break-words rounded-lg! select-none'
place='bottom-start'
offset={24}
className='max-w-80 break-words rounded-lg! select-none'
/>
<Tooltip
float

View File

@ -12,6 +12,7 @@ import {
IconExecute,
IconFitImage,
IconGrid,
IconLeftOpen,
IconLineStraight,
IconLineWave,
IconNewItem,
@ -30,7 +31,7 @@ export function HelpOssGraph() {
<div className='flex flex-col'>
<h1 className='sm:pr-24'>Граф синтеза</h1>
<div className='flex flex-col sm:flex-row'>
<div className='sm:w-56'>
<div className='sm:w-64'>
<h2>Настройка графа</h2>
<ul>
<li>
@ -39,6 +40,9 @@ export function HelpOssGraph() {
<li>
<IconFitImage className='inline-icon' /> Вписать в экран
</li>
<li>
<IconLeftOpen className='inline-icon' /> Панель связанной КС
</li>
<li>
<IconSettings className='inline-icon' /> Диалог настроек
</li>
@ -66,7 +70,7 @@ export function HelpOssGraph() {
<Divider vertical margins='mx-3 mt-3' className='hidden sm:block' />
<div className='sm:w-84'>
<div className='sm:w-76'>
<h2>Изменение узлов</h2>
<ul>
<li>
@ -97,7 +101,7 @@ export function HelpOssGraph() {
<Divider margins='my-2' className='hidden sm:block' />
<div className='flex flex-col-reverse mb-3 sm:flex-row'>
<div className='sm:w-56'>
<div className='sm:w-64'>
<h2>Общие</h2>
<ul>
<li>
@ -114,7 +118,7 @@ export function HelpOssGraph() {
<Divider vertical margins='mx-3' className='hidden sm:block' />
<div className='dense w-84'>
<div className='dense w-76'>
<h2>Контекстное меню</h2>
<ul>
<li>

View File

@ -15,9 +15,9 @@ export function IconShowSidebar({
}
} else {
if (value) {
return <IconLeftClose size={size} className={className ?? 'icon-primary'} />;
} else {
return <IconLeftOpen size={size} className={className ?? 'icon-primary'} />;
} else {
return <IconLeftClose size={size} className={className ?? 'icon-primary'} />;
}
}
}

View File

@ -6,6 +6,7 @@ import clsx from 'clsx';
import { DiagramFlow, useReactFlow, useStoreApi } from '@/components/flow/diagram-flow';
import { useMainHeight } from '@/stores/app-layout';
import { useDialogsStore } from '@/stores/dialogs';
import { usePreferencesStore } from '@/stores/preferences';
import { PARAMETER } from '@/utils/constants';
import { promptText } from '@/utils/labels';
@ -23,6 +24,7 @@ import { useContextMenu } from './context-menu/use-context-menu';
import { OssNodeTypes } from './graph/oss-node-types';
import { CoordinateDisplay } from './coordinate-display';
import { useOssFlow } from './oss-flow-context';
import { SidePanel } from './side-panel';
import { ToolbarOssGraph } from './toolbar-oss-graph';
import { useDragging } from './use-dragging';
import { useGetLayout } from './use-get-layout';
@ -52,6 +54,7 @@ export function OssFlow() {
const showGrid = useOSSGraphStore(state => state.showGrid);
const showCoordinates = useOSSGraphStore(state => state.showCoordinates);
const showPanel = usePreferencesStore(state => state.showOssSidePanel);
const getLayout = useGetLayout();
const { updateLayout } = useUpdateLayout();
@ -225,6 +228,16 @@ export function OssFlow() {
onNodeDrag={handleDrag}
onNodeDragStop={handleDragStop}
/>
<SidePanel
className={clsx(
'absolute right-0 top-0 z-sticky w-84 min-h-80',
'cc-animate-panel cc-shadow-border',
showPanel ? 'translate-x-0' : 'opacity-0 translate-x-full pointer-events-none'
)}
isMounted={showPanel}
selectedItems={selectedItems}
/>
</div>
);
}

View File

@ -0,0 +1 @@
export { SidePanel } from './side-panel';

View File

@ -0,0 +1,72 @@
import { Suspense } from 'react';
import clsx from 'clsx';
import { useDebounce } from 'use-debounce';
import { MiniButton } from '@/components/control';
import { IconClose } from '@/components/icons';
import { Loader } from '@/components/loader';
import { cn } from '@/components/utils';
import { useMainHeight } from '@/stores/app-layout';
import { usePreferencesStore } from '@/stores/preferences';
import { PARAMETER } from '@/utils/constants';
import { type IOssItem, NodeType } from '../../../../models/oss';
import { ViewSchema } from './view-schema';
interface SidePanelProps {
selectedItems: IOssItem[];
className?: string;
isMounted: boolean;
}
export function SidePanel({ selectedItems, isMounted, className }: SidePanelProps) {
const selectedOperation =
selectedItems.length === 1 && selectedItems[0].nodeType === NodeType.OPERATION ? selectedItems[0] : null;
const selectedSchema = selectedOperation?.result ?? null;
const debouncedMounted = useDebounce(isMounted, PARAMETER.moveDuration);
const closePanel = usePreferencesStore(state => state.toggleShowOssSidePanel);
const sidePanelHeight = useMainHeight();
return (
<div
className={cn(
'relative flex flex-col py-2 h-full overflow-hidden',
'border-l rounded-none rounded-l-sm bg-background',
className
)}
style={{ height: sidePanelHeight }}
>
<MiniButton
titleHtml='Закрыть панель'
aria-label='Закрыть'
noPadding
icon={<IconClose size='1.25rem' />}
className='absolute z-pop top-2 right-1'
onClick={closePanel}
/>
<div
className={clsx(
'mt-0 mb-1',
'font-medium text-sm select-none self-center',
'transition-transform',
selectedSchema && 'translate-x-16'
)}
>
Содержание КС
</div>
{!selectedOperation ? (
<div className='text-center text-sm cc-fade-in'>Выделите операцию для просмотра</div>
) : !selectedSchema ? (
<div className='text-center text-sm cc-fade-in'>Отсутствует концептуальная схема для выбранной операции</div>
) : debouncedMounted ? (
<Suspense fallback={<Loader />}>
<ViewSchema schemaID={selectedSchema} />
</Suspense>
) : null}
</div>
);
}

View File

@ -0,0 +1,195 @@
import { urls, useConceptNavigation } from '@/app';
import { type IConstituenta, type IRSForm } from '@/features/rsform';
import { CstType, type IConstituentaBasicsDTO, type ICreateConstituentaDTO } from '@/features/rsform/backend/types';
import { useCreateConstituenta } from '@/features/rsform/backend/use-create-constituenta';
import { useMoveConstituents } from '@/features/rsform/backend/use-move-constituents';
import { useMutatingRSForm } from '@/features/rsform/backend/use-mutating-rsform';
import { generateAlias } from '@/features/rsform/models/rsform-api';
import { useCstSearchStore } from '@/features/rsform/stores/cst-search';
import { MiniButton } from '@/components/control';
import { IconClone, IconDestroy, IconMoveDown, IconMoveUp, IconNewItem, IconRSForm } from '@/components/icons';
import { cn } from '@/components/utils';
import { useDialogsStore } from '@/stores/dialogs';
import { PARAMETER, prefixes } from '@/utils/constants';
import { type RO } from '@/utils/meta';
interface ToolbarConstituentsProps {
schema: IRSForm;
activeCst: IConstituenta | null;
setActive: (cstID: number) => void;
resetActive: () => void;
className?: string;
}
export function ToolbarConstituents({
schema,
activeCst,
setActive,
resetActive,
className
}: ToolbarConstituentsProps) {
const router = useConceptNavigation();
const isProcessing = useMutatingRSForm();
const searchText = useCstSearchStore(state => state.query);
const hasSearch = searchText.length > 0;
const showCreateCst = useDialogsStore(state => state.showCreateCst);
const showDeleteCst = useDialogsStore(state => state.showDeleteCst);
const { moveConstituents } = useMoveConstituents();
const { createConstituenta } = useCreateConstituenta();
function navigateRSForm() {
router.push({ path: urls.schema(schema.id) });
}
function onCreateCst(newCst: RO<IConstituentaBasicsDTO>) {
setActive(newCst.id);
setTimeout(() => {
const element = document.getElementById(`${prefixes.cst_list}${newCst.id}`);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'end'
});
}
}, PARAMETER.refreshTimeout);
}
function createCst() {
const targetType = activeCst?.cst_type ?? CstType.BASE;
const data: ICreateConstituentaDTO = {
insert_after: activeCst?.id ?? null,
cst_type: targetType,
alias: generateAlias(targetType, schema),
term_raw: '',
definition_formal: '',
definition_raw: '',
convention: '',
term_forms: []
};
showCreateCst({ schema: schema, onCreate: onCreateCst, initial: data });
}
function cloneCst() {
if (!activeCst) {
return;
}
void createConstituenta({
itemID: schema.id,
data: {
insert_after: activeCst.id,
cst_type: activeCst.cst_type,
alias: generateAlias(activeCst.cst_type, schema),
term_raw: activeCst.term_raw,
definition_formal: activeCst.definition_formal,
definition_raw: activeCst.definition_raw,
convention: activeCst.convention,
term_forms: activeCst.term_forms
}
}).then(onCreateCst);
}
function promptDeleteCst() {
if (!activeCst) {
return;
}
showDeleteCst({
schema: schema,
selected: [activeCst.id],
afterDelete: resetActive
});
}
function moveUp() {
if (!activeCst) {
return;
}
const currentIndex = schema.items.reduce((prev, cst, index) => {
if (activeCst.id !== cst.id) {
return prev;
} else if (prev === -1) {
return index;
}
return Math.min(prev, index);
}, -1);
const target = Math.max(0, currentIndex - 1);
void moveConstituents({
itemID: schema.id,
data: {
items: [activeCst.id],
move_to: target
}
});
}
function moveDown() {
if (!activeCst) {
return;
}
let count = 0;
const currentIndex = schema.items.reduce((prev, cst, index) => {
if (activeCst.id !== 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);
void moveConstituents({
itemID: schema.id,
data: {
items: [activeCst.id],
move_to: target
}
});
}
return (
<div className={cn('flex gap-0.5', className)}>
<MiniButton
title='Перейти к концептуальной схеме'
icon={<IconRSForm size='1rem' className='icon-primary' />}
onClick={navigateRSForm}
/>
<MiniButton
title='Создать конституенту'
icon={<IconNewItem size='1rem' className='icon-green' />}
onClick={createCst}
disabled={isProcessing}
/>
<MiniButton
title='Клонировать конституенту'
icon={<IconClone size='1rem' className='icon-green' />}
onClick={cloneCst}
disabled={!activeCst || isProcessing}
/>
<MiniButton
title='Удалить выделенную конституенту'
onClick={promptDeleteCst}
icon={<IconDestroy size='1rem' className='icon-red' />}
disabled={!activeCst || isProcessing || activeCst?.is_inherited}
/>
<MiniButton
title='Переместить вверх'
icon={<IconMoveUp size='1rem' className='icon-primary' />}
onClick={moveUp}
disabled={!activeCst || isProcessing || schema.items.length < 2 || hasSearch}
/>
<MiniButton
title='Переместить вниз'
icon={<IconMoveDown size='1rem' className='icon-primary' />}
onClick={moveDown}
disabled={!activeCst || isProcessing || schema.items.length < 2 || hasSearch}
/>
</div>
);
}

View File

@ -0,0 +1,45 @@
import { useState } from 'react';
import { useRSFormSuspense } from '@/features/rsform/backend/use-rsform';
import { RSFormStats } from '@/features/rsform/components/rsform-stats';
import { ViewConstituents } from '@/features/rsform/components/view-constituents';
import { useFitHeight } from '@/stores/app-layout';
import { ToolbarConstituents } from './toolbar-constituents';
interface ViewSchemaProps {
schemaID: number;
}
export function ViewSchema({ schemaID }: ViewSchemaProps) {
const { schema } = useRSFormSuspense({ itemID: schemaID });
const [activeID, setActiveID] = useState<number | null>(null);
const activeCst = activeID ? schema.cstByID.get(activeID) ?? null : null;
const listHeight = useFitHeight('19rem', '10rem');
return (
<div className='grid h-full relative cc-fade-in' style={{ gridTemplateRows: '1fr auto' }}>
<ToolbarConstituents
className='absolute -top-7 left-1'
schema={schema}
activeCst={activeCst}
setActive={setActiveID}
resetActive={() => setActiveID(null)}
/>
<ViewConstituents
dense
noBorder
className='border-y rounded-none'
schema={schema}
activeCst={activeCst}
onActivate={cst => setActiveID(cst.id)}
maxListHeight={listHeight}
/>
<RSFormStats className='pr-4 py-2 ml-[-1rem]' stats={schema.stats} />
</div>
);
}

View File

@ -4,6 +4,7 @@ import React from 'react';
import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components/badge-help';
import { IconShowSidebar } from '@/features/library/components/icon-show-sidebar';
import { type OssNode } from '@/features/oss/models/oss-layout';
import { MiniButton } from '@/components/control';
@ -20,6 +21,7 @@ import {
import { type Styling } from '@/components/props';
import { cn } from '@/components/utils';
import { useDialogsStore } from '@/stores/dialogs';
import { usePreferencesStore } from '@/stores/preferences';
import { isIOS, prepareTooltip } from '@/utils/utils';
import { useMutatingOss } from '../../../backend/use-mutating-oss';
@ -64,10 +66,12 @@ export function ToolbarOssGraph({
const { updateLayout } = useUpdateLayout();
const showOssOptions = useDialogsStore(state => state.showOssOptions);
const showOptions = useDialogsStore(state => state.showOssOptions);
const showSidePanel = usePreferencesStore(state => state.showOssSidePanel);
const toggleShowSidePanel = usePreferencesStore(state => state.toggleShowOssSidePanel);
function handleShowOptions() {
showOssOptions();
showOptions();
}
function handleSavePositions() {
@ -110,6 +114,11 @@ export function ToolbarOssGraph({
icon={<IconFitImage size='1.25rem' className='icon-primary' />}
onClick={resetView}
/>
<MiniButton
title='Панель содержания КС'
icon={<IconShowSidebar value={showSidePanel} isBottom={false} size='1.25rem' />}
onClick={toggleShowSidePanel}
/>
<MiniButton
title='Настройки отображения'
icon={<IconSettings size='1.25rem' className='icon-primary' />}

View File

@ -15,7 +15,7 @@ import { ListConstituents } from './list-constituents';
export interface DlgDeleteCstProps {
schema: IRSForm;
selected: number[];
afterDelete: (initialSchema: IRSForm, deleted: number[]) => void;
afterDelete?: (initialSchema: IRSForm, deleted: number[]) => void;
}
export function DlgDeleteCst() {
@ -31,7 +31,7 @@ export function DlgDeleteCst() {
function handleSubmit() {
const deleted = expandOut ? selected.concat(expansion) : selected;
void cstDelete({ itemID: schema.id, data: { items: deleted } }).then(() => afterDelete(schema, deleted));
void cstDelete({ itemID: schema.id, data: { items: deleted } }).then(() => afterDelete?.(schema, deleted));
}
return (
@ -54,9 +54,7 @@ export function DlgDeleteCst() {
value={expandOut}
onChange={value => setExpandOut(value)}
/>
{hasInherited ? (
<p className='text-sm clr-text-red'>Внимание! Выбранные конституенты имеют наследников в ОСС</p>
) : null}
{hasInherited ? <p className='text-sm clr-text-red'>Внимание! Конституенты имеют наследников в ОСС</p> : null}
</ModalForm>
);
}

View File

@ -12,7 +12,7 @@ export function ListConstituents({ list, schema, title, prefix }: ListConstituen
return (
<div>
{title ? (
<p className='pb-1'>
<p className='mb-1'>
{title}: <b>{list.length}</b>
</p>
) : null}

View File

@ -28,6 +28,9 @@ interface PreferencesStore {
showExpressionControls: boolean;
toggleShowExpressionControls: () => void;
showOssSidePanel: boolean;
toggleShowOssSidePanel: () => void;
}
export const usePreferencesStore = create<PreferencesStore>()(
@ -76,7 +79,10 @@ export const usePreferencesStore = create<PreferencesStore>()(
toggleShowOSSStats: () => set(state => ({ showOSSStats: !state.showOSSStats })),
showExpressionControls: true,
toggleShowExpressionControls: () => set(state => ({ showExpressionControls: !state.showExpressionControls }))
toggleShowExpressionControls: () => set(state => ({ showExpressionControls: !state.showExpressionControls })),
showOssSidePanel: false,
toggleShowOssSidePanel: () => set(state => ({ showOssSidePanel: !state.showOssSidePanel }))
}),
{
version: 1,

View File

@ -148,6 +148,12 @@
transition-duration: var(--duration-transform);
}
@utility cc-animate-panel {
transition-property: translate, opacity;
transition-timing-function: var(--ease-bezier);
transition-duration: var(--duration-transform);
}
@utility cc-animate-position {
transition-property: transform top left bottom right margin padding;
transition-timing-function: var(--ease-bezier);