R: Split context and state dependencies to improve hot reload

This commit is contained in:
Ivan 2025-04-07 21:46:19 +03:00
parent e308a52b35
commit 531c44d3c8
9 changed files with 538 additions and 514 deletions

View File

@ -1,19 +1,7 @@
'use client';
import { createContext, use, useEffect, useState } from 'react';
import { createContext, use } from 'react';
import { urls, useConceptNavigation } from '@/app';
import { useAuthSuspense } from '@/features/auth';
import { useLibrarySearchStore } from '@/features/library';
import { useDeleteItem } from '@/features/library/backend/use-delete-item';
import { RSTabID } from '@/features/rsform/pages/rsform-page/rsedit-context';
import { useRoleStore, UserRole } from '@/features/users';
import { usePreferencesStore } from '@/stores/preferences';
import { promptText } from '@/utils/labels';
import { OperationType } from '../../backend/types';
import { useOssSuspense } from '../../backend/use-oss';
import { type IOperation, type IOperationSchema } from '../../models/oss';
export const OssTabID = {
@ -37,7 +25,7 @@ export interface IOssEditContext {
setSelected: React.Dispatch<React.SetStateAction<number[]>>;
}
const OssEditContext = createContext<IOssEditContext | null>(null);
export const OssEditContext = createContext<IOssEditContext | null>(null);
export const useOssEdit = () => {
const context = use(OssEditContext);
if (context === null) {
@ -45,97 +33,3 @@ export const useOssEdit = () => {
}
return context;
};
interface OssEditStateProps {
itemID: number;
}
export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEditStateProps>) => {
const router = useConceptNavigation();
const adminMode = usePreferencesStore(state => state.adminMode);
const role = useRoleStore(state => state.role);
const adjustRole = useRoleStore(state => state.adjustRole);
const setSearchLocation = useLibrarySearchStore(state => state.setLocation);
const searchLocation = useLibrarySearchStore(state => state.location);
const { user } = useAuthSuspense();
const { schema } = useOssSuspense({ itemID: itemID });
const isOwned = !!user.id && user.id === schema.owner;
const isMutable = role > UserRole.READER && !schema.read_only;
const [selected, setSelected] = useState<number[]>([]);
const { deleteItem } = useDeleteItem();
useEffect(
() =>
adjustRole({
isOwner: isOwned,
isEditor: !!user.id && schema.editors.includes(user.id),
isStaff: user.is_staff,
adminMode: adminMode
}),
[schema, adjustRole, isOwned, user, adminMode]
);
function navigateTab(tab: OssTabID) {
const url = urls.oss_props({
id: schema.id,
tab: tab
});
router.push({ path: url });
}
function navigateOperationSchema(target: number) {
const node = schema.operationByID.get(target);
if (!node?.result) {
return;
}
router.push({ path: urls.schema_props({ id: node.result, tab: RSTabID.CST_LIST }) });
}
function deleteSchema() {
if (!window.confirm(promptText.deleteOSS)) {
return;
}
void deleteItem({
target: schema.id,
beforeInvalidate: () => {
if (searchLocation === schema.location) {
setSearchLocation('');
}
return router.pushAsync({ path: urls.library, force: true });
}
});
}
function canDeleteOperation(target: IOperation) {
if (target.operation_type === OperationType.INPUT) {
return true;
}
return schema.graph.expandOutputs([target.id]).length === 0;
}
return (
<OssEditContext
value={{
schema,
selected,
isOwned,
isMutable,
navigateTab,
navigateOperationSchema,
canDeleteOperation,
deleteSchema,
setSelected
}}
>
{children}
</OssEditContext>
);
};

View File

@ -0,0 +1,113 @@
'use client';
import { useEffect, useState } from 'react';
import { urls, useConceptNavigation } from '@/app';
import { useAuthSuspense } from '@/features/auth';
import { useLibrarySearchStore } from '@/features/library';
import { useDeleteItem } from '@/features/library/backend/use-delete-item';
import { RSTabID } from '@/features/rsform/pages/rsform-page/rsedit-context';
import { useRoleStore, UserRole } from '@/features/users';
import { usePreferencesStore } from '@/stores/preferences';
import { promptText } from '@/utils/labels';
import { OperationType } from '../../backend/types';
import { useOssSuspense } from '../../backend/use-oss';
import { type IOperation } from '../../models/oss';
import { OssEditContext, type OssTabID } from './oss-edit-context';
interface OssEditStateProps {
itemID: number;
}
export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEditStateProps>) => {
const router = useConceptNavigation();
const adminMode = usePreferencesStore(state => state.adminMode);
const role = useRoleStore(state => state.role);
const adjustRole = useRoleStore(state => state.adjustRole);
const setSearchLocation = useLibrarySearchStore(state => state.setLocation);
const searchLocation = useLibrarySearchStore(state => state.location);
const { user } = useAuthSuspense();
const { schema } = useOssSuspense({ itemID: itemID });
const isOwned = !!user.id && user.id === schema.owner;
const isMutable = role > UserRole.READER && !schema.read_only;
const [selected, setSelected] = useState<number[]>([]);
const { deleteItem } = useDeleteItem();
useEffect(
() =>
adjustRole({
isOwner: isOwned,
isEditor: !!user.id && schema.editors.includes(user.id),
isStaff: user.is_staff,
adminMode: adminMode
}),
[schema, adjustRole, isOwned, user, adminMode]
);
function navigateTab(tab: OssTabID) {
const url = urls.oss_props({
id: schema.id,
tab: tab
});
router.push({ path: url });
}
function navigateOperationSchema(target: number) {
const node = schema.operationByID.get(target);
if (!node?.result) {
return;
}
router.push({ path: urls.schema_props({ id: node.result, tab: RSTabID.CST_LIST }) });
}
function deleteSchema() {
if (!window.confirm(promptText.deleteOSS)) {
return;
}
void deleteItem({
target: schema.id,
beforeInvalidate: () => {
if (searchLocation === schema.location) {
setSearchLocation('');
}
return router.pushAsync({ path: urls.library, force: true });
}
});
}
function canDeleteOperation(target: IOperation) {
if (target.operation_type === OperationType.INPUT) {
return true;
}
return schema.graph.expandOutputs([target.id]).length === 0;
}
return (
<OssEditContext
value={{
schema,
selected,
isOwned,
isMutable,
navigateTab,
navigateOperationSchema,
canDeleteOperation,
deleteSchema,
setSelected
}}
>
{children}
</OssEditContext>
);
};

View File

@ -16,7 +16,8 @@ import { useModificationStore } from '@/stores/modification';
import { OperationTooltip } from '../../components/operation-tooltip';
import { OssEditState, OssTabID } from './oss-edit-context';
import { OssTabID } from './oss-edit-context';
import { OssEditState } from './oss-edit-state';
import { OssTabs } from './oss-tabs';
const paramsSchema = z.strictObject({

View File

@ -19,7 +19,7 @@ import { FormCreateCst } from '../dlg-create-cst/form-create-cst';
import { TabArguments } from './tab-arguments';
import { TabTemplate } from './tab-template';
import { TemplateState } from './template-context';
import { TemplateState } from './template-state';
export interface DlgCstTemplateProps {
schema: IRSForm;

View File

@ -1,17 +1,9 @@
'use client';
import { createContext, use, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { createContext, use } from 'react';
import { useDialogsStore } from '@/stores/dialogs';
import { type ICstCreateDTO } from '../../backend/types';
import { type IConstituenta } from '../../models/rsform';
import { generateAlias } from '../../models/rsform-api';
import { type IArgumentValue } from '../../models/rslang';
import { inferTemplatedType, substituteTemplateArgs } from '../../models/rslang-api';
import { type DlgCstTemplateProps } from './dlg-cst-template';
export interface ITemplateContext {
args: IArgumentValue[];
@ -25,7 +17,7 @@ export interface ITemplateContext {
onChangeFilterCategory: (newFilterCategory: IConstituenta | null) => void;
}
const TemplateContext = createContext<ITemplateContext | null>(null);
export const TemplateContext = createContext<ITemplateContext | null>(null);
export const useTemplateContext = () => {
const context = use(TemplateContext);
if (context === null) {
@ -33,63 +25,3 @@ export const useTemplateContext = () => {
}
return context;
};
export const TemplateState = ({ children }: React.PropsWithChildren) => {
const { schema } = useDialogsStore(state => state.props as DlgCstTemplateProps);
const { setValue } = useFormContext<ICstCreateDTO>();
const [templateID, setTemplateID] = useState<number | null>(null);
const [args, setArguments] = useState<IArgumentValue[]>([]);
const [prototype, setPrototype] = useState<IConstituenta | null>(null);
const [filterCategory, setFilterCategory] = useState<IConstituenta | null>(null);
function onChangeArguments(newArgs: IArgumentValue[]) {
setArguments(newArgs);
if (newArgs.length === 0 || !prototype) {
return;
}
const newType = inferTemplatedType(prototype.cst_type, newArgs);
setValue('definition_formal', substituteTemplateArgs(prototype.definition_formal, newArgs));
setValue('cst_type', newType);
setValue('alias', generateAlias(newType, schema));
}
function onChangePrototype(newPrototype: IConstituenta) {
setPrototype(newPrototype);
setArguments(
newPrototype.parse.args.map(arg => ({
alias: arg.alias,
typification: arg.typification,
value: ''
}))
);
setValue('cst_type', newPrototype.cst_type);
setValue('alias', generateAlias(newPrototype.cst_type, schema));
setValue('definition_formal', newPrototype.definition_formal);
setValue('term_raw', newPrototype.term_raw);
setValue('definition_raw', newPrototype.definition_raw);
}
function onChangeTemplateID(newTemplateID: number | null) {
setTemplateID(newTemplateID);
setPrototype(null);
setArguments([]);
}
return (
<TemplateContext
value={{
templateID,
prototype,
filterCategory,
args,
onChangeArguments,
onChangePrototype,
onChangeFilterCategory: setFilterCategory,
onChangeTemplateID
}}
>
{children}
</TemplateContext>
);
};

View File

@ -0,0 +1,75 @@
'use client';
import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useDialogsStore } from '@/stores/dialogs';
import { type ICstCreateDTO } from '../../backend/types';
import { type IConstituenta } from '../../models/rsform';
import { generateAlias } from '../../models/rsform-api';
import { type IArgumentValue } from '../../models/rslang';
import { inferTemplatedType, substituteTemplateArgs } from '../../models/rslang-api';
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 { setValue } = useFormContext<ICstCreateDTO>();
const [templateID, setTemplateID] = useState<number | null>(null);
const [args, setArguments] = useState<IArgumentValue[]>([]);
const [prototype, setPrototype] = useState<IConstituenta | null>(null);
const [filterCategory, setFilterCategory] = useState<IConstituenta | null>(null);
function onChangeArguments(newArgs: IArgumentValue[]) {
setArguments(newArgs);
if (newArgs.length === 0 || !prototype) {
return;
}
const newType = inferTemplatedType(prototype.cst_type, newArgs);
setValue('definition_formal', substituteTemplateArgs(prototype.definition_formal, newArgs));
setValue('cst_type', newType);
setValue('alias', generateAlias(newType, schema));
}
function onChangePrototype(newPrototype: IConstituenta) {
setPrototype(newPrototype);
setArguments(
newPrototype.parse.args.map(arg => ({
alias: arg.alias,
typification: arg.typification,
value: ''
}))
);
setValue('cst_type', newPrototype.cst_type);
setValue('alias', generateAlias(newPrototype.cst_type, schema));
setValue('definition_formal', newPrototype.definition_formal);
setValue('term_raw', newPrototype.term_raw);
setValue('definition_raw', newPrototype.definition_raw);
}
function onChangeTemplateID(newTemplateID: number | null) {
setTemplateID(newTemplateID);
setPrototype(null);
setArguments([]);
}
return (
<TemplateContext
value={{
templateID,
prototype,
filterCategory,
args,
onChangeArguments,
onChangePrototype,
onChangeFilterCategory: setFilterCategory,
onChangeTemplateID
}}
>
{children}
</TemplateContext>
);
};

View File

@ -1,26 +1,9 @@
'use client';
import { createContext, use, useEffect, useState } from 'react';
import { createContext, use } from 'react';
import { urls, useConceptNavigation } from '@/app';
import { useAuthSuspense } from '@/features/auth';
import { useLibrarySearchStore } from '@/features/library';
import { useDeleteItem } from '@/features/library/backend/use-delete-item';
import { useRoleStore, UserRole } from '@/features/users';
import { useDialogsStore } from '@/stores/dialogs';
import { useModificationStore } from '@/stores/modification';
import { usePreferencesStore } from '@/stores/preferences';
import { PARAMETER, prefixes } from '@/utils/constants';
import { promptText } from '@/utils/labels';
import { promptUnsaved } from '@/utils/utils';
import { CstType, type IConstituentaBasicsDTO, type ICstCreateDTO } from '../../backend/types';
import { useCstCreate } from '../../backend/use-cst-create';
import { useCstMove } from '../../backend/use-cst-move';
import { useRSFormSuspense } from '../../backend/use-rsform';
import { type CstType } from '../../backend/types';
import { type IConstituenta, type IRSForm } from '../../models/rsform';
import { generateAlias } from '../../models/rsform-api';
export const RSTabID = {
CARD: 0,
@ -67,7 +50,7 @@ export interface IRSEditContext {
promptTemplate: () => void;
}
const RSEditContext = createContext<IRSEditContext | null>(null);
export const RSEditContext = createContext<IRSEditContext | null>(null);
export const useRSEdit = () => {
const context = use(RSEditContext);
if (context === null) {
@ -75,316 +58,3 @@ export const useRSEdit = () => {
}
return context;
};
interface RSEditStateProps {
itemID: number;
activeTab: RSTabID;
activeVersion?: number;
}
export const RSEditState = ({
itemID,
activeVersion,
activeTab,
children
}: React.PropsWithChildren<RSEditStateProps>) => {
const router = useConceptNavigation();
const adminMode = usePreferencesStore(state => state.adminMode);
const role = useRoleStore(state => state.role);
const adjustRole = useRoleStore(state => state.adjustRole);
const setSearchLocation = useLibrarySearchStore(state => state.setLocation);
const searchLocation = useLibrarySearchStore(state => state.location);
const { user } = useAuthSuspense();
const { schema } = useRSFormSuspense({ itemID: itemID, version: activeVersion });
const { isModified } = useModificationStore();
const isOwned = !!user.id && user.id === schema.owner;
const isArchive = !!activeVersion;
const isMutable = role > UserRole.READER && !schema.read_only;
const isContentEditable = isMutable && !isArchive;
const isAttachedToOSS = schema.oss.length > 0;
const [selected, setSelected] = useState<number[]>([]);
const canDeleteSelected = selected.length > 0 && selected.every(id => !schema.cstByID.get(id)?.is_inherited);
const [focusCst, setFocusCst] = useState<IConstituenta | null>(null);
const activeCst = selected.length === 0 ? null : schema.cstByID.get(selected[selected.length - 1])!;
const { cstCreate } = useCstCreate();
const { cstMove } = useCstMove();
const { deleteItem } = useDeleteItem();
const showCreateCst = useDialogsStore(state => state.showCreateCst);
const showDeleteCst = useDialogsStore(state => state.showDeleteCst);
const showCstTemplate = useDialogsStore(state => state.showCstTemplate);
useEffect(
() =>
adjustRole({
isOwner: isOwned,
isEditor: !!user.id && schema.editors.includes(user.id),
isStaff: user.is_staff,
adminMode: adminMode
}),
[schema, adjustRole, isOwned, user, adminMode]
);
function handleSetFocus(newValue: IConstituenta | null) {
setFocusCst(newValue);
setSelected([]);
}
function navigateVersion(versionID?: number) {
router.push({ path: urls.schema(schema.id, versionID) });
}
function navigateOss(ossID: number, newTab?: boolean) {
router.push({ path: urls.oss(ossID), newTab: newTab });
}
function navigateRSForm({ tab, activeID }: { tab: RSTabID; activeID?: number }) {
const data = {
id: schema.id,
tab: tab,
active: activeID,
version: activeVersion
};
const url = urls.schema_props(data);
if (activeID) {
if (tab === activeTab && tab !== RSTabID.CST_EDIT) {
router.replace({ path: url });
} else {
router.push({ path: url });
}
} else if (tab !== activeTab && tab === RSTabID.CST_EDIT && schema.items.length > 0) {
data.active = schema.items[0].id;
router.replace({ path: urls.schema_props(data) });
} else {
router.push({ path: url });
}
}
function navigateCst(cstID: number) {
if (cstID !== activeCst?.id || activeTab !== RSTabID.CST_EDIT) {
navigateRSForm({ tab: RSTabID.CST_EDIT, activeID: cstID });
}
}
function deleteSchema() {
if (!window.confirm(promptText.deleteLibraryItem)) {
return;
}
const ossID = schema.oss.length > 0 ? schema.oss[0].id : null;
void deleteItem({
target: schema.id,
beforeInvalidate: () => {
if (ossID) {
return router.pushAsync({ path: urls.oss(ossID), force: true });
} else {
if (searchLocation === schema.location) {
setSearchLocation('');
}
return router.pushAsync({ path: urls.library, force: true });
}
}
});
}
function onCreateCst(newCst: IConstituentaBasicsDTO) {
setSelected([newCst.id]);
navigateRSForm({ tab: activeTab, activeID: newCst.id });
if (activeTab === RSTabID.CST_LIST) {
setTimeout(() => {
const element = document.getElementById(`${prefixes.cst_list}${newCst.id}`);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'end'
});
}
}, PARAMETER.refreshTimeout);
}
}
function moveUp() {
if (selected.length === 0) {
return;
}
const currentIndex = schema.items.reduce((prev, cst, index) => {
if (!selected.includes(cst.id)) {
return prev;
} else if (prev === -1) {
return index;
}
return Math.min(prev, index);
}, -1);
const target = Math.max(0, currentIndex - 1);
void cstMove({
itemID: itemID,
data: {
items: selected,
move_to: target
}
});
}
function moveDown() {
if (selected.length === 0) {
return;
}
let count = 0;
const currentIndex = schema.items.reduce((prev, cst, index) => {
if (!selected.includes(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 cstMove({
itemID: itemID,
data: {
items: selected,
move_to: target
}
});
}
function createCst(type: CstType | null, skipDialog: boolean, definition?: string) {
const targetType = type ?? activeCst?.cst_type ?? CstType.BASE;
const data: ICstCreateDTO = {
insert_after: activeCst?.id ?? null,
cst_type: targetType,
alias: generateAlias(targetType, schema),
term_raw: '',
definition_formal: definition ?? '',
definition_raw: '',
convention: '',
term_forms: []
};
if (skipDialog) {
void cstCreate({ itemID: schema.id, data }).then(onCreateCst);
} else {
showCreateCst({ schema: schema, onCreate: onCreateCst, initial: data });
}
}
function cloneCst() {
if (!activeCst) {
return;
}
void cstCreate({
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() {
showDeleteCst({
schema: schema,
selected: selected,
afterDelete: (schema, deleted) => {
const isEmpty = deleted.length === schema.items.length;
const nextActive = isEmpty ? null : getNextActiveOnDelete(activeCst?.id ?? null, schema.items, deleted);
setSelected(nextActive ? [nextActive] : []);
if (!nextActive) {
navigateRSForm({ tab: RSTabID.CST_LIST });
} else if (activeTab === RSTabID.CST_EDIT) {
navigateRSForm({ tab: activeTab, activeID: nextActive });
} else {
navigateRSForm({ tab: activeTab });
}
}
});
}
function promptTemplate() {
if (isModified && !promptUnsaved()) {
return;
}
showCstTemplate({ schema: schema, onCreate: onCreateCst, insertAfter: activeCst?.id });
}
return (
<RSEditContext
value={{
schema,
focusCst,
selected,
activeCst,
activeVersion,
isOwned,
isArchive,
isMutable,
isContentEditable,
isAttachedToOSS,
canDeleteSelected,
navigateVersion,
navigateRSForm,
navigateCst,
navigateOss,
deleteSchema,
setFocus: handleSetFocus,
setSelected,
select: (target: number) => setSelected(prev => [...prev, target]),
deselect: (target: number) => setSelected(prev => prev.filter(id => id !== target)),
toggleSelect: (target: number) =>
setSelected(prev => (prev.includes(target) ? prev.filter(id => id !== target) : [...prev, target])),
deselectAll: () => setSelected([]),
moveUp,
moveDown,
createCst,
createCstDefault: () => createCst(null, false),
cloneCst,
promptDeleteCst,
promptTemplate
}}
>
{children}
</RSEditContext>
);
};
// ====== Internals =========
function getNextActiveOnDelete(activeID: number | null, items: IConstituenta[], deleted: number[]): number | null {
if (items.length === deleted.length) {
return null;
}
let activeIndex = items.findIndex(cst => cst.id === activeID);
if (activeIndex === -1) {
return null;
}
while (activeIndex < items.length && deleted.find(id => id === items[activeIndex].id)) {
++activeIndex;
}
if (activeIndex >= items.length) {
activeIndex = items.length - 1;
while (activeIndex >= 0 && deleted.find(id => id === items[activeIndex].id)) {
--activeIndex;
}
}
return items[activeIndex].id;
}

View File

@ -0,0 +1,338 @@
'use client';
import { useEffect, useState } from 'react';
import { urls, useConceptNavigation } from '@/app';
import { useAuthSuspense } from '@/features/auth';
import { useLibrarySearchStore } from '@/features/library';
import { useDeleteItem } from '@/features/library/backend/use-delete-item';
import { useRoleStore, UserRole } from '@/features/users';
import { useDialogsStore } from '@/stores/dialogs';
import { useModificationStore } from '@/stores/modification';
import { usePreferencesStore } from '@/stores/preferences';
import { PARAMETER, prefixes } from '@/utils/constants';
import { promptText } from '@/utils/labels';
import { promptUnsaved } from '@/utils/utils';
import { CstType, type IConstituentaBasicsDTO, type ICstCreateDTO } from '../../backend/types';
import { useCstCreate } from '../../backend/use-cst-create';
import { useCstMove } from '../../backend/use-cst-move';
import { useRSFormSuspense } from '../../backend/use-rsform';
import { type IConstituenta } from '../../models/rsform';
import { generateAlias } from '../../models/rsform-api';
import { RSEditContext, RSTabID } from './rsedit-context';
interface RSEditStateProps {
itemID: number;
activeTab: RSTabID;
activeVersion?: number;
}
export const RSEditState = ({
itemID,
activeVersion,
activeTab,
children
}: React.PropsWithChildren<RSEditStateProps>) => {
const router = useConceptNavigation();
const adminMode = usePreferencesStore(state => state.adminMode);
const role = useRoleStore(state => state.role);
const adjustRole = useRoleStore(state => state.adjustRole);
const setSearchLocation = useLibrarySearchStore(state => state.setLocation);
const searchLocation = useLibrarySearchStore(state => state.location);
const { user } = useAuthSuspense();
const { schema } = useRSFormSuspense({ itemID: itemID, version: activeVersion });
const { isModified } = useModificationStore();
const isOwned = !!user.id && user.id === schema.owner;
const isArchive = !!activeVersion;
const isMutable = role > UserRole.READER && !schema.read_only;
const isContentEditable = isMutable && !isArchive;
const isAttachedToOSS = schema.oss.length > 0;
const [selected, setSelected] = useState<number[]>([]);
const canDeleteSelected = selected.length > 0 && selected.every(id => !schema.cstByID.get(id)?.is_inherited);
const [focusCst, setFocusCst] = useState<IConstituenta | null>(null);
const activeCst = selected.length === 0 ? null : schema.cstByID.get(selected[selected.length - 1])!;
const { cstCreate } = useCstCreate();
const { cstMove } = useCstMove();
const { deleteItem } = useDeleteItem();
const showCreateCst = useDialogsStore(state => state.showCreateCst);
const showDeleteCst = useDialogsStore(state => state.showDeleteCst);
const showCstTemplate = useDialogsStore(state => state.showCstTemplate);
useEffect(
() =>
adjustRole({
isOwner: isOwned,
isEditor: !!user.id && schema.editors.includes(user.id),
isStaff: user.is_staff,
adminMode: adminMode
}),
[schema, adjustRole, isOwned, user, adminMode]
);
function handleSetFocus(newValue: IConstituenta | null) {
setFocusCst(newValue);
setSelected([]);
}
function navigateVersion(versionID?: number) {
router.push({ path: urls.schema(schema.id, versionID) });
}
function navigateOss(ossID: number, newTab?: boolean) {
router.push({ path: urls.oss(ossID), newTab: newTab });
}
function navigateRSForm({ tab, activeID }: { tab: RSTabID; activeID?: number }) {
const data = {
id: schema.id,
tab: tab,
active: activeID,
version: activeVersion
};
const url = urls.schema_props(data);
if (activeID) {
if (tab === activeTab && tab !== RSTabID.CST_EDIT) {
router.replace({ path: url });
} else {
router.push({ path: url });
}
} else if (tab !== activeTab && tab === RSTabID.CST_EDIT && schema.items.length > 0) {
data.active = schema.items[0].id;
router.replace({ path: urls.schema_props(data) });
} else {
router.push({ path: url });
}
}
function navigateCst(cstID: number) {
if (cstID !== activeCst?.id || activeTab !== RSTabID.CST_EDIT) {
navigateRSForm({ tab: RSTabID.CST_EDIT, activeID: cstID });
}
}
function deleteSchema() {
if (!window.confirm(promptText.deleteLibraryItem)) {
return;
}
const ossID = schema.oss.length > 0 ? schema.oss[0].id : null;
void deleteItem({
target: schema.id,
beforeInvalidate: () => {
if (ossID) {
return router.pushAsync({ path: urls.oss(ossID), force: true });
} else {
if (searchLocation === schema.location) {
setSearchLocation('');
}
return router.pushAsync({ path: urls.library, force: true });
}
}
});
}
function onCreateCst(newCst: IConstituentaBasicsDTO) {
setSelected([newCst.id]);
navigateRSForm({ tab: activeTab, activeID: newCst.id });
if (activeTab === RSTabID.CST_LIST) {
setTimeout(() => {
const element = document.getElementById(`${prefixes.cst_list}${newCst.id}`);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'end'
});
}
}, PARAMETER.refreshTimeout);
}
}
function moveUp() {
if (selected.length === 0) {
return;
}
const currentIndex = schema.items.reduce((prev, cst, index) => {
if (!selected.includes(cst.id)) {
return prev;
} else if (prev === -1) {
return index;
}
return Math.min(prev, index);
}, -1);
const target = Math.max(0, currentIndex - 1);
void cstMove({
itemID: itemID,
data: {
items: selected,
move_to: target
}
});
}
function moveDown() {
if (selected.length === 0) {
return;
}
let count = 0;
const currentIndex = schema.items.reduce((prev, cst, index) => {
if (!selected.includes(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 cstMove({
itemID: itemID,
data: {
items: selected,
move_to: target
}
});
}
function createCst(type: CstType | null, skipDialog: boolean, definition?: string) {
const targetType = type ?? activeCst?.cst_type ?? CstType.BASE;
const data: ICstCreateDTO = {
insert_after: activeCst?.id ?? null,
cst_type: targetType,
alias: generateAlias(targetType, schema),
term_raw: '',
definition_formal: definition ?? '',
definition_raw: '',
convention: '',
term_forms: []
};
if (skipDialog) {
void cstCreate({ itemID: schema.id, data }).then(onCreateCst);
} else {
showCreateCst({ schema: schema, onCreate: onCreateCst, initial: data });
}
}
function cloneCst() {
if (!activeCst) {
return;
}
void cstCreate({
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() {
showDeleteCst({
schema: schema,
selected: selected,
afterDelete: (schema, deleted) => {
const isEmpty = deleted.length === schema.items.length;
const nextActive = isEmpty ? null : getNextActiveOnDelete(activeCst?.id ?? null, schema.items, deleted);
setSelected(nextActive ? [nextActive] : []);
if (!nextActive) {
navigateRSForm({ tab: RSTabID.CST_LIST });
} else if (activeTab === RSTabID.CST_EDIT) {
navigateRSForm({ tab: activeTab, activeID: nextActive });
} else {
navigateRSForm({ tab: activeTab });
}
}
});
}
function promptTemplate() {
if (isModified && !promptUnsaved()) {
return;
}
showCstTemplate({ schema: schema, onCreate: onCreateCst, insertAfter: activeCst?.id });
}
return (
<RSEditContext
value={{
schema,
focusCst,
selected,
activeCst,
activeVersion,
isOwned,
isArchive,
isMutable,
isContentEditable,
isAttachedToOSS,
canDeleteSelected,
navigateVersion,
navigateRSForm,
navigateCst,
navigateOss,
deleteSchema,
setFocus: handleSetFocus,
setSelected,
select: (target: number) => setSelected(prev => [...prev, target]),
deselect: (target: number) => setSelected(prev => prev.filter(id => id !== target)),
toggleSelect: (target: number) =>
setSelected(prev => (prev.includes(target) ? prev.filter(id => id !== target) : [...prev, target])),
deselectAll: () => setSelected([]),
moveUp,
moveDown,
createCst,
createCstDefault: () => createCst(null, false),
cloneCst,
promptDeleteCst,
promptTemplate
}}
>
{children}
</RSEditContext>
);
};
// ====== Internals =========
function getNextActiveOnDelete(activeID: number | null, items: IConstituenta[], deleted: number[]): number | null {
if (items.length === deleted.length) {
return null;
}
let activeIndex = items.findIndex(cst => cst.id === activeID);
if (activeIndex === -1) {
return null;
}
while (activeIndex < items.length && deleted.find(id => id === items[activeIndex].id)) {
++activeIndex;
}
if (activeIndex >= items.length) {
activeIndex = items.length - 1;
while (activeIndex >= 0 && deleted.find(id => id === items[activeIndex].id)) {
--activeIndex;
}
}
return items[activeIndex].id;
}

View File

@ -16,7 +16,8 @@ import { useModificationStore } from '@/stores/modification';
import { ConstituentaTooltip } from '../../components/constituenta-tooltip';
import { RSEditState, RSTabID } from './rsedit-context';
import { RSTabID } from './rsedit-context';
import { RSEditState } from './rsedit-state';
import { RSTabs } from './rstabs';
const paramsSchema = z.strictObject({