From 560e3ba444c456d0e4edf93033f524a85773ba88 Mon Sep 17 00:00:00 2001 From: Siarhei Karol Date: Mon, 25 Nov 2024 13:47:43 +0300 Subject: [PATCH] add Record Generator service --- src/common/helpers/profile.helper.ts | 229 +--------- src/common/hooks/useRecordControls.ts | 15 +- src/common/hooks/useRecordGeneration.ts | 24 + src/common/services/record/index.ts | 2 + .../services/record/record.interface.ts | 10 + src/common/services/record/record.ts | 58 +++ .../record/schemaTraverser.interface.ts | 20 + src/common/services/record/schemaTraverser.ts | 416 ++++++++++++++++++ src/components/EditSection/EditSection.tsx | 6 +- src/contexts/ServicesContext.ts | 3 +- src/providers/ServicesProvider.tsx | 6 +- src/types/serviceContext.d.ts | 2 + 12 files changed, 551 insertions(+), 240 deletions(-) create mode 100644 src/common/hooks/useRecordGeneration.ts create mode 100644 src/common/services/record/index.ts create mode 100644 src/common/services/record/record.interface.ts create mode 100644 src/common/services/record/record.ts create mode 100644 src/common/services/record/schemaTraverser.interface.ts create mode 100644 src/common/services/record/schemaTraverser.ts diff --git a/src/common/helpers/profile.helper.ts b/src/common/helpers/profile.helper.ts index 46e7b31d..30d9074d 100644 --- a/src/common/helpers/profile.helper.ts +++ b/src/common/helpers/profile.helper.ts @@ -1,42 +1,7 @@ -// https://redux.js.org/usage/structuring-reducers/normalizing-state-shape - -import { - COMPLEX_GROUPS, - FORCE_INCLUDE_WHEN_DEPARSING, - GROUPS_WITHOUT_ROOT_WRAPPER, - GROUP_BY_LEVEL, - IGNORE_HIDDEN_PARENT_OR_RECORD_SELECTION, - LOOKUPS_WITH_SIMPLE_STRUCTURE, - NONARRAY_DROPDOWN_OPTIONS, - FORCE_EXCLUDE_WHEN_DEPARSING, - IDENTIFIER_AS_VALUE, - LOC_GOV_URI, - KEEP_VALUE_AS_IS, - OUTGOING_RECORD_IDENTIFIERS_TO_SWAP, -} from '@common/constants/bibframe.constants'; +import { LOOKUPS_WITH_SIMPLE_STRUCTURE, LOC_GOV_URI } from '@common/constants/bibframe.constants'; import { BFLITE_URIS, TYPE_MAP } from '@common/constants/bibframeMapping.constants'; import { AdvancedFieldType } from '@common/constants/uiControls.constants'; -import { - checkGroupIsNonBFMapped, - generateAdvancedFieldObject, - getAdvancedValuesField, - getLookupLabelKey, - selectNonBFMappedGroupData, -} from './schema.helper'; - -type TraverseSchema = { - schema: Map; - userValues: UserValues; - selectedEntries?: string[]; - container: Record; - key: string; - index?: number; - shouldHaveRootWrapper?: boolean; - parentEntryType?: string; - nonBFMappedGroup?: NonBFMappedGroup; -}; - -const getNonArrayTypes = () => [AdvancedFieldType.hidden, AdvancedFieldType.dropdownOption, AdvancedFieldType.profile]; +import { getLookupLabelKey } from './schema.helper'; export const hasElement = (collection: string[], uri?: string) => !!uri && collection.includes(uri); @@ -95,174 +60,6 @@ export const getMappedLookupValue = ({ return mappedUri; }; -const traverseSchema = ({ - schema, - userValues, - selectedEntries = [], - container, - key, - index = 0, - shouldHaveRootWrapper = false, - parentEntryType, - nonBFMappedGroup, -}: TraverseSchema) => { - const { children, uri, uriBFLite, bfid, type } = schema.get(key) || {}; - const uriSelector = uriBFLite || uri; - const selector = (uriSelector && OUTGOING_RECORD_IDENTIFIERS_TO_SWAP[uriSelector]) || uriSelector || bfid; - const userValueMatch = userValues[key]; - const shouldProceed = Object.keys(userValues) - .map(uuid => schema.get(uuid)?.path) - .flat() - .includes(key); - - const isArray = !getNonArrayTypes().includes(type as AdvancedFieldType); - const isArrayContainer = !!selector && Array.isArray(container[selector]); - let updatedNonBFMappedGroup = nonBFMappedGroup; - - if ( - checkGroupIsNonBFMapped({ - propertyURI: uri as string, - parentEntryType: parentEntryType as AdvancedFieldType, - type: type as AdvancedFieldType, - }) - ) { - const { nonBFMappedGroup: generatedNonBFMappedGroup } = selectNonBFMappedGroupData({ - propertyURI: uri as string, - type: type as AdvancedFieldType, - parentEntryType: parentEntryType as AdvancedFieldType, - }); - - if (generatedNonBFMappedGroup) { - updatedNonBFMappedGroup = generatedNonBFMappedGroup as NonBFMappedGroup; - } - } - - if (userValueMatch && uri && selector) { - const advancedValueField = getAdvancedValuesField(uriBFLite); - - const withFormat = userValueMatch.contents.map( - ({ id, label, meta: { uri, parentUri, type, basicLabel, srsId } = {} }) => { - if (KEEP_VALUE_AS_IS.includes(selector) || type === AdvancedFieldType.complex) { - return { id, label, srsId }; - } else if ( - ((parentUri || uri) && (!advancedValueField || updatedNonBFMappedGroup)) || - type === AdvancedFieldType.simple - ) { - return generateLookupValue({ - uriBFLite, - label, - basicLabel, - uri: uri ?? parentUri, - type: type as AdvancedFieldType, - nonBFMappedGroup: updatedNonBFMappedGroup, - }); - } else if (advancedValueField) { - return generateAdvancedFieldObject({ advancedValueField, label }); - } else { - return type ? { label } : label; - } - }, - ); - - if (isArrayContainer && container[selector].length) { - // Add duplicated group - container[selector].push(...withFormat); - } else { - container[selector] = withFormat; - } - } else if (selector && (shouldProceed || index < GROUP_BY_LEVEL)) { - let containerSelector: RecursiveRecordSchema | RecursiveRecordSchema[] | string[]; - let hasRootWrapper = shouldHaveRootWrapper; - - const { profile: profileType, block, dropdownOption, groupComplex, hidden } = AdvancedFieldType; - const isGroupWithoutRootWrapper = hasElement(GROUPS_WITHOUT_ROOT_WRAPPER, uri); - const identifierAsValueSelection = IDENTIFIER_AS_VALUE[selector]; - - if (type === profileType) { - containerSelector = container; - } else if ( - (type === block || - (type === groupComplex && hasElement(COMPLEX_GROUPS, uri)) || - (type === groupComplex && updatedNonBFMappedGroup) || - shouldHaveRootWrapper || - (FORCE_INCLUDE_WHEN_DEPARSING.includes(selector) && type !== hidden)) && - !FORCE_EXCLUDE_WHEN_DEPARSING.includes(selector) - ) { - if (type === dropdownOption && !selectedEntries.includes(key)) { - // Only fields from the selected option should be processed and saved - return; - } - - // Groups like "Provision Activity" don't have "block" wrapper, - // their child elements like "dropdown options" are placed at the top level, - // where any other blocks are placed. - containerSelector = {}; - - if (isArrayContainer) { - // Add duplicated group - container[selector].push(containerSelector); - } else { - container[selector] = type === block ? containerSelector : [containerSelector]; - } - } else if (type === dropdownOption) { - if (!selectedEntries.includes(key)) { - // Only fields from the selected option should be processed and saved - return; - } - - containerSelector = {}; - - if (NONARRAY_DROPDOWN_OPTIONS.includes(selector)) { - container[selector] = containerSelector; - } else if (identifierAsValueSelection) { - containerSelector = { - [identifierAsValueSelection.field]: [identifierAsValueSelection.value], - }; - - container.push(containerSelector); - } else { - container.push({ [selector]: containerSelector }); - } - } else if ( - isGroupWithoutRootWrapper || - type === hidden || - type === groupComplex || - IGNORE_HIDDEN_PARENT_OR_RECORD_SELECTION.includes(selector) - ) { - // Some groups like "Provision Activity" should not have a root node, - // and they put their children directly in the block node. - containerSelector = container; - - if (isGroupWithoutRootWrapper) { - hasRootWrapper = true; - } - } else { - containerSelector = isArray ? [] : {}; - - if (container[selector] && isArrayContainer) { - // Add duplicated group - containerSelector = container[selector]; - } else { - container[selector] = containerSelector; - } - } - - children?.forEach(uuid => - traverseSchema({ - schema, - userValues, - selectedEntries, - container: containerSelector, - key: uuid, - index: index + 1, - shouldHaveRootWrapper: hasRootWrapper, - parentEntryType: type, - nonBFMappedGroup: updatedNonBFMappedGroup, - }), - ); - } -}; - export const filterUserValues = (userValues: UserValues) => Object.values(userValues).reduce((accum, current) => { const { contents, uuid } = current; @@ -277,25 +74,3 @@ export const filterUserValues = (userValues: UserValues) => return accum; }, {} as UserValues); - -export const applyUserValues = ( - schema: Map, - initKey: string | null, - userInput: { - userValues: UserValues; - selectedEntries: string[]; - }, -) => { - const { userValues, selectedEntries } = userInput; - - if (!Object.keys(userValues).length || !schema.size || !initKey) { - return; - } - - const filteredValues = filterUserValues(userValues); - const result: Record = {}; - - traverseSchema({ schema, userValues: filteredValues, selectedEntries, container: result, key: initKey }); - - return result; -}; diff --git a/src/common/hooks/useRecordControls.ts b/src/common/hooks/useRecordControls.ts index 66994971..abaac9a6 100644 --- a/src/common/hooks/useRecordControls.ts +++ b/src/common/hooks/useRecordControls.ts @@ -1,7 +1,6 @@ import { flushSync } from 'react-dom'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; -import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; -import { applyUserValues } from '@common/helpers/profile.helper'; +import { useRecoilState, useSetRecoilState } from 'recoil'; import { postRecord, putRecord, @@ -32,6 +31,7 @@ import state from '@state'; import { useContainerEvents } from './useContainerEvents'; import { ApiErrorCodes, ExternalResourceIdType } from '@common/constants/api.constants'; import { checkHasErrorOfCodeType } from '@common/helpers/api.helper'; +import { useRecordGeneration } from './useRecordGeneration'; type SaveRecordProps = { asRefToNewRecord?: boolean; @@ -49,11 +49,8 @@ type IBaseFetchRecord = { export const useRecordControls = () => { const [searchParams, setSearchParams] = useSearchParams(); const setIsLoading = useSetRecoilState(state.loadingState.isLoading); - const [userValues, setUserValues] = useRecoilState(state.inputs.userValues); - const schema = useRecoilValue(state.config.schema); + const setUserValues = useSetRecoilState(state.inputs.userValues); const setSelectedProfile = useSetRecoilState(state.config.selectedProfile); - const initialSchemaKey = useRecoilValue(state.config.initialSchemaKey); - const selectedEntries = useRecoilValue(state.config.selectedEntries); const [record, setRecord] = useRecoilState(state.inputs.record); const setIsEdited = useSetRecoilState(state.status.recordIsEdited); const setRecordStatus = useSetRecoilState(state.status.recordStatus); @@ -72,6 +69,7 @@ export const useRecordControls = () => { const { dispatchUnblockEvent, dispatchNavigateToOriginEventWithFallback } = useContainerEvents(); const [queryParams] = useSearchParams(); const isClone = queryParams.get(QueryParams.CloneOf); + const { generateRecord } = useRecordGeneration(); const fetchRecord = async (recordId: string, previewParams?: PreviewParams) => { const profile = PROFILE_BFIDS.MONOGRAPH; @@ -105,7 +103,7 @@ export const useRecordControls = () => { isNavigatingBack = true, shouldSetSearchParams = true, }: SaveRecordProps = {}) => { - const parsed = applyUserValues(schema, initialSchemaKey, { selectedEntries, userValues }); + const parsed = generateRecord(); const currentRecordId = record?.id; if (!parsed) return; @@ -190,7 +188,8 @@ export const useRecordControls = () => { }; const saveLocalRecord = () => { - const parsed = applyUserValues(schema, initialSchemaKey, { userValues, selectedEntries }); + // const parsed = applyUserValues(schema, initialSchemaKey, { userValues, selectedEntries }); + const parsed = generateRecord(); if (!parsed) return; diff --git a/src/common/hooks/useRecordGeneration.ts b/src/common/hooks/useRecordGeneration.ts new file mode 100644 index 00000000..e1ac9645 --- /dev/null +++ b/src/common/hooks/useRecordGeneration.ts @@ -0,0 +1,24 @@ +import { useRecoilValue } from 'recoil'; +import { useServicesContext } from './useServicesContext'; +import state from '@state'; + +export const useRecordGeneration = () => { + const { recordGeneratorService } = useServicesContext(); + const schema = useRecoilValue(state.config.schema); + const userValues = useRecoilValue(state.inputs.userValues); + const selectedEntries = useRecoilValue(state.config.selectedEntries); + const initialSchemaKey = useRecoilValue(state.config.initialSchemaKey); + + const generateRecord = () => { + return recordGeneratorService + ?.init({ + schema, + initKey: initialSchemaKey, + userValues, + selectedEntries, + }) + .generate(); + }; + + return { generateRecord }; +}; diff --git a/src/common/services/record/index.ts b/src/common/services/record/index.ts new file mode 100644 index 00000000..193004bf --- /dev/null +++ b/src/common/services/record/index.ts @@ -0,0 +1,2 @@ +export { SchemaTraverser } from './schemaTraverser'; +export { RecordGenerator } from './record'; diff --git a/src/common/services/record/record.interface.ts b/src/common/services/record/record.interface.ts new file mode 100644 index 00000000..28c3e19a --- /dev/null +++ b/src/common/services/record/record.interface.ts @@ -0,0 +1,10 @@ +export interface IRecord { + init: (params: { + schema: Map; + initKey: string | null; + userValues: UserValues; + selectedEntries: string[]; + }) => IRecord; + + generate: () => Record> | undefined; +} diff --git a/src/common/services/record/record.ts b/src/common/services/record/record.ts new file mode 100644 index 00000000..7a00c389 --- /dev/null +++ b/src/common/services/record/record.ts @@ -0,0 +1,58 @@ +import { filterUserValues } from '@common/helpers/profile.helper'; +import { SchemaTraverser } from './schemaTraverser'; + +export class RecordGenerator { + private schema: Map; + private initKey: string | null; + private userValues: UserValues; + private selectedEntries: string[]; + + constructor(private readonly schemaTraverser: SchemaTraverser) { + this.schemaTraverser = schemaTraverser; + this.schema = new Map(); + this.initKey = null; + this.userValues = {}; + this.selectedEntries = []; + } + + init({ + schema, + initKey, + userValues, + selectedEntries, + }: { + schema: Map; + initKey: string | null; + userValues: UserValues; + selectedEntries: string[]; + }) { + this.schema = schema; + this.initKey = initKey; + this.userValues = userValues; + this.selectedEntries = selectedEntries; + + return this; + } + + public generate() { + if (!Object.keys(this.userValues).length || !this.schema.size || !this.initKey) { + return; + } + + const filteredValues = filterUserValues(this.userValues); + const result: Record = {}; + + this.schemaTraverser + .init({ + schema: this.schema, + userValues: filteredValues, + selectedEntries: this.selectedEntries, + initialContainer: result, + }) + .traverse({ + key: this.initKey, + }); + + return result; + } +} diff --git a/src/common/services/record/schemaTraverser.interface.ts b/src/common/services/record/schemaTraverser.interface.ts new file mode 100644 index 00000000..a548903e --- /dev/null +++ b/src/common/services/record/schemaTraverser.interface.ts @@ -0,0 +1,20 @@ +export type Container = Record; + +export type TraverseSchemaParams = { + container?: Container; + key: string; + index?: number; + shouldHaveRootWrapper?: boolean; + parentEntryType?: string; + nonBFMappedGroup?: NonBFMappedGroup; +}; + +export interface ISchemaTraverser { + init: (params: { + schema: Map; + userValues: UserValues; + selectedEntries: string[]; + initialContainer: Record; + }) => ISchemaTraverser; + traverse: (params: TraverseSchemaParams) => void; +} diff --git a/src/common/services/record/schemaTraverser.ts b/src/common/services/record/schemaTraverser.ts new file mode 100644 index 00000000..170586e6 --- /dev/null +++ b/src/common/services/record/schemaTraverser.ts @@ -0,0 +1,416 @@ +import { + COMPLEX_GROUPS, + FORCE_EXCLUDE_WHEN_DEPARSING, + FORCE_INCLUDE_WHEN_DEPARSING, + GROUP_BY_LEVEL, + GROUPS_WITHOUT_ROOT_WRAPPER, + IDENTIFIER_AS_VALUE, + IGNORE_HIDDEN_PARENT_OR_RECORD_SELECTION, + KEEP_VALUE_AS_IS, + NONARRAY_DROPDOWN_OPTIONS, + OUTGOING_RECORD_IDENTIFIERS_TO_SWAP, +} from '@common/constants/bibframe.constants'; +import { AdvancedFieldType } from '@common/constants/uiControls.constants'; +import { generateLookupValue, hasElement } from '@common/helpers/profile.helper'; +import { + generateAdvancedFieldObject, + checkGroupIsNonBFMapped, + selectNonBFMappedGroupData, + getAdvancedValuesField, +} from '@common/helpers/schema.helper'; +import { Container, ISchemaTraverser, TraverseSchemaParams } from './schemaTraverser.interface'; + +export class SchemaTraverser implements ISchemaTraverser { + private schema: Map; + private userValues: UserValues; + private selectedEntries: string[]; + private initialContainer: Container; + + constructor() { + this.schema = new Map(); + this.userValues = {}; + this.selectedEntries = []; + this.initialContainer = {}; + } + + public init({ + schema, + userValues, + selectedEntries, + initialContainer, + }: { + schema: Map; + userValues: UserValues; + selectedEntries: string[]; + initialContainer: Container; + }) { + this.schema = schema; + this.userValues = userValues; + this.selectedEntries = selectedEntries; + this.initialContainer = initialContainer; + + return this; + } + + public traverse({ + container, + key, + index = 0, + shouldHaveRootWrapper = false, + parentEntryType, + nonBFMappedGroup, + }: TraverseSchemaParams) { + const initialContainer = container ?? this.initialContainer; + const { children, uri, uriBFLite, bfid, type } = this.schema.get(key) || {}; + const selector = this.getSelector(uri, uriBFLite, bfid); + const userValueMatch = this.userValues[key]; + const shouldProceed = this.shouldProceed(key); + + const isArray = this.isArray(type as AdvancedFieldType); + const isArrayContainer = this.isArrayContainer(initialContainer, selector); + let updatedNonBFMappedGroup = nonBFMappedGroup; + + if (this.checkGroupIsNonBFMapped(uri as string, parentEntryType as AdvancedFieldType, type as AdvancedFieldType)) { + const { nonBFMappedGroup: generatedNonBFMappedGroup } = selectNonBFMappedGroupData({ + propertyURI: uri as string, + type: type as AdvancedFieldType, + parentEntryType: parentEntryType as AdvancedFieldType, + }); + + if (generatedNonBFMappedGroup) { + updatedNonBFMappedGroup = generatedNonBFMappedGroup as NonBFMappedGroup; + } + } + + if (this.hasUserValueAndSelector(userValueMatch, uri, selector)) { + this.handleUserValueMatch({ + userValueMatch, + uriBFLite, + selector: selector as string, + isArrayContainer, + nonBFMappedGroup: updatedNonBFMappedGroup, + container: initialContainer, + }); + } else if (this.shouldContinueGroupTraverse(shouldProceed, index, selector)) { + this.handleGroupTraverse({ + container: initialContainer, + key, + index, + type: type as AdvancedFieldType, + selector: selector as string, + uri: uri as string, + shouldHaveRootWrapper, + updatedNonBFMappedGroup, + isArrayContainer, + isArray, + children, + }); + } + } + + private getNonArrayTypes() { + return [AdvancedFieldType.hidden, AdvancedFieldType.dropdownOption, AdvancedFieldType.profile]; + } + + private getSelector(uri: string | undefined, uriBFLite: string | undefined, bfid: string | undefined) { + const uriSelector = uriBFLite ?? uri; + return (uriSelector && OUTGOING_RECORD_IDENTIFIERS_TO_SWAP[uriSelector]) || uriSelector || bfid; + } + + private shouldProceed(key: string) { + return Object.keys(this.userValues) + .map(uuid => this.schema.get(uuid)?.path) + .flat() + .includes(key); + } + + private isArray(type: AdvancedFieldType) { + return !this.getNonArrayTypes().includes(type); + } + + private isArrayContainer(container: Container, selector: string | undefined) { + return !!selector && Array.isArray(container[selector]); + } + + private checkGroupIsNonBFMapped(uri: string, parentEntryType: AdvancedFieldType, type: AdvancedFieldType) { + return checkGroupIsNonBFMapped({ + propertyURI: uri, + parentEntryType, + type, + }); + } + + private checkGroupShouldHaveWrapper({ + type, + block, + groupComplex, + uri, + nonBFMappedGroup, + shouldHaveRootWrapper, + selector, + hidden, + }: { + type: AdvancedFieldType; + block: AdvancedFieldType; + groupComplex: AdvancedFieldType; + uri: string; + nonBFMappedGroup?: NonBFMappedGroup; + shouldHaveRootWrapper: boolean; + selector: string; + hidden: AdvancedFieldType; + }) { + return ( + (type === block || + (type === groupComplex && hasElement(COMPLEX_GROUPS, uri)) || + (type === groupComplex && nonBFMappedGroup) || + shouldHaveRootWrapper || + (FORCE_INCLUDE_WHEN_DEPARSING.includes(selector) && type !== hidden)) && + !FORCE_EXCLUDE_WHEN_DEPARSING.includes(selector) + ); + } + + private shouldContinueGroupTraverse(shouldProceed: boolean, index: number, selector?: string) { + return selector && (shouldProceed || index < GROUP_BY_LEVEL); + } + + private hasUserValueAndSelector(userValueMatch: UserValue, uri?: string, selector?: string) { + return userValueMatch && uri && selector; + } + + private checkDropdownOptionWithoutUserValues(type: AdvancedFieldType, key: string) { + return type === AdvancedFieldType.dropdownOption && !this.selectedEntries.includes(key); + } + + private checkEntryWithoutWrapper(isGroupWithoutRootWrapper: boolean, type: AdvancedFieldType, selector: string) { + return ( + isGroupWithoutRootWrapper || + type === AdvancedFieldType.hidden || + type === AdvancedFieldType.groupComplex || + IGNORE_HIDDEN_PARENT_OR_RECORD_SELECTION.includes(selector) + ); + } + + private handleUserValueMatch({ + userValueMatch, + uriBFLite, + selector, + isArrayContainer, + nonBFMappedGroup, + container, + }: { + userValueMatch: UserValue; + uriBFLite: string | undefined; + selector: string; + isArrayContainer: boolean; + nonBFMappedGroup: NonBFMappedGroup | undefined; + container: Container; + }) { + const advancedValueField = getAdvancedValuesField(uriBFLite); + + const withFormat = userValueMatch.contents.map( + ({ id, label, meta: { uri, parentUri, type, basicLabel, srsId } = {} }) => { + if (KEEP_VALUE_AS_IS.includes(selector) || type === AdvancedFieldType.complex) { + return { id, label, srsId }; + } else if ( + ((parentUri || uri) && (!advancedValueField || nonBFMappedGroup)) || + type === AdvancedFieldType.simple + ) { + return generateLookupValue({ + uriBFLite, + label, + basicLabel, + uri: uri ?? parentUri, + type: type as AdvancedFieldType, + nonBFMappedGroup, + }); + } else if (advancedValueField) { + return generateAdvancedFieldObject({ advancedValueField, label }); + } else { + return type ? { label } : label; + } + }, + ); + + if (isArrayContainer && container[selector].length) { + // Add duplicated group + container[selector].push(...withFormat); + } else { + container[selector] = withFormat; + } + } + + private handleGroupsWithWrapper({ + isArrayContainer, + container, + selector, + type, + }: { + isArrayContainer: boolean; + container: Container; + selector: string; + type: AdvancedFieldType; + }) { + // Groups like "Provision Activity" don't have "block" wrapper, + // their child elements like "dropdown options" are placed at the top level, + // where any other blocks are placed. + const containerSelector = {}; + + if (isArrayContainer) { + // Add duplicated group + container[selector].push(containerSelector); + } else { + container[selector] = type === AdvancedFieldType.block ? containerSelector : [containerSelector]; + } + + return containerSelector; + } + + private handleDropdownOption({ + container, + selector, + identifierAsValueSelection, + }: { + container: Container; + selector: string; + identifierAsValueSelection?: { + field: string; + value: string; + }; + }) { + let containerSelector = {}; + + if (NONARRAY_DROPDOWN_OPTIONS.includes(selector)) { + container[selector] = containerSelector; + } else if (identifierAsValueSelection) { + containerSelector = { + [identifierAsValueSelection.field]: [identifierAsValueSelection.value], + }; + + container.push(containerSelector); + } else { + container.push({ [selector]: containerSelector }); + } + + return containerSelector; + } + + handleBasicGroup({ + isArray, + container, + selector, + isArrayContainer, + }: { + isArray: boolean; + container: Container; + selector: string; + isArrayContainer: boolean; + }) { + let containerSelector = isArray ? [] : {}; + + if (container[selector] && isArrayContainer) { + // Add duplicated group + containerSelector = container[selector]; + } else { + container[selector] = containerSelector; + } + + return containerSelector; + } + + private handleGroupTraverse({ + container, + key, + index, + type, + selector, + uri, + shouldHaveRootWrapper, + updatedNonBFMappedGroup, + isArrayContainer, + isArray, + children, + }: { + container: Container; + key: string; + index: number; + type: AdvancedFieldType; + selector: string; + uri: string; + shouldHaveRootWrapper: boolean; + updatedNonBFMappedGroup?: NonBFMappedGroup; + isArrayContainer: boolean; + isArray: boolean; + children?: string[]; + }) { + let containerSelector: RecursiveRecordSchema | RecursiveRecordSchema[] | string[]; + let hasRootWrapper = shouldHaveRootWrapper; + + const { profile: profileType, block, dropdownOption, groupComplex, hidden } = AdvancedFieldType; + const isGroupWithoutRootWrapper = hasElement(GROUPS_WITHOUT_ROOT_WRAPPER, uri); + const identifierAsValueSelection = IDENTIFIER_AS_VALUE[selector]; + + if (type === profileType) { + containerSelector = container; + } else if ( + this.checkGroupShouldHaveWrapper({ + type, + block, + groupComplex, + uri, + nonBFMappedGroup: updatedNonBFMappedGroup, + shouldHaveRootWrapper, + selector, + hidden, + }) + ) { + if (this.checkDropdownOptionWithoutUserValues(type, key)) { + // Only fields from the selected option should be processed and saved + return; + } + + containerSelector = this.handleGroupsWithWrapper({ + isArrayContainer, + container, + selector, + type, + }); + } else if (type === dropdownOption) { + if (!this.selectedEntries.includes(key)) { + // Only fields from the selected option should be processed and saved + return; + } + + containerSelector = this.handleDropdownOption({ + container, + selector, + identifierAsValueSelection, + }); + } else if (this.checkEntryWithoutWrapper(isGroupWithoutRootWrapper, type, selector)) { + // Some groups like "Provision Activity" should not have a root node, + // and they put their children directly in the block node. + containerSelector = container; + + if (isGroupWithoutRootWrapper) { + hasRootWrapper = true; + } + } else { + containerSelector = this.handleBasicGroup({ + isArray, + container, + selector, + isArrayContainer, + }); + } + + children?.forEach(uuid => + this.traverse({ + container: containerSelector, + key: uuid, + index: index + 1, + shouldHaveRootWrapper: hasRootWrapper, + parentEntryType: type, + nonBFMappedGroup: updatedNonBFMappedGroup, + }), + ); + } +} diff --git a/src/components/EditSection/EditSection.tsx b/src/components/EditSection/EditSection.tsx index 1a1f3b76..4c774a36 100644 --- a/src/components/EditSection/EditSection.tsx +++ b/src/components/EditSection/EditSection.tsx @@ -2,7 +2,6 @@ import { useEffect, memo } from 'react'; import { useRecoilValue, useRecoilState } from 'recoil'; import classNames from 'classnames'; import state from '@state'; -import { applyUserValues } from '@common/helpers/profile.helper'; import { saveRecordLocally } from '@common/helpers/record.helper'; import { PROFILE_BFIDS } from '@common/constants/bibframe.constants'; import { AUTOSAVE_INTERVAL } from '@common/constants/storage.constants'; @@ -13,11 +12,11 @@ import { useContainerEvents } from '@common/hooks/useContainerEvents'; import { useServicesContext } from '@common/hooks/useServicesContext'; import { renderDrawComponent } from './renderDrawComponent'; import './EditSection.scss'; +import { useRecordGeneration } from '@common/hooks/useRecordGeneration'; export const EditSection = memo(() => { const { selectedEntriesService } = useServicesContext() as Required; const resourceTemplates = useRecoilValue(state.config.selectedProfile)?.json.Profile.resourceTemplates; - const schema = useRecoilValue(state.config.schema); const initialSchemaKey = useRecoilValue(state.config.initialSchemaKey); const [selectedEntries, setSelectedEntries] = useRecoilState(state.config.selectedEntries); const [userValues, setUserValues] = useRecoilState(state.inputs.userValues); @@ -27,6 +26,7 @@ export const EditSection = memo(() => { const [collapsedEntries, setCollapsedEntries] = useRecoilState(state.ui.collapsedEntries); const collapsibleEntries = useRecoilValue(state.ui.collapsibleEntries); const currentlyEditedEntityBfid = useRecoilValue(state.ui.currentlyEditedEntityBfid); + const { generateRecord } = useRecordGeneration(); useContainerEvents({ watchEditedState: true }); @@ -35,7 +35,7 @@ export const EditSection = memo(() => { const autoSaveRecord = setInterval(() => { try { - const parsed = applyUserValues(schema, initialSchemaKey, { userValues, selectedEntries }); + const parsed = generateRecord(); if (!parsed) return; diff --git a/src/contexts/ServicesContext.ts b/src/contexts/ServicesContext.ts index 1361dc5f..7be25628 100644 --- a/src/contexts/ServicesContext.ts +++ b/src/contexts/ServicesContext.ts @@ -7,5 +7,6 @@ export const ServicesContext = createContext({ lookupCacheService: undefined, recordNormalizingService: undefined, recordToSchemaMappingService: undefined, - schemaCreatorService: undefined + schemaCreatorService: undefined, + recordGeneratorService: undefined, }); diff --git a/src/providers/ServicesProvider.tsx b/src/providers/ServicesProvider.tsx index 5ae7ca73..3cea822e 100644 --- a/src/providers/ServicesProvider.tsx +++ b/src/providers/ServicesProvider.tsx @@ -9,6 +9,7 @@ import { RecordNormalizingService } from '@common/services/recordNormalizing'; import { RecordToSchemaMappingService } from '@common/services/recordToSchemaMapping'; import { useCommonStatus } from '@common/hooks/useCommonStatus'; import { EntryPropertiesGeneratorService } from '@common/services/schema/entryPropertiesGenerator.service'; +import { RecordGenerator, SchemaTraverser } from '@common/services/record'; type ServicesProviderProps = { children: ReactElement; @@ -43,6 +44,7 @@ export const ServicesProvider: FC = ({ children }) => { () => new SchemaService(selectedEntriesService, entryPropertiesGeneratorService), [selectedEntriesService, entryPropertiesGeneratorService], ); + const recordGeneratorService = useMemo(() => new RecordGenerator(new SchemaTraverser()), []); const servicesValue = useMemo( () => ({ @@ -53,6 +55,7 @@ export const ServicesProvider: FC = ({ children }) => { recordNormalizingService, recordToSchemaMappingService, schemaCreatorService, + recordGeneratorService, }), [ selectedEntriesService, @@ -62,8 +65,9 @@ export const ServicesProvider: FC = ({ children }) => { recordNormalizingService, recordToSchemaMappingService, schemaCreatorService, + recordGeneratorService, ], ); return {children}; -}; \ No newline at end of file +}; diff --git a/src/types/serviceContext.d.ts b/src/types/serviceContext.d.ts index 61ba493e..a0ae6354 100644 --- a/src/types/serviceContext.d.ts +++ b/src/types/serviceContext.d.ts @@ -6,6 +6,7 @@ type IRecordNormalizingService = type IRecordToSchemaMappingService = import('@common/services/recordToSchemaMapping/recordToSchemaMapping.interface').IRecordToSchemaMapping; type ISchemaService = import('@common/services/schema/schema.interface').ISchema; +type IRecordService = import('@common/services/record/record.interface').IRecord; type ServicesParams = { selectedEntriesService?: ISelectedEntriesService; @@ -15,4 +16,5 @@ type ServicesParams = { recordNormalizingService?: IRecordNormalizingService; recordToSchemaMappingService?: IRecordToSchemaMappingService; schemaCreatorService?: ISchemaService; + recordGeneratorService?: IRecordService; };