F: Implement association editing UI and fix dialogs caching

This commit is contained in:
Ivan 2025-08-12 22:56:12 +03:00
parent 41e0ba64ba
commit 03ade4fee1
31 changed files with 344 additions and 118 deletions

View File

@ -1,5 +1,7 @@
'use client';
import assert from 'assert';
import { useRef, useState } from 'react';
import { ChevronDownIcon } from 'lucide-react';
@ -9,20 +11,32 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { cn } from '../utils';
interface ComboMultiProps<Option> extends Styling {
interface ComboMultiPropsBase<Option> extends Styling {
id?: string;
items?: Option[];
value: Option[];
onChange: (newValue: Option[]) => void;
idFunc: (item: Option) => string;
labelValueFunc: (item: Option) => string;
labelOptionFunc: (item: Option) => string;
disabled?: boolean;
placeholder?: string;
noSearch?: boolean;
}
interface ComboMultiPropsFull<Option> extends ComboMultiPropsBase<Option> {
onChange: (newValue: Option[]) => void;
}
interface ComboMultiPropsSplit<Option> extends ComboMultiPropsBase<Option> {
onClear: () => void;
onAdd: (item: Option) => void;
onRemove: (item: Option) => void;
}
type ComboMultiProps<Option> = ComboMultiPropsFull<Option> | ComboMultiPropsSplit<Option>;
/**
* Displays a combo-box component with multiple selection.
*/
@ -30,14 +44,15 @@ export function ComboMulti<Option>({
id,
items,
value,
onChange,
labelValueFunc,
labelOptionFunc,
idFunc,
placeholder,
className,
style,
noSearch
disabled,
noSearch,
...restProps
}: ComboMultiProps<Option>) {
const [open, setOpen] = useState(false);
const [popoverWidth, setPopoverWidth] = useState<number | undefined>(undefined);
@ -54,19 +69,34 @@ export function ComboMulti<Option>({
if (value.includes(newValue)) {
handleRemoveValue(newValue);
} else {
onChange([...value, newValue]);
if ('onAdd' in restProps && typeof restProps.onAdd === 'function') {
restProps.onAdd(newValue);
} else {
assert('onChange' in restProps);
restProps.onChange([...value, newValue]);
}
setOpen(false);
}
}
function handleRemoveValue(delValue: Option) {
onChange(value.filter(v => v !== delValue));
if ('onRemove' in restProps && typeof restProps.onRemove === 'function') {
restProps.onRemove(delValue);
} else {
assert('onChange' in restProps);
restProps.onChange(value.filter(v => v !== delValue));
}
setOpen(false);
}
function handleClear(event: React.MouseEvent<SVGElement>) {
event.stopPropagation();
onChange([]);
if ('onClear' in restProps && typeof restProps.onClear === 'function') {
restProps.onClear();
} else {
assert('onChange' in restProps);
restProps.onChange([]);
}
setOpen(false);
}
@ -81,7 +111,7 @@ export function ComboMulti<Option>({
className={cn(
'relative h-9',
'flex gap-2 px-3 py-2 items-center justify-between',
'bg-input disabled:opacity-50',
'bg-input disabled:bg-transparent',
'cursor-pointer disabled:cursor-auto',
'whitespace-nowrap',
'focus-outline border',
@ -91,32 +121,39 @@ export function ComboMulti<Option>({
className
)}
style={style}
disabled={disabled}
>
<div className='flex flex-wrap gap-1 items-center'>
<div className='flex flex-wrap gap-2 items-center'>
{value.length === 0 ? <div className='text-muted-foreground'>{placeholder}</div> : null}
{value.map(item => (
<div key={idFunc(item)} className='flex px-1 items-center border rounded-lg bg-accent text-sm'>
{labelValueFunc(item)}
<IconRemove
tabIndex={-1}
size='1rem'
className='cc-remove cc-hover-pulse'
onClick={event => {
event.stopPropagation();
handleRemoveValue(item);
}}
/>
{!disabled ? (
<IconRemove
tabIndex={-1}
size='1rem'
className='cc-remove cc-hover-pulse'
onClick={
disabled
? undefined
: event => {
event.stopPropagation();
handleRemoveValue(item);
}
}
/>
) : null}
</div>
))}
</div>
<ChevronDownIcon className={cn('text-muted-foreground', !!value && 'opacity-0')} />
{!!value ? (
{!!value && !disabled ? (
<IconRemove
tabIndex={-1}
size='1rem'
className='cc-remove absolute pointer-events-auto right-3 cc-hover-pulse hover:text-primary'
onClick={handleClear}
onClick={value.length === 0 ? undefined : handleClear}
/>
) : null}
</button>
@ -127,16 +164,19 @@ export function ComboMulti<Option>({
<CommandList>
<CommandEmpty>Список пуст</CommandEmpty>
<CommandGroup>
{items?.map(item => (
<CommandItem
key={idFunc(item)}
value={labelOptionFunc(item)}
onSelect={() => handleAddValue(item)}
className={cn(value === item && 'bg-selected text-selected-foreground')}
>
{labelOptionFunc(item)}
</CommandItem>
))}
{items
?.filter(item => !value.includes(item))
.map(item => (
<CommandItem
key={idFunc(item)}
value={labelOptionFunc(item)}
onSelect={() => handleAddValue(item)}
disabled={disabled}
className={cn(value === item && 'bg-selected text-selected-foreground')}
>
{labelOptionFunc(item)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>

View File

@ -3,7 +3,7 @@
import { ReactFlowProvider } from 'reactflow';
import { urls, useConceptNavigation } from '@/app';
import { type IRSForm } from '@/features/rsform';
import { useRSFormSuspense } from '@/features/rsform/backend/use-rsform';
import { RSTabID } from '@/features/rsform/pages/rsform-page/rsedit-context';
import { MiniButton } from '@/components/control';
@ -14,11 +14,12 @@ import { useDialogsStore } from '@/stores/dialogs';
import { TGReadonlyFlow } from './tg-readonly-flow';
export interface DlgShowTermGraphProps {
schema: IRSForm;
schemaID: number;
}
export function DlgShowTermGraph() {
const { schema } = useDialogsStore(state => state.props as DlgShowTermGraphProps);
const { schemaID } = useDialogsStore(state => state.props as DlgShowTermGraphProps);
const { schema } = useRSFormSuspense({ itemID: schemaID });
const hideDialog = useDialogsStore(state => state.hideDialog);
const router = useConceptNavigation();

View File

@ -98,7 +98,7 @@ export function ToolbarSchema({
convention: '',
term_forms: []
};
showCreateCst({ schema: schema, onCreate: onCreateCst, initial: data });
showCreateCst({ schemaID: schema.id, onCreate: onCreateCst, initial: data });
}
function cloneCst() {
@ -126,7 +126,7 @@ export function ToolbarSchema({
return;
}
showDeleteCst({
schema: schema,
schemaID: schema.id,
selected: [activeCst.id],
afterDelete: resetActive
});
@ -192,7 +192,7 @@ export function ToolbarSchema({
}
function handleShowTermGraph() {
showTermGraph({ schema: schema });
showTermGraph({ schemaID: schema.id });
}
function handleReindex() {

View File

@ -33,7 +33,7 @@ export function ViewSchema({ schemaID, isMutable }: ViewSchemaProps) {
}, [schema, setCurrentSchema]);
function handleEditCst(cst: IConstituenta) {
showEditCst({ schema: schema, target: cst });
showEditCst({ schemaID: schema.id, targetID: cst.id });
}
return (

View File

@ -5,7 +5,7 @@ import { DELAYS, KEYS } from '@/backend/configuration';
import { infoMsg } from '@/utils/labels';
import {
type IAssociationDataDTO,
type IAssociation,
type IAssociationTargetDTO,
type ICheckConstituentaDTO,
type IConstituentaCreatedResponse,
@ -154,8 +154,8 @@ export const rsformsApi = {
request: { data: data }
}),
createAssociation: ({ itemID, data }: { itemID: number; data: IAssociationDataDTO }) =>
axiosPost<IAssociationDataDTO, IRSFormDTO>({
createAssociation: ({ itemID, data }: { itemID: number; data: IAssociation }) =>
axiosPost<IAssociation, IRSFormDTO>({
schema: schemaRSForm,
endpoint: `/api/rsforms/${itemID}/create-association`,
request: {
@ -163,8 +163,8 @@ export const rsformsApi = {
successMessage: infoMsg.changesSaved
}
}),
deleteAssociation: ({ itemID, data }: { itemID: number; data: IAssociationDataDTO }) =>
axiosPatch<IAssociationDataDTO, IRSFormDTO>({
deleteAssociation: ({ itemID, data }: { itemID: number; data: IAssociation }) =>
axiosPatch<IAssociation, IRSFormDTO>({
schema: schemaRSForm,
endpoint: `/api/rsforms/${itemID}/delete-association`,
request: {

View File

@ -21,6 +21,8 @@ import { CstType, type IRSFormDTO, ParsingStatus, ValueClass } from './types';
export class RSFormLoader {
private schema: IRSForm;
private graph: Graph = new Graph();
private association_graph: Graph = new Graph();
private full_graph: Graph = new Graph();
private cstByAlias = new Map<string, IConstituenta>();
private cstByID = new Map<number, IConstituenta>();
@ -39,6 +41,8 @@ export class RSFormLoader {
result.graph = this.graph;
result.cstByAlias = this.cstByAlias;
result.cstByID = this.cstByID;
result.association_graph = this.association_graph;
result.full_graph = this.full_graph;
return result;
}
@ -47,6 +51,8 @@ export class RSFormLoader {
this.cstByAlias.set(cst.alias, cst);
this.cstByID.set(cst.id, cst);
this.graph.addNode(cst.id);
this.association_graph.addNode(cst.id);
this.full_graph.addNode(cst.id);
});
}
@ -57,6 +63,7 @@ export class RSFormLoader {
const source = this.cstByAlias.get(alias);
if (source) {
this.graph.addEdge(source.id, cst.id);
this.full_graph.addEdge(source.id, cst.id);
}
});
});
@ -83,6 +90,7 @@ export class RSFormLoader {
cst.is_template = inferTemplate(cst.definition_formal);
cst.cst_class = inferClass(cst.cst_type, cst.is_template);
cst.spawn = [];
cst.associations = [];
cst.spawn_alias = [];
cst.parent_schema = schemaByCst.get(cst.id);
cst.parent_schema_index = cst.parent_schema ? parents.indexOf(cst.parent_schema) + 1 : 0;
@ -102,6 +110,12 @@ export class RSFormLoader {
parent.spawn_alias.push(cst.alias);
}
});
this.schema.association.forEach(assoc => {
const container = this.cstByID.get(assoc.container)!;
container.associations.push(assoc.associate);
this.full_graph.addEdge(container.id, assoc.associate);
this.association_graph.addEdge(container.id, assoc.associate);
});
}
private inferSimpleExpression(target: IConstituenta): boolean {

View File

@ -95,7 +95,7 @@ export interface ICheckConstituentaDTO {
export type ISubstitutionsDTO = z.infer<typeof schemaSubstitutions>;
/** Represents data for creating or deleting an association. */
export type IAssociationDataDTO = z.infer<typeof schemaAssociationData>;
export type IAssociation = z.infer<typeof schemaAssociation>;
/** Represents data for clearing all associations for a target constituenta. */
export type IAssociationTargetDTO = z.infer<typeof schemaAssociationTarget>;
@ -308,6 +308,11 @@ export const schemaConstituenta = schemaConstituentaBasics.extend({
.optional()
});
export const schemaAssociation = z.strictObject({
container: z.number(),
associate: z.number()
});
export const schemaRSForm = schemaLibraryItem.extend({
editors: z.array(z.number()),
@ -315,7 +320,7 @@ export const schemaRSForm = schemaLibraryItem.extend({
versions: z.array(schemaVersionInfo),
items: z.array(schemaConstituenta),
association: z.array(z.strictObject({ container: z.number(), associate: z.number() })),
association: z.array(schemaAssociation),
inheritance: z.array(
z.strictObject({
child: z.number(),
@ -392,11 +397,6 @@ export const schemaSubstitutions = z.strictObject({
substitutions: z.array(schemaSubstituteConstituents).min(1, { message: errorMsg.emptySubstitutions })
});
export const schemaAssociationData = z.strictObject({
container: z.number(),
associate: z.number()
});
export const schemaAssociationTarget = z.strictObject({
target: z.number()
});

View File

@ -5,7 +5,7 @@ import { useUpdateTimestamp } from '@/features/library/backend/use-update-timest
import { KEYS } from '@/backend/configuration';
import { rsformsApi } from './api';
import { type IAssociationDataDTO } from './types';
import { type IAssociation } from './types';
export const useCreateAssociation = () => {
const client = useQueryClient();
@ -24,6 +24,6 @@ export const useCreateAssociation = () => {
onError: () => client.invalidateQueries()
});
return {
createAssociation: (data: { itemID: number; data: IAssociationDataDTO }) => mutation.mutateAsync(data)
createAssociation: (data: { itemID: number; data: IAssociation }) => mutation.mutateAsync(data)
};
};

View File

@ -5,7 +5,7 @@ import { useUpdateTimestamp } from '@/features/library/backend/use-update-timest
import { KEYS } from '@/backend/configuration';
import { rsformsApi } from './api';
import { type IAssociationDataDTO } from './types';
import { type IAssociation } from './types';
export const useDeleteAssociation = () => {
const client = useQueryClient();
@ -24,6 +24,6 @@ export const useDeleteAssociation = () => {
onError: () => client.invalidateQueries()
});
return {
deleteAssociation: (data: { itemID: number; data: IAssociationDataDTO }) => mutation.mutateAsync(data)
deleteAssociation: (data: { itemID: number; data: IAssociation }) => mutation.mutateAsync(data)
};
};

View File

@ -175,7 +175,7 @@ export const RefsInput = forwardRef<ReactCodeMirrorRef, RefsInputInputProps>(
setIsEditing(true);
showEditReference({
schema: schema,
schemaID: schema.id,
initial: data,
onCancel: () => {
setIsEditing(false);

View File

@ -0,0 +1,34 @@
import { ComboMulti } from '@/components/input/combo-multi';
import { type Styling } from '@/components/props';
import { labelConstituenta } from '../labels';
import { type IConstituenta } from '../models/rsform';
interface SelectMultiCstProps extends Styling {
id?: string;
value: IConstituenta[];
items: IConstituenta[];
onClear: () => void;
onAdd: (item: IConstituenta) => void;
onRemove: (item: IConstituenta) => void;
placeholder?: string;
disabled?: boolean;
}
export function SelectMultiConstituenta({ value, items, onClear, onAdd, onRemove, ...restProps }: SelectMultiCstProps) {
return (
<ComboMulti
noSearch
items={items}
value={value}
onClear={onClear}
onAdd={onAdd}
onRemove={onRemove}
idFunc={cst => String(cst.id)}
labelOptionFunc={cst => labelConstituenta(cst)}
labelValueFunc={cst => cst.alias}
{...restProps}
/>
);
}

View File

@ -14,20 +14,21 @@ import {
schemaCreateConstituenta
} from '../../backend/types';
import { useCreateConstituenta } from '../../backend/use-create-constituenta';
import { type IRSForm } from '../../models/rsform';
import { useRSFormSuspense } from '../../backend/use-rsform';
import { validateNewAlias } from '../../models/rsform-api';
import { FormCreateCst } from './form-create-cst';
export interface DlgCreateCstProps {
initial: ICreateConstituentaDTO;
schema: IRSForm;
schemaID: number;
onCreate: (data: RO<IConstituentaBasicsDTO>) => void;
}
export function DlgCreateCst() {
const { initial, schema, onCreate } = useDialogsStore(state => state.props as DlgCreateCstProps);
const { initial, schemaID, onCreate } = useDialogsStore(state => state.props as DlgCreateCstProps);
const { createConstituenta: cstCreate } = useCreateConstituenta();
const { schema } = useRSFormSuspense({ itemID: schemaID });
const methods = useForm<ICreateConstituentaDTO>({
resolver: zodResolver(schemaCreateConstituenta),

View File

@ -19,7 +19,7 @@ import {
schemaCreateConstituenta
} from '../../backend/types';
import { useCreateConstituenta } from '../../backend/use-create-constituenta';
import { type IRSForm } from '../../models/rsform';
import { useRSFormSuspense } from '../../backend/use-rsform';
import { generateAlias, validateNewAlias } from '../../models/rsform-api';
import { FormCreateCst } from '../dlg-create-cst/form-create-cst';
@ -28,7 +28,7 @@ import { TabTemplate } from './tab-template';
import { TemplateState } from './template-state';
export interface DlgCstTemplateProps {
schema: IRSForm;
schemaID: number;
onCreate: (data: RO<IConstituentaBasicsDTO>) => void;
insertAfter?: number;
}
@ -41,8 +41,9 @@ export const TabID = {
export type TabID = (typeof TabID)[keyof typeof TabID];
export function DlgCstTemplate() {
const { schema, onCreate, insertAfter } = useDialogsStore(state => state.props as DlgCstTemplateProps);
const { schemaID, onCreate, insertAfter } = useDialogsStore(state => state.props as DlgCstTemplateProps);
const { createConstituenta: cstCreate } = useCreateConstituenta();
const { schema } = useRSFormSuspense({ itemID: schemaID });
const methods = useForm<ICreateConstituentaDTO>({
resolver: zodResolver(schemaCreateConstituenta),
@ -92,7 +93,7 @@ export function DlgCstTemplate() {
</TabPanel>
<TabPanel>
<TabArguments />
<TabArguments schema={schema} />
</TabPanel>
<TabPanel>

View File

@ -8,21 +8,22 @@ import { MiniButton } from '@/components/control';
import { DataTable, type IConditionalStyle } from '@/components/data-table';
import { IconAccept, IconRemove, IconReset } from '@/components/icons';
import { NoData } from '@/components/view';
import { useDialogsStore } from '@/stores/dialogs';
import { type ICreateConstituentaDTO } from '../../backend/types';
import { PickConstituenta } from '../../components/pick-constituenta';
import { RSInput } from '../../components/rs-input';
import { type IConstituenta } from '../../models/rsform';
import { type IConstituenta, type IRSForm } from '../../models/rsform';
import { type IArgumentValue } from '../../models/rslang';
import { type DlgCstTemplateProps } from './dlg-cst-template';
import { useTemplateContext } from './template-context';
const argumentsHelper = createColumnHelper<IArgumentValue>();
export function TabArguments() {
const { schema } = useDialogsStore(state => state.props as DlgCstTemplateProps);
interface TabArgumentsProps {
schema: IRSForm;
}
export function TabArguments({ schema }: TabArgumentsProps) {
const { control } = useFormContext<ICreateConstituentaDTO>();
const { args, onChangeArguments } = useTemplateContext();
const definition = useWatch({ control, name: 'definition_formal' });

View File

@ -6,6 +6,7 @@ import { useFormContext } from 'react-hook-form';
import { useDialogsStore } from '@/stores/dialogs';
import { type ICreateConstituentaDTO } from '../../backend/types';
import { useRSFormSuspense } from '../../backend/use-rsform';
import { type IConstituenta } from '../../models/rsform';
import { generateAlias } from '../../models/rsform-api';
import { type IArgumentValue } from '../../models/rslang';
@ -15,7 +16,8 @@ import { type DlgCstTemplateProps } from './dlg-cst-template';
import { TemplateContext } from './template-context';
export const TemplateState = ({ children }: React.PropsWithChildren) => {
const { schema } = useDialogsStore(state => state.props as DlgCstTemplateProps);
const { schemaID } = useDialogsStore(state => state.props as DlgCstTemplateProps);
const { schema } = useRSFormSuspense({ itemID: schemaID });
const { setValue } = useFormContext<ICreateConstituentaDTO>();
const [templateID, setTemplateID] = useState<number | null>(null);
const [args, setArguments] = useState<IArgumentValue[]>([]);

View File

@ -8,19 +8,21 @@ import { useDialogsStore } from '@/stores/dialogs';
import { prefixes } from '@/utils/constants';
import { useDeleteConstituents } from '../../backend/use-delete-constituents';
import { useRSFormSuspense } from '../../backend/use-rsform';
import { type IRSForm } from '../../models/rsform';
import { ListConstituents } from './list-constituents';
export interface DlgDeleteCstProps {
schema: IRSForm;
schemaID: number;
selected: number[];
afterDelete?: (initialSchema: IRSForm, deleted: number[]) => void;
}
export function DlgDeleteCst() {
const { selected, schema, afterDelete } = useDialogsStore(state => state.props as DlgDeleteCstProps);
const { selected, schemaID, afterDelete } = useDialogsStore(state => state.props as DlgDeleteCstProps);
const { deleteConstituents: cstDelete } = useDeleteConstituents();
const { schema } = useRSFormSuspense({ itemID: schemaID });
const [expandOut, setExpandOut] = useState(false);
const expansion: number[] = schema.graph.expandAllOutputs(selected);
@ -31,7 +33,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: schemaID, data: { items: deleted } }).then(() => afterDelete?.(schema, deleted));
}
return (

View File

@ -13,20 +13,22 @@ import { useDialogsStore } from '@/stores/dialogs';
import { errorMsg } from '@/utils/labels';
import { type IUpdateConstituentaDTO, schemaUpdateConstituenta } from '../../backend/types';
import { useRSFormSuspense } from '../../backend/use-rsform';
import { useUpdateConstituenta } from '../../backend/use-update-constituenta';
import { type IConstituenta, type IRSForm } from '../../models/rsform';
import { validateNewAlias } from '../../models/rsform-api';
import { RSTabID } from '../../pages/rsform-page/rsedit-context';
import { FormEditCst } from './form-edit-cst';
export interface DlgEditCstProps {
schema: IRSForm;
target: IConstituenta;
schemaID: number;
targetID: number;
}
export function DlgEditCst() {
const { schema, target } = useDialogsStore(state => state.props as DlgEditCstProps);
const { schemaID, targetID } = useDialogsStore(state => state.props as DlgEditCstProps);
const { schema } = useRSFormSuspense({ itemID: schemaID });
const target = schema.cstByID.get(targetID)!;
const hideDialog = useDialogsStore(state => state.hideDialog);
const { updateConstituenta } = useUpdateConstituenta();
const router = useConceptNavigation();

View File

@ -5,12 +5,16 @@ import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components/badge-help';
import { MiniButton } from '@/components/control';
import { TextArea, TextInput } from '@/components/input';
import { Label, TextArea, TextInput } from '@/components/input';
import { CstType, type IUpdateConstituentaDTO } from '../../backend/types';
import { useClearAssociations } from '../../backend/use-clear-associations';
import { useCreateAssociation } from '../../backend/use-create-association';
import { useDeleteAssociation } from '../../backend/use-delete-association';
import { IconCrucialValue } from '../../components/icon-crucial-value';
import { RSInput } from '../../components/rs-input';
import { SelectCstType } from '../../components/select-cst-type';
import { SelectMultiConstituenta } from '../../components/select-multi-constituenta';
import { getRSDefinitionPlaceholder, labelCstTypification, labelRSExpression } from '../../labels';
import { type IConstituenta, type IRSForm } from '../../models/rsform';
import { generateAlias, isBaseSet, isBasicConcept } from '../../models/rsform-api';
@ -21,6 +25,10 @@ interface FormEditCstProps {
}
export function FormEditCst({ target, schema }: FormEditCstProps) {
const { createAssociation } = useCreateAssociation();
const { deleteAssociation } = useDeleteAssociation();
const { clearAssociations } = useClearAssociations();
const {
setValue,
control,
@ -36,6 +44,7 @@ export function FormEditCst({ target, schema }: FormEditCstProps) {
const isBasic = isBasicConcept(cst_type) || cst_type === CstType.NOMINAL;
const isElementary = isBaseSet(cst_type);
const showConvention = !!convention || forceComment || isBasic;
const associations = target.associations.map(id => schema.cstByID.get(id)!);
function handleTypeChange(newValue: CstType) {
setValue('item_data.cst_type', newValue);
@ -47,6 +56,35 @@ export function FormEditCst({ target, schema }: FormEditCstProps) {
setValue('item_data.crucial', !crucial);
}
function handleAddAssociation(item: IConstituenta) {
void createAssociation({
itemID: schema.id,
data: {
container: target.id,
associate: item.id
}
});
}
function handleRemoveAssociation(item: IConstituenta) {
void deleteAssociation({
itemID: schema.id,
data: {
container: target.id,
associate: item.id
}
});
}
function handleClearAssociations() {
void clearAssociations({
itemID: schema.id,
data: {
target: target.id
}
});
}
return (
<>
<div className='flex items-center self-center gap-3'>
@ -83,6 +121,20 @@ export function FormEditCst({ target, schema }: FormEditCstProps) {
error={errors.item_data?.term_raw}
/>
{target.cst_type === CstType.NOMINAL || target.associations.length > 0 ? (
<div className='flex flex-col gap-1'>
<Label text='Ассоциируемые конституенты' />
<SelectMultiConstituenta
items={schema.items.filter(item => item.id !== target.id)}
value={associations}
onAdd={handleAddAssociation}
onClear={handleClearAssociations}
onRemove={handleRemoveAssociation}
placeholder={'Выберите конституенты'}
/>
</div>
) : null}
{cst_type !== CstType.NOMINAL ? (
<TextArea
id='cst_typification'

View File

@ -20,7 +20,6 @@ import {
supportedGrammemes
} from '../../models/language';
import { parseEntityReference, parseGrammemes, parseSyntacticReference } from '../../models/language-api';
import { type IRSForm } from '../../models/rsform';
import { TabEntityReference } from './tab-entity-reference';
import { TabSyntacticReference } from './tab-syntactic-reference';
@ -51,7 +50,7 @@ const schemaEditReferenceState = z
export type IEditReferenceState = z.infer<typeof schemaEditReferenceState>;
export interface DlgEditReferenceProps {
schema: IRSForm;
schemaID: number;
initial: IReferenceInputState;
onSave: (newRef: IReference) => void;
onCancel: () => void;

View File

@ -5,6 +5,7 @@ import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { Label, TextInput } from '@/components/input';
import { useDialogsStore } from '@/stores/dialogs';
import { useRSFormSuspense } from '../../backend/use-rsform';
import { PickConstituenta } from '../../components/pick-constituenta';
import { SelectMultiGrammeme } from '../../components/select-multi-grammeme';
import { SelectWordForm } from '../../components/select-word-form';
@ -15,7 +16,8 @@ import { CstMatchMode } from '../../stores/cst-search';
import { type DlgEditReferenceProps, type IEditReferenceState } from './dlg-edit-reference';
export function TabEntityReference() {
const { schema, initial } = useDialogsStore(state => state.props as DlgEditReferenceProps);
const { schemaID, initial } = useDialogsStore(state => state.props as DlgEditReferenceProps);
const { schema } = useRSFormSuspense({ itemID: schemaID });
const { setValue, control, register } = useFormContext<IEditReferenceState>();
const alias = useWatch({ control, name: 'entity.entity' });

View File

@ -15,21 +15,23 @@ import { useGenerateLexeme } from '../../backend/cctext/use-generate-lexeme';
import { useInflectText } from '../../backend/cctext/use-inflect-text';
import { useIsProcessingCctext } from '../../backend/cctext/use-is-processing-cctext';
import { useParseText } from '../../backend/cctext/use-parse-text';
import { useRSFormSuspense } from '../../backend/use-rsform';
import { useUpdateConstituenta } from '../../backend/use-update-constituenta';
import { SelectMultiGrammeme } from '../../components/select-multi-grammeme';
import { type Grammeme, type IWordForm, supportedGrammemes } from '../../models/language';
import { parseGrammemes, wordFormEquals } from '../../models/language-api';
import { type IConstituenta } from '../../models/rsform';
import { TableWordForms } from './table-word-forms';
export interface DlgEditWordFormsProps {
itemID: number;
target: IConstituenta;
targetID: number;
}
export function DlgEditWordForms() {
const { itemID, target } = useDialogsStore(state => state.props as DlgEditWordFormsProps);
const { itemID, targetID } = useDialogsStore(state => state.props as DlgEditWordFormsProps);
const { schema } = useRSFormSuspense({ itemID: itemID });
const target = schema.cstByID.get(targetID)!;
const { updateConstituenta: cstUpdate } = useUpdateConstituenta();
const isProcessing = useIsProcessingCctext();

View File

@ -11,14 +11,14 @@ import { useDialogsStore } from '@/stores/dialogs';
import { type IInlineSynthesisDTO, schemaInlineSynthesis } from '../../backend/types';
import { useInlineSynthesis } from '../../backend/use-inline-synthesis';
import { type IRSForm } from '../../models/rsform';
import { useRSFormSuspense } from '../../backend/use-rsform';
import { TabConstituents } from './tab-constituents';
import { TabSource } from './tab-source';
import { TabSubstitutions } from './tab-substitutions';
export interface DlgInlineSynthesisProps {
receiver: IRSForm;
receiverID: number;
onSynthesis: () => void;
}
@ -30,9 +30,10 @@ export const TabID = {
export type TabID = (typeof TabID)[keyof typeof TabID];
export function DlgInlineSynthesis() {
const { receiver, onSynthesis } = useDialogsStore(state => state.props as DlgInlineSynthesisProps);
const { receiverID, onSynthesis } = useDialogsStore(state => state.props as DlgInlineSynthesisProps);
const [activeTab, setActiveTab] = useState<TabID>(TabID.SCHEMA);
const { inlineSynthesis } = useInlineSynthesis();
const { schema: receiver } = useRSFormSuspense({ itemID: receiverID });
const methods = useForm<IInlineSynthesisDTO>({
resolver: zodResolver(schemaInlineSynthesis),
@ -81,7 +82,7 @@ export function DlgInlineSynthesis() {
<FormProvider {...methods}>
<TabPanel>
<TabSource />
<TabSource receiver={receiver} />
</TabPanel>
<TabPanel>
@ -95,7 +96,7 @@ export function DlgInlineSynthesis() {
<TabPanel>
{!!sourceID ? (
<Suspense fallback={<Loader />}>
<TabSubstitutions />
<TabSubstitutions receiver={receiver} />
</Suspense>
) : null}
</TabPanel>

View File

@ -7,16 +7,17 @@ import { useLibrary } from '@/features/library/backend/use-library';
import { PickSchema } from '@/features/library/components/pick-schema';
import { TextInput } from '@/components/input';
import { useDialogsStore } from '@/stores/dialogs';
import { type IInlineSynthesisDTO } from '../../backend/types';
import { type IRSForm } from '../../models/rsform';
import { sortItemsForInlineSynthesis } from '../../models/rsform-api';
import { type DlgInlineSynthesisProps } from './dlg-inline-synthesis';
interface TabSourceProps {
receiver: IRSForm;
}
export function TabSource() {
export function TabSource({ receiver }: TabSourceProps) {
const { items: libraryItems } = useLibrary();
const { receiver } = useDialogsStore(state => state.props as DlgInlineSynthesisProps);
const { setValue, control } = useFormContext<IInlineSynthesisDTO>();
const sourceID = useWatch({ control, name: 'source' });

View File

@ -2,16 +2,16 @@
import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { useDialogsStore } from '@/stores/dialogs';
import { type IInlineSynthesisDTO } from '../../backend/types';
import { useRSFormSuspense } from '../../backend/use-rsform';
import { PickSubstitutions } from '../../components/pick-substitutions';
import { type IRSForm } from '../../models/rsform';
import { type DlgInlineSynthesisProps } from './dlg-inline-synthesis';
interface TabSubstitutionsProps {
receiver: IRSForm;
}
export function TabSubstitutions() {
const { receiver } = useDialogsStore(state => state.props as DlgInlineSynthesisProps);
export function TabSubstitutions({ receiver }: TabSubstitutionsProps) {
const { control } = useFormContext<IInlineSynthesisDTO>();
const sourceID = useWatch({ control, name: 'source' });
const selected = useWatch({ control, name: 'items' });

View File

@ -10,24 +10,26 @@ import { ModalForm } from '@/components/modal';
import { useDialogsStore } from '@/stores/dialogs';
import { type CstType, type IUpdateConstituentaDTO, schemaUpdateConstituenta } from '../backend/types';
import { useRSFormSuspense } from '../backend/use-rsform';
import { useUpdateConstituenta } from '../backend/use-update-constituenta';
import { SelectCstType } from '../components/select-cst-type';
import { type IConstituenta, type IRSForm } from '../models/rsform';
import { generateAlias, validateNewAlias } from '../models/rsform-api';
export interface DlgRenameCstProps {
schema: IRSForm;
target: IConstituenta;
schemaID: number;
targetID: number;
}
export function DlgRenameCst() {
const { schema, target } = useDialogsStore(state => state.props as DlgRenameCstProps);
const { schemaID, targetID } = useDialogsStore(state => state.props as DlgRenameCstProps);
const { updateConstituenta: cstUpdate } = useUpdateConstituenta();
const { schema } = useRSFormSuspense({ itemID: schemaID });
const target = schema.cstByID.get(targetID)!;
const { register, setValue, handleSubmit, control } = useForm<IUpdateConstituentaDTO>({
resolver: zodResolver(schemaUpdateConstituenta),
defaultValues: {
target: target.id,
target: targetID,
item_data: {
alias: target.alias,
cst_type: target.cst_type
@ -39,7 +41,7 @@ export function DlgRenameCst() {
const isValid = alias !== target.alias && validateNewAlias(alias, cst_type, schema);
function onSubmit(data: IUpdateConstituentaDTO) {
return cstUpdate({ itemID: schema.id, data: data });
return cstUpdate({ itemID: schemaID, data: data });
}
function handleChangeType(newType: CstType) {

View File

@ -12,18 +12,19 @@ import { ModalForm } from '@/components/modal';
import { useDialogsStore } from '@/stores/dialogs';
import { type ISubstitutionsDTO, schemaSubstitutions } from '../backend/types';
import { useRSFormSuspense } from '../backend/use-rsform';
import { useSubstituteConstituents } from '../backend/use-substitute-constituents';
import { PickSubstitutions } from '../components/pick-substitutions';
import { type IRSForm } from '../models/rsform';
export interface DlgSubstituteCstProps {
schema: IRSForm;
schemaID: number;
onSubstitute: (data: ISubstitutionsDTO) => void;
}
export function DlgSubstituteCst() {
const { onSubstitute, schema } = useDialogsStore(state => state.props as DlgSubstituteCstProps);
const { onSubstitute, schemaID } = useDialogsStore(state => state.props as DlgSubstituteCstProps);
const { substituteConstituents: cstSubstitute } = useSubstituteConstituents();
const { schema } = useRSFormSuspense({ itemID: schemaID });
const {
handleSubmit,

View File

@ -11,7 +11,7 @@ import {
import { type Graph } from '@/models/graph';
import { CstType, type ParsingStatus, type ValueClass } from '../backend/types';
import { CstType, type IAssociation, type ParsingStatus, type ValueClass } from '../backend/types';
import { type IArgumentInfo } from './rslang';
@ -58,6 +58,7 @@ export interface IConstituenta {
term_raw: string;
term_resolved: string;
term_forms: TermForm[];
associations: number[];
parse?: {
status: ParsingStatus;
@ -141,10 +142,13 @@ export interface IRSForm extends ILibraryItemData {
items: IConstituenta[];
inheritance: IInheritanceInfo[];
association: IAssociation[];
oss: ILibraryItemReference[];
stats: IRSFormStats;
graph: Graph;
association_graph: Graph;
full_graph: Graph;
cstByAlias: Map<string, IConstituenta>;
cstByID: Map<number, IConstituenta>;
}

View File

@ -25,8 +25,17 @@ const SIDELIST_LAYOUT_THRESHOLD = 1000; // px
const COLUMN_DENSE_SEARCH_THRESHOLD = 1100;
export function EditorConstituenta() {
const { schema, activeCst, isContentEditable, selected, setSelected, moveUp, moveDown, cloneCst, navigateCst } =
useRSEdit();
const {
schema, //
activeCst,
isContentEditable,
selected,
setSelected,
moveUp,
moveDown,
cloneCst,
navigateCst
} = useRSEdit();
const windowSize = useWindowSize();
const mainHeight = useMainHeight();

View File

@ -6,13 +6,10 @@ import { Controller, useForm } from 'react-hook-form';
import { toast } from 'react-toastify';
import { zodResolver } from '@hookform/resolvers/zod';
import { useUpdateCrucial } from '@/features/rsform/backend/use-update-crucial';
import { IconCrucialValue } from '@/features/rsform/components/icon-crucial-value';
import { MiniButton, SubmitButton } from '@/components/control';
import { TextButton } from '@/components/control/text-button';
import { IconChild, IconPredecessor, IconSave } from '@/components/icons';
import { TextArea } from '@/components/input';
import { Label, TextArea } from '@/components/input';
import { Indicator } from '@/components/view';
import { useDialogsStore } from '@/stores/dialogs';
import { useModificationStore } from '@/stores/modification';
@ -27,9 +24,15 @@ import {
ParsingStatus,
schemaUpdateConstituenta
} from '../../../backend/types';
import { useClearAssociations } from '../../../backend/use-clear-associations';
import { useCreateAssociation } from '../../../backend/use-create-association';
import { useDeleteAssociation } from '../../../backend/use-delete-association';
import { useMutatingRSForm } from '../../../backend/use-mutating-rsform';
import { useUpdateConstituenta } from '../../../backend/use-update-constituenta';
import { useUpdateCrucial } from '../../../backend/use-update-crucial';
import { IconCrucialValue } from '../../../components/icon-crucial-value';
import { RefsInput } from '../../../components/refs-input';
import { SelectMultiConstituenta } from '../../../components/select-multi-constituenta';
import {
getRSDefinitionPlaceholder,
labelCstTypification,
@ -57,6 +60,9 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
const { updateConstituenta } = useUpdateConstituenta();
const { updateCrucial } = useUpdateCrucial();
const { createAssociation } = useCreateAssociation();
const { deleteAssociation } = useDeleteAssociation();
const { clearAssociations } = useClearAssociations();
const showTypification = useDialogsStore(state => state.showShowTypeGraph);
const showEditTerm = useDialogsStore(state => state.showEditWordForms);
const showRenameCst = useDialogsStore(state => state.showRenameCst);
@ -106,6 +112,11 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
[activeCst, localParse]
);
const associations = useMemo(
() => activeCst.associations.map(id => schema.cstByID.get(id)!),
[activeCst.associations, schema.cstByID]
);
const isBasic = isBasicConcept(activeCst.cst_type) || activeCst.cst_type === CstType.NOMINAL;
const isElementary = isBaseSet(activeCst.cst_type);
const showConvention = !!activeCst.convention || forceComment || isBasic;
@ -168,11 +179,11 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
if (isModified && !promptUnsaved()) {
return;
}
showEditTerm({ itemID: schema.id, target: activeCst });
showEditTerm({ itemID: schema.id, targetID: activeCst.id });
}
function handleRenameCst() {
showRenameCst({ schema: schema, target: activeCst });
showRenameCst({ schemaID: schema.id, targetID: activeCst.id });
}
function handleToggleCrucial() {
@ -185,6 +196,35 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
});
}
function handleAddAssociation(item: IConstituenta) {
void createAssociation({
itemID: schema.id,
data: {
container: activeCst.id,
associate: item.id
}
});
}
function handleRemoveAssociation(item: IConstituenta) {
void deleteAssociation({
itemID: schema.id,
data: {
container: activeCst.id,
associate: item.id
}
});
}
function handleClearAssociations() {
void clearAssociations({
itemID: schema.id,
data: {
target: activeCst.id
}
});
}
return (
<form
id={id}
@ -239,6 +279,21 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
)}
/>
{activeCst.cst_type === CstType.NOMINAL || activeCst.associations.length > 0 ? (
<div className='flex flex-col gap-1'>
<Label text='Ассоциируемые конституенты' />
<SelectMultiConstituenta
items={schema.items.filter(item => item.id !== activeCst.id)}
value={associations}
onAdd={handleAddAssociation}
onClear={handleClearAssociations}
onRemove={handleRemoveAssociation}
disabled={disabled || isModified}
placeholder={disabled ? '' : 'Выберите конституенты'}
/>
</div>
) : null}
{activeCst.cst_type !== CstType.NOMINAL ? (
<TextArea
id='cst_typification'

View File

@ -60,7 +60,7 @@ export function MenuEditSchema() {
return;
}
showSubstituteCst({
schema: schema,
schemaID: schema.id,
onSubstitute: data => setSelected(prev => prev.filter(id => !data.substitutions.find(sub => sub.original === id)))
});
}
@ -94,7 +94,7 @@ export function MenuEditSchema() {
return;
}
showInlineSynthesis({
receiver: schema,
receiverID: schema.id,
onSynthesis: () => deselectAll()
});
}

View File

@ -231,7 +231,7 @@ export const RSEditState = ({
if (skipDialog) {
void cstCreate({ itemID: schema.id, data }).then(onCreateCst);
} else {
showCreateCst({ schema: schema, onCreate: onCreateCst, initial: data });
showCreateCst({ schemaID: schema.id, onCreate: onCreateCst, initial: data });
}
}
@ -260,7 +260,7 @@ export const RSEditState = ({
return;
}
showDeleteCst({
schema: schema,
schemaID: schema.id,
selected: selected,
afterDelete: (schema, deleted) => {
const isEmpty = deleted.length === schema.items.length;
@ -281,7 +281,7 @@ export const RSEditState = ({
if (isModified && !promptUnsaved()) {
return;
}
showCstTemplate({ schema: schema, onCreate: onCreateCst, insertAfter: activeCst?.id });
showCstTemplate({ schemaID: schema.id, onCreate: onCreateCst, insertAfter: activeCst?.id });
}
return (