diff --git a/docs/ngxs.md b/docs/ngxs.md
index 3f93391c0..583394c53 100644
--- a/docs/ngxs.md
+++ b/docs/ngxs.md
@@ -46,7 +46,7 @@ Typical NGXS-related files are organized as follows:
src/app/shared/stores/
└── addons/
├── addons.actions.ts # All action definitions
- ├── addons.models.ts # Interfaces & data models
+ ├── addons.model.ts # Interfaces & data model
├── addons.state.ts # State implementation
├── addons.selectors.ts # Reusable selectors
```
diff --git a/docs/testing.md b/docs/testing.md
index 3de20e198..e05d231a4 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -1,359 +1,908 @@
-# OSF Angular Testing Strategy
-
-## Index
-
-- [Overview](#overview)
- - [Pro-tips](#pro-tips)
-- [Best Practices](#best-practices)
-- [Summary Table](#summary-table)
-- [Test Coverage Enforcement (100%)](#test-coverage-enforcement-100)
-- [Key Structure](#key-structure)
-- [Testing Angular Services (with HTTP)](#testing-angular-services-with-http)
-- [Testing Angular Components](#testing-angular-components)
-- [Testing Angular Pipes](#testing-angular-pipes)
-- [Testing Angular Directives](#testing-angular-directives)
-- [Testing Angular NGXS](#testing-ngxs)
+# OSF Angular Unit Testing Guide
+
+## Table of Contents
+
+1. [Test Stack](#1-test-stack)
+2. [Project Testing Infrastructure](#2-project-testing-infrastructure)
+3. [Test File Structure](#3-test-file-structure)
+4. [TestBed Configuration](#4-testbed-configuration)
+5. [Mocking Strategies](#5-mocking-strategies)
+6. [Store Mocking](#6-store-mocking)
+7. [Router & Route Mocking](#7-router--route-mocking)
+8. [Service Mocking](#8-service-mocking)
+9. [Signal-Based Testing](#9-signal-based-testing)
+10. [Async Operations](#10-async-operations)
+11. [Form Testing](#11-form-testing)
+12. [Dialog Testing](#12-dialog-testing)
+13. [Edge Cases](#13-edge-cases)
+14. [Testing Angular Services (HTTP)](#14-testing-angular-services-http)
+15. [Testing NGXS State](#15-testing-ngxs-state)
+16. [Test Data](#16-test-data)
+17. [Coverage Enforcement](#17-coverage-enforcement)
+18. [Best Practices](#18-best-practices)
+19. [Appendix: Assertion Patterns](#appendix-assertion-patterns)
---
-## Overview
+## 1. Test Stack
-The OSF Angular project uses a modular and mock-driven testing strategy. A shared `testing/` folder provides reusable mocks, mock data, and testing module configuration to support consistent and maintainable unit tests across the codebase.
+| Tool | Purpose |
+| ------------------------- | --------------------------------------------------------------------------------------------- |
+| **Jest** | Test runner & assertion library |
+| **Angular TestBed** | Component / service compilation |
+| **ng-mocks** | `MockComponents`, `MockModule`, `MockProvider` |
+| **NGXS** | State management — mocked via `provideMockStore()` for components, real store for state tests |
+| **RxJS** | Observable / Subject-based async testing |
+| **HttpTestingController** | HTTP interception for service and state integration tests |
+| **Custom utilities** | `src/testing/` — builders, factories, mock data |
---
-### Pro-tips
+## 2. Project Testing Infrastructure
-**What to test**
+### Directory: `src/testing/`
-The OSF Angular testing strategy enforces 100% coverage while also serving as a guardrail for future engineers. Each test should highlight the most critical aspect of your code — what you’d want the next developer to understand before making changes. If a test fails during a refactor, it should clearly signal that a core feature was impacted, prompting them to investigate why and preserve the intended behavior.
+```
+src/testing/
+├── osf.testing.provider.ts ← provideOSFCore(), provideOSFHttp()
+├── osf.testing.module.ts ← OSFTestingModule (legacy — prefer providers)
+├── providers/ ← Builder-pattern mocks for services
+│ ├── store-provider.mock.ts
+│ ├── route-provider.mock.ts
+│ ├── router-provider.mock.ts
+│ ├── toast-provider.mock.ts
+│ ├── custom-confirmation-provider.mock.ts
+│ ├── custom-dialog-provider.mock.ts
+│ ├── component-provider.mock.ts
+│ ├── loader-service.mock.ts
+│ └── dialog-provider.mock.ts
+├── mocks/ ← Mock domain models (89+ files)
+│ ├── registries.mock.ts
+│ ├── draft-registration.mock.ts
+│ └── ...
+└── data/ ← JSON API response fixtures
+ ├── dashboard/
+ ├── addons/
+ └── files/
+```
+
+### `provideOSFCore()` — mandatory base provider
+
+Every component test must include `provideOSFCore()`. It configures animations, translations, and environment tokens.
+
+```typescript
+export function provideOSFCore() {
+ return [
+ provideNoopAnimations(),
+ importProvidersFrom(TranslateModule.forRoot()),
+ TranslationServiceMock,
+ EnvironmentTokenMock,
+ ];
+}
+```
+
+> **Never** import `OSFTestingModule` directly in new tests. It is retained for legacy compatibility only. Use `provideOSFCore()` instead.
---
-**Test Data**
+## 3. Test File Structure
+
+### Core rules
+
+- Prefer a single flat `describe` block per file to keep tests searchable and prevent state leakage. Use nested `describe` blocks when it significantly simplifies setup or groups logically distinct behaviors.
+- For specs where all tests share a single configuration, use `beforeEach` with `TestBed.configureTestingModule` directly. Use a `setup()` helper when tests need different selector values, route configs, or other overrides.
+- No `TestBed.resetTestingModule()` in `afterEach` — Angular auto-resets.
+- Use actual interfaces/types for mock data instead of `any`.
+- Co-locate unit tests with components using `*.spec.ts`.
+
+### Standard structure
+
+```typescript
+describe('MyComponent', () => {
+ let component: MyComponent;
+ let fixture: ComponentFixture;
+ let store: Store;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({ ... });
+ store = TestBed.inject(Store);
+ fixture = TestBed.createComponent(MyComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
+```
+
+### `setup()` helper — parameterised tests
+
+Use when tests need different selector values or route configs. Avoids duplicating `TestBed` configuration across tests.
-The OSF Angular Test Data module provides a centralized and consistent source of data across all unit tests. It is intended solely for use within unit tests. By standardizing test data, any changes to underlying data models will produce cascading failures, which help expose the full scope of a refactor. This is preferable to isolated or hardcoded test values, which can lead to false positives and missed regressions.
+Extend `BaseSetupOverrides` from `@testing/providers/store-provider.mock` when the spec only needs standard route/selector overrides. Add component-specific fields as needed.
-The strategy for structuring test data follows two principles:
+Use `mergeSignalOverrides` from `@testing/providers/store-provider.mock` to apply selector overrides on top of default signal values.
-1. Include enough data to cover all relevant permutations required by the test suite.
-2. Ensure the data reflects all possible states (stati) of the model.
+Use `withNoParent()` on `ActivatedRouteMockBuilder` when testing components that guard against a missing parent route.
-**Test Scope**
+```typescript
+import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';
-The OSF Angular project defines a `@testing` scope that can be used for importing all testing-related modules.
+interface SetupOverrides extends BaseSetupOverrides {
+ routerUrl?: string;
+}
+
+function setup(overrides: SetupOverrides = {}) {
+ const routeBuilder = ActivatedRouteMockBuilder.create().withParams(overrides.routeParams ?? { id: 'draft-1' });
+ if (overrides.hasParent === false) routeBuilder.withNoParent();
+ const mockRoute = routeBuilder.build();
+
+ const mockRouter = RouterMockBuilder.create()
+ .withUrl(overrides.routerUrl ?? '/registries/drafts/reg-1/1')
+ .build();
+
+ const defaultSignals = [{ selector: MySelectors.getData, value: mockData }];
+ const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides);
+
+ TestBed.configureTestingModule({
+ imports: [MyComponent],
+ providers: [
+ provideOSFCore(),
+ MockProvider(ActivatedRoute, mockRoute),
+ MockProvider(Router, mockRouter),
+ provideMockStore({ signals }),
+ ],
+ });
+
+ const store = TestBed.inject(Store);
+ const fixture = TestBed.createComponent(MyComponent);
+ return { fixture, component: fixture.componentInstance, store };
+}
+
+// Usage
+it('should handle missing data', () => {
+ const { component } = setup({
+ selectorOverrides: [{ selector: MySelectors.getData, value: null }],
+ });
+ expect(component.hasData()).toBe(false);
+});
+
+it('should not dispatch when parent route is absent', () => {
+ const { store } = setup({ hasParent: false });
+ expect(store.dispatch).not.toHaveBeenCalled();
+});
+```
---
-## Best Practices
+## 4. TestBed Configuration
+
+### Standalone components (standard)
+
+```typescript
+TestBed.configureTestingModule({
+ imports: [
+ ComponentUnderTest,
+ ...MockComponents(ChildA, ChildB),
+ MockModule(PrimeNGModule),
+ ],
+ providers: [
+ provideOSFCore(),
+ MockProvider(ActivatedRoute, mockRoute),
+ MockProvider(Router, mockRouter),
+ MockProvider(ToastService, ToastServiceMock.simple()),
+ provideMockStore({ signals: [...] }),
+ ],
+});
+```
-- Always import `OsfTestingModule` or `OsfTestingStoreModule` to minimize boilerplate and get consistent mock behavior.
-- Use mocks and mock-data from `testing/` to avoid repeating test setup.
-- Avoid real HTTP, translation, or store dependencies in unit tests by default.
-- Co-locate unit tests with components using `*.spec.ts`.
+### Components with signal-input children
+
+Use `overrideComponent` when a child uses Angular signal viewChild and `MockComponents` cannot stub it correctly.
+
+```typescript
+TestBed.configureTestingModule({ ... })
+ .overrideComponent(FilesControlComponent, {
+ remove: { imports: [FilesTreeComponent] },
+ add: {
+ imports: [
+ MockComponentWithSignal('osf-files-tree', [
+ 'files',
+ 'selectionMode',
+ 'totalCount',
+ 'storage',
+ 'currentFolder',
+ 'isLoading',
+ 'scrollHeight',
+ 'viewOnly',
+ 'resourceId',
+ 'provider',
+ 'selectedFiles',
+ ]),
+ ],
+ },
+ });
+```
---
-## Summary Table
+## 5. Mocking Strategies
+
+### Priority order
-| Location | Purpose |
-| ----------------------- | -------------------------------------- |
-| `osf.testing.module.ts` | Unified test module for shared imports |
-| `src/mocks/*.mock.ts` | Mock services and tokens |
-| `src/data/*.data.ts` | Static mock data for test cases |
+Always check `@testing/` before writing inline mocks. Builders and factories almost certainly exist.
+
+1. Use existing builders/factories from `@testing/providers/`
+2. Use `MockProvider` with an explicit mock object
+3. Use `MockComponents` / `MockModule` from ng-mocks
+4. Use `MockComponentWithSignal` for signal-input children
+5. Inline `jest.fn()` mocks as a last resort
+
+### Quick reference
+
+| Need | Use |
+| -------------------------- | ------------------------------------------------------- |
+| Store selectors / dispatch | `provideMockStore()` |
+| Router | `RouterMockBuilder` |
+| ActivatedRoute | `ActivatedRouteMockBuilder` |
+| ToastService | `ToastServiceMock.simple()` |
+| CustomConfirmationService | `CustomConfirmationServiceMock.simple()` |
+| CustomDialogService | `CustomDialogServiceMockBuilder` |
+| LoaderService | `new LoaderServiceMock()` |
+| Child components | `MockComponents(...)` or `MockComponentWithSignal(...)` |
+| PrimeNG modules | `MockModule(...)` |
+
+> **Rule:** Bare `MockProvider(Service)` creates ng-mocks stubs, not `jest.fn()`. When you need `.mockImplementation`, `.mockClear`, or assertion checking, always pass an explicit mock as the second argument.
---
-## Test Coverage Enforcement (100%)
+## 6. Store Mocking
+
+### `provideMockStore` configuration options
+
+| Config key | Maps to | Use case |
+| ----------- | ------------------------------------- | ------------------------------------ |
+| `signals` | `store.selectSignal()` | Signal-based selectors (most common) |
+| `selectors` | `store.select()` / `selectSnapshot()` | Observable-based selectors |
+| `actions` | `store.dispatch()` return value | When component reads dispatch result |
+
+```typescript
+provideMockStore({
+ signals: [
+ { selector: RegistriesSelectors.getDraftRegistration, value: mockDraft },
+ { selector: RegistriesSelectors.getStepsState, value: stepsStateSignal },
+ ],
+ actions: [
+ { action: new CreateDraft({ ... }), value: { id: 'new-draft' } },
+ ],
+})
+```
+
+### `mergeSignalOverrides` — applying selector overrides in `setup()`
-This project **strictly enforces 100% test coverage** through the following mechanisms:
+Use `mergeSignalOverrides(defaults, overrides)` from `@testing/providers/store-provider.mock` instead of inlining the merge logic. It replaces matching selectors and preserves the rest.
-### Husky Pre-Push Hook
+```typescript
+import { mergeSignalOverrides } from '@testing/providers/store-provider.mock';
-Before pushing any code, Husky runs a **pre-push hook** that executes:
+const defaultSignals = [
+ { selector: MySelectors.getData, value: [] },
+ { selector: MySelectors.isLoading, value: false },
+];
+const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides);
+```
+
+### Dispatch assertions
+
+```typescript
+expect(store.dispatch).toHaveBeenCalledWith(new MyAction('id'));
+expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(MyAction));
-```bash
-npm run test:coverage
+// Filter by action type across multiple dispatches
+const calls = (store.dispatch as jest.Mock).mock.calls.filter(([a]: [any]) => a instanceof GetProjects);
+expect(calls.length).toBe(1);
```
-This command:
+### Clearing init dispatches
-- Runs the full test suite with `--coverage`.
-- Fails the push if **coverage drops below 100%**.
-- Ensures developers never bypass test coverage enforcement locally.
+When `ngOnInit` dispatches and you need isolated per-test assertions:
-> Pro Tip: Use `npm run test:watch` during development to maintain coverage incrementally.
+```typescript
+(store.dispatch as jest.Mock).mockClear();
+component.doSomething();
+expect(store.dispatch).toHaveBeenCalledWith(new SpecificAction());
+```
---
-### GitHub Actions CI
+## 7. Router & Route Mocking
-Every pull request and push runs GitHub Actions that:
+### ActivatedRoute
-- Run `npm run test:coverage`.
-- Verify test suite passes with **100% code coverage**.
-- Fail the build if even **1 uncovered branch/line/function** exists.
+```typescript
+const mockRoute = ActivatedRouteMockBuilder.create()
+ .withParams({ id: 'draft-1' })
+ .withQueryParams({ projectId: 'proj-1' })
+ .withData({ feature: 'registries' })
+ .build();
-This guarantees **test integrity in CI** and **prevents regressions**.
+// Nested child routes
+const mockRoute = ActivatedRouteMockBuilder.create()
+ .withParams({ id: 'reg-1' })
+ .withFirstChild((child) => child.withParams({ step: '2' }))
+ .build();
----
+// No parent route (for testing components that guard against missing parent)
+const mockRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'reg-1' }).withNoParent().build();
+```
-### Coverage Expectations
+### Router
-| File Type | Coverage Requirement |
-| ----------- | ------------------------------------------ |
-| `*.ts` | 100% line & branch |
-| `*.spec.ts` | Required per file |
-| Services | Must mock HTTP via `HttpTestingController` |
-| Components | DOM + Input + Output event coverage |
-| Pipes/Utils | All edge cases tested |
+```typescript
+const mockRouter = RouterMockBuilder.create().withUrl('/registries/drafts/reg-1/metadata').build();
+
+expect(mockRouter.navigate).toHaveBeenCalledWith(['../1'], expect.objectContaining({ relativeTo: expect.anything() }));
+expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/registries/prov-1/new');
+```
---
-### Summary
+## 8. Service Mocking
-- **Zero exceptions** for test coverage.
-- **Push blocked** without passing 100% tests.
-- GitHub CI double-checks every PR.
+### Simple factories
+
+```typescript
+const toastService = ToastServiceMock.simple();
+const confirmationService = CustomConfirmationServiceMock.simple();
+// Returns plain objects with jest.fn() methods — safe to assert on directly
+```
+
+### Builder pattern
+
+```typescript
+const mockDialog = CustomDialogServiceMockBuilder.create()
+ .withOpen(
+ jest.fn().mockReturnValue({
+ onClose: dialogClose$.pipe(),
+ close: jest.fn(),
+ })
+ )
+ .build();
+```
+
+### Inline mock (no builder exists)
+
+```typescript
+const mockFilesService = {
+ uploadFile: jest.fn(),
+ getFileGuid: jest.fn(),
+};
+MockProvider(FilesService, mockFilesService);
+```
---
-## Key Structure
+## 9. Signal-Based Testing
-### `src/testing/osf.testing.module.ts`
+### `WritableSignal` for dynamic state
-This module centralizes commonly used providers, declarations, and test utilities. It's intended to be imported into any `*.spec.ts` test file to avoid repetitive boilerplate.
+Pass a `WritableSignal` as the selector value to change state mid-test. The mock store detects `isSignal(value)` and returns it as-is, so updates propagate automatically.
-Example usage:
+```typescript
+let stepsStateSignal: WritableSignal<{ invalid: boolean }[]>;
-```ts
-import { TestBed } from '@angular/core/testing';
-import { OsfTestingModule } from '@testing/osf.testing.module';
+beforeEach(() => {
+ stepsStateSignal = signal([{ invalid: true }]);
+ provideMockStore({
+ signals: [{ selector: RegistriesSelectors.getStepsState, value: stepsStateSignal }],
+ });
+});
-beforeEach(async () => {
- await TestBed.configureTestingModule({
- imports: [OsfTestingModule],
- }).compileComponents();
+it('should react to signal changes', () => {
+ expect(component.isDraftInvalid()).toBe(true);
+ stepsStateSignal.set([{ invalid: false }]);
+ expect(component.isDraftInvalid()).toBe(false);
});
```
-### OSFTestingModule
+### Setting signal inputs
+
+```typescript
+fixture.componentRef.setInput('attachedFiles', []);
+fixture.componentRef.setInput('projectId', 'project-1');
+fixture.detectChanges();
-**Imports:**
+// Never use direct property assignment for signal inputs
+```
-- `NoopAnimationsModule` – disables Angular animations for clean, predictable unit tests.
-- `BrowserModule` – required for bootstrapping Angular features.
-- `CommonModule` – provides core Angular directives (e.g., `ngIf`, `ngFor`).
-- `TranslateModule.forRoot()` – sets up the translation layer for template-based testing with `@ngx-translate`.
+---
-**Providers:**
+## 10. Async Operations
-- `provideNoopAnimations()` – disables animation via the new standalone provider API.
-- `provideRouter([])` – injects an empty router config for component-level testing.
-- `provideHttpClient(withInterceptorsFromDi())` – ensures DI-compatible HTTP interceptors are respected in tests.
-- `provideHttpClientTesting()` – injects `HttpTestingController` for mocking HTTP requests in unit tests.
-- `TranslationServiceMock` – mocks i18n service methods.
-- `EnvironmentTokenMock` – mocks environment config values.
+### `fakeAsync` + `tick` for debounced operations
----
+```typescript
+it('should dispatch after debounce', fakeAsync(() => {
+ (store.dispatch as jest.Mock).mockClear();
+ component.onProjectFilter('abc');
+ tick(300);
+ expect(store.dispatch).toHaveBeenCalledWith(new GetProjects('user-1', 'abc'));
+}));
-### OSFTestingStoreModule
+// Deduplication — only the last value dispatches
+it('should debounce rapid calls', fakeAsync(() => {
+ (store.dispatch as jest.Mock).mockClear();
+ component.onProjectFilter('a');
+ component.onProjectFilter('ab');
+ component.onProjectFilter('abc');
+ tick(300);
+ const calls = (store.dispatch as jest.Mock).mock.calls.filter(([a]: [any]) => a instanceof GetProjects);
+ expect(calls.length).toBe(1);
+}));
+```
+
+### `done` callback for output emissions
+
+```typescript
+it('should emit attachFile', (done) => {
+ component.attachFile.subscribe((f) => {
+ expect(f).toEqual({ id: 'file-1' });
+ done();
+ });
+ component.selectFile({ id: 'file-1' } as FileModel);
+});
+```
-**Imports:**
+---
-- `OSFTestingModule` – reuses core mocks and modules.
+## 11. Form Testing
-**Providers:**
+### Validation and submit
-- `StoreMock` – mocks NgRx Store for selector and dispatch testing.
-- `ToastServiceMock` – injects a mock version of the UI toast service.
+```typescript
+it('should be invalid when title is empty', () => {
+ component.metadataForm.patchValue({ title: '' });
+ expect(component.metadataForm.get('title')?.valid).toBe(false);
+});
-### Testing Mocks
+it('should trim values on submit', () => {
+ component.metadataForm.patchValue({
+ title: ' Padded Title ',
+ description: ' Padded Desc ',
+ });
+ (store.dispatch as jest.Mock).mockClear();
+ component.submitMetadata();
+ expect(store.dispatch).toHaveBeenCalledWith(
+ new UpdateDraft('draft-1', expect.objectContaining({ title: 'Padded Title' }))
+ );
+});
+```
-The `src/testing/mocks/` directory provides common service and token mocks to isolate unit tests from real implementations.
+### Validator toggling & touched state
-**examples**
+```typescript
+it('should toggle validator', () => {
+ component.toggleFromProject();
+ expect(component.draftForm.get('project')?.validator).toBeTruthy();
+ component.toggleFromProject();
+ expect(component.draftForm.get('project')?.validator).toBeNull();
+});
-- `environment.token.mock.ts` – Mocks environment tokens like base API URLs.
-- `store.mock.ts` – NGXS or other store-related mocks.
-- `translation.service.mock.ts` – Prevents needing actual i18n setup during testing.
-- `toast.service.mock.ts` – Mocks user feedback services to track invocations without UI.
+it('should mark form touched on init when invalid', () => {
+ expect(component.metadataForm.touched).toBe(true);
+});
+```
---
-### Test Data
+## 12. Dialog Testing
-The `src/testing/data/` directory includes fake/mock data used by tests to simulate external API responses or internal state.
+### Subject-based `onClose`
-The OSF Angular Test Data module provides a centralized and consistent source of data across all unit tests. It is intended solely for use within unit tests. By standardizing test data, any changes to underlying data models will produce cascading failures, which help expose the full scope of a refactor. This is preferable to isolated or hardcoded test values, which can lead to false positives and missed regressions.
+Always use a real `Subject` for `onClose` — `MockProvider` cannot auto-generate reactive streams. Use `provideDynamicDialogRefMock()` where applicable.
-The strategy for structuring test data follows two principles:
+```typescript
+const dialogClose$ = new Subject();
+const mockDialog = CustomDialogServiceMockBuilder.create()
+ .withOpen(
+ jest.fn().mockReturnValue({
+ onClose: dialogClose$.pipe(),
+ close: jest.fn(),
+ })
+ )
+ .build();
-1. Include enough data to cover all relevant permutations required by the test suite.
-2. Ensure the data reflects all possible states (stati) of the model.
+it('should navigate on confirm', () => {
+ component.openConfirmDialog();
+ dialogClose$.next(true);
+ expect(mockRouter.navigate).toHaveBeenCalledWith(['/new-reg-1/overview']);
+});
-**examples**
+it('should not navigate on cancel', () => {
+ component.openConfirmDialog();
+ dialogClose$.next(false);
+ expect(mockRouter.navigate).not.toHaveBeenCalled();
+});
+```
+
+### Chained dialogs
+
+```typescript
+it('should pass data between dialogs', () => {
+ const selectClose$ = new Subject();
+ const confirmClose$ = new Subject();
+ let callCount = 0;
+
+ (dialog.open as jest.Mock).mockImplementation(() => {
+ callCount++;
+ const subj = callCount === 1 ? selectClose$ : confirmClose$;
+ return { onClose: subj.pipe(), close: jest.fn() };
+ });
+
+ component.openSelectComponentsDialog();
+ selectClose$.next(['comp-1']);
-- `addons.authorized-storage.data.ts`
-- `addons.external-storage.data.ts`
-- `addons.configured.data.ts`
-- `addons.operation-invocation.data.ts`
+ expect(dialog.open).toHaveBeenCalledTimes(2);
+ const secondArgs = (dialog.open as jest.Mock).mock.calls[1];
+ expect(secondArgs[1].data.components).toEqual(['comp-1']);
+});
+```
+
+### Confirmation service (auto-confirm pattern)
+
+```typescript
+it('should dispatch on confirm', () => {
+ mockConfirmation.confirmDelete.mockImplementation(({ onConfirm }: any) => onConfirm());
+ (store.dispatch as jest.Mock).mockClear();
+ component.deleteDraft();
+ expect(store.dispatch).toHaveBeenCalledWith(new DeleteDraft('draft-1'));
+});
+```
---
-## Testing Angular Services (with HTTP)
+## 13. Edge Cases
-All OSF Angular services that make HTTP requests must be tested using `HttpClientTestingModule` and `HttpTestingController`. This testing style verifies both the API call itself and the logic that maps the response into application data.
+### `ngOnDestroy` — conditional cleanup
-When using HttpTestingController to flush HTTP requests in tests, only use data from the @testing/data mocks to ensure consistency and full test coverage.
+Components that auto-save on destroy must skip saves when the resource was already deleted. Test both paths.
-Any error handling will also need to be tested.
+```typescript
+it('should skip updates on destroy when draft was deleted', () => {
+ (store.dispatch as jest.Mock).mockClear();
+ component.isDraftDeleted = true;
+ component.ngOnDestroy();
+ expect(store.dispatch).not.toHaveBeenCalled();
+});
-### Setup
+it('should dispatch update on destroy when fields changed', () => {
+ component.metadataForm.patchValue({ title: 'Changed Title' });
+ (store.dispatch as jest.Mock).mockClear();
+ component.ngOnDestroy();
+ expect(store.dispatch).toHaveBeenCalledWith(
+ new UpdateDraft('draft-1', expect.objectContaining({ title: 'Changed Title' }))
+ );
+});
-```ts
-import { HttpTestingController } from '@angular/common/http/testing';
-import { OSFTestingModule } from '@testing/osf.testing.module';
+it('should not dispatch update on destroy when fields are unchanged', () => {
+ (store.dispatch as jest.Mock).mockClear();
+ component.ngOnDestroy();
+ expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdateDraft));
+});
+```
-let service: YourService;
+### Null / undefined selector values
-beforeEach(() => {
- TestBed.configureTestingModule({
- imports: [OSFTestingModule],
- providers: [YourService],
+```typescript
+it('should handle null draft', () => {
+ const { component } = setup({
+ selectorOverrides: [{ selector: Selectors.getDraft, value: null }],
});
+ expect(component).toBeTruthy();
+});
+```
- service = TestBed.inject(YourService);
+### Empty arrays vs populated arrays
+
+```typescript
+it('should mark invalid when required field has empty array', () => {
+ const { component } = setup({
+ selectorOverrides: [{ selector: Selectors.getStepsData, value: { field1: [] } }],
+ });
+ expect(component.steps()[1].invalid).toBe(true);
+});
+
+it('should not mark invalid with non-empty array', () => {
+ const { component } = setup({
+ selectorOverrides: [{ selector: Selectors.getStepsData, value: { field1: ['item'] } }],
+ });
+ expect(component.steps()[1].invalid).toBe(false);
});
```
-### Example Test
+### Missing links / properties
-```ts
-it('should call correct endpoint and return expected data', inject(
- [HttpTestingController],
- (httpMock: HttpTestingController) => {
- service.getSomething().subscribe((data) => {
- expect(data).toEqual(mockData);
- });
+```typescript
+it('should not upload when no upload link', () => {
+ currentFolderSignal.set({ links: {} } as FileFolderModel);
+ component.uploadFiles(file);
+ expect(mockFilesService.uploadFile).not.toHaveBeenCalled();
+});
+```
- const req = httpMock.expectOne('/api/endpoint');
- expect(req.request.method).toBe('GET');
- req.flush(getMockDataFromTestingData());
+### File size limits
- httpMock.verify(); // Verify no outstanding HTTP calls
- }
-));
+```typescript
+it('should warn on oversized file', () => {
+ const oversizedFile = new File([''], 'big.bin');
+ Object.defineProperty(oversizedFile, 'size', { value: FILE_SIZE_LIMIT });
+ component.onFileSelected({ target: { files: [oversizedFile] } } as unknown as Event);
+ expect(toastService.showWarn).toHaveBeenCalledWith('shared.files.limitText');
+});
```
-### Key Rules
+### Deduplication
-- Use `OSFTestingModule` to isolate the service
-- Inject and use `HttpTestingController`
-- Always call `httpMock.expectOne()` to verify the URL and method
-- Always call `req.flush()` to simulate the backend response
-- Add `httpMock.verify()` in each `it` to catch unflushed requests
+```typescript
+it('should deduplicate file selection', () => {
+ const file = { id: 'file-1' } as FileModel;
+ component.onFileTreeSelected(file);
+ component.onFileTreeSelected(file);
+ expect(component.filesSelection).toEqual([file]);
+});
+```
+
+### Conditional dispatch based on state
+
+```typescript
+it('should not dispatch when submitting', () => {
+ const { store } = setup({
+ selectorOverrides: [
+ { selector: Selectors.isDraftSubmitting, value: true },
+ { selector: Selectors.getDraft, value: { ...DEFAULT_DRAFT, hasProject: true } },
+ ],
+ });
+ expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchProjectChildren));
+});
+```
---
-## Testing Angular Components
+## 14. Testing Angular Services (HTTP)
-- coming soon
+All services that make HTTP requests must be tested using `HttpClientTestingModule` and `HttpTestingController`. Only use data from `@testing/data` mocks when flushing requests — never hardcode response values inline.
----
+### Setup
-## Testing Angular Pipes
+```typescript
+import { HttpTestingController } from '@angular/common/http/testing';
+import { provideOSFCore, provideOSFHttp } from '@testing/osf.testing.provider';
-- coming soon
+let service: YourService;
----
+beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [],
+ providers: [provideOSFCore(), provideOSFHttp(), YourService],
+ });
+ service = TestBed.inject(YourService);
+});
+```
-## Testing Angular Directives
+### Example test
-- coming soon
+```typescript
+it('should call correct endpoint and return expected data', () => {
+ const httpMock = TestBed.inject(HttpTestingController);
----
+ service.getSomething().subscribe((data) => {
+ expect(data).toEqual(mockData);
+ });
+
+ const req = httpMock.expectOne('/api/endpoint');
+ expect(req.request.method).toBe('GET');
+ req.flush(getMockDataFromTestingData());
-## NGXS State Testing Strategy
+ httpMock.verify();
+});
+```
-The OSF Angular strategy for NGXS state testing is to create **small integration test scenarios**. This is a deliberate departure from traditional **black box isolated** testing. The rationale is:
+### Key rules
-1. **NGXS actions** tested in isolation are difficult to mock and result in garbage-in/garbage-out tests.
-2. **NGXS selectors** tested in isolation are easy to mock but also lead to garbage-in/garbage-out outcomes.
-3. **NGXS states** tested in isolation are easy to invoke but provide no meaningful validation.
-4. **Mocking service calls** during state testing introduces false positives, since the mocked service responses may not reflect actual backend behavior.
+- Use `provideOSFCore() + provideOSFHttp()` to isolate the service
+- Always call `httpMock.expectOne()` to verify the URL and method
+- Always call `req.flush()` with data from `@testing/data` — never hardcode responses inline
+- Add `httpMock.verify()` at the end of each test to catch unflushed requests
+- Error handling paths must also be tested
-This approach favors realism and accuracy over artificial test isolation.
+---
-### Test Outline Strategy
+## 15. Testing NGXS State
-1. **Dispatch the primary action** – Kick off the state logic under test.
-2. **Dispatch any dependent actions** – Include any secondary actions that rely on the primary action's outcome.
-3. **Verify the loading selector is `true`** – Ensure the loading state is activated during the async flow.
-4. **Verify the service call using `HttpTestingController` and `@testing/data` mocks** – Confirm that the correct HTTP request is made and flushed with known mock data.
-5. **Verify the loading selector is `false`** – Ensure the loading state deactivates after the response is handled.
-6. **Verify the primary data selector** – Check that the core selector related to the dispatched action returns the expected state.
-7. **Verify any additional selectors** – Assert the output of other derived selectors relevant to the action.
-8. **Validate the test with `httpMock.verify()`** – Confirm that all HTTP requests were flushed and none remain unhandled:
+The OSF Angular strategy for NGXS state testing is to create **small integration test scenarios** rather than isolated unit tests. This is a deliberate design decision.
-```ts
-expect(httpMock.verify).toBeTruthy();
-```
+### Why integration testing for NGXS?
-### Example
+- Actions tested in isolation are hard to mock and produce garbage-in/garbage-out tests
+- Selectors tested in isolation are easy to mock but equally produce false positives
+- States tested in isolation are easy to invoke but provide no meaningful validation
+- Mocking service calls during state tests introduces false positives — mocked responses may not reflect actual backend behaviour
-This is an example of an NGXS action test that involves both a **primary action** and a **dependent action**. The dependency must be dispatched first to ensure the test environment mimics the actual runtime behavior. This pattern helps validate not only the action effects but also the full selector state after updates. All HTTP requests are flushed using the centralized `@testing/data` mocks.
+### Test outline — required steps
-```ts
-it('should test action, state and selectors', inject([HttpTestingController], (httpMock: HttpTestingController) => {
+1. **Dispatch the primary action** — kick off the state logic under test
+2. **Dispatch any dependent actions** — include secondary actions that rely on the primary action's outcome
+3. **Verify the loading selector is `true`** — ensure loading state activates during the async flow
+4. **Flush HTTP requests with `@testing/data` mocks** — confirm correct requests are made and flushed with known data
+5. **Verify the loading selector is `false`** — ensure loading deactivates after the response is handled
+6. **Verify the primary data selector** — check the core selector returns expected state
+7. **Verify additional selectors** — assert derived selectors relevant to the action
+8. **Call `httpMock.verify()`** — confirm no HTTP requests remain unhandled
+
+### Example
+
+```typescript
+it('should test action, state and selectors', () => {
+ const httpMock = TestBed.inject(HttpTestingController);
let result: any[] = [];
- // Dependency Action
+
+ // 1. Dispatch dependent action first
store.dispatch(new GetAuthorizedStorageAddons('reference-id')).subscribe();
- // Primary Action
+ // 2. Dispatch primary action
store.dispatch(new GetAuthorizedStorageOauthToken('account-id')).subscribe(() => {
result = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddons);
});
- // Loading selector is true
+ // 3. Loading selector is true
const loading = store.selectSignal(AddonsSelectors.getAuthorizedStorageAddonsLoading);
expect(loading()).toBeTruthy();
- // Http request for service for dependency action
- let request = httpMock.expectOne('api/path/dependency/action');
- expect(request.request.method).toBe('GET');
- // @testing/data response mock
- request.flush(getAddonsAuthorizedStorageData());
+ // 4a. Flush dependent action HTTP request
+ let req = httpMock.expectOne('api/path/dependency/action');
+ expect(req.request.method).toBe('GET');
+ req.flush(getAddonsAuthorizedStorageData());
- // Http request for service for primary action
- let request = httpMock.expectOne('api/path/primary/action');
- expect(request.request.method).toBe('PATCH');
- // @testing/data response mock with updates
+ // 4b. Flush primary action HTTP request
+ req = httpMock.expectOne('api/path/primary/action');
+ expect(req.request.method).toBe('PATCH');
const addonWithToken = getAddonsAuthorizedStorageData(1);
addonWithToken.data.attributes.oauth_token = 'ya2.34234324534';
- request.flush(addonWithToken);
+ req.flush(addonWithToken);
- // Full testing of the dependency selector
- expect(result[1]).toEqual(
- Object({
- accountOwnerId: '0b441148-83e5-4f7f-b302-b07b528b160b',
- })
- );
+ // 5. Loading selector is false
+ expect(loading()).toBeFalsy();
- // Full testing of the primary selector
- let oauthToken = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(result[0].id));
+ // 6. Primary selector — verify only the targeted record was updated
+ const oauthToken = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(result[0].id));
expect(oauthToken).toBe('ya29.A0AS3H6NzDCKgrUx');
- // Verify only the requested `account-id` was updated
- oauthToken = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(result[1].id));
- expect(oauthToken).toBe(result[1].oauthToken);
+ // 7. Other selector — verify untargeted record is unchanged
+ const otherToken = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(result[1].id));
+ expect(otherToken).toBe(result[1].oauthToken);
- // Loading selector is false
- expect(loading()).toBeFalsy();
-
- // httpMock.verify to ensure no other api calls are called.
- expect(httpMock.verify).toBeTruthy();
-}));
+ // 8. No outstanding requests
+ httpMock.verify();
+});
```
---
+
+## 16. Test Data
+
+Test data lives in two directories under `src/testing/`. Always use these — never hardcode response values inline in tests.
+
+### `testing/mocks/` — domain model mocks (89+ files)
+
+Pre-built mock objects for domain models used directly in component tests. Imported via `@testing/mocks/*`.
+
+| File | Purpose |
+| ---------------------------- | ---------------------------------------------- |
+| `registries.mock.ts` | `MOCK_DRAFT_REGISTRATION`, `MOCK_PAGES_SCHEMA` |
+| `draft-registration.mock.ts` | `MOCK_DRAFT_REGISTRATION` with full shape |
+| `schema-response.mock.ts` | Schema response fixtures |
+| `contributors.mock.ts` | Contributor model mocks |
+| `project.mock.ts` | Project model mocks |
+
+### `testing/data/` — JSON API response fixtures
+
+Centralised raw JSON API responses used for HTTP flush in service and state integration tests. Imported via `@testing/data/*`.
+
+| File | Purpose |
+| ------------------------------------- | --------------------------------- |
+| `addons.authorized-storage.data.ts` | Authorised storage addon fixtures |
+| `addons.external-storage.data.ts` | External storage addon fixtures |
+| `addons.configured.data.ts` | Configured addon state fixtures |
+| `addons.operation-invocation.data.ts` | Operation invocation fixtures |
+
+### Why centralised test data matters
+
+- Any change to an underlying data model produces cascading test failures, exposing the full scope of a refactor
+- Hardcoded inline values lead to false positives and missed regressions
+- Consistent data across tests makes selector and state assertions directly comparable
+
+### Data structure principles
+
+1. Include enough data to cover all relevant permutations required by the test suite
+2. Ensure data reflects all possible states of the model
+
+---
+
+## 17. Coverage Enforcement
+
+This project strictly enforces 90%+ test coverage through GitHub Actions CI.
+
+### Coverage requirements
+
+| File type | Requirement | Notes |
+| ------------- | ------------------ | ------------------------------------------ |
+| `*.ts` | 90%+ line & branch | Zero exceptions |
+| Services | 90%+ | Must mock HTTP via `HttpTestingController` |
+| Components | 90%+ | DOM + Input + Output event coverage |
+| Pipes / utils | 90%+ | All edge cases tested |
+| NGXS state | 90%+ | Integration test approach required |
+
+### Enforcement pipeline
+
+- **GitHub Actions CI:** runs on every PR and push — build fails if a single uncovered branch, line, or function exists
+
+> **Tip:** Use `npm run test:watch` during development to maintain coverage incrementally rather than discovering gaps at push time.
+
+---
+
+## 18. Best Practices
+
+1. **Always use `provideOSFCore()`** — never import `OSFTestingModule` directly in new tests.
+2. **Always use `provideMockStore()`** — never mock `component.actions` via `Object.defineProperty`.
+3. **Always pass explicit mocks to `MockProvider`** when you need `jest.fn()` assertions. Bare `MockProvider(Service)` creates ng-mocks stubs.
+4. **Check `@testing/` before creating inline mocks** — builders and factories almost certainly exist.
+5. **Prefer a single flat `describe` block** per file to keep tests searchable and prevent state leakage. Use nested `describe` blocks when it significantly simplifies setup or groups logically distinct behaviors. No `afterEach`.
+6. **No redundant tests** — merge tests that cover the same code path.
+7. **Use `(store.dispatch as jest.Mock).mockClear()`** when `ngOnInit` dispatches and you need isolated per-test assertions.
+8. **Use `WritableSignal` for dynamic state** — pass `signal()` values to `provideMockStore` when tests need to mutate state mid-test.
+9. **Use `Subject` for dialog `onClose`** — gives explicit control over dialog result timing. Use `provideDynamicDialogRefMock()` where applicable.
+10. **Use `fakeAsync` + `tick`** for debounced operations — specify the exact debounce duration.
+11. **Use `fixture.componentRef.setInput()`** for signal inputs — never direct property assignment.
+12. **Use `ngMocks.faster()`** when all tests in a file share identical `TestBed` config — reuses the compiled module for speed. Do not use if any test requires a different config: shared state will cause subtle test pollution.
+13. **Use typed mock interfaces** (`ToastServiceMockType`, `RouterMockType`, etc.) — avoid `any`.
+14. **Test both positive and negative paths** — confirm an action fires AND confirm it does not fire when conditions are not met.
+15. **Only use `@testing/data` fixtures in HTTP flushes** — never hardcode response values inline in service or state tests.
+16. **Each test should highlight the most critical aspect of the code** — if a test fails during a refactor, it should clearly signal that a core feature was impacted.
+
+---
+
+## Appendix: Assertion Patterns
+
+### Action dispatch
+
+```typescript
+expect(store.dispatch).toHaveBeenCalledWith(new MyAction('id'));
+expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(MyAction));
+expect(store.dispatch).toHaveBeenCalledWith(new UpdateDraft('draft-1', expect.objectContaining({ title: 'Changed' })));
+```
+
+### Router navigation
+
+```typescript
+expect(mockRouter.navigate).toHaveBeenCalledWith(['../1'], expect.objectContaining({ relativeTo: expect.anything() }));
+expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/target');
+```
+
+### Dialog open calls
+
+```typescript
+expect(mockDialog.open).toHaveBeenCalled();
+const callArgs = (mockDialog.open as jest.Mock).mock.calls[0];
+expect(callArgs[1].header).toBe('expected.title');
+expect(callArgs[1].data.draftId).toBe('draft-1');
+```
+
+### Filtering dispatch calls by action type
+
+```typescript
+const calls = (store.dispatch as jest.Mock).mock.calls.filter(([a]: [any]) => a instanceof GetProjects);
+expect(calls.length).toBe(1);
+expect(calls[0][0]).toEqual(new GetProjects('user-1', 'abc'));
+```
diff --git a/jest.config.js b/jest.config.js
index a59db139c..4d2d69fcc 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -3,6 +3,8 @@ module.exports = {
setupFilesAfterEnv: ['/setup-jest.ts'],
globalSetup: '/jest.global-setup.ts',
collectCoverage: false,
+ clearMocks: true,
+ restoreMocks: true,
coverageReporters: ['json-summary', 'lcov', 'clover'],
moduleNameMapper: {
'^@osf/(.*)$': '/src/app/$1',
@@ -36,12 +38,10 @@ module.exports = {
'!src/app/app.config.ts',
'!src/app/app.routes.ts',
'!src/app/**/*.routes.{ts,js}',
- '!src/app/**/**/*.routes.{ts,js}',
'!src/app/**/*.route.{ts,js}',
'!src/app/**/mappers/**',
'!src/app/shared/mappers/**',
- '!src/app/**/*.models.{ts.js}',
- '!src/app/**/*.model.{ts.js}',
+ '!src/app/**/*.model.{ts,js}',
'!src/app/**/models/*.{ts,js}',
'!src/app/shared/models/**',
'!src/app/**/*.enum.{ts,js}',
@@ -54,10 +54,10 @@ module.exports = {
extensionsToTreatAsEsm: ['.ts'],
coverageThreshold: {
global: {
- branches: 34.8,
- functions: 38.0,
- lines: 65.5,
- statements: 66.0,
+ branches: 43.3,
+ functions: 42.7,
+ lines: 69.3,
+ statements: 69.8,
},
},
watchPathIgnorePatterns: [
diff --git a/setup-jest.ts b/setup-jest.ts
index 0ec261638..9fbefed7c 100644
--- a/setup-jest.ts
+++ b/setup-jest.ts
@@ -52,3 +52,11 @@ jest.mock('@newrelic/browser-agent/loaders/browser-agent', () => ({
stop: jest.fn(),
})),
}));
+
+if (!globalThis.structuredClone) {
+ Object.defineProperty(globalThis, 'structuredClone', {
+ value: (value: T): T => JSON.parse(JSON.stringify(value)) as T,
+ writable: true,
+ configurable: true,
+ });
+}
diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts
index 1e9d45fad..4756b8699 100644
--- a/src/app/app.routes.server.ts
+++ b/src/app/app.routes.server.ts
@@ -21,10 +21,6 @@ export const serverRoutes: ServerRoute[] = [
path: 'forgotpassword',
renderMode: RenderMode.Prerender,
},
- {
- path: '',
- renderMode: RenderMode.Prerender,
- },
{
path: 'dashboard',
renderMode: RenderMode.Client,
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index f40e58fea..1de586b65 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -107,6 +107,13 @@ export const routes: Routes = [
'@osf/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component'
).then((mod) => mod.PreprintPendingModerationComponent),
},
+ {
+ path: 'preprints/:providerId/:id/download',
+ loadComponent: () =>
+ import('@osf/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component').then(
+ (c) => c.PreprintDownloadRedirectComponent
+ ),
+ },
{
path: 'preprints/:providerId/:id',
loadComponent: () =>
@@ -175,6 +182,14 @@ export const routes: Routes = [
import('./core/components/page-not-found/page-not-found.component').then((mod) => mod.PageNotFoundComponent),
data: { skipBreadcrumbs: true },
},
+ {
+ path: 'spam-content',
+ loadComponent: () =>
+ import('./core/components/resource-is-spammed/resource-is-spammed.component').then(
+ (mod) => mod.ResourceIsSpammedComponent
+ ),
+ data: { skipBreadcrumbs: true },
+ },
{
path: 'project/:id/node/:nodeId/files/:provider/:fileId',
loadComponent: () =>
diff --git a/src/app/core/animations/fade.in-out.animation.ts b/src/app/core/animations/fade.in-out.animation.ts
deleted file mode 100644
index 7befb072b..000000000
--- a/src/app/core/animations/fade.in-out.animation.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { animate, style, transition, trigger } from '@angular/animations';
-
-/**
- * Angular animation trigger for fading elements in and out.
- *
- * This trigger can be used with Angular structural directives like `*ngIf` or `@if`
- * to smoothly animate the appearance and disappearance of components or elements.
- *
- * ## Usage:
- *
- * In the component decorator:
- * ```ts
- * @Component({
- * selector: 'my-component',
- * templateUrl: './my.component.html',
- * animations: [fadeInOut]
- * })
- * export class MyComponent {}
- * ```
- *
- * In the template:
- * ```html
- * @if (show) {
- *
- * Fades in and out!
- *
- * }
- * ```
- *
- * ## Transitions:
- * - **:enter** — Fades in from opacity `0` to `1` over `200ms`.
- * - **:leave** — Fades out from opacity `1` to `0` over `200ms`.
- *
- * @returns An Angular `AnimationTriggerMetadata` object used for component animations.
- */
-export const fadeInOutAnimation = trigger('fadeInOut', [
- transition(':enter', [style({ opacity: 0 }), animate('200ms', style({ opacity: 1 }))]),
- transition(':leave', [animate('200ms', style({ opacity: 0 }))]),
-]);
diff --git a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.spec.ts b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.spec.ts
index c40290217..e6eefb1a9 100644
--- a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.spec.ts
+++ b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.spec.ts
@@ -20,7 +20,7 @@ describe('Component: Cookie Consent Banner', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
- imports: [OSFTestingModule, CookieConsentBannerComponent, MockComponent(IconComponent)],
+ imports: [CookieConsentBannerComponent, OSFTestingModule, MockComponent(IconComponent)],
providers: [{ provide: CookieService, useValue: cookieServiceMock }],
});
diff --git a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.ts b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.ts
index 30f039af9..c593da853 100644
--- a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.ts
+++ b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.ts
@@ -7,7 +7,6 @@ import { Message } from 'primeng/message';
import { isPlatformBrowser } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, PLATFORM_ID, signal } from '@angular/core';
-import { fadeInOutAnimation } from '@core/animations/fade.in-out.animation';
import { IconComponent } from '@osf/shared/components/icon/icon.component';
/**
@@ -15,7 +14,6 @@ import { IconComponent } from '@osf/shared/components/icon/icon.component';
*
* - Uses `ngx-cookie-service` to persist acceptance across sessions.
* - Automatically hides the banner if consent is already recorded.
- * - Animates in/out using the `fadeInOutAnimation`.
* - Supports translation via `TranslatePipe`.
*/
@Component({
@@ -23,7 +21,6 @@ import { IconComponent } from '@osf/shared/components/icon/icon.component';
templateUrl: './cookie-consent-banner.component.html',
styleUrls: ['./cookie-consent-banner.component.scss'],
imports: [Button, TranslatePipe, IconComponent, Message],
- animations: [fadeInOutAnimation],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CookieConsentBannerComponent {
diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html
index a936ebefc..9dd9ed582 100644
--- a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html
+++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html
@@ -1,13 +1,12 @@
@if (maintenance() && !dismissed()) {
}
diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts
index e617a1333..80d8d59b1 100644
--- a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts
+++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.spec.ts
@@ -1,16 +1,15 @@
import { CookieService } from 'ngx-cookie-service';
-import { MessageModule } from 'primeng/message';
-
import { of } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
-import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MaintenanceBannerComponent } from './maintenance-banner.component';
+import { OSFTestingModule } from '@testing/osf.testing.module';
+
describe('Component: Maintenance Banner', () => {
let fixture: ComponentFixture;
let httpClient: { get: jest.Mock };
@@ -25,7 +24,7 @@ describe('Component: Maintenance Banner', () => {
httpClient = { get: jest.fn() } as any;
await TestBed.configureTestingModule({
- imports: [MaintenanceBannerComponent, NoopAnimationsModule, MessageModule],
+ imports: [MaintenanceBannerComponent, OSFTestingModule],
providers: [
{ provide: CookieService, useValue: cookieService },
{ provide: HttpClient, useValue: httpClient },
diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts
index 05b269412..71a328e52 100644
--- a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts
+++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts
@@ -5,8 +5,6 @@ import { MessageModule } from 'primeng/message';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, OnInit, PLATFORM_ID, signal } from '@angular/core';
-import { fadeInOutAnimation } from '@core/animations/fade.in-out.animation';
-
import { MaintenanceModel } from '../models/maintenance.model';
import { MaintenanceService } from '../services/maintenance.service';
@@ -17,8 +15,6 @@ import { MaintenanceService } from '../services/maintenance.service';
* the banner. If not, it queries the maintenance status from the server and displays
* the maintenance message if one is active.
*
- * The component supports animation via `fadeInOutAnimation` and is optimized with `OnPush` change detection.
- *
* @example
* ```html
*
@@ -29,7 +25,6 @@ import { MaintenanceService } from '../services/maintenance.service';
imports: [CommonModule, MessageModule],
templateUrl: './maintenance-banner.component.html',
styleUrls: ['./maintenance-banner.component.scss'],
- animations: [fadeInOutAnimation],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MaintenanceBannerComponent implements OnInit {
diff --git a/src/app/core/components/resource-is-spammed/resource-is-spammed.component.html b/src/app/core/components/resource-is-spammed/resource-is-spammed.component.html
new file mode 100644
index 000000000..a55f6556b
--- /dev/null
+++ b/src/app/core/components/resource-is-spammed/resource-is-spammed.component.html
@@ -0,0 +1,13 @@
+
+ {{ 'resourceSpammed.title' | translate }}
+
+
+ {{ 'resourceSpammed.message' | translate }}
+
+
+
+ {{ 'resourceSpammed.contact' | translate }}
+ {{ supportEmail }}
+ {{ 'resourceSpammed.footer' | translate }}
+
+
diff --git a/src/app/core/components/resource-is-spammed/resource-is-spammed.component.scss b/src/app/core/components/resource-is-spammed/resource-is-spammed.component.scss
new file mode 100644
index 000000000..5d202f7c6
--- /dev/null
+++ b/src/app/core/components/resource-is-spammed/resource-is-spammed.component.scss
@@ -0,0 +1,7 @@
+@use "styles/mixins" as mix;
+
+:host {
+ @include mix.flex-center;
+ flex: 1;
+ background: var(--gradient-3);
+}
diff --git a/src/app/core/components/resource-is-spammed/resource-is-spammed.component.spec.ts b/src/app/core/components/resource-is-spammed/resource-is-spammed.component.spec.ts
new file mode 100644
index 000000000..f2f4c8d00
--- /dev/null
+++ b/src/app/core/components/resource-is-spammed/resource-is-spammed.component.spec.ts
@@ -0,0 +1,29 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ResourceIsSpammedComponent } from './resource-is-spammed.component';
+
+import { provideOSFCore } from '@testing/osf.testing.provider';
+
+describe('ResourceIsSpammedComponent', () => {
+ let component: ResourceIsSpammedComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [ResourceIsSpammedComponent],
+ providers: [provideOSFCore()],
+ });
+
+ fixture = TestBed.createComponent(ResourceIsSpammedComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should set supportEmail from environment token', () => {
+ expect(component.supportEmail).toBe('support@test.com');
+ });
+});
diff --git a/src/app/core/components/resource-is-spammed/resource-is-spammed.component.ts b/src/app/core/components/resource-is-spammed/resource-is-spammed.component.ts
new file mode 100644
index 000000000..1ae22206b
--- /dev/null
+++ b/src/app/core/components/resource-is-spammed/resource-is-spammed.component.ts
@@ -0,0 +1,17 @@
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+
+import { ENVIRONMENT } from '@core/provider/environment.provider';
+
+@Component({
+ selector: 'osf-resource-is-spammed',
+ imports: [TranslatePipe],
+ templateUrl: './resource-is-spammed.component.html',
+ styleUrl: './resource-is-spammed.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ResourceIsSpammedComponent {
+ private readonly environment = inject(ENVIRONMENT);
+ readonly supportEmail = this.environment.supportEmail;
+}
diff --git a/src/app/core/services/user.service.ts b/src/app/core/services/user.service.ts
index 8de41701d..3506c67cc 100644
--- a/src/app/core/services/user.service.ts
+++ b/src/app/core/services/user.service.ts
@@ -5,9 +5,9 @@ import { inject, Injectable } from '@angular/core';
import { ENVIRONMENT } from '@core/provider/environment.provider';
import { ProfileSettingsKey } from '@osf/shared/enums/profile-settings-key.enum';
import { UserMapper } from '@osf/shared/mappers/user';
+import { UserData, UserModel } from '@osf/shared/models/user/user.model';
import { JsonApiService } from '@osf/shared/services/json-api.service';
import { ProfileSettingsUpdate } from '@shared/models/profile-settings-update.model';
-import { UserData, UserModel } from '@shared/models/user/user.models';
import {
UserAcceptedTermsOfServiceJsonApi,
UserDataJsonApi,
diff --git a/src/app/core/store/user/user.actions.ts b/src/app/core/store/user/user.actions.ts
index c645288df..9219d8847 100644
--- a/src/app/core/store/user/user.actions.ts
+++ b/src/app/core/store/user/user.actions.ts
@@ -1,7 +1,7 @@
import { Education } from '@osf/shared/models/user/education.model';
import { Employment } from '@osf/shared/models/user/employment.model';
import { SocialModel } from '@osf/shared/models/user/social.model';
-import { UserModel } from '@osf/shared/models/user/user.models';
+import { UserModel } from '@osf/shared/models/user/user.model';
export class GetCurrentUser {
static readonly type = '[User] Get Current User';
diff --git a/src/app/core/store/user/user.model.ts b/src/app/core/store/user/user.model.ts
index 35a18a34b..e006d52c2 100644
--- a/src/app/core/store/user/user.model.ts
+++ b/src/app/core/store/user/user.model.ts
@@ -1,5 +1,5 @@
import { AsyncStateModel } from '@osf/shared/models/store/async-state.model';
-import { UserModel } from '@osf/shared/models/user/user.models';
+import { UserModel } from '@osf/shared/models/user/user.model';
export interface UserStateModel {
currentUser: AsyncStateModel;
diff --git a/src/app/core/store/user/user.selectors.ts b/src/app/core/store/user/user.selectors.ts
index 7b42ca0ad..311d3eec1 100644
--- a/src/app/core/store/user/user.selectors.ts
+++ b/src/app/core/store/user/user.selectors.ts
@@ -3,7 +3,7 @@ import { Selector } from '@ngxs/store';
import { Education } from '@osf/shared/models/user/education.model';
import { Employment } from '@osf/shared/models/user/employment.model';
import { SocialModel } from '@osf/shared/models/user/social.model';
-import { UserModel } from '@osf/shared/models/user/user.models';
+import { UserModel } from '@osf/shared/models/user/user.model';
import { UserStateModel } from './user.model';
import { UserState } from './user.state';
diff --git a/src/app/core/store/user/user.state.ts b/src/app/core/store/user/user.state.ts
index a5bdb2e88..c3b65d803 100644
--- a/src/app/core/store/user/user.state.ts
+++ b/src/app/core/store/user/user.state.ts
@@ -9,8 +9,8 @@ import { UserService } from '@core/services/user.service';
import { ProfileSettingsKey } from '@osf/shared/enums/profile-settings-key.enum';
import { removeNullable } from '@osf/shared/helpers/remove-nullable.helper';
import { UserMapper } from '@osf/shared/mappers/user';
+import { UserModel } from '@osf/shared/models/user/user.model';
import { SocialModel } from '@shared/models/user/social.model';
-import { UserModel } from '@shared/models/user/user.models';
import {
AcceptTermsOfServiceByUser,
diff --git a/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.ts b/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.ts
index 500b29f19..03be73487 100644
--- a/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.ts
+++ b/src/app/features/admin-institutions/pages/institutions-summary/institutions-summary.component.ts
@@ -9,7 +9,7 @@ import { ActivatedRoute } from '@angular/router';
import { BarChartComponent } from '@osf/shared/components/bar-chart/bar-chart.component';
import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component';
import { StatisticCardComponent } from '@osf/shared/components/statistic-card/statistic-card.component';
-import { DatasetInput } from '@osf/shared/models/charts/dataset-input';
+import { DatasetInput } from '@osf/shared/models/charts/dataset-input.model';
import { SelectOption } from '@osf/shared/models/select-option.model';
import { DoughnutChartComponent } from '@shared/components/doughnut-chart/doughnut-chart.component';
diff --git a/src/app/features/admin-institutions/store/institutions-admin.model.ts b/src/app/features/admin-institutions/store/institutions-admin.model.ts
index d62a6d0eb..239078b99 100644
--- a/src/app/features/admin-institutions/store/institutions-admin.model.ts
+++ b/src/app/features/admin-institutions/store/institutions-admin.model.ts
@@ -1,4 +1,4 @@
-import { Institution } from '@osf/shared/models/institutions/institutions.models';
+import { Institution } from '@osf/shared/models/institutions/institutions.model';
import { AsyncStateModel } from '@osf/shared/models/store/async-state.model';
import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model';
diff --git a/src/app/features/admin-institutions/store/institutions-admin.selectors.ts b/src/app/features/admin-institutions/store/institutions-admin.selectors.ts
index 4211deb98..721901e4d 100644
--- a/src/app/features/admin-institutions/store/institutions-admin.selectors.ts
+++ b/src/app/features/admin-institutions/store/institutions-admin.selectors.ts
@@ -1,6 +1,6 @@
import { Selector } from '@ngxs/store';
-import { Institution } from '@osf/shared/models/institutions/institutions.models';
+import { Institution } from '@osf/shared/models/institutions/institutions.model';
import { InstitutionDepartment, InstitutionSearchFilter, InstitutionSummaryMetrics, InstitutionUser } from '../models';
diff --git a/src/app/features/admin-institutions/store/institutions-admin.state.ts b/src/app/features/admin-institutions/store/institutions-admin.state.ts
index 6e2d720cb..8285669a1 100644
--- a/src/app/features/admin-institutions/store/institutions-admin.state.ts
+++ b/src/app/features/admin-institutions/store/institutions-admin.state.ts
@@ -6,7 +6,7 @@ import { catchError, tap, throwError } from 'rxjs';
import { inject, Injectable } from '@angular/core';
import { handleSectionError } from '@osf/shared/helpers/state-error.handler';
-import { Institution } from '@osf/shared/models/institutions/institutions.models';
+import { Institution } from '@osf/shared/models/institutions/institutions.model';
import { InstitutionsService } from '@osf/shared/services/institutions.service';
import { InstitutionsAdminService } from '../services/institutions-admin.service';
diff --git a/src/app/features/analytics/analytics.component.ts b/src/app/features/analytics/analytics.component.ts
index a24a9d66d..5eba9475b 100644
--- a/src/app/features/analytics/analytics.component.ts
+++ b/src/app/features/analytics/analytics.component.ts
@@ -30,7 +30,7 @@ import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-l
import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens';
import { replaceBadEncodedChars } from '@osf/shared/helpers/format-bad-encoding.helper';
import { Primitive } from '@osf/shared/helpers/types.helper';
-import { DatasetInput } from '@osf/shared/models/charts/dataset-input';
+import { DatasetInput } from '@osf/shared/models/charts/dataset-input.model';
import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service';
import { AnalyticsKpiComponent } from './components';
diff --git a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts
index 8b17c2989..acb6a1d0b 100644
--- a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts
+++ b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts
@@ -14,7 +14,7 @@ import { collectionFilterTypes } from '@osf/features/collections/constants';
import { AddToCollectionSteps, CollectionFilterType } from '@osf/features/collections/enums';
import { CollectionFilterEntry } from '@osf/features/collections/models/collection-filter-entry.model';
import { AddToCollectionSelectors } from '@osf/features/collections/store/add-to-collection';
-import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.models';
+import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model';
import { CollectionsSelectors, GetCollectionDetails } from '@osf/shared/stores/collections';
@Component({
diff --git a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts
index 95913df5c..878893086 100644
--- a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts
+++ b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts
@@ -40,11 +40,12 @@ import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/tr
import { InputLimits } from '@osf/shared/constants/input-limits.const';
import { ResourceType } from '@osf/shared/enums/resource-type.enum';
import { LicenseModel } from '@osf/shared/models/license/license.model';
-import { ProjectModel } from '@osf/shared/models/projects/projects.models';
+import { ProjectModel } from '@osf/shared/models/projects/projects.model';
import { InterpolatePipe } from '@osf/shared/pipes/interpolate.pipe';
import { ToastService } from '@osf/shared/services/toast.service';
import { GetAllContributors } from '@osf/shared/stores/contributors';
import { ClearProjects, ProjectsSelectors, UpdateProjectMetadata } from '@osf/shared/stores/projects';
+import { CollectionsSelectors } from '@shared/stores/collections';
@Component({
selector: 'osf-project-metadata-step',
@@ -86,6 +87,7 @@ export class ProjectMetadataStepComponent {
readonly inputLimits = InputLimits;
readonly selectedProject = select(ProjectsSelectors.getSelectedProject);
+ readonly collectionProvider = select(CollectionsSelectors.getCollectionProvider);
readonly collectionLicenses = select(AddToCollectionSelectors.getCollectionLicenses);
readonly isSelectedProjectUpdateSubmitting = select(ProjectsSelectors.getSelectedProjectUpdateSubmitting);
@@ -113,7 +115,8 @@ export class ProjectMetadataStepComponent {
readonly projectLicense = computed(() => {
const project = this.selectedProject();
- return project ? (this.collectionLicenses().find((license) => license.id === project.licenseId) ?? null) : null;
+ const licenseId = project?.licenseId || this.collectionProvider()?.defaultLicenseId;
+ return project ? (this.collectionLicenses().find((license) => license.id === licenseId) ?? null) : null;
});
private readonly isFormUnchanged = computed(() => {
@@ -235,7 +238,6 @@ export class ProjectMetadataStepComponent {
this.formService.updateLicenseValidators(this.projectMetadataForm, license);
});
}
-
this.populateFormFromProject();
});
diff --git a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts
index 7c610f347..7658ab614 100644
--- a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts
+++ b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts
@@ -9,8 +9,8 @@ import { ChangeDetectionStrategy, Component, computed, input, output, signal } f
import { AddToCollectionSteps } from '@osf/features/collections/enums';
import { ProjectSelectorComponent } from '@osf/shared/components/project-selector/project-selector.component';
+import { ProjectModel } from '@osf/shared/models/projects/projects.model';
import { SetSelectedProject } from '@osf/shared/stores/projects';
-import { ProjectModel } from '@shared/models/projects/projects.models';
import { CollectionsSelectors, GetUserCollectionSubmissions } from '@shared/stores/collections';
import { ProjectsSelectors } from '@shared/stores/projects/projects.selectors';
diff --git a/src/app/features/collections/components/collections-search-result-card/collections-search-result-card.component.spec.ts b/src/app/features/collections/components/collections-search-result-card/collections-search-result-card.component.spec.ts
index f4738728c..96895956c 100644
--- a/src/app/features/collections/components/collections-search-result-card/collections-search-result-card.component.spec.ts
+++ b/src/app/features/collections/components/collections-search-result-card/collections-search-result-card.component.spec.ts
@@ -4,7 +4,7 @@ import { ComponentRef } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component';
-import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.models';
+import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model';
import { CollectionsSearchResultCardComponent } from './collections-search-result-card.component';
diff --git a/src/app/features/collections/components/collections-search-result-card/collections-search-result-card.component.ts b/src/app/features/collections/components/collections-search-result-card/collections-search-result-card.component.ts
index 08299c88f..2a7b91117 100644
--- a/src/app/features/collections/components/collections-search-result-card/collections-search-result-card.component.ts
+++ b/src/app/features/collections/components/collections-search-result-card/collections-search-result-card.component.ts
@@ -5,7 +5,7 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/co
import { collectionFilterNames } from '@osf/features/collections/constants';
import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component';
-import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.models';
+import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model';
@Component({
selector: 'osf-collections-search-result-card',
diff --git a/src/app/features/collections/models/collection-license-json-api.models.ts b/src/app/features/collections/models/collection-license-json-api.model.ts
similarity index 100%
rename from src/app/features/collections/models/collection-license-json-api.models.ts
rename to src/app/features/collections/models/collection-license-json-api.model.ts
diff --git a/src/app/features/collections/services/project-metadata-form.service.ts b/src/app/features/collections/services/project-metadata-form.service.ts
index f0204c614..84a563259 100644
--- a/src/app/features/collections/services/project-metadata-form.service.ts
+++ b/src/app/features/collections/services/project-metadata-form.service.ts
@@ -3,9 +3,9 @@ import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ProjectMetadataFormControls } from '@osf/features/collections/enums';
import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper';
+import { ProjectModel } from '@osf/shared/models/projects/projects.model';
import { LicenseModel } from '@shared/models/license/license.model';
import { ProjectMetadataUpdatePayload } from '@shared/models/project-metadata-update-payload.model';
-import { ProjectModel } from '@shared/models/projects/projects.models';
import { ProjectMetadataForm } from '../models/project-metadata-form.model';
diff --git a/src/app/features/collections/store/add-to-collection/add-to-collection.model.ts b/src/app/features/collections/store/add-to-collection/add-to-collection.model.ts
index 04ad27492..ba47d319d 100644
--- a/src/app/features/collections/store/add-to-collection/add-to-collection.model.ts
+++ b/src/app/features/collections/store/add-to-collection/add-to-collection.model.ts
@@ -1,4 +1,4 @@
-import { CollectionProjectSubmission } from '@osf/shared/models/collections/collections.models';
+import { CollectionProjectSubmission } from '@osf/shared/models/collections/collections.model';
import { LicenseModel } from '@shared/models/license/license.model';
import { AsyncStateModel } from '@shared/models/store/async-state.model';
diff --git a/src/app/features/contributors/contributors.component.spec.ts b/src/app/features/contributors/contributors.component.spec.ts
index 249c8e8b3..d47ee0199 100644
--- a/src/app/features/contributors/contributors.component.spec.ts
+++ b/src/app/features/contributors/contributors.component.spec.ts
@@ -33,16 +33,6 @@ describe('Component: Contributors', () => {
const mockContributors: ContributorModel[] = [MOCK_CONTRIBUTOR, MOCK_CONTRIBUTOR_WITHOUT_HISTORY];
- beforeAll(() => {
- if (typeof (globalThis as any).structuredClone !== 'function') {
- Object.defineProperty(globalThis as any, 'structuredClone', {
- configurable: true,
- writable: true,
- value: (o: unknown) => JSON.parse(JSON.stringify(o)),
- });
- }
- });
-
beforeEach(async () => {
jest.useFakeTimers();
diff --git a/src/app/features/contributors/contributors.component.ts b/src/app/features/contributors/contributors.component.ts
index 8db8f09f6..9be8e6435 100644
--- a/src/app/features/contributors/contributors.component.ts
+++ b/src/app/features/contributors/contributors.component.ts
@@ -395,37 +395,49 @@ export class ContributorsComponent implements OnInit, OnDestroy {
removeContributor(contributor: ContributorModel) {
const isDeletingSelf = contributor.userId === this.currentUser()?.id;
+ const resourceDetails = this.resourceDetails();
+ const resourceId = this.resourceId();
+ const rootParentId = resourceDetails.rootParentId ?? resourceId;
- this.customDialogService
- .open(RemoveContributorDialogComponent, {
- header: 'project.contributors.removeDialog.title',
- width: '448px',
- data: {
- name: contributor.fullName,
- hasChildren: !!this.resourceChildren()?.length,
- },
- })
- .onClose.pipe(
- filter((res) => res !== undefined),
- switchMap((removeFromChildren: boolean) =>
- this.actions.deleteContributor(
- this.resourceId(),
- this.resourceType(),
- contributor.userId,
- isDeletingSelf,
- removeFromChildren
- )
- ),
- takeUntilDestroyed(this.destroyRef)
- )
+ this.loaderService.show();
+
+ this.actions
+ .getResourceWithChildren(rootParentId, resourceId, this.resourceType())
+ .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
- this.toastService.showSuccess('project.contributors.removeDialog.successMessage', {
- name: contributor.fullName,
- });
+ this.loaderService.hide();
- if (isDeletingSelf) {
- this.router.navigate(['/']);
- }
+ this.customDialogService
+ .open(RemoveContributorDialogComponent, {
+ header: 'project.contributors.removeDialog.title',
+ width: '448px',
+ data: {
+ name: contributor.fullName,
+ hasChildren: !!this.resourceChildren()?.length,
+ },
+ })
+ .onClose.pipe(
+ filter((res) => res !== undefined),
+ switchMap((removeFromChildren: boolean) =>
+ this.actions.deleteContributor(
+ this.resourceId(),
+ this.resourceType(),
+ contributor.userId,
+ isDeletingSelf,
+ removeFromChildren
+ )
+ ),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe(() => {
+ this.toastService.showSuccess('project.contributors.removeDialog.successMessage', {
+ name: contributor.fullName,
+ });
+
+ if (isDeletingSelf) {
+ this.router.navigate(['/']);
+ }
+ });
});
}
diff --git a/src/app/features/contributors/models/index.ts b/src/app/features/contributors/models/index.ts
index 83d6f898d..62aef551e 100644
--- a/src/app/features/contributors/models/index.ts
+++ b/src/app/features/contributors/models/index.ts
@@ -1,2 +1,2 @@
export * from './resource-info.model';
-export * from './view-only-components.models';
+export * from './view-only-components.model';
diff --git a/src/app/features/contributors/models/view-only-components.models.ts b/src/app/features/contributors/models/view-only-components.model.ts
similarity index 100%
rename from src/app/features/contributors/models/view-only-components.models.ts
rename to src/app/features/contributors/models/view-only-components.model.ts
diff --git a/src/app/features/files/files.routes.ts b/src/app/features/files/files.routes.ts
index 5c93c2baf..8b4cb2b77 100644
--- a/src/app/features/files/files.routes.ts
+++ b/src/app/features/files/files.routes.ts
@@ -18,6 +18,7 @@ export const filesRoutes: Routes = [
{
path: ':fileProvider',
canMatch: [isFileProvider],
+ data: { canonicalPathTemplate: 'files/:fileProvider' },
loadComponent: () => import('@osf/features/files/pages/files/files.component').then((c) => c.FilesComponent),
},
{
@@ -27,18 +28,12 @@ export const filesRoutes: Routes = [
},
{
path: ':fileGuid',
+ data: { canonicalPathTemplate: 'files/:fileGuid' },
loadComponent: () => {
return import('@osf/features/files/pages/file-detail/file-detail.component').then(
(c) => c.FileDetailComponent
);
},
- children: [
- {
- path: 'metadata',
- loadChildren: () => import('@osf/features/metadata/metadata.routes').then((mod) => mod.metadataRoutes),
- data: { resourceType: ResourceType.File },
- },
- ],
},
],
},
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 1ec9d858f..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
@@ -1,6 +1,6 @@
import { createDispatchMap, select, Store } from '@ngxs/store';
-import { TranslatePipe, TranslateService } from '@ngx-translate/core';
+import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Menu } from 'primeng/menu';
@@ -10,7 +10,6 @@ import { Tab, TabList, Tabs } from 'primeng/tabs';
import { switchMap } from 'rxjs';
import { Clipboard } from '@angular/cdk/clipboard';
-import { DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
@@ -45,10 +44,10 @@ import { MetadataTabsComponent } from '@osf/shared/components/metadata-tabs/meta
import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component';
import { MetadataResourceEnum } from '@osf/shared/enums/metadata-resource.enum';
import { ResourceType } from '@osf/shared/enums/resource-type.enum';
-import { pathJoin } from '@osf/shared/helpers/path-join.helper';
import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.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 { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service';
@@ -95,7 +94,6 @@ import {
templateUrl: './file-detail.component.html',
styleUrl: './file-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
- providers: [DatePipe],
})
export class FileDetailComponent implements OnInit, OnDestroy {
@HostBinding('class') classes = 'flex flex-column flex-1 w-full h-full';
@@ -109,17 +107,14 @@ export class FileDetailComponent implements OnInit, OnDestroy {
readonly customConfirmationService = inject(CustomConfirmationService);
private readonly metaTags = inject(MetaTagsService);
- private readonly datePipe = inject(DatePipe);
+ private readonly metaTagsBuilder = inject(MetaTagsBuilderService);
private readonly viewOnlyService = inject(ViewOnlyLinkHelperService);
- private readonly translateService = inject(TranslateService);
private readonly environment = inject(ENVIRONMENT);
private readonly clipboard = inject(Clipboard);
private readonly signpostingService = inject(SignpostingService);
readonly dataciteService = inject(DataciteService);
- private readonly webUrl = this.environment.webUrl;
-
private readonly actions = createDispatchMap({
getFile: GetFile,
getFileRevisions: GetFileRevisions,
@@ -208,24 +203,14 @@ export class FileDetailComponent implements OnInit, OnDestroy {
}
const file = this.file();
+
if (!file) return null;
- return {
- osfGuid: file.guid,
- title: this.fileCustomMetadata()?.title || file.name,
- type: this.fileCustomMetadata()?.resourceTypeGeneral,
- description:
- this.fileCustomMetadata()?.description ?? this.translateService.instant('files.metaTagDescriptionPlaceholder'),
- url: pathJoin(this.webUrl, this.fileGuid),
- publishedDate: this.datePipe.transform(file.dateCreated, 'yyyy-MM-dd'),
- modifiedDate: this.datePipe.transform(file.dateModified, 'yyyy-MM-dd'),
- language: this.fileCustomMetadata()?.language,
- contributors: this.resourceContributors()?.map((contributor) => ({
- fullName: contributor.fullName,
- givenName: contributor.givenName,
- familyName: contributor.familyName,
- })),
- };
+ return this.metaTagsBuilder.buildFileMetaTagsData({
+ file,
+ fileMetadata: this.fileCustomMetadata(),
+ contributors: this.resourceContributors() ?? [],
+ });
});
constructor() {
diff --git a/src/app/features/files/pages/files/files.component.spec.ts b/src/app/features/files/pages/files/files.component.spec.ts
index 656984423..36741cc1a 100644
--- a/src/app/features/files/pages/files/files.component.spec.ts
+++ b/src/app/features/files/pages/files/files.component.spec.ts
@@ -4,7 +4,7 @@ import { DialogService } from 'primeng/dynamicdialog';
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { ActivatedRoute } from '@angular/router';
+import { ActivatedRoute, Router } from '@angular/router';
import { SENTRY_TOKEN } from '@core/provider/sentry.provider';
import { FileUploadDialogComponent } from '@osf/shared/components/file-upload-dialog/file-upload-dialog.component';
@@ -18,8 +18,10 @@ import { CustomConfirmationService } from '@osf/shared/services/custom-confirmat
import { FilesService } from '@osf/shared/services/files.service';
import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource';
import { GoogleFilePickerComponent } from '@shared/components/google-file-picker/google-file-picker.component';
+import { FileLabelModel } from '@shared/models/files/file-label.model';
import { FilesSelectionActionsComponent } from '../../components';
+import { FileProvider } from '../../constants';
import { FilesSelectors } from '../../store';
import { FilesComponent } from './files.component';
@@ -29,6 +31,8 @@ import { getNodeFilesMappedData } from '@testing/data/files/node.data';
import { testNode } from '@testing/mocks/base-node.mock';
import { OSFTestingModule } from '@testing/osf.testing.module';
import { MockComponentWithSignal } from '@testing/providers/component-provider.mock';
+import { ActivatedRouteMock } from '@testing/providers/route-provider.mock';
+import { provideRouterMock, RouterMockType } from '@testing/providers/router-provider.mock';
import { provideMockStore } from '@testing/providers/store-provider.mock';
describe('Component: Files', () => {
@@ -188,4 +192,138 @@ describe('Component: Files', () => {
expect(() => component.updateFilesList()).not.toThrow();
});
});
+
+ describe('handleRootFolderChange', () => {
+ it('should preserve view_only query param when switching storage providers', () => {
+ const router = TestBed.inject(Router);
+ const navigateSpy = jest.spyOn(router, 'navigate').mockResolvedValue(true);
+
+ const selectedFolder: FileLabelModel = {
+ label: 'Dropbox',
+ folder: { provider: FileProvider.Dropbox } as any,
+ };
+
+ component.handleRootFolderChange(selectedFolder);
+
+ expect(navigateSpy).toHaveBeenCalledWith([`/${component.resourceId()}/files`, FileProvider.Dropbox], {
+ queryParamsHandling: 'preserve',
+ });
+ });
+ });
+
+ describe('invalid provider fallback effect', () => {
+ let innerComponent: FilesComponent;
+ let innerFixture: ComponentFixture;
+ let routerMock: RouterMockType;
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ routerMock = {
+ ...TestBed.inject(Router),
+ navigate: jest.fn().mockResolvedValue(true),
+ url: '/abc123/files/unknownprovider?view_only=testtoken',
+ } as RouterMockType;
+
+ await TestBed.configureTestingModule({
+ imports: [
+ FilesComponent,
+ OSFTestingModule,
+ ...MockComponents(
+ FileUploadDialogComponent,
+ FormSelectComponent,
+ GoogleFilePickerComponent,
+ LoadingSpinnerComponent,
+ SearchInputComponent,
+ SubHeaderComponent,
+ ViewOnlyLinkMessageComponent,
+ GoogleFilePickerComponent,
+ FilesSelectionActionsComponent
+ ),
+ ],
+ providers: [
+ FilesService,
+ MockProvider(CustomConfirmationService),
+ DialogService,
+ {
+ provide: SENTRY_TOKEN,
+ useValue: {
+ captureException: jest.fn(),
+ captureMessage: jest.fn(),
+ setUser: jest.fn(),
+ },
+ },
+ {
+ provide: ActivatedRoute,
+ useValue: ActivatedRouteMock.withParams({ fileProvider: 'unknownprovider' }).build(),
+ },
+ provideRouterMock(routerMock),
+ provideMockStore({
+ signals: [
+ {
+ selector: CurrentResourceSelectors.getResourceDetails,
+ value: testNode,
+ },
+ {
+ selector: FilesSelectors.getRootFolders,
+ value: getNodeFilesMappedData(),
+ },
+ {
+ selector: FilesSelectors.getCurrentFolder,
+ value: getNodeFilesMappedData(0),
+ },
+ {
+ selector: FilesSelectors.getConfiguredStorageAddons,
+ value: getConfiguredAddonsMappedData(),
+ },
+ {
+ selector: FilesSelectors.getProvider,
+ value: 'osfstorage',
+ },
+ {
+ selector: FilesSelectors.getStorageSupportedFeatures,
+ value: {
+ osfstorage: ['AddUpdateFiles', 'DownloadAsZip', 'DeleteFiles', 'CopyInto'],
+ },
+ },
+ ],
+ }),
+ ],
+ })
+ .overrideComponent(FilesComponent, {
+ remove: {
+ imports: [FilesTreeComponent],
+ },
+ add: {
+ imports: [
+ MockComponentWithSignal('osf-files-tree', [
+ 'files',
+ 'currentFolder',
+ 'isLoading',
+ 'viewOnly',
+ 'resourceId',
+ 'provider',
+ 'storage',
+ 'totalCount',
+ 'allowedMenuActions',
+ 'supportUpload',
+ 'selectedFiles',
+ 'scrollHeight',
+ ]),
+ ],
+ },
+ })
+ .compileComponents();
+
+ innerFixture = TestBed.createComponent(FilesComponent);
+ innerComponent = innerFixture.componentInstance;
+ innerFixture.detectChanges();
+ });
+
+ it('should preserve view_only query param when redirecting to osfstorage for invalid provider', () => {
+ expect(routerMock.navigate).toHaveBeenCalledWith(
+ [`/${innerComponent.resourceId()}/files`, FileProvider.OsfStorage],
+ { queryParamsHandling: 'preserve' }
+ );
+ });
+ });
});
diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts
index f06b4a010..75b852eae 100644
--- a/src/app/features/files/pages/files/files.component.ts
+++ b/src/app/features/files/pages/files/files.component.ts
@@ -292,7 +292,9 @@ export class FilesComponent {
const rootFoldersOption = rootFoldersOptions.find((option) => option.folder.provider === providerName);
if (!rootFoldersOption) {
- this.router.navigate([`/${this.resourceId()}/files`, FileProvider.OsfStorage]);
+ this.router.navigate([`/${this.resourceId()}/files`, FileProvider.OsfStorage], {
+ queryParamsHandling: 'preserve',
+ });
} else {
this.currentRootFolder.set({
label: rootFoldersOption.label,
@@ -688,6 +690,6 @@ export class FilesComponent {
handleRootFolderChange(selectedFolder: FileLabelModel) {
const provider = selectedFolder.folder?.provider;
const resourceId = this.resourceId();
- this.router.navigate([`/${resourceId}/files`, provider]);
+ this.router.navigate([`/${resourceId}/files`, provider], { queryParamsHandling: 'preserve' });
}
}
diff --git a/src/app/features/home/pages/dashboard/dashboard.component.ts b/src/app/features/home/pages/dashboard/dashboard.component.ts
index 17522201b..f9fa9eb5c 100644
--- a/src/app/features/home/pages/dashboard/dashboard.component.ts
+++ b/src/app/features/home/pages/dashboard/dashboard.component.ts
@@ -23,11 +23,11 @@ import { SearchInputComponent } from '@osf/shared/components/search-input/search
import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component';
import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants';
import { SortOrder } from '@osf/shared/enums/sort-order.enum';
+import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.model';
+import { MyResourcesSearchFilters } from '@osf/shared/models/my-resources/my-resources-search-filters.model';
import { CustomDialogService } from '@osf/shared/services/custom-dialog.service';
import { ProjectRedirectDialogService } from '@osf/shared/services/project-redirect-dialog.service';
import { ClearMyResources, GetMyProjects, MyResourcesSelectors } from '@osf/shared/stores/my-resources';
-import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models';
-import { MyResourcesSearchFilters } from '@shared/models/my-resources/my-resources-search-filters.models';
import { TableParameters } from '@shared/models/table-parameters.model';
@Component({
diff --git a/src/app/features/institutions/pages/institutions-list/institutions-list.component.spec.ts b/src/app/features/institutions/pages/institutions-list/institutions-list.component.spec.ts
index 2c90dcfd1..a83e876a8 100644
--- a/src/app/features/institutions/pages/institutions-list/institutions-list.component.spec.ts
+++ b/src/app/features/institutions/pages/institutions-list/institutions-list.component.spec.ts
@@ -1,38 +1,30 @@
-import { MockComponents, MockProvider } from 'ng-mocks';
+import { Store } from '@ngxs/store';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { MockComponents } from 'ng-mocks';
+
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { FormControl } from '@angular/forms';
-import { ActivatedRoute, Router } from '@angular/router';
import { ScheduledBannerComponent } from '@core/components/osf-banners/scheduled-banner/scheduled-banner.component';
import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component';
import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component';
import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component';
-import { InstitutionsSelectors } from '@osf/shared/stores/institutions';
+import { FetchInstitutions, InstitutionsSelectors } from '@osf/shared/stores/institutions';
import { InstitutionsListComponent } from './institutions-list.component';
import { MOCK_INSTITUTION } from '@testing/mocks/institution.mock';
import { OSFTestingModule } from '@testing/osf.testing.module';
-import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock';
-import { RouterMockBuilder } from '@testing/providers/router-provider.mock';
import { provideMockStore } from '@testing/providers/store-provider.mock';
-describe.skip('Component: Institutions List', () => {
+describe('InstitutionsListComponent', () => {
let component: InstitutionsListComponent;
let fixture: ComponentFixture;
- let routerMock: ReturnType;
- let activatedRouteMock: ReturnType;
+ let store: Store;
const mockInstitutions = [MOCK_INSTITUTION];
- const mockTotalCount = 2;
beforeEach(async () => {
- routerMock = RouterMockBuilder.create().build();
- activatedRouteMock = ActivatedRouteMockBuilder.create()
- .withQueryParams({ page: '1', size: '10', search: '' })
- .build();
-
await TestBed.configureTestingModule({
imports: [
InstitutionsListComponent,
@@ -43,17 +35,15 @@ describe.skip('Component: Institutions List', () => {
provideMockStore({
signals: [
{ selector: InstitutionsSelectors.getInstitutions, value: mockInstitutions },
- { selector: InstitutionsSelectors.getInstitutionsTotalCount, value: mockTotalCount },
{ selector: InstitutionsSelectors.isInstitutionsLoading, value: false },
],
}),
- MockProvider(Router, routerMock),
- MockProvider(ActivatedRoute, activatedRouteMock),
],
}).compileComponents();
fixture = TestBed.createComponent(InstitutionsListComponent);
component = fixture.componentInstance;
+ store = TestBed.inject(Store);
fixture.detectChanges();
});
@@ -61,6 +51,26 @@ describe.skip('Component: Institutions List', () => {
expect(component).toBeTruthy();
});
+ it('should dispatch FetchInstitutions on init', () => {
+ expect(store.dispatch).toHaveBeenCalledWith(expect.any(FetchInstitutions));
+ const action = (store.dispatch as jest.Mock).mock.calls[0][0] as FetchInstitutions;
+ expect(action.searchValue).toBeUndefined();
+ });
+
+ it('should dispatch FetchInstitutions with search value after debounce', fakeAsync(() => {
+ (store.dispatch as jest.Mock).mockClear();
+ component.searchControl.setValue('test search');
+ tick(300);
+ expect(store.dispatch).toHaveBeenCalledWith(new FetchInstitutions('test search'));
+ }));
+
+ it('should dispatch FetchInstitutions with empty string when search is null', fakeAsync(() => {
+ (store.dispatch as jest.Mock).mockClear();
+ component.searchControl.setValue(null);
+ tick(300);
+ expect(store.dispatch).toHaveBeenCalledWith(new FetchInstitutions(''));
+ }));
+
it('should initialize with correct default values', () => {
expect(component.classes).toBe('flex-1 flex flex-column w-full');
expect(component.searchControl).toBeInstanceOf(FormControl);
diff --git a/src/app/features/meetings/models/index.ts b/src/app/features/meetings/models/index.ts
index 4ff56dcc5..3f2965a75 100644
--- a/src/app/features/meetings/models/index.ts
+++ b/src/app/features/meetings/models/index.ts
@@ -1,4 +1,4 @@
export * from './meeting-feature-card.model';
-export * from './meetings.models';
+export * from './meetings.model';
export * from './meetings-json-api.model';
export * from './partner-organization.model';
diff --git a/src/app/features/meetings/models/meetings.models.ts b/src/app/features/meetings/models/meetings.model.ts
similarity index 100%
rename from src/app/features/meetings/models/meetings.models.ts
rename to src/app/features/meetings/models/meetings.model.ts
diff --git a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.ts b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.ts
index 36f21d30f..bcf1badf8 100644
--- a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.ts
+++ b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.ts
@@ -6,7 +6,7 @@ import { Card } from 'primeng/card';
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component';
-import { Institution } from '@osf/shared/models/institutions/institutions.models';
+import { Institution } from '@osf/shared/models/institutions/institutions.model';
@Component({
selector: 'osf-metadata-affiliated-institutions',
diff --git a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts
index af5c251b3..65616f04e 100644
--- a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts
+++ b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts
@@ -3,7 +3,7 @@ import { MockComponents } from 'ng-mocks';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum';
-import { CollectionSubmission } from '@osf/shared/models/collections/collections.models';
+import { CollectionSubmission } from '@osf/shared/models/collections/collections.model';
import { MetadataCollectionItemComponent } from './metadata-collection-item.component';
diff --git a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts
index e8ee18b6f..1c023afd9 100644
--- a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts
+++ b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts
@@ -8,7 +8,7 @@ import { RouterLink } from '@angular/router';
import { collectionFilterNames } from '@osf/features/collections/constants';
import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum';
-import { CollectionSubmission } from '@osf/shared/models/collections/collections.models';
+import { CollectionSubmission } from '@osf/shared/models/collections/collections.model';
import { KeyValueModel } from '@osf/shared/models/common/key-value.model';
import { CollectionStatusSeverityPipe } from '@osf/shared/pipes/collection-status-severity.pipe';
diff --git a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts
index daa67530d..affc90e98 100644
--- a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts
+++ b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts
@@ -5,7 +5,7 @@ import { Skeleton } from 'primeng/skeleton';
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
-import { CollectionSubmission } from '@osf/shared/models/collections/collections.models';
+import { CollectionSubmission } from '@osf/shared/models/collections/collections.model';
import { MetadataCollectionItemComponent } from '../metadata-collection-item/metadata-collection-item.component';
diff --git a/src/app/features/metadata/components/metadata-date-info/metadata-date-info.component.html b/src/app/features/metadata/components/metadata-date-info/metadata-date-info.component.html
index b4c1d5fa2..b83f434ad 100644
--- a/src/app/features/metadata/components/metadata-date-info/metadata-date-info.component.html
+++ b/src/app/features/metadata/components/metadata-date-info/metadata-date-info.component.html
@@ -5,7 +5,7 @@
{{ 'project.overview.metadata.dateCreated' | translate }}
-
+
{{ dateCreated() | date: dateFormat }}
@@ -15,7 +15,7 @@
{{ 'project.overview.metadata.dateUpdated' | translate }}
-
+
{{ dateModified() | date: dateFormat }}
diff --git a/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.html b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.html
new file mode 100644
index 000000000..3f1465c0d
--- /dev/null
+++ b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.html
@@ -0,0 +1,13 @@
+
+
+
+
{{ 'registry.overview.metadata.type' | translate }}
+
{{ type() }}
+
+
+
+
{{ 'registry.overview.metadata.registry' | translate }}
+
{{ provider()?.name }}
+
+
+
diff --git a/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.scss b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.spec.ts b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.spec.ts
new file mode 100644
index 000000000..8dacb5d07
--- /dev/null
+++ b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.spec.ts
@@ -0,0 +1,130 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { RegistryProviderDetails } from '@osf/shared/models/provider/registry-provider.model';
+
+import { MetadataRegistryInfoComponent } from './metadata-registry-info.component';
+
+import { OSFTestingModule } from '@testing/osf.testing.module';
+
+describe('MetadataRegistryInfoComponent', () => {
+ let component: MetadataRegistryInfoComponent;
+ let fixture: ComponentFixture;
+
+ const mockProvider: RegistryProviderDetails = {
+ id: 'test-provider-id',
+ name: 'Test Registry Provider',
+ descriptionHtml: 'Test description
',
+ permissions: [],
+ brand: null,
+ iri: 'https://example.com/registry',
+ reviewsWorkflow: 'standard',
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [MetadataRegistryInfoComponent, OSFTestingModule],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(MetadataRegistryInfoComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with default values', () => {
+ expect(component.type()).toBe('');
+ expect(component.provider()).toBeUndefined();
+ });
+
+ it('should set type input', () => {
+ const mockType = 'Clinical Trial';
+ fixture.componentRef.setInput('type', mockType);
+ fixture.detectChanges();
+
+ expect(component.type()).toBe(mockType);
+ });
+
+ it('should set provider input', () => {
+ fixture.componentRef.setInput('provider', mockProvider);
+ fixture.detectChanges();
+
+ expect(component.provider()).toEqual(mockProvider);
+ });
+
+ it('should handle undefined type input', () => {
+ fixture.componentRef.setInput('type', undefined);
+ fixture.detectChanges();
+
+ expect(component.type()).toBeUndefined();
+ });
+
+ it('should handle null provider input', () => {
+ fixture.componentRef.setInput('provider', null);
+ fixture.detectChanges();
+
+ expect(component.provider()).toBeNull();
+ });
+
+ it('should render type in template', () => {
+ const mockType = 'Preprint';
+ fixture.componentRef.setInput('type', mockType);
+ fixture.detectChanges();
+
+ const compiled = fixture.nativeElement;
+ const typeElement = compiled.querySelector('[data-test-display-registry-type]');
+ expect(typeElement).toBeTruthy();
+ expect(typeElement.textContent.trim()).toBe(mockType);
+ });
+
+ it('should render provider name in template', () => {
+ fixture.componentRef.setInput('provider', mockProvider);
+ fixture.detectChanges();
+
+ const compiled = fixture.nativeElement;
+ const providerElement = compiled.querySelector('[data-test-display-registry-provider]');
+ expect(providerElement).toBeTruthy();
+ expect(providerElement.textContent.trim()).toBe(mockProvider.name);
+ });
+
+ it('should display empty string when type is empty', () => {
+ fixture.componentRef.setInput('type', '');
+ fixture.detectChanges();
+
+ const compiled = fixture.nativeElement;
+ const typeElement = compiled.querySelector('[data-test-display-registry-type]');
+ expect(typeElement.textContent.trim()).toBe('');
+ });
+
+ it('should display empty string when provider is null', () => {
+ fixture.componentRef.setInput('provider', null);
+ fixture.detectChanges();
+
+ const compiled = fixture.nativeElement;
+ const providerElement = compiled.querySelector('[data-test-display-registry-provider]');
+ expect(providerElement.textContent.trim()).toBe('');
+ });
+
+ it('should display both type and provider when both are set', () => {
+ const mockType = 'Registered Report';
+ fixture.componentRef.setInput('type', mockType);
+ fixture.componentRef.setInput('provider', mockProvider);
+ fixture.detectChanges();
+
+ const compiled = fixture.nativeElement;
+ const typeElement = compiled.querySelector('[data-test-display-registry-type]');
+ const providerElement = compiled.querySelector('[data-test-display-registry-provider]');
+
+ expect(typeElement.textContent.trim()).toBe(mockType);
+ expect(providerElement.textContent.trim()).toBe(mockProvider.name);
+ });
+
+ it('should display translated labels', () => {
+ fixture.detectChanges();
+
+ const compiled = fixture.nativeElement;
+ const headings = compiled.querySelectorAll('h2');
+ expect(headings.length).toBe(2);
+ });
+});
diff --git a/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.ts b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.ts
new file mode 100644
index 000000000..25c83a0f0
--- /dev/null
+++ b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.ts
@@ -0,0 +1,19 @@
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { Card } from 'primeng/card';
+
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+
+import { RegistryProviderDetails } from '@osf/shared/models/provider/registry-provider.model';
+
+@Component({
+ selector: 'osf-metadata-registry-info',
+ imports: [Card, TranslatePipe],
+ templateUrl: './metadata-registry-info.component.html',
+ styleUrl: './metadata-registry-info.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class MetadataRegistryInfoComponent {
+ type = input('');
+ provider = input();
+}
diff --git a/src/app/features/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.spec.ts b/src/app/features/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.spec.ts
index 2378c870a..aa55a264f 100644
--- a/src/app/features/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.spec.ts
+++ b/src/app/features/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.spec.ts
@@ -5,8 +5,8 @@ import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component';
+import { Institution } from '@osf/shared/models/institutions/institutions.model';
import { InstitutionsSelectors } from '@osf/shared/stores/institutions';
-import { Institution } from '@shared/models/institutions/institutions.models';
import { AffiliatedInstitutionsDialogComponent } from './affiliated-institutions-dialog.component';
diff --git a/src/app/features/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.ts b/src/app/features/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.ts
index 079629c43..b05fa2e11 100644
--- a/src/app/features/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.ts
+++ b/src/app/features/metadata/dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component.ts
@@ -9,7 +9,7 @@ import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@ang
import { ReactiveFormsModule } from '@angular/forms';
import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component';
-import { Institution } from '@osf/shared/models/institutions/institutions.models';
+import { Institution } from '@osf/shared/models/institutions/institutions.model';
import { FetchUserInstitutions, InstitutionsSelectors } from '@osf/shared/stores/institutions';
@Component({
diff --git a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.spec.ts b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.spec.ts
index b1e61b48e..cbb3b0b73 100644
--- a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.spec.ts
+++ b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.spec.ts
@@ -28,16 +28,6 @@ describe('ContributorsDialogComponent', () => {
const mockContributors: ContributorModel[] = [MOCK_CONTRIBUTOR];
- beforeAll(() => {
- if (typeof (globalThis as any).structuredClone !== 'function') {
- Object.defineProperty(globalThis as any, 'structuredClone', {
- configurable: true,
- writable: true,
- value: (o: unknown) => JSON.parse(JSON.stringify(o)),
- });
- }
- });
-
beforeEach(async () => {
mockCustomDialogService = CustomDialogServiceMockBuilder.create().build();
diff --git a/src/app/features/metadata/mappers/metadata.mapper.ts b/src/app/features/metadata/mappers/metadata.mapper.ts
index 5c166574d..eb6044cb3 100644
--- a/src/app/features/metadata/mappers/metadata.mapper.ts
+++ b/src/app/features/metadata/mappers/metadata.mapper.ts
@@ -25,6 +25,7 @@ export class MetadataMapper {
provider: response.embeds?.provider?.data.id,
public: response.attributes.public,
currentUserPermissions: response.attributes.current_user_permissions,
+ registrationSupplement: response.attributes.registration_supplement,
};
}
diff --git a/src/app/features/metadata/metadata.component.html b/src/app/features/metadata/metadata.component.html
index 491ad60dd..f49bd7ff0 100644
--- a/src/app/features/metadata/metadata.component.html
+++ b/src/app/features/metadata/metadata.component.html
@@ -33,6 +33,10 @@
[readonly]="!hasWriteAccess()"
/>
+ @if (isRegistrationType()) {
+
+ }
+
{
{ selector: MetadataSelectors.getSubmitting, value: false },
{ selector: MetadataSelectors.getCedarRecords, value: [] },
{ selector: MetadataSelectors.getCedarTemplates, value: null },
+ { selector: RegistrationProviderSelectors.getBrandedProvider, value: null },
],
}),
],
diff --git a/src/app/features/metadata/metadata.component.ts b/src/app/features/metadata/metadata.component.ts
index 4914f6991..5e4dc966d 100644
--- a/src/app/features/metadata/metadata.component.ts
+++ b/src/app/features/metadata/metadata.component.ts
@@ -39,6 +39,7 @@ import {
InstitutionsSelectors,
UpdateResourceInstitutions,
} from '@osf/shared/stores/institutions';
+import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider';
import {
FetchChildrenSubjects,
FetchSelectedSubjects,
@@ -50,6 +51,7 @@ import { MetadataTabsModel } from '@shared/models/metadata-tabs.model';
import { SubjectModel } from '@shared/models/subject/subject.model';
import { MetadataCollectionsComponent } from './components/metadata-collections/metadata-collections.component';
+import { MetadataRegistryInfoComponent } from './components/metadata-registry-info/metadata-registry-info.component';
import { EditTitleDialogComponent } from './dialogs/edit-title-dialog/edit-title-dialog.component';
import {
MetadataAffiliatedInstitutionsComponent,
@@ -114,6 +116,7 @@ import {
MetadataTitleComponent,
MetadataRegistrationDoiComponent,
MetadataCollectionsComponent,
+ MetadataRegistryInfoComponent,
],
templateUrl: './metadata.component.html',
styleUrl: './metadata.component.scss',
@@ -153,6 +156,7 @@ export class MetadataComponent implements OnInit, OnDestroy {
affiliatedInstitutions = select(InstitutionsSelectors.getResourceInstitutions);
areInstitutionsLoading = select(InstitutionsSelectors.areResourceInstitutionsLoading);
areResourceInstitutionsSubmitting = select(InstitutionsSelectors.areResourceInstitutionsSubmitting);
+ registryProvider = select(RegistrationProviderSelectors.getBrandedProvider);
projectSubmissions = select(CollectionsSelectors.getCurrentProjectSubmissions);
isProjectSubmissionsLoading = select(CollectionsSelectors.getCurrentProjectSubmissionsLoading);
diff --git a/src/app/features/metadata/metadata.routes.ts b/src/app/features/metadata/metadata.routes.ts
index cc8c4097e..5c8e80eac 100644
--- a/src/app/features/metadata/metadata.routes.ts
+++ b/src/app/features/metadata/metadata.routes.ts
@@ -14,6 +14,7 @@ export const metadataRoutes: Routes = [
},
{
path: ':recordId',
+ data: { canonicalPathTemplate: 'metadata/:recordId' },
component: MetadataComponent,
},
];
diff --git a/src/app/features/metadata/models/metadata-json-api.model.ts b/src/app/features/metadata/models/metadata-json-api.model.ts
index 11dfbd342..802777461 100644
--- a/src/app/features/metadata/models/metadata-json-api.model.ts
+++ b/src/app/features/metadata/models/metadata-json-api.model.ts
@@ -21,6 +21,7 @@ export interface MetadataAttributesJsonApi {
category?: string;
node_license?: LicenseRecordJsonApi;
public?: boolean;
+ registration_supplement?: string;
current_user_permissions: UserPermissions[];
}
diff --git a/src/app/features/metadata/models/metadata.model.ts b/src/app/features/metadata/models/metadata.model.ts
index 00e71c8bc..d9ebf6491 100644
--- a/src/app/features/metadata/models/metadata.model.ts
+++ b/src/app/features/metadata/models/metadata.model.ts
@@ -1,6 +1,6 @@
+import { Institution } from '@osf/shared/models/institutions/institutions.model';
import { UserPermissions } from '@shared/enums/user-permissions.enum';
import { IdentifierModel } from '@shared/models/identifiers/identifier.model';
-import { Institution } from '@shared/models/institutions/institutions.models';
import { LicenseModel } from '@shared/models/license/license.model';
export interface MetadataModel {
@@ -24,6 +24,7 @@ export interface MetadataModel {
};
public?: boolean;
currentUserPermissions: UserPermissions[];
+ registrationSupplement?: string;
}
export interface CustomItemMetadataRecord {
diff --git a/src/app/features/metadata/services/index.ts b/src/app/features/metadata/services/index.ts
deleted file mode 100644
index 92c69e450..000000000
--- a/src/app/features/metadata/services/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './metadata.service';
diff --git a/src/app/features/metadata/store/metadata.state.ts b/src/app/features/metadata/store/metadata.state.ts
index 305f9d7ab..88ac11b71 100644
--- a/src/app/features/metadata/store/metadata.state.ts
+++ b/src/app/features/metadata/store/metadata.state.ts
@@ -5,9 +5,9 @@ import { catchError, finalize, tap } from 'rxjs';
import { inject, Injectable } from '@angular/core';
import { handleSectionError } from '@osf/shared/helpers/state-error.handler';
+import { MetadataService } from '@osf/shared/services/metadata.service';
import { CedarMetadataRecord, CedarMetadataRecordJsonApi, MetadataModel } from '../models';
-import { MetadataService } from '../services';
import {
AddCedarMetadataRecordToState,
diff --git a/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.spec.ts b/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.spec.ts
index fd0b7ef0f..81c1c24db 100644
--- a/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.spec.ts
+++ b/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.spec.ts
@@ -4,8 +4,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { IconComponent } from '@osf/shared/components/icon/icon.component';
+import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model';
import { CollectionsSelectors } from '@osf/shared/stores/collections';
-import { CollectionSubmissionWithGuid } from '@shared/models/collections/collections.models';
import { DateAgoPipe } from '@shared/pipes/date-ago.pipe';
import { SubmissionReviewStatus } from '../../enums';
diff --git a/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.ts b/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.ts
index 0f5d0a3ae..a1d475ac1 100644
--- a/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.ts
+++ b/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.ts
@@ -12,7 +12,7 @@ import { collectionFilterNames } from '@osf/features/collections/constants';
import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component';
import { IconComponent } from '@osf/shared/components/icon/icon.component';
import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component';
-import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.models';
+import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model';
import { DateAgoPipe } from '@osf/shared/pipes/date-ago.pipe';
import { CollectionsSelectors } from '@osf/shared/stores/collections';
diff --git a/src/app/features/moderation/components/collection-submissions-list/collection-submissions-list.component.ts b/src/app/features/moderation/components/collection-submissions-list/collection-submissions-list.component.ts
index e87f597fd..b98c947df 100644
--- a/src/app/features/moderation/components/collection-submissions-list/collection-submissions-list.component.ts
+++ b/src/app/features/moderation/components/collection-submissions-list/collection-submissions-list.component.ts
@@ -8,7 +8,7 @@ import {
GetCollectionSubmissionContributors,
LoadMoreCollectionSubmissionContributors,
} from '@osf/features/moderation/store/collections-moderation';
-import { CollectionSubmissionWithGuid } from '@shared/models/collections/collections.models';
+import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model';
import { CollectionsModerationSelectors } from '../../store/collections-moderation';
import { CollectionSubmissionItemComponent } from '../collection-submission-item/collection-submission-item.component';
diff --git a/src/app/features/moderation/components/moderators-list/moderators-list.component.spec.ts b/src/app/features/moderation/components/moderators-list/moderators-list.component.spec.ts
index 1da622c33..f19023271 100644
--- a/src/app/features/moderation/components/moderators-list/moderators-list.component.spec.ts
+++ b/src/app/features/moderation/components/moderators-list/moderators-list.component.spec.ts
@@ -41,16 +41,6 @@ describe('ModeratorsListComponent', () => {
const mockModerators: ModeratorModel[] = MOCK_MODERATORS;
- beforeAll(() => {
- if (typeof (globalThis as any).structuredClone !== 'function') {
- Object.defineProperty(globalThis as any, 'structuredClone', {
- configurable: true,
- writable: true,
- value: (o: unknown) => JSON.parse(JSON.stringify(o)),
- });
- }
- });
-
beforeEach(async () => {
mockActivatedRoute = ActivatedRouteMockBuilder.create()
.withParams({ providerId: mockProviderId })
diff --git a/src/app/features/moderation/components/registry-pending-submissions/registry-pending-submissions.component.html b/src/app/features/moderation/components/registry-pending-submissions/registry-pending-submissions.component.html
index 77f120b97..7275ce5ce 100644
--- a/src/app/features/moderation/components/registry-pending-submissions/registry-pending-submissions.component.html
+++ b/src/app/features/moderation/components/registry-pending-submissions/registry-pending-submissions.component.html
@@ -39,7 +39,7 @@
[submission]="item"
[status]="selectedReviewOption()"
(selected)="navigateToRegistration(item)"
- (loadContributors)="loadContributors(item)"
+ (loadAdditionalData)="loadAdditionalData(item)"
(loadMoreContributors)="loadMoreContributors(item)"
>
diff --git a/src/app/features/moderation/components/registry-pending-submissions/registry-pending-submissions.component.ts b/src/app/features/moderation/components/registry-pending-submissions/registry-pending-submissions.component.ts
index ac0f00e76..26b08f29c 100644
--- a/src/app/features/moderation/components/registry-pending-submissions/registry-pending-submissions.component.ts
+++ b/src/app/features/moderation/components/registry-pending-submissions/registry-pending-submissions.component.ts
@@ -26,6 +26,7 @@ import { RegistrySort, SubmissionReviewStatus } from '../../enums';
import { RegistryModeration } from '../../models';
import {
GetRegistrySubmissionContributors,
+ GetRegistrySubmissionFunders,
GetRegistrySubmissions,
LoadMoreRegistrySubmissionContributors,
RegistryModerationSelectors,
@@ -63,6 +64,7 @@ export class RegistryPendingSubmissionsComponent implements OnInit {
getRegistrySubmissions: GetRegistrySubmissions,
getRegistrySubmissionContributors: GetRegistrySubmissionContributors,
loadMoreRegistrySubmissionContributors: LoadMoreRegistrySubmissionContributors,
+ getRegistrySubmissionFunders: GetRegistrySubmissionFunders,
});
readonly submissions = select(RegistryModerationSelectors.getRegistrySubmissions);
@@ -129,6 +131,11 @@ export class RegistryPendingSubmissionsComponent implements OnInit {
this.actions.loadMoreRegistrySubmissionContributors(item.id);
}
+ loadAdditionalData(item: RegistryModeration) {
+ this.actions.getRegistrySubmissionContributors(item.id);
+ this.actions.getRegistrySubmissionFunders(item.id);
+ }
+
private getStatusFromQueryParams() {
const queryParams = this.route.snapshot.queryParams;
const statusValues = Object.values(SubmissionReviewStatus);
diff --git a/src/app/features/moderation/components/registry-submission-item/registry-submission-item.component.html b/src/app/features/moderation/components/registry-submission-item/registry-submission-item.component.html
index 006ce75fc..8bc4d4a6e 100644
--- a/src/app/features/moderation/components/registry-submission-item/registry-submission-item.component.html
+++ b/src/app/features/moderation/components/registry-submission-item/registry-submission-item.component.html
@@ -64,12 +64,19 @@ {{ submission().title }}
+
+
+
diff --git a/src/app/features/moderation/components/registry-submission-item/registry-submission-item.component.ts b/src/app/features/moderation/components/registry-submission-item/registry-submission-item.component.ts
index 770db64d4..93b331b09 100644
--- a/src/app/features/moderation/components/registry-submission-item/registry-submission-item.component.ts
+++ b/src/app/features/moderation/components/registry-submission-item/registry-submission-item.component.ts
@@ -10,6 +10,7 @@ import { ContributorsListComponent } from '@osf/shared/components/contributors-l
import { IconComponent } from '@osf/shared/components/icon/icon.component';
import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component';
import { DateAgoPipe } from '@osf/shared/pipes/date-ago.pipe';
+import { FunderAwardsListComponent } from '@shared/funder-awards-list/funder-awards-list.component';
import { REGISTRY_ACTION_LABEL, ReviewStatusIcon } from '../../constants';
import { ActionStatus, SubmissionReviewStatus } from '../../enums';
@@ -29,6 +30,7 @@ import { RegistryModeration } from '../../models';
AccordionHeader,
AccordionContent,
ContributorsListComponent,
+ FunderAwardsListComponent,
],
templateUrl: './registry-submission-item.component.html',
styleUrl: './registry-submission-item.component.scss',
@@ -37,9 +39,8 @@ import { RegistryModeration } from '../../models';
export class RegistrySubmissionItemComponent {
status = input.required();
submission = input.required();
- loadContributors = output();
loadMoreContributors = output();
-
+ loadAdditionalData = output();
selected = output();
readonly reviewStatusIcon = ReviewStatusIcon;
@@ -67,6 +68,6 @@ export class RegistrySubmissionItemComponent {
});
handleOpen() {
- this.loadContributors.emit();
+ this.loadAdditionalData.emit();
}
}
diff --git a/src/app/features/moderation/components/registry-submissions/registry-submissions.component.html b/src/app/features/moderation/components/registry-submissions/registry-submissions.component.html
index 5066d15f7..73386a8f1 100644
--- a/src/app/features/moderation/components/registry-submissions/registry-submissions.component.html
+++ b/src/app/features/moderation/components/registry-submissions/registry-submissions.component.html
@@ -39,7 +39,7 @@
[submission]="item"
[status]="selectedReviewOption()"
(selected)="navigateToRegistration(item)"
- (loadContributors)="loadContributors(item)"
+ (loadAdditionalData)="loadAdditionalData(item)"
(loadMoreContributors)="loadMoreContributors(item)"
>
diff --git a/src/app/features/moderation/components/registry-submissions/registry-submissions.component.ts b/src/app/features/moderation/components/registry-submissions/registry-submissions.component.ts
index e664daacc..3271d565f 100644
--- a/src/app/features/moderation/components/registry-submissions/registry-submissions.component.ts
+++ b/src/app/features/moderation/components/registry-submissions/registry-submissions.component.ts
@@ -26,6 +26,7 @@ import { RegistrySort, SubmissionReviewStatus } from '../../enums';
import { RegistryModeration } from '../../models';
import {
GetRegistrySubmissionContributors,
+ GetRegistrySubmissionFunders,
GetRegistrySubmissions,
LoadMoreRegistrySubmissionContributors,
RegistryModerationSelectors,
@@ -63,6 +64,7 @@ export class RegistrySubmissionsComponent implements OnInit {
getRegistrySubmissions: GetRegistrySubmissions,
getRegistrySubmissionContributors: GetRegistrySubmissionContributors,
loadMoreRegistrySubmissionContributors: LoadMoreRegistrySubmissionContributors,
+ getRegistrySubmissionFunders: GetRegistrySubmissionFunders,
});
readonly submissions = select(RegistryModerationSelectors.getRegistrySubmissions);
@@ -129,6 +131,11 @@ export class RegistrySubmissionsComponent implements OnInit {
this.actions.loadMoreRegistrySubmissionContributors(item.id);
}
+ loadAdditionalData(item: RegistryModeration) {
+ this.actions.getRegistrySubmissionContributors(item.id);
+ this.actions.getRegistrySubmissionFunders(item.id);
+ }
+
private getStatusFromQueryParams() {
const queryParams = this.route.snapshot.queryParams;
const statusValues = Object.values(SubmissionReviewStatus);
diff --git a/src/app/features/moderation/models/collection-submission-review-action-json.api.ts b/src/app/features/moderation/models/collection-submission-review-action-json-api.model.ts
similarity index 100%
rename from src/app/features/moderation/models/collection-submission-review-action-json.api.ts
rename to src/app/features/moderation/models/collection-submission-review-action-json-api.model.ts
diff --git a/src/app/features/moderation/models/index.ts b/src/app/features/moderation/models/index.ts
index 302a37ead..7d32ef4a3 100644
--- a/src/app/features/moderation/models/index.ts
+++ b/src/app/features/moderation/models/index.ts
@@ -1,5 +1,5 @@
export * from './collection-submission-review-action.model';
-export * from './collection-submission-review-action-json.api';
+export * from './collection-submission-review-action-json-api.model';
export * from './invite-moderator-form.model';
export * from './moderator.model';
export * from './moderator-add.model';
diff --git a/src/app/features/moderation/models/registry-moderation.model.ts b/src/app/features/moderation/models/registry-moderation.model.ts
index 2d59b2681..31da4f051 100644
--- a/src/app/features/moderation/models/registry-moderation.model.ts
+++ b/src/app/features/moderation/models/registry-moderation.model.ts
@@ -1,3 +1,4 @@
+import { Funder } from '@osf/features/metadata/models';
import { RegistrationReviewStates } from '@osf/shared/enums/registration-review-states.enum';
import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum';
import { ContributorModel } from '@shared/models/contributors/contributor.model';
@@ -18,4 +19,6 @@ export interface RegistryModeration {
contributors?: ContributorModel[];
totalContributors?: number;
contributorsPage?: number;
+ funders?: Funder[];
+ fundersLoading?: boolean;
}
diff --git a/src/app/features/moderation/services/moderators.service.ts b/src/app/features/moderation/services/moderators.service.ts
index dfb86fca8..3a184913f 100644
--- a/src/app/features/moderation/services/moderators.service.ts
+++ b/src/app/features/moderation/services/moderators.service.ts
@@ -8,7 +8,7 @@ import { parseSearchTotalCount } from '@osf/shared/helpers/search-total-count.he
import { MapResources } from '@osf/shared/mappers/search';
import { JsonApiResponse } from '@osf/shared/models/common/json-api.model';
import { PaginatedData } from '@osf/shared/models/paginated-data.model';
-import { IndexCardSearchResponseJsonApi } from '@osf/shared/models/search/index-card-search-json-api.models';
+import { IndexCardSearchResponseJsonApi } from '@osf/shared/models/search/index-card-search-json-api.model';
import { SearchUserDataModel } from '@osf/shared/models/user/search-user-data.model';
import { JsonApiService } from '@osf/shared/services/json-api.service';
import { StringOrNull } from '@shared/helpers/types.helper';
diff --git a/src/app/features/moderation/store/collections-moderation/collections-moderation.model.ts b/src/app/features/moderation/store/collections-moderation/collections-moderation.model.ts
index b685d281e..daa7cb0af 100644
--- a/src/app/features/moderation/store/collections-moderation/collections-moderation.model.ts
+++ b/src/app/features/moderation/store/collections-moderation/collections-moderation.model.ts
@@ -1,5 +1,5 @@
import { CollectionSubmissionReviewAction } from '@osf/features/moderation/models';
-import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.models';
+import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model';
import { AsyncStateModel } from '@osf/shared/models/store/async-state.model';
import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model';
diff --git a/src/app/features/moderation/store/registry-moderation/registry-moderation.actions.ts b/src/app/features/moderation/store/registry-moderation/registry-moderation.actions.ts
index 3e350142c..eafd2f856 100644
--- a/src/app/features/moderation/store/registry-moderation/registry-moderation.actions.ts
+++ b/src/app/features/moderation/store/registry-moderation/registry-moderation.actions.ts
@@ -27,3 +27,9 @@ export class LoadMoreRegistrySubmissionContributors {
constructor(public registryId: string) {}
}
+
+export class GetRegistrySubmissionFunders {
+ static readonly type = `${ACTION_SCOPE} Get Registry Submission Funders`;
+
+ constructor(public registryId: string) {}
+}
diff --git a/src/app/features/moderation/store/registry-moderation/registry-moderation.state.ts b/src/app/features/moderation/store/registry-moderation/registry-moderation.state.ts
index 52b068e89..5c4715b76 100644
--- a/src/app/features/moderation/store/registry-moderation/registry-moderation.state.ts
+++ b/src/app/features/moderation/store/registry-moderation/registry-moderation.state.ts
@@ -7,6 +7,7 @@ import { inject, Injectable } from '@angular/core';
import { handleSectionError } from '@osf/shared/helpers/state-error.handler';
import { PaginatedData } from '@osf/shared/models/paginated-data.model';
+import { MetadataService } from '@osf/shared/services/metadata.service';
import { DEFAULT_TABLE_PARAMS } from '@shared/constants/default-table-params.constants';
import { ResourceType } from '@shared/enums/resource-type.enum';
import { ContributorsService } from '@shared/services/contributors.service';
@@ -16,6 +17,7 @@ import { RegistryModerationService } from '../../services';
import {
GetRegistrySubmissionContributors,
+ GetRegistrySubmissionFunders,
GetRegistrySubmissions,
LoadMoreRegistrySubmissionContributors,
} from './registry-moderation.actions';
@@ -29,7 +31,7 @@ import { REGISTRY_MODERATION_STATE_DEFAULTS, RegistryModerationStateModel } from
export class RegistryModerationState {
private readonly registryModerationService = inject(RegistryModerationService);
private readonly contributorsService = inject(ContributorsService);
-
+ private readonly metadataService = inject(MetadataService);
@Action(GetRegistrySubmissionContributors)
getRegistrySubmissionContributors(
ctx: StateContext,
@@ -151,4 +153,59 @@ export class RegistryModerationState {
catchError((error) => handleSectionError(ctx, 'submissions', error))
);
}
+
+ @Action(GetRegistrySubmissionFunders)
+ getRegistrySubmissionFunders(
+ ctx: StateContext,
+ { registryId }: GetRegistrySubmissionFunders
+ ) {
+ const state = ctx.getState();
+ const submission = state.submissions.data.find((s) => s.id === registryId);
+
+ if (submission?.funders && submission.funders.length > 0) {
+ return;
+ }
+
+ ctx.setState(
+ patch({
+ submissions: patch({
+ data: updateItem(
+ (submission) => submission.id === registryId,
+ patch({ fundersLoading: true })
+ ),
+ }),
+ })
+ );
+
+ return this.metadataService.getCustomItemMetadata(registryId).pipe(
+ tap((res) => {
+ ctx.setState(
+ patch({
+ submissions: patch({
+ data: updateItem(
+ (submission) => submission.id === registryId,
+ patch({
+ funders: res.funders,
+ fundersLoading: false,
+ })
+ ),
+ }),
+ })
+ );
+ }),
+ catchError((error) => {
+ ctx.setState(
+ patch({
+ submissions: patch({
+ data: updateItem(
+ (submission) => submission.id === registryId,
+ patch({ fundersLoading: false })
+ ),
+ }),
+ })
+ );
+ return handleSectionError(ctx, 'submissions', error);
+ })
+ );
+ }
}
diff --git a/src/app/features/my-projects/my-projects.component.spec.ts b/src/app/features/my-projects/my-projects.component.spec.ts
index d3d9da18f..5c1caaab4 100644
--- a/src/app/features/my-projects/my-projects.component.spec.ts
+++ b/src/app/features/my-projects/my-projects.component.spec.ts
@@ -48,12 +48,12 @@ describe('MyProjectsComponent', () => {
{ selector: MyResourcesSelectors.getTotalProjects, value: 0 },
{ selector: MyResourcesSelectors.getTotalRegistrations, value: 0 },
{ selector: MyResourcesSelectors.getTotalPreprints, value: 0 },
- { selector: MyResourcesSelectors.getTotalBookmarks, value: 0 },
+ { selector: BookmarksSelectors.getBookmarksTotalCount, value: 0 },
{ selector: BookmarksSelectors.getBookmarksCollectionId, value: null },
{ selector: MyResourcesSelectors.getProjects, value: [] },
{ selector: MyResourcesSelectors.getRegistrations, value: [] },
{ selector: MyResourcesSelectors.getPreprints, value: [] },
- { selector: MyResourcesSelectors.getBookmarks, value: [] },
+ { selector: BookmarksSelectors.getBookmarks, value: [] },
],
}),
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts
index 688499d53..8e499b175 100644
--- a/src/app/features/my-projects/my-projects.component.ts
+++ b/src/app/features/my-projects/my-projects.component.ts
@@ -32,6 +32,8 @@ import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header
import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants';
import { SortOrder } from '@osf/shared/enums/sort-order.enum';
import { IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens';
+import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.model';
+import { MyResourcesSearchFilters } from '@osf/shared/models/my-resources/my-resources-search-filters.model';
import { CustomDialogService } from '@osf/shared/services/custom-dialog.service';
import { ProjectRedirectDialogService } from '@osf/shared/services/project-redirect-dialog.service';
import { BookmarksSelectors, GetAllMyBookmarks, GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks';
@@ -42,8 +44,6 @@ import {
GetMyRegistrations,
MyResourcesSelectors,
} from '@osf/shared/stores/my-resources';
-import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models';
-import { MyResourcesSearchFilters } from '@shared/models/my-resources/my-resources-search-filters.models';
import { QueryParams } from '@shared/models/query-params.model';
import { TableParameters } from '@shared/models/table-parameters.model';
diff --git a/src/app/features/preprints/components/advisory-board/advisory-board.component.spec.ts b/src/app/features/preprints/components/advisory-board/advisory-board.component.spec.ts
index 588caf723..8119d7b99 100644
--- a/src/app/features/preprints/components/advisory-board/advisory-board.component.spec.ts
+++ b/src/app/features/preprints/components/advisory-board/advisory-board.component.spec.ts
@@ -9,84 +9,54 @@ describe('AdvisoryBoardComponent', () => {
const mockHtmlContent =
'Advisory Board This is advisory board content.
';
- beforeEach(async () => {
- await TestBed.configureTestingModule({
+ beforeEach(() => {
+ TestBed.configureTestingModule({
imports: [AdvisoryBoardComponent],
- }).compileComponents();
+ });
fixture = TestBed.createComponent(AdvisoryBoardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
- it('should create', () => {
- expect(component).toBeTruthy();
- });
+ function getSection(): HTMLElement | null {
+ return fixture.nativeElement.querySelector('section');
+ }
it('should have default input values', () => {
expect(component.htmlContent()).toBeNull();
- expect(component.brand()).toBeUndefined();
expect(component.isLandingPage()).toBe(false);
});
- it('should not render section when htmlContent is null', () => {
+ it.each([null, undefined])('should not render section when htmlContent is %s', (htmlContent) => {
+ fixture.componentRef.setInput('htmlContent', htmlContent);
fixture.detectChanges();
- const compiled = fixture.nativeElement;
- const section = compiled.querySelector('section');
-
- expect(section).toBeNull();
- });
-
- it('should not render section when htmlContent is undefined', () => {
- fixture.componentRef.setInput('htmlContent', undefined);
- fixture.detectChanges();
-
- const compiled = fixture.nativeElement;
- const section = compiled.querySelector('section');
-
- expect(section).toBeNull();
+ expect(getSection()).toBeNull();
});
it('should render section when htmlContent is provided', () => {
fixture.componentRef.setInput('htmlContent', mockHtmlContent);
fixture.detectChanges();
- const compiled = fixture.nativeElement;
- const section = compiled.querySelector('section');
-
- expect(section).toBeTruthy();
- expect(section.innerHTML).toBe(mockHtmlContent);
- });
-
- it('should apply correct CSS classes when isLandingPage is false', () => {
- fixture.componentRef.setInput('htmlContent', mockHtmlContent);
- fixture.componentRef.setInput('isLandingPage', false);
- fixture.detectChanges();
-
- const compiled = fixture.nativeElement;
- const section = compiled.querySelector('section');
+ const section = getSection();
expect(section).toBeTruthy();
- expect(section.classList.contains('osf-preprint-service')).toBe(false);
- expect(section.classList.contains('preprints-advisory-board-section')).toBe(true);
- expect(section.classList.contains('pt-3')).toBe(true);
- expect(section.classList.contains('pb-5')).toBe(true);
- expect(section.classList.contains('px-3')).toBe(true);
- expect(section.classList.contains('flex')).toBe(true);
- expect(section.classList.contains('flex-column')).toBe(true);
+ expect(section?.innerHTML).toContain('Advisory Board');
+ expect(section?.innerHTML).toContain('This is advisory board content.');
});
- it('should apply correct CSS classes when isLandingPage is true', () => {
+ it.each([
+ { isLandingPage: false, hasLandingClass: false },
+ { isLandingPage: true, hasLandingClass: true },
+ ])('should handle landing class when isLandingPage is $isLandingPage', ({ isLandingPage, hasLandingClass }) => {
fixture.componentRef.setInput('htmlContent', mockHtmlContent);
- fixture.componentRef.setInput('isLandingPage', true);
+ fixture.componentRef.setInput('isLandingPage', isLandingPage);
fixture.detectChanges();
- const compiled = fixture.nativeElement;
- const section = compiled.querySelector('section');
+ const section = getSection();
expect(section).toBeTruthy();
- expect(section.classList.contains('osf-preprint-service')).toBe(true);
- expect(section.classList.contains('preprints-advisory-board-section')).toBe(true);
+ expect(section?.classList.contains('osf-preprint-service')).toBe(hasLandingClass);
});
});
diff --git a/src/app/features/preprints/components/advisory-board/advisory-board.component.ts b/src/app/features/preprints/components/advisory-board/advisory-board.component.ts
index 2840658ce..48435e681 100644
--- a/src/app/features/preprints/components/advisory-board/advisory-board.component.ts
+++ b/src/app/features/preprints/components/advisory-board/advisory-board.component.ts
@@ -2,7 +2,6 @@ import { NgClass } from '@angular/common';
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { StringOrNullOrUndefined } from '@osf/shared/helpers/types.helper';
-import { BrandModel } from '@osf/shared/models/brand/brand.model';
import { SafeHtmlPipe } from '@osf/shared/pipes/safe-html.pipe';
@Component({
@@ -14,6 +13,5 @@ import { SafeHtmlPipe } from '@osf/shared/pipes/safe-html.pipe';
})
export class AdvisoryBoardComponent {
htmlContent = input(null);
- brand = input();
isLandingPage = input(false);
}
diff --git a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.html b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.html
index 6ab5364ec..61646f32b 100644
--- a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.html
+++ b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.html
@@ -6,14 +6,14 @@ {{ 'preprints.browseBySubjects.title' | translate }}
}
} @else {
- @for (subject of subjects(); track subject) {
+ @for (subject of subjects(); track subject.id) {
}
}
diff --git a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.spec.ts b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.spec.ts
index 8c71f8c0c..b09b902fa 100644
--- a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.spec.ts
+++ b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.spec.ts
@@ -1,4 +1,7 @@
+import { MockProvider } from 'ng-mocks';
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute } from '@angular/router';
import { ResourceType } from '@shared/enums/resource-type.enum';
import { SubjectModel } from '@shared/models/subject/subject.model';
@@ -6,7 +9,8 @@ import { SubjectModel } from '@shared/models/subject/subject.model';
import { BrowseBySubjectsComponent } from './browse-by-subjects.component';
import { SUBJECTS_MOCK } from '@testing/mocks/subject.mock';
-import { OSFTestingModule } from '@testing/osf.testing.module';
+import { provideOSFCore } from '@testing/osf.testing.provider';
+import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock';
describe('BrowseBySubjectsComponent', () => {
let component: BrowseBySubjectsComponent;
@@ -14,130 +18,77 @@ describe('BrowseBySubjectsComponent', () => {
const mockSubjects: SubjectModel[] = SUBJECTS_MOCK;
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- imports: [BrowseBySubjectsComponent, OSFTestingModule],
- }).compileComponents();
+ function setup(overrides?: {
+ subjects?: SubjectModel[];
+ areSubjectsLoading?: boolean;
+ isProviderLoading?: boolean;
+ isLandingPage?: boolean;
+ }) {
+ TestBed.configureTestingModule({
+ imports: [BrowseBySubjectsComponent],
+ providers: [provideOSFCore(), MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().build())],
+ });
fixture = TestBed.createComponent(BrowseBySubjectsComponent);
component = fixture.componentInstance;
- });
-
- it('should create', () => {
- fixture.componentRef.setInput('subjects', []);
- fixture.componentRef.setInput('areSubjectsLoading', false);
- fixture.componentRef.setInput('isProviderLoading', false);
-
+ fixture.componentRef.setInput('subjects', overrides?.subjects ?? []);
+ fixture.componentRef.setInput('areSubjectsLoading', overrides?.areSubjectsLoading ?? false);
+ fixture.componentRef.setInput('isProviderLoading', overrides?.isProviderLoading ?? false);
+ fixture.componentRef.setInput('isLandingPage', overrides?.isLandingPage ?? false);
fixture.detectChanges();
- expect(component).toBeTruthy();
- });
+ }
- it('should have default input values', () => {
- fixture.componentRef.setInput('subjects', []);
- fixture.componentRef.setInput('areSubjectsLoading', false);
- fixture.componentRef.setInput('isProviderLoading', false);
- fixture.detectChanges();
+ it('should keep default isLandingPage input as false', () => {
+ setup();
- expect(component.subjects()).toEqual([]);
- expect(component.areSubjectsLoading()).toBe(false);
- expect(component.isProviderLoading()).toBe(false);
expect(component.isLandingPage()).toBe(false);
});
- it('should display title', () => {
- fixture.componentRef.setInput('subjects', []);
- fixture.componentRef.setInput('areSubjectsLoading', false);
- fixture.componentRef.setInput('isProviderLoading', false);
- fixture.detectChanges();
-
- const compiled = fixture.nativeElement;
- const title = compiled.querySelector('h2');
+ it('should render skeleton rows while loading', () => {
+ setup({ areSubjectsLoading: true, subjects: mockSubjects });
- expect(title).toBeTruthy();
- expect(title.textContent).toBe('preprints.browseBySubjects.title');
+ expect(fixture.nativeElement.querySelectorAll('p-skeleton').length).toBe(6);
+ expect(fixture.nativeElement.querySelectorAll('p-button').length).toBe(0);
});
- it('should display correct subject names in buttons', () => {
- fixture.componentRef.setInput('subjects', mockSubjects);
- fixture.componentRef.setInput('areSubjectsLoading', false);
- fixture.componentRef.setInput('isProviderLoading', false);
- fixture.detectChanges();
-
- const compiled = fixture.nativeElement;
- const buttons = compiled.querySelectorAll('p-button');
+ it('should render one button per subject when not loading', () => {
+ setup({ subjects: mockSubjects });
- expect(buttons[0].getAttribute('ng-reflect-label')).toBe('Mathematics');
- expect(buttons[1].getAttribute('ng-reflect-label')).toBe('Physics');
+ expect(fixture.nativeElement.querySelectorAll('p-button').length).toBe(mockSubjects.length);
});
- it('should compute linksToSearchPageForSubject correctly', () => {
- fixture.componentRef.setInput('subjects', mockSubjects);
- fixture.componentRef.setInput('areSubjectsLoading', false);
- fixture.componentRef.setInput('isProviderLoading', false);
- fixture.detectChanges();
-
- const links = component.linksToSearchPageForSubject();
+ it('should build query params for subject with iri', () => {
+ setup({ subjects: mockSubjects });
- expect(links).toHaveLength(2);
- expect(links[0]).toEqual({
+ expect(component.getQueryParamsForSubject(mockSubjects[0])).toEqual({
tab: ResourceType.Preprint,
filter_subject: '[{"label":"Mathematics","value":"https://example.com/subjects/mathematics"}]',
});
- expect(links[1]).toEqual({
- tab: ResourceType.Preprint,
- filter_subject: '[{"label":"Physics","value":"https://example.com/subjects/physics"}]',
- });
});
- it('should set correct routerLink for non-landing page', () => {
- fixture.componentRef.setInput('subjects', mockSubjects);
- fixture.componentRef.setInput('areSubjectsLoading', false);
- fixture.componentRef.setInput('isProviderLoading', false);
- fixture.componentRef.setInput('isLandingPage', false);
- fixture.detectChanges();
-
- const compiled = fixture.nativeElement;
- const buttons = compiled.querySelectorAll('p-button');
-
- expect(buttons[0].getAttribute('ng-reflect-router-link')).toBe('discover');
- });
-
- it('should set correct routerLink for landing page', () => {
- fixture.componentRef.setInput('subjects', mockSubjects);
- fixture.componentRef.setInput('areSubjectsLoading', false);
- fixture.componentRef.setInput('isProviderLoading', false);
- fixture.componentRef.setInput('isLandingPage', true);
- fixture.detectChanges();
-
- const compiled = fixture.nativeElement;
- const buttons = compiled.querySelectorAll('p-button');
-
- expect(buttons[0].getAttribute('ng-reflect-router-link')).toBe('/search');
- });
-
- it('should handle subjects without iri', () => {
- const subjectsWithoutIri: SubjectModel[] = [
- {
- id: 'subject-1',
- name: 'Physics',
- iri: undefined,
- children: [],
- parent: null,
- expanded: false,
- },
- ];
-
- fixture.componentRef.setInput('subjects', subjectsWithoutIri);
- fixture.componentRef.setInput('areSubjectsLoading', false);
- fixture.componentRef.setInput('isProviderLoading', false);
- fixture.detectChanges();
-
- const links = component.linksToSearchPageForSubject();
-
- expect(links).toHaveLength(1);
- expect(links[0]).toEqual({
+ it('should build query params for subject without iri', () => {
+ setup();
+ const subjectWithoutIri = {
+ id: 'subject-1',
+ name: 'Physics',
+ iri: undefined,
+ children: [],
+ parent: null,
+ expanded: false,
+ } as SubjectModel;
+
+ expect(component.getQueryParamsForSubject(subjectWithoutIri)).toEqual({
tab: ResourceType.Preprint,
filter_subject: '[{"label":"Physics"}]',
});
});
+
+ it.each([
+ { isLandingPage: false, expected: 'discover' },
+ { isLandingPage: true, expected: '/search' },
+ ])('should resolve route for isLandingPage=$isLandingPage', ({ isLandingPage, expected }) => {
+ setup({ isLandingPage });
+
+ expect(component.subjectRoute()).toBe(expected);
+ });
});
diff --git a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.ts b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.ts
index c701c73c4..6833c042b 100644
--- a/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.ts
+++ b/src/app/features/preprints/components/browse-by-subjects/browse-by-subjects.component.ts
@@ -6,20 +6,28 @@ import { Skeleton } from 'primeng/skeleton';
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { RouterLink } from '@angular/router';
-import { ResourceType } from '@shared/enums/resource-type.enum';
-import { SubjectModel } from '@shared/models/subject/subject.model';
+import { ResourceType } from '@osf/shared/enums/resource-type.enum';
+import { SubjectModel } from '@osf/shared/models/subject/subject.model';
@Component({
selector: 'osf-browse-by-subjects',
- imports: [RouterLink, Skeleton, TranslatePipe, Button],
+ imports: [Button, Skeleton, RouterLink, TranslatePipe],
templateUrl: './browse-by-subjects.component.html',
styleUrl: './browse-by-subjects.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BrowseBySubjectsComponent {
- subjects = input.required();
- linksToSearchPageForSubject = computed(() => {
- return this.subjects().map((subject) => ({
+ readonly subjects = input.required();
+ readonly areSubjectsLoading = input.required();
+ readonly isProviderLoading = input.required();
+ readonly isLandingPage = input(false);
+
+ readonly skeletonArray = new Array(6);
+
+ readonly subjectRoute = computed(() => (this.isLandingPage() ? '/search' : 'discover'));
+
+ getQueryParamsForSubject(subject: SubjectModel) {
+ return {
tab: ResourceType.Preprint,
filter_subject: JSON.stringify([
{
@@ -27,10 +35,6 @@ export class BrowseBySubjectsComponent {
value: subject.iri,
},
]),
- }));
- });
- areSubjectsLoading = input.required();
- isProviderLoading = input.required();
- isLandingPage = input(false);
- skeletonArray = Array.from({ length: 6 }, (_, i) => i + 1);
+ };
+ }
}
diff --git a/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.html b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.html
index 18a160459..8a5250e76 100644
--- a/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.html
+++ b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.html
@@ -1,7 +1,7 @@
-
- @if (preprint()) {
- @let preprintValue = preprint()!;
+@let preprintValue = preprint();
+
+ @if (preprintValue) {
@if (preprintValue.customPublicationCitation) {
@@ -19,12 +19,17 @@ {{ 'preprints.details.originalPublicationDate' | translate }}
}
- @if (preprintValue.doi) {
+ @if (preprintValue.articleDoiLink) {
}
diff --git a/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.scss b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.scss
index 5722bc8e5..e69de29bb 100644
--- a/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.scss
+++ b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.scss
@@ -1,3 +0,0 @@
-.white-space-pre-line {
- white-space: pre-line;
-}
diff --git a/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.spec.ts b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.spec.ts
index c6c1824d8..4f5bff759 100644
--- a/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.spec.ts
+++ b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.spec.ts
@@ -1,11 +1,12 @@
-import { MockComponents, MockPipe } from 'ng-mocks';
+import { Store } from '@ngxs/store';
+
+import { MockComponents } from 'ng-mocks';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { PreprintSelectors } from '@osf/features/preprints/store/preprint';
import { LicenseDisplayComponent } from '@osf/shared/components/license-display/license-display.component';
-import { InterpolatePipe } from '@osf/shared/pipes/interpolate.pipe';
import { SubjectsSelectors } from '@osf/shared/stores/subjects';
import { CitationSectionComponent } from '../citation-section/citation-section.component';
@@ -13,51 +14,46 @@ import { CitationSectionComponent } from '../citation-section/citation-section.c
import { AdditionalInfoComponent } from './additional-info.component';
import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock';
-import { OSFTestingModule } from '@testing/osf.testing.module';
-import { provideMockStore } from '@testing/providers/store-provider.mock';
+import { provideOSFCore } from '@testing/osf.testing.provider';
+import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';
describe('AdditionalInfoComponent', () => {
let component: AdditionalInfoComponent;
let fixture: ComponentFixture
;
+ let store: Store;
- const mockPreprint = PREPRINT_MOCK;
+ interface SetupOverrides extends BaseSetupOverrides {
+ preprintProviderId?: string;
+ }
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- imports: [
- AdditionalInfoComponent,
- OSFTestingModule,
- ...MockComponents(CitationSectionComponent, LicenseDisplayComponent),
- MockPipe(InterpolatePipe),
- ],
+ function setup(overrides: SetupOverrides = {}) {
+ TestBed.configureTestingModule({
+ imports: [AdditionalInfoComponent, ...MockComponents(CitationSectionComponent, LicenseDisplayComponent)],
providers: [
+ provideOSFCore(),
provideMockStore({
- signals: [
- {
- selector: PreprintSelectors.getPreprint,
- value: mockPreprint,
- },
- {
- selector: PreprintSelectors.isPreprintLoading,
- value: false,
- },
- {
- selector: SubjectsSelectors.getSelectedSubjects,
- value: [],
- },
- {
- selector: SubjectsSelectors.areSelectedSubjectsLoading,
- value: false,
- },
- ],
+ signals: mergeSignalOverrides(
+ [
+ { selector: PreprintSelectors.getPreprint, value: PREPRINT_MOCK },
+ { selector: PreprintSelectors.isPreprintLoading, value: false },
+ { selector: SubjectsSelectors.getSelectedSubjects, value: [] },
+ { selector: SubjectsSelectors.areSelectedSubjectsLoading, value: false },
+ ],
+ overrides.selectorOverrides
+ ),
}),
],
- }).compileComponents();
+ });
fixture = TestBed.createComponent(AdditionalInfoComponent);
component = fixture.componentInstance;
- fixture.componentRef.setInput('preprintProviderId', 'osf');
+ store = TestBed.inject(Store);
+ fixture.componentRef.setInput('preprintProviderId', overrides.preprintProviderId ?? 'osf');
fixture.detectChanges();
+ }
+
+ beforeEach(() => {
+ setup();
});
it('should create', () => {
@@ -66,12 +62,12 @@ describe('AdditionalInfoComponent', () => {
it('should return license from preprint when available', () => {
const license = component.license();
- expect(license).toBe(mockPreprint.embeddedLicense);
+ expect(license).toBe(PREPRINT_MOCK.embeddedLicense);
});
it('should return license options record from preprint when available', () => {
const licenseOptionsRecord = component.licenseOptionsRecord();
- expect(licenseOptionsRecord).toEqual(mockPreprint.licenseOptions);
+ expect(licenseOptionsRecord).toEqual(PREPRINT_MOCK.licenseOptions);
});
it('should have skeleton data array with 5 null elements', () => {
@@ -89,4 +85,36 @@ describe('AdditionalInfoComponent', () => {
queryParams: { search: 'test-tag' },
});
});
+
+ it('should not render DOI link when articleDoiLink is missing', () => {
+ const doiLink = fixture.nativeElement.querySelector('a[href*="doi.org"]');
+ expect(doiLink).toBeNull();
+ });
+
+ it('should render DOI link when articleDoiLink is available', () => {
+ setup({
+ selectorOverrides: [
+ {
+ selector: PreprintSelectors.getPreprint,
+ value: {
+ ...PREPRINT_MOCK,
+ articleDoiLink: 'https://doi.org/10.1234/sample.article-doi',
+ },
+ },
+ ],
+ });
+
+ const doiLink = fixture.nativeElement.querySelector('a[href*="doi.org"]') as HTMLAnchorElement | null;
+ expect(doiLink).not.toBeNull();
+ expect(doiLink?.getAttribute('href')).toBe('https://doi.org/10.1234/sample.article-doi');
+ expect(doiLink?.textContent?.trim()).toBe('https://doi.org/10.1234/sample.article-doi');
+ });
+
+ it('should not dispatch subject fetch when preprint id is missing', () => {
+ setup({
+ selectorOverrides: [{ selector: PreprintSelectors.getPreprint, value: null }],
+ });
+
+ expect(store.dispatch).not.toHaveBeenCalled();
+ });
});
diff --git a/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.ts b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.ts
index fa1253572..689fee6c5 100644
--- a/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.ts
+++ b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.ts
@@ -19,41 +19,33 @@ import { CitationSectionComponent } from '../citation-section/citation-section.c
@Component({
selector: 'osf-preprint-additional-info',
- imports: [Card, TranslatePipe, Tag, Skeleton, DatePipe, CitationSectionComponent, LicenseDisplayComponent],
+ imports: [Card, Tag, Skeleton, CitationSectionComponent, LicenseDisplayComponent, DatePipe, TranslatePipe],
templateUrl: './additional-info.component.html',
styleUrl: './additional-info.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdditionalInfoComponent {
- private actions = createDispatchMap({
- fetchSubjects: FetchSelectedSubjects,
- });
- private router = inject(Router);
+ private readonly router = inject(Router);
+ private readonly actions = createDispatchMap({ fetchSubjects: FetchSelectedSubjects });
- preprintProviderId = input.required();
+ readonly preprintProviderId = input.required();
- preprint = select(PreprintSelectors.getPreprint);
- isPreprintLoading = select(PreprintSelectors.isPreprintLoading);
+ readonly preprint = select(PreprintSelectors.getPreprint);
+ readonly isPreprintLoading = select(PreprintSelectors.isPreprintLoading);
- subjects = select(SubjectsSelectors.getSelectedSubjects);
- areSelectedSubjectsLoading = select(SubjectsSelectors.areSelectedSubjectsLoading);
+ readonly subjects = select(SubjectsSelectors.getSelectedSubjects);
+ readonly areSelectedSubjectsLoading = select(SubjectsSelectors.areSelectedSubjectsLoading);
- license = computed(() => {
- const preprint = this.preprint();
- if (!preprint) return null;
- return preprint.embeddedLicense;
- });
+ readonly license = computed(() => this.preprint()?.embeddedLicense ?? null);
+ readonly licenseOptionsRecord = computed(() => (this.preprint()?.licenseOptions ?? {}) as Record);
- licenseOptionsRecord = computed(() => (this.preprint()?.licenseOptions ?? {}) as Record);
-
- skeletonData = Array.from({ length: 5 }, () => null);
+ readonly skeletonData = new Array(5).fill(null);
constructor() {
effect(() => {
- const preprint = this.preprint();
- if (!preprint) return;
-
- this.actions.fetchSubjects(this.preprint()!.id, ResourceType.Preprint);
+ const preprintId = this.preprint()?.id;
+ if (!preprintId) return;
+ this.actions.fetchSubjects(preprintId, ResourceType.Preprint);
});
}
diff --git a/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.html b/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.html
index dfce6c59d..96c36d724 100644
--- a/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.html
+++ b/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.html
@@ -4,6 +4,7 @@
{{ 'project.overview.metadata.citation' | translate }}
+
@if (areCitationsLoading()) {
@@ -18,25 +19,28 @@ {{ citation.title }}
+
{{ 'project.overview.metadata.getMoreCitations' | translate }}
+
{{ selectedOption.label }}
+
@if (styledCitation()) {
{{ styledCitation()?.citation }}
}
diff --git a/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.spec.ts b/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.spec.ts
index 340def6a0..614674f66 100644
--- a/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.spec.ts
+++ b/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.spec.ts
@@ -1,104 +1,160 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Store } from '@ngxs/store';
-import { CitationStyle } from '@osf/shared/models/citations/citation-style.model';
-import { CitationsSelectors } from '@osf/shared/stores/citations';
+import { SelectChangeEvent, SelectFilterEvent } from 'primeng/select';
+
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+
+import { ResourceType } from '@shared/enums/resource-type.enum';
+import {
+ CitationsSelectors,
+ FetchDefaultProviderCitationStyles,
+ GetCitationStyles,
+ GetStyledCitation,
+} from '@shared/stores/citations';
import { CitationSectionComponent } from './citation-section.component';
import { CITATION_STYLES_MOCK } from '@testing/mocks/citation-style.mock';
-import { TranslationServiceMock } from '@testing/mocks/translation.service.mock';
-import { OSFTestingModule } from '@testing/osf.testing.module';
-import { provideMockStore } from '@testing/providers/store-provider.mock';
+import { provideOSFCore } from '@testing/osf.testing.provider';
+import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';
describe('CitationSectionComponent', () => {
let component: CitationSectionComponent;
let fixture: ComponentFixture;
+ let store: Store;
+
+ const mockCitationStyles = CITATION_STYLES_MOCK;
+ const mockDefaultCitations = [{ id: 'apa', title: 'APA', citation: 'APA Citation Text' }];
+ const mockStyledCitation = { citation: 'Styled Citation Text' };
- const mockCitationStyles: CitationStyle[] = CITATION_STYLES_MOCK;
- const mockDefaultCitations = {
- apa: 'APA Citation Text',
- mla: 'MLA Citation Text',
- };
- const mockStyledCitation = 'Styled Citation Text';
+ interface SetupOverrides extends BaseSetupOverrides {
+ preprintId?: string;
+ providerId?: string;
+ detectChanges?: boolean;
+ }
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- imports: [CitationSectionComponent, OSFTestingModule],
+ function setup(overrides: SetupOverrides = {}) {
+ TestBed.configureTestingModule({
+ imports: [CitationSectionComponent],
providers: [
- TranslationServiceMock,
+ provideOSFCore(),
provideMockStore({
- signals: [
- {
- selector: CitationsSelectors.getDefaultCitations,
- value: mockDefaultCitations,
- },
- {
- selector: CitationsSelectors.getDefaultCitationsLoading,
- value: false,
- },
- {
- selector: CitationsSelectors.getCitationStyles,
- value: mockCitationStyles,
- },
- {
- selector: CitationsSelectors.getCitationStylesLoading,
- value: false,
- },
- {
- selector: CitationsSelectors.getStyledCitation,
- value: mockStyledCitation,
- },
- ],
+ signals: mergeSignalOverrides(
+ [
+ { selector: CitationsSelectors.getDefaultCitations, value: mockDefaultCitations },
+ { selector: CitationsSelectors.getDefaultCitationsLoading, value: false },
+ { selector: CitationsSelectors.getCitationStyles, value: mockCitationStyles },
+ { selector: CitationsSelectors.getCitationStylesLoading, value: false },
+ { selector: CitationsSelectors.getStyledCitation, value: mockStyledCitation },
+ ],
+ overrides.selectorOverrides
+ ),
}),
],
- }).compileComponents();
+ });
fixture = TestBed.createComponent(CitationSectionComponent);
component = fixture.componentInstance;
+ store = TestBed.inject(Store);
- fixture.componentRef.setInput('preprintId', 'test-preprint-id');
- });
+ fixture.componentRef.setInput('preprintId', overrides.preprintId ?? 'test-preprint-id');
+ fixture.componentRef.setInput('providerId', overrides.providerId ?? 'osf');
+
+ if (overrides.detectChanges ?? true) {
+ fixture.detectChanges();
+ (store.dispatch as jest.Mock).mockClear();
+ }
+ }
it('should create', () => {
+ setup();
expect(component).toBeTruthy();
});
- it('should return default citations from store', () => {
- const defaultCitations = component.defaultCitations();
- expect(defaultCitations).toBe(mockDefaultCitations);
+ it('should return signals directly from the store', () => {
+ setup();
+ expect(component.defaultCitations()).toBe(mockDefaultCitations);
+ expect(component.citationStyles()).toBe(mockCitationStyles);
+ expect(component.styledCitation()).toEqual(mockStyledCitation);
});
- it('should return citation styles from store', () => {
- const citationStyles = component.citationStyles();
- expect(citationStyles).toBe(mockCitationStyles);
+ it('should map citation styles into select options', () => {
+ setup();
+ const citationStylesOptions = component.citationStylesOptions();
+ expect(citationStylesOptions).toEqual(
+ mockCitationStyles.map((style) => ({
+ label: style.title,
+ value: style,
+ }))
+ );
});
- it('should return styled citation from store', () => {
- const styledCitation = component.styledCitation();
- expect(styledCitation).toBe(mockStyledCitation);
+ it('should return loading filter message when citation styles are loading', () => {
+ setup({
+ selectorOverrides: [{ selector: CitationsSelectors.getCitationStylesLoading, value: true }],
+ });
+ expect(component.filterMessage()).toBe('project.overview.metadata.citationLoadingPlaceholder');
});
- it('should have citation styles options signal', () => {
- const citationStylesOptions = component.citationStylesOptions();
- expect(citationStylesOptions).toBeDefined();
- expect(Array.isArray(citationStylesOptions)).toBe(true);
+ it('should return empty-state filter message when citation styles are not loading', () => {
+ setup();
+ expect(component.filterMessage()).toBe('project.overview.metadata.noCitationStylesFound');
});
- it('should handle citation style filter search', () => {
- const mockEvent = {
- originalEvent: new Event('input'),
- filter: 'test filter',
- };
-
- expect(() => component.handleCitationStyleFilterSearch(mockEvent)).not.toThrow();
+ it('should dispatch FetchDefaultProviderCitationStyles on init with correct inputs', () => {
+ setup({ detectChanges: false });
+ fixture.detectChanges();
+ expect(store.dispatch).toHaveBeenCalledWith(
+ new FetchDefaultProviderCitationStyles(ResourceType.Preprint, 'test-preprint-id', 'osf')
+ );
});
- it('should handle get styled citation', () => {
- const mockEvent = {
+ it('should dispatch GetStyledCitation when a style is selected', () => {
+ setup();
+ const mockEvent: SelectChangeEvent = {
value: { id: 'style-1' },
originalEvent: new Event('change'),
};
- expect(() => component.handleGetStyledCitation(mockEvent)).not.toThrow();
+ component.handleGetStyledCitation(mockEvent);
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ new GetStyledCitation(ResourceType.Preprint, 'test-preprint-id', 'style-1')
+ );
});
+
+ it('should debounce and deduplicate citation style filter dispatches', fakeAsync(() => {
+ setup();
+ const preventDefault = jest.fn();
+ const eventApa: SelectFilterEvent = {
+ originalEvent: { preventDefault } as unknown as Event,
+ filter: 'apa',
+ };
+
+ component.handleCitationStyleFilterSearch(eventApa);
+
+ expect(preventDefault).toHaveBeenCalled();
+ expect(store.dispatch).not.toHaveBeenCalled();
+
+ tick(299);
+ expect(store.dispatch).not.toHaveBeenCalled();
+
+ tick(1);
+ expect(store.dispatch).toHaveBeenCalledWith(new GetCitationStyles('apa'));
+ expect(store.dispatch).toHaveBeenCalledTimes(1);
+
+ (store.dispatch as jest.Mock).mockClear();
+ component.handleCitationStyleFilterSearch(eventApa);
+ tick(300);
+ expect(store.dispatch).not.toHaveBeenCalled();
+
+ const eventMla: SelectFilterEvent = {
+ originalEvent: { preventDefault: jest.fn() } as unknown as Event,
+ filter: 'mla',
+ };
+ component.handleCitationStyleFilterSearch(eventMla);
+ tick(300);
+
+ expect(store.dispatch).toHaveBeenCalledWith(new GetCitationStyles('mla'));
+ }));
});
diff --git a/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.ts b/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.ts
index 4435424fe..899d739d7 100644
--- a/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.ts
+++ b/src/app/features/preprints/components/preprint-details/citation-section/citation-section.component.ts
@@ -30,34 +30,34 @@ import {
FetchDefaultProviderCitationStyles,
GetCitationStyles,
GetStyledCitation,
- UpdateCustomCitation,
} from '@shared/stores/citations';
@Component({
selector: 'osf-preprint-citation-section',
- imports: [Accordion, AccordionPanel, AccordionHeader, TranslatePipe, AccordionContent, Skeleton, Divider, Select],
+ imports: [Accordion, AccordionPanel, AccordionHeader, AccordionContent, Divider, Skeleton, Select, TranslatePipe],
templateUrl: './citation-section.component.html',
styleUrl: './citation-section.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CitationSectionComponent implements OnInit {
- preprintId = input.required();
- providerId = input.required();
+ readonly preprintId = input.required();
+ readonly providerId = input.required();
private readonly destroyRef = inject(DestroyRef);
private readonly filterSubject = new Subject();
- private actions = createDispatchMap({
+
+ private readonly actions = createDispatchMap({
getDefaultCitations: FetchDefaultProviderCitationStyles,
getCitationStyles: GetCitationStyles,
getStyledCitation: GetStyledCitation,
- updateCustomCitation: UpdateCustomCitation,
});
- defaultCitations = select(CitationsSelectors.getDefaultCitations);
- areCitationsLoading = select(CitationsSelectors.getDefaultCitationsLoading);
- citationStyles = select(CitationsSelectors.getCitationStyles);
- areCitationStylesLoading = select(CitationsSelectors.getCitationStylesLoading);
- styledCitation = select(CitationsSelectors.getStyledCitation);
+ readonly defaultCitations = select(CitationsSelectors.getDefaultCitations);
+ readonly areCitationsLoading = select(CitationsSelectors.getDefaultCitationsLoading);
+ readonly citationStyles = select(CitationsSelectors.getCitationStyles);
+ readonly areCitationStylesLoading = select(CitationsSelectors.getCitationStylesLoading);
+ readonly styledCitation = select(CitationsSelectors.getStyledCitation);
+
citationStylesOptions = signal[]>([]);
filterMessage = computed(() =>
@@ -87,9 +87,7 @@ export class CitationSectionComponent implements OnInit {
private setupFilterDebounce(): void {
this.filterSubject
.pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
- .subscribe((filterValue) => {
- this.actions.getCitationStyles(filterValue);
- });
+ .subscribe((filterValue) => this.actions.getCitationStyles(filterValue));
}
private setupCitationStylesEffect(): void {
@@ -100,6 +98,7 @@ export class CitationSectionComponent implements OnInit {
label: style.title,
value: style,
}));
+
this.citationStylesOptions.set(options);
});
}
diff --git a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.html b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.html
index 052197694..e5eedf033 100644
--- a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.html
+++ b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.html
@@ -1,7 +1,8 @@
-
- @if (preprint()) {
- @let preprintValue = preprint()!;
+@let preprintValue = preprint();
+@let preprintProviderValue = preprintProvider();
+
+ @if (preprintValue) {
{{ 'preprints.preprintStepper.review.sections.metadata.authors' | translate }}
@@ -29,65 +30,8 @@ {{ 'common.labels.affiliatedInstitutions' | translate }}
}
- @if (preprintProvider()?.assertionsEnabled) {
-
- {{ 'preprints.preprintStepper.review.sections.authorAssertions.publicData' | translate }}
-
- @switch (preprintValue.hasDataLinks) {
- @case (ApplicabilityStatus.NotApplicable) {
- {{ 'preprints.preprintStepper.review.sections.authorAssertions.noData' | translate }}
- }
- @case (ApplicabilityStatus.Unavailable) {
- {{ preprintValue.whyNoData | fixSpecialChar }}
- }
- @case (ApplicabilityStatus.Applicable) {
- @for (link of preprintValue.dataLinks; track $index) {
- {{ link }}
- }
- }
- }
-
-
-
-
- {{ 'preprints.preprintStepper.review.sections.authorAssertions.publicPreregistration' | translate }}
-
-
- @switch (preprintValue.hasPreregLinks) {
- @case (ApplicabilityStatus.NotApplicable) {
-
- {{ 'preprints.preprintStepper.review.sections.authorAssertions.noPrereg' | translate }}
-
- }
- @case (ApplicabilityStatus.Unavailable) {
- {{ preprintValue.whyNoPrereg | fixSpecialChar }}
- }
- @case (ApplicabilityStatus.Applicable) {
- @switch (preprintValue.preregLinkInfo) {
- @case (PreregLinkInfo.Analysis) {
-
- {{ 'preprints.preprintStepper.common.labels.preregTypes.analysis' | translate }}
-
- }
- @case (PreregLinkInfo.Designs) {
-
- {{ 'preprints.preprintStepper.common.labels.preregTypes.designs' | translate }}
-
- }
- @case (PreregLinkInfo.Both) {
-
- {{ 'preprints.preprintStepper.common.labels.preregTypes.both' | translate }}
-
- }
- }
- @for (link of preprintValue.preregLinks; track $index) {
-
- {{ link }}
-
- }
- }
- }
-
+ @if (preprintProviderValue?.assertionsEnabled) {
+
}
@if (preprintValue.nodeId) {
@@ -100,12 +44,12 @@
{{ 'preprints.details.supplementalMaterials' | translate }}
}
- @if (preprintProvider()?.assertionsEnabled) {
+ @if (preprintProviderValue?.assertionsEnabled) {
{{ 'preprints.preprintStepper.review.sections.authorAssertions.conflictOfInterest' | translate }}
@if (preprintValue.hasCoi) {
- {{ preprintValue.coiStatement | fixSpecialChar }}
+ {{ preprintValue.coiStatement }}
} @else {
{{ 'preprints.preprintStepper.review.sections.authorAssertions.noCoi' | translate }}
}
@@ -113,7 +57,7 @@ {{ 'preprints.preprintStepper.review.sections.authorAssertions.conflictOfInt
}
diff --git a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.spec.ts b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.spec.ts
index 327d980dc..6598d2107 100644
--- a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.spec.ts
+++ b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.spec.ts
@@ -1,5 +1,8 @@
+import { Store } from '@ngxs/store';
+
import { MockComponents, MockProvider } from 'ng-mocks';
+import { PLATFORM_ID } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ENVIRONMENT } from '@core/provider/environment.provider';
@@ -7,10 +10,16 @@ import { PreprintSelectors } from '@osf/features/preprints/store/preprint';
import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component';
import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component';
import { IconComponent } from '@osf/shared/components/icon/icon.component';
-import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component';
-import { ContributorsSelectors } from '@shared/stores/contributors';
-import { InstitutionsSelectors } from '@shared/stores/institutions';
-
+import { ResourceType } from '@shared/enums/resource-type.enum';
+import {
+ ContributorsSelectors,
+ GetBibliographicContributors,
+ LoadMoreBibliographicContributors,
+ ResetContributorsState,
+} from '@shared/stores/contributors';
+import { FetchResourceInstitutions, InstitutionsSelectors } from '@shared/stores/institutions';
+
+import { PreprintAuthorAssertionsComponent } from '../preprint-author-assertions/preprint-author-assertions.component';
import { PreprintDoiSectionComponent } from '../preprint-doi-section/preprint-doi-section.component';
import { GeneralInformationComponent } from './general-information.component';
@@ -19,97 +28,133 @@ import { MOCK_CONTRIBUTOR } from '@testing/mocks/contributors.mock';
import { MOCK_INSTITUTION } from '@testing/mocks/institution.mock';
import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock';
import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details';
-import { OSFTestingModule } from '@testing/osf.testing.module';
-import { provideMockStore } from '@testing/providers/store-provider.mock';
+import { provideOSFCore } from '@testing/osf.testing.provider';
+import { MockComponentWithSignal } from '@testing/providers/component-provider.mock';
+import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';
describe('GeneralInformationComponent', () => {
let component: GeneralInformationComponent;
let fixture: ComponentFixture;
+ let store: Store;
- const mockPreprint = PREPRINT_MOCK;
const mockContributors = [MOCK_CONTRIBUTOR];
const mockInstitutions = [MOCK_INSTITUTION];
- const mockPreprintProvider = PREPRINT_PROVIDER_DETAILS_MOCK;
const mockWebUrl = 'https://staging4.osf.io';
- beforeEach(async () => {
- await TestBed.configureTestingModule({
+ interface SetupOverrides extends BaseSetupOverrides {
+ platformId?: string;
+ }
+
+ function setup(overrides: SetupOverrides = {}) {
+ TestBed.configureTestingModule({
imports: [
GeneralInformationComponent,
- OSFTestingModule,
...MockComponents(
- TruncatedTextComponent,
- PreprintDoiSectionComponent,
- IconComponent,
AffiliatedInstitutionsViewComponent,
- ContributorsListComponent
+ ContributorsListComponent,
+ IconComponent,
+ PreprintDoiSectionComponent,
+ PreprintAuthorAssertionsComponent
),
+ MockComponentWithSignal('osf-truncated-text'),
],
providers: [
+ provideOSFCore(),
MockProvider(ENVIRONMENT, { webUrl: mockWebUrl }),
+ MockProvider(PLATFORM_ID, overrides.platformId ?? 'browser'),
provideMockStore({
- signals: [
- {
- selector: PreprintSelectors.getPreprint,
- value: mockPreprint,
- },
- {
- selector: PreprintSelectors.isPreprintLoading,
- value: false,
- },
- {
- selector: ContributorsSelectors.getBibliographicContributors,
- value: mockContributors,
- },
- {
- selector: ContributorsSelectors.isBibliographicContributorsLoading,
- value: false,
- },
- {
- selector: ContributorsSelectors.hasMoreBibliographicContributors,
- value: false,
- },
- {
- selector: InstitutionsSelectors.getResourceInstitutions,
- value: mockInstitutions,
- },
- ],
+ signals: mergeSignalOverrides(
+ [
+ { selector: PreprintSelectors.getPreprint, value: PREPRINT_MOCK },
+ { selector: PreprintSelectors.isPreprintLoading, value: false },
+ { selector: ContributorsSelectors.getBibliographicContributors, value: mockContributors },
+ { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false },
+ { selector: ContributorsSelectors.hasMoreBibliographicContributors, value: false },
+ { selector: InstitutionsSelectors.getResourceInstitutions, value: mockInstitutions },
+ ],
+ overrides.selectorOverrides
+ ),
}),
],
- }).compileComponents();
+ });
fixture = TestBed.createComponent(GeneralInformationComponent);
component = fixture.componentInstance;
+ store = TestBed.inject(Store);
+ fixture.componentRef.setInput('preprintProvider', PREPRINT_PROVIDER_DETAILS_MOCK);
+ }
- fixture.componentRef.setInput('preprintProvider', mockPreprintProvider);
+ it('should create', () => {
+ setup();
+ expect(component).toBeTruthy();
});
- it('should return preprint from store', () => {
- const preprint = component.preprint();
- expect(preprint).toBe(mockPreprint);
+ it('should expose preprint, contributors, institutions and computed link', () => {
+ setup();
+ expect(component.preprint()).toBe(PREPRINT_MOCK);
+ expect(component.bibliographicContributors()).toBe(mockContributors);
+ expect(component.affiliatedInstitutions()).toBe(mockInstitutions);
+ expect(component.nodeLink()).toBe(`${mockWebUrl}/node-123`);
+ expect(component.preprintProvider()).toBe(PREPRINT_PROVIDER_DETAILS_MOCK);
});
- it('should return contributors from store', () => {
- const contributors = component.bibliographicContributors();
- expect(contributors).toBe(mockContributors);
+ it('should have skeleton data array with 5 null elements', () => {
+ setup();
+ expect(component.skeletonData).toHaveLength(5);
+ expect(component.skeletonData.every((item) => item === null)).toBe(true);
});
- it('should return affiliated institutions from store', () => {
- const institutions = component.affiliatedInstitutions();
- expect(institutions).toBe(mockInstitutions);
+ it('should dispatch constructor effect actions when preprint id exists', () => {
+ setup();
+ fixture.detectChanges();
+ TestBed.flushEffects();
+ expect(store.dispatch).toHaveBeenCalledWith(
+ new GetBibliographicContributors(PREPRINT_MOCK.id, ResourceType.Preprint)
+ );
+ expect(store.dispatch).toHaveBeenCalledWith(new FetchResourceInstitutions(PREPRINT_MOCK.id, ResourceType.Preprint));
});
- it('should compute node link from preprint', () => {
- const nodeLink = component.nodeLink();
- expect(nodeLink).toBe(`${mockWebUrl}/node-123`);
+ it('should not dispatch constructor effect actions when preprint is missing', () => {
+ setup({
+ selectorOverrides: [{ selector: PreprintSelectors.getPreprint, value: undefined }],
+ });
+ (store.dispatch as jest.Mock).mockClear();
+ fixture.detectChanges();
+ TestBed.flushEffects();
+ expect(store.dispatch).not.toHaveBeenCalled();
});
- it('should have skeleton data array with 5 null elements', () => {
- expect(component.skeletonData).toHaveLength(5);
- expect(component.skeletonData.every((item) => item === null)).toBe(true);
+ it('should dispatch load more contributors with preprint id', () => {
+ setup();
+ (store.dispatch as jest.Mock).mockClear();
+ component.handleLoadMoreContributors();
+ expect(store.dispatch).toHaveBeenCalledWith(
+ new LoadMoreBibliographicContributors(PREPRINT_MOCK.id, ResourceType.Preprint)
+ );
+ });
+
+ it('should dispatch load more contributors with undefined id when preprint is missing', () => {
+ setup({
+ selectorOverrides: [{ selector: PreprintSelectors.getPreprint, value: undefined }],
+ });
+ (store.dispatch as jest.Mock).mockClear();
+ component.handleLoadMoreContributors();
+ expect(store.dispatch).toHaveBeenCalledWith(
+ new LoadMoreBibliographicContributors(undefined, ResourceType.Preprint)
+ );
+ });
+
+ it('should reset contributors state on destroy in browser', () => {
+ setup({ platformId: 'browser' });
+ (store.dispatch as jest.Mock).mockClear();
+ component.ngOnDestroy();
+ expect(store.dispatch).toHaveBeenCalledWith(new ResetContributorsState());
});
- it('should have preprint provider input', () => {
- expect(component.preprintProvider()).toBe(mockPreprintProvider);
+ it('should not reset contributors state on destroy in server platform', () => {
+ setup({ platformId: 'server' });
+ (store.dispatch as jest.Mock).mockClear();
+ component.ngOnDestroy();
+ expect(store.dispatch).not.toHaveBeenCalledWith(new ResetContributorsState());
});
});
diff --git a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.ts b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.ts
index 8228a9cd1..85a1fe398 100644
--- a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.ts
+++ b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.ts
@@ -17,18 +17,15 @@ import {
output,
PLATFORM_ID,
} from '@angular/core';
-import { FormsModule } from '@angular/forms';
import { ENVIRONMENT } from '@core/provider/environment.provider';
-import { ApplicabilityStatus, PreregLinkInfo } from '@osf/features/preprints/enums';
import { PreprintProviderDetails } from '@osf/features/preprints/models';
-import { FetchPreprintDetails, PreprintSelectors } from '@osf/features/preprints/store/preprint';
+import { PreprintSelectors } from '@osf/features/preprints/store/preprint';
import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component';
import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component';
import { IconComponent } from '@osf/shared/components/icon/icon.component';
import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component';
import { ResourceType } from '@osf/shared/enums/resource-type.enum';
-import { FixSpecialCharPipe } from '@osf/shared/pipes/fix-special-char.pipe';
import {
ContributorsSelectors,
GetBibliographicContributors,
@@ -37,21 +34,21 @@ import {
} from '@osf/shared/stores/contributors';
import { FetchResourceInstitutions, InstitutionsSelectors } from '@osf/shared/stores/institutions';
+import { PreprintAuthorAssertionsComponent } from '../preprint-author-assertions/preprint-author-assertions.component';
import { PreprintDoiSectionComponent } from '../preprint-doi-section/preprint-doi-section.component';
@Component({
selector: 'osf-preprint-general-information',
imports: [
Card,
- TranslatePipe,
Skeleton,
- FormsModule,
- TruncatedTextComponent,
- PreprintDoiSectionComponent,
- IconComponent,
AffiliatedInstitutionsViewComponent,
ContributorsListComponent,
- FixSpecialCharPipe,
+ IconComponent,
+ PreprintDoiSectionComponent,
+ PreprintAuthorAssertionsComponent,
+ TruncatedTextComponent,
+ TranslatePipe,
],
templateUrl: './general-information.component.html',
styleUrl: './general-information.component.scss',
@@ -62,40 +59,36 @@ export class GeneralInformationComponent implements OnDestroy {
private readonly platformId = inject(PLATFORM_ID);
private readonly isBrowser = isPlatformBrowser(this.platformId);
- readonly ApplicabilityStatus = ApplicabilityStatus;
- readonly PreregLinkInfo = PreregLinkInfo;
+ readonly preprintProvider = input.required();
+ readonly preprintVersionSelected = output();
- private actions = createDispatchMap({
+ private readonly actions = createDispatchMap({
getBibliographicContributors: GetBibliographicContributors,
- resetContributorsState: ResetContributorsState,
- fetchPreprintById: FetchPreprintDetails,
fetchResourceInstitutions: FetchResourceInstitutions,
loadMoreBibliographicContributors: LoadMoreBibliographicContributors,
+ resetContributorsState: ResetContributorsState,
});
- preprintProvider = input.required();
- preprintVersionSelected = output();
-
- preprint = select(PreprintSelectors.getPreprint);
- isPreprintLoading = select(PreprintSelectors.isPreprintLoading);
+ readonly preprint = select(PreprintSelectors.getPreprint);
+ readonly isPreprintLoading = select(PreprintSelectors.isPreprintLoading);
- affiliatedInstitutions = select(InstitutionsSelectors.getResourceInstitutions);
+ readonly affiliatedInstitutions = select(InstitutionsSelectors.getResourceInstitutions);
- bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors);
- areContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading);
- hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors);
+ readonly bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors);
+ readonly areContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading);
+ readonly hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors);
- skeletonData = Array.from({ length: 5 }, () => null);
+ readonly skeletonData = new Array(5).fill(null);
- nodeLink = computed(() => `${this.environment.webUrl}/${this.preprint()?.nodeId}`);
+ readonly nodeLink = computed(() => `${this.environment.webUrl}/${this.preprint()?.nodeId}`);
constructor() {
effect(() => {
- const preprint = this.preprint();
- if (!preprint) return;
+ const preprintId = this.preprint()?.id;
+ if (!preprintId) return;
- this.actions.getBibliographicContributors(this.preprint()!.id, ResourceType.Preprint);
- this.actions.fetchResourceInstitutions(this.preprint()!.id, ResourceType.Preprint);
+ this.actions.getBibliographicContributors(preprintId, ResourceType.Preprint);
+ this.actions.fetchResourceInstitutions(preprintId, ResourceType.Preprint);
});
}
diff --git a/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.html b/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.html
index 4d110f54a..737811593 100644
--- a/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.html
+++ b/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.html
@@ -13,14 +13,22 @@
} @else {
- {{ actionCreatorName() }}
+ @if (actionCreatorLink()) {
+ {{ actionCreatorName() }}
+ } @else {
+ {{ actionCreatorName() }}
+ }
{{ recentActivityLanguage() | translate: { documentType: documentType()?.singular } }}
{{ labelDate() | date: 'MMM d, y' }}
}
@if (isPendingWithdrawal()) {
- {{ withdrawalRequesterName() }}
+ @if (withdrawalRequesterLink()) {
+ {{ withdrawalRequesterName() }}
+ } @else {
+ {{ withdrawalRequesterName() }}
+ }
{{ requestActivityLanguage()! | translate: { documentType: documentType()?.singular } }}
{{ latestWithdrawalRequest()?.dateLastTransitioned | date: 'MMM d, y' }}
}
diff --git a/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.spec.ts b/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.spec.ts
index b8413c5aa..063906282 100644
--- a/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.spec.ts
+++ b/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.spec.ts
@@ -5,192 +5,168 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReviewAction } from '@osf/features/moderation/models';
import { ProviderReviewsWorkflow, ReviewsState } from '@osf/features/preprints/enums';
-import { PreprintRequest } from '@osf/features/preprints/models';
+import { PreprintProviderDetails, PreprintRequest } from '@osf/features/preprints/models';
import { PreprintSelectors } from '@osf/features/preprints/store/preprint';
import { IconComponent } from '@osf/shared/components/icon/icon.component';
import { ModerationStatusBannerComponent } from './moderation-status-banner.component';
-import { EnvironmentTokenMock } from '@testing/mocks/environment.token.mock';
import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock';
+import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details';
import { PREPRINT_REQUEST_MOCK } from '@testing/mocks/preprint-request.mock';
-import { MOCK_PROVIDER } from '@testing/mocks/provider.mock';
import { REVIEW_ACTION_MOCK } from '@testing/mocks/review-action.mock';
-import { TranslationServiceMock } from '@testing/mocks/translation.service.mock';
-import { OSFTestingModule } from '@testing/osf.testing.module';
-import { provideMockStore } from '@testing/providers/store-provider.mock';
+import { provideOSFCore } from '@testing/osf.testing.provider';
+import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';
describe('ModerationStatusBannerComponent', () => {
let component: ModerationStatusBannerComponent;
let fixture: ComponentFixture;
const mockPreprint = PREPRINT_MOCK;
- const mockProvider = MOCK_PROVIDER;
- const mockReviewAction: ReviewAction = REVIEW_ACTION_MOCK;
- const mockWithdrawalRequest: PreprintRequest = PREPRINT_REQUEST_MOCK;
-
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- imports: [
- ModerationStatusBannerComponent,
- OSFTestingModule,
- MockComponent(IconComponent),
- MockPipes(TitleCasePipe, DatePipe),
- ],
+ const mockProvider = PREPRINT_PROVIDER_DETAILS_MOCK;
+ const mockReviewAction = REVIEW_ACTION_MOCK;
+ const mockWithdrawalRequest = PREPRINT_REQUEST_MOCK;
+
+ interface SetupOverrides extends BaseSetupOverrides {
+ provider?: PreprintProviderDetails | undefined;
+ latestAction?: ReviewAction | null;
+ latestWithdrawalRequest?: PreprintRequest | null;
+ isPendingWithdrawal?: boolean;
+ }
+
+ function setup(overrides: SetupOverrides = {}) {
+ TestBed.configureTestingModule({
+ imports: [ModerationStatusBannerComponent, MockComponent(IconComponent), ...MockPipes(TitleCasePipe, DatePipe)],
providers: [
- TranslationServiceMock,
- EnvironmentTokenMock,
+ provideOSFCore(),
provideMockStore({
- signals: [
- {
- selector: PreprintSelectors.getPreprint,
- value: mockPreprint,
- },
- ],
+ signals: mergeSignalOverrides(
+ [{ selector: PreprintSelectors.getPreprint, value: mockPreprint }],
+ overrides.selectorOverrides
+ ),
}),
],
- }).compileComponents();
+ });
fixture = TestBed.createComponent(ModerationStatusBannerComponent);
component = fixture.componentInstance;
-
- fixture.componentRef.setInput('provider', mockProvider);
- fixture.componentRef.setInput('latestAction', mockReviewAction);
- fixture.componentRef.setInput('latestWithdrawalRequest', mockWithdrawalRequest);
- fixture.componentRef.setInput('isPendingWithdrawal', false);
- });
+ fixture.componentRef.setInput('provider', 'provider' in overrides ? overrides.provider : mockProvider);
+ fixture.componentRef.setInput(
+ 'latestAction',
+ 'latestAction' in overrides ? overrides.latestAction : mockReviewAction
+ );
+ fixture.componentRef.setInput(
+ 'latestWithdrawalRequest',
+ 'latestWithdrawalRequest' in overrides ? overrides.latestWithdrawalRequest : mockWithdrawalRequest
+ );
+ fixture.componentRef.setInput('isPendingWithdrawal', overrides.isPendingWithdrawal ?? false);
+ }
it('should create', () => {
+ setup();
expect(component).toBeTruthy();
});
- it('should return preprint from store', () => {
- const preprint = component.preprint();
- expect(preprint).toBe(mockPreprint);
- });
-
- it('should compute noActions when latestAction is null', () => {
- fixture.componentRef.setInput('latestAction', null);
- const noActions = component.noActions();
- expect(noActions).toBe(true);
- });
-
- it('should compute noActions when latestAction exists', () => {
- const noActions = component.noActions();
- expect(noActions).toBe(false);
- });
-
- it('should compute documentType from provider', () => {
- const documentType = component.documentType();
- expect(documentType).toBeDefined();
- expect(documentType?.singular).toBeDefined();
- });
-
- it('should compute labelDate from preprint dateLastTransitioned', () => {
- const labelDate = component.labelDate();
- expect(labelDate).toBe(mockPreprint.dateLastTransitioned);
- });
-
- it('should compute status for pending preprint', () => {
- const status = component.status();
- expect(status).toBe('preprints.details.statusBanner.pending');
- });
-
- it('should compute status for accepted preprint', () => {
- const acceptedPreprint = { ...mockPreprint, reviewsState: ReviewsState.Accepted };
- jest.spyOn(component, 'preprint').mockReturnValue(acceptedPreprint);
- const status = component.status();
- expect(status).toBe('preprints.details.statusBanner.accepted');
+ it('should expose store preprint and provider-based document type', () => {
+ setup();
+ expect(component.preprint()).toBe(mockPreprint);
+ expect(component.documentType()?.singular).toBeDefined();
});
- it('should compute status for pending withdrawal', () => {
- fixture.componentRef.setInput('isPendingWithdrawal', true);
- const status = component.status();
- expect(status).toBe('preprints.details.statusBanner.pending');
+ it('should return null documentType when provider is missing', () => {
+ setup({ provider: undefined });
+ expect(component.documentType()).toBeNull();
});
- it('should compute iconClass for pending preprint', () => {
- const iconClass = component.iconClass();
- expect(iconClass).toBe('hourglass');
+ it('should compute currentState and fallback to pending when preprint is missing', () => {
+ setup({
+ selectorOverrides: [{ selector: PreprintSelectors.getPreprint, value: undefined }],
+ });
+ expect(component.currentState()).toBe(ReviewsState.Pending);
});
- it('should compute iconClass for accepted preprint', () => {
- const acceptedPreprint = { ...mockPreprint, reviewsState: ReviewsState.Accepted };
- jest.spyOn(component, 'preprint').mockReturnValue(acceptedPreprint);
- const iconClass = component.iconClass();
- expect(iconClass).toBe('check-circle');
- });
-
- it('should compute iconClass for pending withdrawal', () => {
- fixture.componentRef.setInput('isPendingWithdrawal', true);
- const iconClass = component.iconClass();
- expect(iconClass).toBe('hourglass');
- });
+ it('should compute labelDate using dateLastTransitioned and prefer dateWithdrawn when present', () => {
+ setup();
+ expect(component.labelDate()).toBe(mockPreprint.dateLastTransitioned);
- it('should compute severity for pending preprint with post-moderation', () => {
- const postModerationProvider = { ...mockProvider, reviewsWorkflow: ProviderReviewsWorkflow.PostModeration };
- fixture.componentRef.setInput('provider', postModerationProvider);
- const severity = component.severity();
- expect(severity).toBe('secondary');
+ setup({
+ selectorOverrides: [
+ {
+ selector: PreprintSelectors.getPreprint,
+ value: { ...mockPreprint, dateWithdrawn: '2024-01-01T00:00:00Z' },
+ },
+ ],
+ });
+ expect(component.labelDate()).toBe('2024-01-01T00:00:00Z');
});
- it('should compute severity for accepted preprint', () => {
- const acceptedPreprint = { ...mockPreprint, reviewsState: ReviewsState.Accepted };
- jest.spyOn(component, 'preprint').mockReturnValue(acceptedPreprint);
- const severity = component.severity();
- expect(severity).toBe('success');
+ it('should compute status, icon and severity for pending withdrawal', () => {
+ setup({ isPendingWithdrawal: true });
+ expect(component.status()).toBe('preprints.details.statusBanner.pending');
+ expect(component.iconClass()).toBe('hourglass');
+ expect(component.severity()).toBe('warn');
});
- it('should compute severity for pending withdrawal', () => {
- fixture.componentRef.setInput('isPendingWithdrawal', true);
- const severity = component.severity();
- expect(severity).toBe('warn');
+ it('should compute status, icon and severity from non-pending current state', () => {
+ setup({
+ selectorOverrides: [
+ {
+ selector: PreprintSelectors.getPreprint,
+ value: { ...mockPreprint, reviewsState: ReviewsState.Accepted },
+ },
+ ],
+ });
+ expect(component.status()).toBe('preprints.details.statusBanner.accepted');
+ expect(component.iconClass()).toBe('check-circle');
+ expect(component.severity()).toBe('success');
});
- it('should compute recentActivityLanguage for no actions', () => {
- fixture.componentRef.setInput('latestAction', null);
- const language = component.recentActivityLanguage();
- expect(language).toBe('preprints.details.moderationStatusBanner.recentActivity.automatic.pending');
+ it('should compute severity for pending preprint based on provider workflow', () => {
+ setup({
+ provider: { ...mockProvider, reviewsWorkflow: ProviderReviewsWorkflow.PostModeration },
+ });
+ expect(component.severity()).toBe('secondary');
});
- it('should compute recentActivityLanguage with actions', () => {
- const language = component.recentActivityLanguage();
- expect(language).toBe('preprints.details.moderationStatusBanner.recentActivity.pending');
- });
+ it('should compute recent activity language for automatic and action-based paths', () => {
+ setup({ latestAction: null });
+ expect(component.noActions()).toBe(true);
+ expect(component.recentActivityLanguage()).toBe(
+ 'preprints.details.moderationStatusBanner.recentActivity.automatic.pending'
+ );
- it('should compute requestActivityLanguage for pending withdrawal', () => {
- fixture.componentRef.setInput('isPendingWithdrawal', true);
- const language = component.requestActivityLanguage();
- expect(language).toBe('preprints.details.moderationStatusBanner.recentActivity.pendingWithdrawal');
+ setup();
+ expect(component.noActions()).toBe(false);
+ expect(component.recentActivityLanguage()).toBe('preprints.details.moderationStatusBanner.recentActivity.pending');
});
- it('should not compute requestActivityLanguage when not pending withdrawal', () => {
- const language = component.requestActivityLanguage();
- expect(language).toBeUndefined();
- });
+ it('should compute request activity language only for pending withdrawal', () => {
+ setup();
+ expect(component.requestActivityLanguage()).toBeUndefined();
- it('should compute actionCreatorName from latestAction', () => {
- const name = component.actionCreatorName();
- expect(name).toBe('Test User');
+ setup({ isPendingWithdrawal: true });
+ expect(component.requestActivityLanguage()).toBe(
+ 'preprints.details.moderationStatusBanner.recentActivity.pendingWithdrawal'
+ );
});
- it('should compute actionCreatorId from latestAction', () => {
- const id = component.actionCreatorId();
- expect(id).toBe('user-1');
- });
+ it('should compute action creator fields and nullable action creator link', () => {
+ setup();
+ expect(component.actionCreatorName()).toBe('Test User');
+ expect(component.actionCreatorId()).toBe('user-1');
+ expect(component.actionCreatorLink()).toBe(`${component.webUrl}/user-1`);
- it('should compute actionCreatorLink with environment webUrl', () => {
- const link = component.actionCreatorLink();
- expect(link).toBe(`${EnvironmentTokenMock.useValue.webUrl}/user-1`);
+ setup({ latestAction: null });
+ expect(component.actionCreatorLink()).toBeNull();
});
- it('should compute withdrawalRequesterName from latestWithdrawalRequest', () => {
- const name = component.withdrawalRequesterName();
- expect(name).toBe('John Doe');
- });
+ it('should compute withdrawal requester fields and nullable requester link', () => {
+ setup();
+ expect(component.withdrawalRequesterName()).toBe('John Doe');
+ expect(component.withdrawalRequesterId()).toBe('user-123');
+ expect(component.withdrawalRequesterLink()).toBe(`${component.webUrl}/user-123`);
- it('should compute withdrawalRequesterId from latestWithdrawalRequest', () => {
- const id = component.withdrawalRequesterId();
- expect(id).toBe('user-123');
+ setup({ latestWithdrawalRequest: null });
+ expect(component.withdrawalRequesterLink()).toBeNull();
});
});
diff --git a/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.ts b/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.ts
index 0440d25a5..00f902413 100644
--- a/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.ts
+++ b/src/app/features/preprints/components/preprint-details/moderation-status-banner/moderation-status-banner.component.ts
@@ -16,7 +16,7 @@ import {
statusSeverityByState,
statusSeverityByWorkflow,
} from '@osf/features/preprints/constants';
-import { ProviderReviewsWorkflow, ReviewsState } from '@osf/features/preprints/enums';
+import { ReviewsState } from '@osf/features/preprints/enums';
import { getPreprintDocumentType } from '@osf/features/preprints/helpers';
import { PreprintProviderDetails, PreprintRequest } from '@osf/features/preprints/models';
import { PreprintSelectors } from '@osf/features/preprints/store/preprint';
@@ -33,16 +33,17 @@ export class ModerationStatusBannerComponent {
private readonly translateService = inject(TranslateService);
private readonly environment = inject(ENVIRONMENT);
- webUrl = this.environment.webUrl;
+ readonly webUrl = this.environment.webUrl;
- preprint = select(PreprintSelectors.getPreprint);
- provider = input.required();
- latestAction = input.required();
- latestWithdrawalRequest = input.required();
+ readonly preprint = select(PreprintSelectors.getPreprint);
- isPendingWithdrawal = input.required();
+ readonly provider = input.required();
+ readonly latestAction = input.required();
+ readonly latestWithdrawalRequest = input.required();
+ readonly isPendingWithdrawal = input.required();
noActions = computed(() => this.latestAction() === null);
+ currentState = computed(() => this.preprint()?.reviewsState ?? ReviewsState.Pending);
documentType = computed(() => {
const provider = this.provider();
@@ -52,12 +53,12 @@ export class ModerationStatusBannerComponent {
});
labelDate = computed(() => {
- const preprint = this.preprint()!;
- return preprint.dateWithdrawn ? preprint.dateWithdrawn : preprint.dateLastTransitioned;
+ const preprint = this.preprint();
+ return preprint?.dateWithdrawn ? preprint.dateWithdrawn : preprint?.dateLastTransitioned;
});
status = computed(() => {
- const currentState = this.preprint()!.reviewsState;
+ const currentState = this.currentState();
if (this.isPendingWithdrawal()) {
return statusLabelKeyByState[ReviewsState.Pending]!;
@@ -67,7 +68,7 @@ export class ModerationStatusBannerComponent {
});
iconClass = computed(() => {
- const currentState = this.preprint()!.reviewsState;
+ const currentState = this.currentState();
if (this.isPendingWithdrawal()) {
return statusIconByState[ReviewsState.Pending];
@@ -77,19 +78,22 @@ export class ModerationStatusBannerComponent {
});
severity = computed(() => {
- const currentState = this.preprint()!.reviewsState;
+ const currentState = this.currentState();
+ const workflow = this.provider()?.reviewsWorkflow;
if (this.isPendingWithdrawal()) {
return statusSeverityByState[ReviewsState.Pending];
- } else {
- return currentState === ReviewsState.Pending
- ? statusSeverityByWorkflow[this.provider()?.reviewsWorkflow as ProviderReviewsWorkflow]
- : statusSeverityByState[currentState];
}
+
+ if (currentState === ReviewsState.Pending && workflow) {
+ return statusSeverityByWorkflow[workflow];
+ }
+
+ return statusSeverityByState[currentState];
});
recentActivityLanguage = computed(() => {
- const currentState = this.preprint()!.reviewsState;
+ const currentState = this.currentState();
if (this.noActions()) {
return recentActivityMessageByState.automatic[currentState]!;
@@ -107,8 +111,16 @@ export class ModerationStatusBannerComponent {
});
actionCreatorName = computed(() => this.latestAction()?.creator?.name);
- actionCreatorLink = computed(() => `${this.webUrl}/${this.actionCreatorId()}`);
actionCreatorId = computed(() => this.latestAction()?.creator?.id);
+ actionCreatorLink = computed(() => {
+ const creatorId = this.actionCreatorId();
+ return creatorId ? `${this.webUrl}/${creatorId}` : null;
+ });
+
withdrawalRequesterName = computed(() => this.latestWithdrawalRequest()?.creator.name);
withdrawalRequesterId = computed(() => this.latestWithdrawalRequest()?.creator.id);
+ withdrawalRequesterLink = computed(() => {
+ const requesterId = this.withdrawalRequesterId();
+ return requesterId ? `${this.webUrl}/${requesterId}` : null;
+ });
}
diff --git a/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.html b/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.html
new file mode 100644
index 000000000..4260565a6
--- /dev/null
+++ b/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.html
@@ -0,0 +1,69 @@
+
+
+ {{ 'preprints.preprintStepper.review.sections.authorAssertions.publicData' | translate }}
+
+ @switch (preprint().hasDataLinks) {
+ @case (ApplicabilityStatus.NotApplicable) {
+ {{ 'preprints.preprintStepper.review.sections.authorAssertions.noData' | translate }}
+ }
+ @case (ApplicabilityStatus.Unavailable) {
+ {{ preprint().whyNoData }}
+ }
+ @case (ApplicabilityStatus.Applicable) {
+ @for (link of preprint().dataLinks; track link) {
+ {{ link }}
+ }
+ }
+ }
+
+
+
+
+ {{ 'preprints.preprintStepper.review.sections.authorAssertions.publicPreregistration' | translate }}
+
+
+ @switch (preprint().hasPreregLinks) {
+ @case (ApplicabilityStatus.NotApplicable) {
+
+ {{ 'preprints.preprintStepper.review.sections.authorAssertions.noPrereg' | translate }}
+
+ }
+ @case (ApplicabilityStatus.Unavailable) {
+ {{ preprint().whyNoPrereg }}
+ }
+ @case (ApplicabilityStatus.Applicable) {
+ @switch (preprint().preregLinkInfo) {
+ @case (PreregLinkInfo.Analysis) {
+
+ {{ 'preprints.preprintStepper.common.labels.preregTypes.analysis' | translate }}
+
+ }
+ @case (PreregLinkInfo.Designs) {
+
+ {{ 'preprints.preprintStepper.common.labels.preregTypes.designs' | translate }}
+
+ }
+ @case (PreregLinkInfo.Both) {
+
+ {{ 'preprints.preprintStepper.common.labels.preregTypes.both' | translate }}
+
+ }
+ }
+
+ @for (link of preprint().preregLinks; track $index) {
+ {{ link }}
+ }
+ }
+ }
+
+
+
+ {{ 'preprints.preprintStepper.review.sections.authorAssertions.conflictOfInterest' | translate }}
+
+ @if (preprint().hasCoi) {
+ {{ preprint().coiStatement }}
+ } @else {
+ {{ 'preprints.preprintStepper.review.sections.authorAssertions.noCoi' | translate }}
+ }
+
+
diff --git a/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.scss b/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.spec.ts
new file mode 100644
index 000000000..5bb0513e0
--- /dev/null
+++ b/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.spec.ts
@@ -0,0 +1,43 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ApplicabilityStatus, PreregLinkInfo } from '@osf/features/preprints/enums';
+
+import { PreprintAuthorAssertionsComponent } from './preprint-author-assertions.component';
+
+import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock';
+import { provideOSFCore } from '@testing/osf.testing.provider';
+
+describe('PreprintAuthorAssertionsComponent', () => {
+ let component: PreprintAuthorAssertionsComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [PreprintAuthorAssertionsComponent],
+ providers: [provideOSFCore()],
+ });
+
+ fixture = TestBed.createComponent(PreprintAuthorAssertionsComponent);
+ component = fixture.componentInstance;
+ fixture.componentRef.setInput('preprint', { ...PREPRINT_MOCK });
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should expose enums to the template', () => {
+ expect(component.ApplicabilityStatus).toBe(ApplicabilityStatus);
+ expect(component.PreregLinkInfo).toBe(PreregLinkInfo);
+ });
+
+ it('should reactively update when the preprint input changes', () => {
+ expect(component.preprint()).toEqual(PREPRINT_MOCK);
+
+ const updatedMock = { ...PREPRINT_MOCK, id: 'new-id-999' };
+ fixture.componentRef.setInput('preprint', updatedMock);
+
+ expect(component.preprint()).toEqual(updatedMock);
+ });
+});
diff --git a/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.ts b/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.ts
new file mode 100644
index 000000000..aff0adf2e
--- /dev/null
+++ b/src/app/features/preprints/components/preprint-details/preprint-author-assertions/preprint-author-assertions.component.ts
@@ -0,0 +1,20 @@
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+
+import { ApplicabilityStatus, PreregLinkInfo } from '@osf/features/preprints/enums';
+import { PreprintModel } from '@osf/features/preprints/models';
+
+@Component({
+ selector: 'osf-preprint-author-assertions',
+ imports: [TranslatePipe],
+ templateUrl: './preprint-author-assertions.component.html',
+ styleUrl: './preprint-author-assertions.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PreprintAuthorAssertionsComponent {
+ readonly preprint = input.required();
+
+ readonly ApplicabilityStatus = ApplicabilityStatus;
+ readonly PreregLinkInfo = PreregLinkInfo;
+}
diff --git a/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.html b/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.html
index b32a5e842..ba6a67499 100644
--- a/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.html
+++ b/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.html
@@ -26,7 +26,7 @@ {{ 'preprints.details.doi.title' | translate: { documentType: preprintProvid
} @else {
@if (!preprintValue?.isPublic) {
{{ 'preprints.details.doi.pendingDoi' | translate: { documentType: preprintProviderValue.preprintWord } }}
- } @else if (preprintProvider()?.reviewsWorkflow && !preprintValue?.isPublished) {
+ } @else if (preprintProviderValue?.reviewsWorkflow && !preprintValue?.isPublished) {
{{ 'preprints.details.doi.pendingDoiModeration' | translate }}
} @else {
{{ 'preprints.details.doi.noDoi' | translate }}
diff --git a/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.spec.ts
index e4a95c947..20e2128c2 100644
--- a/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.spec.ts
+++ b/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.spec.ts
@@ -1,48 +1,38 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { PreprintModel, PreprintProviderDetails } from '@osf/features/preprints/models';
+import { PreprintModel } from '@osf/features/preprints/models';
import { PreprintSelectors } from '@osf/features/preprints/store/preprint';
import { PreprintDoiSectionComponent } from './preprint-doi-section.component';
import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock';
import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details';
-import { TranslationServiceMock } from '@testing/mocks/translation.service.mock';
-import { OSFTestingModule } from '@testing/osf.testing.module';
+import { provideOSFCore } from '@testing/osf.testing.provider';
import { provideMockStore } from '@testing/providers/store-provider.mock';
describe('PreprintDoiSectionComponent', () => {
let component: PreprintDoiSectionComponent;
let fixture: ComponentFixture;
- const mockPreprint: PreprintModel = PREPRINT_MOCK;
+ const mockPreprint = PREPRINT_MOCK;
- const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK;
+ const mockProvider = PREPRINT_PROVIDER_DETAILS_MOCK;
const mockVersionIds = ['version-1', 'version-2', 'version-3'];
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- imports: [PreprintDoiSectionComponent, OSFTestingModule],
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [PreprintDoiSectionComponent],
providers: [
- TranslationServiceMock,
+ provideOSFCore(),
provideMockStore({
signals: [
- {
- selector: PreprintSelectors.getPreprint,
- value: mockPreprint,
- },
- {
- selector: PreprintSelectors.getPreprintVersionIds,
- value: mockVersionIds,
- },
- {
- selector: PreprintSelectors.arePreprintVersionIdsLoading,
- value: false,
- },
+ { selector: PreprintSelectors.getPreprint, value: mockPreprint },
+ { selector: PreprintSelectors.getPreprintVersionIds, value: mockVersionIds },
+ { selector: PreprintSelectors.arePreprintVersionIdsLoading, value: false },
],
}),
],
- }).compileComponents();
+ });
fixture = TestBed.createComponent(PreprintDoiSectionComponent);
component = fixture.componentInstance;
@@ -52,11 +42,9 @@ describe('PreprintDoiSectionComponent', () => {
it('should compute versions dropdown options from version IDs', () => {
const options = component.versionsDropdownOptions();
- expect(options).toEqual([
- { label: 'Version 3', value: 'version-1' },
- { label: 'Version 2', value: 'version-2' },
- { label: 'Version 1', value: 'version-3' },
- ]);
+ expect(options).toHaveLength(3);
+ expect(options.map((option) => option.value)).toEqual(['version-1', 'version-2', 'version-3']);
+ expect(options.every((option) => typeof option.label === 'string' && option.label.length > 0)).toBe(true);
});
it('should return empty array when no version IDs', () => {
@@ -65,6 +53,12 @@ describe('PreprintDoiSectionComponent', () => {
expect(options).toEqual([]);
});
+ it('should return empty array when version IDs are undefined', () => {
+ jest.spyOn(component, 'preprintVersionIds').mockReturnValue(undefined as unknown as string[]);
+ const options = component.versionsDropdownOptions();
+ expect(options).toEqual([]);
+ });
+
it('should emit preprintVersionSelected when selecting different version', () => {
const emitSpy = jest.spyOn(component.preprintVersionSelected, 'emit');
component.selectPreprintVersion('version-2');
@@ -77,6 +71,13 @@ describe('PreprintDoiSectionComponent', () => {
expect(emitSpy).not.toHaveBeenCalled();
});
+ it('should not emit when current preprint is unavailable', () => {
+ jest.spyOn(component, 'preprint').mockReturnValue(undefined as unknown as PreprintModel);
+ const emitSpy = jest.spyOn(component.preprintVersionSelected, 'emit');
+ component.selectPreprintVersion('version-2');
+ expect(emitSpy).not.toHaveBeenCalled();
+ });
+
it('should handle preprint provider input', () => {
const provider = component.preprintProvider();
expect(provider).toBe(mockProvider);
diff --git a/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.ts b/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.ts
index 869822f1f..63df8cc1b 100644
--- a/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.ts
+++ b/src/app/features/preprints/components/preprint-details/preprint-doi-section/preprint-doi-section.component.ts
@@ -1,10 +1,10 @@
import { select } from '@ngxs/store';
-import { TranslatePipe } from '@ngx-translate/core';
+import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { Select } from 'primeng/select';
-import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { PreprintProviderDetails } from '@osf/features/preprints/models';
@@ -18,26 +18,32 @@ import { PreprintSelectors } from '@osf/features/preprints/store/preprint';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PreprintDoiSectionComponent {
- preprintProvider = input.required();
- preprint = select(PreprintSelectors.getPreprint);
+ private readonly translateService = inject(TranslateService);
- preprintVersionSelected = output();
+ readonly preprintProvider = input.required();
- preprintVersionIds = select(PreprintSelectors.getPreprintVersionIds);
- arePreprintVersionIdsLoading = select(PreprintSelectors.arePreprintVersionIdsLoading);
+ readonly preprintVersionSelected = output();
+
+ readonly preprint = select(PreprintSelectors.getPreprint);
+ readonly preprintVersionIds = select(PreprintSelectors.getPreprintVersionIds);
+ readonly arePreprintVersionIdsLoading = select(PreprintSelectors.arePreprintVersionIdsLoading);
versionsDropdownOptions = computed(() => {
- const preprintVersionIds = this.preprintVersionIds();
+ const preprintVersionIds = this.preprintVersionIds() ?? [];
if (!preprintVersionIds.length) return [];
return preprintVersionIds.map((versionId, index) => ({
- label: `Version ${preprintVersionIds.length - index}`,
+ label: this.translateService.instant('preprints.details.file.version', {
+ version: preprintVersionIds.length - index,
+ }),
value: versionId,
}));
});
selectPreprintVersion(versionId: string) {
- if (this.preprint()!.id === versionId) return;
+ const currentPreprintId = this.preprint()?.id;
+
+ if (!currentPreprintId || currentPreprintId === versionId) return;
this.preprintVersionSelected.emit(versionId);
}
diff --git a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.html b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.html
index 3e17a370b..a9858153d 100644
--- a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.html
+++ b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.html
@@ -1,8 +1,10 @@
+@let safeLinkValue = safeLink();
+
- @if (safeLink()) {
+ @if (safeLinkValue) {
}
+
@if (isIframeLoading || isFileLoading()) {
}
@@ -29,7 +32,7 @@
@@ -40,9 +43,8 @@
}
- @if (file()) {
- @let preprintValue = preprint()!;
-
+ @let preprintValue = preprint();
+ @if (file() && preprintValue) {
{{ dateLabel() | translate }}: {{ preprintValue.dateCreated | date: 'longDate' }}
@if (isMedium() || isLarge()) {
diff --git a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.spec.ts
index e251447a3..04fc5152f 100644
--- a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.spec.ts
+++ b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.spec.ts
@@ -1,6 +1,6 @@
import { MockComponent, MockProvider } from 'ng-mocks';
-import { BehaviorSubject, of } from 'rxjs';
+import { BehaviorSubject } from 'rxjs';
import { ComponentFixture, TestBed } from '@angular/core/testing';
@@ -8,19 +8,20 @@ import { ProviderReviewsWorkflow } from '@osf/features/preprints/enums';
import { PreprintSelectors } from '@osf/features/preprints/store/preprint';
import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component';
import { IS_LARGE, IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens';
+import { FileVersionModel } from '@shared/models/files/file-version.model';
import { DataciteService } from '@shared/services/datacite/datacite.service';
import { PreprintFileSectionComponent } from './preprint-file-section.component';
import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock';
-import { TranslationServiceMock } from '@testing/mocks/translation.service.mock';
-import { OSFTestingModule } from '@testing/osf.testing.module';
-import { provideMockStore } from '@testing/providers/store-provider.mock';
+import { provideOSFCore } from '@testing/osf.testing.provider';
+import { DataciteServiceMockBuilder, DataciteServiceMockType } from '@testing/providers/datacite.service.mock';
+import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';
describe('PreprintFileSectionComponent', () => {
let component: PreprintFileSectionComponent;
let fixture: ComponentFixture
;
- let dataciteService: jest.Mocked;
+ let dataciteService: DataciteServiceMockType;
let isMediumSubject: BehaviorSubject;
let isLargeSubject: BehaviorSubject;
@@ -32,141 +33,134 @@ describe('PreprintFileSectionComponent', () => {
render: 'https://example.com/render',
},
};
- const mockFileVersions = [
+ const mockFileVersions: FileVersionModel[] = [
{
id: '1',
- dateCreated: '2024-01-15T10:00:00Z',
+ size: 100,
+ name: 'test-file-v1.pdf',
+ dateCreated: new Date('2024-01-15T10:00:00Z'),
downloadLink: 'https://example.com/download/1',
},
{
id: '2',
- dateCreated: '2024-01-16T10:00:00Z',
+ size: 200,
+ name: 'test-file-v2.pdf',
+ dateCreated: new Date('2024-01-16T10:00:00Z'),
downloadLink: 'https://example.com/download/2',
},
];
- beforeEach(async () => {
+ interface SetupOverrides extends BaseSetupOverrides {
+ providerReviewsWorkflow?: ProviderReviewsWorkflow | null;
+ }
+
+ function setup(overrides: SetupOverrides = {}) {
isMediumSubject = new BehaviorSubject(false);
isLargeSubject = new BehaviorSubject(true);
+ dataciteService = DataciteServiceMockBuilder.create().build();
- await TestBed.configureTestingModule({
- imports: [PreprintFileSectionComponent, OSFTestingModule, MockComponent(LoadingSpinnerComponent)],
+ TestBed.configureTestingModule({
+ imports: [PreprintFileSectionComponent, MockComponent(LoadingSpinnerComponent)],
providers: [
- TranslationServiceMock,
- {
- provide: DataciteService,
- useValue: {
- logIdentifiableDownload: jest.fn().mockReturnValue(of(void 0)),
- },
- },
+ provideOSFCore(),
+ MockProvider(DataciteService, dataciteService),
MockProvider(IS_MEDIUM, isMediumSubject),
MockProvider(IS_LARGE, isLargeSubject),
provideMockStore({
- signals: [
- {
- selector: PreprintSelectors.getPreprint,
- value: mockPreprint,
- },
- {
- selector: PreprintSelectors.getPreprintFile,
- value: mockFile,
- },
- {
- selector: PreprintSelectors.isPreprintFileLoading,
- value: false,
- },
- {
- selector: PreprintSelectors.getPreprintFileVersions,
- value: mockFileVersions,
- },
- {
- selector: PreprintSelectors.arePreprintFileVersionsLoading,
- value: false,
- },
- ],
+ signals: mergeSignalOverrides(
+ [
+ { selector: PreprintSelectors.getPreprint, value: mockPreprint },
+ { selector: PreprintSelectors.getPreprintFile, value: mockFile },
+ { selector: PreprintSelectors.isPreprintFileLoading, value: false },
+ { selector: PreprintSelectors.getPreprintFileVersions, value: mockFileVersions },
+ { selector: PreprintSelectors.arePreprintFileVersionsLoading, value: false },
+ ],
+ overrides.selectorOverrides
+ ),
}),
],
- }).compileComponents();
+ });
fixture = TestBed.createComponent(PreprintFileSectionComponent);
component = fixture.componentInstance;
-
- fixture.componentRef.setInput('providerReviewsWorkflow', ProviderReviewsWorkflow.PreModeration);
-
- dataciteService = TestBed.inject(DataciteService) as jest.MockedObject;
- });
+ fixture.componentRef.setInput(
+ 'providerReviewsWorkflow',
+ overrides.providerReviewsWorkflow ?? ProviderReviewsWorkflow.PreModeration
+ );
+ }
it('should create', () => {
+ setup();
expect(component).toBeTruthy();
});
- it('should return preprint from store', () => {
- const preprint = component.preprint();
- expect(preprint).toBe(mockPreprint);
- });
-
- it('should return file from store', () => {
- const file = component.file();
- expect(file).toBe(mockFile);
- });
-
- it('should return file loading state from store', () => {
- const loading = component.isFileLoading();
- expect(loading).toBe(false);
- });
-
- it('should return file versions from store', () => {
- const versions = component.fileVersions();
- expect(versions).toBe(mockFileVersions);
- });
-
- it('should return file versions loading state from store', () => {
- const loading = component.areFileVersionsLoading();
- expect(loading).toBe(false);
+ it('should expose selector signals', () => {
+ setup();
+ expect(component.preprint()).toBe(mockPreprint);
+ expect(component.file()).toBe(mockFile);
+ expect(component.isFileLoading()).toBe(false);
+ expect(component.fileVersions()).toBe(mockFileVersions);
+ expect(component.areFileVersionsLoading()).toBe(false);
});
it('should compute safe link from file render link', () => {
+ setup();
const safeLink = component.safeLink();
- expect(safeLink).toBeDefined();
+ expect(safeLink).toBe('https://example.com/render');
+ });
+
+ it('should return null safe link when render link is missing', () => {
+ setup({
+ selectorOverrides: [{ selector: PreprintSelectors.getPreprintFile, value: { ...mockFile, links: {} } }],
+ });
+ expect(component.safeLink()).toBeNull();
});
it('should compute version menu items from file versions', () => {
+ setup();
const menuItems = component.versionMenuItems();
expect(menuItems).toHaveLength(2);
- expect(menuItems[0]).toHaveProperty('label');
- expect(menuItems[0]).toHaveProperty('url');
- expect(menuItems[0]).toHaveProperty('command');
+ expect(menuItems[0].label).toBeTruthy();
+ expect(menuItems[0].url).toBe('https://example.com/download/1');
+ expect(menuItems[0].command).toBeDefined();
});
it('should return empty array when no file versions', () => {
+ setup();
jest.spyOn(component, 'fileVersions').mockReturnValue([]);
const menuItems = component.versionMenuItems();
expect(menuItems).toEqual([]);
});
- it('should compute date label for pre-moderation workflow', () => {
- const label = component.dateLabel();
- expect(label).toBe('preprints.details.file.submitted');
+ it('should return empty array when file versions are undefined', () => {
+ setup();
+ jest.spyOn(component, 'fileVersions').mockReturnValue(undefined as unknown as typeof mockFileVersions);
+ const menuItems = component.versionMenuItems();
+ expect(menuItems).toEqual([]);
});
- it('should compute date label for post-moderation workflow', () => {
- fixture.componentRef.setInput('providerReviewsWorkflow', ProviderReviewsWorkflow.PostModeration);
+ it('should compute date label for pre-moderation workflow', () => {
+ setup({ providerReviewsWorkflow: ProviderReviewsWorkflow.PreModeration });
const label = component.dateLabel();
- expect(label).toBe('preprints.details.file.created');
+ expect(label).toBe('preprints.details.file.submitted');
});
- it('should return created label when no reviews workflow', () => {
+ it('should return created label for non-pre-moderation workflows', () => {
+ setup({ providerReviewsWorkflow: ProviderReviewsWorkflow.PostModeration });
+ expect(component.dateLabel()).toBe('preprints.details.file.created');
fixture.componentRef.setInput('providerReviewsWorkflow', null);
const label = component.dateLabel();
expect(label).toBe('preprints.details.file.created');
});
it('should call dataciteService.logIdentifiableDownload when logDownload is called', () => {
+ setup();
component.logDownload();
expect(dataciteService.logIdentifiableDownload).toHaveBeenCalledWith(component.preprint$);
});
it('should call logDownload when version menu item command is executed', () => {
+ setup();
const menuItems = component.versionMenuItems();
expect(menuItems.length).toBeGreaterThan(0);
@@ -180,11 +174,13 @@ describe('PreprintFileSectionComponent', () => {
});
it('should handle isMedium signal', () => {
+ setup();
const isMedium = component.isMedium();
expect(typeof isMedium).toBe('boolean');
});
it('should handle isLarge signal', () => {
+ setup();
const isLarge = component.isLarge();
expect(typeof isLarge).toBe('boolean');
});
diff --git a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.ts b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.ts
index 26f737ef9..8b81a88a3 100644
--- a/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.ts
+++ b/src/app/features/preprints/components/preprint-details/preprint-file-section/preprint-file-section.component.ts
@@ -9,24 +9,23 @@ import { Skeleton } from 'primeng/skeleton';
import { DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, input } from '@angular/core';
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
-import { DomSanitizer } from '@angular/platform-browser';
import { ProviderReviewsWorkflow } from '@osf/features/preprints/enums';
import { PreprintSelectors } from '@osf/features/preprints/store/preprint';
import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component';
import { IS_LARGE, IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens';
+import { SafeUrlPipe } from '@osf/shared/pipes/safe-url.pipe';
import { DataciteService } from '@osf/shared/services/datacite/datacite.service';
@Component({
selector: 'osf-preprint-file-section',
- imports: [LoadingSpinnerComponent, DatePipe, Skeleton, Menu, Button, TranslatePipe],
+ imports: [LoadingSpinnerComponent, Skeleton, Menu, Button, DatePipe, TranslatePipe, SafeUrlPipe],
templateUrl: './preprint-file-section.component.html',
styleUrl: './preprint-file-section.component.scss',
providers: [DatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PreprintFileSectionComponent {
- private readonly sanitizer = inject(DomSanitizer);
private readonly datePipe = inject(DatePipe);
private readonly translateService = inject(TranslateService);
private readonly destroyRef = inject(DestroyRef);
@@ -38,26 +37,17 @@ export class PreprintFileSectionComponent {
isLarge = toSignal(inject(IS_LARGE));
preprint = select(PreprintSelectors.getPreprint);
+ preprint$ = toObservable(this.preprint);
file = select(PreprintSelectors.getPreprintFile);
- preprint$ = toObservable(select(PreprintSelectors.getPreprint));
isFileLoading = select(PreprintSelectors.isPreprintFileLoading);
- safeLink = computed(() => {
- const link = this.file()?.links.render;
- if (!link) return null;
-
- return this.sanitizer.bypassSecurityTrustResourceUrl(link);
- });
- isIframeLoading = true;
-
fileVersions = select(PreprintSelectors.getPreprintFileVersions);
areFileVersionsLoading = select(PreprintSelectors.arePreprintFileVersionsLoading);
- logDownload() {
- this.dataciteService.logIdentifiableDownload(this.preprint$).pipe(takeUntilDestroyed(this.destroyRef)).subscribe();
- }
+ safeLink = computed(() => this.file()?.links.render ?? null);
+ isIframeLoading = true;
versionMenuItems = computed(() => {
- const fileVersions = this.fileVersions();
+ const fileVersions = this.fileVersions() ?? [];
if (!fileVersions.length) return [];
return fileVersions.map((version) => ({
@@ -77,4 +67,8 @@ export class PreprintFileSectionComponent {
? 'preprints.details.file.submitted'
: 'preprints.details.file.created';
});
+
+ logDownload() {
+ this.dataciteService.logIdentifiableDownload(this.preprint$).pipe(takeUntilDestroyed(this.destroyRef)).subscribe();
+ }
}
diff --git a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.html b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.html
index 66c43fbaf..f4f6f0f1a 100644
--- a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.html
+++ b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.html
@@ -1,10 +1,10 @@
-
+
{{ 'preprints.details.decision.approve.label' | translate }}
{{ 'preprints.details.decision.approve.explanation' | translate }}
@@ -41,7 +41,7 @@ {{ 'preprints.details.decision.approve.label' | transl
(change)="requestDecisionToggled()"
/>
-
+
{{ 'preprints.details.decision.decline.label' | translate }}
{{ 'preprints.details.decision.decline.explanation' | translate }}
@@ -50,8 +50,10 @@ {{ 'preprints.details.decision.decline.label' | transl
-
{{ labelRequestDecisionJustification() | translate }}
-
{{ reviewerComment()?.length ?? 0 }}/{{ decisionCommentLimit }}
+
+ {{ labelRequestDecisionJustification() | translate }}
+
+
{{ requestDecisionJustification()?.length ?? 0 }}/{{ decisionCommentLimit }}