Implementing basic oss graph pt2

This commit is contained in:
Ivan 2024-07-21 15:17:36 +03:00
parent 286abaf476
commit 7b39b76498
18 changed files with 495 additions and 26 deletions

View File

@ -21,6 +21,8 @@ This readme file is used mostly to document project dependencies
## ✨ Frontend [Vite + React + Typescript]
- to regenerate parsers use 'npm run generate' script
<details>
<summary>npm install</summary>
<pre>
@ -67,6 +69,7 @@ This readme file is used mostly to document project dependencies
<pre>
- ESLint
- Colorize
- Tailwind CSS IntelliSense
- Code Spell Checker (eng + rus)
- Backticks
- Svg Preview

View File

@ -45,6 +45,7 @@ class OperationCreateSerializer(serializers.Serializer):
'comment', 'position_x', 'position_y'
item_data = OperationData()
arguments = PKField(many=True, queryset=Operation.objects.all(), required=False)
positions = serializers.ListField(
child=OperationPositionSerializer(),
default=[]

View File

@ -166,6 +166,25 @@ class TestOssViewset(EndpointTester):
self.toggle_admin(True)
self.executeCreated(data=data, item=self.unowned_id)
@decl_endpoint('/api/oss/{item}/create-operation', method='post')
def test_create_operation_arguments(self):
self.populateData()
data = {
'item_data': {
'alias': 'Test4',
'operation_type': OperationType.SYNTHESIS
},
'positions': [],
'arguments': [self.operation1.pk, self.operation3.pk]
}
response = self.executeCreated(data=data, item=self.owned_id)
self.owned.item.refresh_from_db()
new_operation = response.data['new_operation']
arguments = self.owned.arguments()
self.assertTrue(arguments.filter(operation__id=new_operation['id'], argument=self.operation1))
self.assertTrue(arguments.filter(operation__id=new_operation['id'], argument=self.operation3))
@decl_endpoint('/api/oss/{item}/delete-operation', method='patch')
def test_delete_operation(self):
self.executeNotFound(item=self.invalid_id)

View File

@ -98,6 +98,9 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
with transaction.atomic():
schema.update_positions(serializer.validated_data['positions'])
new_operation = schema.create_operation(**serializer.validated_data['item_data'])
if new_operation.operation_type != m.OperationType.INPUT and 'arguments' in serializer.validated_data:
for argument in serializer.validated_data['arguments']:
schema.add_argument(operation=new_operation, argument=argument)
schema.item.refresh_from_db()
response = Response(

View File

@ -12,12 +12,13 @@ WORKDIR /result
RUN npm install -g typescript vite
COPY package.json package-lock.json ./
RUN npm ci
COPY ./ ./
COPY ./env/.env.$BUILD_TYPE ./
RUN rm -rf ./env
RUN npm ci
ENV NODE_ENV=production
RUN npm run build

View File

@ -4,7 +4,7 @@
"version": "1.0.0",
"type": "module",
"scripts": {
"prepare": "lezer-generator src/components/RSInput/rslang/rslangFull.grammar -o src/components/RSInput/rslang/parser.ts && lezer-generator src/components/RefsInput/parse/refsText.grammar -o src/components/RefsInput/parse/parser.ts",
"generate": "lezer-generator src/components/RSInput/rslang/rslangFull.grammar -o src/components/RSInput/rslang/parser.ts && lezer-generator src/components/RefsInput/parse/refsText.grammar -o src/components/RefsInput/parse/parser.ts",
"test": "jest",
"dev": "vite --host",
"build": "tsc && vite build",

View File

@ -0,0 +1,58 @@
'use client';
import clsx from 'clsx';
import { useCallback, useMemo } from 'react';
import { IOperation, OperationID } from '@/models/oss';
import { matchOperation } from '@/models/ossAPI';
import { CProps } from '../props';
import SelectSingle from '../ui/SelectSingle';
interface SelectOperationProps extends CProps.Styling {
items?: IOperation[];
value?: IOperation;
onSelectValue: (newValue?: IOperation) => void;
placeholder?: string;
}
function SelectOperation({
className,
items,
value,
onSelectValue,
placeholder = 'Выберите операцию',
...restProps
}: SelectOperationProps) {
const options = useMemo(() => {
return (
items?.map(cst => ({
value: cst.id,
label: `${cst.alias}: ${cst.title}`
})) ?? []
);
}, [items]);
const filter = useCallback(
(option: { value: OperationID | undefined; label: string }, inputValue: string) => {
const operation = items?.find(item => item.id === option.value);
return !operation ? false : matchOperation(operation, inputValue);
},
[items]
);
return (
<SelectSingle
className={clsx('text-ellipsis', className)}
options={options}
value={value ? { value: value.id, label: `${value.alias}: ${value.title}` } : undefined}
onChange={data => onSelectValue(items?.find(cst => cst.id === data?.value))}
// @ts-expect-error: TODO: use type definitions from react-select in filter object
filterOption={filter}
placeholder={placeholder}
{...restProps}
/>
);
}
export default SelectOperation;

View File

@ -25,8 +25,8 @@ function TextArea({
<div
className={clsx(
{
'flex flex-col gap-2': !dense,
'flex items-center gap-3': dense
'flex flex-col flex-grow gap-2': !dense,
'flex flex-grow items-center gap-3': dense
},
dense && className
)}

View File

@ -0,0 +1,147 @@
'use client';
import clsx from 'clsx';
import { useLayoutEffect, useMemo, useState } from 'react';
import { TabList, TabPanel, Tabs } from 'react-tabs';
import BadgeHelp from '@/components/info/BadgeHelp';
import Modal from '@/components/ui/Modal';
import Overlay from '@/components/ui/Overlay';
import TabLabel from '@/components/ui/TabLabel';
import { useLibrary } from '@/context/LibraryContext';
import { LibraryItemID } from '@/models/library';
import { HelpTopic, Position2D } from '@/models/miscellaneous';
import { IOperationCreateData, IOperationPosition, IOperationSchema, OperationID, OperationType } from '@/models/oss';
import { PARAMETER } from '@/utils/constants';
import { describeOperationType, labelOperationType } from '@/utils/labels';
import TabInputOperation from './TabInputOperation';
import TabSynthesisOperation from './TabSynthesisOperation';
interface DlgCreateOperationProps {
hideWindow: () => void;
oss: IOperationSchema;
positions: IOperationPosition[];
insertPosition: Position2D;
onCreate: (data: IOperationCreateData) => void;
}
export enum TabID {
INPUT = 0,
SYNTHESIS = 1
}
function DlgCreateOperation({ hideWindow, oss, insertPosition, positions, onCreate }: DlgCreateOperationProps) {
const library = useLibrary();
const [activeTab, setActiveTab] = useState(TabID.INPUT);
const [alias, setAlias] = useState('');
const [title, setTitle] = useState('');
const [comment, setComment] = useState('');
const [inputs, setInputs] = useState<OperationID[]>([]);
const [attachedID, setAttachedID] = useState<LibraryItemID | undefined>(undefined);
const isValid = useMemo(() => alias !== '', [alias]);
useLayoutEffect(() => {
if (attachedID) {
const schema = library.items.find(value => value.id === attachedID);
if (schema) {
setAlias(schema.alias);
setTitle(schema.title);
setComment(schema.comment);
}
}
}, [attachedID, library]);
const handleSubmit = () => {
const data: IOperationCreateData = {
item_data: {
position_x: insertPosition.x,
position_y: insertPosition.y,
alias: alias,
title: title,
comment: comment,
operation_type: activeTab === TabID.INPUT ? OperationType.INPUT : OperationType.SYNTHESIS,
result: activeTab === TabID.INPUT ? attachedID ?? null : null
},
positions: positions,
arguments: activeTab === TabID.INPUT ? undefined : inputs.length > 0 ? inputs : undefined
};
onCreate(data);
};
const inputPanel = useMemo(
() => (
<TabPanel>
<TabInputOperation
alias={alias}
setAlias={setAlias}
comment={comment}
setComment={setComment}
title={title}
setTitle={setTitle}
attachedID={attachedID}
setAttachedID={setAttachedID}
/>
</TabPanel>
),
[alias, comment, title, attachedID]
);
const synthesisPanel = useMemo(
() => (
<TabPanel>
<TabSynthesisOperation
oss={oss}
alias={alias}
setAlias={setAlias}
comment={comment}
setComment={setComment}
title={title}
setTitle={setTitle}
setInputs={setInputs}
/>
</TabPanel>
),
[oss, alias, comment, title]
);
return (
<Modal
header='Создание операции'
submitText='Создать'
hideWindow={hideWindow}
canSubmit={isValid}
onSubmit={handleSubmit}
className='w-[40rem] px-6 min-h-[35rem]'
>
<Overlay position='top-0 right-0'>
<BadgeHelp topic={HelpTopic.CC_OSS} className={clsx(PARAMETER.TOOLTIP_WIDTH, 'sm:max-w-[40rem]')} offset={14} />
</Overlay>
<Tabs
selectedTabClassName='clr-selected'
className='flex flex-col'
selectedIndex={activeTab}
onSelect={setActiveTab}
>
<TabList className={clsx('mb-3 self-center', 'flex', 'border divide-x rounded-none')}>
<TabLabel
title={describeOperationType(OperationType.INPUT)}
label={labelOperationType(OperationType.INPUT)}
/>
<TabLabel
title={describeOperationType(OperationType.SYNTHESIS)}
label={labelOperationType(OperationType.SYNTHESIS)}
/>
</TabList>
{inputPanel}
{synthesisPanel}
</Tabs>
</Modal>
);
}
export default DlgCreateOperation;

View File

@ -0,0 +1,65 @@
import PickSchema from '@/components/select/PickSchema';
import Label from '@/components/ui/Label';
import TextArea from '@/components/ui/TextArea';
import TextInput from '@/components/ui/TextInput';
import AnimateFade from '@/components/wrap/AnimateFade';
import { LibraryItemID } from '@/models/library';
import { limits, patterns } from '@/utils/constants';
interface TabInputOperationProps {
alias: string;
setAlias: React.Dispatch<React.SetStateAction<string>>;
title: string;
setTitle: React.Dispatch<React.SetStateAction<string>>;
comment: string;
setComment: React.Dispatch<React.SetStateAction<string>>;
attachedID: LibraryItemID | undefined;
setAttachedID: React.Dispatch<React.SetStateAction<LibraryItemID | undefined>>;
}
function TabInputOperation({
alias,
setAlias,
title,
setTitle,
comment,
setComment,
attachedID,
setAttachedID
}: TabInputOperationProps) {
return (
<AnimateFade className='cc-column'>
<TextInput
id='operation_title'
label='Полное название'
value={title}
onChange={event => setTitle(event.target.value)}
/>
<div className='flex gap-6'>
<TextInput
id='operation_alias'
label='Сокращение'
className='w-[14rem]'
pattern={patterns.library_alias}
title={`не более ${limits.library_alias_len} символов`}
value={alias}
onChange={event => setAlias(event.target.value)}
/>
<TextArea
id='operation_comment'
label='Описание'
noResize
rows={3}
value={comment}
onChange={event => setComment(event.target.value)}
/>
</div>
<Label text='Загружаемая концептуальная схема' />
<PickSchema value={attachedID} onSelectValue={setAttachedID} rows={8} />
</AnimateFade>
);
}
export default TabInputOperation;

View File

@ -0,0 +1,92 @@
'use client';
import { useEffect, useState } from 'react';
import SelectOperation from '@/components/select/SelectOperation';
import FlexColumn from '@/components/ui/FlexColumn';
import Label from '@/components/ui/Label';
import TextArea from '@/components/ui/TextArea';
import TextInput from '@/components/ui/TextInput';
import AnimateFade from '@/components/wrap/AnimateFade';
import { IOperation, IOperationSchema, OperationID } from '@/models/oss';
import { limits, patterns } from '@/utils/constants';
interface TabSynthesisOperationProps {
oss: IOperationSchema;
alias: string;
setAlias: React.Dispatch<React.SetStateAction<string>>;
title: string;
setTitle: React.Dispatch<React.SetStateAction<string>>;
comment: string;
setComment: React.Dispatch<React.SetStateAction<string>>;
setInputs: React.Dispatch<React.SetStateAction<OperationID[]>>;
}
function TabSynthesisOperation({
oss,
alias,
setAlias,
title,
setTitle,
comment,
setComment,
setInputs
}: TabSynthesisOperationProps) {
const [left, setLeft] = useState<IOperation | undefined>(undefined);
const [right, setRight] = useState<IOperation | undefined>(undefined);
useEffect(() => {
const inputs: OperationID[] = [];
if (left) {
inputs.push(left.id);
}
if (right) {
inputs.push(right.id);
}
setInputs(inputs);
}, [setInputs, left, right]);
return (
<AnimateFade className='cc-column'>
<TextInput
id='operation_title'
label='Полное название'
value={title}
onChange={event => setTitle(event.target.value)}
/>
<div className='flex gap-6'>
<TextInput
id='operation_alias'
label='Сокращение'
className='w-[14rem]'
pattern={patterns.library_alias}
title={`не более ${limits.library_alias_len} символов`}
value={alias}
onChange={event => setAlias(event.target.value)}
/>
<TextArea
id='operation_comment'
label='Описание'
noResize
rows={3}
value={comment}
onChange={event => setComment(event.target.value)}
/>
</div>
<div className='flex justify-between'>
<FlexColumn>
<Label text='Аргумент 1' />
<SelectOperation items={oss.items} value={left} onSelectValue={setLeft} />
</FlexColumn>
<FlexColumn>
<Label text='Аргумент 2' className='text-right' />
<SelectOperation items={oss.items} value={right} onSelectValue={setRight} />
</FlexColumn>
</div>
</AnimateFade>
);
}
export default TabSynthesisOperation;

View File

@ -0,0 +1 @@
export { default } from './DlgCreateOperation';

View File

@ -33,14 +33,25 @@ export interface IOperation {
position_x: number;
position_y: number;
result: LibraryItemID;
result: LibraryItemID | null;
}
/**
* Represents {@link IOperation} position.
*/
export interface IOperationPosition extends Pick<IOperation, 'id' | 'position_x' | 'position_y'> {}
/**
* Represents {@link IOperation} data, used in creation process.
*/
export interface IOperationCreateData
extends Pick<IOperation, 'alias' | 'operation_type' | 'title' | 'comment' | 'position_x' | 'position_y'> {}
export interface IOperationCreateData {
item_data: Pick<
IOperation,
'alias' | 'operation_type' | 'title' | 'comment' | 'position_x' | 'position_y' | 'result'
>;
arguments: OperationID[] | undefined;
positions: IOperationPosition[];
}
/**
* Represents {@link IOperation} Argument.

View File

@ -0,0 +1,18 @@
/**
* Module: API for OperationSystem.
*/
import { TextMatcher } from '@/utils/utils';
import { IOperation } from './oss';
/**
* Checks if a given target {@link IOperation} matches the specified query using.
*
* @param target - The target object to be matched.
* @param query - The query string used for matching.
*/
export function matchOperation(target: IOperation, query: string): boolean {
const matcher = new TextMatcher(query);
return matcher.test(target.alias) || matcher.test(target.title);
}

View File

@ -30,8 +30,6 @@ function OssFlow() {
const controller = useOssEdit();
const viewport = useViewport();
console.log(controller.isMutable);
const initialNodes: Node[] = useMemo(
() =>
!model.schema
@ -65,6 +63,16 @@ function OssFlow() {
const [nodes, onNodesChange] = useNodesState<Node>(initialNodes);
const [edges, onEdgesChange] = useEdgesState<Edge>(initialEdges);
const getPositions = useCallback(
() =>
nodes.map(node => ({
id: Number(node.id),
position_x: node.position.x,
position_y: node.position.y
})),
[nodes]
);
const handleNodesChange = useCallback(
(changes: NodeChange[]) => {
// @ts-expect-error TODO: Figure out internal type errors in ReactFlow
@ -83,10 +91,10 @@ function OssFlow() {
const handleCreateOperation = useCallback(() => {
// TODO: calculate insert location
controller.promptCreateOperation(viewport.x, viewport.y);
}, [controller, viewport]);
controller.promptCreateOperation(viewport.x, viewport.y, getPositions());
}, [controller, viewport, getPositions]);
const proOptions: ProOptions = { hideAttribution: true };
const proOptions: ProOptions = useMemo(() => ({ hideAttribution: true }), []);
const canvasWidth = useMemo(() => 'calc(100vw - 1rem)', []);
const canvasHeight = useMemo(() => calculateHeight('1.75rem + 4px'), [calculateHeight]);
@ -98,12 +106,8 @@ function OssFlow() {
[]
);
return (
<AnimateFade>
<Overlay position='top-0 pt-1 right-1/2 translate-x-1/2' className='rounded-b-2xl cc-blur'>
<ToolbarOssGraph onCreate={handleCreateOperation} />
</Overlay>
<div className='relative' style={{ height: canvasHeight, width: canvasWidth }}>
const graph = useMemo(
() => (
<ReactFlow
nodes={nodes}
edges={edges}
@ -113,6 +117,17 @@ function OssFlow() {
proOptions={proOptions}
nodeTypes={OssNodeTypes}
/>
),
[nodes, edges, proOptions, handleNodesChange, handleEdgesChange, OssNodeTypes]
);
return (
<AnimateFade>
<Overlay position='top-0 pt-1 right-1/2 translate-x-1/2' className='rounded-b-2xl cc-blur'>
<ToolbarOssGraph onCreate={handleCreateOperation} />
</Overlay>
<div className='relative' style={{ height: canvasHeight, width: canvasWidth }}>
{graph}
</div>
</AnimateFade>
);

View File

@ -19,7 +19,7 @@ function ToolbarOssGraph({ onCreate }: ToolbarOssGraphProps) {
<div className='cc-icons'>
{controller.isMutable ? (
<MiniButton
title='Новая конституента'
title='Новая операция'
icon={<IconNewItem size='1.25rem' className='icon-green' />}
disabled={controller.isProcessing}
onClick={onCreate}

View File

@ -9,10 +9,11 @@ import { useAuth } from '@/context/AuthContext';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useOSS } from '@/context/OssContext';
import DlgChangeLocation from '@/dialogs/DlgChangeLocation';
import DlgCreateOperation from '@/dialogs/DlgCreateOperation';
import DlgEditEditors from '@/dialogs/DlgEditEditors';
import { AccessPolicy } from '@/models/library';
import { Position2D } from '@/models/miscellaneous';
import { IOperationCreateData, IOperationSchema } from '@/models/oss';
import { IOperationCreateData, IOperationPosition, IOperationSchema } from '@/models/oss';
import { UserID, UserLevel } from '@/models/user';
import { information } from '@/utils/labels';
@ -30,7 +31,7 @@ export interface IOssEditContext {
share: () => void;
promptCreateOperation: (x: number, y: number) => void;
promptCreateOperation: (x: number, y: number, positions: IOperationPosition[]) => void;
}
const OssEditContext = createContext<IOssEditContext | null>(null);
@ -64,6 +65,7 @@ export const OssEditState = ({ children }: OssEditStateProps) => {
const [showCreateOperation, setShowCreateOperation] = useState(false);
const [insertPosition, setInsertPosition] = useState<Position2D>({ x: 0, y: 0 });
const [positions, setPositions] = useState<IOperationPosition[]>([]);
useLayoutEffect(
() =>
@ -142,8 +144,9 @@ export const OssEditState = ({ children }: OssEditStateProps) => {
[model]
);
const promptCreateOperation = useCallback((x: number, y: number) => {
const promptCreateOperation = useCallback((x: number, y: number, positions: IOperationPosition[]) => {
setInsertPosition({ x: x, y: y });
setPositions(positions);
setShowCreateOperation(true);
}, []);
@ -188,6 +191,15 @@ export const OssEditState = ({ children }: OssEditStateProps) => {
onChangeLocation={handleSetLocation}
/>
) : null}
{showCreateOperation ? (
<DlgCreateOperation
hideWindow={() => setShowCreateOperation(false)}
oss={model.schema}
positions={positions}
insertPosition={insertPosition}
onCreate={handleCreateOperation}
/>
) : null}
</AnimatePresence>
) : null}

View File

@ -10,6 +10,7 @@ import { GramData, Grammeme, ReferenceType } from '@/models/language';
import { AccessPolicy, LibraryItemType, LocationHead } from '@/models/library';
import { validateLocation } from '@/models/libraryAPI';
import { CstMatchMode, DependencyMode, GraphColoring, GraphSizing, HelpTopic } from '@/models/miscellaneous';
import { OperationType } from '@/models/oss';
import { CstClass, CstType, ExpressionStatus, IConstituenta, IRSForm } from '@/models/rsform';
import {
IArgumentInfo,
@ -889,6 +890,28 @@ export function describeLibraryItemType(itemType: LibraryItemType): string {
}
}
/**
* Retrieves label for {@link OperationType}.
*/
export function labelOperationType(itemType: OperationType): string {
// prettier-ignore
switch (itemType) {
case OperationType.INPUT: return 'Загрузка';
case OperationType.SYNTHESIS: return 'Синтез';
}
}
/**
* Retrieves description for {@link OperationType}.
*/
export function describeOperationType(itemType: OperationType): string {
// prettier-ignore
switch (itemType) {
case OperationType.INPUT: return 'Загрузка концептуальной схемы в ОСС';
case OperationType.SYNTHESIS: return 'Синтез концептуальных схем';
}
}
/**
* UI info descriptors.
*/