Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UILD-410: Record generation refactoring #42

Merged
merged 8 commits into from
Nov 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 2 additions & 227 deletions src/common/helpers/profile.helper.ts
Original file line number Diff line number Diff line change
@@ -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<string, SchemaEntry>;
userValues: UserValues;
selectedEntries?: string[];
container: Record<string, any>;
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);

Expand Down Expand Up @@ -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;
Expand All @@ -277,25 +74,3 @@ export const filterUserValues = (userValues: UserValues) =>

return accum;
}, {} as UserValues);

export const applyUserValues = (
schema: Map<string, SchemaEntry>,
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<string, RecordEntry> = {};

traverseSchema({ schema, userValues: filteredValues, selectedEntries, container: result, key: initKey });

return result;
};
14 changes: 6 additions & 8 deletions src/common/hooks/useRecordControls.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -190,7 +188,7 @@ export const useRecordControls = () => {
};

const saveLocalRecord = () => {
const parsed = applyUserValues(schema, initialSchemaKey, { userValues, selectedEntries });
const parsed = generateRecord();

if (!parsed) return;

Expand Down
24 changes: 24 additions & 0 deletions src/common/hooks/useRecordGeneration.ts
Original file line number Diff line number Diff line change
@@ -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 };
};
2 changes: 2 additions & 0 deletions src/common/services/record/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { SchemaTraverser } from './schemaTraverser';
export { RecordGenerator } from './record';
9 changes: 9 additions & 0 deletions src/common/services/record/record.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface IRecordGenerator {
init: (params: {
schema: Map<string, SchemaEntry>;
initKey: string | null;
userValues: UserValues;
selectedEntries: string[];
}) => void;
generate: () => Record<string, RecordEntry<RecursiveRecordSchema>> | undefined;
}
57 changes: 57 additions & 0 deletions src/common/services/record/record.ts
Original file line number Diff line number Diff line change
@@ -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<string, SchemaEntry>;
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<string, SchemaEntry>;
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<string, RecordEntry> = {};

this.schemaTraverser
.init({
schema: this.schema,
userValues: filteredValues,
selectedEntries: this.selectedEntries,
initialContainer: result,
})
.traverse({
key: this.initKey,
});

return result;
}
}
Loading
Loading