diff --git a/CHANGELOG b/CHANGELOG index 810354abb..9758ee36b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,31 @@ We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO. +26.5.0 (2026-02-26) +=================== + +* Config updates to enable Angular SSR + +26.4.0 (2026-02-26) +=================== + +* Search and fetch funder ids from ROR instead of CrossRef + +26.3.1 (2026-02-25) +=================== + +* Hotfix to prevent 403 error when fetching metadata from causing a redirect + +26.3.0 (2026-02-24) +=================== + +* FAIR Signposting + +26.2.1 (2026-02-03) +=================== + +* Hotfix for navigation translations and contributor search + 26.2.0 (2026-01-29) =================== diff --git a/Dockerfile b/Dockerfile index 1692b05bc..f487a8ccb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,59 +1,37 @@ -# Build -FROM node:22-alpine AS build - +# Dependencies stage +FROM node:22-alpine AS deps WORKDIR /app - COPY package*.json ./ -RUN npm install +RUN npm ci --no-audit --no-fund +# Build stage (SSR build output) +FROM deps AS build COPY . . +RUN NG_BUILD_OPTIMIZE_CHUNKS=1 npx ng build --configuration=ssr --verbose -RUN npm link @angular/cli -RUN NG_BUILD_OPTIMIZE_CHUNKS=1 ng build --verbose - -# Dist -FROM node:22-alpine AS dist - -WORKDIR /code - -COPY --from=build /app/dist /code/dist - -# SSR -FROM node:22-alpine AS ssr - +# SSR runtime stage +FROM build AS ssr WORKDIR /app - -COPY package*.json ./ -RUN npm install - -COPY . . - -RUN npm link @angular/cli -RUN NG_BUILD_OPTIMIZE_CHUNKS=1 ng build --configuration=ssr --verbose - -RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund - +RUN npm prune --omit=dev --no-audit --no-fund EXPOSE 4000 - ENV PORT=4000 - CMD ["node", "dist/osf/server/server.mjs"] -# Dev - run only -FROM build AS dev +# Static dist artifact stage +FROM node:22-alpine AS dist +WORKDIR /code +COPY --from=build /app/dist /code/dist +# Dev server stage +FROM deps AS dev +COPY . . EXPOSE 4200 +CMD ["npx", "ng", "serve", "--host", "0.0.0.0"] -CMD ["ng", "serve"] - -# Local Development - coding +# Local development stage FROM node:22-alpine AS local-dev WORKDIR /app - -# Install deps in the image (kept in container) COPY package*.json ./ -# COPY package-lock.docker.json ./package-lock.json RUN npm ci --no-audit --no-fund - -# Expose Angular dev server EXPOSE 4200 +CMD ["npx", "ng", "serve", "--host", "0.0.0.0"] diff --git a/package-lock.json b/package-lock.json index 8e6a1db03..5dc582df6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "osf", - "version": "26.1.0", + "version": "26.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "osf", - "version": "26.1.0", + "version": "26.4.0", "dependencies": { "@angular/animations": "^19.2.0", "@angular/cdk": "^19.2.1", diff --git a/package.json b/package.json index fafb56652..6649d8f60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "osf", - "version": "26.2.0", + "version": "26.5.0", "scripts": { "ng": "ng", "analyze-bundle": "ng build --configuration=analyze-bundle && source-map-explorer dist/**/*.js --no-border-checks", diff --git a/src/@types/ace-builds.d.ts b/src/@types/ace-builds.d.ts new file mode 100644 index 000000000..0f45f6f88 --- /dev/null +++ b/src/@types/ace-builds.d.ts @@ -0,0 +1 @@ +declare module 'ace-builds/src-noconflict/ext-language_tools'; diff --git a/src/app/app.config.server.ts b/src/app/app.config.server.ts index 1f4702fcd..b4e68de23 100644 --- a/src/app/app.config.server.ts +++ b/src/app/app.config.server.ts @@ -2,11 +2,42 @@ import { ApplicationConfig, mergeApplicationConfig } from '@angular/core'; import { provideServerRendering } from '@angular/platform-server'; import { provideServerRouting } from '@angular/ssr'; +import { SSR_CONFIG } from '@core/constants/ssr-config.token'; +import { ConfigModel } from '@core/models/config.model'; + import { appConfig } from './app.config'; import { serverRoutes } from './app.routes.server'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function loadSsrConfig(): ConfigModel { + const serverDistFolder = dirname(fileURLToPath(import.meta.url)); + const configPath = resolve(serverDistFolder, '../browser/assets/config/config.json'); + + let config = {} as ConfigModel; + + if (existsSync(configPath)) { + try { + config = JSON.parse(readFileSync(configPath, 'utf-8')); + } catch { + config = {} as ConfigModel; + } + } + + return { + ...config, + throttleToken: process.env['THROTTLE_TOKEN'] || '', + } as ConfigModel; +} + const serverConfig: ApplicationConfig = { - providers: [provideServerRendering(), provideServerRouting(serverRoutes)], + providers: [ + provideServerRendering(), + provideServerRouting(serverRoutes), + { provide: SSR_CONFIG, useFactory: loadSsrConfig }, + ], }; export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index 77c193b59..4756b8699 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -133,6 +133,14 @@ export const serverRoutes: ServerRoute[] = [ path: ':id/overview', renderMode: RenderMode.Server, }, + { + path: ':id/metadata/:recordId', + renderMode: RenderMode.Server, + }, + { + path: ':id/wiki', + renderMode: RenderMode.Server, + }, { path: ':id/files/**', renderMode: RenderMode.Server, diff --git a/src/app/core/components/nav-menu/nav-menu.component.ts b/src/app/core/components/nav-menu/nav-menu.component.ts index ad038e5af..9d6e05de6 100644 --- a/src/app/core/components/nav-menu/nav-menu.component.ts +++ b/src/app/core/components/nav-menu/nav-menu.component.ts @@ -44,10 +44,12 @@ export class NavMenuComponent { private readonly isAuthenticated = select(UserSelectors.isAuthenticated); private readonly currentResource = select(CurrentResourceSelectors.getCurrentResource); private readonly provider = select(ProviderSelectors.getCurrentProvider); + private readonly translationsReady = toSignal(this.translateService.stream('navigation.overview')); readonly actions = createDispatchMap({ getResourceDetails: GetResourceDetails }); readonly mainMenuItems = computed(() => { + this.translationsReady(); const isAuthenticated = this.isAuthenticated(); const filtered = filterMenuItems(MENU_ITEMS, isAuthenticated); diff --git a/src/app/core/constants/ssr-config.token.ts b/src/app/core/constants/ssr-config.token.ts new file mode 100644 index 000000000..4a1d25df9 --- /dev/null +++ b/src/app/core/constants/ssr-config.token.ts @@ -0,0 +1,5 @@ +import { InjectionToken } from '@angular/core'; + +import { ConfigModel } from '@core/models/config.model'; + +export const SSR_CONFIG = new InjectionToken('SSR_CONFIG'); diff --git a/src/app/core/helpers/i18n.helper.ts b/src/app/core/helpers/i18n.helper.ts index ce39ee708..af50092d5 100644 --- a/src/app/core/helpers/i18n.helper.ts +++ b/src/app/core/helpers/i18n.helper.ts @@ -1,10 +1,20 @@ import { TranslateLoader, TranslateModuleConfig } from '@ngx-translate/core'; import { TranslateHttpLoader } from '@ngx-translate/http-loader'; +import { isPlatformServer } from '@angular/common'; import { HttpClient } from '@angular/common/http'; +import { inject, PLATFORM_ID } from '@angular/core'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; function httpLoaderFactory(http: HttpClient): TranslateHttpLoader { - return new TranslateHttpLoader(http, './assets/i18n/', '.json'); + const platformId = inject(PLATFORM_ID); + const environment = inject(ENVIRONMENT); + const basePrefix = '/assets/i18n/'; + const webUrl = environment.webUrl?.replace(/\/+$/, '') ?? ''; + const prefix = isPlatformServer(platformId) && webUrl ? `${webUrl}${basePrefix}` : basePrefix; + + return new TranslateHttpLoader(http, prefix, '.json'); } export const provideTranslation = (): TranslateModuleConfig => ({ diff --git a/src/app/core/interceptors/auth.interceptor.spec.ts b/src/app/core/interceptors/auth.interceptor.spec.ts index 62e9a5f9c..ff870d84c 100644 --- a/src/app/core/interceptors/auth.interceptor.spec.ts +++ b/src/app/core/interceptors/auth.interceptor.spec.ts @@ -4,35 +4,33 @@ import { MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; import { HttpRequest } from '@angular/common/http'; -import { runInInjectionContext } from '@angular/core'; +import { PLATFORM_ID, runInInjectionContext } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { EnvironmentModel } from '@osf/shared/models/environment.model'; + import { authInterceptor } from './auth.interceptor'; describe('authInterceptor', () => { let cookieService: CookieService; - let mockHandler: jest.Mock; + let cookieServiceMock: { get: jest.Mock }; - beforeEach(() => { - mockHandler = jest.fn(); + const setup = (platformId = 'browser', environmentOverrides: Partial = {}) => { + cookieServiceMock = { get: jest.fn() }; TestBed.configureTestingModule({ providers: [ - MockProvider(CookieService, { - get: jest.fn(), - }), - { - provide: 'PLATFORM_ID', - useValue: 'browser', - }, - { - provide: 'REQUEST', - useValue: null, - }, + MockProvider(CookieService, cookieServiceMock), + MockProvider(PLATFORM_ID, platformId), + MockProvider(ENVIRONMENT, { throttleToken: '', ...environmentOverrides } as EnvironmentModel), ], }); cookieService = TestBed.inject(CookieService); + }; + + beforeEach(() => { jest.clearAllMocks(); }); @@ -44,12 +42,13 @@ describe('authInterceptor', () => { }; const createHandler = () => { - const handler = mockHandler.mockReturnValue(of({})); + const handler = jest.fn().mockReturnValue(of({})); return handler; }; - it('should skip CrossRef funders API requests', () => { - const request = createRequest('/api.crossref.org/funders/10.13039/100000001'); + it('should skip ROR funders API requests', () => { + setup(); + const request = createRequest('https://api.ror.org/v2'); const handler = createHandler(); runInInjectionContext(TestBed, () => authInterceptor(request, handler)); @@ -60,6 +59,7 @@ describe('authInterceptor', () => { }); it('should set Accept header to */* for text response type', () => { + setup(); const request = createRequest('/api/v2/projects/', { responseType: 'text' }); const handler = createHandler(); @@ -71,6 +71,7 @@ describe('authInterceptor', () => { }); it('should set Accept header to API version for json response type', () => { + setup(); const request = createRequest('/api/v2/projects/', { responseType: 'json' }); const handler = createHandler(); @@ -82,6 +83,7 @@ describe('authInterceptor', () => { }); it('should set Content-Type header when not present', () => { + setup(); const request = createRequest('/api/v2/projects/'); const handler = createHandler(); @@ -93,6 +95,7 @@ describe('authInterceptor', () => { }); it('should not override existing Content-Type header', () => { + setup(); const request = createRequest('/api/v2/projects/'); const requestWithHeaders = request.clone({ setHeaders: { 'Content-Type': 'application/json' }, @@ -107,7 +110,8 @@ describe('authInterceptor', () => { }); it('should add CSRF token and withCredentials in browser platform', () => { - jest.spyOn(cookieService, 'get').mockReturnValue('csrf-token-123'); + setup(); + cookieServiceMock.get.mockReturnValue('csrf-token-123'); const request = createRequest('/api/v2/projects/'); const handler = createHandler(); @@ -122,7 +126,8 @@ describe('authInterceptor', () => { }); it('should not add CSRF token when not available in browser platform', () => { - jest.spyOn(cookieService, 'get').mockReturnValue(''); + setup(); + cookieServiceMock.get.mockReturnValue(''); const request = createRequest('/api/v2/projects/'); const handler = createHandler(); @@ -135,4 +140,37 @@ describe('authInterceptor', () => { expect(modifiedRequest.headers.has('X-CSRFToken')).toBe(false); expect(modifiedRequest.withCredentials).toBe(true); }); + + it('should not add X-Throttle-Token on browser platform', () => { + setup('browser', { throttleToken: 'test-token' }); + const request = createRequest('/api/v2/projects/'); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => authInterceptor(request, handler)); + + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.headers.has('X-Throttle-Token')).toBe(false); + }); + + it('should add X-Throttle-Token on server platform when token is present', () => { + setup('server', { throttleToken: 'test-token' }); + const request = createRequest('/api/v2/projects/'); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => authInterceptor(request, handler)); + + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.headers.get('X-Throttle-Token')).toBe('test-token'); + }); + + it('should not add X-Throttle-Token on server platform when token is empty', () => { + setup('server', { throttleToken: '' }); + const request = createRequest('/api/v2/projects/'); + const handler = createHandler(); + + runInInjectionContext(TestBed, () => authInterceptor(request, handler)); + + const modifiedRequest = handler.mock.calls[0][0]; + expect(modifiedRequest.headers.has('X-Throttle-Token')).toBe(false); + }); }); diff --git a/src/app/core/interceptors/auth.interceptor.ts b/src/app/core/interceptors/auth.interceptor.ts index 5d192b8e8..52653d458 100644 --- a/src/app/core/interceptors/auth.interceptor.ts +++ b/src/app/core/interceptors/auth.interceptor.ts @@ -2,17 +2,23 @@ import { CookieService } from 'ngx-cookie-service'; import { Observable } from 'rxjs'; +import { isPlatformServer } from '@angular/common'; import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http'; -import { inject } from '@angular/core'; +import { inject, PLATFORM_ID } from '@angular/core'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; + +import { environment } from 'src/environments/environment'; export const authInterceptor: HttpInterceptorFn = ( req: HttpRequest, next: HttpHandlerFn ): Observable> => { - if (req.url.includes('/api.crossref.org/funders')) { + if (req.url.startsWith(environment.funderApiUrl)) { return next(req); } + const platformId = inject(PLATFORM_ID); const cookieService = inject(CookieService); const csrfToken = cookieService.get('api-csrf'); @@ -28,6 +34,14 @@ export const authInterceptor: HttpInterceptorFn = ( headers['X-CSRFToken'] = csrfToken; } + if (isPlatformServer(platformId)) { + const environment = inject(ENVIRONMENT); + + if (environment.throttleToken) { + headers['X-Throttle-Token'] = environment.throttleToken; + } + } + const authReq = req.clone({ setHeaders: headers, withCredentials: true }); return next(authReq); diff --git a/src/app/core/interceptors/error.interceptor.spec.ts b/src/app/core/interceptors/error.interceptor.spec.ts index 52185f9c6..17d5bbec3 100644 --- a/src/app/core/interceptors/error.interceptor.spec.ts +++ b/src/app/core/interceptors/error.interceptor.spec.ts @@ -2,7 +2,7 @@ import { MockProvider } from 'ng-mocks'; import { throwError } from 'rxjs'; -import { HttpContext, HttpErrorResponse, HttpRequest } from '@angular/common/http'; +import { HttpContext, HttpErrorResponse, HttpHeaders, HttpRequest } from '@angular/common/http'; import { runInInjectionContext } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; @@ -271,4 +271,25 @@ describe('errorInterceptor', () => { }); }); }); + + it('should not navigate for 403 errors when X-No-Auth-Redirect header is true', () => { + const error = new HttpErrorResponse({ + error: {}, + status: 403, + url: '/metadata/abcde/?format=google-dataset-json-ld', + headers: new HttpHeaders({ 'X-No-Auth-Redirect': 'true' }), + }); + const request = createRequest(); + + runInInjectionContext(TestBed, () => { + const result = errorInterceptor(request, createErrorHandler(error)); + result.subscribe({ + error: () => { + expect(router.navigate).not.toHaveBeenCalled(); + expect(loaderService.hide).toHaveBeenCalled(); + expect(toastService.showError).not.toHaveBeenCalled(); + }, + }); + }); + }); }); diff --git a/src/app/core/interceptors/error.interceptor.ts b/src/app/core/interceptors/error.interceptor.ts index df5a58c0d..99848cc4b 100644 --- a/src/app/core/interceptors/error.interceptor.ts +++ b/src/app/core/interceptors/error.interceptor.ts @@ -63,7 +63,8 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => { if (error.status === 403) { const requestAccessRegex = /\/v2\/nodes\/[^/]+\/requests\/?$/i; - if (error.url && requestAccessRegex.test(error.url)) { + + if (error.url && (requestAccessRegex.test(error.url) || req.headers.has('X-No-Auth-Redirect'))) { loaderService.hide(); return throwError(() => error); } diff --git a/src/app/core/interceptors/view-only.interceptor.spec.ts b/src/app/core/interceptors/view-only.interceptor.spec.ts index 83fbef868..d75ff8cb1 100644 --- a/src/app/core/interceptors/view-only.interceptor.spec.ts +++ b/src/app/core/interceptors/view-only.interceptor.spec.ts @@ -113,14 +113,14 @@ describe('viewOnlyInterceptor', () => { it('should not modify funders API requests even when view-only param exists', () => { jest.spyOn(viewOnlyHelper, 'getViewOnlyParam').mockReturnValue('funder123'); - const request = createRequest('/api.crossref.org/funders/10.13039/100000001'); + const request = createRequest('https://api.ror.org/v2'); const handler = createHandler(); runInInjectionContext(TestBed, () => viewOnlyInterceptor(request, handler)); expect(handler).toHaveBeenCalledTimes(1); const modifiedRequest = handler.mock.calls[0][0]; - expect(modifiedRequest.url).toBe('/api.crossref.org/funders/10.13039/100000001'); + expect(modifiedRequest.url).toBe('https://api.ror.org/v2'); }); it('should handle requests to other external APIs normally', () => { diff --git a/src/app/core/interceptors/view-only.interceptor.ts b/src/app/core/interceptors/view-only.interceptor.ts index fe3ae6fa1..3c969bfb5 100644 --- a/src/app/core/interceptors/view-only.interceptor.ts +++ b/src/app/core/interceptors/view-only.interceptor.ts @@ -6,6 +6,8 @@ import { Router } from '@angular/router'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; +import { environment } from 'src/environments/environment'; + export const viewOnlyInterceptor: HttpInterceptorFn = ( req: HttpRequest, next: HttpHandlerFn @@ -15,7 +17,7 @@ export const viewOnlyInterceptor: HttpInterceptorFn = ( const viewOnlyParam = viewOnlyHelper.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); } diff --git a/src/app/core/services/osf-config.service.spec.ts b/src/app/core/services/osf-config.service.spec.ts index 2c899ab48..c2c87234d 100644 --- a/src/app/core/services/osf-config.service.spec.ts +++ b/src/app/core/services/osf-config.service.spec.ts @@ -1,65 +1,108 @@ -import { HttpTestingController } from '@angular/common/http/testing'; +import { MockProvider } from 'ng-mocks'; + +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { PLATFORM_ID } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { SSR_CONFIG } from '@core/constants/ssr-config.token'; import { ConfigModel } from '@core/models/config.model'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { EnvironmentModel } from '@osf/shared/models/environment.model'; import { OSFConfigService } from './osf-config.service'; -import { OSFTestingModule } from '@testing/osf.testing.module'; - -describe('Service: Config', () => { +describe('OSFConfigService', () => { let service: OSFConfigService; - let httpMock: HttpTestingController; let environment: EnvironmentModel; const mockConfig: ConfigModel = { + sentryDsn: 'https://sentry.example.com/123', + googleTagManagerId: 'GTM-TEST', + googleFilePickerApiKey: '', + googleFilePickerAppId: 0, apiDomainUrl: 'https://api.example.com', - production: true, - } as any; // Cast to any if index signature isn’t added + }; - beforeEach(async () => { - jest.clearAllMocks(); - await TestBed.configureTestingModule({ - imports: [OSFTestingModule], - providers: [OSFConfigService], - }).compileComponents(); + const setupBrowser = () => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), MockProvider(PLATFORM_ID, 'browser')], + }); service = TestBed.inject(OSFConfigService); - httpMock = TestBed.inject(HttpTestingController); environment = TestBed.inject(ENVIRONMENT); - }); + }; - it('should return a value with get()', async () => { - let loadPromise = service.load(); - const request = httpMock.expectOne('/assets/config/config.json'); - request.flush(mockConfig); - await loadPromise; - expect(environment.apiDomainUrl).toBe('https://api.example.com'); - expect(environment.production).toBeTruthy(); - loadPromise = service.load(); + const setupServer = (ssrConfig: ConfigModel | null = null) => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + MockProvider(PLATFORM_ID, 'server'), + ...(ssrConfig ? [{ provide: SSR_CONFIG, useValue: ssrConfig }] : []), + ], + }); + + service = TestBed.inject(OSFConfigService); + environment = TestBed.inject(ENVIRONMENT); + }; + + it('should load config via HTTP on browser and merge into ENVIRONMENT', async () => { + setupBrowser(); + const httpMock = TestBed.inject(HttpTestingController); + + const loadPromise = service.load(); + httpMock.expectOne('/assets/config/config.json').flush(mockConfig); await loadPromise; expect(environment.apiDomainUrl).toBe('https://api.example.com'); - expect(environment.production).toBeTruthy(); - - expect(httpMock.verify()).toBeUndefined(); + expect(environment.sentryDsn).toBe('https://sentry.example.com/123'); + httpMock.verify(); }); - it('should return a value with ahs()', async () => { - let loadPromise = service.load(); - const request = httpMock.expectOne('/assets/config/config.json'); - request.flush(mockConfig); - await loadPromise; + it('should only fetch config once on repeated load calls', async () => { + setupBrowser(); + const httpMock = TestBed.inject(HttpTestingController); + + const firstLoad = service.load(); + httpMock.expectOne('/assets/config/config.json').flush(mockConfig); + await firstLoad; + + await service.load(); + httpMock.expectNone('/assets/config/config.json'); + expect(environment.apiDomainUrl).toBe('https://api.example.com'); - expect(environment.production).toBeTruthy(); + httpMock.verify(); + }); - loadPromise = service.load(); + it('should fallback to empty config on HTTP error', async () => { + setupBrowser(); + const httpMock = TestBed.inject(HttpTestingController); + const originalUrl = environment.apiDomainUrl; + + const loadPromise = service.load(); + httpMock.expectOne('/assets/config/config.json').error(new ProgressEvent('error')); await loadPromise; + + expect(environment.apiDomainUrl).toBe(originalUrl); + httpMock.verify(); + }); + + it('should load config from SSR_CONFIG on server and merge into ENVIRONMENT', async () => { + setupServer(mockConfig); + + await service.load(); + expect(environment.apiDomainUrl).toBe('https://api.example.com'); - expect(environment.production).toBeTruthy(); + expect(environment.sentryDsn).toBe('https://sentry.example.com/123'); + }); + + it('should fallback to empty config on server when SSR_CONFIG is not provided', async () => { + setupServer(); + const originalUrl = environment.apiDomainUrl; + + await service.load(); - expect(httpMock.verify()).toBeUndefined(); + expect(environment.apiDomainUrl).toBe(originalUrl); }); }); diff --git a/src/app/core/services/osf-config.service.ts b/src/app/core/services/osf-config.service.ts index 8a237ddd9..d9975a1f7 100644 --- a/src/app/core/services/osf-config.service.ts +++ b/src/app/core/services/osf-config.service.ts @@ -4,59 +4,36 @@ import { isPlatformBrowser } from '@angular/common'; import { HttpClient } from '@angular/common/http'; import { inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { SSR_CONFIG } from '@core/constants/ssr-config.token'; import { ConfigModel } from '@core/models/config.model'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -/** - * Service for loading and accessing configuration values - * from the static JSON file at `/assets/config/config.json`. - * - * This service ensures that the configuration is only fetched once - * and made available application-wide via promise-based access. - * - * Consumers must call `get()` or `has()` using `await` to ensure - * that config values are available after loading completes. - */ @Injectable({ providedIn: 'root' }) export class OSFConfigService { - /** - * Angular's HttpClient used to fetch the configuration JSON. - * Injected via Angular's dependency injection system. - */ private http: HttpClient = inject(HttpClient); private platformId = inject(PLATFORM_ID); - - /** - * Injected instance of the application environment configuration. - * */ private environment = inject(ENVIRONMENT); - - /** - * Stores the loaded configuration object after it is fetched from the server. - * Remains `null` until `load()` is successfully called. - */ + private ssrConfig = inject(SSR_CONFIG, { optional: true }); private config: ConfigModel | null = null; - /** - * Loads the configuration from the JSON file if not already loaded. - * Ensures that only one request is made. - * On the server, this is skipped as config is only needed in the browser. - */ async load(): Promise { - if (!this.config && isPlatformBrowser(this.platformId)) { + if (this.config) return; + + if (isPlatformBrowser(this.platformId)) { this.config = await lastValueFrom( this.http.get('/assets/config/config.json').pipe( shareReplay(1), catchError(() => of({} as ConfigModel)) ) ); + } else { + this.config = (this.ssrConfig ?? {}) as ConfigModel; + } - // Apply every key from config to environment - for (const [key, value] of Object.entries(this.config)) { - // eslint-disable-next-line - // @ts-ignore - this.environment[key] = value; - } + for (const [key, value] of Object.entries(this.config)) { + // eslint-disable-next-line + // @ts-ignore + this.environment[key] = value; } } } diff --git a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts index b9d5863bb..493e85134 100644 --- a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts +++ b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts @@ -130,7 +130,7 @@ export class MoveFileDialogComponent { const currentProject = this.currentProject(); if (currentProject) { const rootParentId = currentProject.rootResourceId ?? currentProject.id; - this.actions.getComponentsTree(rootParentId, currentProject.id, ResourceType.Project); + this.actions.getComponentsTree(rootParentId, currentProject.id, ResourceType.Project, true); } effect(() => { diff --git a/src/app/features/files/pages/file-detail/file-detail.component.spec.ts b/src/app/features/files/pages/file-detail/file-detail.component.spec.ts index 551a56853..cc50e65dd 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.spec.ts +++ b/src/app/features/files/pages/file-detail/file-detail.component.spec.ts @@ -41,8 +41,8 @@ describe('FileDetailComponent', () => { } as unknown as jest.Mocked; const mockRoute: Partial = { - params: of({ providerId: 'osf', preprintId: 'p1' }), - queryParams: of({ providerId: 'osf', preprintId: 'p1' }), + params: of({ providerId: 'osf', fileGuid: 'file-1' }), + queryParams: of({ providerId: 'osf', fileGuid: 'file-1' }), }; (MOCK_STORE.selectSignal as jest.Mock).mockImplementation((selector) => { switch (selector) { @@ -79,6 +79,7 @@ describe('FileDetailComponent', () => { }).compileComponents(); fixture = TestBed.createComponent(FileDetailComponent); component = fixture.componentInstance; + document.head.innerHTML = ''; fixture.detectChanges(); }); @@ -95,4 +96,15 @@ describe('FileDetailComponent', () => { it('should call dataciteService.logIdentifiableView on start ', () => { expect(dataciteService.logIdentifiableView).toHaveBeenCalledWith(component.fileMetadata$); }); + + it('should add signposting tags during SSR', () => { + fixture.detectChanges(); + + const linkTags = Array.from(document.head.querySelectorAll('link[rel="linkset"]')); + expect(linkTags.length).toBe(2); + expect(linkTags[0].getAttribute('href')).toBe('http://localhost:4200/metadata/file-1/?format=linkset'); + expect(linkTags[0].getAttribute('type')).toBe('application/linkset'); + expect(linkTags[1].getAttribute('href')).toBe('http://localhost:4200/metadata/file-1/?format=linkset-json'); + expect(linkTags[1].getAttribute('type')).toBe('application/linkset+json'); + }); }); diff --git a/src/app/features/files/pages/file-detail/file-detail.component.ts b/src/app/features/files/pages/file-detail/file-detail.component.ts index a9c4ec849..aba1fe647 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.ts +++ b/src/app/features/files/pages/file-detail/file-detail.component.ts @@ -18,6 +18,8 @@ import { effect, HostBinding, inject, + OnDestroy, + OnInit, signal, } from '@angular/core'; import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; @@ -46,6 +48,7 @@ import { CustomConfirmationService } from '@osf/shared/services/custom-confirmat import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; import { MetaTagsBuilderService } from '@osf/shared/services/meta-tags-builder.service'; +import { SignpostingService } from '@osf/shared/services/signposting.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { FileDetailsModel } from '@shared/models/files/file.model'; @@ -92,7 +95,7 @@ import { styleUrl: './file-detail.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class FileDetailComponent { +export class FileDetailComponent implements OnInit, OnDestroy { @HostBinding('class') classes = 'flex flex-column flex-1 w-full h-full'; readonly store = inject(Store); @@ -108,6 +111,7 @@ export class FileDetailComponent { private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); private readonly environment = inject(ENVIRONMENT); private readonly clipboard = inject(Clipboard); + private readonly signpostingService = inject(SignpostingService); readonly dataciteService = inject(DataciteService); @@ -269,6 +273,16 @@ export class FileDetailComponent { this.dataciteService.logIdentifiableView(this.fileMetadata$).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); } + ngOnInit(): void { + this.signpostingService.addSignposting(this.fileGuid); + } + + ngOnDestroy(): void { + if (this.fileGuid) { + this.signpostingService.removeSignpostingLinkTags(); + } + } + getIframeLink(version: string) { const url = this.getMfrUrlWithVersion(version); if (url) { diff --git a/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.html b/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.html index 8a8c42613..1c0256467 100644 --- a/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.html +++ b/src/app/features/meetings/pages/meetings-landing/meetings-landing.component.html @@ -16,26 +16,23 @@ /> diff --git a/src/app/features/metadata/dialogs/funding-dialog/funding-dialog.component.html b/src/app/features/metadata/dialogs/funding-dialog/funding-dialog.component.html index b0288a118..c2473b8cf 100644 --- a/src/app/features/metadata/dialogs/funding-dialog/funding-dialog.component.html +++ b/src/app/features/metadata/dialogs/funding-dialog/funding-dialog.component.html @@ -10,13 +10,13 @@ { let component: FundingDialogComponent; let fixture: ComponentFixture; @@ -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 }, ], }), @@ -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', () => { @@ -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); @@ -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', @@ -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'); @@ -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(); }); }); diff --git a/src/app/features/metadata/dialogs/funding-dialog/funding-dialog.component.ts b/src/app/features/metadata/dialogs/funding-dialog/funding-dialog.component.ts index 0e2f80403..6ee276d68 100644 --- a/src/app/features/metadata/dialogs/funding-dialog/funding-dialog.component.ts +++ b/src/app/features/metadata/dialogs/funding-dialog/funding-dialog.component.ts @@ -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({ @@ -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({ fundingEntries: new FormArray>([]) }); @@ -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); @@ -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', }); } } diff --git a/src/app/features/metadata/mappers/index.ts b/src/app/features/metadata/mappers/index.ts index 43aace43c..5c0905874 100644 --- a/src/app/features/metadata/mappers/index.ts +++ b/src/app/features/metadata/mappers/index.ts @@ -1,2 +1,3 @@ export * from './cedar-records.mapper'; export * from './metadata.mapper'; +export * from './ror.mapper'; diff --git a/src/app/features/metadata/mappers/ror.mapper.ts b/src/app/features/metadata/mappers/ror.mapper.ts new file mode 100644 index 000000000..701b2a140 --- /dev/null +++ b/src/app/features/metadata/mappers/ror.mapper.ts @@ -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 ?? ''; + } +} diff --git a/src/app/features/metadata/metadata.component.ts b/src/app/features/metadata/metadata.component.ts index 85958743d..5e4dc966d 100644 --- a/src/app/features/metadata/metadata.component.ts +++ b/src/app/features/metadata/metadata.component.ts @@ -11,6 +11,7 @@ import { DestroyRef, effect, inject, + OnDestroy, OnInit, signal, } from '@angular/core'; @@ -24,6 +25,7 @@ import { MetadataResourceEnum } from '@osf/shared/enums/metadata-resource.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { SignpostingService } from '@osf/shared/services/signposting.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { CollectionsSelectors, GetProjectSubmissions } from '@osf/shared/stores/collections'; import { @@ -120,7 +122,7 @@ import { styleUrl: './metadata.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MetadataComponent implements OnInit { +export class MetadataComponent implements OnInit, OnDestroy { private readonly activeRoute = inject(ActivatedRoute); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); @@ -128,6 +130,7 @@ export class MetadataComponent implements OnInit { private readonly toastService = inject(ToastService); private readonly customConfirmationService = inject(CustomConfirmationService); private readonly environment = inject(ENVIRONMENT); + private readonly signpostingService = inject(SignpostingService); private resourceId = ''; @@ -268,12 +271,18 @@ export class MetadataComponent implements OnInit { this.actions.getCedarTemplates(); this.actions.fetchSelectedSubjects(this.resourceId, this.resourceType()); + this.signpostingService.addMetadataSignposting(this.resourceId); + if (this.isProjectType()) { this.actions.getProjectSubmissions(this.resourceId); } } } + ngOnDestroy(): void { + this.signpostingService.removeSignpostingLinkTags(); + } + onTabChange(tabId: string | number): void { const tab = this.tabs().find((x) => x.id === tabId.toString()); diff --git a/src/app/features/metadata/models/index.ts b/src/app/features/metadata/models/index.ts index 5d1c3aded..30e978c95 100644 --- a/src/app/features/metadata/models/index.ts +++ b/src/app/features/metadata/models/index.ts @@ -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'; diff --git a/src/app/features/metadata/models/metadata.model.ts b/src/app/features/metadata/models/metadata.model.ts index 675ad53fe..d9ebf6491 100644 --- a/src/app/features/metadata/models/metadata.model.ts +++ b/src/app/features/metadata/models/metadata.model.ts @@ -41,33 +41,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[]; -} diff --git a/src/app/features/metadata/models/ror.model.ts b/src/app/features/metadata/models/ror.model.ts new file mode 100644 index 000000000..5775df7e4 --- /dev/null +++ b/src/app/features/metadata/models/ror.model.ts @@ -0,0 +1,111 @@ +export interface RorAdmin { + created: { + date: string; + schema_version: string; + }; + last_modified: { + date: string; + schema_version: string; + }; +} + +export interface RorExternalId { + all: string[]; + preferred: string | null; + type: 'grid' | 'fundref' | 'isni' | 'wikidata'; +} + +export interface RorLink { + type: 'website' | 'wikipedia'; + value: string; +} + +export interface RorGeonamesDetails { + continent_code: string; + continent_name: string; + country_code: string; + country_name: string; + country_subdivision_code: string; + country_subdivision_name: string; + lat: number; + lng: number; + name: string; +} + +export interface RorLocation { + geonames_details: RorGeonamesDetails; + geonames_id: number; +} + +export interface RorName { + lang: string | null; + types: ('ror_display' | 'label' | 'alias' | 'acronym')[]; + value: string; +} + +export interface RorRelationship { + type: string; + id: string; + label: string; +} + +export interface RorOrganization { + id: string; + admin: RorAdmin; + domains: string[]; + established: number | null; + external_ids: RorExternalId[]; + links: RorLink[]; + locations: RorLocation[]; + names: RorName[]; + relationships: RorRelationship[]; + status: 'active' | 'inactive' | 'withdrawn'; + types: ( + | 'education' + | 'healthcare' + | 'company' + | 'archive' + | 'nonprofit' + | 'government' + | 'facility' + | 'other' + | 'funder' + )[]; +} + +export interface RorMetaCount { + id: string; + title: string; + count: number; +} + +export interface RorMeta { + types: RorMetaCount[]; + countries: RorMetaCount[]; + continents: RorMetaCount[]; + statuses: RorMetaCount[]; +} + +export interface RorSearchResponse { + items: RorOrganization[]; + meta: RorMeta; + number_of_results: number; + time_taken: number; +} + +export interface RorFunderOption { + id: string; + name: string; +} + +export interface RorDisplayData { + id: string; + displayName: string; + acronym?: string; + type: string; + country: string; + city: string; + established?: number; + website?: string; + status: string; +} diff --git a/src/app/features/metadata/store/metadata.model.ts b/src/app/features/metadata/store/metadata.model.ts index 1deae0c00..a5e05dc5b 100644 --- a/src/app/features/metadata/store/metadata.model.ts +++ b/src/app/features/metadata/store/metadata.model.ts @@ -6,12 +6,13 @@ import { } from '@osf/features/metadata/models'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; -import { CrossRefFunder, MetadataModel } from '../models'; +import { MetadataModel } from '../models'; +import { RorFunderOption } from '../models/ror.model'; export interface MetadataStateModel { metadata: AsyncStateModel; customMetadata: AsyncStateModel; - fundersList: AsyncStateModel; + fundersList: AsyncStateModel; cedarTemplates: AsyncStateModel; cedarRecord: AsyncStateModel; cedarRecords: AsyncStateModel; diff --git a/src/app/features/metadata/store/metadata.state.ts b/src/app/features/metadata/store/metadata.state.ts index af839233a..88ac11b71 100644 --- a/src/app/features/metadata/store/metadata.state.ts +++ b/src/app/features/metadata/store/metadata.state.ts @@ -118,9 +118,9 @@ export class MetadataState { }); return this.metadataService.getFundersList(action.search).pipe( - tap((response) => { + tap((options) => { ctx.patchState({ - fundersList: { data: response.message.items, isLoading: false, error: null }, + fundersList: { data: options, isLoading: false, error: null }, }); }), catchError((error) => handleSectionError(ctx, 'fundersList', error)) diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts index 6823577f4..c497d6821 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts @@ -567,6 +567,7 @@ describe('PreprintDetailsComponent SSR', () => { store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintDetailsComponent); component = fixture.componentInstance; + document.head.innerHTML = ''; }); it('should render successfully on the server without throwing errors', () => { @@ -574,7 +575,20 @@ describe('PreprintDetailsComponent SSR', () => { expect(component).toBeTruthy(); }); - it('should skip reset dispatches during ngOnDestroy in SSR environment', () => { + it('should add signposting tags during SSR', () => { + fixture.detectChanges(); + + const linkTags = Array.from(document.head.querySelectorAll('link[rel="linkset"]')); + expect(linkTags.length).toBe(2); + expect(linkTags[0].getAttribute('href')).toBe('http://localhost:4200/metadata/preprint-1/?format=linkset'); + expect(linkTags[0].getAttribute('type')).toBe('application/linkset'); + expect(linkTags[1].getAttribute('href')).toBe('http://localhost:4200/metadata/preprint-1/?format=linkset-json'); + expect(linkTags[1].getAttribute('type')).toBe('application/linkset+json'); + }); + + it('should not access browser-only APIs during SSR', () => { + const platformId = TestBed.inject(PLATFORM_ID); + expect(platformId).toBe('server'); fixture.detectChanges(); (store.dispatch as jest.Mock).mockClear(); diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts index df896e0da..279e56a90 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts @@ -34,6 +34,7 @@ import { CustomDialogService } from '@osf/shared/services/custom-dialog.service' import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; import { MetaTagsBuilderService } from '@osf/shared/services/meta-tags-builder.service'; +import { SignpostingService } from '@osf/shared/services/signposting.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ContributorsSelectors } from '@osf/shared/stores/contributors'; @@ -99,6 +100,8 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { private readonly metaTagsBuilder = inject(MetaTagsBuilderService); private readonly dataciteService = inject(DataciteService); private readonly prerenderReady = inject(PrerenderReadyService); + private readonly signpostingService = inject(SignpostingService); + private readonly environment = inject(ENVIRONMENT); private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); @@ -300,6 +303,8 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { this.fetchPreprint(preprintId); } + this.signpostingService.addSignposting(this.preprintId()); + this.dataciteService.logIdentifiableView(this.preprint$).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); } @@ -309,6 +314,8 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { this.actions.clearCurrentProvider(); } + this.signpostingService.removeSignpostingLinkTags(); + this.helpScoutService.unsetResourceType(); } diff --git a/src/app/features/project/overview/project-overview.component.spec.ts b/src/app/features/project/overview/project-overview.component.spec.ts index 61373020d..ae3182118 100644 --- a/src/app/features/project/overview/project-overview.component.spec.ts +++ b/src/app/features/project/overview/project-overview.component.spec.ts @@ -262,6 +262,7 @@ describe('ProjectOverviewComponent SSR Tests', () => { store = TestBed.inject(Store); fixture = TestBed.createComponent(ProjectOverviewComponent); component = fixture.componentInstance; + document.head.innerHTML = ''; }); it('should render ProjectOverviewComponent server-side without errors', () => { @@ -285,6 +286,17 @@ describe('ProjectOverviewComponent SSR Tests', () => { expect(component).toBeTruthy(); }); + it('should add signposting tags during SSR', () => { + fixture.detectChanges(); + + const linkTags = Array.from(document.head.querySelectorAll('link[rel="linkset"]')); + expect(linkTags.length).toBe(2); + expect(linkTags[0].getAttribute('href')).toBe('http://localhost:4200/metadata/project-123/?format=linkset'); + expect(linkTags[0].getAttribute('type')).toBe('application/linkset'); + expect(linkTags[1].getAttribute('href')).toBe('http://localhost:4200/metadata/project-123/?format=linkset-json'); + expect(linkTags[1].getAttribute('type')).toBe('application/linkset+json'); + }); + it('should not call browser-only actions in ngOnDestroy during SSR', () => { const dispatchSpy = jest.spyOn(store, 'dispatch'); diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index 209c33834..cc22bd778 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -16,6 +16,7 @@ import { effect, HostBinding, inject, + OnDestroy, OnInit, PLATFORM_ID, } from '@angular/core'; @@ -35,6 +36,7 @@ import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-l import { Mode } from '@osf/shared/enums/mode.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { SignpostingService } from '@osf/shared/services/signposting.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { @@ -93,7 +95,7 @@ import { ], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ProjectOverviewComponent implements OnInit { +export class ProjectOverviewComponent implements OnInit, OnDestroy { @HostBinding('class') classes = 'flex flex-1 flex-column w-full h-full'; private readonly route = inject(ActivatedRoute); @@ -104,6 +106,7 @@ export class ProjectOverviewComponent implements OnInit { private readonly customDialogService = inject(CustomDialogService); private readonly platformId = inject(PLATFORM_ID); private readonly isBrowser = isPlatformBrowser(this.platformId); + private readonly signpostingService = inject(SignpostingService); submissions = select(CollectionsModerationSelectors.getCollectionSubmissions); collectionProvider = select(CollectionsSelectors.getCollectionProvider); @@ -193,9 +196,14 @@ export class ProjectOverviewComponent implements OnInit { this.actions.getBookmarksId(); this.actions.getComponents(projectId); this.actions.getLinkedProjects(projectId); + this.signpostingService.addSignposting(projectId); } } + ngOnDestroy(): void { + this.signpostingService.removeSignpostingLinkTags(); + } + handleOpenMakeDecisionDialog() { this.customDialogService .open(MakeDecisionDialogComponent, { diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts index e6c579ac8..927fc222a 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts @@ -15,6 +15,8 @@ import { effect, HostBinding, inject, + OnDestroy, + OnInit, signal, } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; @@ -30,6 +32,7 @@ import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.e import { toCamelCase } from '@osf/shared/helpers/camel-case'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { LoaderService } from '@osf/shared/services/loader.service'; +import { SignpostingService } from '@osf/shared/services/signposting.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks'; @@ -74,7 +77,7 @@ import { styleUrl: './registry-overview.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class RegistryOverviewComponent { +export class RegistryOverviewComponent implements OnInit, OnDestroy { @HostBinding('class') classes = 'flex-1 flex flex-column w-full h-full'; private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); @@ -83,6 +86,7 @@ export class RegistryOverviewComponent { private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); private readonly customDialogService = inject(CustomDialogService); private readonly loaderService = inject(LoaderService); + private readonly signpostingService = inject(SignpostingService); readonly registry = select(RegistrySelectors.getRegistry); readonly isRegistryLoading = select(RegistrySelectors.isRegistryLoading); @@ -158,6 +162,14 @@ export class RegistryOverviewComponent { this.actions.getBookmarksId(); } + ngOnInit(): void { + this.signpostingService.addSignposting(this.registryId()); + } + + ngOnDestroy(): void { + this.signpostingService.removeSignpostingLinkTags(); + } + openRevision(revisionIndex: number): void { this.selectedRevisionIndex.set(revisionIndex); } diff --git a/src/app/shared/components/global-search/global-search.component.html b/src/app/shared/components/global-search/global-search.component.html index 138e275ce..467e90239 100644 --- a/src/app/shared/components/global-search/global-search.component.html +++ b/src/app/shared/components/global-search/global-search.component.html @@ -11,6 +11,7 @@
@for (affiliatedEntity of affiliatedEntities().slice(0, limit); track $index) { { }); }); - it('should configure editor when onEditorLoaded is called', () => { - component.onEditorLoaded(mockEditorInstance); + it('should configure editor when onEditorLoaded is called', async () => { + await component.onEditorLoaded(mockEditorInstance); expect((component as any).editorInstance).toBe(mockEditorInstance); expect(mockEditorInstance.setShowPrintMargin).toHaveBeenCalledWith(false); diff --git a/src/app/shared/components/wiki/edit-section/edit-section.component.ts b/src/app/shared/components/wiki/edit-section/edit-section.component.ts index 94393870d..6b2be9688 100644 --- a/src/app/shared/components/wiki/edit-section/edit-section.component.ts +++ b/src/app/shared/components/wiki/edit-section/edit-section.component.ts @@ -10,8 +10,6 @@ import { FormsModule } from '@angular/forms'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import 'ace-builds/src-noconflict/ext-language_tools'; - import { WikiSyntaxHelpDialogComponent } from '../wiki-syntax-help-dialog/wiki-syntax-help-dialog.component'; @Component({ @@ -63,14 +61,16 @@ export class EditSectionComponent { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - onEditorLoaded(editor: any) { + async onEditorLoaded(editor: any) { this.editorInstance = editor; editor.setShowPrintMargin(false); - const langTools = ace.require('ace/ext/language_tools'); + await import('ace-builds/src-noconflict/ext-language_tools'); + const aceGlobal = (globalThis as { ace?: { require?: (path: string) => { snippetCompleter?: unknown } } }).ace; + const langTools = aceGlobal?.require?.('ace/ext/language_tools'); editor.setOptions({ enableBasicAutocompletion: this.autoCompleteEnabled, enableLiveAutocompletion: this.autoCompleteEnabled, - enableSnippets: [langTools.snippetCompleter], + enableSnippets: langTools?.snippetCompleter ? [langTools.snippetCompleter] : [], }); } diff --git a/src/app/shared/components/wiki/wiki-list/wiki-list.component.html b/src/app/shared/components/wiki/wiki-list/wiki-list.component.html index d35962419..b6d16d796 100644 --- a/src/app/shared/components/wiki/wiki-list/wiki-list.component.html +++ b/src/app/shared/components/wiki/wiki-list/wiki-list.component.html @@ -10,7 +10,7 @@ } @else { @if (expanded()) { - +
({ + static getNodesWithChildren( + data: BaseNodeDataJsonApi[], + parentId: string, + includeAncestors = false + ): NodeShortInfoModel[] { + const nodes = includeAncestors + ? this.getAllAncestorsAndDescendants(data, parentId) + : this.getAllDescendants(data, parentId); + + return nodes.map((item) => ({ id: item.id, title: replaceBadEncodedChars(item.attributes.title), isPublic: item.attributes.public, @@ -82,4 +90,23 @@ export class BaseNodeMapper { return [parent, ...descendants]; } + + static getAllAncestors(allNodes: BaseNodeDataJsonApi[], nodeId: string): BaseNodeDataJsonApi[] { + const node = allNodes.find((n) => n.id === nodeId); + if (!node) return []; + + const parentId = node.relationships.parent?.data?.id; + if (!parentId) return [node]; + + const ancestors = this.getAllAncestors(allNodes, parentId); + + return [node, ...ancestors]; + } + + static getAllAncestorsAndDescendants(allNodes: BaseNodeDataJsonApi[], nodeId: string): BaseNodeDataJsonApi[] { + const ancestors = this.getAllAncestors(allNodes, nodeId); + const descendants = this.getAllDescendants(allNodes, nodeId).slice(1); + + return [...ancestors, ...descendants]; + } } diff --git a/src/app/shared/models/environment.model.ts b/src/app/shared/models/environment.model.ts index 9b399bbed..184fe4ce4 100644 --- a/src/app/shared/models/environment.model.ts +++ b/src/app/shared/models/environment.model.ts @@ -5,6 +5,7 @@ export interface EnvironmentModel { shareTroveUrl: string; addonsApiUrl: string; funderApiUrl: string; + rorClientId: string; casUrl: string; recaptchaSiteKey: string; twitterHandle: string; @@ -63,4 +64,5 @@ export interface EnvironmentModel { * @example 123456789012 */ googleFilePickerAppId: number; + throttleToken: string; } diff --git a/src/app/shared/models/signposting.model.ts b/src/app/shared/models/signposting.model.ts new file mode 100644 index 000000000..91f08817b --- /dev/null +++ b/src/app/shared/models/signposting.model.ts @@ -0,0 +1,8 @@ +export const LINKSET_TYPE = 'application/linkset'; +export const LINKSET_JSON_TYPE = 'application/linkset+json'; + +export interface SignpostingLink { + rel: string; + href: string; + type: string; +} diff --git a/src/app/shared/services/json-api.service.ts b/src/app/shared/services/json-api.service.ts index 09865805c..41fec68ee 100644 --- a/src/app/shared/services/json-api.service.ts +++ b/src/app/shared/services/json-api.service.ts @@ -11,8 +11,13 @@ import { JsonApiResponse } from '@osf/shared/models/common/json-api.model'; export class JsonApiService { http: HttpClient = inject(HttpClient); - get(url: string, params?: Record, context?: HttpContext): Observable { - return this.http.get(url, { params: this.buildHttpParams(params), context }); + get( + url: string, + params?: Record, + context?: HttpContext, + headers?: Record + ): Observable { + return this.http.get(url, { params: this.buildHttpParams(params), context, headers }); } private buildHttpParams(params?: Record): HttpParams { diff --git a/src/app/shared/services/metadata.service.ts b/src/app/shared/services/metadata.service.ts index b74d82b64..82c1bd357 100644 --- a/src/app/shared/services/metadata.service.ts +++ b/src/app/shared/services/metadata.service.ts @@ -4,25 +4,28 @@ import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { CedarRecordsMapper, MetadataMapper } from '@osf/features/metadata/mappers'; +import { CedarRecordsMapper, MetadataMapper, RorMapper } from '@osf/features/metadata/mappers'; import { CedarMetadataRecord, CedarMetadataRecordJsonApi, CedarMetadataTemplateJsonApi, CedarRecordDataBinding, - CrossRefFundersResponse, CustomItemMetadataRecord, CustomMetadataJsonApi, CustomMetadataJsonApiResponse, MetadataJsonApi, MetadataJsonApiResponse, MetadataModel, + RorFunderOption, + RorSearchResponse, } from '@osf/features/metadata/models'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; -import { LicenseOptions } from '@osf/shared/models/license/license.model'; -import { BaseNodeAttributesJsonApi } from '@osf/shared/models/nodes/base-node-attributes-json-api.model'; -import { JsonApiService } from '@osf/shared/services/json-api.service'; + +import { ResourceType } from '../enums/resource-type.enum'; +import { IdentifierModel } from '../models/identifiers/identifier.model'; +import { LicenseOptions } from '../models/license/license.model'; +import { BaseNodeAttributesJsonApi } from '../models/nodes/base-node-attributes-json-api.model'; + +import { JsonApiService } from './json-api.service'; @Injectable({ providedIn: 'root', @@ -79,14 +82,18 @@ export class MetadataService { ); } - getFundersList(searchQuery?: string): Observable { - let url = `${this.funderApiUrl}funders?mailto=support%40osf.io`; + getFundersList(searchQuery?: string): Observable { + let url = `${this.funderApiUrl}/organizations?filter=types:funder`; if (searchQuery && searchQuery.trim()) { url += `&query=${encodeURIComponent(searchQuery.trim())}`; } - return this.jsonApiService.get(url); + const headers = this.environment.rorClientId ? { 'Client-Id': this.environment.rorClientId } : undefined; + + return this.jsonApiService + .get(url, undefined, undefined, headers) + .pipe(map((response) => RorMapper.toFunderOptions(response))); } getMetadataCedarTemplates(url?: string): Observable { diff --git a/src/app/shared/services/resource.service.ts b/src/app/shared/services/resource.service.ts index 9255215d0..604199658 100644 --- a/src/app/shared/services/resource.service.ts +++ b/src/app/shared/services/resource.service.ts @@ -84,12 +84,13 @@ export class ResourceGuidService { getResourceWithChildren( rootParentId: string, resourceId: string, - resourceType: ResourceType + resourceType: ResourceType, + includeAncestors = false ): Observable { const resourcePath = this.urlMap.get(resourceType); return this.jsonApiService .get(`${this.apiUrl}/${resourcePath}/?filter[root]=${rootParentId}&page[size]=100`) - .pipe(map((response) => BaseNodeMapper.getNodesWithChildren(response.data, resourceId))); + .pipe(map((response) => BaseNodeMapper.getNodesWithChildren(response.data, resourceId, includeAncestors))); } } diff --git a/src/app/shared/services/signposting.service.spec.ts b/src/app/shared/services/signposting.service.spec.ts new file mode 100644 index 000000000..e1f160546 --- /dev/null +++ b/src/app/shared/services/signposting.service.spec.ts @@ -0,0 +1,68 @@ +import { RendererFactory2, RESPONSE_INIT } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { SignpostingService } from './signposting.service'; + +describe('Service: Signposting', () => { + let service: SignpostingService; + let mockResponseInit: ResponseInit; + let createdLinks: Record[]; + let mockAppendChild: jest.Mock; + + beforeEach(() => { + createdLinks = []; + mockAppendChild = jest.fn(); + mockResponseInit = { headers: new Headers() }; + + TestBed.configureTestingModule({ + providers: [ + SignpostingService, + { provide: RESPONSE_INIT, useValue: mockResponseInit }, + { + provide: RendererFactory2, + useValue: { + createRenderer: () => ({ + createElement: jest.fn().mockImplementation(() => { + const link: Record = {}; + createdLinks.push(link); + return link; + }), + setAttribute: jest.fn().mockImplementation((el, attr, value) => { + el[attr] = value; + }), + appendChild: mockAppendChild, + }), + }, + }, + ], + }); + + service = TestBed.inject(SignpostingService); + }); + + it('should set headers using addSignposting', () => { + service.addSignposting('abcde'); + const linkHeader = (mockResponseInit.headers as Headers).get('Link'); + expect(linkHeader).toBe( + '; rel="linkset"; type="application/linkset", ; rel="linkset"; type="application/linkset+json"' + ); + }); + + it('should add link tags using addSignposting', () => { + service.addSignposting('abcde'); + + expect(createdLinks).toEqual([ + { + rel: 'linkset', + href: 'https://staging3.osf.io/metadata/abcde/?format=linkset', + type: 'application/linkset', + }, + { + rel: 'linkset', + href: 'https://staging3.osf.io/metadata/abcde/?format=linkset-json', + type: 'application/linkset+json', + }, + ]); + expect(mockAppendChild).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/app/shared/services/signposting.service.ts b/src/app/shared/services/signposting.service.ts new file mode 100644 index 000000000..43765f617 --- /dev/null +++ b/src/app/shared/services/signposting.service.ts @@ -0,0 +1,91 @@ +import { DOCUMENT } from '@angular/common'; +import { inject, Injectable, RendererFactory2, RESPONSE_INIT } from '@angular/core'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; + +import { LINKSET_JSON_TYPE, LINKSET_TYPE, SignpostingLink } from '../models/signposting.model'; + +@Injectable({ + providedIn: 'root', +}) +export class SignpostingService { + private readonly document = inject(DOCUMENT); + private readonly environment = inject(ENVIRONMENT); + private readonly responseInit = inject(RESPONSE_INIT, { optional: true }); + private readonly renderer = inject(RendererFactory2).createRenderer(null, null); + + addSignposting(guid: string): void { + const links = this.generateSignpostingLinks(guid); + + this.addSignpostingLinkHeaders(links); + this.addSignpostingLinkTags(links); + } + + addMetadataSignposting(guid: string): void { + const links = this.generateSignpostingLinks(guid, true); + + this.addSignpostingLinkHeaders(links); + this.addSignpostingLinkTags(links); + } + + removeSignpostingLinkTags(): void { + const linkElements = this.document.head.querySelectorAll('link[rel="linkset"], link[rel="describes"]'); + linkElements.forEach((linkElement) => { + this.renderer.removeChild(this.document.head, linkElement); + }); + } + + private generateSignpostingLinks(guid: string, isMetadata?: boolean): SignpostingLink[] { + if (isMetadata) { + return [ + { + rel: 'describes', + href: `${this.environment.webUrl}/${guid}/`, + type: 'text/html', + }, + ]; + } + const baseUrl = `${this.environment.webUrl}/metadata/${guid}/`; + + return [ + { + rel: 'linkset', + href: this.buildUrl(baseUrl, 'linkset'), + type: LINKSET_TYPE, + }, + { + rel: 'linkset', + href: this.buildUrl(baseUrl, 'linkset-json'), + type: LINKSET_JSON_TYPE, + }, + ]; + } + + private buildUrl(base: string, format: string): string { + const url = new URL(base); + url.searchParams.set('format', format); + return url.toString(); + } + + private addSignpostingLinkHeaders(links: SignpostingLink[]): void { + if (!this.responseInit) return; + + const headers = + this.responseInit.headers instanceof Headers ? this.responseInit.headers : new Headers(this.responseInit.headers); + + const linkHeaderValue = links.map((link) => `<${link.href}>; rel="${link.rel}"; type="${link.type}"`).join(', '); + + headers.set('Link', linkHeaderValue); + this.responseInit.headers = headers; + } + + private addSignpostingLinkTags(links: SignpostingLink[]): void { + links.forEach((link) => { + const linkElement = this.renderer.createElement('link'); + this.renderer.setAttribute(linkElement, 'rel', link.rel); + this.renderer.setAttribute(linkElement, 'href', link.href); + this.renderer.setAttribute(linkElement, 'type', link.type); + this.renderer.appendChild(this.document.head, linkElement); + }); + } +} diff --git a/src/app/shared/stores/current-resource/current-resource.actions.ts b/src/app/shared/stores/current-resource/current-resource.actions.ts index f911603e7..10ef71daa 100644 --- a/src/app/shared/stores/current-resource/current-resource.actions.ts +++ b/src/app/shared/stores/current-resource/current-resource.actions.ts @@ -24,6 +24,7 @@ export class GetResourceWithChildren { constructor( public rootParentId: string, public resourceId: string, - public resourceType: ResourceType + public resourceType: ResourceType, + public includeAncestors = false ) {} } diff --git a/src/app/shared/stores/current-resource/current-resource.state.ts b/src/app/shared/stores/current-resource/current-resource.state.ts index 199c355d8..9bceb57bc 100644 --- a/src/app/shared/stores/current-resource/current-resource.state.ts +++ b/src/app/shared/stores/current-resource/current-resource.state.ts @@ -87,7 +87,7 @@ export class CurrentResourceState { }); return this.resourceService - .getResourceWithChildren(action.rootParentId, action.resourceId, action.resourceType) + .getResourceWithChildren(action.rootParentId, action.resourceId, action.resourceType, action.includeAncestors) .pipe( tap((children) => { ctx.patchState({ diff --git a/src/assets/config/template.json b/src/assets/config/template.json index 55891f4a1..826cb39c4 100644 --- a/src/assets/config/template.json +++ b/src/assets/config/template.json @@ -4,6 +4,7 @@ "shareTroveUrl": "", "addonsApiUrl": "", "funderApiUrl": "", + "rorClientId": "", "casUrl": "", "recaptchaSiteKey": "", "dataciteTrackerRepoId": null, diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index c4230b680..a7df308e9 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -27,9 +27,10 @@ export const environment = { */ addonsApiUrl: 'https://addons.staging3.osf.io/v1', /** - * API endpoint for funder metadata resolution via Crossref. + * API endpoint for funder metadata resolution via ROR. */ - funderApiUrl: 'https://api.crossref.org/', + funderApiUrl: 'https://api.ror.org/v2', + rorClientId: '', /** * URL for OSF Central Authentication Service (CAS). */ diff --git a/src/environments/environment.docker.ts b/src/environments/environment.docker.ts index 157755c97..c05044358 100644 --- a/src/environments/environment.docker.ts +++ b/src/environments/environment.docker.ts @@ -4,7 +4,8 @@ export const environment = { apiDomainUrl: 'http://localhost:8000', shareTroveUrl: 'http://localhost:8003/trove', addonsApiUrl: 'http://localhost:8004/v1', - funderApiUrl: 'https://api.crossref.org/', + funderApiUrl: 'https://api.ror.org/v2', + rorClientId: '', casUrl: 'http://localhost:8080', recaptchaSiteKey: '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI', twitterHandle: 'OSFramework', diff --git a/src/environments/environment.staging.ts b/src/environments/environment.staging.ts index a7725102f..6e1406f77 100644 --- a/src/environments/environment.staging.ts +++ b/src/environments/environment.staging.ts @@ -27,9 +27,10 @@ export const environment = { */ addonsApiUrl: 'https://addons.staging4.osf.io/v1', /** - * API endpoint for funder metadata resolution via Crossref. + * API endpoint for funder metadata resolution via ROR. */ - funderApiUrl: 'https://api.crossref.org/', + funderApiUrl: 'https://api.ror.org/v2', + rorClientId: '', /** * URL for OSF Central Authentication Service (CAS). */ diff --git a/src/environments/environment.ts b/src/environments/environment.ts index cbffe140e..b54351e41 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -27,9 +27,10 @@ export const environment = { */ addonsApiUrl: 'https://addons.staging3.osf.io/v1', /** - * API endpoint for funder metadata resolution via Crossref. + * API endpoint for funder metadata resolution via ROR. */ - funderApiUrl: 'https://api.crossref.org/', + funderApiUrl: 'https://api.ror.org/v2', + rorClientId: '', /** * URL for OSF Central Authentication Service (CAS). */ diff --git a/src/testing/mocks/funder.mock.ts b/src/testing/mocks/funder.mock.ts index 415e6f7a5..f8ea586b2 100644 --- a/src/testing/mocks/funder.mock.ts +++ b/src/testing/mocks/funder.mock.ts @@ -4,7 +4,7 @@ export const MOCK_FUNDERS: Funder[] = [ { funderName: 'National Science Foundation', funderIdentifier: '10.13039/100000001', - funderIdentifierType: 'Crossref Funder ID', + funderIdentifierType: 'ROR', awardNumber: 'NSF-1234567', awardUri: 'https://www.nsf.gov/awardsearch/showAward?AWD_ID=1234567', awardTitle: 'Research Grant for Advanced Computing', @@ -12,7 +12,7 @@ export const MOCK_FUNDERS: Funder[] = [ { funderName: 'National Institutes of Health', funderIdentifier: '10.13039/100000002', - funderIdentifierType: 'Crossref Funder ID', + funderIdentifierType: 'ROR', awardNumber: 'NIH-R01-GM123456', awardUri: 'https://reporter.nih.gov/project-details/12345678', awardTitle: 'Biomedical Research Project',