Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
27686dc
136580: Submission - Replace plaintext with relationship
Atmire-Kristof Dec 3, 2025
73eb506
Merge remote-tracking branch 'atmire/w2p-112198_add-relationship-effe…
Atmire-Kristof Dec 3, 2025
9d45fd0
136580: replace relationship queue post-merge
Atmire-Kristof Dec 3, 2025
80070a8
136580: Lint fix
Atmire-Kristof Dec 3, 2025
7e925da
Merge remote-tracking branch 'dspace/main' into w2p-136580_Replace-pl…
Atmire-Kristof Dec 3, 2025
b4c244e
136580: Fixed selectObject being called twice for non-repeatable fields
alexandrevryghem Dec 4, 2025
df0277b
136580: Submission select multiple relationships fix
Atmire-Kristof Dec 10, 2025
0fff8f5
Merge branch 'w2p-136580_Replace-plaintext-relationship-7.6' into w2p…
Atmire-Kristof Dec 10, 2025
00f3a38
138978: Replace plaintext relationship submission object patch fix
Atmire-Kristof Mar 6, 2026
1a643f5
Merge remote-tracking branch 'dspace/main' into w2p-136580_Replace-pl…
Atmire-Kristof Mar 9, 2026
9c93729
Merge branch 'w2p-136580_Replace-plaintext-relationship-7.6' into w2p…
Atmire-Kristof Mar 9, 2026
ccb6d03
138978: Optional sectionDataProvider
Atmire-Kristof Mar 9, 2026
19f40d7
Merge branch 'w2p-136580_Replace-plaintext-relationship-7.6' into w2p…
Atmire-Kristof Mar 9, 2026
e4dc497
141370: Fix lookup replace for non-repeatable and fresh values
Atmire-Kristof May 15, 2026
b09fef7
Merge branch 'w2p-136580_Replace-plaintext-relationship-7.6' into w2p…
Atmire-Kristof May 15, 2026
558a11f
Merge remote-tracking branch 'dspace/main' into w2p-136580_Replace-pl…
Atmire-Kristof May 15, 2026
1addfc6
136580: Fix change-detection to detect authority changes
Atmire-Kristof May 27, 2026
acabb5a
Merge branch 'w2p-136580_Replace-plaintext-relationship-7.6' into w2p…
Atmire-Kristof May 27, 2026
b7f47bc
141370: Prevent race condition between requests on relationship lookup
Atmire-Kristof Jun 1, 2026
c963c69
Merge branch 'w2p-136580_Replace-plaintext-relationship-7.6' into w2p…
Atmire-Kristof Jun 1, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,11 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
modalComp.query = this.model.value;
} else if (typeof this.model.value.value === 'string') {
modalComp.query = this.model.value.value;
// If the existing value is not virtual, store properties on the modal required to perform a replace operation
if (!this.model.value.isVirtual) {
modalComp.replaceValuePlace = this.model.value.place;
modalComp.replaceValueMetadataField = this.model.name;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { NameVariantService } from './name-variant.service';
import {
AddRelationshipAction,
RemoveRelationshipAction,
ReplaceRelationshipAction,
} from './relationship.actions';

describe('DsDynamicLookupRelationModalComponent', () => {
Expand All @@ -54,9 +55,11 @@ describe('DsDynamicLookupRelationModalComponent', () => {
let item;
let item1;
let item2;
let item3;
let testWSI;
let searchResult1;
let searchResult2;
let searchResult3;
let listID;
let selection$;
let selectableListService;
Expand Down Expand Up @@ -90,11 +93,13 @@ describe('DsDynamicLookupRelationModalComponent', () => {
item = Object.assign(new Item(), { uuid: '7680ca97-e2bd-4398-bfa7-139a8673dc42', metadata: {} });
item1 = Object.assign(new Item(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' });
item2 = Object.assign(new Item(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' });
item3 = Object.assign(new Item(), { uuid: '6264b66f-ae25-4221-b72a-8696536c5ebb' });
testWSI = new WorkspaceItem();
testWSI.item = createSuccessfulRemoteDataObject$(item);
testWSI.collection = createSuccessfulRemoteDataObject$(collection);
searchResult1 = Object.assign(new ItemSearchResult(), { indexableObject: item1 });
searchResult2 = Object.assign(new ItemSearchResult(), { indexableObject: item2 });
searchResult3 = Object.assign(new ItemSearchResult(), { indexableObject: item3 });
listID = '6b0c8221-fcb4-47a8-b483-ca32363fffb3';
selection$ = of([searchResult1, searchResult2]);
selectableListService = { getSelectableList: () => selection$ };
Expand Down Expand Up @@ -197,13 +202,37 @@ describe('DsDynamicLookupRelationModalComponent', () => {
spyOn((component as any).store, 'dispatch');
});

it('should dispatch an AddRelationshipAction for each selected object', () => {
component.select(searchResult1, searchResult2);
const action = new AddRelationshipAction(component.item, searchResult1.indexableObject, relationship.relationshipType, submissionId, nameVariant);
const action2 = new AddRelationshipAction(component.item, searchResult2.indexableObject, relationship.relationshipType, submissionId, nameVariant);
describe('when replace properties are present', () => {
beforeEach(() => {
component.replaceValuePlace = 3;
component.replaceValueMetadataField = 'dc.subject';
});

expect((component as any).store.dispatch).toHaveBeenCalledWith(action);
expect((component as any).store.dispatch).toHaveBeenCalledWith(action2);
it('should dispatch a ReplaceRelationshipAction for the first selected object and a AddRelationshipAction for every other selected object', () => {
component.select(searchResult1, searchResult2, searchResult3);
const action1 = new ReplaceRelationshipAction(component.item, searchResult1.indexableObject, true, 3, 'dc.subject', relationship.relationshipType, submissionId, nameVariant);
const action2 = new AddRelationshipAction(component.item, searchResult2.indexableObject, relationship.relationshipType, submissionId, nameVariant);
const action3 = new AddRelationshipAction(component.item, searchResult3.indexableObject, relationship.relationshipType, submissionId, nameVariant);

expect((component as any).store.dispatch).toHaveBeenCalledWith(action1);
expect((component as any).store.dispatch).toHaveBeenCalledWith(action2);
expect((component as any).store.dispatch).toHaveBeenCalledWith(action3);
expect(component.replaceValuePlace).toBeUndefined();
expect(component.replaceValueMetadataField).toBeUndefined();
});
});

describe('when replace properties are missing', () => {
it('should dispatch an AddRelationshipAction for each selected object', () => {
component.select(searchResult1, searchResult2, searchResult3);
const action1 = new AddRelationshipAction(component.item, searchResult1.indexableObject, relationship.relationshipType, submissionId, nameVariant);
const action2 = new AddRelationshipAction(component.item, searchResult2.indexableObject, relationship.relationshipType, submissionId, nameVariant);
const action3 = new AddRelationshipAction(component.item, searchResult3.indexableObject, relationship.relationshipType, submissionId, nameVariant);

expect((component as any).store.dispatch).toHaveBeenCalledWith(action1);
expect((component as any).store.dispatch).toHaveBeenCalledWith(action2);
expect((component as any).store.dispatch).toHaveBeenCalledWith(action3);
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { NameVariantService } from './name-variant.service';
import {
AddRelationshipAction,
RemoveRelationshipAction,
ReplaceRelationshipAction,
UpdateRelationshipNameVariantAction,
} from './relationship.actions';
import { ThemedDynamicLookupRelationSearchTabComponent } from './search-tab/themed-dynamic-lookup-relation-search-tab.component';
Expand Down Expand Up @@ -148,6 +149,17 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
*/
hiddenQuery: string;

/**
* The index of the plain-text value that should be replaced by adding a relationship
*/
replaceValuePlace: number;

/**
* The metadata field of the value to replace with a relationship
* Undefined if no value needs replacing
*/
replaceValueMetadataField: string;

/**
* A map of subscriptions within this component
*/
Expand Down Expand Up @@ -302,9 +314,17 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
]);
obs
.subscribe((arr: any[]) => {
return arr.forEach((object: any) => {
const addRelationshipAction = new AddRelationshipAction(this.item, object.item, this.relationshipOptions.relationshipType, this.submissionId, object.nameVariant);
this.store.dispatch(addRelationshipAction);
return arr.forEach((object: any, i: number) => {
let action;
if (i === 0 && hasValue(this.replaceValueMetadataField)) {
// This is the first action this modal performs and "replace" properties are present to replace an existing metadata value
action = new ReplaceRelationshipAction(this.item, object.item, true, this.replaceValuePlace, this.replaceValueMetadataField, this.relationshipOptions.relationshipType, this.submissionId, object.nameVariant);
// Only "replace" once, reset replace properties so future actions become "add"
this.resetReplaceProperties();
} else {
action = new AddRelationshipAction(this.item, object.item, this.relationshipOptions.relationshipType, this.submissionId, object.nameVariant);
}
this.store.dispatch(action);
},
);
});
Expand All @@ -327,6 +347,7 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
* @param selectableObjects
*/
deselect(...selectableObjects: SearchResult<DSpaceObject>[]) {
this.resetReplaceProperties();
this.zone.runOutsideAngular(
() => selectableObjects.forEach((object) => {
this.subMap[object.indexableObject.uuid].unsubscribe();
Expand Down Expand Up @@ -364,6 +385,11 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
this.totalInternal$.next(totalPages);
}

private resetReplaceProperties() {
this.replaceValueMetadataField = undefined;
this.replaceValuePlace = undefined;
}

ngOnDestroy() {
this.router.navigate([], {});
Object.values(this.subMap).forEach((subscription) => subscription.unsubscribe());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Action } from '@ngrx/store';

export const RelationshipActionTypes = {
ADD_RELATIONSHIP: type('dspace/relationship/ADD_RELATIONSHIP'),
REPLACE_RELATIONSHIP: type('dspace/relationship/REPLACE_RELATIONSHIP'),
REMOVE_RELATIONSHIP: type('dspace/relationship/REMOVE_RELATIONSHIP'),
UPDATE_NAME_VARIANT: type('dspace/relationship/UPDATE_NAME_VARIANT'),
UPDATE_RELATIONSHIP: type('dspace/relationship/UPDATE_RELATIONSHIP'),
Expand Down Expand Up @@ -132,10 +133,53 @@ export class RemoveRelationshipAction implements Action {
}
}

/**
* An ngrx action to replace a plain-text metadata value with a new relationship
*/
export class ReplaceRelationshipAction implements Action {
type = RelationshipActionTypes.REPLACE_RELATIONSHIP;

payload: {
item1: Item;
item2: Item;
replaceLeftSide: boolean;
place: number;
mdField: string;
relationshipType: string;
submissionId: string;
nameVariant: string;
};

/**
* Create a new AddRelationshipAction
*
* @param item1 The first item in the relationship
* @param item2 The second item in the relationship
* @param replaceLeftSide If true, the item on the left side (item1) will have its metadata value replaced
* @param place The index of the metadata value that should be replaced with the new relationship
* @param mdField The metadata field of the value to replace
* @param relationshipType The label of the relationshipType
* @param submissionId The current submissionId
* @param nameVariant The nameVariant of the relationshipType
*/
constructor(
item1: Item,
item2: Item,
replaceLeftSide: boolean,
place: number,
mdField: string,
relationshipType: string,
submissionId: string,
nameVariant?: string,
) {
this.payload = { item1, item2, replaceLeftSide, place, mdField, relationshipType, submissionId, nameVariant };
}
}

/**
* A type to encompass all RelationshipActions
*/
export type RelationshipAction
= AddRelationshipAction
| ReplaceRelationshipAction
| RemoveRelationshipAction;
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@ import {
} from 'rxjs';
import { last } from 'rxjs/operators';

import { ItemDataService } from '../../../../../core/data/item-data.service';
import { SubmissionObjectService } from '../../../../../submission/submission-object.service';
import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service';
import {
AddRelationshipAction,
RelationshipActionTypes,
RemoveRelationshipAction,
ReplaceRelationshipAction,
} from './relationship.actions';
import { RelationshipEffects } from './relationship.effects';

Expand Down Expand Up @@ -66,6 +68,7 @@ describe('RelationshipEffects', () => {
let notificationsService;
let translateService;
let selectableListService;
let itemService;

function init() {
testUUID1 = '20e24c2f-a00a-467c-bdee-c929e79bf08d';
Expand Down Expand Up @@ -108,8 +111,8 @@ describe('RelationshipEffects', () => {
getRelationshipByItemsAndLabel:
() => of(relationship),
deleteRelationship: () => of(new RestResponse(true, 200, 'OK')),
addRelationship: () => of(new RestResponse(true, 200, 'OK')),

addRelationship: () => createSuccessfulRemoteDataObject$(new Relationship()),
update: () => createSuccessfulRemoteDataObject$(new Relationship()),
};
mockRelationshipTypeService = {
getRelationshipTypeByLabelAndTypes:
Expand All @@ -123,6 +126,9 @@ describe('RelationshipEffects', () => {
findSelectedByCondition: of({}),
deselectSingle: {},
});
itemService = jasmine.createSpyObj('itemService', {
patch: createSuccessfulRemoteDataObject$(new Item()),
});
}

beforeEach(waitForAsync(() => {
Expand All @@ -133,6 +139,7 @@ describe('RelationshipEffects', () => {
provideMockActions(() => actions),
{ provide: RelationshipTypeDataService, useValue: mockRelationshipTypeService },
{ provide: RelationshipDataService, useValue: mockRelationshipService },
{ provide: ItemDataService, useValue: itemService },
{
provide: SubmissionObjectService, useValue: {
findById: () => createSuccessfulRemoteDataObject$(new WorkspaceItem()),
Expand All @@ -155,6 +162,7 @@ describe('RelationshipEffects', () => {
identifier = (relationEffects as any).createIdentifier(leftItem, rightItem, relationshipType.leftwardType);
spyOn((relationEffects as any), 'addRelationship').and.stub();
spyOn((relationEffects as any), 'removeRelationship').and.stub();
spyOn((relationEffects as any), 'replaceRelationship').and.stub();
});

describe('mapLastActions$', () => {
Expand Down Expand Up @@ -225,6 +233,75 @@ describe('RelationshipEffects', () => {
});
});

describe('When a REPLACE_RELATIONSHIP action is triggered', () => {
describe('When it\'s the first time for this identifier', () => {
let action;

it('should set the current value debounceMap and the value of the initialActionMap to REPLACE_RELATIONSHIP', () => {
action = new ReplaceRelationshipAction(leftItem, rightItem, true, 0, 'dc.subject', relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action });
const expected = cold('--b-|', { b: undefined });
expect(relationEffects.mapLastActions$).toBeObservable(expected);

expect((relationEffects as any).initialActionMap[identifier]).toBe(action.type);
expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type);
});
});

describe('When it\'s not the first time for this identifier', () => {
let action;
const testActionType = 'TEST_TYPE';
beforeEach(() => {
(relationEffects as any).initialActionMap[identifier] = testActionType;
(relationEffects as any).debounceMap[identifier] = new BehaviorSubject<string>(testActionType);
});

it('should set the current value debounceMap to REPLACE_RELATIONSHIP but not change the value of the initialActionMap', () => {
action = new ReplaceRelationshipAction(leftItem, rightItem, true, 0, 'dc.subject', relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action });

const expected = cold('--b-|', { b: undefined });
expect(relationEffects.mapLastActions$).toBeObservable(expected);

expect((relationEffects as any).initialActionMap[identifier]).toBe(testActionType);
expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type);
});
});

describe('When the initialActionMap contains a REPLACE_RELATIONSHIP action', () => {
let action;
describe('When the last value in the debounceMap is also a REPLACE_RELATIONSHIP action', () => {
beforeEach(() => {
jasmine.getEnv().allowRespy(true);
spyOn((relationEffects as any), 'replaceRelationship').and.returnValue(createSuccessfulRemoteDataObject$(relationship));
spyOn((relationEffects as any).relationshipService, 'update').and.callThrough();
((relationEffects as any).debounceTime as jasmine.Spy).and.returnValue((v) => v);
(relationEffects as any).initialActionMap[identifier] = RelationshipActionTypes.REPLACE_RELATIONSHIP;
});

it('should call replaceRelationship on the effect', () => {
action = new ReplaceRelationshipAction(leftItem, rightItem, true, 0, 'dc.subject', relationshipType.leftwardType, '1234');
actions = hot('--a-|', { a: action });
const expected = cold('--b-|', { b: undefined });
expect(relationEffects.mapLastActions$).toBeObservable(expected);
expect((relationEffects as any).replaceRelationship).toHaveBeenCalledWith(leftItem, rightItem, true, 0, 'dc.subject', relationshipType.leftwardType, '1234', undefined);
});
});

describe('When the last value in the debounceMap is instead a REMOVE_RELATIONSHIP action', () => {
it('should <b>not</b> call removeRelationship or replaceRelationship on the effect', () => {
const actiona = new ReplaceRelationshipAction(leftItem, rightItem, true, 0, 'dc.subject', relationshipType.leftwardType, '1234');
const actionb = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType, '1234');
actions = hot('--ab-|', { a: actiona, b: actionb });
const expected = cold('--bb-|', { b: undefined });
expect(relationEffects.mapLastActions$).toBeObservable(expected);
expect((relationEffects as any).replaceRelationship).not.toHaveBeenCalled();
expect((relationEffects as any).removeRelationship).not.toHaveBeenCalled();
});
});
});
});

describe('When an REMOVE_RELATIONSHIP action is triggered', () => {
describe('When it\'s the first time for this identifier', () => {
let action;
Expand Down
Loading
Loading