Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion src/app/core/interceptors/auth.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const authInterceptor: HttpInterceptorFn = (

const csrfToken = cookieService.get('api-csrf');

if (!req.url.includes('/api.crossref.org/funders')) {
if (!req.url.includes('/api.ror')) {
const headers: Record<string, string> = {};

headers['Accept'] = req.responseType === 'text' ? '*/*' : 'application/vnd.api+json;version=2.20';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@
<p-select
[id]="'funderName-' + $index"
formControlName="funderName"
[options]="funderOptions()"
optionLabel="label"
optionValue="value"
[options]="getOptionsForIndex($index)"
optionLabel="name"
optionValue="name"
[placeholder]="'project.metadata.funding.dialog.selectFunder' | translate"
class="w-full"
[filter]="true"
filterBy="label"
filterBy="name"
[showClear]="true"
[loading]="fundersLoading()"
[emptyFilterMessage]="filterMessage() | translate"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import { Store } from '@ngxs/store';

import { MockProvider, MockProviders } from 'ng-mocks';

import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';

import { DestroyRef } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DestroyRef, signal } from '@angular/core';
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';

import { MetadataSelectors } from '../../store';
import { RorFunderOption } from '../../models/ror.model';
import { GetFundersList, MetadataSelectors } from '../../store';

import { FundingDialogComponent } from './funding-dialog.component';

import { MOCK_FUNDERS } from '@testing/mocks/funder.mock';
import { OSFTestingModule } from '@testing/osf.testing.module';
import { provideMockStore } from '@testing/providers/store-provider.mock';

const MOCK_ROR_FUNDERS: RorFunderOption[] = [{ id: 'https://ror.org/0test', name: 'Test Funder' }];

describe('FundingDialogComponent', () => {
let component: FundingDialogComponent;
let fixture: ComponentFixture<FundingDialogComponent>;
Expand All @@ -25,7 +29,7 @@ describe('FundingDialogComponent', () => {
MockProvider(DynamicDialogConfig, { data: { funders: [] } }),
provideMockStore({
signals: [
{ selector: MetadataSelectors.getFundersList, value: MOCK_FUNDERS },
{ selector: MetadataSelectors.getFundersList, value: MOCK_ROR_FUNDERS },
{ selector: MetadataSelectors.getFundersLoading, value: false },
],
}),
Expand All @@ -41,22 +45,15 @@ describe('FundingDialogComponent', () => {
expect(component).toBeTruthy();
});

it('should add funding entry', () => {
const initialLength = component.fundingEntries.length;
component.addFundingEntry();

expect(component.fundingEntries.length).toBe(initialLength + 1);
const entry = component.fundingEntries.at(component.fundingEntries.length - 1);
expect(entry.get('funderName')?.value).toBe(null);
expect(entry.get('awardTitle')?.value).toBe('');
});

it('should not remove funding entry when only one exists', () => {
it('should not remove last funding entry and close dialog with empty result', () => {
const dialogRef = TestBed.inject(DynamicDialogRef);
const closeSpy = jest.spyOn(dialogRef, 'close');
expect(component.fundingEntries.length).toBe(1);

component.removeFundingEntry(0);

expect(component.fundingEntries.length).toBe(1);
expect(closeSpy).toHaveBeenCalledWith({ fundingEntries: [] });
});

it('should save valid form data', () => {
Expand Down Expand Up @@ -145,16 +142,19 @@ describe('FundingDialogComponent', () => {
expect(entry.get('funderIdentifierType')?.value).toBe(initialValues.funderIdentifierType);
});

it('should remove funding entry when more than one exists', () => {
component.addFundingEntry();
expect(component.fundingEntries.length).toBe(2);
it('should update funding entry when funder is selected from ROR list', () => {
const entry = component.fundingEntries.at(0);

component.removeFundingEntry(0);
expect(component.fundingEntries.length).toBe(1);
component.onFunderSelected('Test Funder', 0);

expect(entry.get('funderName')?.value).toBe('Test Funder');
expect(entry.get('funderIdentifier')?.value).toBe('https://ror.org/0test');
expect(entry.get('funderIdentifierType')?.value).toBe('ROR');
});

it('should not remove funding entry when only one exists', () => {
expect(component.fundingEntries.length).toBe(1);
it('should remove funding entry when more than one exists', () => {
component.addFundingEntry();
expect(component.fundingEntries.length).toBe(2);

component.removeFundingEntry(0);
expect(component.fundingEntries.length).toBe(1);
Expand Down Expand Up @@ -227,32 +227,117 @@ describe('FundingDialogComponent', () => {
expect(entry.get('awardNumber')?.value).toBe('');
});

it('should emit search query to searchSubject', () => {
const searchSpy = jest.spyOn(component['searchSubject'], 'next');

component.onFunderSearch('test search');

expect(searchSpy).toHaveBeenCalledWith('test search');
it('should dispatch getFundersList after debounce when searching', fakeAsync(() => {
const store = TestBed.inject(Store);
const dispatchSpy = jest.spyOn(store, 'dispatch');

component.onFunderSearch('query');
expect(dispatchSpy).not.toHaveBeenCalled();
tick(300);
expect(dispatchSpy).toHaveBeenCalledWith(new GetFundersList('query'));
}));

it('should pre-populate entries from config funders on init', () => {
TestBed.resetTestingModule();
const configFunders = [
{
funderName: 'NSF',
funderIdentifier: 'https://ror.org/nsf',
funderIdentifierType: 'ROR',
awardTitle: 'Grant A',
awardUri: 'https://example.com/a',
awardNumber: '123',
},
];
TestBed.configureTestingModule({
imports: [FundingDialogComponent, OSFTestingModule],
providers: [
MockProviders(DynamicDialogRef, DestroyRef),
MockProvider(DynamicDialogConfig, { data: { funders: configFunders } }),
provideMockStore({
signals: [
{ selector: MetadataSelectors.getFundersList, value: [] },
{ selector: MetadataSelectors.getFundersLoading, value: false },
],
}),
],
}).compileComponents();
const f = TestBed.createComponent(FundingDialogComponent);
f.detectChanges();
const c = f.componentInstance;
expect(c.fundingEntries.length).toBe(1);
const entry = c.fundingEntries.at(0);
expect(entry.get('funderName')?.value).toBe('NSF');
expect(entry.get('funderIdentifier')?.value).toBe('https://ror.org/nsf');
expect(entry.get('funderIdentifierType')?.value).toBe('ROR');
expect(entry.get('awardTitle')?.value).toBe('Grant A');
expect(entry.get('awardUri')?.value).toBe('https://example.com/a');
expect(entry.get('awardNumber')?.value).toBe('123');
});

it('should handle empty search term', () => {
const searchSpy = jest.spyOn(component['searchSubject'], 'next');

component.onFunderSearch('');
it('getOptionsForIndex returns custom option plus list when entry name is not in list', () => {
const entry = component.fundingEntries.at(0);
entry.patchValue({ funderName: 'Custom Funder', funderIdentifier: 'custom-id' });
const options = component.getOptionsForIndex(0);
expect(options).toHaveLength(2);
expect(options[0]).toEqual({ id: 'custom-id', name: 'Custom Funder' });
expect(options[1]).toEqual(MOCK_ROR_FUNDERS[0]);
});

expect(searchSpy).toHaveBeenCalledWith('');
it('getOptionsForIndex returns list when entry has no name', () => {
const options = component.getOptionsForIndex(0);
expect(options).toEqual(MOCK_ROR_FUNDERS);
});

it('should handle multiple search calls', () => {
const searchSpy = jest.spyOn(component['searchSubject'], 'next');
it('filterMessage returns loading key when funders loading', () => {
TestBed.resetTestingModule();
const loadingSignal = signal(true);
TestBed.configureTestingModule({
imports: [FundingDialogComponent, OSFTestingModule],
providers: [
MockProviders(DynamicDialogRef, DestroyRef),
MockProvider(DynamicDialogConfig, { data: { funders: [] } }),
provideMockStore({
signals: [
{ selector: MetadataSelectors.getFundersList, value: [] },
{ selector: MetadataSelectors.getFundersLoading, value: loadingSignal },
],
}),
],
}).compileComponents();
const f = TestBed.createComponent(FundingDialogComponent);
f.detectChanges();
expect(f.componentInstance.filterMessage()).toBe('project.metadata.funding.dialog.loadingFunders');
loadingSignal.set(false);
expect(f.componentInstance.filterMessage()).toBe('project.metadata.funding.dialog.noFundersFound');
});

component.onFunderSearch('first');
component.onFunderSearch('second');
component.onFunderSearch('third');
it('save returns only entries with at least one of funderName, awardTitle, awardUri, awardNumber', () => {
const dialogRef = TestBed.inject(DynamicDialogRef);
const closeSpy = jest.spyOn(dialogRef, 'close');
component.addFundingEntry();
component.fundingEntries.at(0).patchValue({ funderName: 'Funder A', awardTitle: 'Award A' });
component.fundingEntries.at(1).patchValue({ funderName: 'Funder B', awardTitle: 'Award B' });
fixture.detectChanges();
component.save();
expect(closeSpy).toHaveBeenCalledWith({
fundingEntries: [
expect.objectContaining({ funderName: 'Funder A', awardTitle: 'Award A' }),
expect.objectContaining({ funderName: 'Funder B', awardTitle: 'Award B' }),
],
});
});

expect(searchSpy).toHaveBeenCalledTimes(3);
expect(searchSpy).toHaveBeenNthCalledWith(1, 'first');
expect(searchSpy).toHaveBeenNthCalledWith(2, 'second');
expect(searchSpy).toHaveBeenNthCalledWith(3, 'third');
it('should not save when awardUri is invalid', () => {
const dialogRef = TestBed.inject(DynamicDialogRef);
const closeSpy = jest.spyOn(dialogRef, 'close');
const entry = component.fundingEntries.at(0);
entry.patchValue({
funderName: 'Test Funder',
awardUri: 'not-a-valid-url',
});
fixture.detectChanges();
component.save();
expect(closeSpy).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@ import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } fr

import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper';

import { Funder, FundingDialogResult, FundingEntryForm, FundingForm, SupplementData } from '../../models';
import {
Funder,
FundingDialogResult,
FundingEntryForm,
FundingForm,
RorFunderOption,
SupplementData,
} from '../../models';
import { GetFundersList, MetadataSelectors } from '../../store';

@Component({
Expand All @@ -33,15 +40,6 @@ export class FundingDialogComponent implements OnInit {

fundersList = select(MetadataSelectors.getFundersList);
fundersLoading = select(MetadataSelectors.getFundersLoading);
funderOptions = computed(() => {
const funders = this.fundersList() || [];
return funders.map((funder) => ({
label: funder.name,
value: funder.name,
id: funder.id,
uri: funder.uri,
}));
});

fundingForm = new FormGroup<FundingForm>({ fundingEntries: new FormArray<FormGroup<FundingEntryForm>>([]) });

Expand Down Expand Up @@ -108,6 +106,18 @@ export class FundingDialogComponent implements OnInit {
});
}

getOptionsForIndex(index: number): RorFunderOption[] {
const list = this.fundersList() ?? [];
const entry = this.fundingEntries.at(index);
const name = entry?.get('funderName')?.value;

if (!name || list.some((f) => f.name === name)) {
return list;
}

return [{ id: entry?.get('funderIdentifier')?.value ?? '', name }, ...list];
}

addFundingEntry(supplement?: SupplementData): void {
const entry = this.createFundingEntryGroup(supplement);
this.fundingEntries.push(entry);
Expand All @@ -132,8 +142,8 @@ export class FundingDialogComponent implements OnInit {
const entry = this.fundingEntries.at(index);
entry.patchValue({
funderName: selectedFunder.name,
funderIdentifier: selectedFunder.uri,
funderIdentifierType: 'Crossref Funder ID',
funderIdentifier: selectedFunder.id,
funderIdentifierType: 'ROR',
});
}
}
Expand Down
1 change: 1 addition & 0 deletions src/app/features/metadata/mappers/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './cedar-records.mapper';
export * from './metadata.mapper';
export * from './ror.mapper';
19 changes: 19 additions & 0 deletions src/app/features/metadata/mappers/ror.mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { RorFunderOption, RorOrganization, RorSearchResponse } from '../models/ror.model';

export class RorMapper {
static toFunderOptions(response: RorSearchResponse): RorFunderOption[] {
return response.items.map((org) => ({
id: org.id,
name: this.getRorDisplayName(org),
}));
}

static getRorDisplayName(org: RorOrganization): string {
const rorDisplay = org.names?.find((n) => n.types?.includes('ror_display'));
if (rorDisplay?.value) return rorDisplay.value;
const label = org.names?.find((n) => n.types?.includes('label'));
if (label?.value) return label.value;
if (org.names?.length && org.names[0].value) return org.names[0].value;
return org.id ?? '';
}
}
1 change: 1 addition & 0 deletions src/app/features/metadata/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './funding-dialog.model';
export * from './metadata.model';
export * from './metadata-json-api.model';
export * from './resource-information-form.model';
export * from './ror.model';
30 changes: 0 additions & 30 deletions src/app/features/metadata/models/metadata.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,33 +40,3 @@ export interface Funder {
awardUri: string;
awardTitle: string;
}

export interface CrossRefFundersResponse {
status: string;
'message-type': string;
'message-version': string;
message: CrossRefFundersMessage;
}

export interface CrossRefFundersMessage {
'items-per-page': number;
query: CrossRefQuery;
'total-results': number;
items: CrossRefFunder[];
}

export interface CrossRefQuery {
'start-index': number;
'search-terms': string | null;
}

export interface CrossRefFunder {
id: string;
location: string;
name: string;
'alt-names': string[];
uri: string;
replaces: string[];
'replaced-by': string[];
tokens: string[];
}
Loading