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 float
id={globalIDs.tooltip} id={globalIDs.tooltip}
layer='z-topmost' layer='z-topmost'
place='right-start' place='bottom-start'
className='mt-8 max-w-80 break-words rounded-lg! select-none' offset={24}
className='max-w-80 break-words rounded-lg! select-none'
/> />
<Tooltip <Tooltip
float float

View File

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

View File

@ -15,9 +15,9 @@ export function IconShowSidebar({
} }
} else { } else {
if (value) { if (value) {
return <IconLeftClose size={size} className={className ?? 'icon-primary'} />;
} else {
return <IconLeftOpen size={size} className={className ?? 'icon-primary'} />; 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 { DiagramFlow, useReactFlow, useStoreApi } from '@/components/flow/diagram-flow';
import { useMainHeight } from '@/stores/app-layout'; import { useMainHeight } from '@/stores/app-layout';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { usePreferencesStore } from '@/stores/preferences';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { promptText } from '@/utils/labels'; 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 { OssNodeTypes } from './graph/oss-node-types';
import { CoordinateDisplay } from './coordinate-display'; import { CoordinateDisplay } from './coordinate-display';
import { useOssFlow } from './oss-flow-context'; import { useOssFlow } from './oss-flow-context';
import { SidePanel } from './side-panel';
import { ToolbarOssGraph } from './toolbar-oss-graph'; import { ToolbarOssGraph } from './toolbar-oss-graph';
import { useDragging } from './use-dragging'; import { useDragging } from './use-dragging';
import { useGetLayout } from './use-get-layout'; import { useGetLayout } from './use-get-layout';
@ -52,6 +54,7 @@ export function OssFlow() {
const showGrid = useOSSGraphStore(state => state.showGrid); const showGrid = useOSSGraphStore(state => state.showGrid);
const showCoordinates = useOSSGraphStore(state => state.showCoordinates); const showCoordinates = useOSSGraphStore(state => state.showCoordinates);
const showPanel = usePreferencesStore(state => state.showOssSidePanel);
const getLayout = useGetLayout(); const getLayout = useGetLayout();
const { updateLayout } = useUpdateLayout(); const { updateLayout } = useUpdateLayout();
@ -225,6 +228,16 @@ export function OssFlow() {
onNodeDrag={handleDrag} onNodeDrag={handleDrag}
onNodeDragStop={handleDragStop} 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> </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 { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components/badge-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 { type OssNode } from '@/features/oss/models/oss-layout';
import { MiniButton } from '@/components/control'; import { MiniButton } from '@/components/control';
@ -20,6 +21,7 @@ import {
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
import { cn } from '@/components/utils'; import { cn } from '@/components/utils';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { usePreferencesStore } from '@/stores/preferences';
import { isIOS, prepareTooltip } from '@/utils/utils'; import { isIOS, prepareTooltip } from '@/utils/utils';
import { useMutatingOss } from '../../../backend/use-mutating-oss'; import { useMutatingOss } from '../../../backend/use-mutating-oss';
@ -64,10 +66,12 @@ export function ToolbarOssGraph({
const { updateLayout } = useUpdateLayout(); 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() { function handleShowOptions() {
showOssOptions(); showOptions();
} }
function handleSavePositions() { function handleSavePositions() {
@ -110,6 +114,11 @@ export function ToolbarOssGraph({
icon={<IconFitImage size='1.25rem' className='icon-primary' />} icon={<IconFitImage size='1.25rem' className='icon-primary' />}
onClick={resetView} onClick={resetView}
/> />
<MiniButton
title='Панель содержания КС'
icon={<IconShowSidebar value={showSidePanel} isBottom={false} size='1.25rem' />}
onClick={toggleShowSidePanel}
/>
<MiniButton <MiniButton
title='Настройки отображения' title='Настройки отображения'
icon={<IconSettings size='1.25rem' className='icon-primary' />} icon={<IconSettings size='1.25rem' className='icon-primary' />}

View File

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

View File

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

View File

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

View File

@ -148,6 +148,12 @@
transition-duration: var(--duration-transform); 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 { @utility cc-animate-position {
transition-property: transform top left bottom right margin padding; transition-property: transform top left bottom right margin padding;
transition-timing-function: var(--ease-bezier); transition-timing-function: var(--ease-bezier);