diff --git a/src/common/constants/bibframe.constants.ts b/src/common/constants/bibframe.constants.ts index 16c8a4a9..e0f47816 100644 --- a/src/common/constants/bibframe.constants.ts +++ b/src/common/constants/bibframe.constants.ts @@ -113,6 +113,7 @@ export const IDENTIFIER_AS_VALUE: Record): AdvancedF return AdvancedFieldType.__fallback; }; + +export const deleteFromSetImmutable = (set: Set, toDelete: T[]) => { + const clone = new Set([...set]); + + toDelete.forEach(entry => clone.delete(entry)); + + return clone; +}; diff --git a/src/common/hooks/useProfileSchema.ts b/src/common/hooks/useProfileSchema.ts index afc29d0e..e87075bf 100644 --- a/src/common/hooks/useProfileSchema.ts +++ b/src/common/hooks/useProfileSchema.ts @@ -1,22 +1,38 @@ import { useRecoilState, useSetRecoilState } from 'recoil'; import state from '@state'; import { useServicesContext } from './useServicesContext'; +import { deleteFromSetImmutable } from '@common/helpers/common.helper'; export const useProfileSchema = () => { const { selectedEntriesService, schemaWithDuplicatesService } = useServicesContext() as Required; const [schema, setSchema] = useRecoilState(state.config.schema); const setSelectedEntries = useSetRecoilState(state.config.selectedEntries); - const setClonePrototypes = useSetRecoilState(state.config.clonePrototypes); + const setCollapsibleEntries = useSetRecoilState(state.ui.collapsibleEntries); + const setIsEdited = useSetRecoilState(state.status.recordIsEdited); + const setUserValues = useSetRecoilState(state.inputs.userValues); const getSchemaWithCopiedEntries = (entry: SchemaEntry, selectedEntries: string[]) => { selectedEntriesService.set(selectedEntries); schemaWithDuplicatesService.set(schema); - schemaWithDuplicatesService.duplicateEntry(entry); + const newUuid = schemaWithDuplicatesService.duplicateEntry(entry); setSelectedEntries(selectedEntriesService.get()); - setClonePrototypes(prev => [...prev, entry.uuid]); + setCollapsibleEntries(prev => new Set(newUuid ? [...prev, entry.uuid, newUuid] : [...prev, entry.uuid])); setSchema(schemaWithDuplicatesService.get()); + + setIsEdited(true); + }; + + const getSchemaWithDeletedEntries = (entry: SchemaEntry) => { + schemaWithDuplicatesService.set(schema); + const deletedUuids = schemaWithDuplicatesService.deleteEntry(entry); + + setCollapsibleEntries(prev => deleteFromSetImmutable(prev, [entry.uuid])); + setSchema(schemaWithDuplicatesService.get()); + setUserValues(prev => Object.fromEntries(Object.entries(prev).filter(([key]) => !deletedUuids?.includes(key)))); + + setIsEdited(true); }; - return { getSchemaWithCopiedEntries }; + return { getSchemaWithCopiedEntries, getSchemaWithDeletedEntries }; }; diff --git a/src/common/services/recordToSchemaMapping/recordToSchemaMapping.service.ts b/src/common/services/recordToSchemaMapping/recordToSchemaMapping.service.ts index c98d150c..26cca68c 100644 --- a/src/common/services/recordToSchemaMapping/recordToSchemaMapping.service.ts +++ b/src/common/services/recordToSchemaMapping/recordToSchemaMapping.service.ts @@ -423,17 +423,6 @@ export class RecordToSchemaMappingService implements IRecordToSchemaMapping { const newEntryUuid = this.repeatableFieldsService?.duplicateEntry(schemaUiElem, false) ?? ''; this.updatedSchema = this.repeatableFieldsService?.get(); - // Parameters are defined for further proper duplication of repeatable subcomponents - const duplicatedElem = this.updatedSchema.get(newEntryUuid); - - if (duplicatedElem) { - duplicatedElem.cloneOf = schemaUiElem.uuid; - duplicatedElem.clonedBy = []; - schemaUiElem.clonedBy = Array.isArray(schemaUiElem.clonedBy) - ? [...schemaUiElem.clonedBy, newEntryUuid] - : [newEntryUuid]; - } - this.schemaArray = Array.from(this.updatedSchema?.values() || []); return { newEntryUuid }; diff --git a/src/common/services/schema/schemaWithDuplicates.interface.ts b/src/common/services/schema/schemaWithDuplicates.interface.ts index 477e0ab3..18a347c7 100644 --- a/src/common/services/schema/schemaWithDuplicates.interface.ts +++ b/src/common/services/schema/schemaWithDuplicates.interface.ts @@ -4,4 +4,6 @@ export interface ISchemaWithDuplicates { set: (schema: Schema) => void; duplicateEntry: (entry: SchemaEntry, isManualDuplication?: boolean) => string | undefined; + + deleteEntry: (entry: SchemaEntry) => string[] | undefined; } diff --git a/src/common/services/schema/schemaWithDuplicates.service.ts b/src/common/services/schema/schemaWithDuplicates.service.ts index 52178790..edf1438d 100644 --- a/src/common/services/schema/schemaWithDuplicates.service.ts +++ b/src/common/services/schema/schemaWithDuplicates.service.ts @@ -4,17 +4,15 @@ import { ISelectedEntries } from '../selectedEntries/selectedEntries.interface'; import { getParentEntryUuid, getUdpatedAssociatedEntries } from '@common/helpers/schema.helper'; import { generateEmptyValueUuid } from '@common/helpers/complexLookup.helper'; import { IEntryPropertiesGeneratorService } from './entryPropertiesGenerator.interface'; +import { MIN_AMT_OF_SIBLING_ENTRIES_TO_BE_DELETABLE } from '@common/constants/bibframe.constants'; export class SchemaWithDuplicatesService implements ISchemaWithDuplicatesService { - private isManualDuplication: boolean; - constructor( private schema: Map, private readonly selectedEntriesService: ISelectedEntries, private readonly entryPropertiesGeneratorService?: IEntryPropertiesGeneratorService, ) { this.set(schema); - this.isManualDuplication = true; } get() { @@ -25,14 +23,13 @@ export class SchemaWithDuplicatesService implements ISchemaWithDuplicatesService this.schema = cloneDeep(schema); } - duplicateEntry(entry: SchemaEntry, isManualDuplication = true) { - this.isManualDuplication = isManualDuplication; - const { uuid, path, children, constraints, clonedBy, cloneOf } = entry; + duplicateEntry(entry: SchemaEntry) { + const { uuid, path, children, constraints, uri = '' } = entry; if (!constraints?.repeatable) return; const updatedEntryUuid = uuidv4(); - const updatedEntry = this.getCopiedEntry(entry, updatedEntryUuid, undefined, true); + const updatedEntry = this.getCopiedEntry(entry, updatedEntryUuid); updatedEntry.children = this.getUpdatedChildren(children, updatedEntry); const parentEntryUuid = getParentEntryUuid(path); @@ -41,56 +38,82 @@ export class SchemaWithDuplicatesService implements ISchemaWithDuplicatesService parentEntry, originalEntryUuid: uuid, updatedEntryUuid: updatedEntryUuid, + childEntryId: uri, }); if (updatedParentEntry) { this.schema.set(parentEntryUuid, updatedParentEntry); this.schema.set(updatedEntryUuid, updatedEntry); - if (this.isManualDuplication && cloneOf) { - // dupicating the field that's a clone - // got to set the initial prototype's properties - const initialPrototype = this.schema.get(cloneOf)!; - - this.schema.set(initialPrototype.uuid, { - ...initialPrototype, - clonedBy: [...(initialPrototype.clonedBy ?? []), updatedEntryUuid], - }); - } else { - this.schema.set(uuid, { - ...entry, - clonedBy: this.isManualDuplication ? [...(clonedBy ?? []), updatedEntryUuid] : undefined, - }); - } - + this.updateDeletabilityAndPositioning(updatedParentEntry?.twinChildren?.[uri]); this.entryPropertiesGeneratorService?.applyHtmlIdToEntries(this.schema); } - this.isManualDuplication = true; return updatedEntryUuid; } - private getCopiedEntry(entry: SchemaEntry, updatedUuid: string, parentElemPath?: string[], includeCloneInfo = false) { - const { path, uuid, cloneIndex = 0, htmlId } = entry; - const copiedEntry = cloneDeep(entry); + deleteEntry(entry: SchemaEntry) { + const { deletable, uuid, path, uri = '' } = entry; - copiedEntry.uuid = updatedUuid; - copiedEntry.path = this.getUpdatedPath(path, updatedUuid, parentElemPath); + if (!deletable) return; - if (includeCloneInfo) { - copiedEntry.cloneIndex = cloneIndex + 1; - } + const parent = this.schema.get(getParentEntryUuid(path)); + const twinSiblings = parent?.twinChildren?.[uri]; - if (htmlId) { - this.entryPropertiesGeneratorService?.addEntryWithHtmlId(updatedUuid); + if (twinSiblings) { + const updatedTwinSiblings = twinSiblings?.filter(twinUuid => twinUuid !== uuid); + + this.schema.set(parent.uuid, { + ...parent, + twinChildren: { + ...parent.twinChildren, + [uri]: updatedTwinSiblings, + }, + children: parent.children?.filter(child => child !== uuid), + }); + + this.updateDeletabilityAndPositioning(updatedTwinSiblings); } - if (this.isManualDuplication && includeCloneInfo) { - if (!copiedEntry.cloneOf) { - copiedEntry.cloneOf = uuid; + const deletedUuids: string[] = []; + + this.deleteEntryAndChildren(entry, deletedUuids); + + return deletedUuids; + } + + private deleteEntryAndChildren(entry?: SchemaEntry, deletedUuids?: string[]) { + if (!entry) return; + + const { children, uuid } = entry; + + if (children) { + for (const child of children) { + this.deleteEntryAndChildren(this.schema.get(child), deletedUuids); } + } + + deletedUuids?.push(uuid); + this.schema.delete(uuid); + } - copiedEntry.clonedBy = undefined; + private updateDeletabilityAndPositioning(uuids: string[] = []) { + const deletable = uuids.length >= MIN_AMT_OF_SIBLING_ENTRIES_TO_BE_DELETABLE; + + uuids.forEach((uuid, cloneIndex) => + this.schema.set(uuid, { ...(this.schema.get(uuid) ?? {}), deletable, cloneIndex } as SchemaEntry), + ); + } + + private getCopiedEntry(entry: SchemaEntry, updatedUuid: string, parentElemPath?: string[]) { + const { path, htmlId } = entry; + const copiedEntry = cloneDeep(entry); + + copiedEntry.uuid = updatedUuid; + copiedEntry.path = this.getUpdatedPath(path, updatedUuid, parentElemPath); + + if (htmlId) { + this.entryPropertiesGeneratorService?.addEntryWithHtmlId(updatedUuid); } return copiedEntry; @@ -104,7 +127,7 @@ export class SchemaWithDuplicatesService implements ISchemaWithDuplicatesService children?.forEach((entryUuid: string, index: number) => { const entry = this.schema.get(entryUuid); - if (!entry || entry.cloneOf) return; + if (!entry) return; const { children } = entry; let updatedEntryUuid = newUuids?.[index] ?? uuidv4(); @@ -119,7 +142,6 @@ export class SchemaWithDuplicatesService implements ISchemaWithDuplicatesService this.schema.set(updatedEntryUuid, copiedEntry); copiedEntry.children = this.getUpdatedChildren(children, copiedEntry); - copiedEntry.clonedBy = []; const { updatedEntry, controlledByEntry } = this.getUpdatedAssociatedEntries({ initialEntry: copiedEntry, @@ -148,10 +170,12 @@ export class SchemaWithDuplicatesService implements ISchemaWithDuplicatesService parentEntry, originalEntryUuid, updatedEntryUuid, + childEntryId, }: { parentEntry?: SchemaEntry; originalEntryUuid: string; updatedEntryUuid: string; + childEntryId?: string; }) { if (!parentEntry) return; @@ -159,6 +183,16 @@ export class SchemaWithDuplicatesService implements ISchemaWithDuplicatesService const { children } = updatedParentEntry; const originalEntryIndex = children?.indexOf(originalEntryUuid); + if (childEntryId) { + if (!updatedParentEntry.twinChildren) { + updatedParentEntry.twinChildren = {}; + } + + updatedParentEntry.twinChildren[childEntryId] = [ + ...new Set([...(updatedParentEntry.twinChildren[childEntryId] ?? []), originalEntryUuid, updatedEntryUuid]), + ]; + } + if (originalEntryIndex !== undefined && originalEntryIndex >= 0) { // Add the UUID of the copied entry to the parent element's array of children, // saving the order of the elements diff --git a/src/components/DuplicateGroup/DuplicateGroup.tsx b/src/components/DuplicateGroup/DuplicateGroup.tsx index cddb4421..da2c8af8 100644 --- a/src/components/DuplicateGroup/DuplicateGroup.tsx +++ b/src/components/DuplicateGroup/DuplicateGroup.tsx @@ -1,7 +1,6 @@ import { FC, memo } from 'react'; import classNames from 'classnames'; import { Button, ButtonType } from '@components/Button'; -import { IS_DISABLED_FOR_ALPHA } from '@common/constants/feature.constants'; import Plus16 from '@src/assets/plus-16.svg?react'; import Trash16 from '@src/assets/trash-16.svg?react'; import { getHtmlIdForSchemaControl } from '@common/helpers/schema.helper'; @@ -9,29 +8,34 @@ import { SchemaControlType } from '@common/constants/uiControls.constants'; import './DuplicateGroup.scss'; interface Props { - onClick?: VoidFunction; + onClickDuplicate?: VoidFunction; + onClickDelete?: VoidFunction; hasDeleteButton?: boolean; + deleteDisabled?: boolean; className?: string; htmlId?: string; } -export const DuplicateGroup: FC = memo(({ onClick, hasDeleteButton = true, className, htmlId }) => ( -
- - {hasDeleteButton && ( +export const DuplicateGroup: FC = memo( + ({ onClickDuplicate, onClickDelete, hasDeleteButton = true, className, htmlId, deleteDisabled = true }) => ( +
- )} -
-)); + {hasDeleteButton && ( + + )} +
+ ), +); diff --git a/src/components/DuplicateGroupContainer/DuplicateGroupContainer.tsx b/src/components/DuplicateGroupContainer/DuplicateGroupContainer.tsx index 275c3b47..afc55052 100644 --- a/src/components/DuplicateGroupContainer/DuplicateGroupContainer.tsx +++ b/src/components/DuplicateGroupContainer/DuplicateGroupContainer.tsx @@ -6,43 +6,54 @@ import { IFields } from '@components/Fields'; import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; import ArrowChevronUp from '@src/assets/arrow-chevron-up.svg?react'; +import { deleteFromSetImmutable } from '@common/helpers/common.helper'; import './DuplicateGroupContainer.scss'; interface IDuplicateGroupContainer { entry: SchemaEntry; generateComponent: (f: Partial) => ReactNode; groupClassName?: string; + twins?: string[]; } -export const DuplicateGroupContainer: FC = ({ entry, generateComponent, groupClassName }) => { - const [collapsedGroups, setCollapsedGroups] = useRecoilState(state.ui.collapsedGroups); - const { uuid, clonedBy } = entry; - const clonesAmount = clonedBy?.length; - const isCollapsed = collapsedGroups.includes(uuid); +export const DuplicateGroupContainer: FC = ({ + entry: { uuid }, + twins = [], + generateComponent, + groupClassName, +}) => { + const [collapsedEntries, setCollapsedEntries] = useRecoilState(state.ui.collapsedEntries); + const twinsAmount = twins.length; + const visibleTwins = twins.filter(twinUuid => !collapsedEntries.has(twinUuid)); + const isCollapsed = visibleTwins.length === 0; + + const toggleCollapseExpand = () => + setCollapsedEntries(prev => { + const twinsAndPrevCombined = new Set([...(twins ?? []), ...prev]); + + // Can use .difference method of Set() once it's been available for some time + return twinsAndPrevCombined.size === prev.size ? deleteFromSetImmutable(prev, twins) : twinsAndPrevCombined; + }); return (
{generateComponent({ uuid, groupingDisabled: true })} - - {!isCollapsed && clonedBy?.map(cloneUuid => generateComponent({ uuid: cloneUuid, groupingDisabled: true }))} + {!!twinsAmount && ( + + )} + {visibleTwins?.map(twinUuid => generateComponent({ uuid: twinUuid, groupingDisabled: true }))}
); }; diff --git a/src/components/DuplicateSubcomponentContainer/DuplicateSubcomponentContainer.tsx b/src/components/DuplicateSubcomponentContainer/DuplicateSubcomponentContainer.tsx index 369be6d0..18c4399c 100644 --- a/src/components/DuplicateSubcomponentContainer/DuplicateSubcomponentContainer.tsx +++ b/src/components/DuplicateSubcomponentContainer/DuplicateSubcomponentContainer.tsx @@ -4,16 +4,17 @@ import { IFields } from '@components/Fields'; interface IDuplicateSubcomponentContainer { entry: SchemaEntry; generateComponent: (f: Partial) => ReactNode; + twins?: string[]; } -export const DuplicateSubcomponentContainer: FC = ({ entry, generateComponent }) => { - const { uuid, clonedBy } = entry; +export const DuplicateSubcomponentContainer: FC = ({ + entry: { uuid }, + twins, + generateComponent, +}) => ( + + {generateComponent({ uuid, groupingDisabled: true })} - return ( - - {generateComponent({ uuid, groupingDisabled: true })} - - {clonedBy?.map(clonedByUuid => generateComponent({ uuid: clonedByUuid, groupingDisabled: true }))} - - ); -}; + {twins?.map(twinUuid => generateComponent({ uuid: twinUuid, groupingDisabled: true }))} + +); diff --git a/src/components/EditSection/EditSection.tsx b/src/components/EditSection/EditSection.tsx index e4d3da98..57e754c1 100644 --- a/src/components/EditSection/EditSection.tsx +++ b/src/components/EditSection/EditSection.tsx @@ -40,8 +40,8 @@ export const EditSection = memo(() => { const [isEdited, setIsEdited] = useRecoilState(state.status.recordIsEdited); const record = useRecoilValue(state.inputs.record); const selectedRecordBlocks = useRecoilValue(state.inputs.selectedRecordBlocks); - const [collapsedGroups, setCollapsedGroups] = useRecoilState(state.ui.collapsedGroups); - const clonePrototypes = useRecoilValue(state.config.clonePrototypes); + const [collapsedEntries, setCollapsedEntries] = useRecoilState(state.ui.collapsedEntries); + const collapsibleEntries = useRecoilValue(state.ui.collapsibleEntries); const currentlyEditedEntityBfid = useRecoilValue(state.ui.currentlyEditedEntityBfid); useContainerEvents({ watchEditedState: true }); @@ -78,7 +78,7 @@ export const EditSection = memo(() => { })); }; - const handleGroupsCollapseExpand = () => setCollapsedGroups(collapsedGroups.length ? [] : clonePrototypes); + const handleGroupsCollapseExpand = () => setCollapsedEntries(collapsedEntries.size ? new Set([]) : collapsibleEntries); const drawComponent = useCallback( ({ schema, entry, disabledFields, level = 0, isCompact = false }: IDrawComponent) => { @@ -97,9 +97,9 @@ export const EditSection = memo(() => { className="entity-heading" > {displayNameWithAltValue} - {!!clonePrototypes.length && ( + {!!collapsibleEntries.size && ( )} @@ -197,7 +197,7 @@ export const EditSection = memo(() => { return null; }, - [selectedEntries, collapsedGroups], + [selectedEntries, collapsedEntries, schema], ); // Uncomment if it is needed to render certain groups of fields disabled, then use it as a prop in Fields component diff --git a/src/components/FieldWithMetadataAndControls/CompactLayout.tsx b/src/components/FieldWithMetadataAndControls/CompactLayout.tsx index 27398caf..5fa33185 100644 --- a/src/components/FieldWithMetadataAndControls/CompactLayout.tsx +++ b/src/components/FieldWithMetadataAndControls/CompactLayout.tsx @@ -4,23 +4,27 @@ import { DuplicateGroup } from '@components/DuplicateGroup'; type ICompactLayout = { children: ReactNode; + entry: SchemaEntry; displayName?: string; showLabel?: boolean; labelContainerClassName?: string; hasDuplicateGroupButton?: boolean; htmlId?: string; onClickDuplicateGroup?: VoidFunction; + onClickDeleteGroup?: VoidFunction; }; export const CompactLayout: FC = memo( ({ children, + entry: { deletable }, displayName, showLabel, labelContainerClassName, htmlId, hasDuplicateGroupButton, onClickDuplicateGroup, + onClickDeleteGroup, }) => { return ( <> @@ -28,7 +32,14 @@ export const CompactLayout: FC = memo(
{children}
- {hasDuplicateGroupButton && } + {hasDuplicateGroupButton && ( + + )} ); }, diff --git a/src/components/FieldWithMetadataAndControls/ExtendedLayout.tsx b/src/components/FieldWithMetadataAndControls/ExtendedLayout.tsx index c2e2e1ed..cdea3915 100644 --- a/src/components/FieldWithMetadataAndControls/ExtendedLayout.tsx +++ b/src/components/FieldWithMetadataAndControls/ExtendedLayout.tsx @@ -14,12 +14,13 @@ type IExtendedLayout = { hasDuplicateGroupButton?: boolean; hasDuplicateSubcomponentButton?: boolean; onClickDuplicateGroup?: VoidFunction; + onClickDeleteGroup?: VoidFunction; }; export const ExtendedLayout: FC = memo( ({ children, - entry, + entry: { type, deletable }, htmlId, displayName, showLabel, @@ -27,16 +28,22 @@ export const ExtendedLayout: FC = memo( hasDuplicateGroupButton, hasDuplicateSubcomponentButton, onClickDuplicateGroup, + onClickDeleteGroup, }) => { - const { type } = entry; - return ( <>
{displayName && showLabel && (
{displayName}
)} - {hasDuplicateGroupButton && } + {hasDuplicateGroupButton && ( + + )}
{children && (
@@ -51,7 +58,7 @@ export const ExtendedLayout: FC = memo( {hasDuplicateSubcomponentButton && ( diff --git a/src/components/FieldWithMetadataAndControls/FieldWithMetadataAndControls.tsx b/src/components/FieldWithMetadataAndControls/FieldWithMetadataAndControls.tsx index 09aa3203..4256f0e2 100644 --- a/src/components/FieldWithMetadataAndControls/FieldWithMetadataAndControls.tsx +++ b/src/components/FieldWithMetadataAndControls/FieldWithMetadataAndControls.tsx @@ -33,7 +33,7 @@ export const FieldWithMetadataAndControls: FC = ( }) => { const schema = useRecoilValue(state.config.schema); const selectedEntries = useRecoilValue(state.config.selectedEntries); - const { getSchemaWithCopiedEntries } = useProfileSchema(); + const { getSchemaWithCopiedEntries, getSchemaWithDeletedEntries } = useProfileSchema(); const { uuid, displayName, htmlId } = entry; const hasDuplicateGroupButton = checkRepeatableGroup({ schema, entry, level, isDisabled: disabled }); @@ -43,7 +43,12 @@ export const FieldWithMetadataAndControls: FC = ( getSchemaWithCopiedEntries(entry, selectedEntries); }; + const onClickDeleteGroup = () => { + getSchemaWithDeletedEntries(entry); + }; + const commonLayoutProps = { + entry, displayName, showLabel, htmlId, @@ -51,6 +56,7 @@ export const FieldWithMetadataAndControls: FC = ( hasDuplicateGroupButton, hasDuplicateSubcomponentButton, onClickDuplicateGroup, + onClickDeleteGroup, }; return ( @@ -67,9 +73,7 @@ export const FieldWithMetadataAndControls: FC = ( {isCompact ? ( {children} ) : ( - - {children} - + {children} )}
); diff --git a/src/components/Fields/Fields.tsx b/src/components/Fields/Fields.tsx index 1b2bf3ac..ff3abc94 100644 --- a/src/components/Fields/Fields.tsx +++ b/src/components/Fields/Fields.tsx @@ -39,18 +39,16 @@ export const Fields: FC = memo( }) => { const schema = useRecoilValue(state.config.schema); const selectedEntries = useRecoilValue(state.config.selectedEntries); - const collapsedGroups = useRecoilValue(state.ui.collapsedGroups); const currentlyEditedEntityBfid = useRecoilValue(state.ui.currentlyEditedEntityBfid); const entry = uuid && schema?.get(uuid); if (!entry) return null; - const { type, bfid = '', children, cloneOf = '' } = entry; + const { type, bfid = '', children, twinChildren } = entry; const isDropdownAndSelected = type === AdvancedFieldType.dropdownOption && selectedEntries.includes(uuid); - const shouldRender = - !collapsedGroups.includes(cloneOf) && (level === ENTITY_LEVEL ? currentlyEditedEntityBfid?.has(bfid) : true); + const shouldRender = level === ENTITY_LEVEL ? currentlyEditedEntityBfid?.has(bfid) : true; const shouldRenderChildren = isDropdownAndSelected || type !== AdvancedFieldType.dropdownOption; const isCompact = !children?.length && level <= groupByLevel; const shouldGroup = !groupingDisabled && level === groupByLevel; @@ -59,6 +57,7 @@ export const Fields: FC = memo( const generateFieldsComponent = ({ ...fieldsProps }: Partial) => ( = memo( children?.map(uuid => { const entry = schema.get(uuid); + if (!entry) return; + + const { uri = '' } = entry; + const twinChildrenOfSameType = twinChildren?.[uri]; + const isFirstTwinChild = twinChildrenOfSameType?.[0] === uuid; + // render cloned / grouped items starting from the main item (prototype) separately - if (entry?.clonedBy) { + if (isFirstTwinChild) { + const restOfTwinChildren = twinChildrenOfSameType.slice(1); + return level === 1 ? ( ) : ( - + ); } // cloned / grouped items already rendered in the prototype - if (entry?.cloneOf) return null; + if (twinChildrenOfSameType) return null; return ( | null>({ default: null, }); -const clonePrototypes = atom({ - key: 'config.clonePrototypes', - default: [], -}); - const hasNavigationOrigin = atom({ key: 'config.hasNavigationOrigin', default: false, @@ -74,6 +69,5 @@ export default { i18nMessages, collectRecordDataForPreview, customEvents, - clonePrototypes, hasNavigationOrigin, }; diff --git a/src/state/ui.ts b/src/state/ui.ts index 49b6157d..c5f30411 100644 --- a/src/state/ui.ts +++ b/src/state/ui.ts @@ -15,9 +15,14 @@ const isDuplicateImportedResourceModalOpen = atom({ default: false, }); -const collapsedGroups = atom({ - key: 'ui.collapsedGroups', - default: [], +const collapsedEntries = atom>({ + key: 'ui.collapsedEntries', + default: new Set(), +}); + +const collapsibleEntries = atom>({ + key: 'ui.collapsibleEntries', + default: new Set(), }); const currentlyEditedEntityBfid = atom>({ @@ -33,8 +38,9 @@ const currentlyPreviewedEntityBfid = atom>({ export default { isAdvancedSearchOpen, isMarcPreviewOpen, - collapsedGroups, + collapsedEntries, currentlyEditedEntityBfid, currentlyPreviewedEntityBfid, isDuplicateImportedResourceModalOpen, + collapsibleEntries, }; diff --git a/src/test/__mocks__/common/hooks/useServicesContext.mock.ts b/src/test/__mocks__/common/hooks/useServicesContext.mock.ts index e3649aa9..7850b4f6 100644 --- a/src/test/__mocks__/common/hooks/useServicesContext.mock.ts +++ b/src/test/__mocks__/common/hooks/useServicesContext.mock.ts @@ -17,6 +17,7 @@ export const schemaWithDuplicatesService = { get: jest.fn(), set: jest.fn(), duplicateEntry: jest.fn(), + deleteEntry: jest.fn(), } as unknown as ISchemaWithDuplicatesService; export const lookupCacheService = { diff --git a/src/test/__tests__/common/hooks/useProfileSchema.test.ts b/src/test/__tests__/common/hooks/useProfileSchema.test.ts new file mode 100644 index 00000000..45cebe54 --- /dev/null +++ b/src/test/__tests__/common/hooks/useProfileSchema.test.ts @@ -0,0 +1,37 @@ +import '@src/test/__mocks__/common/hooks/useServicesContext.mock'; +import { schemaWithDuplicatesService } from '@src/test/__mocks__/common/hooks/useServicesContext.mock'; +import { useProfileSchema } from '@common/hooks/useProfileSchema'; +import { renderHook } from '@testing-library/react'; +import { useSetRecoilState, useRecoilState } from 'recoil'; + +jest.mock('recoil'); + +describe('useProfileSchema', () => { + const entry = { + uri: 'mockUri', + path: ['testKey-0', 'testKey-2', 'testKey-4', 'testKey-6'], + uuid: 'testKey-6', + children: ['nonExistent', 'testKey-7'], + }; + + beforeEach(() => { + (useSetRecoilState as jest.Mock).mockImplementation(jest.fn); + (useRecoilState as jest.Mock).mockReturnValueOnce([new Set(), jest.fn()]); + }); + + test('get schema with copied entries', () => { + const { result } = renderHook(() => useProfileSchema()); + + result.current.getSchemaWithCopiedEntries(entry, []); + + expect(schemaWithDuplicatesService.duplicateEntry).toHaveBeenCalled(); + }) + + test('get schema with deleted entries', () => { + const { result } = renderHook(() => useProfileSchema()); + + result.current.getSchemaWithDeletedEntries(entry); + + expect(schemaWithDuplicatesService.deleteEntry).toHaveBeenCalled(); + }) +}) \ No newline at end of file diff --git a/src/test/__tests__/common/services/schema/schemaWithDuplicates.service.test.ts b/src/test/__tests__/common/services/schema/schemaWithDuplicates.service.test.ts index c48d95ef..19be11d4 100644 --- a/src/test/__tests__/common/services/schema/schemaWithDuplicates.service.test.ts +++ b/src/test/__tests__/common/services/schema/schemaWithDuplicates.service.test.ts @@ -24,16 +24,21 @@ describe('SchemaWithDuplicatesService', () => { let schemaWithDuplicatesService: SchemaWithDuplicatesService; - beforeEach(() => { + const initServices = (altSchema: Schema = schema) => { const selectedEntriesService = new SelectedEntriesService(selectedEntries); - schemaWithDuplicatesService = new SchemaWithDuplicatesService(schema, selectedEntriesService); - }); + schemaWithDuplicatesService = new SchemaWithDuplicatesService(altSchema, selectedEntriesService); + }; describe('duplicateEntry', () => { + beforeEach(initServices); + + const constraints = { repeatable: true } as Constraints; const entry = { path: ['testKey-0', 'testKey-2', 'testKey-4'], uuid: 'testKey-4', + uri: 'mockUri', children: ['testKey-5'], + constraints, }; test('adds a copied entry', () => { @@ -44,21 +49,28 @@ describe('SchemaWithDuplicatesService', () => { .mockReturnValueOnce('testKey-9') .mockReturnValueOnce('testKey-10'); - const constraints = { repeatable: true } as Constraints; const entryData = { ...entry, constraints }; const testResult = new Map([ ['testKey-0', { path: ['testKey-0'], uuid: 'testKey-0', children: ['testKey-1', 'testKey-2'] }], ['testKey-1', { path: ['testKey-0', 'testKey-1'], uuid: 'testKey-1', children: ['testKey-3'] }], - ['testKey-2', { path: ['testKey-0', 'testKey-2'], uuid: 'testKey-2', children: ['testKey-4', 'testKey-7'] }], + [ + 'testKey-2', + { + path: ['testKey-0', 'testKey-2'], + uuid: 'testKey-2', + children: ['testKey-4', 'testKey-7'], + twinChildren: { mockUri: ['testKey-4', 'testKey-7'] }, + }, + ], ['testKey-3', { path: ['testKey-0', 'testKey-1', 'testKey-3'], uuid: 'testKey-3', children: [] }], [ 'testKey-4', { path: ['testKey-0', 'testKey-2', 'testKey-4'], uuid: 'testKey-4', + cloneIndex: 0, children: ['testKey-5'], - clonedBy: ['testKey-7'], - constraints, + deletable: true, }, ], [ @@ -74,11 +86,11 @@ describe('SchemaWithDuplicatesService', () => { { path: ['testKey-0', 'testKey-2', 'testKey-7'], uuid: 'testKey-7', + uri: 'mockUri', cloneIndex: 1, children: ['testKey-8'], + deletable: true, constraints, - cloneOf: 'testKey-4', - clonedBy: undefined, }, ], [ @@ -87,7 +99,6 @@ describe('SchemaWithDuplicatesService', () => { path: ['testKey-0', 'testKey-2', 'testKey-7', 'testKey-8'], uuid: 'testKey-8', children: ['testKey-9'], - clonedBy: [], }, ], [ @@ -96,7 +107,6 @@ describe('SchemaWithDuplicatesService', () => { path: ['testKey-0', 'testKey-2', 'testKey-7', 'testKey-8', 'testKey-9'], uuid: 'testKey-9', children: [], - clonedBy: [], }, ], ]); @@ -115,4 +125,110 @@ describe('SchemaWithDuplicatesService', () => { expect(schemaWithDuplicatesService.get()).toEqual(schema); }); }); + + describe('deleteEntry', () => { + const entry = { + uri: 'mockUri', + path: ['testKey-0', 'testKey-2', 'testKey-4', 'testKey-6'], + uuid: 'testKey-6', + children: ['nonExistent', 'testKey-7'], + deletable: true, + }; + + const getSchema = (altEntry: SchemaEntry = entry, otherEntries: [string, SchemaEntry][] = []) => + new Map([ + ['testKey-0', { path: ['testKey-0'], uuid: 'testKey-0', children: ['testKey-1', 'testKey-2'] }], + ['testKey-1', { path: ['testKey-0', 'testKey-1'], uuid: 'testKey-1', children: ['testKey-3'] }], + ['testKey-2', { path: ['testKey-0', 'testKey-2'], uuid: 'testKey-2', children: ['testKey-4'] }], + ['testKey-3', { path: ['testKey-0', 'testKey-1', 'testKey-3'], uuid: 'testKey-3', children: [] }], + [ + 'testKey-4', + { + path: ['testKey-0', 'testKey-2', 'testKey-4'], + uuid: 'testKey-4', + children: ['testKey-5', altEntry.uuid], + twinChildren: { mockUri: ['testKey-5', altEntry.uuid] }, + }, + ], + [ + 'testKey-5', + { + uri: 'mockUri', + path: ['testKey-0', 'testKey-2', 'testKey-4', 'testKey-5'], + uuid: 'testKey-5', + children: [], + deletable: true, + }, + ], + ['testKey-6', altEntry], + ...otherEntries, + ]); + + test('deletes an entry', () => { + initServices( + getSchema(entry, [ + [ + 'testKey-7', + { + path: ['testKey-0', 'testKey-2', 'testKey-4', 'testKey-6', 'testKey-7'], + uuid: 'testKey-7', + children: [], + }, + ], + ]), + ); + + const testResult = new Map([ + ['testKey-0', { path: ['testKey-0'], uuid: 'testKey-0', children: ['testKey-1', 'testKey-2'] }], + ['testKey-1', { path: ['testKey-0', 'testKey-1'], uuid: 'testKey-1', children: ['testKey-3'] }], + ['testKey-2', { path: ['testKey-0', 'testKey-2'], uuid: 'testKey-2', children: ['testKey-4'] }], + ['testKey-3', { path: ['testKey-0', 'testKey-1', 'testKey-3'], uuid: 'testKey-3', children: [] }], + [ + 'testKey-4', + { + path: ['testKey-0', 'testKey-2', 'testKey-4'], + uuid: 'testKey-4', + children: ['testKey-5'], + twinChildren: { mockUri: ['testKey-5'] }, + }, + ], + [ + 'testKey-5', + { + uri: 'mockUri', + path: ['testKey-0', 'testKey-2', 'testKey-4', 'testKey-5'], + uuid: 'testKey-5', + children: [], + deletable: false, + }, + ], + [ + 'testKey-5', + { + uri: 'mockUri', + path: ['testKey-0', 'testKey-2', 'testKey-4', 'testKey-5'], + uuid: 'testKey-5', + children: [], + cloneIndex: 0, + deletable: false, + }, + ], + ]); + + schemaWithDuplicatesService.deleteEntry(entry); + + expect(schemaWithDuplicatesService.get()).toEqual(testResult); + }); + + test("doesn't delete an entry if it lack deletable property", () => { + const nonDeletableEntry = { ...entry, deletable: false }; + const schema = getSchema(nonDeletableEntry); + + initServices(schema); + + schemaWithDuplicatesService.deleteEntry(nonDeletableEntry); + + expect(schemaWithDuplicatesService.get()).toEqual(schema); + }); + }); }); diff --git a/src/test/__tests__/components/DuplicateGroup.test.tsx b/src/test/__tests__/components/DuplicateGroup.test.tsx index 62edb78b..690726ae 100644 --- a/src/test/__tests__/components/DuplicateGroup.test.tsx +++ b/src/test/__tests__/components/DuplicateGroup.test.tsx @@ -6,7 +6,7 @@ describe('DuplicateGroup', () => { const onClick = jest.fn(); function renderComponent(hasDeleteButton = true) { - render(); + render(); } test('renders DuplicateGroup component', () => { diff --git a/src/test/__tests__/components/DuplicateGroupContainer.test.tsx b/src/test/__tests__/components/DuplicateGroupContainer.test.tsx index ccef8334..88fff311 100644 --- a/src/test/__tests__/components/DuplicateGroupContainer.test.tsx +++ b/src/test/__tests__/components/DuplicateGroupContainer.test.tsx @@ -7,7 +7,6 @@ const mockClonedByUuid = '0xf'; const mockEntry = { uuid: '0xb', - clonedBy: [mockClonedByUuid], }; jest.mock('react-intl', () => ({ @@ -32,6 +31,7 @@ describe('DuplicateGroupContainer', () => {
{uuid}
} + twins={[mockClonedByUuid]} /> , ); diff --git a/src/test/__tests__/components/DuplicateSubcomponentContainer.test.tsx b/src/test/__tests__/components/DuplicateSubcomponentContainer.test.tsx index 69963097..0f41c9a2 100644 --- a/src/test/__tests__/components/DuplicateSubcomponentContainer.test.tsx +++ b/src/test/__tests__/components/DuplicateSubcomponentContainer.test.tsx @@ -4,7 +4,6 @@ import { IFields } from '@components/Fields'; const mockEntry = { uuid: 'testUuid_1', - clonedBy: ['clonedByUuid_1', 'clonedByUuid_2'], } as SchemaEntry; const mockGenerateComponent = ({ uuid }: Partial) => (
@@ -16,7 +15,13 @@ describe('DuplicateSubcomponentContainer', () => { const { getByTestId } = screen; test('renders "DuplicateSubcomponentContainer" with generated subcomponents', () => { - render(); + render( + , + ); expect(getByTestId('test-repeatable-subcomponent-testUuid_1')).toBeInTheDocument(); expect(getByTestId('test-repeatable-subcomponent-clonedByUuid_1')).toBeInTheDocument(); diff --git a/src/test/__tests__/components/EditSection.test.tsx b/src/test/__tests__/components/EditSection.test.tsx index 4dd76bd5..bb820609 100644 --- a/src/test/__tests__/components/EditSection.test.tsx +++ b/src/test/__tests__/components/EditSection.test.tsx @@ -123,6 +123,7 @@ const schema = new Map([ 'uuid6', { bfid: 'uuid6Bfid', + uri: 'uuid6Uri', displayName: 'uuid6', type: AdvancedFieldType.dropdown, path: ['uuid0', 'uuid2', 'uuid6'], @@ -251,13 +252,13 @@ describe('EditSection', () => { }); describe('duplicate groups', () => { - test('duplicates a field group', () => { - const { getByTestId } = renderScreen(); - const parentElement = getByTestId('field-with-meta-controls-uuid6'); + test('duplicates a field group', async () => { + const { findByTestId } = renderScreen(); + const parentElement = await findByTestId('field-with-meta-controls-uuid6'); fireEvent.click(within(parentElement).getByTestId('--addDuplicate')); - expect(getByTestId('duplicate-group-clone-amount')).toBeInTheDocument(); + expect(await findByTestId('duplicate-group-clone-amount')).toBeInTheDocument(); }); test('collapses the duplicated group', async () => { @@ -270,16 +271,5 @@ describe('EditSection', () => { expect(await findAllByText('uuid6')).toHaveLength(1); }); - - test('collapses all duplicated groups', async () => { - const { getByTestId, findAllByText } = renderScreen(); - const parentElement = getByTestId('field-with-meta-controls-uuid6'); - - fireEvent.click(within(parentElement).getByTestId('--addDuplicate')); - - fireEvent.click((await findAllByText('ld.collapseAll'))[0]); - - expect(await findAllByText('uuid6')).toHaveLength(1); - }); }); }); diff --git a/src/types/render.d.ts b/src/types/render.d.ts index b1efcf81..2ac5a585 100644 --- a/src/types/render.d.ts +++ b/src/types/render.d.ts @@ -76,14 +76,14 @@ type Constraints = { }; type SchemaEntry = { - path: Array; + path: string[]; uuid: string; bfid?: string; uri?: string; uriBFLite?: string; displayName?: string; type?: string; - children?: Array; + children?: string[]; constraints?: Constraints; cloneOf?: string; clonedBy?: string[]; @@ -92,6 +92,8 @@ type SchemaEntry = { linkedEntry?: LinkedEntry; htmlId?: string; cloneIndex?: number; + twinChildren?: Record; + deletable?: boolean; }; type LinkedEntry = {