Skip to content
Open
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
4 changes: 3 additions & 1 deletion src/app/core/interceptors/auth.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Observable } from 'rxjs';
import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';

import { environment } from 'src/environments/environment';

export const authInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
Expand All @@ -13,7 +15,7 @@ export const authInterceptor: HttpInterceptorFn = (

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

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

headers['Accept'] = req.responseType === 'text' ? '*/*' : 'application/vnd.api+json;version=2.20';
Expand Down
4 changes: 3 additions & 1 deletion src/app/core/interceptors/view-only.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { Router } from '@angular/router';

import { getViewOnlyParam } from '@osf/shared/helpers/view-only.helper';

import { environment } from 'src/environments/environment';

export const viewOnlyInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
Expand All @@ -14,7 +16,7 @@ export const viewOnlyInterceptor: HttpInterceptorFn = (

const viewOnlyParam = getViewOnlyParam(router);

if (!req.url.includes('/api.crossref.org/funders') && viewOnlyParam) {
if (!req.url.startsWith(environment.funderApiUrl) && viewOnlyParam) {
if (req.url.includes('view_only=')) {
return next(req);
}
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,23 @@
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 +30,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 +46,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 +143,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 All @@ -172,7 +173,7 @@ describe('FundingDialogComponent', () => {
const supplement = {
funderName: 'Test Funder',
funderIdentifier: 'test-id',
funderIdentifierType: 'Crossref Funder ID',
funderIdentifierType: 'ROR',
title: 'Test Award',
url: 'https://test.com',
awardNumber: 'AWARD-123',
Expand All @@ -183,7 +184,7 @@ describe('FundingDialogComponent', () => {
const entry = component.fundingEntries.at(component.fundingEntries.length - 1);
expect(entry.get('funderName')?.value).toBe('Test Funder');
expect(entry.get('funderIdentifier')?.value).toBe('test-id');
expect(entry.get('funderIdentifierType')?.value).toBe('Crossref Funder ID');
expect(entry.get('funderIdentifierType')?.value).toBe('ROR');
expect(entry.get('awardTitle')?.value).toBe('Test Award');
expect(entry.get('awardUri')?.value).toBe('https://test.com');
expect(entry.get('awardNumber')?.value).toBe('AWARD-123');
Expand Down Expand Up @@ -227,32 +228,107 @@ describe('FundingDialogComponent', () => {
expect(entry.get('awardNumber')?.value).toBe('');
});

it('should emit search query to searchSubject', () => {
const searchSpy = jest.spyOn(component['searchSubject'], 'next');
it('should dispatch getFundersList after debounce when searching', fakeAsync(() => {
const store = TestBed.inject(Store);
const dispatchSpy = jest.spyOn(store, 'dispatch');

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

expect(searchSpy).toHaveBeenCalledWith('test search');
it('should pre-populate entries from config funders on init', () => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [FundingDialogComponent, OSFTestingModule],
providers: [
MockProviders(DynamicDialogRef, DestroyRef),
MockProvider(DynamicDialogConfig, { data: { funders: [MOCK_FUNDERS[0]] } }),
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(MOCK_FUNDERS[0].funderName);
expect(entry.get('funderIdentifier')?.value).toBe(MOCK_FUNDERS[0].funderIdentifier);
expect(entry.get('funderIdentifierType')?.value).toBe(MOCK_FUNDERS[0].funderIdentifierType);
expect(entry.get('awardTitle')?.value).toBe(MOCK_FUNDERS[0].awardTitle);
expect(entry.get('awardUri')?.value).toBe(MOCK_FUNDERS[0].awardUri);
expect(entry.get('awardNumber')?.value).toBe(MOCK_FUNDERS[0].awardNumber);
});

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';
Loading