From 6b737cebbbbe644b9b95da751c60a3110135fbfe Mon Sep 17 00:00:00 2001 From: Siarhei Karol <135722306+SKarolFolio@users.noreply.github.com> Date: Tue, 26 Nov 2024 16:36:01 +0500 Subject: [PATCH] UILD-410: Record generation refactoring (#42) --- src/common/helpers/profile.helper.ts | 229 +--------- src/common/hooks/useRecordControls.ts | 14 +- src/common/hooks/useRecordGeneration.ts | 24 ++ src/common/services/record/index.ts | 2 + .../services/record/record.interface.ts | 9 + src/common/services/record/record.ts | 57 +++ .../record/schemaTraverser.interface.ts | 22 + src/common/services/record/schemaTraverser.ts | 401 ++++++++++++++++++ src/components/EditSection/EditSection.tsx | 6 +- src/contexts/ServicesContext.ts | 3 +- src/providers/ServicesProvider.tsx | 6 +- .../common/hooks/useServicesContext.mock.ts | 6 + .../common/hooks/useRecordGeneration.test.ts | 33 ++ .../common/services/record/record.test.ts | 92 ++++ .../services/record/schemaTraverser.test.ts | 385 +++++++++++++++++ src/types/serviceContext.d.ts | 2 + 16 files changed, 1051 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 create mode 100644 src/test/__tests__/common/hooks/useRecordGeneration.test.ts create mode 100644 src/test/__tests__/common/services/record/record.test.ts create mode 100644 src/test/__tests__/common/services/record/schemaTraverser.test.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..4b9b7821 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,7 @@ export const useRecordControls = () => { }; const saveLocalRecord = () => { - 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..4495f3d3 --- /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 = () => { + recordGeneratorService?.init({ + schema, + initKey: initialSchemaKey, + userValues, + selectedEntries, + }); + + return recordGeneratorService?.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..24403111 --- /dev/null +++ b/src/common/services/record/record.interface.ts @@ -0,0 +1,9 @@ +export interface IRecordGenerator { + init: (params: { + schema: Map; + initKey: string | null; + userValues: UserValues; + selectedEntries: string[]; + }) => void; + 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..a62473a0 --- /dev/null +++ b/src/common/services/record/record.ts @@ -0,0 +1,57 @@ +import { filterUserValues } from '@common/helpers/profile.helper'; +import { SchemaTraverser } from './schemaTraverser'; +import { IRecordGenerator } from './record.interface'; + +export class RecordGenerator implements IRecordGenerator { + 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; + } + + 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..d168016c --- /dev/null +++ b/src/common/services/record/schemaTraverser.interface.ts @@ -0,0 +1,22 @@ +export type Container = Record; + +export type InitSchemaParams = { + schema: Map; + userValues: UserValues; + selectedEntries: string[]; + initialContainer: Container; +}; + +export type TraverseSchemaParams = { + container?: Container; + key: string; + index?: number; + shouldHaveRootWrapper?: boolean; + parentEntryType?: string; + nonBFMappedGroup?: NonBFMappedGroup; +}; + +export interface ISchemaTraverser { + init: (params: InitSchemaParams) => 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..a06ff8f4 --- /dev/null +++ b/src/common/services/record/schemaTraverser.ts @@ -0,0 +1,401 @@ +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, InitSchemaParams, 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 }: InitSchemaParams) { + 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() { + const { hidden, dropdownOption, profile } = AdvancedFieldType; + + return [hidden, dropdownOption, 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, + uri, + nonBFMappedGroup, + shouldHaveRootWrapper, + selector, + }: { + type: AdvancedFieldType; + uri: string; + nonBFMappedGroup?: NonBFMappedGroup; + shouldHaveRootWrapper: boolean; + selector: string; + }) { + const { block, groupComplex, 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; + } + + private 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, dropdownOption } = 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, + uri, + nonBFMappedGroup: updatedNonBFMappedGroup, + shouldHaveRootWrapper, + selector, + }) + ) { + 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/test/__mocks__/common/hooks/useServicesContext.mock.ts b/src/test/__mocks__/common/hooks/useServicesContext.mock.ts index 7850b4f6..2db0556b 100644 --- a/src/test/__mocks__/common/hooks/useServicesContext.mock.ts +++ b/src/test/__mocks__/common/hooks/useServicesContext.mock.ts @@ -42,6 +42,11 @@ export const schemaCreatorService = { generate: jest.fn(), } as ISchemaService; +export const recordGeneratorService = { + init: jest.fn(), + generate: jest.fn(), +} as IRecordGeneratorService; + jest.mock('@common/hooks/useServicesContext.ts', () => ({ useServicesContext: () => ({ userValuesService, @@ -51,5 +56,6 @@ jest.mock('@common/hooks/useServicesContext.ts', () => ({ recordNormalizingService, recordToSchemaMappingService, schemaCreatorService, + recordGeneratorService, }), })); diff --git a/src/test/__tests__/common/hooks/useRecordGeneration.test.ts b/src/test/__tests__/common/hooks/useRecordGeneration.test.ts new file mode 100644 index 00000000..03740efe --- /dev/null +++ b/src/test/__tests__/common/hooks/useRecordGeneration.test.ts @@ -0,0 +1,33 @@ +import { recordGeneratorService } from '@src/test/__mocks__/common/hooks/useServicesContext.mock'; +import { useRecoilValue } from 'recoil'; +import { renderHook } from '@testing-library/react'; +import { useRecordGeneration } from '@common/hooks/useRecordGeneration'; + +jest.mock('recoil'); + +describe('useRecordGeneration', () => { + it('generates a record successfully', () => { + const schema = 'mockSchema'; + const userValues = 'mockUserValues'; + const selectedEntries = 'mockSelectedEntries'; + const initKey = 'mockInitialSchemaKey'; + + (useRecoilValue as jest.Mock) + .mockReturnValueOnce(schema) + .mockReturnValueOnce(userValues) + .mockReturnValueOnce(selectedEntries) + .mockReturnValueOnce(initKey); + + const { result } = renderHook(() => useRecordGeneration()); + + result.current.generateRecord(); + + expect(recordGeneratorService.init).toHaveBeenCalledWith({ + schema, + initKey, + userValues, + selectedEntries, + }); + expect(recordGeneratorService.generate).toHaveBeenCalled(); + }); +}); diff --git a/src/test/__tests__/common/services/record/record.test.ts b/src/test/__tests__/common/services/record/record.test.ts new file mode 100644 index 00000000..ceee805f --- /dev/null +++ b/src/test/__tests__/common/services/record/record.test.ts @@ -0,0 +1,92 @@ +import { RecordGenerator, SchemaTraverser } from '@common/services/record'; +import * as ProfileHelper from '@common/helpers/profile.helper'; + +describe('RecordGenerator', () => { + let recordGenerator: RecordGenerator; + let schemaTraverserMock: SchemaTraverser; + + beforeEach(() => { + schemaTraverserMock = { + init: jest.fn().mockReturnThis(), + traverse: jest.fn(), + } as unknown as SchemaTraverser; + + recordGenerator = new RecordGenerator(schemaTraverserMock); + }); + + describe('init', () => { + it('sets the schema, initKey, userValues, and selectedEntries', () => { + const schema = new Map(); + const initKey = 'testKey'; + const userValues = { key_1: { contents: [] } } as unknown as UserValues; + const selectedEntries = ['entry 1', 'entry 2']; + + recordGenerator.init({ schema, initKey, userValues, selectedEntries }); + + expect(recordGenerator['schema']).toBe(schema); + expect(recordGenerator['initKey']).toBe(initKey); + expect(recordGenerator['userValues']).toBe(userValues); + expect(recordGenerator['selectedEntries']).toEqual(selectedEntries); + }); + }); + + describe('generate', () => { + it('returns undefined if userValues is empty', () => { + recordGenerator['userValues'] = {}; + recordGenerator['schema'] = new Map([['key_1', {} as SchemaEntry]]); + recordGenerator['initKey'] = 'key_1'; + + const result = recordGenerator.generate(); + + expect(result).toBeUndefined(); + expect(schemaTraverserMock.init).not.toHaveBeenCalled(); + expect(schemaTraverserMock.traverse).not.toHaveBeenCalled(); + }); + + it('returns undefined if schema is empty', () => { + recordGenerator['userValues'] = { key_1: { contents: [] } } as unknown as UserValues; + recordGenerator['schema'] = new Map(); + recordGenerator['initKey'] = 'key_1'; + + const result = recordGenerator.generate(); + + expect(result).toBeUndefined(); + expect(schemaTraverserMock.init).not.toHaveBeenCalled(); + expect(schemaTraverserMock.traverse).not.toHaveBeenCalled(); + }); + + it('returns undefined if initKey is null', () => { + recordGenerator['userValues'] = { key_1: { contents: [] } } as unknown as UserValues; + recordGenerator['schema'] = new Map([['key_1', {} as SchemaEntry]]); + recordGenerator['initKey'] = null; + + const result = recordGenerator.generate(); + + expect(result).toBeUndefined(); + expect(schemaTraverserMock.init).not.toHaveBeenCalled(); + expect(schemaTraverserMock.traverse).not.toHaveBeenCalled(); + }); + + it('calls "schemaTraverser.init" and "traverse" when all conditions are met', () => { + const filteredValues = { key_1: { contents: [] } } as unknown as UserValues; + jest.spyOn(ProfileHelper, 'filterUserValues').mockReturnValue(filteredValues); + schemaTraverserMock.traverse = jest.fn(() => {}); + + recordGenerator['userValues'] = { key_1: { contents: [] } } as unknown as UserValues; + recordGenerator['schema'] = new Map([['key_1', {} as SchemaEntry]]); + recordGenerator['initKey'] = 'key_1'; + recordGenerator['selectedEntries'] = ['entry 1']; + + const result = recordGenerator.generate(); + + expect(schemaTraverserMock.init).toHaveBeenCalledWith({ + schema: recordGenerator['schema'], + userValues: filteredValues, + selectedEntries: recordGenerator['selectedEntries'], + initialContainer: {}, + }); + expect(schemaTraverserMock.traverse).toHaveBeenCalledWith({ key: 'key_1' }); + expect(result).toEqual({}); + }); + }); +}); diff --git a/src/test/__tests__/common/services/record/schemaTraverser.test.ts b/src/test/__tests__/common/services/record/schemaTraverser.test.ts new file mode 100644 index 00000000..5ad570d5 --- /dev/null +++ b/src/test/__tests__/common/services/record/schemaTraverser.test.ts @@ -0,0 +1,385 @@ +import { AdvancedFieldType } from '@common/constants/uiControls.constants'; +import { SchemaTraverser } from '@common/services/record'; +import { Container, InitSchemaParams, TraverseSchemaParams } from '@common/services/record/schemaTraverser.interface'; +import * as SchemaHelper from '@common/helpers/schema.helper'; +import * as ProfileHelper from '@common/helpers/profile.helper'; + +describe('SchemaTraverser', () => { + let schemaTraverser: SchemaTraverser; + let schema: Map; + let userValues: UserValues; + let selectedEntries: string[]; + let initialContainer: Container; + + beforeEach(() => { + schemaTraverser = new SchemaTraverser(); + schema = new Map(); + userValues = {}; + selectedEntries = []; + initialContainer = {}; + }); + + describe('init', () => { + it('initializes the schema traverser with given parameters', () => { + const params: InitSchemaParams = { schema, userValues, selectedEntries, initialContainer }; + const result = schemaTraverser.init(params); + + expect(result).toBe(schemaTraverser); + expect(schemaTraverser['schema']).toBe(schema); + expect(schemaTraverser['userValues']).toBe(userValues); + expect(schemaTraverser['selectedEntries']).toBe(selectedEntries); + expect(schemaTraverser['initialContainer']).toBe(initialContainer); + }); + }); + + describe('traverse', () => { + let schemaTraverserTyped: any; + + beforeEach(() => { + schemaTraverserTyped = schemaTraverser as any; + }); + + it('handles user value match', () => { + const key = 'testKey'; + const traverseParams: TraverseSchemaParams = { container: {}, key }; + const schemaEntry = { + children: [], + uri: 'testUri', + uriBFLite: 'testUriBFLite', + bfid: 'testBfid', + type: AdvancedFieldType.simple, + }; + schema.set(key, schemaEntry as unknown as SchemaEntry); + userValues[key] = { contents: [{ id: '1', label: 'testLabel' }] } as UserValue; + schemaTraverser.init({ schema, userValues, selectedEntries, initialContainer }); + + const handleUserValueMatchSpy = jest.spyOn(schemaTraverserTyped, 'handleUserValueMatch'); + schemaTraverser.traverse(traverseParams); + + expect(handleUserValueMatchSpy).toHaveBeenCalled(); + }); + + it('handles user value match with nonBFMappedGroup', () => { + const key = 'testKey'; + const traverseParams: TraverseSchemaParams = { container: {}, key }; + const userValue = { contents: [{ id: '1', label: 'testLabel' }] } as UserValue; + const nonBFMappedGroup = { uri: 'testUri', data: {} as NonBFMappedGroupData }; + const schemaEntry = { + children: [], + uri: 'testUri', + uriBFLite: 'testUriBFLite', + bfid: 'testBfid', + type: AdvancedFieldType.simple, + }; + schema.set(key, schemaEntry as unknown as SchemaEntry); + + userValues[key] = userValue; + schemaTraverser.init({ schema, userValues, selectedEntries, initialContainer }); + + const handleUserValueMatchSpy = jest.spyOn(schemaTraverserTyped, 'handleUserValueMatch'); + jest.spyOn(schemaTraverserTyped, 'checkGroupIsNonBFMapped').mockReturnValue(true); + jest + .spyOn(SchemaHelper, 'selectNonBFMappedGroupData') + .mockReturnValue({ nonBFMappedGroup, selectedNonBFRecord: {} }); + + schemaTraverser.traverse(traverseParams); + + expect(handleUserValueMatchSpy).toHaveBeenCalledWith({ + container: { + testUriBFLite: ['testLabel'], + }, + isArrayContainer: false, + nonBFMappedGroup, + selector: 'testUriBFLite', + uriBFLite: 'testUriBFLite', + userValueMatch: userValue, + }); + }); + + it('handles group traverse', () => { + const key = 'testKey'; + const traverseParams: TraverseSchemaParams = { container: {}, key }; + const schemaEntry = { + children: ['childKey'], + uri: 'testUri', + uriBFLite: 'testUriBFLite', + bfid: 'testBfid', + type: AdvancedFieldType.groupComplex, + }; + schema.set(key, schemaEntry as SchemaEntry); + schema.set('childKey', { + children: [], + uri: 'childUri', + uriBFLite: 'childUriBFLite', + bfid: 'childBfid', + type: AdvancedFieldType.simple, + } as unknown as SchemaEntry); + schemaTraverser.init({ schema, userValues, selectedEntries, initialContainer }); + + const handleGroupTraverseSpy = jest.spyOn(schemaTraverser as any, 'handleGroupTraverse'); + schemaTraverser.traverse(traverseParams); + + expect(handleGroupTraverseSpy).toHaveBeenCalled(); + }); + }); + + describe('private methods', () => { + let schemaTraverserTyped: any; + + beforeEach(() => { + schemaTraverserTyped = schemaTraverser as any; + }); + + it('returns correct non-array types', () => { + const nonArrayTypes = schemaTraverserTyped.getNonArrayTypes(); + + expect(nonArrayTypes).toEqual([ + AdvancedFieldType.hidden, + AdvancedFieldType.dropdownOption, + AdvancedFieldType.profile, + ]); + }); + + it('returns correct selector', () => { + const selector = schemaTraverserTyped.getSelector('uri', 'uriBFLite', 'bfid'); + + expect(selector).toBe('uriBFLite'); + }); + + it('returns correct shouldProceed value', () => { + userValues = { testKey: {} } as unknown as UserValues; + schema.set('testKey', { path: ['testKey'] } as unknown as SchemaEntry); + schemaTraverser.init({ schema, userValues, selectedEntries, initialContainer }); + + const shouldProceed = schemaTraverserTyped.shouldProceed('testKey'); + + expect(shouldProceed).toBe(true); + }); + + it('returns correct isArray value', () => { + const isArray = schemaTraverserTyped.isArray(AdvancedFieldType.simple); + + expect(isArray).toBe(true); + }); + + it('returns correct isArrayContainer value', () => { + const container = { testSelector: [] }; + const isArrayContainer = schemaTraverserTyped.isArrayContainer(container, 'testSelector'); + + expect(isArrayContainer).toBe(true); + }); + + it('returns correct checkGroupIsNonBFMapped value', () => { + const checkGroupIsNonBFMapped = schemaTraverserTyped.checkGroupIsNonBFMapped( + 'uri', + AdvancedFieldType.simple, + AdvancedFieldType.groupComplex, + ); + + expect(checkGroupIsNonBFMapped).toBe(false); + }); + + it('returns correct checkGroupShouldHaveWrapper value', () => { + const checkGroupShouldHaveWrapper = schemaTraverserTyped.checkGroupShouldHaveWrapper({ + type: AdvancedFieldType.groupComplex, + uri: 'uri', + shouldHaveRootWrapper: false, + selector: 'selector', + }); + + expect(checkGroupShouldHaveWrapper).toBe(false); + }); + + it('returns correct shouldContinueGroupTraverse value', () => { + const shouldContinueGroupTraverse = schemaTraverserTyped.shouldContinueGroupTraverse(true, 0, 'selector'); + + expect(shouldContinueGroupTraverse).toBe(true); + }); + + it('returns correct hasUserValueAndSelector value', () => { + const hasUserValueAndSelector = schemaTraverserTyped.hasUserValueAndSelector({ contents: [] }, 'uri', 'selector'); + + expect(hasUserValueAndSelector).toBe(true); + }); + + it('returns correct checkDropdownOptionWithoutUserValues value', () => { + const checkDropdownOptionWithoutUserValues = schemaTraverserTyped.checkDropdownOptionWithoutUserValues( + AdvancedFieldType.dropdownOption, + 'key', + ); + + expect(checkDropdownOptionWithoutUserValues).toBe(true); + }); + + it('returns correct checkEntryWithoutWrapper value', () => { + const checkEntryWithoutWrapper = schemaTraverserTyped.checkEntryWithoutWrapper( + false, + AdvancedFieldType.hidden, + 'selector', + ); + + expect(checkEntryWithoutWrapper).toBe(true); + }); + }); + + describe('handleUserValueMatch', () => { + const container = {} as Container; + const uriBFLite = 'testUriBFLite'; + const selector = 'testSelector'; + let userValueMatch: UserValue; + let isArrayContainer: boolean; + let nonBFMappedGroup: NonBFMappedGroup | undefined; + let schemaTraverserTyped: any; + + beforeEach(() => { + schemaTraverserTyped = schemaTraverser as any; + userValueMatch = { + contents: [{ id: '1', label: 'testLabel' }], + } as UserValue; + isArrayContainer = false; + nonBFMappedGroup = undefined; + }); + + it('handles user value match with KEEP_VALUE_AS_IS', () => { + jest.spyOn(SchemaHelper, 'getAdvancedValuesField').mockReturnValue(undefined); + jest.spyOn(ProfileHelper, 'generateLookupValue').mockReturnValue({ id: ['1'], label: ['testLabel'] }); + + schemaTraverserTyped.handleUserValueMatch({ + userValueMatch, + uriBFLite, + selector, + isArrayContainer, + nonBFMappedGroup, + container, + }); + + expect(container[selector]).toEqual(['testLabel']); + }); + + it('handles user value match with advancedValueField', () => { + jest.spyOn(SchemaHelper, 'getAdvancedValuesField').mockReturnValue('advancedField'); + jest.spyOn(SchemaHelper, 'generateAdvancedFieldObject').mockReturnValue({ advancedField: ['testLabel'] }); + + schemaTraverserTyped.handleUserValueMatch({ + userValueMatch, + uriBFLite, + selector, + isArrayContainer, + nonBFMappedGroup, + container, + }); + + expect(container[selector]).toEqual([{ advancedField: ['testLabel'] }]); + }); + + it('handles user value match with nonBFMappedGroup', () => { + nonBFMappedGroup = { uri: 'testUri', data: {} as NonBFMappedGroupData }; + jest.spyOn(SchemaHelper, 'getAdvancedValuesField').mockReturnValue(undefined); + jest.spyOn(ProfileHelper, 'generateLookupValue').mockReturnValue({ id: ['1'], label: ['testLabel'] }); + + schemaTraverserTyped.handleUserValueMatch({ + userValueMatch, + uriBFLite, + selector, + isArrayContainer, + nonBFMappedGroup, + container, + }); + + expect(container[selector]).toEqual(['testLabel']); + }); + + it('handles user value match with isArrayContainer', () => { + isArrayContainer = true; + container[selector] = [{ id: '2', label: 'existingLabel' }]; + userValueMatch = userValueMatch = { + contents: [{ id: '1', label: 'testLabel', meta: { type: AdvancedFieldType.simple } }], + } as UserValue; + jest.spyOn(SchemaHelper, 'getAdvancedValuesField').mockReturnValue(undefined); + jest.spyOn(ProfileHelper, 'generateLookupValue').mockReturnValue({ id: ['1'], label: ['testLabel'] }); + + schemaTraverserTyped.handleUserValueMatch({ + userValueMatch, + uriBFLite, + selector, + isArrayContainer, + nonBFMappedGroup, + container, + }); + + expect(container[selector]).toEqual([ + { id: '2', label: 'existingLabel' }, + { id: ['1'], label: ['testLabel'] }, + ]); + }); + + it('handles user value match without advancedValueField and nonBFMappedGroup', () => { + jest.spyOn(SchemaHelper, 'getAdvancedValuesField').mockReturnValue(undefined); + jest.spyOn(ProfileHelper, 'generateLookupValue').mockReturnValue({ id: ['1'], label: ['testLabel'] }); + + schemaTraverserTyped.handleUserValueMatch({ + userValueMatch, + uriBFLite, + selector, + isArrayContainer, + nonBFMappedGroup, + container, + }); + + expect(container[selector]).toEqual(['testLabel']); + }); + }); + + describe('handleGroupsWithWrapper', () => { + const container = {} as Container; + const selector = 'testSelector'; + let type: AdvancedFieldType; + let isArrayContainer: boolean; + let schemaTraverserTyped: any; + + beforeEach(() => { + schemaTraverserTyped = schemaTraverser as any; + type = AdvancedFieldType.block; + isArrayContainer = false; + }); + + it('handles groups with wrapper for block type', () => { + const result = schemaTraverserTyped.handleGroupsWithWrapper({ + isArrayContainer, + container, + selector, + type, + }); + + expect(container[selector]).toEqual({}); + expect(result).toEqual({}); + }); + + it('handles groups with wrapper for non-block type', () => { + type = AdvancedFieldType.groupComplex; + const result = schemaTraverserTyped.handleGroupsWithWrapper({ + isArrayContainer, + container, + selector, + type, + }); + + expect(container[selector]).toEqual([{}]); + expect(result).toEqual({}); + }); + + it('handles groups with wrapper for array container', () => { + isArrayContainer = true; + container[selector] = [{}]; + const result = schemaTraverserTyped.handleGroupsWithWrapper({ + isArrayContainer, + container, + selector, + type, + }); + + expect(container[selector]).toEqual([{}, {}]); + expect(result).toEqual({}); + }); + }); +}); diff --git a/src/types/serviceContext.d.ts b/src/types/serviceContext.d.ts index 61ba493e..170d8452 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 IRecordGeneratorService = import('@common/services/record/record.interface').IRecordGenerator; type ServicesParams = { selectedEntriesService?: ISelectedEntriesService; @@ -15,4 +16,5 @@ type ServicesParams = { recordNormalizingService?: IRecordNormalizingService; recordToSchemaMappingService?: IRecordToSchemaMappingService; schemaCreatorService?: ISchemaService; + recordGeneratorService?: IRecordGeneratorService; };