diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/oss-edit-context.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/oss-edit-context.tsx index 5853d7c5..8860c010 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/oss-edit-context.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/oss-edit-context.tsx @@ -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>; } -const OssEditContext = createContext(null); +export const OssEditContext = createContext(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) => { - 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([]); - - 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 ( - - {children} - - ); -}; diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/oss-edit-state.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/oss-edit-state.tsx new file mode 100644 index 00000000..acfb2b78 --- /dev/null +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/oss-edit-state.tsx @@ -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) => { + 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([]); + + 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 ( + + {children} + + ); +}; diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/oss-page.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/oss-page.tsx index 61fc0a0c..0470295e 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/oss-page.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/oss-page.tsx @@ -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({ diff --git a/rsconcept/frontend/src/features/rsform/dialogs/dlg-cst-template/dlg-cst-template.tsx b/rsconcept/frontend/src/features/rsform/dialogs/dlg-cst-template/dlg-cst-template.tsx index 12e90992..bd8d4acf 100644 --- a/rsconcept/frontend/src/features/rsform/dialogs/dlg-cst-template/dlg-cst-template.tsx +++ b/rsconcept/frontend/src/features/rsform/dialogs/dlg-cst-template/dlg-cst-template.tsx @@ -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; diff --git a/rsconcept/frontend/src/features/rsform/dialogs/dlg-cst-template/template-context.tsx b/rsconcept/frontend/src/features/rsform/dialogs/dlg-cst-template/template-context.tsx index 9559a294..c80eab75 100644 --- a/rsconcept/frontend/src/features/rsform/dialogs/dlg-cst-template/template-context.tsx +++ b/rsconcept/frontend/src/features/rsform/dialogs/dlg-cst-template/template-context.tsx @@ -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(null); +export const TemplateContext = createContext(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(); - const [templateID, setTemplateID] = useState(null); - const [args, setArguments] = useState([]); - const [prototype, setPrototype] = useState(null); - const [filterCategory, setFilterCategory] = useState(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 ( - - {children} - - ); -}; diff --git a/rsconcept/frontend/src/features/rsform/dialogs/dlg-cst-template/template-state.tsx b/rsconcept/frontend/src/features/rsform/dialogs/dlg-cst-template/template-state.tsx new file mode 100644 index 00000000..0332e436 --- /dev/null +++ b/rsconcept/frontend/src/features/rsform/dialogs/dlg-cst-template/template-state.tsx @@ -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(); + const [templateID, setTemplateID] = useState(null); + const [args, setArguments] = useState([]); + const [prototype, setPrototype] = useState(null); + const [filterCategory, setFilterCategory] = useState(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 ( + + {children} + + ); +}; diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsedit-context.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsedit-context.tsx index ff086975..9e5f2d58 100644 --- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsedit-context.tsx +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsedit-context.tsx @@ -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(null); +export const RSEditContext = createContext(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) => { - 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([]); - const canDeleteSelected = selected.length > 0 && selected.every(id => !schema.cstByID.get(id)?.is_inherited); - const [focusCst, setFocusCst] = useState(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 ( - 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} - - ); -}; - -// ====== 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; -} diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsedit-state.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsedit-state.tsx new file mode 100644 index 00000000..80ef0045 --- /dev/null +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsedit-state.tsx @@ -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) => { + 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([]); + const canDeleteSelected = selected.length > 0 && selected.every(id => !schema.cstByID.get(id)?.is_inherited); + const [focusCst, setFocusCst] = useState(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 ( + 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} + + ); +}; + +// ====== 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; +} diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsform-page.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsform-page.tsx index 050ae3e8..cadd98b8 100644 --- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsform-page.tsx +++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/rsform-page.tsx @@ -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({