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) {

{{ 'preprints.details.publicationDoi' | translate }}

- - {{ preprint()?.articleDoiLink }} + + {{ 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 @@
diff --git a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.scss b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.scss index 86cfbe725..e69de29bb 100644 --- a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.scss +++ b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.scss @@ -1,12 +0,0 @@ -@use "styles/mixins" as mix; - -.search-input-container { - position: relative; - - img { - position: absolute; - right: mix.rem(4px); - top: mix.rem(4px); - z-index: 1; - } -} diff --git a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.spec.ts b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.spec.ts index dbb62042e..4b3444fa5 100644 --- a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.spec.ts +++ b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.spec.ts @@ -1,177 +1,118 @@ -import { MockComponent, MockProvider } from 'ng-mocks'; - -import { DialogService } from 'primeng/dynamicdialog'; +import { MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { PreprintProviderDetails } from '@osf/features/preprints/models'; -import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; + +import { PreprintsHelpDialogComponent } from '../preprints-help-dialog/preprints-help-dialog.component'; import { PreprintProviderHeroComponent } from './preprint-provider-hero.component'; 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 { DialogServiceMockBuilder } from '@testing/providers/dialog-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; describe('PreprintProviderHeroComponent', () => { let component: PreprintProviderHeroComponent; let fixture: ComponentFixture; - let mockDialogService: ReturnType; + let customDialogMock: CustomDialogServiceMockType; const mockPreprintProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; - beforeEach(async () => { - mockDialogService = DialogServiceMockBuilder.create().build(); + function setup(overrides?: { + searchControl?: FormControl; + preprintProvider?: PreprintProviderDetails | undefined; + isPreprintProviderLoading?: boolean; + }) { + customDialogMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - await TestBed.configureTestingModule({ - imports: [PreprintProviderHeroComponent, OSFTestingModule, MockComponent(SearchInputComponent)], + TestBed.configureTestingModule({ + imports: [PreprintProviderHeroComponent], providers: [ - MockProvider(DialogService, mockDialogService), + provideOSFCore(), + MockProvider(CustomDialogService, customDialogMock), MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().build()), - TranslationServiceMock, ], - }) - .overrideComponent(PreprintProviderHeroComponent, { - set: { - providers: [{ provide: DialogService, useValue: mockDialogService }], - }, - }) - .compileComponents(); + }); fixture = TestBed.createComponent(PreprintProviderHeroComponent); component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); + fixture.componentRef.setInput( + 'searchControl', + overrides && 'searchControl' in overrides ? overrides.searchControl : new FormControl('', { nonNullable: true }) + ); + fixture.componentRef.setInput( + 'preprintProvider', + overrides && 'preprintProvider' in overrides ? overrides.preprintProvider : mockPreprintProvider + ); + fixture.componentRef.setInput( + 'isPreprintProviderLoading', + overrides && 'isPreprintProviderLoading' in overrides ? overrides.isPreprintProviderLoading : false + ); fixture.detectChanges(); + } - expect(component).toBeTruthy(); - }); + function query(selector: string): Element | null { + return fixture.nativeElement.querySelector(selector); + } - it('should display loading skeletons when isPreprintProviderLoading is true', () => { - fixture.componentRef.setInput('isPreprintProviderLoading', true); - fixture.detectChanges(); + it('should show skeletons while loading', () => { + setup({ isPreprintProviderLoading: true, preprintProvider: undefined }); - const compiled = fixture.nativeElement; - const skeletons = compiled.querySelectorAll('p-skeleton'); - const providerName = compiled.querySelector('.preprint-provider-name'); - const providerLogo = compiled.querySelector('img'); - const addButton = compiled.querySelector('p-button'); - const searchInput = compiled.querySelector('osf-search-input'); - - expect(skeletons.length).toBeGreaterThan(0); - expect(providerName).toBeNull(); - expect(providerLogo).toBeNull(); - expect(addButton).toBeNull(); - expect(searchInput).toBeNull(); + expect(fixture.nativeElement.querySelectorAll('p-skeleton').length).toBeGreaterThan(0); + expect(query('.preprint-provider-name')).toBeNull(); + expect(query('img')).toBeNull(); + expect(query('p-button')).toBeNull(); + expect(query('osf-search-input')).toBeNull(); }); - it('should display provider information when not loading', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); + it('should render provider info when not loading', () => { + setup(); - const compiled = fixture.nativeElement; - const providerName = compiled.querySelector('.preprint-provider-name'); - const providerLogo = compiled.querySelector('img'); - const description = compiled.querySelector('.provider-description div'); - - expect(providerName).toBeTruthy(); - expect(providerName?.textContent).toBe('OSF Preprints'); - expect(providerLogo).toBeTruthy(); - expect(providerLogo?.getAttribute('src')).toBe('https://osf.io/assets/hero-logo.png'); - expect(description).toBeTruthy(); - expect(description?.innerHTML).toContain('

Open preprints for all disciplines

'); + expect(query('.preprint-provider-name')?.textContent).toBe('OSF Preprints'); + expect((query('img') as HTMLImageElement).getAttribute('src')).toBe('https://osf.io/assets/hero-logo.png'); + expect(query('.provider-description div')?.innerHTML).toContain('

Open preprints for all disciplines

'); }); - it('should display add preprint button when allowSubmissions is true', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); - - const compiled = fixture.nativeElement; - const addButton = compiled.querySelector('p-button'); + it('should hide provider-dependent content when provider is undefined and not loading', () => { + setup({ preprintProvider: undefined, isPreprintProviderLoading: false }); - expect(addButton).toBeTruthy(); - expect(addButton?.getAttribute('ng-reflect-label')).toBe('Preprints.addpreprint'); - expect(addButton?.getAttribute('ng-reflect-router-link')).toBe('/preprints,osf-preprints,submi'); + expect(query('.preprint-provider-name')).toBeNull(); + expect(query('img')).toBeNull(); }); - it('should display search input when not loading', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); + it('should emit normalized search value', () => { + setup(); + jest.spyOn(component.triggerSearch, 'emit'); - const compiled = fixture.nativeElement; - const searchInput = compiled.querySelector('osf-search-input'); + component.onTriggerSearch('test “quoted” value'); - expect(searchInput).toBeTruthy(); - expect(searchInput?.getAttribute('ng-reflect-show-help-icon')).toBe('true'); - expect(searchInput?.getAttribute('ng-reflect-placeholder')).toBe('Preprints.searchplaceholder'); + expect(component.triggerSearch.emit).toHaveBeenCalledWith('test "quoted" value'); }); - it('should emit triggerSearch when onTriggerSearch is called', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); - + it('should emit empty string when search value is missing', () => { + setup(); jest.spyOn(component.triggerSearch, 'emit'); - const searchValue = 'test search query'; - component.onTriggerSearch(searchValue); + component.onTriggerSearch(undefined as unknown as string); - expect(component.triggerSearch.emit).toHaveBeenCalledWith(searchValue); + expect(component.triggerSearch.emit).toHaveBeenCalledWith(''); }); - it('should open help dialog when openHelpDialog is called', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); - - expect(mockDialogService.open).toBeDefined(); - expect(typeof mockDialogService.open).toBe('function'); + it('should open help dialog with expected header', () => { + setup(); component.openHelpDialog(); - expect(mockDialogService.open).toHaveBeenCalledWith(expect.any(Function), { - focusOnShow: false, + expect(customDialogMock.open).toHaveBeenCalledWith(PreprintsHelpDialogComponent, { header: 'preprints.helpDialog.header', - closeOnEscape: true, - modal: true, - closable: true, - breakpoints: { '768px': '95vw' }, }); }); - - it('should update when input properties change', () => { - fixture.componentRef.setInput('searchControl', new FormControl('')); - fixture.componentRef.setInput('preprintProvider', undefined); - fixture.componentRef.setInput('isPreprintProviderLoading', true); - fixture.detectChanges(); - - let compiled = fixture.nativeElement; - expect(compiled.querySelectorAll('p-skeleton').length).toBeGreaterThan(0); - expect(compiled.querySelector('.preprint-provider-name')).toBeNull(); - - fixture.componentRef.setInput('preprintProvider', mockPreprintProvider); - fixture.componentRef.setInput('isPreprintProviderLoading', false); - fixture.detectChanges(); - - compiled = fixture.nativeElement; - expect(compiled.querySelectorAll('p-skeleton').length).toBe(0); - expect(compiled.querySelector('.preprint-provider-name')).toBeTruthy(); - expect(compiled.querySelector('.preprint-provider-name')?.textContent).toBe('OSF Preprints'); - }); }); diff --git a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.ts b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.ts index 826d17eca..2fa2bc2aa 100644 --- a/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.ts +++ b/src/app/features/preprints/components/preprint-provider-hero/preprint-provider-hero.component.ts @@ -18,24 +18,24 @@ import { PreprintsHelpDialogComponent } from '../preprints-help-dialog/preprints @Component({ selector: 'osf-preprint-provider-hero', - imports: [Button, RouterLink, SearchInputComponent, Skeleton, TranslatePipe, TitleCasePipe, SafeHtmlPipe], + imports: [Button, Skeleton, RouterLink, SearchInputComponent, SafeHtmlPipe, TitleCasePipe, TranslatePipe], templateUrl: './preprint-provider-hero.component.html', styleUrl: './preprint-provider-hero.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintProviderHeroComponent { - customDialogService = inject(CustomDialogService); + private readonly customDialogService = inject(CustomDialogService); - searchControl = input(new FormControl()); - preprintProvider = input.required(); - isPreprintProviderLoading = input.required(); - triggerSearch = output(); + readonly searchControl = input>(new FormControl('', { nonNullable: true })); + readonly isPreprintProviderLoading = input.required(); + readonly preprintProvider = input(); + readonly triggerSearch = output(); - onTriggerSearch(value: string) { - this.triggerSearch.emit(normalizeQuotes(value)!); + onTriggerSearch(value: string): void { + this.triggerSearch.emit(normalizeQuotes(value) ?? ''); } - openHelpDialog() { + openHelpDialog(): void { this.customDialogService.open(PreprintsHelpDialogComponent, { header: 'preprints.helpDialog.header' }); } } diff --git a/src/app/features/preprints/components/preprint-services/preprint-services.component.spec.ts b/src/app/features/preprints/components/preprint-services/preprint-services.component.spec.ts index fd76e1ec9..552053787 100644 --- a/src/app/features/preprints/components/preprint-services/preprint-services.component.spec.ts +++ b/src/app/features/preprints/components/preprint-services/preprint-services.component.spec.ts @@ -5,7 +5,7 @@ import { PreprintProviderShortInfo } from '@osf/features/preprints/models'; import { PreprintServicesComponent } from './preprint-services.component'; import { PREPRINT_PROVIDER_SHORT_INFO_MOCK } from '@testing/mocks/preprint-provider-short-info.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('PreprintServicesComponent', () => { let component: PreprintServicesComponent; @@ -13,32 +13,30 @@ describe('PreprintServicesComponent', () => { const mockProviders: PreprintProviderShortInfo[] = [PREPRINT_PROVIDER_SHORT_INFO_MOCK]; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PreprintServicesComponent, OSFTestingModule], - }).compileComponents(); + function setup(providers: PreprintProviderShortInfo[]) { + TestBed.configureTestingModule({ + imports: [PreprintServicesComponent], + providers: [provideOSFCore()], + }); fixture = TestBed.createComponent(PreprintServicesComponent); component = fixture.componentInstance; - }); + fixture.componentRef.setInput('preprintProvidersToAdvertise', providers); + } it('should create', () => { - fixture.componentRef.setInput('preprintProvidersToAdvertise', []); - fixture.detectChanges(); - + setup(mockProviders); expect(component).toBeTruthy(); }); - it('should accept preprint providers input', () => { - fixture.componentRef.setInput('preprintProvidersToAdvertise', mockProviders); - fixture.detectChanges(); + it('should keep provided providers input', () => { + setup(mockProviders); expect(component.preprintProvidersToAdvertise()).toEqual(mockProviders); }); - it('should handle empty providers array', () => { - fixture.componentRef.setInput('preprintProvidersToAdvertise', []); - fixture.detectChanges(); + it('should keep empty providers input', () => { + setup([]); expect(component.preprintProvidersToAdvertise()).toEqual([]); }); diff --git a/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.html b/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.html index ef1b931d3..9669136c1 100644 --- a/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.html +++ b/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.html @@ -1,8 +1,8 @@

{{ 'preprints.helpDialog.message' | translate }} - {{ 'preprints.helpDialog.linkText' | translate }}. + {{ 'preprints.helpDialog.linkText' | translate }}.

diff --git a/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.spec.ts b/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.spec.ts index dfe20d050..ccd92cbc7 100644 --- a/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.spec.ts +++ b/src/app/features/preprints/components/preprints-help-dialog/preprints-help-dialog.component.spec.ts @@ -2,16 +2,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { PreprintsHelpDialogComponent } from './preprints-help-dialog.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('PreprintsHelpDialogComponent', () => { let component: PreprintsHelpDialogComponent; let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PreprintsHelpDialogComponent, OSFTestingModule], - }).compileComponents(); + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [PreprintsHelpDialogComponent], + providers: [provideOSFCore()], + }); fixture = TestBed.createComponent(PreprintsHelpDialogComponent); component = fixture.componentInstance; diff --git a/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.html b/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.html index e29db002e..dd2c13d9b 100644 --- a/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.html +++ b/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.html @@ -19,6 +19,6 @@
- +
diff --git a/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.spec.ts b/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.spec.ts index 6af60f53a..80a4473af 100644 --- a/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.spec.ts +++ b/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.spec.ts @@ -7,57 +7,55 @@ import { TextInputComponent } from '@osf/shared/components/text-input/text-input import { ArrayInputComponent } from './array-input.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('ArrayInputComponent', () => { let component: ArrayInputComponent; let fixture: ComponentFixture; - let formArray: FormArray; + let formArray: FormArray>; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ArrayInputComponent, MockComponent(TextInputComponent), OSFTestingModule], - }).compileComponents(); + function setup(overrides?: { withValidators?: boolean; formArray?: FormArray> }) { + TestBed.configureTestingModule({ + imports: [ArrayInputComponent, MockComponent(TextInputComponent)], + providers: [provideOSFCore()], + }); fixture = TestBed.createComponent(ArrayInputComponent); component = fixture.componentInstance; - formArray = new FormArray([new FormControl('test')]); - fixture.componentRef.setInput('formArray', formArray); + formArray = + overrides?.formArray ?? new FormArray>([new FormControl('test', { nonNullable: true })]); + fixture.componentRef.setInput('formArray', formArray as FormArray); fixture.componentRef.setInput('inputPlaceholder', 'Enter value'); - fixture.componentRef.setInput('validators', [Validators.required]); + if (overrides?.withValidators ?? true) { + fixture.componentRef.setInput('validators', [Validators.required]); + } fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should have correct input values', () => { - expect(component.formArray()).toBe(formArray); - expect(component.inputPlaceholder()).toBe('Enter value'); - expect(component.validators()).toEqual([Validators.required]); - }); + } it('should add new control to form array', () => { + setup(); const initialLength = formArray.length; component.add(); expect(formArray.length).toBe(initialLength + 1); - expect(formArray.at(formArray.length - 1)).toBeInstanceOf(FormControl); + const newControl = formArray.at(formArray.length - 1); + expect(newControl.value).toBe(''); + expect(newControl.hasError('required')).toBe(true); }); - it('should add control with correct validators', () => { + it('should add control without validators when validators input is not set', () => { + setup({ withValidators: false }); component.add(); const newControl = formArray.at(formArray.length - 1); - expect(newControl.hasError('required')).toBe(true); + expect(newControl.errors).toBeNull(); }); it('should remove control at specified index', () => { - component.add(); + setup(); component.add(); const initialLength = formArray.length; @@ -67,9 +65,8 @@ describe('ArrayInputComponent', () => { }); it('should not remove control if only one control exists', () => { - const singleControlArray = new FormArray([new FormControl('only')]); - fixture.componentRef.setInput('formArray', singleControlArray); - fixture.detectChanges(); + const singleControlArray = new FormArray>([new FormControl('only', { nonNullable: true })]); + setup({ formArray: singleControlArray }); const initialLength = singleControlArray.length; @@ -77,27 +74,4 @@ describe('ArrayInputComponent', () => { expect(singleControlArray.length).toBe(initialLength); }); - - it('should handle multiple add and remove operations', () => { - const initialLength = formArray.length; - - component.add(); - component.add(); - component.add(); - - expect(formArray.length).toBe(initialLength + 3); - - component.remove(1); - component.remove(2); - - expect(formArray.length).toBe(initialLength + 1); - }); - - it('should create controls with nonNullable true', () => { - component.add(); - - const newControl = formArray.at(formArray.length - 1); - expect(newControl.value).toBe(''); - expect(newControl.hasError('required')).toBe(true); - }); }); diff --git a/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.ts b/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.ts index 4e32dd133..024eb0a03 100644 --- a/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.ts +++ b/src/app/features/preprints/components/stepper/author-assertion-step/array-input/array-input.component.ts @@ -15,11 +15,11 @@ import { TextInputComponent } from '@osf/shared/components/text-input/text-input changeDetection: ChangeDetectionStrategy.OnPush, }) export class ArrayInputComponent { - formArray = input.required>(); - inputPlaceholder = input.required(); - validators = input.required(); + readonly formArray = input.required>(); + readonly inputPlaceholder = input.required(); + readonly validators = input([]); - add() { + add(): void { this.formArray().push( new FormControl('', { nonNullable: true, @@ -28,9 +28,11 @@ export class ArrayInputComponent { ); } - remove(index: number) { - if (this.formArray().length > 1) { - this.formArray().removeAt(index); + remove(index: number): void { + const formArray = this.formArray(); + + if (formArray.length > 1) { + formArray.removeAt(index); } } } diff --git a/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.html b/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.html index 31c97a19d..6840ebace 100644 --- a/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.html +++ b/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.html @@ -224,7 +224,7 @@

{{ 'preprints.preprintStepper.authorAssertions.publicPreregistration.title' styleClass="w-full" [label]="'common.buttons.back' | translate" severity="info" - (click)="backButtonClicked()" + (onClick)="backButtonClicked()" /> {{ 'preprints.preprintStepper.authorAssertions.publicPreregistration.title' tooltipPosition="top" [disabled]="authorAssertionsForm.invalid" [loading]="isUpdatingPreprint()" - (click)="nextButtonClicked()" + (onClick)="nextButtonClicked()" /> diff --git a/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.spec.ts b/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.spec.ts index bba0d23d6..7fb094c34 100644 --- a/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.spec.ts +++ b/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.spec.ts @@ -1,10 +1,15 @@ -import { MockComponents, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockComponents, MockDirective, MockProvider } from 'ng-mocks'; + +import { Textarea } from 'primeng/textarea'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl } from '@angular/forms'; -import { ApplicabilityStatus } from '@osf/features/preprints/enums'; +import { ApplicabilityStatus, PreregLinkInfo } from '@osf/features/preprints/enums'; import { PreprintModel } from '@osf/features/preprints/models'; -import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; +import { PreprintStepperSelectors, UpdatePreprint } from '@osf/features/preprints/store/preprint-stepper'; import { FormSelectComponent } from '@osf/shared/components/form-select/form-select.component'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ToastService } from '@osf/shared/services/toast.service'; @@ -13,75 +18,275 @@ import { ArrayInputComponent } from './array-input/array-input.component'; import { AuthorAssertionsStepComponent } from './author-assertions-step.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 { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('AuthorAssertionsStepComponent', () => { let component: AuthorAssertionsStepComponent; let fixture: ComponentFixture; - let toastServiceMock: ReturnType; - let customConfirmationServiceMock: ReturnType; + let store: Store; + let toastServiceMock: ToastServiceMockType; + let customConfirmationServiceMock: CustomConfirmationServiceMockType; const mockPreprint: PreprintModel = PREPRINT_MOCK; - beforeEach(async () => { - toastServiceMock = ToastServiceMockBuilder.create().build(); - customConfirmationServiceMock = CustomConfirmationServiceMockBuilder.create().build(); + const populatedPreprint: PreprintModel = { + ...mockPreprint, + hasCoi: true, + coiStatement: 'Author is a board member of the funder.', + hasDataLinks: ApplicabilityStatus.Applicable, + dataLinks: ['https://data.example/ds1', 'https://data.example/ds2'], + whyNoData: null, + hasPreregLinks: ApplicabilityStatus.Applicable, + preregLinks: ['https://prereg.example/reg1'], + whyNoPrereg: null, + preregLinkInfo: PreregLinkInfo.Both, + }; + + const cleanPreprint: PreprintModel = { + ...mockPreprint, + hasCoi: false, + coiStatement: null, + hasDataLinks: ApplicabilityStatus.NotApplicable, + dataLinks: [], + whyNoData: null, + hasPreregLinks: ApplicabilityStatus.NotApplicable, + preregLinks: [], + whyNoPrereg: null, + preregLinkInfo: null, + }; + + const defaultSignals: SignalOverride[] = [ + { selector: PreprintStepperSelectors.getPreprint, value: mockPreprint }, + { selector: PreprintStepperSelectors.isPreprintSubmitting, value: false }, + ]; + + function setup(overrides?: { selectorOverrides?: SignalOverride[]; detectChanges?: boolean }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + toastServiceMock = ToastServiceMock.simple(); + customConfirmationServiceMock = CustomConfirmationServiceMock.simple(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ AuthorAssertionsStepComponent, - OSFTestingModule, - MockComponents(ArrayInputComponent, FormSelectComponent), + ...MockComponents(ArrayInputComponent, FormSelectComponent), + MockDirective(Textarea), ], providers: [ - TranslationServiceMock, + provideOSFCore(), MockProvider(ToastService, toastServiceMock), MockProvider(CustomConfirmationService, customConfirmationServiceMock), - provideMockStore({ - signals: [ - { - selector: PreprintStepperSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintStepperSelectors.isPreprintSubmitting, - value: false, - }, - ], - }), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(AuthorAssertionsStepComponent); component = fixture.componentInstance; - }); + if (overrides?.detectChanges ?? false) { + fixture.detectChanges(); + } + } + + it('should create and initialize form with preprint defaults', () => { + setup(); - it('should create', () => { expect(component).toBeTruthy(); + expect(component.authorAssertionsForm.controls.hasCoi.value).toBe(false); + expect(component.authorAssertionsForm.controls.coiStatement.value).toBeNull(); + expect(component.authorAssertionsForm.controls.hasDataLinks.value).toBe(ApplicabilityStatus.NotApplicable); + expect(component.authorAssertionsForm.controls.hasPreregLinks.value).toBe(ApplicabilityStatus.NotApplicable); + expect(component.hasCoiValue()).toBe(false); + expect(component.hasDataLinks()).toBe(ApplicabilityStatus.NotApplicable); + expect(component.hasPreregLinks()).toBe(ApplicabilityStatus.NotApplicable); + }); + + it('should hydrate form from a preprint that has real data', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: populatedPreprint }], + }); + + const controls = component.authorAssertionsForm.controls; + expect(controls.hasCoi.value).toBe(true); + expect(controls.coiStatement.value).toBe('Author is a board member of the funder.'); + expect(controls.hasDataLinks.value).toBe(ApplicabilityStatus.Applicable); + expect(controls.dataLinks.length).toBe(2); + expect(controls.hasPreregLinks.value).toBe(ApplicabilityStatus.Applicable); + expect(controls.preregLinks.length).toBe(1); + expect(controls.preregLinkInfo.value).toBe(PreregLinkInfo.Both); + }); + + it('should enable coiStatement control when hasCoi becomes true', () => { + setup({ detectChanges: true }); + component.authorAssertionsForm.controls.hasCoi.setValue(true); + fixture.detectChanges(); + + expect(component.authorAssertionsForm.controls.coiStatement.enabled).toBe(true); }); - it('should initialize form with preprint data', () => { - expect(component.authorAssertionsForm.get('hasCoi')?.value).toBe(false); - expect(component.authorAssertionsForm.get('coiStatement')?.value).toBeNull(); - expect(component.authorAssertionsForm.get('hasDataLinks')?.value).toBe(ApplicabilityStatus.NotApplicable); - expect(component.authorAssertionsForm.get('hasPreregLinks')?.value).toBe(ApplicabilityStatus.NotApplicable); + it('should disable and clear coiStatement control when hasCoi becomes false', () => { + setup({ detectChanges: true }); + const { hasCoi, coiStatement } = component.authorAssertionsForm.controls; + hasCoi.setValue(true); + coiStatement.setValue('Some statement'); + fixture.detectChanges(); + + hasCoi.setValue(false); + fixture.detectChanges(); + + expect(coiStatement.value).toBeNull(); + expect(coiStatement.disabled).toBe(true); + }); + + it('should enable whyNoData and clear dataLinks when hasDataLinks is Unavailable', () => { + setup({ detectChanges: true }); + const { hasDataLinks, whyNoData, dataLinks } = component.authorAssertionsForm.controls; + dataLinks.push(new FormControl('https://existing.example')); + + hasDataLinks.setValue(ApplicabilityStatus.Unavailable); + fixture.detectChanges(); + + expect(whyNoData.enabled).toBe(true); + expect(dataLinks.length).toBe(0); + }); + + it('should add an empty dataLinks entry and clear whyNoData when hasDataLinks is Applicable', () => { + setup({ detectChanges: true }); + const { hasDataLinks, whyNoData, dataLinks } = component.authorAssertionsForm.controls; + hasDataLinks.setValue(ApplicabilityStatus.Unavailable); + whyNoData.setValue('No data available'); + fixture.detectChanges(); + + hasDataLinks.setValue(ApplicabilityStatus.Applicable); + fixture.detectChanges(); + + expect(dataLinks.length).toBe(1); + expect(whyNoData.value).toBeNull(); + }); + + it('should enable whyNoPrereg and clear preregLinks/preregLinkInfo when hasPreregLinks is Unavailable', () => { + setup({ detectChanges: true }); + const { hasPreregLinks, whyNoPrereg, preregLinkInfo, preregLinks } = component.authorAssertionsForm.controls; + preregLinks.push(new FormControl('https://existing.example')); + preregLinkInfo.setValue(PreregLinkInfo.Both); + + hasPreregLinks.setValue(ApplicabilityStatus.Unavailable); + fixture.detectChanges(); + + expect(whyNoPrereg.enabled).toBe(true); + expect(preregLinks.length).toBe(0); + expect(preregLinkInfo.value).toBeNull(); + }); + + it('should add an empty preregLinks entry and enable preregLinkInfo when hasPreregLinks is Applicable', () => { + setup({ detectChanges: true }); + const { hasPreregLinks, preregLinks, preregLinkInfo } = component.authorAssertionsForm.controls; + hasPreregLinks.setValue(ApplicabilityStatus.Unavailable); + fixture.detectChanges(); + + hasPreregLinks.setValue(ApplicabilityStatus.Applicable); + fixture.detectChanges(); + + expect(preregLinks.length).toBe(1); + expect(preregLinkInfo.enabled).toBe(true); }); - it('should emit nextClicked when nextButtonClicked is called', () => { + it('should return early in nextButtonClicked when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + }); const emitSpy = jest.spyOn(component.nextClicked, 'emit'); + (store.dispatch as jest.Mock).mockClear(); + + component.nextButtonClicked(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdatePreprint)); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should dispatch UpdatePreprint, show success toast, and emit next on valid submission', () => { + setup(); + const emitSpy = jest.spyOn(component.nextClicked, 'emit'); + component.authorAssertionsForm.patchValue({ + hasCoi: true, + coiStatement: 'COI', + hasDataLinks: ApplicabilityStatus.Applicable, + hasPreregLinks: ApplicabilityStatus.Applicable, + preregLinkInfo: PreregLinkInfo.Both, + }); + component.authorAssertionsForm.controls.dataLinks.push(new FormControl('https://data.example')); + component.authorAssertionsForm.controls.preregLinks.push(new FormControl('https://prereg.example')); + (store.dispatch as jest.Mock).mockClear(); + component.nextButtonClicked(); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdatePreprint(mockPreprint.id, { + hasCoi: true, + coiStatement: 'COI', + hasDataLinks: ApplicabilityStatus.Applicable, + whyNoData: null, + dataLinks: ['https://data.example'], + hasPreregLinks: ApplicabilityStatus.Applicable, + whyNoPrereg: null, + preregLinks: ['https://prereg.example'], + preregLinkInfo: PreregLinkInfo.Both, + }) + ); expect(toastServiceMock.showSuccess).toHaveBeenCalledWith( 'preprints.preprintStepper.common.successMessages.preprintSaved' ); expect(emitSpy).toHaveBeenCalled(); }); - it('should show confirmation dialog when backButtonClicked is called with changes', () => { + it('should omit preregLinkInfo from the UpdatePreprint payload when it is empty', () => { + setup(); + component.authorAssertionsForm.patchValue({ + hasPreregLinks: ApplicabilityStatus.Applicable, + preregLinkInfo: null, + }); + component.authorAssertionsForm.controls.preregLinks.push(new FormControl('https://prereg.example')); + (store.dispatch as jest.Mock).mockClear(); + + component.nextButtonClicked(); + + expect(store.dispatch).toHaveBeenCalledWith( + new UpdatePreprint(mockPreprint.id, expect.objectContaining({ preregLinkInfo: undefined })) + ); + }); + + it('should return early in backButtonClicked when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + }); + const emitSpy = jest.spyOn(component.backClicked, 'emit'); + + component.backButtonClicked(); + + expect(customConfirmationServiceMock.confirmContinue).not.toHaveBeenCalled(); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should emit back immediately when there are no unsaved changes', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: cleanPreprint }], + }); + const emitSpy = jest.spyOn(component.backClicked, 'emit'); + + component.backButtonClicked(); + + expect(customConfirmationServiceMock.confirmContinue).not.toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should handle discard confirmation callbacks when there are unsaved changes', () => { + setup(); + const emitSpy = jest.spyOn(component.backClicked, 'emit'); component.authorAssertionsForm.patchValue({ hasCoi: true }); component.backButtonClicked(); @@ -92,20 +297,13 @@ describe('AuthorAssertionsStepComponent', () => { onConfirm: expect.any(Function), onReject: expect.any(Function), }); - }); - it('should expose readonly properties', () => { - expect(component.CustomValidators).toBeDefined(); - expect(component.ApplicabilityStatus).toBe(ApplicabilityStatus); - expect(component.inputLimits).toBeDefined(); - expect(component.INPUT_VALIDATION_MESSAGES).toBeDefined(); - expect(component.preregLinkOptions).toBeDefined(); - expect(component.linkValidators).toBeDefined(); - }); + const { onReject } = customConfirmationServiceMock.confirmContinue.mock.calls[0][0]; + onReject(); + expect(emitSpy).not.toHaveBeenCalled(); - it('should have correct signal values', () => { - expect(component.hasCoiValue()).toBe(false); - expect(component.hasDataLinks()).toBe(ApplicabilityStatus.NotApplicable); - expect(component.hasPreregLinks()).toBe(ApplicabilityStatus.NotApplicable); + const { onConfirm } = customConfirmationServiceMock.confirmContinue.mock.calls[0][0]; + onConfirm(); + expect(emitSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.ts b/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.ts index 97ca4a946..e8838344a 100644 --- a/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.ts +++ b/src/app/features/preprints/components/stepper/author-assertion-step/author-assertions-step.component.ts @@ -40,89 +40,88 @@ import { ArrayInputComponent } from './array-input/array-input.component'; @Component({ selector: 'osf-author-assertions-step', imports: [ + Button, Card, - FormsModule, + Message, RadioButton, - ReactiveFormsModule, Textarea, - Message, - TranslatePipe, - NgClass, - Button, Tooltip, + NgClass, + FormsModule, + ReactiveFormsModule, ArrayInputComponent, FormSelectComponent, + TranslatePipe, ], templateUrl: './author-assertions-step.component.html', styleUrl: './author-assertions-step.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class AuthorAssertionsStepComponent { - private toastService = inject(ToastService); - private confirmationService = inject(CustomConfirmationService); - private actions = createDispatchMap({ updatePreprint: UpdatePreprint }); + private readonly toastService = inject(ToastService); + private readonly confirmationService = inject(CustomConfirmationService); + private readonly actions = createDispatchMap({ updatePreprint: UpdatePreprint }); - readonly CustomValidators = CustomValidators; readonly ApplicabilityStatus = ApplicabilityStatus; readonly inputLimits = formInputLimits; readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; readonly preregLinkOptions = preregLinksOptions; readonly linkValidators = [CustomValidators.linkValidator(), CustomValidators.requiredTrimmed()]; - createdPreprint = select(PreprintStepperSelectors.getPreprint); - isUpdatingPreprint = select(PreprintStepperSelectors.isPreprintSubmitting); + readonly createdPreprint = select(PreprintStepperSelectors.getPreprint); + readonly isUpdatingPreprint = select(PreprintStepperSelectors.isPreprintSubmitting); readonly authorAssertionsForm = new FormGroup({ - hasCoi: new FormControl(this.createdPreprint()!.hasCoi || false, { + hasCoi: new FormControl(this.createdPreprint()?.hasCoi ?? false, { nonNullable: true, validators: [], }), - coiStatement: new FormControl(this.createdPreprint()!.coiStatement, { + coiStatement: new FormControl(this.createdPreprint()?.coiStatement ?? null, { nonNullable: false, validators: [], }), hasDataLinks: new FormControl( - this.createdPreprint()!.hasDataLinks || ApplicabilityStatus.NotApplicable, + this.createdPreprint()?.hasDataLinks ?? ApplicabilityStatus.NotApplicable, { nonNullable: true, validators: [], } ), dataLinks: new FormArray( - this.createdPreprint()!.dataLinks?.map((link) => new FormControl(link)) || [] + this.createdPreprint()?.dataLinks?.map((link) => new FormControl(link)) || [] ), - whyNoData: new FormControl(this.createdPreprint()!.whyNoData, { + whyNoData: new FormControl(this.createdPreprint()?.whyNoData ?? null, { nonNullable: false, validators: [], }), hasPreregLinks: new FormControl( - this.createdPreprint()!.hasPreregLinks || ApplicabilityStatus.NotApplicable, + this.createdPreprint()?.hasPreregLinks ?? ApplicabilityStatus.NotApplicable, { nonNullable: true, validators: [], } ), preregLinks: new FormArray( - this.createdPreprint()!.preregLinks?.map((link) => new FormControl(link)) || [] + this.createdPreprint()?.preregLinks?.map((link) => new FormControl(link)) || [] ), - whyNoPrereg: new FormControl(this.createdPreprint()!.whyNoPrereg, { + whyNoPrereg: new FormControl(this.createdPreprint()?.whyNoPrereg ?? null, { nonNullable: false, validators: [], }), - preregLinkInfo: new FormControl(this.createdPreprint()!.preregLinkInfo, { + preregLinkInfo: new FormControl(this.createdPreprint()?.preregLinkInfo ?? null, { nonNullable: false, validators: [], }), }); hasCoiValue = toSignal(this.authorAssertionsForm.controls['hasCoi'].valueChanges, { - initialValue: this.createdPreprint()!.hasCoi || false, + initialValue: this.createdPreprint()?.hasCoi ?? false, }); hasDataLinks = toSignal(this.authorAssertionsForm.controls['hasDataLinks'].valueChanges, { - initialValue: this.createdPreprint()!.hasDataLinks || ApplicabilityStatus.NotApplicable, + initialValue: this.createdPreprint()?.hasDataLinks ?? ApplicabilityStatus.NotApplicable, }); hasPreregLinks = toSignal(this.authorAssertionsForm.controls['hasPreregLinks'].valueChanges, { - initialValue: this.createdPreprint()!.hasPreregLinks || ApplicabilityStatus.NotApplicable, + initialValue: this.createdPreprint()?.hasPreregLinks ?? ApplicabilityStatus.NotApplicable, }); nextClicked = output(); @@ -194,7 +193,13 @@ export class AuthorAssertionsStepComponent { }); } - nextButtonClicked() { + nextButtonClicked(): void { + const preprintId = this.createdPreprint()?.id; + + if (!preprintId) { + return; + } + const formValue = this.authorAssertionsForm.getRawValue(); const hasCoi = formValue.hasCoi; @@ -210,7 +215,7 @@ export class AuthorAssertionsStepComponent { const preregLinkInfo = formValue.preregLinkInfo || undefined; this.actions - .updatePreprint(this.createdPreprint()!.id, { + .updatePreprint(preprintId, { hasCoi, coiStatement, hasDataLinks, @@ -229,9 +234,15 @@ export class AuthorAssertionsStepComponent { }); } - backButtonClicked() { + backButtonClicked(): void { + const preprint = this.createdPreprint(); + + if (!preprint) { + return; + } + const formValue = this.authorAssertionsForm.getRawValue(); - const changedFields = findChangedFields(formValue, this.createdPreprint()!); + const changedFields = findChangedFields(formValue, preprint); if (!Object.keys(changedFields).length) { this.backClicked.emit(); @@ -248,7 +259,7 @@ export class AuthorAssertionsStepComponent { }); } - private disableAndClearValidators(control: AbstractControl) { + private disableAndClearValidators(control: AbstractControl): void { if (control instanceof FormArray) { while (control.length !== 0) { control.removeAt(0); @@ -261,12 +272,12 @@ export class AuthorAssertionsStepComponent { control.disable(); } - private enableAndSetValidators(control: AbstractControl, validators: ValidatorFn[]) { + private enableAndSetValidators(control: AbstractControl, validators: ValidatorFn[]): void { control.setValidators(validators); control.enable(); } - private addAtLeastOneControl(formArray: FormArray) { + private addAtLeastOneControl(formArray: FormArray): void { if (formArray.controls.length > 0) return; formArray.push( diff --git a/src/app/features/preprints/components/stepper/file-step/file-step.component.html b/src/app/features/preprints/components/stepper/file-step/file-step.component.html index 8a95bdcec..ee8b37860 100644 --- a/src/app/features/preprints/components/stepper/file-step/file-step.component.html +++ b/src/app/features/preprints/components/stepper/file-step/file-step.component.html @@ -4,7 +4,7 @@

{{ 'preprints.preprintStepper.file.title' | translate }}

{{ 'preprints.preprintStepper.file.uploadDescription' - | translate: { preprintWord: provider()?.preprintWord | titlecase } + | translate: { preprintWord: provider().preprintWord | titlecase } }}

{{ 'preprints.preprintStepper.file.note' | translate }}

@@ -56,7 +56,7 @@

{{ 'preprints.preprintStepper.file.title' | translate }}

(onClick)="fileInput.click()" /> - + } } @@ -64,25 +64,27 @@

{{ 'preprints.preprintStepper.file.title' | translate }}

@if (selectedFileSource() === PreprintFileSource.Project && !preprintFile() && !isPreprintFileLoading()) {
-

{{ 'preprints.preprintStepper.file.projectSelection.description' | translate }}

- {{ 'preprints.preprintStepper.file.projectSelection.subDescription' | translate }} + {{ 'preprints.preprintStepper.projectSelection.description' | translate }} +

+

+ {{ 'preprints.preprintStepper.projectSelection.subDescription' | translate }}

@@ -154,11 +156,9 @@

{{ 'preprints.preprintStepper.file.title' | translate }}

class="w-6 md:w-9rem" styleClass="w-full" [label]="'common.buttons.next' | translate" - [disabled]="!preprintFile() || versionFileMode()" + [disabled]="!canProceedToNext()" [pTooltip]=" - !preprintFile() || versionFileMode() - ? ('preprints.preprintStepper.common.validation.fillRequiredFields' | translate) - : '' + !canProceedToNext() ? ('preprints.preprintStepper.common.validation.fillRequiredFields' | translate) : '' " tooltipPosition="top" (onClick)="nextButtonClicked()" diff --git a/src/app/features/preprints/components/stepper/file-step/file-step.component.spec.ts b/src/app/features/preprints/components/stepper/file-step/file-step.component.spec.ts index 1471c9a39..37d2b0841 100644 --- a/src/app/features/preprints/components/stepper/file-step/file-step.component.spec.ts +++ b/src/app/features/preprints/components/stepper/file-step/file-step.component.spec.ts @@ -1,120 +1,197 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SelectChangeEvent } from 'primeng/select'; + +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { PreprintFileSource } from '@osf/features/preprints/enums'; import { PreprintModel, PreprintProviderDetails } from '@osf/features/preprints/models'; -import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; +import { + CopyFileFromProject, + FetchAvailableProjects, + FetchPreprintFilesLinks, + FetchPreprintPrimaryFile, + FetchProjectFilesByLink, + PreprintStepperSelectors, + ReuploadFile, + SetPreprintStepperCurrentFolder, + SetProjectRootFolder, + SetSelectedPreprintFileSource, + UploadFile, +} from '@osf/features/preprints/store/preprint-stepper'; import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; +import { FileModel } from '@osf/shared/models/files/file.model'; +import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { FileFolderModel } from '@shared/models/files/file-folder.model'; import { FileStepComponent } from './file-step.component'; import { OSF_FILE_MOCK } from '@testing/mocks/osf-file.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 { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('FileStepComponent', () => { let component: FileStepComponent; let fixture: ComponentFixture; - let toastServiceMock: ReturnType; - let confirmationServiceMock: ReturnType; + let store: Store; + let toastServiceMock: ToastServiceMockType; + let confirmationServiceMock: CustomConfirmationServiceMockType; + const originalPointerEvent = (globalThis as unknown as { PointerEvent?: typeof Event }).PointerEvent; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; const mockPreprint: PreprintModel = PREPRINT_MOCK; const mockProjectFiles: FileFolderModel[] = [OSF_FILE_MOCK]; const mockPreprintFile: FileFolderModel = OSF_FILE_MOCK; - + const mockCurrentFolder: FileFolderModel = OSF_FILE_MOCK; const mockAvailableProjects = [ - { id: 'project-1', title: 'Test Project 1' }, - { id: 'project-2', title: 'Test Project 2' }, + { id: 'project-1', name: 'Test Project 1' }, + { id: 'project-2', name: 'Test Project 2' }, ]; - beforeEach(async () => { - toastServiceMock = ToastServiceMockBuilder.create().build(); - confirmationServiceMock = CustomConfirmationServiceMockBuilder.create().build(); + const defaultSignals: SignalOverride[] = [ + { selector: PreprintStepperSelectors.getPreprint, value: mockPreprint }, + { selector: PreprintStepperSelectors.getSelectedFileSource, value: PreprintFileSource.None }, + { selector: PreprintStepperSelectors.getUploadLink, value: 'upload-link' }, + { selector: PreprintStepperSelectors.getPreprintFile, value: mockPreprintFile }, + { selector: PreprintStepperSelectors.isPreprintFilesLoading, value: false }, + { selector: PreprintStepperSelectors.getAvailableProjects, value: mockAvailableProjects }, + { selector: PreprintStepperSelectors.areAvailableProjectsLoading, value: false }, + { selector: PreprintStepperSelectors.getProjectFiles, value: mockProjectFiles }, + { selector: PreprintStepperSelectors.getFilesTotalCount, value: 1 }, + { selector: PreprintStepperSelectors.areProjectFilesLoading, value: false }, + { selector: PreprintStepperSelectors.getCurrentFolder, value: mockCurrentFolder }, + { selector: PreprintStepperSelectors.isCurrentFolderLoading, value: false }, + ]; - await TestBed.configureTestingModule({ - imports: [FileStepComponent, ...MockComponents(IconComponent, FilesTreeComponent), OSFTestingModule], + function setup(overrides?: { + selectorOverrides?: SignalOverride[]; + provider?: PreprintProviderDetails; + detectChanges?: boolean; + }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + toastServiceMock = ToastServiceMock.simple(); + confirmationServiceMock = CustomConfirmationServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [FileStepComponent, ...MockComponents(IconComponent, FilesTreeComponent)], providers: [ + provideOSFCore(), MockProvider(ToastService, toastServiceMock), MockProvider(CustomConfirmationService, confirmationServiceMock), - provideMockStore({ - signals: [ - { - selector: PreprintStepperSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintStepperSelectors.getSelectedProviderId, - value: 'provider-1', - }, - { - selector: PreprintStepperSelectors.getSelectedFileSource, - value: PreprintFileSource.None, - }, - { - selector: PreprintStepperSelectors.getUploadLink, - value: 'upload-link', - }, - { - selector: PreprintStepperSelectors.getPreprintFile, - value: mockPreprintFile, - }, - { - selector: PreprintStepperSelectors.isPreprintFilesLoading, - value: false, - }, - { - selector: PreprintStepperSelectors.getAvailableProjects, - value: mockAvailableProjects, - }, - { - selector: PreprintStepperSelectors.areAvailableProjectsLoading, - value: false, - }, - { - selector: PreprintStepperSelectors.getProjectFiles, - value: mockProjectFiles, - }, - { - selector: PreprintStepperSelectors.areProjectFilesLoading, - value: false, - }, - { - selector: PreprintStepperSelectors.getCurrentFolder, - value: null, - }, - ], - }), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(FileStepComponent); component = fixture.componentInstance; - fixture.componentRef.setInput('provider', mockProvider); - fixture.detectChanges(); + fixture.componentRef.setInput('provider', overrides?.provider ?? mockProvider); + if (overrides?.detectChanges ?? true) { + fixture.detectChanges(); + } + } + + beforeAll(() => { + if (!(globalThis as unknown as { PointerEvent?: typeof Event }).PointerEvent) { + (globalThis as unknown as { PointerEvent: typeof Event }).PointerEvent = MouseEvent as unknown as typeof Event; + } + }); + + afterAll(() => { + if (originalPointerEvent) { + (globalThis as unknown as { PointerEvent: typeof Event }).PointerEvent = originalPointerEvent; + } else { + delete (globalThis as unknown as { PointerEvent?: typeof Event }).PointerEvent; + } }); it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should initialize with correct values', () => { - expect(component.provider()).toBe(mockProvider); - expect(component.preprint()).toBe(mockPreprint); - expect(component.selectedFileSource()).toBe(PreprintFileSource.None); - expect(component.preprintFile()).toBe(mockPreprintFile); + it('should compute state values', () => { + setup({ detectChanges: false }); + expect(component.preprintHasPrimaryFile()).toBe(true); + expect(component.isFileSourceSelected()).toBe(false); + expect(component.canProceedToNext()).toBe(true); + expect(component.cancelSourceOptionButtonVisible()).toBe(false); + }); + + it('should dispatch fetch links and primary file fetch in ngOnInit', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprintFile, value: null }], + detectChanges: false, + }); + + component.ngOnInit(); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintFilesLinks()); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintPrimaryFile()); + }); + + it('should not dispatch primary file fetch in ngOnInit without primary file id', () => { + setup({ + selectorOverrides: [ + { selector: PreprintStepperSelectors.getPreprint, value: { ...mockPreprint, primaryFileId: null } }, + ], + detectChanges: false, + }); + + component.ngOnInit(); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintFilesLinks()); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchPreprintPrimaryFile)); + }); + + it('should dispatch available projects from debounced projectNameControl value', fakeAsync(() => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + + component.projectNameControl.setValue('project-search'); + tick(500); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchAvailableProjects('project-search')); + })); + + it('should skip available projects dispatch when value equals selectedProjectId', fakeAsync(() => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + component.selectedProjectId.set('project-1'); + + component.projectNameControl.setValue('project-1'); + tick(500); + + expect(store.dispatch).not.toHaveBeenCalledWith(new FetchAvailableProjects('project-1')); + })); + + it('should handle selectFileSource for project and computer source', () => { + setup({ detectChanges: false }); + + component.selectFileSource(PreprintFileSource.Project); + expect(store.dispatch).toHaveBeenCalledWith(new SetSelectedPreprintFileSource(PreprintFileSource.Project)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchAvailableProjects(null)); + + (store.dispatch as jest.Mock).mockClear(); + component.selectFileSource(PreprintFileSource.Computer); + + expect(store.dispatch).toHaveBeenCalledWith(new SetSelectedPreprintFileSource(PreprintFileSource.Computer)); + expect(store.dispatch).not.toHaveBeenCalledWith(new FetchAvailableProjects(null)); }); - it('should emit backClicked when backButtonClicked is called', () => { + it('should emit backClicked', () => { + setup({ detectChanges: false }); const emitSpy = jest.spyOn(component.backClicked, 'emit'); component.backButtonClicked(); @@ -122,7 +199,8 @@ describe('FileStepComponent', () => { expect(emitSpy).toHaveBeenCalled(); }); - it('should emit nextClicked when nextButtonClicked is called with primary file', () => { + it('should handle nextButtonClicked for allowed and blocked states', () => { + setup({ detectChanges: false }); const emitSpy = jest.spyOn(component.nextClicked, 'emit'); component.nextButtonClicked(); @@ -130,82 +208,158 @@ describe('FileStepComponent', () => { expect(toastServiceMock.showSuccess).toHaveBeenCalledWith( 'preprints.preprintStepper.common.successMessages.preprintSaved' ); - expect(emitSpy).toHaveBeenCalled(); - }); + expect(emitSpy).toHaveBeenCalledTimes(1); - it('should not emit nextClicked when nextButtonClicked is called without primary file', () => { - const emitSpy = jest.spyOn(component.nextClicked, 'emit'); - jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, primaryFileId: null }); + component.versionFileMode.set(true); + toastServiceMock.showSuccess.mockClear(); component.nextButtonClicked(); - expect(emitSpy).not.toHaveBeenCalled(); + expect(toastServiceMock.showSuccess).not.toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledTimes(1); + + jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, primaryFileId: null } as PreprintModel); + component.versionFileMode.set(false); + + component.nextButtonClicked(); + + expect(toastServiceMock.showSuccess).not.toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledTimes(1); }); - it('should handle file selection for upload', () => { - const mockFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); - const mockEvent = { - target: { - files: [mockFile], - }, - } as any; + it('should skip file upload dispatches when no file is selected', () => { + setup({ detectChanges: false }); - component.onFileSelected(mockEvent); + const input = document.createElement('input'); + Object.defineProperty(input, 'files', { value: [] }); + component.onFileSelected({ target: input } as unknown as Event); - expect(mockFile).toBeDefined(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UploadFile)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(ReuploadFile)); }); - it('should handle file selection for reupload', () => { - component.versionFileMode.set(true); + it('should handle upload and reupload flows in onFileSelected', () => { + setup({ detectChanges: false }); + const file = new File(['file-body'], 'test.txt'); + const input = document.createElement('input'); + Object.defineProperty(input, 'files', { value: [file] }); - const mockFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); - const mockEvent = { - target: { - files: [mockFile], - }, - } as any; + component.onFileSelected({ target: input } as unknown as Event); + expect(store.dispatch).toHaveBeenCalledWith(new UploadFile(file)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintPrimaryFile()); - component.onFileSelected(mockEvent); + (store.dispatch as jest.Mock).mockClear(); + component.versionFileMode.set(true); + component.onFileSelected({ target: input } as unknown as Event); + + expect(store.dispatch).toHaveBeenCalledWith(new ReuploadFile(file)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintPrimaryFile()); expect(component.versionFileMode()).toBe(false); }); - it('should handle version file confirmation', () => { - confirmationServiceMock.confirmContinue.mockImplementation(({ onConfirm }) => { - onConfirm(); - }); + it('should handle selectProject with and without current folder files link', () => { + setup({ detectChanges: false }); + + component.selectProject({ + value: 'project-1', + originalEvent: new PointerEvent('click'), + } as SelectChangeEvent); + + expect(store.dispatch).toHaveBeenCalledWith(new SetProjectRootFolder('project-1')); + expect(store.dispatch).toHaveBeenCalledWith(new FetchProjectFilesByLink(mockCurrentFolder.links.filesLink, 1)); + expect(component.selectedProjectId()).toBe('project-1'); + + jest.spyOn(component, 'currentFolder').mockReturnValue(null); + (store.dispatch as jest.Mock).mockClear(); + + component.selectProject({ + value: 'project-1', + originalEvent: new PointerEvent('click'), + } as SelectChangeEvent); + + expect(store.dispatch).toHaveBeenCalledWith(new SetProjectRootFolder('project-1')); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchProjectFilesByLink)); + }); + + it('should return early in selectProject when original event is not pointer event', () => { + setup({ detectChanges: false }); + + component.selectProject({ + value: 'project-1', + originalEvent: new Event('change'), + } as SelectChangeEvent); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SetProjectRootFolder)); + }); + + it('should dispatch copy file from project and preprint file fetch', () => { + setup({ detectChanges: false }); + const projectFile = OSF_FILE_MOCK as unknown as FileModel; + + component.selectProjectFile(projectFile); + + expect(store.dispatch).toHaveBeenCalledWith(new CopyFileFromProject(projectFile)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintPrimaryFile()); + }); + + it('should set version mode and reset selected source on version file confirmation', () => { + setup({ detectChanges: false }); component.versionFile(); + const options = confirmationServiceMock.confirmContinue.mock.calls[0][0]; + options.onConfirm(); - expect(confirmationServiceMock.confirmContinue).toHaveBeenCalledWith({ - headerKey: 'preprints.preprintStepper.file.versionFile.header', - messageKey: 'preprints.preprintStepper.file.versionFile.message', - onConfirm: expect.any(Function), - onReject: expect.any(Function), - }); expect(component.versionFileMode()).toBe(true); + expect(store.dispatch).toHaveBeenCalledWith(new SetSelectedPreprintFileSource(PreprintFileSource.None)); }); - it('should handle cancel button click', () => { - jest.spyOn(component, 'preprintFile').mockReturnValue(null); + it('should not change mode or selected source on version file reject', () => { + setup({ detectChanges: false }); - component.cancelButtonClicked(); + component.versionFile(); + const options = confirmationServiceMock.confirmContinue.mock.calls[0][0]; + (store.dispatch as jest.Mock).mockClear(); + options.onReject(); - expect(component.preprintFile()).toBeNull(); + expect(component.versionFileMode()).toBe(false); + expect(store.dispatch).not.toHaveBeenCalledWith(new SetSelectedPreprintFileSource(PreprintFileSource.None)); }); - it('should not handle cancel button click when preprint file exists', () => { + it('should handle cancelButtonClicked for file present and file missing states', () => { + setup({ detectChanges: false }); + component.cancelButtonClicked(); + expect(store.dispatch).not.toHaveBeenCalledWith(new SetSelectedPreprintFileSource(PreprintFileSource.None)); - expect(component.preprintFile()).toBeDefined(); + jest.spyOn(component, 'preprintFile').mockReturnValue(null); + (store.dispatch as jest.Mock).mockClear(); + + component.cancelButtonClicked(); + expect(store.dispatch).toHaveBeenCalledWith(new SetSelectedPreprintFileSource(PreprintFileSource.None)); }); - it('should expose readonly properties', () => { - expect(component.PreprintFileSource).toBe(PreprintFileSource); + it('should handle setCurrentFolder for unchanged and changed folders', () => { + setup({ detectChanges: false }); + + component.setCurrentFolder(mockCurrentFolder); + expect(store.dispatch).not.toHaveBeenCalledWith(new SetPreprintStepperCurrentFolder(mockCurrentFolder)); + expect(store.dispatch).not.toHaveBeenCalledWith(new FetchProjectFilesByLink(mockCurrentFolder.links.filesLink, 1)); + + (store.dispatch as jest.Mock).mockClear(); + const nextFolder = { ...mockCurrentFolder, id: 'folder-2' } as FileFolderModel; + + component.setCurrentFolder(nextFolder); + + expect(store.dispatch).toHaveBeenCalledWith(new SetPreprintStepperCurrentFolder(nextFolder)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchProjectFilesByLink(nextFolder.links.filesLink, 1)); }); - it('should have correct form control', () => { - expect(component.projectNameControl).toBeDefined(); - expect(component.projectNameControl.value).toBeNull(); + it('should dispatch files load in onLoadFiles', () => { + setup({ detectChanges: false }); + + component.onLoadFiles({ link: '/v2/nodes/node-456/files/', page: 3 }); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchProjectFilesByLink('/v2/nodes/node-456/files/', 3)); }); }); diff --git a/src/app/features/preprints/components/stepper/file-step/file-step.component.ts b/src/app/features/preprints/components/stepper/file-step/file-step.component.ts index 9f556128a..b8f771b61 100644 --- a/src/app/features/preprints/components/stepper/file-step/file-step.component.ts +++ b/src/app/features/preprints/components/stepper/file-step/file-step.component.ts @@ -42,6 +42,7 @@ import { } from '@osf/features/preprints/store/preprint-stepper'; import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; +import { ClearFileDirective } from '@osf/shared/directives/clear-file.directive'; import { StringOrNull } from '@osf/shared/helpers/types.helper'; import { FileModel } from '@osf/shared/models/files/file.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; @@ -52,16 +53,17 @@ import { ToastService } from '@osf/shared/services/toast.service'; selector: 'osf-file-step', imports: [ Button, - TitleCasePipe, - NgClass, + Card, Tooltip, Skeleton, - IconComponent, - Card, Select, + NgClass, ReactiveFormsModule, + IconComponent, FilesTreeComponent, + TitleCasePipe, TranslatePipe, + ClearFileDirective, ], templateUrl: './file-step.component.html', styleUrl: './file-step.component.scss', @@ -70,6 +72,8 @@ import { ToastService } from '@osf/shared/services/toast.service'; export class FileStepComponent implements OnInit { private toastService = inject(ToastService); private customConfirmationService = inject(CustomConfirmationService); + private destroyRef = inject(DestroyRef); + private actions = createDispatchMap({ setSelectedFileSource: SetSelectedPreprintFileSource, getPreprintFilesLinks: FetchPreprintFilesLinks, @@ -82,13 +86,11 @@ export class FileStepComponent implements OnInit { copyFileFromProject: CopyFileFromProject, setCurrentFolder: SetPreprintStepperCurrentFolder, }); - private destroyRef = inject(DestroyRef); readonly PreprintFileSource = PreprintFileSource; - provider = input.required(); + provider = input.required(); preprint = select(PreprintStepperSelectors.getPreprint); - providerId = select(PreprintStepperSelectors.getSelectedProviderId); selectedFileSource = select(PreprintStepperSelectors.getSelectedFileSource); fileUploadLink = select(PreprintStepperSelectors.getUploadLink); @@ -120,12 +122,15 @@ export class FileStepComponent implements OnInit { backClicked = output(); isFileSourceSelected = computed(() => this.selectedFileSource() !== PreprintFileSource.None); + canProceedToNext = computed(() => !!this.preprintFile() && !this.versionFileMode()); ngOnInit() { this.actions.getPreprintFilesLinks(); + if (this.preprintHasPrimaryFile() && !this.preprintFile()) { this.actions.fetchPreprintFile(); } + this.projectNameControl.valueChanges .pipe(debounceTime(500), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) .subscribe((projectNameOrId) => { @@ -150,7 +155,7 @@ export class FileStepComponent implements OnInit { } nextButtonClicked() { - if (!this.preprint()?.primaryFileId) { + if (!this.canProceedToNext() || !this.preprint()?.primaryFileId) { return; } @@ -163,20 +168,14 @@ export class FileStepComponent implements OnInit { const file = input.files?.[0]; if (!file) return; - if (this.versionFileMode()) { + const isVersionFileMode = this.versionFileMode(); + + if (isVersionFileMode) { this.versionFileMode.set(false); - this.actions.reuploadFile(file).subscribe({ - next: () => { - this.actions.fetchPreprintFile(); - }, - }); - } else { - this.actions.uploadFile(file).subscribe({ - next: () => { - this.actions.fetchPreprintFile(); - }, - }); } + + const uploadAction = isVersionFileMode ? this.actions.reuploadFile(file) : this.actions.uploadFile(file); + uploadAction.subscribe(() => this.actions.fetchPreprintFile()); } selectProject(event: SelectChangeEvent) { @@ -185,6 +184,7 @@ export class FileStepComponent implements OnInit { } this.selectedProjectId.set(event.value); + this.actions .setProjectRootFolder(event.value) .pipe( @@ -201,11 +201,7 @@ export class FileStepComponent implements OnInit { } selectProjectFile(file: FileModel) { - this.actions.copyFileFromProject(file).subscribe({ - next: () => { - this.actions.fetchPreprintFile(); - }, - }); + this.actions.copyFileFromProject(file).subscribe(() => this.actions.fetchPreprintFile()); } versionFile() { @@ -232,6 +228,7 @@ export class FileStepComponent implements OnInit { if (this.currentFolder()?.id === folder.id) { return; } + this.actions.setCurrentFolder(folder); this.actions.getProjectFilesByLink(folder.links.filesLink, 1); } diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.html b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.html index 090082de5..8866ee45c 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.html +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.html @@ -6,16 +6,14 @@

{{ 'preprints.preprintStepper.metadata.affiliatedInstitutionsTitle' | transl class="mt-3" [innerHTML]=" 'preprints.preprintStepper.metadata.affiliatedInstitutionsDescription' - | translate: { preprintWord: provider()?.preprintWord } + | translate: { preprintWord: provider().preprintWord } " >

diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.spec.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.spec.ts index 8bb817170..048833d6b 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.spec.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.spec.ts @@ -1,128 +1,144 @@ +import { Store } from '@ngxs/store'; + import { MockComponent } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ReviewsState } from '@osf/features/preprints/enums'; -import { PreprintProviderDetails } from '@osf/features/preprints/models'; -import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; +import { PreprintModel, PreprintProviderDetails } from '@osf/features/preprints/models'; +import { PreprintStepperSelectors, SetInstitutionsChanged } from '@osf/features/preprints/store/preprint-stepper'; import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component'; -import { Institution } from '@shared/models/institutions/institutions.models'; -import { InstitutionsSelectors } from '@shared/stores/institutions'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; +import { + FetchResourceInstitutions, + FetchUserInstitutions, + InstitutionsSelectors, + UpdateResourceInstitutions, +} from '@shared/stores/institutions'; import { PreprintsAffiliatedInstitutionsComponent } from './preprints-affiliated-institutions.component'; 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 { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; describe('PreprintsAffiliatedInstitutionsComponent', () => { let component: PreprintsAffiliatedInstitutionsComponent; let fixture: ComponentFixture; + let store: Store; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; - const mockPreprint: any = { id: 'preprint-1', reviewsState: ReviewsState.Pending }; + const mockPreprint: PreprintModel = PREPRINT_MOCK; const mockUserInstitutions: Institution[] = [MOCK_INSTITUTION]; const mockResourceInstitutions: Institution[] = [MOCK_INSTITUTION]; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - PreprintsAffiliatedInstitutionsComponent, - OSFTestingModule, - MockComponent(AffiliatedInstitutionSelectComponent), - ], - providers: [ - provideMockStore({ - signals: [ - { - selector: InstitutionsSelectors.getUserInstitutions, - value: mockUserInstitutions, - }, - { - selector: InstitutionsSelectors.areUserInstitutionsLoading, - value: false, - }, - { - selector: InstitutionsSelectors.getResourceInstitutions, - value: mockResourceInstitutions, - }, - { - selector: InstitutionsSelectors.areResourceInstitutionsLoading, - value: false, - }, - { - selector: InstitutionsSelectors.areResourceInstitutionsSubmitting, - value: false, - }, - { - selector: PreprintStepperSelectors.getInstitutionsChanged, - value: false, - }, - ], - }), - ], - }).compileComponents(); - + const defaultSignals: SignalOverride[] = [ + { selector: InstitutionsSelectors.getUserInstitutions, value: mockUserInstitutions }, + { selector: InstitutionsSelectors.areUserInstitutionsLoading, value: false }, + { selector: InstitutionsSelectors.getResourceInstitutions, value: mockResourceInstitutions }, + { selector: InstitutionsSelectors.areResourceInstitutionsLoading, value: false }, + { selector: InstitutionsSelectors.areResourceInstitutionsSubmitting, value: false }, + { selector: PreprintStepperSelectors.getInstitutionsChanged, value: false }, + ]; + + function setup(overrides?: { + selectorOverrides?: SignalOverride[]; + preprint?: PreprintModel; + detectChanges?: boolean; + }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + + TestBed.configureTestingModule({ + imports: [PreprintsAffiliatedInstitutionsComponent, MockComponent(AffiliatedInstitutionSelectComponent)], + providers: [provideOSFCore(), provideMockStore({ signals })], + }); + + store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintsAffiliatedInstitutionsComponent); component = fixture.componentInstance; fixture.componentRef.setInput('provider', mockProvider); - fixture.componentRef.setInput('preprint', mockPreprint); - fixture.detectChanges(); - }); + fixture.componentRef.setInput('preprint', overrides?.preprint ?? mockPreprint); + if (overrides?.detectChanges ?? true) { + fixture.detectChanges(); + } + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should initialize with correct values', () => { - expect(component.provider()).toBe(mockProvider); - expect(component.preprint()!.id).toBe('preprint-1'); - expect(component.userInstitutions()).toBe(mockUserInstitutions); - expect(component.areUserInstitutionsLoading()).toBe(false); - expect(component.resourceInstitutions()).toBe(mockResourceInstitutions); - expect(component.areResourceInstitutionsLoading()).toBe(false); - expect(component.areResourceInstitutionsSubmitting()).toBe(false); + it('should compute loading state when any loading flag is true', () => { + setup({ + selectorOverrides: [{ selector: InstitutionsSelectors.areResourceInstitutionsSubmitting, value: true }], + }); + expect(component.isLoading()).toBe(true); }); - it('should initialize selectedInstitutions with resource institutions', () => { + it('should initialize selected institutions from resource institutions effect', () => { + setup(); expect(component.selectedInstitutions()).toEqual(mockResourceInstitutions); }); - it('should handle institutions change', () => { - const newInstitutions = [MOCK_INSTITUTION]; + it('should keep selected institutions empty when resource institutions are empty', () => { + setup({ + selectorOverrides: [{ selector: InstitutionsSelectors.getResourceInstitutions, value: [] }], + }); + expect(component.selectedInstitutions()).toEqual([]); + }); - component.onInstitutionsChange(newInstitutions); + it('should dispatch fetch actions on init lifecycle', () => { + setup(); - expect(component.selectedInstitutions()).toEqual(newInstitutions); + expect(store.dispatch).toHaveBeenCalledWith(new FetchUserInstitutions()); + expect(store.dispatch).toHaveBeenCalledWith(new FetchResourceInstitutions(mockPreprint.id, ResourceType.Preprint)); }); - it('should handle effect for resource institutions', () => { - const newResourceInstitutions = [MOCK_INSTITUTION]; + it('should auto-apply user institutions on create flow when institutions not changed', () => { + setup({ + preprint: { ...mockPreprint, reviewsState: ReviewsState.Initial }, + }); - jest.spyOn(component, 'resourceInstitutions').mockReturnValue(newResourceInstitutions); - component.ngOnInit(); - - expect(component.selectedInstitutions()).toEqual(newResourceInstitutions); + expect(store.dispatch).toHaveBeenCalledWith(new SetInstitutionsChanged(true)); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateResourceInstitutions(mockPreprint.id, ResourceType.Preprint, mockUserInstitutions) + ); }); - it('should not update selectedInstitutions when resource institutions is empty', () => { - const initialInstitutions = component.selectedInstitutions(); + it('should not auto-apply user institutions when not in create flow', () => { + setup({ + preprint: { ...mockPreprint, reviewsState: ReviewsState.Pending }, + }); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SetInstitutionsChanged)); + expect(store.dispatch).not.toHaveBeenCalledWith( + new UpdateResourceInstitutions(mockPreprint.id, ResourceType.Preprint, mockUserInstitutions) + ); + }); - jest.spyOn(component, 'resourceInstitutions').mockReturnValue([]); - component.ngOnInit(); + it('should not auto-apply user institutions when institutions already changed', () => { + setup({ + preprint: { ...mockPreprint, reviewsState: ReviewsState.Initial }, + selectorOverrides: [{ selector: PreprintStepperSelectors.getInstitutionsChanged, value: true }], + }); - expect(component.selectedInstitutions()).toEqual(initialInstitutions); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SetInstitutionsChanged)); + expect(store.dispatch).not.toHaveBeenCalledWith( + new UpdateResourceInstitutions(mockPreprint.id, ResourceType.Preprint, mockUserInstitutions) + ); }); - it('should handle multiple institution changes', () => { - const firstChange = [MOCK_INSTITUTION]; - const secondChange = [MOCK_INSTITUTION]; + it('should dispatch update institutions on selection change', () => { + setup(); + const updatedInstitutions = [MOCK_INSTITUTION]; - component.onInstitutionsChange(firstChange); - expect(component.selectedInstitutions()).toEqual(firstChange); + component.onInstitutionsChange(updatedInstitutions); - component.onInstitutionsChange(secondChange); - expect(component.selectedInstitutions()).toEqual(secondChange); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateResourceInstitutions(mockPreprint.id, ResourceType.Preprint, updatedInstitutions) + ); }); }); diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.ts index f1a315ce1..084d50f9e 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-affiliated-institutions/preprints-affiliated-institutions.component.ts @@ -4,14 +4,14 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; -import { ChangeDetectionStrategy, Component, effect, input, OnInit, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, input, OnInit, signal } from '@angular/core'; import { ReviewsState } from '@osf/features/preprints/enums'; import { PreprintModel, PreprintProviderDetails } from '@osf/features/preprints/models'; import { PreprintStepperSelectors, SetInstitutionsChanged } from '@osf/features/preprints/store/preprint-stepper'; import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { FetchResourceInstitutions, FetchUserInstitutions, @@ -27,17 +27,17 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintsAffiliatedInstitutionsComponent implements OnInit { - provider = input.required(); - preprint = input.required(); + readonly provider = input.required(); + readonly preprint = input.required(); - selectedInstitutions = signal([]); + readonly selectedInstitutions = signal([]); - userInstitutions = select(InstitutionsSelectors.getUserInstitutions); - areUserInstitutionsLoading = select(InstitutionsSelectors.areUserInstitutionsLoading); - resourceInstitutions = select(InstitutionsSelectors.getResourceInstitutions); - areResourceInstitutionsLoading = select(InstitutionsSelectors.areResourceInstitutionsLoading); - areResourceInstitutionsSubmitting = select(InstitutionsSelectors.areResourceInstitutionsSubmitting); - institutionsChanged = select(PreprintStepperSelectors.getInstitutionsChanged); + readonly userInstitutions = select(InstitutionsSelectors.getUserInstitutions); + readonly areUserInstitutionsLoading = select(InstitutionsSelectors.areUserInstitutionsLoading); + readonly resourceInstitutions = select(InstitutionsSelectors.getResourceInstitutions); + readonly areResourceInstitutionsLoading = select(InstitutionsSelectors.areResourceInstitutionsLoading); + readonly areResourceInstitutionsSubmitting = select(InstitutionsSelectors.areResourceInstitutionsSubmitting); + readonly institutionsChanged = select(PreprintStepperSelectors.getInstitutionsChanged); private readonly actions = createDispatchMap({ fetchUserInstitutions: FetchUserInstitutions, @@ -46,6 +46,13 @@ export class PreprintsAffiliatedInstitutionsComponent implements OnInit { setInstitutionsChanged: SetInstitutionsChanged, }); + isLoading = computed( + () => + this.areUserInstitutionsLoading() || + this.areResourceInstitutionsLoading() || + this.areResourceInstitutionsSubmitting() + ); + constructor() { effect(() => { const resourceInstitutions = this.resourceInstitutions(); @@ -56,7 +63,7 @@ export class PreprintsAffiliatedInstitutionsComponent implements OnInit { effect(() => { const userInstitutions = this.userInstitutions(); - const isCreateFlow = this.preprint()?.reviewsState === ReviewsState.Initial; + const isCreateFlow = this.preprint().reviewsState === ReviewsState.Initial; if (userInstitutions.length > 0 && isCreateFlow && !this.institutionsChanged()) { this.actions.setInstitutionsChanged(true); @@ -67,7 +74,7 @@ export class PreprintsAffiliatedInstitutionsComponent implements OnInit { ngOnInit() { this.actions.fetchUserInstitutions(); - this.actions.fetchResourceInstitutions(this.preprint()!.id, ResourceType.Preprint); + this.actions.fetchResourceInstitutions(this.preprint().id, ResourceType.Preprint); } onInstitutionsChange(institutions: Institution[]): void { diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.spec.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.spec.ts index 6da10dfb3..580bf3eee 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.spec.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.spec.ts @@ -1,103 +1,253 @@ +import { Store } from '@ngxs/store'; + import { MockComponent, MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { UserSelectors } from '@core/store/user'; import { ContributorsTableComponent } from '@osf/shared/components/contributors'; +import { AddContributorType } from '@osf/shared/enums/contributors/add-contributor-type.enum'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { ContributorModel } from '@shared/models/contributors/contributor.model'; -import { ContributorsSelectors } from '@shared/stores/contributors'; +import { + BulkAddContributors, + BulkUpdateContributors, + ContributorsSelectors, + DeleteContributor, + GetAllContributors, + LoadMoreContributors, +} from '@shared/stores/contributors'; import { PreprintsContributorsComponent } from './preprints-contributors.component'; -import { MOCK_CONTRIBUTOR } from '@testing/mocks/contributors.mock'; -import { MOCK_USER } from '@testing/mocks/data.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { MOCK_CONTRIBUTOR, MOCK_CONTRIBUTOR_ADD } from '@testing/mocks/contributors.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { CustomDialogServiceMock, CustomDialogServiceMockType } from '@testing/providers/custom-dialog-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('PreprintsContributorsComponent', () => { let component: PreprintsContributorsComponent; let fixture: ComponentFixture; - let toastServiceMock: ReturnType; - let confirmationServiceMock: ReturnType; - let mockCustomDialogService: ReturnType; - - const mockContributors: ContributorModel[] = [MOCK_CONTRIBUTOR]; - const mockCurrentUser = MOCK_USER; - - beforeEach(async () => { - toastServiceMock = ToastServiceMockBuilder.create().build(); - confirmationServiceMock = CustomConfirmationServiceMockBuilder.create().build(); - mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); - - await TestBed.configureTestingModule({ - imports: [PreprintsContributorsComponent, OSFTestingModule, MockComponent(ContributorsTableComponent)], + let store: Store; + let dialogMock: CustomDialogServiceMockType; + let confirmationMock: CustomConfirmationServiceMockType; + let toastMock: ToastServiceMockType; + + const mockContributors = [MOCK_CONTRIBUTOR]; + const preprintId = 'preprint-1'; + const defaultSignals: SignalOverride[] = [ + { selector: ContributorsSelectors.getContributors, value: mockContributors }, + { selector: ContributorsSelectors.isContributorsLoading, value: false }, + { selector: ContributorsSelectors.isContributorsLoadingMore, value: false }, + { selector: ContributorsSelectors.getContributorsPageSize, value: 10 }, + { selector: ContributorsSelectors.getContributorsTotalCount, value: 1 }, + ]; + + function setup(overrides?: { + preprintId?: string; + selectorOverrides?: SignalOverride[]; + addDialogCloseValue?: unknown; + addUnregisteredDialogCloseValue?: unknown; + }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + const addDialogClose$ = of(overrides?.addDialogCloseValue); + const addUnregisteredDialogClose$ = of(overrides?.addUnregisteredDialogCloseValue); + + dialogMock = CustomDialogServiceMock.create() + .withOpen( + jest.fn((component: unknown) => { + const isUnregisteredDialog = + typeof component === 'function' && `${component}`.includes('AddUnregisteredContributorDialogComponent'); + return { + onClose: isUnregisteredDialog ? addUnregisteredDialogClose$ : addDialogClose$, + } as never; + }) + ) + .build(); + confirmationMock = CustomConfirmationServiceMock.simple(); + toastMock = ToastServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [PreprintsContributorsComponent, MockComponent(ContributorsTableComponent)], providers: [ - MockProvider(ToastService, toastServiceMock), - MockProvider(CustomConfirmationService, confirmationServiceMock), - MockProvider(CustomDialogService, mockCustomDialogService), - provideMockStore({ - signals: [ - { - selector: ContributorsSelectors.getContributors, - value: mockContributors, - }, - { - selector: ContributorsSelectors.isContributorsLoading, - value: false, - }, - { - selector: UserSelectors.getCurrentUser, - value: mockCurrentUser, - }, - ], - }), + provideOSFCore(), + MockProvider(CustomDialogService, dialogMock), + MockProvider(CustomConfirmationService, confirmationMock), + MockProvider(ToastService, toastMock), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintsContributorsComponent); component = fixture.componentInstance; - fixture.componentRef.setInput('preprintId', 'preprint-1'); + fixture.componentRef.setInput( + 'preprintId', + overrides && 'preprintId' in overrides ? overrides.preprintId : preprintId + ); + fixture.detectChanges(); + } + + it('should fetch contributors when preprint id exists', () => { + setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new GetAllContributors(preprintId, ResourceType.Preprint)); + }); + + it('should clone initial contributors into editable contributors', () => { + setup(); + + expect(component.contributors()).toEqual(mockContributors); + expect(component.contributors()).not.toBe(mockContributors); + }); + + it('should compute hasChanges correctly', () => { + setup(); + expect(component.hasChanges).toBe(false); + + component.contributors.set([{ ...MOCK_CONTRIBUTOR, fullName: 'Updated Name' }]); + expect(component.hasChanges).toBe(true); + + component.contributors.set([]); + expect(component.hasChanges).toBe(true); + }); + + it('should compute table params from selector values', () => { + setup(); + + expect(component.tableParams().totalRecords).toBe(1); + expect(component.tableParams().rows).toBe(10); + expect(component.tableParams().paginator).toBe(false); + expect(component.tableParams().scrollable).toBe(true); + }); + + it('should reset contributors on cancel', () => { + setup(); + component.contributors.set([{ ...MOCK_CONTRIBUTOR, fullName: 'Changed' }]); + + component.cancel(); + + expect(component.contributors()).toEqual(mockContributors); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should save changed contributors and show success toast', () => { + setup(); + component.contributors.set([{ ...MOCK_CONTRIBUTOR, fullName: 'Updated Name' }]); + + component.save(); + + expect(store.dispatch).toHaveBeenCalledWith( + new BulkUpdateContributors(preprintId, ResourceType.Preprint, [{ ...MOCK_CONTRIBUTOR, fullName: 'Updated Name' }]) + ); + expect(toastMock.showSuccess).toHaveBeenCalledWith( + 'project.contributors.toastMessages.multipleUpdateSuccessMessage' + ); + }); + + it('should open add contributor dialog', () => { + setup(); + + component.openAddContributorDialog(); + + expect(dialogMock.open).toHaveBeenCalled(); }); - it('should remove contributor with confirmation', () => { - const contributorToRemove = mockContributors[0]; + it('should ignore empty add contributor dialog result', () => { + setup({ addDialogCloseValue: null }); - confirmationServiceMock.confirmDelete.mockImplementation(({ onConfirm }) => { - onConfirm(); + component.openAddContributorDialog(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(BulkAddContributors)); + }); + + it('should open unregistered dialog when add dialog returns unregistered type', () => { + setup({ + addDialogCloseValue: { type: AddContributorType.Unregistered, data: [MOCK_CONTRIBUTOR_ADD] }, + }); + const openUnregisteredSpy = jest.spyOn(component, 'openAddUnregisteredContributorDialog'); + + component.openAddContributorDialog(); + + expect(openUnregisteredSpy).toHaveBeenCalled(); + }); + + it('should add contributors when add dialog returns registered type', () => { + setup({ + addDialogCloseValue: { type: AddContributorType.Registered, data: [MOCK_CONTRIBUTOR_ADD] }, }); - component.removeContributor(contributorToRemove); + component.openAddContributorDialog(); + + expect(store.dispatch).toHaveBeenCalledWith( + new BulkAddContributors(preprintId, ResourceType.Preprint, [MOCK_CONTRIBUTOR_ADD]) + ); + expect(toastMock.showSuccess).toHaveBeenCalledWith('project.contributors.toastMessages.multipleAddSuccessMessage'); + }); - expect(confirmationServiceMock.confirmDelete).toHaveBeenCalledWith({ + it('should open registered dialog when unregistered dialog returns registered type', () => { + setup({ + addUnregisteredDialogCloseValue: { type: AddContributorType.Registered, data: [MOCK_CONTRIBUTOR_ADD] }, + }); + const openRegisteredSpy = jest.spyOn(component, 'openAddContributorDialog'); + + component.openAddUnregisteredContributorDialog(); + + expect(openRegisteredSpy).toHaveBeenCalled(); + }); + + it('should add unregistered contributor and show named toast', () => { + setup({ + addUnregisteredDialogCloseValue: { + type: AddContributorType.Unregistered, + data: [{ ...MOCK_CONTRIBUTOR_ADD, fullName: 'Jane Doe' }], + }, + }); + + component.openAddUnregisteredContributorDialog(); + + expect(store.dispatch).toHaveBeenCalledWith( + new BulkAddContributors(preprintId, ResourceType.Preprint, [{ ...MOCK_CONTRIBUTOR_ADD, fullName: 'Jane Doe' }]) + ); + expect(toastMock.showSuccess).toHaveBeenCalledWith('project.contributors.toastMessages.addSuccessMessage', { + name: 'Jane Doe', + }); + }); + + it('should open delete confirmation and delete contributor on confirm', () => { + setup(); + + component.removeContributor(MOCK_CONTRIBUTOR); + + expect(confirmationMock.confirmDelete).toHaveBeenCalledWith({ headerKey: 'project.contributors.removeDialog.title', messageKey: 'project.contributors.removeDialog.message', - messageParams: { name: contributorToRemove.fullName }, + messageParams: { name: MOCK_CONTRIBUTOR.fullName }, acceptLabelKey: 'common.buttons.remove', onConfirm: expect.any(Function), }); - }); - it('should expose readonly properties', () => { - expect(component.destroyRef).toBeDefined(); - expect(component.customDialogService).toBeDefined(); - expect(component.toastService).toBeDefined(); - expect(component.customConfirmationService).toBeDefined(); - expect(component.actions).toBeDefined(); - }); + const { onConfirm } = confirmationMock.confirmDelete.mock.calls[0][0]; + onConfirm(); - it('should handle effect for contributors', () => { - component.ngOnInit(); + expect(store.dispatch).toHaveBeenCalledWith( + new DeleteContributor(preprintId, ResourceType.Preprint, MOCK_CONTRIBUTOR.userId) + ); + expect(toastMock.showSuccess).toHaveBeenCalledWith('project.contributors.removeDialog.successMessage', { + name: MOCK_CONTRIBUTOR.fullName, + }); + }); - expect(component).toBeTruthy(); + it('should load more contributors', () => { + setup({ preprintId }); + component.loadMoreContributors(); + expect(store.dispatch).toHaveBeenCalledWith(new LoadMoreContributors(preprintId, ResourceType.Preprint)); }); }); diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts index 3fbfa30d6..aad0b7305 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts @@ -5,7 +5,6 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; import { Message } from 'primeng/message'; -import { TableModule } from 'primeng/table'; import { filter } from 'rxjs'; @@ -21,7 +20,6 @@ import { signal, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; import { AddContributorDialogComponent, @@ -49,25 +47,33 @@ import { TableParameters } from '@shared/models/table-parameters.model'; @Component({ selector: 'osf-preprints-contributors', - imports: [FormsModule, TableModule, ContributorsTableComponent, TranslatePipe, Card, Button, Message], + imports: [Button, Card, Message, ContributorsTableComponent, TranslatePipe], templateUrl: './preprints-contributors.component.html', styleUrl: './preprints-contributors.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintsContributorsComponent implements OnInit { - preprintId = input(''); + readonly preprintId = input.required(); readonly destroyRef = inject(DestroyRef); readonly customDialogService = inject(CustomDialogService); readonly toastService = inject(ToastService); readonly customConfirmationService = inject(CustomConfirmationService); - initialContributors = select(ContributorsSelectors.getContributors); - contributors = signal([]); - contributorsTotalCount = select(ContributorsSelectors.getContributorsTotalCount); - isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); - isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); - pageSize = select(ContributorsSelectors.getContributorsPageSize); + readonly initialContributors = select(ContributorsSelectors.getContributors); + readonly contributors = signal([]); + readonly contributorsTotalCount = select(ContributorsSelectors.getContributorsTotalCount); + readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); + readonly isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); + readonly pageSize = select(ContributorsSelectors.getContributorsPageSize); + + readonly actions = createDispatchMap({ + getContributors: GetAllContributors, + deleteContributor: DeleteContributor, + bulkUpdateContributors: BulkUpdateContributors, + bulkAddContributors: BulkAddContributors, + loadMoreContributors: LoadMoreContributors, + }); readonly tableParams = computed(() => ({ ...DEFAULT_TABLE_PARAMS, @@ -78,14 +84,6 @@ export class PreprintsContributorsComponent implements OnInit { rows: this.pageSize(), })); - actions = createDispatchMap({ - getContributors: GetAllContributors, - deleteContributor: DeleteContributor, - bulkUpdateContributors: BulkUpdateContributors, - bulkAddContributors: BulkAddContributors, - loadMoreContributors: LoadMoreContributors, - }); - get hasChanges(): boolean { return JSON.stringify(this.initialContributors()) !== JSON.stringify(this.contributors()); } @@ -155,9 +153,12 @@ export class PreprintsContributorsComponent implements OnInit { } else { const params = { name: res.data[0].fullName }; - this.actions.bulkAddContributors(this.preprintId(), ResourceType.Preprint, res.data).subscribe({ - next: () => this.toastService.showSuccess('project.contributors.toastMessages.addSuccessMessage', params), - }); + this.actions + .bulkAddContributors(this.preprintId(), ResourceType.Preprint, res.data) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => + this.toastService.showSuccess('project.contributors.toastMessages.addSuccessMessage', params) + ); } }); } @@ -172,12 +173,10 @@ export class PreprintsContributorsComponent implements OnInit { this.actions .deleteContributor(this.preprintId(), ResourceType.Preprint, contributor.userId) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: () => { - this.toastService.showSuccess('project.contributors.removeDialog.successMessage', { - name: contributor.fullName, - }); - }, + .subscribe(() => { + this.toastService.showSuccess('project.contributors.removeDialog.successMessage', { + name: contributor.fullName, + }); }); }, }); diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.html b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.html index 77e60fe11..624123cc0 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.html +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.html @@ -1,8 +1,12 @@ +@let preprint = createdPreprint(); +

{{ 'preprints.preprintStepper.metadata.title' | translate }}

-
- -
+@if (preprint) { +
+ +
+}

{{ 'shared.license.title' | translate }}

@@ -14,9 +18,10 @@

{{ 'shared.license.title' | translate }}

{{ 'common.links.helpGuide' | translate }}.

+ {{ 'shared.license.title' | translate }}

/> -
- -
+@if (preprint) { +
+ +
-
- -
+
+ +
+}
@@ -47,10 +58,12 @@

{{ 'preprints.preprintStepper.metadata.tagsTitle' | translate }}

{{ 'preprints.preprintStepper.metadata.publicationDoi.title' | translate }}

+ @let doiControl = metadataForm.controls['doi']; + @if (doiControl.errors?.['pattern'] && (doiControl.touched || doiControl.dirty)) { - {{ 'preprints.preprintStepper.metadata.publicationDoi.patternError' | translate }} + + {{ 'preprints.preprintStepper.metadata.publicationDoi.patternError' | translate }} }
@@ -91,7 +104,7 @@

{{ 'preprints.preprintStepper.metadata.publicationCitationTitle styleClass="w-full" [label]="'common.buttons.back' | translate" severity="info" - (click)="backButtonClicked()" + (onClick)="backButtonClicked()" /> {{ 'preprints.preprintStepper.metadata.publicationCitationTitle tooltipPosition="top" [disabled]="metadataForm.invalid || !createdPreprint()?.licenseId" [loading]="isUpdatingPreprint()" - (click)="nextButtonClicked()" + (onClick)="nextButtonClicked()" /> diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.spec.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.spec.ts index ee60f9c14..8c906c5a4 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.spec.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.spec.ts @@ -1,18 +1,24 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormControl, FormGroup } from '@angular/forms'; -import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { formInputLimits } from '@osf/features/preprints/constants'; -import { PreprintProviderDetails } from '@osf/features/preprints/models'; -import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; +import { PreprintModel, PreprintProviderDetails } from '@osf/features/preprints/models'; +import { + FetchLicenses, + PreprintStepperSelectors, + SaveLicense, + UpdatePreprint, +} from '@osf/features/preprints/store/preprint-stepper'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { LicenseComponent } from '@osf/shared/components/license/license.component'; import { TextInputComponent } from '@osf/shared/components/text-input/text-input.component'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ToastService } from '@osf/shared/services/toast.service'; +import { LicenseModel } from '@shared/models/license/license.model'; import { PreprintsAffiliatedInstitutionsComponent } from './preprints-affiliated-institutions/preprints-affiliated-institutions.component'; import { PreprintsContributorsComponent } from './preprints-contributors/preprints-contributors.component'; @@ -22,31 +28,39 @@ import { PreprintsMetadataStepComponent } from './preprints-metadata-step.compon import { MOCK_LICENSE } from '@testing/mocks/license.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 { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('PreprintsMetadataStepComponent', () => { let component: PreprintsMetadataStepComponent; let fixture: ComponentFixture; - let toastServiceMock: ReturnType; - let customConfirmationServiceMock: ReturnType; + let store: Store; + let toastServiceMock: ToastServiceMockType; + let customConfirmationServiceMock: CustomConfirmationServiceMockType; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; - const mockPreprint = PREPRINT_MOCK; - const mockLicenses = [MOCK_LICENSE]; + const mockPreprint: PreprintModel = PREPRINT_MOCK; + const mockLicenses: LicenseModel[] = [MOCK_LICENSE]; + + const defaultSignals: SignalOverride[] = [ + { selector: PreprintStepperSelectors.getLicenses, value: mockLicenses }, + { selector: PreprintStepperSelectors.getPreprint, value: mockPreprint }, + { selector: PreprintStepperSelectors.isPreprintSubmitting, value: false }, + ]; - beforeEach(async () => { - toastServiceMock = ToastServiceMockBuilder.create().withShowSuccess(jest.fn()).build(); - customConfirmationServiceMock = CustomConfirmationServiceMockBuilder.create() - .withConfirmContinue(jest.fn()) - .build(); + function setup(overrides?: { selectorOverrides?: SignalOverride[]; detectChanges?: boolean }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + toastServiceMock = ToastServiceMock.simple(); + customConfirmationServiceMock = CustomConfirmationServiceMock.simple(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ PreprintsMetadataStepComponent, - OSFTestingModule, ...MockComponents( PreprintsContributorsComponent, IconComponent, @@ -57,125 +71,199 @@ describe('PreprintsMetadataStepComponent', () => { ), ], providers: [ + provideOSFCore(), MockProvider(ToastService, toastServiceMock), MockProvider(CustomConfirmationService, customConfirmationServiceMock), - provideNoopAnimations(), - provideMockStore({ - signals: [ - { - selector: PreprintStepperSelectors.getLicenses, - value: mockLicenses, - }, - { - selector: PreprintStepperSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintStepperSelectors.isPreprintSubmitting, - value: false, - }, - ], - }), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintsMetadataStepComponent); component = fixture.componentInstance; fixture.componentRef.setInput('provider', mockProvider); - fixture.detectChanges(); - }); + if (overrides?.detectChanges ?? true) { + fixture.detectChanges(); + } + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should initialize with correct default values', () => { + it('should initialize defaults and form values', () => { + setup(); + expect(component.inputLimits).toBe(formInputLimits); expect(component.INPUT_VALIDATION_MESSAGES).toBe(INPUT_VALIDATION_MESSAGES); expect(component.today).toBeInstanceOf(Date); + expect(component.metadataForm.controls.doi.value).toBe(mockPreprint.doi); + expect(component.metadataForm.controls.customPublicationCitation.value).toBe( + mockPreprint.customPublicationCitation + ); + expect(component.metadataForm.controls.tags.value).toEqual(mockPreprint.tags); }); - it('should initialize form with correct structure', () => { - fixture.detectChanges(); - expect(component.metadataForm).toBeInstanceOf(FormGroup); - expect(component.metadataForm.controls['doi']).toBeInstanceOf(FormControl); - expect(component.metadataForm.controls['originalPublicationDate']).toBeInstanceOf(FormControl); - expect(component.metadataForm.controls['customPublicationCitation']).toBeInstanceOf(FormControl); - expect(component.metadataForm.controls['tags']).toBeInstanceOf(FormControl); - expect(component.metadataForm.controls['subjects']).toBeInstanceOf(FormControl); + it('should dispatch fetch licenses on init', () => { + setup(); + expect(store.dispatch).toHaveBeenCalledWith(new FetchLicenses(mockProvider.id)); }); - it('should return licenses from store', () => { - const licenses = component.licenses(); - expect(licenses).toBe(mockLicenses); - }); + it('should auto-select default license and dispatch save when license has no required fields', () => { + const licenseWithoutFields = { ...MOCK_LICENSE, id: 'license-no-fields', requiredFields: [] }; + setup({ + selectorOverrides: [ + { selector: PreprintStepperSelectors.getLicenses, value: [licenseWithoutFields] }, + { + selector: PreprintStepperSelectors.getPreprint, + value: { ...mockPreprint, licenseId: null, defaultLicenseId: 'license-no-fields' }, + }, + ], + }); - it('should return created preprint from store', () => { - const preprint = component.createdPreprint(); - expect(preprint).toBe(mockPreprint); + expect(component.defaultLicense()).toBe('license-no-fields'); + expect(store.dispatch).toHaveBeenCalledWith(new SaveLicense('license-no-fields', undefined)); }); - it('should return submission state from store', () => { - const isSubmitting = component.isUpdatingPreprint(); - expect(isSubmitting).toBe(false); - }); + it('should auto-select default license without dispatching save when license requires fields', () => { + setup({ + selectorOverrides: [ + { selector: PreprintStepperSelectors.getLicenses, value: [MOCK_LICENSE] }, + { + selector: PreprintStepperSelectors.getPreprint, + value: { ...mockPreprint, licenseId: null, defaultLicenseId: MOCK_LICENSE.id }, + }, + ], + }); - it('should return provider input', () => { - const provider = component.provider(); - expect(provider).toBe(mockProvider); + expect(component.defaultLicense()).toBe(MOCK_LICENSE.id); + expect(store.dispatch).not.toHaveBeenCalledWith(new SaveLicense(MOCK_LICENSE.id, undefined)); }); - it('should handle next button click with valid form', () => { - fixture.detectChanges(); + it('should return early in nextButtonClicked when form is invalid', () => { + setup(); const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); + (store.dispatch as jest.Mock).mockClear(); + + component.metadataForm.patchValue({ subjects: [] }); + component.nextButtonClicked(); + + expect(nextClickedSpy).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdatePreprint)); + }); - component.metadataForm.patchValue({ - subjects: [{ id: 'subject1', name: 'Test Subject' }], + it('should return early in nextButtonClicked when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + detectChanges: false, }); + component.initForm(); + component.metadataForm.patchValue({ subjects: [{ id: 'subject-1', name: 'Subject 1' }] }); + const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); + (store.dispatch as jest.Mock).mockClear(); component.nextButtonClicked(); - expect(nextClickedSpy).toHaveBeenCalled(); + expect(nextClickedSpy).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdatePreprint)); }); - it('should not proceed with next button click when form is invalid', () => { - fixture.detectChanges(); + it('should update preprint and emit success in nextButtonClicked', () => { + setup(); const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); - - component.metadataForm.patchValue({ - subjects: [], - }); + component.metadataForm.patchValue({ subjects: [{ id: 'subject-1', name: 'Subject 1' }] }); + (store.dispatch as jest.Mock).mockClear(); component.nextButtonClicked(); - expect(nextClickedSpy).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(UpdatePreprint)); + expect(toastServiceMock.showSuccess).toHaveBeenCalledWith( + 'preprints.preprintStepper.common.successMessages.preprintSaved' + ); + expect(nextClickedSpy).toHaveBeenCalled(); + }); + + it('should dispatch save license from createLicense', () => { + setup({ detectChanges: false }); + component.createLicense({ id: MOCK_LICENSE.id, licenseOptions: { year: '2024', copyrightHolders: 'A' } }); + expect(store.dispatch).toHaveBeenCalledWith( + new SaveLicense(MOCK_LICENSE.id, { year: '2024', copyrightHolders: 'A' }) + ); + }); + + it('should dispatch save license in selectLicense only when required fields are absent', () => { + setup({ detectChanges: false }); + const noFields = { ...MOCK_LICENSE, id: 'no-fields', requiredFields: [] }; + (store.dispatch as jest.Mock).mockClear(); + + component.selectLicense(noFields); + expect(store.dispatch).toHaveBeenCalledWith(new SaveLicense('no-fields', undefined)); + + (store.dispatch as jest.Mock).mockClear(); + component.selectLicense(MOCK_LICENSE); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SaveLicense)); + }); + + it('should update tags form control', () => { + setup(); + component.updateTags(['alpha', 'beta']); + expect(component.metadataForm.controls.tags.value).toEqual(['alpha', 'beta']); }); - it('should handle back button click with changes', () => { - component.metadataForm.patchValue({ - doi: 'new-doi', + it('should return early in backButtonClicked when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + detectChanges: false, }); + component.initForm(); + const backClickedSpy = jest.spyOn(component.backClicked, 'emit'); component.backButtonClicked(); - expect(customConfirmationServiceMock.confirmContinue).toHaveBeenCalled(); + expect(backClickedSpy).not.toHaveBeenCalled(); + expect(customConfirmationServiceMock.confirmContinue).not.toHaveBeenCalled(); }); - it('should handle select license without required fields', () => { - const license = mockLicenses[0]; + it('should emit back when there are no changes in backButtonClicked', () => { + setup(); + const backClickedSpy = jest.spyOn(component.backClicked, 'emit'); + component.metadataForm.patchValue({ subjects: [{ id: 'subject-1', name: 'Subject 1' }] }); + + component.backButtonClicked(); - expect(() => component.selectLicense(license)).not.toThrow(); + expect(backClickedSpy).toHaveBeenCalled(); + expect(customConfirmationServiceMock.confirmContinue).not.toHaveBeenCalled(); }); - it('should handle select license with required fields', () => { - const license = mockLicenses[0]; + it('should request confirmation and emit on confirm when there are changes in backButtonClicked', () => { + setup(); + const backClickedSpy = jest.spyOn(component.backClicked, 'emit'); + component.metadataForm.patchValue({ doi: '10.9999/changed', subjects: [{ id: 'subject-1', name: 'Subject 1' }] }); + + component.backButtonClicked(); + + expect(customConfirmationServiceMock.confirmContinue).toHaveBeenCalledWith({ + headerKey: 'common.discardChanges.header', + messageKey: 'common.discardChanges.message', + onConfirm: expect.any(Function), + onReject: expect.any(Function), + }); - expect(() => component.selectLicense(license)).not.toThrow(); + const { onConfirm } = customConfirmationServiceMock.confirmContinue.mock.calls[0][0]; + onConfirm(); + expect(backClickedSpy).toHaveBeenCalled(); }); - it('should handle edge case with empty licenses', () => { - const licenses = component.licenses(); - expect(licenses).toBeDefined(); - expect(Array.isArray(licenses)).toBe(true); + it('should not emit on reject when there are changes in backButtonClicked', () => { + setup(); + const backClickedSpy = jest.spyOn(component.backClicked, 'emit'); + component.metadataForm.patchValue({ doi: '10.9999/changed', subjects: [{ id: 'subject-1', name: 'Subject 1' }] }); + + component.backButtonClicked(); + + const { onReject } = customConfirmationServiceMock.confirmContinue.mock.calls[0][0]; + onReject(); + expect(backClickedSpy).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.ts index 716bcdfa9..66be42590 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.ts @@ -9,13 +9,12 @@ import { InputText } from 'primeng/inputtext'; import { Message } from 'primeng/message'; import { Tooltip } from 'primeng/tooltip'; -import { ChangeDetectionStrategy, Component, inject, input, OnInit, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, inject, input, OnInit, output, signal } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { formInputLimits } from '@osf/features/preprints/constants'; import { MetadataForm, PreprintModel, PreprintProviderDetails } from '@osf/features/preprints/models'; import { - CreatePreprint, FetchLicenses, PreprintStepperSelectors, SaveLicense, @@ -39,21 +38,21 @@ import { PreprintsSubjectsComponent } from './preprints-subjects/preprints-subje @Component({ selector: 'osf-preprints-metadata', imports: [ - PreprintsContributorsComponent, Button, Card, - ReactiveFormsModule, Message, - TranslatePipe, DatePicker, - IconComponent, InputText, - TextInputComponent, Tooltip, + ReactiveFormsModule, + IconComponent, LicenseComponent, TagsInputComponent, PreprintsSubjectsComponent, + PreprintsContributorsComponent, PreprintsAffiliatedInstitutionsComponent, + TextInputComponent, + TranslatePipe, ], templateUrl: './preprints-metadata-step.component.html', styleUrl: './preprints-metadata-step.component.scss', @@ -62,28 +61,48 @@ import { PreprintsSubjectsComponent } from './preprints-subjects/preprints-subje export class PreprintsMetadataStepComponent implements OnInit { private customConfirmationService = inject(CustomConfirmationService); private toastService = inject(ToastService); + + provider = input.required(); + nextClicked = output(); + backClicked = output(); + private actions = createDispatchMap({ - createPreprint: CreatePreprint, updatePreprint: UpdatePreprint, fetchLicenses: FetchLicenses, saveLicense: SaveLicense, }); - metadataForm!: FormGroup; - inputLimits = formInputLimits; - readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - today = new Date(); - licenses = select(PreprintStepperSelectors.getLicenses); createdPreprint = select(PreprintStepperSelectors.getPreprint); isUpdatingPreprint = select(PreprintStepperSelectors.isPreprintSubmitting); - provider = input.required(); - nextClicked = output(); - backClicked = output(); + metadataForm!: FormGroup; + today = new Date(); + + defaultLicense = signal(undefined); + + readonly inputLimits = formInputLimits; + readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; + + constructor() { + effect(() => { + const licenses = this.licenses(); + const preprint = this.createdPreprint(); + + if (licenses.length && preprint && !preprint.licenseId && preprint.defaultLicenseId) { + const defaultLicense = licenses.find((license) => license.id === preprint?.defaultLicenseId); + if (defaultLicense) { + this.defaultLicense.set(defaultLicense.id); + if (!defaultLicense.requiredFields.length) { + this.actions.saveLicense(defaultLicense.id); + } + } + } + }); + } ngOnInit() { - this.actions.fetchLicenses(); + this.actions.fetchLicenses(this.provider().id); this.initForm(); } @@ -118,11 +137,16 @@ export class PreprintsMetadataStepComponent implements OnInit { return; } - const model = this.metadataForm.value; + const preprint = this.createdPreprint(); - const changedFields = findChangedFields(model, this.createdPreprint()!); + if (!preprint) { + return; + } - this.actions.updatePreprint(this.createdPreprint()!.id, changedFields).subscribe({ + const model = this.metadataForm.value; + const changedFields = findChangedFields(model, preprint); + + this.actions.updatePreprint(preprint.id, changedFields).subscribe({ complete: () => { this.toastService.showSuccess('preprints.preprintStepper.common.successMessages.preprintSaved'); this.nextClicked.emit(); @@ -138,6 +162,7 @@ export class PreprintsMetadataStepComponent implements OnInit { if (license.requiredFields.length) { return; } + this.actions.saveLicense(license.id); } @@ -148,9 +173,14 @@ export class PreprintsMetadataStepComponent implements OnInit { } backButtonClicked() { - const formValue = this.metadataForm.value; - delete formValue.subjects; - const changedFields = findChangedFields(formValue, this.createdPreprint()!); + const preprint = this.createdPreprint(); + + if (!preprint) { + return; + } + + const { subjects: _subjects, ...formValue } = this.metadataForm.value; + const changedFields = findChangedFields(formValue, preprint); if (!Object.keys(changedFields).length) { this.backClicked.emit(); diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-subjects/preprints-subjects.component.spec.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-subjects/preprints-subjects.component.spec.ts index 1c9a4abe3..f820cf809 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-subjects/preprints-subjects.component.spec.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-subjects/preprints-subjects.component.spec.ts @@ -1,167 +1,85 @@ +import { Store } from '@ngxs/store'; + import { MockComponent } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl } from '@angular/forms'; -import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; import { SubjectsComponent } from '@osf/shared/components/subjects/subjects.component'; -import { SubjectModel } from '@osf/shared/models/subject/subject.model'; -import { SubjectsSelectors } from '@osf/shared/stores/subjects'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { + FetchChildrenSubjects, + FetchSelectedSubjects, + FetchSubjects, + SubjectsSelectors, + UpdateResourceSubjects, +} from '@osf/shared/stores/subjects'; import { PreprintsSubjectsComponent } from './preprints-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 { provideMockStore } from '@testing/providers/store-provider.mock'; describe('PreprintsSubjectsComponent', () => { let component: PreprintsSubjectsComponent; let fixture: ComponentFixture; + let store: Store; - const mockSubjects: SubjectModel[] = SUBJECTS_MOCK; + const mockSubjects = SUBJECTS_MOCK; - const mockFormControl = new FormControl([]); + beforeEach(() => { + const control = new FormControl([]); - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PreprintsSubjectsComponent, OSFTestingModule, MockComponent(SubjectsComponent)], + TestBed.configureTestingModule({ + imports: [PreprintsSubjectsComponent, MockComponent(SubjectsComponent)], providers: [ + provideOSFCore(), provideMockStore({ signals: [ - { selector: PreprintStepperSelectors.getSelectedProviderId, value: 'test-provider-id' }, { selector: SubjectsSelectors.getSelectedSubjects, value: mockSubjects }, { selector: SubjectsSelectors.areSelectedSubjectsLoading, value: false }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintsSubjectsComponent); component = fixture.componentInstance; - + fixture.componentRef.setInput('control', control); + fixture.componentRef.setInput('providerId', 'test-provider-id'); fixture.componentRef.setInput('preprintId', 'test-preprint-id'); - fixture.componentRef.setInput('control', mockFormControl); - - fixture.detectChanges(); - }); - - describe('Component Creation', () => { - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should be an instance of PreprintsSubjectsComponent', () => { - expect(component).toBeInstanceOf(PreprintsSubjectsComponent); - }); - }); - - it('should have required inputs', () => { - expect(component.preprintId()).toBe('test-preprint-id'); - expect(component.control()).toBe(mockFormControl); - }); - - it('should have NGXS selectors defined', () => { - expect(component.selectedSubjects).toBeDefined(); - expect(component.isSubjectsUpdating).toBeDefined(); - expect(component['selectedProviderId']).toBeDefined(); - }); - - it('should have actions defined', () => { - expect(component.actions).toBeDefined(); - expect(component.actions.fetchSubjects).toBeDefined(); - expect(component.actions.fetchSelectedSubjects).toBeDefined(); - expect(component.actions.fetchChildrenSubjects).toBeDefined(); - expect(component.actions.updateResourceSubjects).toBeDefined(); - }); - - it('should have INPUT_VALIDATION_MESSAGES constant', () => { - expect(component.INPUT_VALIDATION_MESSAGES).toBeDefined(); - }); - - it('should get selected subjects from store', () => { - expect(component.selectedSubjects()).toEqual(mockSubjects); - }); - - it('should get subjects loading state from store', () => { - expect(component.isSubjectsUpdating()).toBe(false); - }); - - it('should get selected provider ID from store', () => { - expect(component['selectedProviderId']()).toBe('test-provider-id'); - }); - - it('should call getSubjectChildren with parent ID', () => { - const parentId = 'parent-123'; - - expect(() => component.getSubjectChildren(parentId)).not.toThrow(); - }); - - it('should call searchSubjects with search term', () => { - const searchTerm = 'mathematics'; - - expect(() => component.searchSubjects(searchTerm)).not.toThrow(); - }); - - it('should handle null control gracefully', () => { - const nullControl = new FormControl(null); - fixture.componentRef.setInput('control', nullControl); - - expect(() => component.updateControlState(mockSubjects)).not.toThrow(); - }); - - it('should mark control as touched and dirty', () => { - const freshControl = new FormControl([]); - fixture.componentRef.setInput('control', freshControl); - - component.updateControlState(mockSubjects); - - expect(freshControl.touched).toBe(true); - expect(freshControl.dirty).toBe(true); - }); - - it('should render subjects component', () => { - const subjectsComponent = fixture.nativeElement.querySelector('osf-subjects'); - expect(subjectsComponent).toBeTruthy(); - }); - - it('should handle control with required error', () => { - mockFormControl.setErrors({ required: true }); - mockFormControl.markAsTouched(); - mockFormControl.markAsDirty(); fixture.detectChanges(); - - expect(component).toBeTruthy(); - expect(mockFormControl.errors).toEqual({ required: true }); }); - it('should not show error message when control is valid', () => { - mockFormControl.setErrors(null); - fixture.detectChanges(); - - const errorMessage = fixture.nativeElement.querySelector('p-message'); - expect(errorMessage).toBeFalsy(); + it('should fetch provider subjects and selected subjects on init when ids exist', () => { + expect(store.dispatch).toHaveBeenCalledWith(new FetchSubjects(ResourceType.Preprint, 'test-provider-id')); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSelectedSubjects('test-preprint-id', ResourceType.Preprint)); + expect(component.control().value).toEqual(mockSubjects); }); - it('should handle empty preprintId', () => { - fixture.componentRef.setInput('preprintId', ''); + it('should dispatch child subjects fetch', () => { + component.getSubjectChildren('parent-123'); - expect(() => component.ngOnInit()).not.toThrow(); + expect(store.dispatch).toHaveBeenCalledWith(new FetchChildrenSubjects('parent-123')); }); - it('should handle undefined preprintId', () => { - fixture.componentRef.setInput('preprintId', undefined); - - expect(() => component.ngOnInit()).not.toThrow(); + it('should search subjects', () => { + component.searchSubjects('math'); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSubjects(ResourceType.Preprint, 'test-provider-id', 'math')); }); - it('should handle empty subjects array', () => { - const emptySubjects: SubjectModel[] = []; + it('should update control state and resource subjects when preprint id exists', () => { + const control = component.control(); - expect(() => component.updateSelectedSubjects(emptySubjects)).not.toThrow(); - expect(mockFormControl.value).toEqual(emptySubjects); - }); + component.updateSelectedSubjects(mockSubjects); - it('should handle null subjects', () => { - expect(() => component.updateControlState(null as any)).not.toThrow(); + expect(control.value).toEqual(mockSubjects); + expect(control.touched).toBe(true); + expect(control.dirty).toBe(true); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateResourceSubjects('test-preprint-id', ResourceType.Preprint, mockSubjects) + ); }); }); diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-subjects/preprints-subjects.component.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-subjects/preprints-subjects.component.ts index 0d18ceb5c..cad361ca3 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-subjects/preprints-subjects.component.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-subjects/preprints-subjects.component.ts @@ -8,7 +8,6 @@ import { Message } from 'primeng/message'; import { ChangeDetectionStrategy, Component, effect, input, OnInit } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; import { SubjectsComponent } from '@osf/shared/components/subjects/subjects.component'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; @@ -29,21 +28,22 @@ import { SubjectModel } from '@shared/models/subject/subject.model'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintsSubjectsComponent implements OnInit { - preprintId = input(); + readonly control = input.required(); + readonly providerId = input.required(); + readonly preprintId = input.required(); - private readonly selectedProviderId = select(PreprintStepperSelectors.getSelectedProviderId); - selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); - isSubjectsUpdating = select(SubjectsSelectors.areSelectedSubjectsLoading); - control = input.required(); + readonly selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); + readonly isSubjectsUpdating = select(SubjectsSelectors.areSelectedSubjectsLoading); - readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - actions = createDispatchMap({ + readonly actions = createDispatchMap({ fetchSubjects: FetchSubjects, fetchSelectedSubjects: FetchSelectedSubjects, fetchChildrenSubjects: FetchChildrenSubjects, updateResourceSubjects: UpdateResourceSubjects, }); + readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; + constructor() { effect(() => { this.updateControlState(this.selectedSubjects()); @@ -51,30 +51,28 @@ export class PreprintsSubjectsComponent implements OnInit { } ngOnInit(): void { - this.actions.fetchSubjects(ResourceType.Preprint, this.selectedProviderId()!); + this.actions.fetchSubjects(ResourceType.Preprint, this.providerId()); this.actions.fetchSelectedSubjects(this.preprintId()!, ResourceType.Preprint); } - getSubjectChildren(parentId: string) { + getSubjectChildren(parentId: string): void { this.actions.fetchChildrenSubjects(parentId); } - searchSubjects(search: string) { - this.actions.fetchSubjects(ResourceType.Preprint, this.selectedProviderId()!, search); + searchSubjects(search: string): void { + this.actions.fetchSubjects(ResourceType.Preprint, this.providerId(), search); } - updateSelectedSubjects(subjects: SubjectModel[]) { + updateSelectedSubjects(subjects: SubjectModel[]): void { this.updateControlState(subjects); - - this.actions.updateResourceSubjects(this.preprintId()!, ResourceType.Preprint, subjects); + this.actions.updateResourceSubjects(this.preprintId(), ResourceType.Preprint, subjects); } - updateControlState(value: SubjectModel[]) { - if (this.control()) { - this.control().setValue(value); - this.control().markAsTouched(); - this.control().markAsDirty(); - this.control().updateValueAndValidity(); - } + updateControlState(value: SubjectModel[]): void { + const control = this.control(); + control.setValue(value); + control.markAsTouched(); + control.markAsDirty(); + control.updateValueAndValidity(); } } diff --git a/src/app/features/preprints/components/stepper/review-step/review-step.component.html b/src/app/features/preprints/components/stepper/review-step/review-step.component.html index c3d45f941..236dc3d20 100644 --- a/src/app/features/preprints/components/stepper/review-step/review-step.component.html +++ b/src/app/features/preprints/components/stepper/review-step/review-step.component.html @@ -5,7 +5,7 @@

{{ 'preprints.preprintStepper.review.title' | translate | titlecase }}

{{ 'preprints.preprintStepper.review.workflowDescription' - | translate: { providerName: provider()?.name, reviewsWorkflow: provider()?.reviewsWorkflow } + | translate: { providerName: provider().name, reviewsWorkflow: provider().reviewsWorkflow } }}

@@ -28,30 +28,32 @@

{{ 'preprints.preprintStepper.review.sections.titleAndAbstract.title' | tran

{{ 'preprints.preprintStepper.review.sections.titleAndAbstract.service' - | translate: { preprintWord: provider()?.preprintWord | titlecase } + | translate: { preprintWord: provider().preprintWord | titlecase } }}

Provider logo -

{{ provider()?.name }}

+

{{ provider().name }}

-
-

{{ 'preprints.preprintStepper.common.labels.title' | translate }}

-

{{ preprint()!.title }}

-
+ @if (preprint(); as preprint) { +
+

{{ 'preprints.preprintStepper.common.labels.title' | translate }}

+

{{ preprint.title }}

+
-
-

{{ 'preprints.preprintStepper.common.labels.abstract' | translate }}

- -
+
+

{{ 'preprints.preprintStepper.common.labels.abstract' | translate }}

+ +
+ } @@ -143,7 +145,7 @@

{{ 'preprints.preprintStepper.review.sections.metadata.publicationCitation' -@if (provider()?.assertionsEnabled) { +@if (provider().assertionsEnabled) {

{{ 'preprints.preprintStepper.review.sections.authorAssertions.title' | translate }}

@@ -222,7 +224,7 @@

{{ 'preprints.preprintStepper.review.sections.supplements.title' | translate }}

@if (preprintProject()) { -

{{ preprintProject()?.name | fixSpecialChar }}

+

{{ preprintProject()?.name }}

} @else {

{{ 'preprints.preprintStepper.review.sections.supplements.noSupplements' | translate }}

} @@ -235,15 +237,15 @@

{{ 'preprints.preprintStepper.review.sections.supplements.title' | translate styleClass="w-full" [label]="'common.buttons.cancel' | translate" severity="info" - (click)="cancelSubmission()" [disabled]="isPreprintSubmitting()" + (onClick)="cancelSubmission()" /> diff --git a/src/app/features/preprints/components/stepper/review-step/review-step.component.spec.ts b/src/app/features/preprints/components/stepper/review-step/review-step.component.spec.ts index 3187a6bdb..4dad236d3 100644 --- a/src/app/features/preprints/components/stepper/review-step/review-step.component.spec.ts +++ b/src/app/features/preprints/components/stepper/review-step/review-step.component.spec.ts @@ -1,18 +1,34 @@ -import { MockComponents, MockPipe } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockComponents, MockPipe, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { PreprintProviderDetails } from '@osf/features/preprints/models'; -import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; +import { + FetchLicenses, + FetchPreprintProject, + PreprintStepperSelectors, + SubmitPreprint, + UpdatePreprint, + UpdatePrimaryFileRelationship, +} from '@osf/features/preprints/store/preprint-stepper'; 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 { LicenseDisplayComponent } from '@osf/shared/components/license-display/license-display.component'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { InterpolatePipe } from '@osf/shared/pipes/interpolate.pipe'; import { ToastService } from '@osf/shared/services/toast.service'; -import { InterpolatePipe } from '@shared/pipes/interpolate.pipe'; -import { ContributorsSelectors } from '@shared/stores/contributors'; -import { InstitutionsSelectors } from '@shared/stores/institutions'; -import { SubjectsSelectors } from '@shared/stores/subjects'; +import { + ContributorsSelectors, + GetBibliographicContributors, + LoadMoreBibliographicContributors, +} from '@osf/shared/stores/contributors'; +import { FetchResourceInstitutions, InstitutionsSelectors } from '@osf/shared/stores/institutions'; +import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; + +import { ReviewsState } from '../../../enums'; import { ReviewStepComponent } from './review-step.component'; @@ -23,83 +39,209 @@ import { OSF_FILE_MOCK } from '@testing/mocks/osf-file.mock'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; import { SUBJECTS_MOCK } from '@testing/mocks/subject.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { RouterMock } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { RouterMock, RouterMockType } from '@testing/providers/router-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('ReviewStepComponent', () => { let component: ReviewStepComponent; let fixture: ComponentFixture; - let router: jest.Mocked; + let store: Store; + let routerMock: RouterMockType; + let toastMock: ToastServiceMockType; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; const mockPreprint = PREPRINT_MOCK; const mockPreprintFile = OSF_FILE_MOCK; - const mockContributors = [MOCK_CONTRIBUTOR]; - const mockSubjects = SUBJECTS_MOCK; - const mockInstitutions = [MOCK_INSTITUTION]; - const mockLicense = MOCK_LICENSE; - const mockPreprintProject = { - id: 'project-id', - name: 'Test Project', - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ + + const defaultSignals: SignalOverride[] = [ + { selector: PreprintStepperSelectors.getPreprint, value: mockPreprint }, + { selector: PreprintStepperSelectors.getPreprintFile, value: mockPreprintFile }, + { selector: PreprintStepperSelectors.isPreprintSubmitting, value: false }, + { selector: PreprintStepperSelectors.getPreprintLicense, value: MOCK_LICENSE }, + { selector: PreprintStepperSelectors.getPreprintProject, value: { id: 'project-id', name: 'Test Project' } }, + { selector: ContributorsSelectors.getBibliographicContributors, value: [MOCK_CONTRIBUTOR] }, + { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, + { selector: ContributorsSelectors.hasMoreBibliographicContributors, value: false }, + { selector: SubjectsSelectors.getSelectedSubjects, value: SUBJECTS_MOCK }, + { selector: InstitutionsSelectors.getResourceInstitutions, value: [MOCK_INSTITUTION] }, + ]; + + function setup(overrides?: { + selectorOverrides?: SignalOverride[]; + provider?: PreprintProviderDetails; + detectChanges?: boolean; + }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + routerMock = RouterMock.create().build(); + toastMock = ToastServiceMock.simple(); + + TestBed.configureTestingModule({ imports: [ ReviewStepComponent, - OSFTestingModule, ...MockComponents(AffiliatedInstitutionsViewComponent, ContributorsListComponent, LicenseDisplayComponent), MockPipe(InterpolatePipe), ], providers: [ - { provide: Router, useValue: RouterMock.create().build() }, - { provide: ToastService, useValue: ToastServiceMock.simple() }, - provideMockStore({ - signals: [ - { selector: PreprintStepperSelectors.getPreprint, value: mockPreprint }, - { selector: PreprintStepperSelectors.getPreprintFile, value: mockPreprintFile }, - { selector: PreprintStepperSelectors.isPreprintSubmitting, value: false }, - { selector: PreprintStepperSelectors.getPreprintLicense, value: mockLicense }, - { selector: PreprintStepperSelectors.getPreprintProject, value: mockPreprintProject }, - { selector: ContributorsSelectors.getBibliographicContributors, value: mockContributors }, - { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, - { selector: ContributorsSelectors.hasMoreBibliographicContributors, value: false }, - { selector: SubjectsSelectors.getSelectedSubjects, value: mockSubjects }, - { selector: InstitutionsSelectors.getResourceInstitutions, value: mockInstitutions }, - ], - }), + provideOSFCore(), + MockProvider(Router, routerMock), + MockProvider(ToastService, toastMock), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(ReviewStepComponent); component = fixture.componentInstance; - router = TestBed.inject(Router) as jest.Mocked; + fixture.componentRef.setInput('provider', overrides && 'provider' in overrides ? overrides.provider : mockProvider); + if (overrides?.detectChanges ?? true) { + fixture.detectChanges(); + } + } + + it('should dispatch initial fetch actions when preprint exists', () => { + setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchLicenses(mockProvider.id)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintProject()); + expect(store.dispatch).toHaveBeenCalledWith( + new GetBibliographicContributors(mockPreprint.id, ResourceType.Preprint) + ); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSelectedSubjects(mockPreprint.id, ResourceType.Preprint)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchResourceInstitutions(mockPreprint.id, ResourceType.Preprint)); + }); + + it('should skip preprint-dependent fetches when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + }); - fixture.componentRef.setInput('provider', mockProvider); + expect(store.dispatch).toHaveBeenCalledWith(new FetchLicenses(mockProvider.id)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintProject()); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetBibliographicContributors)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchSelectedSubjects)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchResourceInstitutions)); }); - it('should have required provider input', () => { - expect(component.provider()).toEqual(mockProvider); + it('should expose license options record from preprint', () => { + setup(); + expect(component.licenseOptionsRecord()).toEqual(mockPreprint.licenseOptions ?? {}); }); - it('should create license options record', () => { - const licenseOptionsRecord = component.licenseOptionsRecord(); - expect(licenseOptionsRecord).toEqual({ copyrightHolders: 'John Doe', year: '2023' }); + it('should expose empty license options record when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + detectChanges: false, + }); + expect(component.licenseOptionsRecord()).toEqual({}); }); - it('should handle cancelSubmission method', () => { + it('should navigate to preprints list on cancel', () => { + setup(); + component.cancelSubmission(); - expect(router.navigateByUrl).toHaveBeenCalledWith('/preprints'); + + expect(routerMock.navigateByUrl).toHaveBeenCalledWith('/preprints'); + }); + + it('should return early in submitPreprint when required data is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + detectChanges: false, + }); + + component.submitPreprint(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdatePrimaryFileRelationship)); + expect(toastMock.showSuccess).not.toHaveBeenCalled(); + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); + + it('should return early in submitPreprint when no file id is available', () => { + const preprintWithoutPrimaryFileId = { ...mockPreprint, primaryFileId: null }; + setup({ + selectorOverrides: [ + { selector: PreprintStepperSelectors.getPreprint, value: preprintWithoutPrimaryFileId }, + { selector: PreprintStepperSelectors.getPreprintFile, value: null }, + ], + }); + + component.submitPreprint(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdatePrimaryFileRelationship)); + expect(toastMock.showSuccess).not.toHaveBeenCalled(); + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); + + it('should publish directly when provider has no reviews workflow', () => { + setup({ + provider: { ...mockProvider, reviewsWorkflow: null }, + }); + + component.submitPreprint(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdatePrimaryFileRelationship(mockPreprintFile.id)); + expect(store.dispatch).toHaveBeenCalledWith(new UpdatePreprint(mockPreprint.id, { isPublished: true })); + expect(toastMock.showSuccess).toHaveBeenCalledWith( + 'preprints.preprintStepper.common.successMessages.preprintSubmitted' + ); + expect(routerMock.navigate).toHaveBeenCalledWith(['/preprints', mockProvider.id, mockPreprint.id]); + }); + + it('should submit preprint when workflow exists and reviews state is not accepted', () => { + const preprintPending = { ...mockPreprint, reviewsState: ReviewsState.Pending }; + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: preprintPending }], + }); + + component.submitPreprint(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdatePrimaryFileRelationship(mockPreprintFile.id)); + expect(store.dispatch).toHaveBeenCalledWith(new SubmitPreprint()); + expect(toastMock.showSuccess).toHaveBeenCalledWith( + 'preprints.preprintStepper.common.successMessages.preprintSubmitted' + ); + expect(routerMock.navigate).toHaveBeenCalledWith(['/preprints', mockProvider.id, mockPreprint.id]); }); - it('should handle submitting state', () => { - expect(component.isPreprintSubmitting()).toBe(false); + it('should skip submit/update when reviews state is accepted and still navigate', () => { + const preprintAccepted = { ...mockPreprint, reviewsState: ReviewsState.Accepted }; + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: preprintAccepted }], + }); + + component.submitPreprint(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdatePrimaryFileRelationship(mockPreprintFile.id)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SubmitPreprint)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdatePreprint)); + expect(toastMock.showSuccess).toHaveBeenCalledWith( + 'preprints.preprintStepper.common.successMessages.preprintSubmitted' + ); + expect(routerMock.navigate).toHaveBeenCalledWith(['/preprints', mockProvider.id, mockPreprint.id]); }); - it('should have proper method signatures', () => { - expect(typeof component.submitPreprint).toBe('function'); - expect(typeof component.cancelSubmission).toBe('function'); + it('should load more contributors when preprint id exists', () => { + setup(); + + component.loadMoreContributors(); + + expect(store.dispatch).toHaveBeenCalledWith( + new LoadMoreBibliographicContributors(mockPreprint.id, ResourceType.Preprint) + ); + }); + + it('should load more contributors with undefined id when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + detectChanges: false, + }); + + component.loadMoreContributors(); + + expect(store.dispatch).toHaveBeenCalledWith( + new LoadMoreBibliographicContributors(undefined, ResourceType.Preprint) + ); }); }); diff --git a/src/app/features/preprints/components/stepper/review-step/review-step.component.ts b/src/app/features/preprints/components/stepper/review-step/review-step.component.ts index 99a0375d4..5a37192fb 100644 --- a/src/app/features/preprints/components/stepper/review-step/review-step.component.ts +++ b/src/app/features/preprints/components/stepper/review-step/review-step.component.ts @@ -26,7 +26,6 @@ import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affi import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { LicenseDisplayComponent } from '@osf/shared/components/license-display/license-display.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; -import { FixSpecialCharPipe } from '@osf/shared/pipes/fix-special-char.pipe'; import { ToastService } from '@osf/shared/services/toast.service'; import { ResourceType } from '@shared/enums/resource-type.enum'; import { @@ -40,26 +39,28 @@ import { FetchSelectedSubjects, SubjectsSelectors } from '@shared/stores/subject @Component({ selector: 'osf-review-step', imports: [ + Button, Card, - TruncatedTextComponent, Tag, - DatePipe, - Button, - TitleCasePipe, - TranslatePipe, AffiliatedInstitutionsViewComponent, ContributorsListComponent, LicenseDisplayComponent, - FixSpecialCharPipe, + TruncatedTextComponent, + DatePipe, + TitleCasePipe, + TranslatePipe, ], templateUrl: './review-step.component.html', styleUrl: './review-step.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ReviewStepComponent implements OnInit { - private router = inject(Router); - private toastService = inject(ToastService); - private actions = createDispatchMap({ + private readonly router = inject(Router); + private readonly toastService = inject(ToastService); + + readonly provider = input.required(); + + private readonly actions = createDispatchMap({ getBibliographicContributors: GetBibliographicContributors, fetchSubjects: FetchSelectedSubjects, fetchLicenses: FetchLicenses, @@ -71,41 +72,55 @@ export class ReviewStepComponent implements OnInit { loadMoreBibliographicContributors: LoadMoreBibliographicContributors, }); - provider = input.required(); + readonly preprint = select(PreprintStepperSelectors.getPreprint); + readonly preprintFile = select(PreprintStepperSelectors.getPreprintFile); + readonly isPreprintSubmitting = select(PreprintStepperSelectors.isPreprintSubmitting); - preprint = select(PreprintStepperSelectors.getPreprint); - preprintFile = select(PreprintStepperSelectors.getPreprintFile); - isPreprintSubmitting = select(PreprintStepperSelectors.isPreprintSubmitting); - - bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors); - areContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); - hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors); - subjects = select(SubjectsSelectors.getSelectedSubjects); - affiliatedInstitutions = select(InstitutionsSelectors.getResourceInstitutions); - license = select(PreprintStepperSelectors.getPreprintLicense); - preprintProject = select(PreprintStepperSelectors.getPreprintProject); - licenseOptionsRecord = computed(() => (this.preprint()?.licenseOptions ?? {}) as Record); + readonly bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors); + readonly areContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); + readonly hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors); + readonly subjects = select(SubjectsSelectors.getSelectedSubjects); + readonly affiliatedInstitutions = select(InstitutionsSelectors.getResourceInstitutions); + readonly license = select(PreprintStepperSelectors.getPreprintLicense); + readonly preprintProject = select(PreprintStepperSelectors.getPreprintProject); + readonly licenseOptionsRecord = computed(() => (this.preprint()?.licenseOptions ?? {}) as Record); readonly ApplicabilityStatus = ApplicabilityStatus; readonly PreregLinkInfo = PreregLinkInfo; ngOnInit(): void { - this.actions.getBibliographicContributors(this.preprint()?.id, ResourceType.Preprint); - this.actions.fetchSubjects(this.preprint()!.id, ResourceType.Preprint); - this.actions.fetchLicenses(); + this.actions.fetchLicenses(this.provider().id); this.actions.fetchPreprintProject(); - this.actions.fetchResourceInstitutions(this.preprint()!.id, ResourceType.Preprint); + + const preprintId = this.preprint()?.id; + if (!preprintId) { + return; + } + + this.actions.getBibliographicContributors(preprintId, ResourceType.Preprint); + this.actions.fetchSubjects(preprintId, ResourceType.Preprint); + this.actions.fetchResourceInstitutions(preprintId, ResourceType.Preprint); } - submitPreprint() { - const preprint = this.preprint()!; - const preprintFile = this.preprintFile()!; + submitPreprint(): void { + const preprint = this.preprint(); + const provider = this.provider(); + + if (!preprint) { + return; + } + + const preprintFileId = this.preprintFile()?.id ?? preprint.primaryFileId; + + if (!preprintFileId) { + return; + } this.actions - .updatePrimaryFileRelationship(preprintFile?.id ?? preprint.primaryFileId) + .updatePrimaryFileRelationship(preprintFileId) .pipe( switchMap(() => { - if (!this.provider()?.reviewsWorkflow) { + if (!provider.reviewsWorkflow) { return this.actions.updatePreprint(preprint.id, { isPublished: true }); } @@ -116,13 +131,13 @@ export class ReviewStepComponent implements OnInit { }), tap(() => { this.toastService.showSuccess('preprints.preprintStepper.common.successMessages.preprintSubmitted'); - this.router.navigate(['/preprints', this.provider()!.id, preprint.id]); + this.router.navigate(['/preprints', provider.id, preprint.id]); }) ) .subscribe(); } - cancelSubmission() { + cancelSubmission(): void { this.router.navigateByUrl('/preprints'); } diff --git a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html index fb3d8b8c9..f90168727 100644 --- a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html +++ b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html @@ -15,7 +15,7 @@

{{ 'preprints.preprintStepper.supplements.title' | translate }}

styleClass="w-full" [label]="'preprints.preprintStepper.supplements.options.connectExisting' | translate" severity="secondary" - (click)="selectSupplementOption(SupplementOptions.ConnectExistingProject)" + (onClick)="selectSupplementOption(SupplementOptions.ConnectExistingProject)" /> @@ -33,26 +33,26 @@

{{ 'preprints.preprintStepper.supplements.title' | translate }}

- {{ 'preprints.preprintStepper.supplements.projectSelection.description' | translate }} + {{ 'preprints.preprintStepper.projectSelection.description' | translate }}

- {{ 'preprints.preprintStepper.supplements.projectSelection.subDescription' | translate }} + {{ 'preprints.preprintStepper.projectSelection.subDescription' | translate }}

@@ -88,7 +88,7 @@

{{ 'preprints.preprintStepper.supplements.title' | translate }}

styleClass="w-full" [label]="'common.buttons.back' | translate" severity="info" - (click)="backButtonClicked()" + (onClick)="backButtonClicked()" /> {{ 'preprints.preprintStepper.supplements.title' | translate }}

[label]="'common.buttons.next' | translate" [disabled]="isNextButtonDisabled()" [loading]="isPreprintSubmitting()" - (click)="nextButtonClicked()" + (onClick)="nextButtonClicked()" /> diff --git a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts index 8f7123c13..554477a13 100644 --- a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts +++ b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts @@ -1,125 +1,344 @@ -import { MockComponent, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { ConfirmationService } from 'primeng/api'; +import { MockComponent, MockProvider } from 'ng-mocks'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; +import { SupplementOptions } from '@osf/features/preprints/enums'; +import { + ConnectProject, + CreateNewProject, + DisconnectProject, + FetchAvailableProjects, + FetchPreprintProject, + PreprintStepperSelectors, +} from '@osf/features/preprints/store/preprint-stepper'; import { AddProjectFormComponent } from '@osf/shared/components/add-project-form/add-project-form.component'; +import { ProjectFormControls } from '@osf/shared/enums/create-project-form-controls.enum'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { SupplementsStepComponent } from './supplements-step.component'; -import { TranslateServiceMock } from '@testing/mocks/translate.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; +import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('SupplementsStepComponent', () => { let component: SupplementsStepComponent; let fixture: ComponentFixture; - let mockToastService: ReturnType; + let store: Store; + let toastMock: ToastServiceMockType; + let confirmationMock: CustomConfirmationServiceMockType; + const originalPointerEvent = (globalThis as unknown as { PointerEvent?: typeof Event }).PointerEvent; + + const defaultSignals: SignalOverride[] = [ + { selector: PreprintStepperSelectors.getPreprint, value: { ...PREPRINT_MOCK, nodeId: null } }, + { selector: PreprintStepperSelectors.isPreprintSubmitting, value: false }, + { selector: PreprintStepperSelectors.getAvailableProjects, value: [] }, + { selector: PreprintStepperSelectors.areAvailableProjectsLoading, value: false }, + { selector: PreprintStepperSelectors.getPreprintProject, value: null }, + { selector: PreprintStepperSelectors.isPreprintProjectLoading, value: false }, + ]; - beforeEach(async () => { - mockToastService = ToastServiceMock.simple(); + function setup(overrides?: { selectorOverrides?: SignalOverride[]; detectChanges?: boolean }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + toastMock = ToastServiceMock.simple(); + confirmationMock = CustomConfirmationServiceMock.simple(); - await TestBed.configureTestingModule({ - imports: [SupplementsStepComponent, MockComponent(AddProjectFormComponent), OSFTestingModule], + TestBed.configureTestingModule({ + imports: [SupplementsStepComponent, MockComponent(AddProjectFormComponent)], providers: [ - provideMockStore({ - signals: [ - { - selector: PreprintStepperSelectors.getPreprint, - value: {}, - }, - { - selector: PreprintStepperSelectors.isPreprintSubmitting, - value: false, - }, - { - selector: PreprintStepperSelectors.getAvailableProjects, - value: [], - }, - { - selector: PreprintStepperSelectors.areAvailableProjectsLoading, - value: false, - }, - { - selector: PreprintStepperSelectors.getPreprintProject, - value: null, - }, - { - selector: PreprintStepperSelectors.isPreprintProjectLoading, - value: false, - }, - ], - }), - TranslateServiceMock, - MockProvider(ConfirmationService, { - confirm: jest.fn(), - close: jest.fn(), - }), - { provide: ToastService, useValue: mockToastService }, + provideOSFCore(), + MockProvider(ToastService, toastMock), + MockProvider(CustomConfirmationService, confirmationMock), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(SupplementsStepComponent); component = fixture.componentInstance; - fixture.detectChanges(); + if (overrides?.detectChanges ?? true) { + fixture.detectChanges(); + } + } + + beforeAll(() => { + if (!(globalThis as unknown as { PointerEvent?: typeof Event }).PointerEvent) { + (globalThis as unknown as { PointerEvent: typeof Event }).PointerEvent = MouseEvent as unknown as typeof Event; + } + }); + + afterAll(() => { + if (originalPointerEvent) { + (globalThis as unknown as { PointerEvent: typeof Event }).PointerEvent = originalPointerEvent; + } else { + delete (globalThis as unknown as { PointerEvent?: typeof Event }).PointerEvent; + } }); it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should call getAvailableProjects when project name changes after debounce', () => { - jest.useFakeTimers(); + it('should fetch preprint project in constructor effect when node id exists and differs from selected project', () => { + setup({ + selectorOverrides: [ + { selector: PreprintStepperSelectors.getPreprint, value: { ...PREPRINT_MOCK, nodeId: 'node-1' } }, + { selector: PreprintStepperSelectors.getPreprintProject, value: { id: 'project-1', name: 'Test Project' } }, + ], + }); - const getAvailableProjectsSpy = jest.fn(); - Object.defineProperty(component, 'actions', { - value: { getAvailableProjects: getAvailableProjectsSpy }, - writable: true, + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintProject()); + }); + + it('should skip preprint project fetch when node id matches selected project id', () => { + setup({ + selectorOverrides: [ + { selector: PreprintStepperSelectors.getPreprint, value: { ...PREPRINT_MOCK, nodeId: 'node-1' } }, + { selector: PreprintStepperSelectors.getPreprintProject, value: { id: 'node-1', name: 'Node Project' } }, + ], }); - component.ngOnInit(); - component.projectNameControl.setValue('test-project'); - jest.advanceTimersByTime(500); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchPreprintProject)); + }); - expect(getAvailableProjectsSpy).toHaveBeenCalledWith('test-project'); - jest.useRealTimers(); + it('should skip preprint project fetch when node id is absent', () => { + setup({ + selectorOverrides: [ + { selector: PreprintStepperSelectors.getPreprint, value: { ...PREPRINT_MOCK, nodeId: null } }, + ], + }); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchPreprintProject)); }); - it('should not call getAvailableProjects if value is the same as selectedProjectId', () => { - jest.useFakeTimers(); - const getAvailableProjectsSpy = jest.fn(); + it('should dispatch available projects from debounced project search', fakeAsync(() => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + + component.projectNameControl.setValue('search-query'); + tick(500); + + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(store.dispatch).toHaveBeenCalledWith(new FetchAvailableProjects('search-query')); + })); + + it('should not dispatch before the debounce window elapses', fakeAsync(() => { + setup(); + (store.dispatch as jest.Mock).mockClear(); - Object.defineProperty(component, 'actions', { - value: { getAvailableProjects: getAvailableProjectsSpy }, - writable: true, + component.projectNameControl.setValue('search-query'); + tick(300); + + expect(store.dispatch).not.toHaveBeenCalledWith(new FetchAvailableProjects('search-query')); + tick(200); + })); + + it('should skip available projects dispatch when value equals selected project id', fakeAsync(() => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + component.selectedProjectId.set('project-1'); + + component.projectNameControl.setValue('project-1'); + tick(500); + + expect(store.dispatch).not.toHaveBeenCalledWith(new FetchAvailableProjects('project-1')); + })); + + it('should select supplement option and reset create form for create-new option', () => { + setup({ detectChanges: false }); + component.createProjectForm.patchValue({ + [ProjectFormControls.Title]: 'Project', + [ProjectFormControls.StorageLocation]: 'region-1', }); - jest.spyOn(component, 'selectedProjectId').mockReturnValue('test-project'); - component.ngOnInit(); - component.projectNameControl.setValue('test-project'); - jest.advanceTimersByTime(500); + component.selectSupplementOption(SupplementOptions.CreateNewProject); - expect(getAvailableProjectsSpy).not.toHaveBeenCalled(); - jest.useRealTimers(); + expect(component.selectedSupplementOption()).toBe(SupplementOptions.CreateNewProject); + expect(component.createProjectForm.controls.title.value).toBe(''); + expect(component.createProjectForm.controls.storageLocation.value).toBe(''); + expect(store.dispatch).toHaveBeenCalledWith(new FetchAvailableProjects(null)); }); - it('should handle empty values', () => { - jest.useFakeTimers(); - const getAvailableProjectsSpy = jest.fn(); - Object.defineProperty(component, 'actions', { - value: { getAvailableProjects: getAvailableProjectsSpy }, - writable: true, + it('should select supplement option without resetting form for connect-existing option', () => { + setup({ detectChanges: false }); + component.createProjectForm.patchValue({ [ProjectFormControls.Title]: 'Keep me' }); + + component.selectSupplementOption(SupplementOptions.ConnectExistingProject); + + expect(component.selectedSupplementOption()).toBe(SupplementOptions.ConnectExistingProject); + expect(component.createProjectForm.controls.title.value).toBe('Keep me'); + }); + + it('should dispatch connect project and show success toast on project selection', () => { + setup({ detectChanges: false }); + + component.selectProject({ + value: 'project-1', + originalEvent: new PointerEvent('click'), + } as never); + + expect(component.selectedProjectId()).toBe('project-1'); + expect(store.dispatch).toHaveBeenCalledWith(new ConnectProject('project-1')); + expect(toastMock.showSuccess).toHaveBeenCalledWith( + 'preprints.preprintStepper.supplements.successMessages.projectConnected' + ); + }); + + it('should return early in selectProject when event is not pointer event', () => { + setup({ detectChanges: false }); + + component.selectProject({ + value: 'project-1', + originalEvent: new Event('change'), + } as never); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(ConnectProject)); + expect(toastMock.showSuccess).not.toHaveBeenCalled(); + }); + + it('should disconnect project on confirmation and show success toast', () => { + setup({ detectChanges: false }); + component.selectedProjectId.set('project-1'); + + component.disconnectProject(); + + expect(confirmationMock.confirmDelete).toHaveBeenCalledWith({ + headerKey: 'preprints.preprintStepper.supplements.disconnectProject.header', + messageKey: 'preprints.preprintStepper.supplements.disconnectProject.message', + onConfirm: expect.any(Function), }); - component.ngOnInit(); - component.projectNameControl.setValue(''); - jest.advanceTimersByTime(500); + const { onConfirm } = confirmationMock.confirmDelete.mock.calls[0][0]; + onConfirm(); - expect(getAvailableProjectsSpy).toHaveBeenCalledWith(''); - jest.useRealTimers(); + expect(store.dispatch).toHaveBeenCalledWith(new DisconnectProject()); + expect(component.selectedProjectId()).toBeNull(); + expect(toastMock.showSuccess).toHaveBeenCalledWith( + 'preprints.preprintStepper.supplements.successMessages.projectDisconnected' + ); + }); + + it('should not dispatch disconnect or clear selection when disconnect is cancelled', () => { + setup({ detectChanges: false }); + component.selectedProjectId.set('project-1'); + + component.disconnectProject(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(DisconnectProject)); + expect(component.selectedProjectId()).toBe('project-1'); + expect(toastMock.showSuccess).not.toHaveBeenCalled(); + }); + + it('should return early when create project form is invalid', () => { + setup({ detectChanges: false }); + const emitSpy = jest.spyOn(component.nextClicked, 'emit'); + + component.submitCreateProjectForm(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(CreateNewProject)); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should create project, show success and emit next when form is valid', () => { + setup({ detectChanges: false }); + const emitSpy = jest.spyOn(component.nextClicked, 'emit'); + component.createProjectForm.patchValue({ + [ProjectFormControls.Title]: 'New Project', + [ProjectFormControls.StorageLocation]: 'region-1', + [ProjectFormControls.Affiliations]: ['inst-1'], + [ProjectFormControls.Description]: 'Description', + [ProjectFormControls.Template]: 'template-id', + }); + + component.submitCreateProjectForm(); + + expect(store.dispatch).toHaveBeenCalledWith( + new CreateNewProject('New Project', 'Description', 'template-id', 'region-1', ['inst-1']) + ); + expect(toastMock.showSuccess).toHaveBeenCalledWith( + 'preprints.preprintStepper.supplements.successMessages.projectCreated' + ); + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should submit create project form path in nextButtonClicked for create-new option', () => { + setup({ detectChanges: false }); + const createSpy = jest.spyOn(component, 'submitCreateProjectForm'); + component.selectedSupplementOption.set(SupplementOptions.CreateNewProject); + + component.nextButtonClicked(); + + expect(createSpy).toHaveBeenCalled(); + }); + + it('should emit next and show saved toast in nextButtonClicked for non-create option', () => { + setup({ detectChanges: false }); + const emitSpy = jest.spyOn(component.nextClicked, 'emit'); + component.selectedSupplementOption.set(SupplementOptions.ConnectExistingProject); + + component.nextButtonClicked(); + + expect(toastMock.showSuccess).toHaveBeenCalledWith( + 'preprints.preprintStepper.common.successMessages.preprintSaved' + ); + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should handle discard-changes confirmation callbacks in backButtonClicked', () => { + setup({ detectChanges: false }); + const emitSpy = jest.spyOn(component.backClicked, 'emit'); + component.selectedSupplementOption.set(SupplementOptions.CreateNewProject); + component.createProjectForm.patchValue({ [ProjectFormControls.Title]: 'Has data' }); + + component.backButtonClicked(); + + expect(confirmationMock.confirmContinue).toHaveBeenCalledWith({ + headerKey: 'preprints.preprintStepper.supplements.discardChanges.header', + messageKey: 'preprints.preprintStepper.supplements.discardChanges.message', + onConfirm: expect.any(Function), + onReject: expect.any(Function), + }); + + const { onReject } = confirmationMock.confirmContinue.mock.calls[0][0]; + onReject(); + expect(emitSpy).not.toHaveBeenCalled(); + + const { onConfirm } = confirmationMock.confirmContinue.mock.calls[0][0]; + onConfirm(); + + expect(emitSpy).toHaveBeenCalledTimes(1); + }); + + it('should emit back immediately in backButtonClicked when no create form data', () => { + setup({ detectChanges: false }); + const emitSpy = jest.spyOn(component.backClicked, 'emit'); + + component.backButtonClicked(); + + expect(emitSpy).toHaveBeenCalled(); + expect(confirmationMock.confirmContinue).not.toHaveBeenCalled(); + }); + + it('should compute next button disabled state for create and connect options', () => { + setup({ detectChanges: false }); + component.selectedSupplementOption.set(SupplementOptions.CreateNewProject); + expect(component.isNextButtonDisabled()).toBe(true); + + component.createProjectForm.patchValue({ + [ProjectFormControls.Title]: 'Valid title', + [ProjectFormControls.StorageLocation]: 'region-1', + }); + expect(component.isNextButtonDisabled()).toBe(false); + component.selectedSupplementOption.set(SupplementOptions.ConnectExistingProject); + expect(component.isNextButtonDisabled()).toBe(false); }); }); diff --git a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts index c4e58c40b..04fbb95c3 100644 --- a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts +++ b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts @@ -61,29 +61,33 @@ import { ProjectForm } from '@shared/models/projects/create-project-form.model'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SupplementsStepComponent implements OnInit { - private customConfirmationService = inject(CustomConfirmationService); + private readonly customConfirmationService = inject(CustomConfirmationService); private readonly toastService = inject(ToastService); - private actions = createDispatchMap({ + private readonly destroyRef = inject(DestroyRef); + + private readonly actions = createDispatchMap({ getAvailableProjects: FetchAvailableProjects, connectProject: ConnectProject, disconnectProject: DisconnectProject, fetchPreprintProject: FetchPreprintProject, createNewProject: CreateNewProject, }); - private destroyRef = inject(DestroyRef); - - readonly SupplementOptions = SupplementOptions; - createdPreprint = select(PreprintStepperSelectors.getPreprint); - isPreprintSubmitting = select(PreprintStepperSelectors.isPreprintSubmitting); - availableProjects = select(PreprintStepperSelectors.getAvailableProjects); - areAvailableProjectsLoading = select(PreprintStepperSelectors.areAvailableProjectsLoading); - preprintProject = select(PreprintStepperSelectors.getPreprintProject); - isPreprintProjectLoading = select(PreprintStepperSelectors.isPreprintProjectLoading); + readonly createdPreprint = select(PreprintStepperSelectors.getPreprint); + readonly isPreprintSubmitting = select(PreprintStepperSelectors.isPreprintSubmitting); + readonly availableProjects = select(PreprintStepperSelectors.getAvailableProjects); + readonly areAvailableProjectsLoading = select(PreprintStepperSelectors.areAvailableProjectsLoading); + readonly preprintProject = select(PreprintStepperSelectors.getPreprintProject); + readonly isPreprintProjectLoading = select(PreprintStepperSelectors.isPreprintProjectLoading); selectedSupplementOption = signal(SupplementOptions.None); selectedProjectId = signal(null); + nextClicked = output(); + backClicked = output(); + + readonly SupplementOptions = SupplementOptions; + readonly projectNameControl = new FormControl(null); readonly createProjectForm = new FormGroup({ [ProjectFormControls.Title]: new FormControl('', { @@ -124,20 +128,16 @@ export class SupplementsStepComponent implements OnInit { return; } - untracked(() => { - const preprintProject = this.preprintProject(); - if (preprint.nodeId === preprintProject?.id) { - return; - } - }); + const shouldFetchPreprintProject = untracked(() => preprint.nodeId !== this.preprintProject()?.id); + + if (!shouldFetchPreprintProject) { + return; + } this.actions.fetchPreprintProject(); }); } - nextClicked = output(); - backClicked = output(); - ngOnInit() { this.projectNameControl.valueChanges .pipe(debounceTime(500), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) @@ -164,6 +164,7 @@ export class SupplementsStepComponent implements OnInit { if (!(event.originalEvent instanceof PointerEvent)) { return; } + this.selectedProjectId.set(event.value); this.actions.connectProject(event.value).subscribe({ diff --git a/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.html b/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.html index 01bd85ba5..9091466b8 100644 --- a/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.html +++ b/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.html @@ -64,6 +64,6 @@

{{ 'preprints.preprintStepper.titleAndAbstract.title' | translate }}

tooltipPosition="top" [disabled]="titleAndAbstractForm.invalid" [loading]="isUpdatingPreprint()" - (click)="nextButtonClicked()" + (onClick)="nextButtonClicked()" /> diff --git a/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.spec.ts b/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.spec.ts index a972d6439..95edb3149 100644 --- a/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.spec.ts +++ b/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.spec.ts @@ -1,14 +1,20 @@ -import { MockComponent } from 'ng-mocks'; +import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; + +import { Textarea } from 'primeng/textarea'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; import { TitleAndAbstractStepComponent } from '@osf/features/preprints/components'; import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; import { TextInputComponent } from '@osf/shared/components/text-input/text-input.component'; +import { ToastService } from '@osf/shared/services/toast.service'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; describe('TitleAndAbstractStepComponent', () => { let component: TitleAndAbstractStepComponent; @@ -16,19 +22,20 @@ describe('TitleAndAbstractStepComponent', () => { const mockPreprint = PREPRINT_MOCK; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TitleAndAbstractStepComponent, OSFTestingModule, MockComponent(TextInputComponent)], + function setup(overrides?: { createdPreprint?: typeof mockPreprint | null; providerId?: string }) { + const mockToastService = ToastServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [TitleAndAbstractStepComponent, MockComponent(TextInputComponent), MockDirective(Textarea)], providers: [ + provideOSFCore(), + MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().build()), + MockProvider(ToastService, mockToastService), provideMockStore({ signals: [ { selector: PreprintStepperSelectors.getPreprint, - value: null, - }, - { - selector: PreprintStepperSelectors.getSelectedProviderId, - value: 'provider-1', + value: overrides && 'createdPreprint' in overrides ? overrides.createdPreprint : null, }, { selector: PreprintStepperSelectors.isPreprintSubmitting, @@ -37,137 +44,78 @@ describe('TitleAndAbstractStepComponent', () => { ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(TitleAndAbstractStepComponent); component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize form with empty values', () => { - expect(component.titleAndAbstractForm.get('title')?.value).toBe(''); - expect(component.titleAndAbstractForm.get('description')?.value).toBe(''); - }); - - it('should have form invalid when fields are empty', () => { - expect(component.titleAndAbstractForm.invalid).toBe(true); - }); - - it('should have form valid when fields are filled correctly', () => { + fixture.componentRef.setInput( + 'providerId', + overrides && 'providerId' in overrides ? overrides.providerId : 'provider-1' + ); + fixture.detectChanges(); + } + + function fillValidForm() { component.titleAndAbstractForm.patchValue({ title: 'Valid Title', description: 'Valid description with sufficient length', }); - expect(component.titleAndAbstractForm.valid).toBe(true); - }); + } - it('should validate title max length', () => { - const longTitle = 'a'.repeat(513); - component.titleAndAbstractForm.patchValue({ - title: longTitle, - description: 'Valid description', - }); - expect(component.titleAndAbstractForm.get('title')?.hasError('maxlength')).toBe(true); - }); - - it('should validate description is required', () => { - component.titleAndAbstractForm.patchValue({ - title: 'Valid Title', - description: '', - }); - expect(component.titleAndAbstractForm.get('description')?.hasError('required')).toBe(true); - }); + it('should initialize form with empty values', () => { + setup(); - it('should not proceed when form is invalid', () => { - const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); - component.nextButtonClicked(); - expect(nextClickedSpy).not.toHaveBeenCalled(); + expect(component.titleAndAbstractForm.controls.title.value).toBe(''); + expect(component.titleAndAbstractForm.controls.description.value).toBe(''); + expect(component.titleAndAbstractForm.invalid).toBe(true); }); - it('should emit nextClicked when form is valid and no existing preprint', () => { - component.titleAndAbstractForm.patchValue({ - title: 'Valid Title', - description: 'Valid description with sufficient length', - }); + it('should enforce title and description validation', () => { + setup(); - const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); - component.nextButtonClicked(); - expect(nextClickedSpy).toHaveBeenCalled(); - }); + component.titleAndAbstractForm.patchValue({ title: 'a'.repeat(513), description: 'Valid description' }); + expect(component.titleAndAbstractForm.controls.title.hasError('maxlength')).toBe(true); - it('should initialize form with existing preprint data', () => { - component.titleAndAbstractForm.patchValue({ - title: mockPreprint.title, - description: mockPreprint.description, - }); - expect(component.titleAndAbstractForm.get('title')?.value).toBe(mockPreprint.title); - expect(component.titleAndAbstractForm.get('description')?.value).toBe(mockPreprint.description); - }); + component.titleAndAbstractForm.patchValue({ title: 'Valid title', description: 'Short' }); + expect(component.titleAndAbstractForm.controls.description.hasError('minlength')).toBe(true); - it('should emit nextClicked when form is valid and preprint exists', () => { - component.titleAndAbstractForm.patchValue({ - title: mockPreprint.title, - description: mockPreprint.description, - }); - const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); - component.nextButtonClicked(); - expect(nextClickedSpy).toHaveBeenCalled(); + component.titleAndAbstractForm.patchValue({ title: 'Valid title', description: '' }); + expect(component.titleAndAbstractForm.controls.description.hasError('required')).toBe(true); }); - it('should emit nextClicked when form is valid and no existing preprint', () => { - component.titleAndAbstractForm.patchValue({ - title: 'Test Title', - description: 'Test description with sufficient length', - }); - - const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); - component.nextButtonClicked(); + it('should patch form with existing preprint values', () => { + setup({ createdPreprint: mockPreprint }); - expect(nextClickedSpy).toHaveBeenCalled(); + expect(component.titleAndAbstractForm.controls.title.value).toBe(mockPreprint.title); + expect(component.titleAndAbstractForm.controls.description.value).toBe(mockPreprint.description); }); - it('should emit nextClicked when form is valid and preprint exists', () => { - jest.spyOn(component, 'createdPreprint').mockReturnValue(mockPreprint); - - component.titleAndAbstractForm.patchValue({ - title: 'Updated Title', - description: 'Updated description with sufficient length', - }); + it('should not dispatch or emit when form is invalid', () => { + setup(); + const emitSpy = jest.spyOn(component.nextClicked, 'emit'); - const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); component.nextButtonClicked(); - expect(nextClickedSpy).toHaveBeenCalled(); + expect(emitSpy).not.toHaveBeenCalled(); }); - it('should not emit nextClicked when form is invalid', () => { - const nextClickedSpy = jest.spyOn(component.nextClicked, 'emit'); + it('should create preprint and emit next when form is valid and no preprint exists', () => { + setup({ createdPreprint: null, providerId: 'provider-1' }); + fillValidForm(); + const emitSpy = jest.spyOn(component.nextClicked, 'emit'); component.nextButtonClicked(); - expect(nextClickedSpy).not.toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalled(); }); - it('should have correct form validation for title and description', () => { - component.titleAndAbstractForm.patchValue({ - title: '', - description: 'Valid description', - }); - expect(component.titleAndAbstractForm.get('title')?.hasError('required')).toBe(true); + it('should update preprint and emit next when form is valid and preprint exists', () => { + setup({ createdPreprint: mockPreprint }); + fillValidForm(); + const emitSpy = jest.spyOn(component.nextClicked, 'emit'); - component.titleAndAbstractForm.patchValue({ - title: 'Valid Title', - description: 'Short', - }); - expect(component.titleAndAbstractForm.get('description')?.hasError('minlength')).toBe(true); + component.nextButtonClicked(); - component.titleAndAbstractForm.patchValue({ - title: 'Valid Title', - description: 'Valid description with sufficient length', - }); - expect(component.titleAndAbstractForm.valid).toBe(true); + expect(emitSpy).toHaveBeenCalled(); }); }); diff --git a/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.ts b/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.ts index cadca4dc6..c15c59dd0 100644 --- a/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.ts +++ b/src/app/features/preprints/components/stepper/title-and-abstract-step/title-and-abstract-step.component.ts @@ -8,7 +8,7 @@ import { Message } from 'primeng/message'; import { Textarea } from 'primeng/textarea'; import { Tooltip } from 'primeng/tooltip'; -import { ChangeDetectionStrategy, Component, effect, inject, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, inject, input, output } from '@angular/core'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { RouterLink } from '@angular/router'; @@ -27,30 +27,36 @@ import { ToastService } from '@osf/shared/services/toast.service'; @Component({ selector: 'osf-title-and-abstract-step', imports: [ - Card, - FormsModule, Button, + Card, Textarea, RouterLink, - ReactiveFormsModule, Tooltip, Message, - TranslatePipe, + FormsModule, + ReactiveFormsModule, TextInputComponent, + TranslatePipe, ], templateUrl: './title-and-abstract-step.component.html', styleUrl: './title-and-abstract-step.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class TitleAndAbstractStepComponent { - private toastService = inject(ToastService); + private readonly toastService = inject(ToastService); + + readonly providerId = input.required(); + readonly nextClicked = output(); - private actions = createDispatchMap({ + private readonly actions = createDispatchMap({ createPreprint: CreatePreprint, updatePreprint: UpdatePreprint, }); - inputLimits = formInputLimits; + readonly createdPreprint = select(PreprintStepperSelectors.getPreprint); + readonly isUpdatingPreprint = select(PreprintStepperSelectors.isPreprintSubmitting); + + readonly inputLimits = formInputLimits; readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; titleAndAbstractForm = new FormGroup({ @@ -68,12 +74,6 @@ export class TitleAndAbstractStepComponent { }), }); - createdPreprint = select(PreprintStepperSelectors.getPreprint); - providerId = select(PreprintStepperSelectors.getSelectedProviderId); - - isUpdatingPreprint = select(PreprintStepperSelectors.isPreprintSubmitting); - nextClicked = output(); - constructor() { effect(() => { const createdPreprint = this.createdPreprint(); @@ -86,22 +86,23 @@ export class TitleAndAbstractStepComponent { }); } - nextButtonClicked() { + nextButtonClicked(): void { if (this.titleAndAbstractForm.invalid) { return; } - const model = this.titleAndAbstractForm.value; + const model = this.titleAndAbstractForm.getRawValue(); + const createdPreprint = this.createdPreprint(); - if (this.createdPreprint()) { - this.actions.updatePreprint(this.createdPreprint()!.id, model).subscribe({ + if (createdPreprint) { + this.actions.updatePreprint(createdPreprint.id, model).subscribe({ complete: () => { this.nextClicked.emit(); this.toastService.showSuccess('preprints.preprintStepper.common.successMessages.preprintSaved'); }, }); } else { - this.actions.createPreprint(model.title!, model.description!, this.providerId()!).subscribe({ + this.actions.createPreprint(model.title, model.description, this.providerId()).subscribe({ complete: () => { this.nextClicked.emit(); this.toastService.showSuccess('preprints.preprintStepper.common.successMessages.preprintSaved'); diff --git a/src/app/features/preprints/guards/index.ts b/src/app/features/preprints/guards/index.ts deleted file mode 100644 index 1b75c6aed..000000000 --- a/src/app/features/preprints/guards/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './preprints-moderator.guard'; diff --git a/src/app/features/preprints/guards/preprints-moderator.guard.spec.ts b/src/app/features/preprints/guards/preprints-moderator.guard.spec.ts new file mode 100644 index 000000000..45d1e29a8 --- /dev/null +++ b/src/app/features/preprints/guards/preprints-moderator.guard.spec.ts @@ -0,0 +1,53 @@ +import { MockProvider } from 'ng-mocks'; + +import { runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; + +import { UserSelectors } from '@core/store/user'; + +import { preprintsModeratorGuard } from './preprints-moderator.guard'; + +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('preprintsModeratorGuard', () => { + let routerMock: RouterMockType; + const routeSnapshot = {} as ActivatedRouteSnapshot; + const stateSnapshot = {} as RouterStateSnapshot; + + function setup(canViewReviews: boolean) { + const urlTree = {} as UrlTree; + + routerMock = RouterMockBuilder.create().withCreateUrlTree(jest.fn().mockReturnValue(urlTree)).build(); + + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [{ selector: UserSelectors.getCanViewReviews, value: canViewReviews }], + }), + MockProvider(Router, routerMock), + ], + }); + + return { urlTree }; + } + + it('should allow activation when user can view reviews', () => { + setup(true); + + const result = runInInjectionContext(TestBed, () => preprintsModeratorGuard(routeSnapshot, stateSnapshot)); + + expect(result).toBe(true); + expect(routerMock.createUrlTree).not.toHaveBeenCalled(); + }); + + it('should return forbidden UrlTree when user cannot view reviews', () => { + const { urlTree } = setup(false); + + const result = runInInjectionContext(TestBed, () => preprintsModeratorGuard(routeSnapshot, stateSnapshot)); + + expect(routerMock.createUrlTree).toHaveBeenCalledWith(['/forbidden']); + expect(result).toBe(urlTree); + }); +}); diff --git a/src/app/features/preprints/guards/preprints-moderator.guard.ts b/src/app/features/preprints/guards/preprints-moderator.guard.ts index 4a3b625eb..a6fba2a91 100644 --- a/src/app/features/preprints/guards/preprints-moderator.guard.ts +++ b/src/app/features/preprints/guards/preprints-moderator.guard.ts @@ -12,8 +12,8 @@ export const preprintsModeratorGuard: CanActivateFn = () => { const canUserViewReviews = store.selectSnapshot(UserSelectors.getCanViewReviews); if (!canUserViewReviews) { - router.navigateByUrl('/forbidden'); + return router.createUrlTree(['/forbidden']); } - return canUserViewReviews; + return true; }; diff --git a/src/app/features/preprints/mappers/preprint-providers.mapper.ts b/src/app/features/preprints/mappers/preprint-providers.mapper.ts index dbddfde22..c9fa25ac2 100644 --- a/src/app/features/preprints/mappers/preprint-providers.mapper.ts +++ b/src/app/features/preprints/mappers/preprint-providers.mapper.ts @@ -57,8 +57,8 @@ export class PreprintProvidersMapper { ): PreprintProviderShortInfo[] { return response.map((item) => ({ id: item.id, - descriptionHtml: item.attributes.description, - name: item.attributes.name, + name: replaceBadEncodedChars(item.attributes.name), + descriptionHtml: replaceBadEncodedChars(item.attributes.description), whiteWideImageUrl: item.attributes.assets?.wide_white, squareColorNoTransparentImageUrl: item.attributes.assets?.square_color_no_transparent, })); diff --git a/src/app/features/preprints/mappers/preprints.mapper.ts b/src/app/features/preprints/mappers/preprints.mapper.ts index 61dd10329..ed01f2be9 100644 --- a/src/app/features/preprints/mappers/preprints.mapper.ts +++ b/src/app/features/preprints/mappers/preprints.mapper.ts @@ -47,7 +47,7 @@ export class PreprintsMapper { datePublished: response.attributes.date_published, dateLastTransitioned: response.attributes.date_last_transitioned, title: replaceBadEncodedChars(response.attributes.title), - description: response.attributes.description, + description: replaceBadEncodedChars(response.attributes.description), reviewsState: response.attributes.reviews_state, preprintDoiCreated: response.attributes.preprint_doi_created, currentUserPermissions: response.attributes.current_user_permissions, @@ -71,18 +71,21 @@ export class PreprintsMapper { } : null, hasCoi: response.attributes.has_coi, - coiStatement: response.attributes.conflict_of_interest_statement, + coiStatement: response.attributes.conflict_of_interest_statement + ? replaceBadEncodedChars(response.attributes.conflict_of_interest_statement) + : null, hasDataLinks: response.attributes.has_data_links, dataLinks: response.attributes.data_links, - whyNoData: response.attributes.why_no_data, + whyNoData: response.attributes.why_no_data ? replaceBadEncodedChars(response.attributes.why_no_data) : null, hasPreregLinks: response.attributes.has_prereg_links, - whyNoPrereg: response.attributes.why_no_prereg, + whyNoPrereg: response.attributes.why_no_prereg ? replaceBadEncodedChars(response.attributes.why_no_prereg) : null, preregLinks: response.attributes.prereg_links, preregLinkInfo: response.attributes.prereg_link_info, preprintDoiLink: response.links.preprint_doi, articleDoiLink: response.links.doi, embeddedLicense: null, providerId: response.relationships?.provider?.data?.id, + defaultLicenseId: response.attributes.default_license_id, }; } @@ -105,7 +108,7 @@ export class PreprintsMapper { datePublished: data.attributes.date_published, dateLastTransitioned: data.attributes.date_last_transitioned, title: replaceBadEncodedChars(data.attributes.title), - description: data.attributes.description, + description: replaceBadEncodedChars(data.attributes.description), reviewsState: data.attributes.reviews_state, preprintDoiCreated: data.attributes.preprint_doi_created, currentUserPermissions: data.attributes.current_user_permissions, @@ -129,12 +132,14 @@ export class PreprintsMapper { } : null, hasCoi: data.attributes.has_coi, - coiStatement: data.attributes.conflict_of_interest_statement, + coiStatement: data.attributes.conflict_of_interest_statement + ? replaceBadEncodedChars(data.attributes.conflict_of_interest_statement) + : null, hasDataLinks: data.attributes.has_data_links, dataLinks: data.attributes.data_links, - whyNoData: data.attributes.why_no_data, + whyNoData: data.attributes.why_no_data ? replaceBadEncodedChars(data.attributes.why_no_data) : null, hasPreregLinks: data.attributes.has_prereg_links, - whyNoPrereg: data.attributes.why_no_prereg, + whyNoPrereg: data.attributes.why_no_prereg ? replaceBadEncodedChars(data.attributes.why_no_prereg) : null, preregLinks: data.attributes.prereg_links, preregLinkInfo: data.attributes.prereg_link_info, embeddedLicense: LicensesMapper.fromLicenseDataJsonApi(data.embeds?.license?.data), diff --git a/src/app/features/preprints/models/index.ts b/src/app/features/preprints/models/index.ts index 9131b75cb..5c556d995 100644 --- a/src/app/features/preprints/models/index.ts +++ b/src/app/features/preprints/models/index.ts @@ -1,10 +1,10 @@ -export * from './preprint.models'; -export * from './preprint-json-api.models'; -export * from './preprint-licenses-json-api.models'; -export * from './preprint-provider.models'; -export * from './preprint-provider-json-api.models'; -export * from './preprint-request.models'; -export * from './preprint-request-action.models'; -export * from './preprint-request-action-json-api.models'; -export * from './preprint-request-json-api.models'; -export * from './submit-preprint-form.models'; +export * from './preprint.model'; +export * from './preprint-json-api.model'; +export * from './preprint-licenses-json-api.model'; +export * from './preprint-provider.model'; +export * from './preprint-provider-json-api.model'; +export * from './preprint-request.model'; +export * from './preprint-request-action.model'; +export * from './preprint-request-action-json-api.model'; +export * from './preprint-request-json-api.model'; +export * from './submit-preprint-form.model'; diff --git a/src/app/features/preprints/models/preprint-json-api.models.ts b/src/app/features/preprints/models/preprint-json-api.model.ts similarity index 98% rename from src/app/features/preprints/models/preprint-json-api.models.ts rename to src/app/features/preprints/models/preprint-json-api.model.ts index 9761ef0fa..3fa417f48 100644 --- a/src/app/features/preprints/models/preprint-json-api.models.ts +++ b/src/app/features/preprints/models/preprint-json-api.model.ts @@ -37,6 +37,7 @@ export interface PreprintAttributesJsonApi { why_no_prereg: StringOrNull; prereg_links: string[]; prereg_link_info: PreregLinkInfo | null; + default_license_id: string; } export interface PreprintRelationshipsJsonApi { diff --git a/src/app/features/preprints/models/preprint-licenses-json-api.models.ts b/src/app/features/preprints/models/preprint-licenses-json-api.model.ts similarity index 100% rename from src/app/features/preprints/models/preprint-licenses-json-api.models.ts rename to src/app/features/preprints/models/preprint-licenses-json-api.model.ts diff --git a/src/app/features/preprints/models/preprint-provider-json-api.models.ts b/src/app/features/preprints/models/preprint-provider-json-api.model.ts similarity index 94% rename from src/app/features/preprints/models/preprint-provider-json-api.models.ts rename to src/app/features/preprints/models/preprint-provider-json-api.model.ts index 0dffb8a70..e71cc5ee2 100644 --- a/src/app/features/preprints/models/preprint-provider-json-api.models.ts +++ b/src/app/features/preprints/models/preprint-provider-json-api.model.ts @@ -4,7 +4,7 @@ import { BrandDataJsonApi } from '@osf/shared/models/brand/brand.json-api.model' import { ProviderReviewsWorkflow } from '../enums'; -import { PreprintWord } from './preprint-provider.models'; +import { PreprintWord } from './preprint-provider.model'; export interface PreprintProviderDetailsJsonApi { id: string; diff --git a/src/app/features/preprints/models/preprint-provider.models.ts b/src/app/features/preprints/models/preprint-provider.model.ts similarity index 100% rename from src/app/features/preprints/models/preprint-provider.models.ts rename to src/app/features/preprints/models/preprint-provider.model.ts diff --git a/src/app/features/preprints/models/preprint-request-action-json-api.models.ts b/src/app/features/preprints/models/preprint-request-action-json-api.model.ts similarity index 100% rename from src/app/features/preprints/models/preprint-request-action-json-api.models.ts rename to src/app/features/preprints/models/preprint-request-action-json-api.model.ts diff --git a/src/app/features/preprints/models/preprint-request-action.models.ts b/src/app/features/preprints/models/preprint-request-action.model.ts similarity index 100% rename from src/app/features/preprints/models/preprint-request-action.models.ts rename to src/app/features/preprints/models/preprint-request-action.model.ts diff --git a/src/app/features/preprints/models/preprint-request-json-api.models.ts b/src/app/features/preprints/models/preprint-request-json-api.model.ts similarity index 100% rename from src/app/features/preprints/models/preprint-request-json-api.models.ts rename to src/app/features/preprints/models/preprint-request-json-api.model.ts diff --git a/src/app/features/preprints/models/preprint-request.models.ts b/src/app/features/preprints/models/preprint-request.model.ts similarity index 100% rename from src/app/features/preprints/models/preprint-request.models.ts rename to src/app/features/preprints/models/preprint-request.model.ts diff --git a/src/app/features/preprints/models/preprint.models.ts b/src/app/features/preprints/models/preprint.model.ts similarity index 98% rename from src/app/features/preprints/models/preprint.models.ts rename to src/app/features/preprints/models/preprint.model.ts index 527c1a76d..e966e60ce 100644 --- a/src/app/features/preprints/models/preprint.models.ts +++ b/src/app/features/preprints/models/preprint.model.ts @@ -47,6 +47,7 @@ export interface PreprintModel { articleDoiLink?: string; identifiers?: IdentifierModel[]; providerId: string; + defaultLicenseId?: string; } export interface PreprintFilesLinks { diff --git a/src/app/features/preprints/models/submit-preprint-form.models.ts b/src/app/features/preprints/models/submit-preprint-form.model.ts similarity index 100% rename from src/app/features/preprints/models/submit-preprint-form.models.ts rename to src/app/features/preprints/models/submit-preprint-form.model.ts diff --git a/src/app/features/preprints/pages/create-new-version/create-new-version.component.html b/src/app/features/preprints/pages/create-new-version/create-new-version.component.html index 9d7f9fd03..42bc5e0e6 100644 --- a/src/app/features/preprints/pages/create-new-version/create-new-version.component.html +++ b/src/app/features/preprints/pages/create-new-version/create-new-version.component.html @@ -6,9 +6,9 @@ } @else {

{{ 'preprints.createNewVersionTitle' | translate }} @@ -30,17 +30,17 @@

} -
- @switch (currentStep().value) { - @case (PreprintSteps.File) { - - } - @case (PreprintSteps.Review) { - +@let provider = preprintProvider(); + +@if (provider) { +
+ @switch (currentStep().value) { + @case (PreprintSteps.File) { + + } + @case (PreprintSteps.Review) { + + } } - } -
+
+} diff --git a/src/app/features/preprints/pages/create-new-version/create-new-version.component.spec.ts b/src/app/features/preprints/pages/create-new-version/create-new-version.component.spec.ts index 2f76e66ea..eaeaa8b2d 100644 --- a/src/app/features/preprints/pages/create-new-version/create-new-version.component.spec.ts +++ b/src/app/features/preprints/pages/create-new-version/create-new-version.component.spec.ts @@ -1,3 +1,5 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; @@ -7,7 +9,6 @@ import { ActivatedRoute, Router } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; -import { StepOption } from '@osf/shared/models/step-option.model'; import { BrandService } from '@osf/shared/services/brand.service'; import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; import { HeaderStyleService } from '@osf/shared/services/header-style.service'; @@ -16,181 +17,180 @@ import { FileStepComponent, ReviewStepComponent } from '../../components'; import { createNewVersionStepsConst } from '../../constants'; import { PreprintSteps } from '../../enums'; import { PreprintProviderDetails } from '../../models'; -import { PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { PreprintStepperSelectors } from '../../store/preprint-stepper'; +import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; +import { FetchPreprintById, PreprintStepperSelectors, ResetPreprintStepperState } from '../../store/preprint-stepper'; import { CreateNewVersionComponent } from './create-new-version.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 { BrandServiceMock, BrandServiceMockType } from '@testing/providers/brand-service.mock'; +import { BrowserTabServiceMock, BrowserTabServiceMockType } from '@testing/providers/browser-tab-service.mock'; +import { HeaderStyleServiceMock, HeaderStyleServiceMockType } from '@testing/providers/header-style-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; describe('CreateNewVersionComponent', () => { let component: CreateNewVersionComponent; let fixture: ComponentFixture; - let routerMock: ReturnType; - let routeMock: ReturnType; + let store: Store; + let routerMock: RouterMockType; + let brandServiceMock: BrandServiceMockType; + let headerStyleMock: HeaderStyleServiceMockType; + let browserTabMock: BrowserTabServiceMockType; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; - const mockPreprint = PREPRINT_MOCK; const mockProviderId = 'osf'; const mockPreprintId = 'test_preprint_123'; - beforeEach(async () => { + const defaultSignals: SignalOverride[] = [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: mockProvider }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, + { selector: PreprintStepperSelectors.hasBeenSubmitted, value: false }, + ]; + + function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); - routeMock = ActivatedRouteMockBuilder.create() + const routeMock = ActivatedRouteMockBuilder.create() .withParams({ providerId: mockProviderId, preprintId: mockPreprintId }) .withQueryParams({}) .build(); - await TestBed.configureTestingModule({ - imports: [ - CreateNewVersionComponent, - OSFTestingModule, - ...MockComponents(StepperComponent, FileStepComponent, ReviewStepComponent), - ], + brandServiceMock = BrandServiceMock.simple(); + headerStyleMock = HeaderStyleServiceMock.simple(); + browserTabMock = BrowserTabServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [CreateNewVersionComponent, ...MockComponents(StepperComponent, FileStepComponent, ReviewStepComponent)], providers: [ - TranslationServiceMock, - MockProvider(BrandService), - MockProvider(BrowserTabService), - MockProvider(HeaderStyleService), - MockProvider(Router, routerMock), + provideOSFCore(), MockProvider(ActivatedRoute, routeMock), + MockProvider(Router, routerMock), + MockProvider(BrandService, brandServiceMock), + MockProvider(HeaderStyleService, headerStyleMock), + MockProvider(BrowserTabService, browserTabMock), MockProvider(IS_WEB, of(true)), - provideMockStore({ - signals: [ - { - selector: PreprintStepperSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), - value: mockProvider, - }, - { - selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, - value: false, - }, - { - selector: PreprintStepperSelectors.hasBeenSubmitted, - value: false, - }, - ], - }), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(CreateNewVersionComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); - - afterEach(() => { - if (fixture) { - fixture.destroy(); - } - jest.restoreAllMocks(); - }); + } it('should initialize with correct default values', () => { + setup(); + expect(component.PreprintSteps).toBe(PreprintSteps); expect(component.newVersionSteps).toBe(createNewVersionStepsConst); expect(component.currentStep()).toEqual(createNewVersionStepsConst[0]); expect(component.classes).toBe('flex-1 flex flex-column w-full'); }); - it('should return preprint from store', () => { - const preprint = component.preprint(); - expect(preprint).toBe(mockPreprint); - }); + it('should dispatch initial actions on creation', () => { + setup(); - it('should return preprint provider from store', () => { - const provider = component.preprintProvider(); - expect(provider).toBe(mockProvider); + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProviderById(mockProviderId)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintById(mockPreprintId)); }); - it('should return loading state from store', () => { - const loading = component.isPreprintProviderLoading(); - expect(loading).toBe(false); - }); + it('should apply branding when provider is available', () => { + setup(); - it('should return submission state from store', () => { - const submitted = component.hasBeenSubmitted(); - expect(submitted).toBe(false); + expect(brandServiceMock.applyBranding).toHaveBeenCalledWith(mockProvider.brand); + expect(headerStyleMock.applyHeaderStyles).toHaveBeenCalledWith( + mockProvider.brand.primaryColor, + mockProvider.brand.secondaryColor, + mockProvider.brand.heroBackgroundImageUrl + ); + expect(browserTabMock.updateTabStyles).toHaveBeenCalledWith(mockProvider.faviconUrl, mockProvider.name); }); - it('should return web environment state', () => { - const isWeb = component.isWeb(); - expect(typeof isWeb).toBe('boolean'); - }); + it('should reset services on destroy', () => { + setup(); - it('should initialize with first step as current step', () => { - expect(component.currentStep()).toEqual(createNewVersionStepsConst[0]); + component.ngOnDestroy(); + + expect(headerStyleMock.resetToDefaults).toHaveBeenCalled(); + expect(brandServiceMock.resetBranding).toHaveBeenCalled(); + expect(browserTabMock.resetToDefaults).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new ResetPreprintStepperState()); }); - it('should handle step change when moving to previous step', () => { - const previousStep = createNewVersionStepsConst[0]; + it('should prevent beforeunload when not submitted', () => { + setup(); + const event = { preventDefault: jest.fn() } as unknown as BeforeUnloadEvent; - component.stepChange(previousStep); + component.onBeforeUnload(event); - expect(component.currentStep()).toEqual(previousStep); + expect(event.preventDefault).toHaveBeenCalled(); }); - it('should not change step when moving to next step', () => { - const currentStep = component.currentStep(); - const nextStep = createNewVersionStepsConst[1]; + it('should not prevent beforeunload when submitted', () => { + setup({ selectorOverrides: [{ selector: PreprintStepperSelectors.hasBeenSubmitted, value: true }] }); + const event = { preventDefault: jest.fn() } as unknown as BeforeUnloadEvent; - component.stepChange(nextStep); + component.onBeforeUnload(event); - expect(component.currentStep()).toEqual(currentStep); + expect(event.preventDefault).not.toHaveBeenCalled(); }); - it('should move to next step', () => { - const currentIndex = component.currentStep()?.index ?? 0; - const nextStep = createNewVersionStepsConst[currentIndex + 1]; - - component.moveToNextStep(); + it('should prevent deactivation when not submitted', () => { + setup(); - expect(component.currentStep()).toEqual(nextStep); + expect(component.canDeactivate()).toBe(false); }); - it('should navigate to previous step (preprint page)', () => { - component.moveToPreviousStep(); + it('should allow deactivation when submitted', () => { + setup({ selectorOverrides: [{ selector: PreprintStepperSelectors.hasBeenSubmitted, value: true }] }); - expect(routerMock.navigate).toHaveBeenCalledWith([mockPreprintId.split('_')[0]]); + expect(component.canDeactivate()).toBe(true); }); - it('should return canDeactivate state', () => { - const canDeactivate = component.canDeactivate(); - expect(canDeactivate).toBe(false); + it('should ignore stepping forward via stepper', () => { + setup(); + + component.stepChange(createNewVersionStepsConst[1]); + + expect(component.currentStep()).toEqual(createNewVersionStepsConst[0]); }); - it('should handle beforeunload event', () => { - const event = { - preventDefault: jest.fn(), - } as unknown as BeforeUnloadEvent; + it('should allow stepping back via stepper', () => { + setup(); + component.moveToNextStep(); - const result = component.onBeforeUnload(event); + component.stepChange(createNewVersionStepsConst[0]); - expect(event.preventDefault).toHaveBeenCalled(); - expect(result).toBe(false); + expect(component.currentStep()).toEqual(createNewVersionStepsConst[0]); }); - it('should handle step navigation correctly', () => { + it('should move to next step', () => { + setup(); + component.moveToNextStep(); + expect(component.currentStep()).toEqual(createNewVersionStepsConst[1]); + }); - component.stepChange(createNewVersionStepsConst[0]); - expect(component.currentStep()).toEqual(createNewVersionStepsConst[0]); + it('should not move past the last step', () => { + setup(); + component.currentStep.set(createNewVersionStepsConst[createNewVersionStepsConst.length - 1]); + + component.moveToNextStep(); + + expect(component.currentStep()).toEqual(createNewVersionStepsConst[createNewVersionStepsConst.length - 1]); }); - it('should handle edge case when moving to next step with undefined current step', () => { - component.currentStep.set({} as StepOption); + it('should navigate back to preprint page', () => { + setup(); - expect(() => component.moveToNextStep()).not.toThrow(); + component.navigateBack(); + + expect(routerMock.navigate).toHaveBeenCalledWith([mockPreprintId.split('_')[0]]); }); }); diff --git a/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts b/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts index 253a9d8df..70176a373 100644 --- a/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts +++ b/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts @@ -4,7 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Skeleton } from 'primeng/skeleton'; -import { map, Observable, of } from 'rxjs'; +import { map } from 'rxjs'; import { ChangeDetectionStrategy, @@ -14,7 +14,6 @@ import { HostListener, inject, OnDestroy, - OnInit, signal, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -32,12 +31,7 @@ import { FileStepComponent, ReviewStepComponent } from '../../components'; import { createNewVersionStepsConst } from '../../constants'; import { PreprintSteps } from '../../enums'; import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { - FetchPreprintById, - PreprintStepperSelectors, - ResetPreprintStepperState, - SetSelectedPreprintProviderId, -} from '../../store/preprint-stepper'; +import { FetchPreprintById, PreprintStepperSelectors, ResetPreprintStepperState } from '../../store/preprint-stepper'; @Component({ selector: 'osf-create-new-version', @@ -46,41 +40,42 @@ import { styleUrl: './create-new-version.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CreateNewVersionComponent implements OnInit, OnDestroy, CanDeactivateComponent { +export class CreateNewVersionComponent implements OnDestroy, CanDeactivateComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; - private route = inject(ActivatedRoute); - private router = inject(Router); - private brandService = inject(BrandService); - private headerStyleHelper = inject(HeaderStyleService); - private browserTabHelper = inject(BrowserTabService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly brandService = inject(BrandService); + private readonly headerStyleHelper = inject(HeaderStyleService); + private readonly browserTabHelper = inject(BrowserTabService); - private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId'])) ?? of(undefined)); - private preprintId = toSignal(this.route.params.pipe(map((params) => params['preprintId'])) ?? of(undefined)); + private readonly providerId = toSignal(this.route.params.pipe(map((params) => params['providerId']))); + private readonly preprintId = toSignal(this.route.params.pipe(map((params) => params['preprintId']))); - private actions = createDispatchMap({ + private readonly actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, - setSelectedPreprintProviderId: SetSelectedPreprintProviderId, - resetState: ResetPreprintStepperState, fetchPreprint: FetchPreprintById, + resetState: ResetPreprintStepperState, }); - readonly PreprintSteps = PreprintSteps; - readonly newVersionSteps = createNewVersionStepsConst; + readonly preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); + readonly isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); + readonly hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted); - preprint = select(PreprintStepperSelectors.getPreprint); - preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); - isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); - hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted); currentStep = signal(createNewVersionStepsConst[0]); isWeb = toSignal(inject(IS_WEB)); + readonly PreprintSteps = PreprintSteps; + readonly newVersionSteps = createNewVersionStepsConst; + constructor() { + this.actions.getPreprintProviderById(this.providerId()); + this.actions.fetchPreprint(this.preprintId()); + effect(() => { const provider = this.preprintProvider(); if (provider) { - this.actions.setSelectedPreprintProviderId(provider.id); this.brandService.applyBranding(provider.brand); this.headerStyleHelper.applyHeaderStyles( provider.brand.primaryColor, @@ -93,14 +88,10 @@ export class CreateNewVersionComponent implements OnInit, OnDestroy, CanDeactiva } @HostListener('window:beforeunload', ['$event']) - onBeforeUnload($event: BeforeUnloadEvent): boolean { - $event.preventDefault(); - return false; - } - - ngOnInit() { - this.actions.getPreprintProviderById(this.providerId()); - this.actions.fetchPreprint(this.preprintId()); + onBeforeUnload($event: BeforeUnloadEvent): void { + if (!this.hasBeenSubmitted()) { + $event.preventDefault(); + } } ngOnDestroy() { @@ -110,25 +101,31 @@ export class CreateNewVersionComponent implements OnInit, OnDestroy, CanDeactiva this.actions.resetState(); } - canDeactivate(): Observable | boolean { + canDeactivate(): boolean { return this.hasBeenSubmitted(); } stepChange(step: StepOption): void { - const currentStepIndex = this.currentStep()?.index ?? 0; - if (step.index >= currentStepIndex) { + if (step.index >= this.currentStep().index) { return; } this.currentStep.set(step); } - moveToNextStep() { - this.currentStep.set(this.newVersionSteps[this.currentStep()?.index + 1]); + moveToNextStep(): void { + const nextStep = this.newVersionSteps[this.currentStep().index + 1]; + + if (nextStep) { + this.currentStep.set(nextStep); + } } - moveToPreviousStep() { - const id = this.preprintId().split('_')[0]; - this.router.navigate([id]); + navigateBack(): void { + const id = this.preprintId()?.split('_')[0]; + + if (id) { + this.router.navigate([id]); + } } } diff --git a/src/app/features/preprints/pages/index.ts b/src/app/features/preprints/pages/index.ts deleted file mode 100644 index e79f5f462..000000000 --- a/src/app/features/preprints/pages/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { PreprintsLandingComponent } from '@osf/features/preprints/pages/landing/preprints-landing.component'; -export { PreprintProviderDiscoverComponent } from '@osf/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component'; -export { PreprintProviderOverviewComponent } from '@osf/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component'; diff --git a/src/app/features/preprints/pages/landing/preprints-landing.component.spec.ts b/src/app/features/preprints/pages/landing/preprints-landing.component.spec.ts deleted file mode 100644 index 6be95e3fb..000000000 --- a/src/app/features/preprints/pages/landing/preprints-landing.component.spec.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { MockComponents, MockPipe, MockProvider } from 'ng-mocks'; - -import { TitleCasePipe } from '@angular/common'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Router } from '@angular/router'; - -import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { BrandService } from '@osf/shared/services/brand.service'; - -import { AdvisoryBoardComponent, BrowseBySubjectsComponent, PreprintServicesComponent } from '../../components'; -import { PreprintProviderDetails } from '../../models'; -import { PreprintProvidersSelectors } from '../../store/preprint-providers'; - -import { PreprintsLandingComponent } from './preprints-landing.component'; - -import { EnvironmentTokenMock } from '@testing/mocks/environment.token.mock'; -import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; -import { PREPRINT_PROVIDER_SHORT_INFO_MOCK } from '@testing/mocks/preprint-provider-short-info.mock'; -import { SUBJECTS_MOCK } from '@testing/mocks/subject.mock'; -import { TranslationServiceMock } from '@testing/mocks/translation.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; - -describe('PreprintsLandingComponent', () => { - let component: PreprintsLandingComponent; - let fixture: ComponentFixture; - let routerMock: ReturnType; - - const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; - const mockProvidersToAdvertise = [PREPRINT_PROVIDER_SHORT_INFO_MOCK]; - const mockHighlightedSubjects = SUBJECTS_MOCK; - const mockDefaultProvider = 'osf'; - - beforeEach(async () => { - routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); - - await TestBed.configureTestingModule({ - imports: [ - PreprintsLandingComponent, - OSFTestingModule, - ...MockComponents( - SearchInputComponent, - AdvisoryBoardComponent, - PreprintServicesComponent, - BrowseBySubjectsComponent - ), - MockPipe(TitleCasePipe), - ], - providers: [ - TranslationServiceMock, - EnvironmentTokenMock, - MockProvider(BrandService), - MockProvider(Router, routerMock), - provideMockStore({ - signals: [ - { - selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockDefaultProvider), - value: mockProvider, - }, - { - selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, - value: false, - }, - { - selector: PreprintProvidersSelectors.getPreprintProvidersToAdvertise, - value: mockProvidersToAdvertise, - }, - { - selector: PreprintProvidersSelectors.getHighlightedSubjectsForProvider, - value: mockHighlightedSubjects, - }, - { - selector: PreprintProvidersSelectors.areSubjectsLoading, - value: false, - }, - ], - }), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(PreprintsLandingComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize with correct default values', () => { - expect(component.searchControl.value).toBe(''); - expect(component.supportEmail).toBeDefined(); - }); - - it('should return preprint provider from store', () => { - const provider = component.osfPreprintProvider(); - expect(provider).toBe(mockProvider); - }); - - it('should return loading state from store', () => { - const loading = component.isPreprintProviderLoading(); - expect(loading).toBe(false); - }); - - it('should return providers to advertise from store', () => { - const providers = component.preprintProvidersToAdvertise(); - expect(providers).toBe(mockProvidersToAdvertise); - }); - - it('should return highlighted subjects from store', () => { - const subjects = component.highlightedSubjectsByProviderId(); - expect(subjects).toBe(mockHighlightedSubjects); - }); - - it('should return subjects loading state from store', () => { - const loading = component.areSubjectsLoading(); - expect(loading).toBe(false); - }); - - it('should have correct CSS classes', () => { - expect(component.classes).toBe('flex-1 flex flex-column w-full h-full'); - }); - - it('should navigate to search page with search value', () => { - component.searchControl.setValue('test search'); - - component.redirectToSearchPageWithValue(); - - expect(routerMock.navigate).toHaveBeenCalledWith(['/search'], { - queryParams: { search: 'test search', tab: ResourceType.Preprint }, - }); - }); - - it('should navigate to search page with empty search value', () => { - component.searchControl.setValue(''); - - component.redirectToSearchPageWithValue(); - - expect(routerMock.navigate).toHaveBeenCalledWith(['/search'], { - queryParams: { search: '', tab: ResourceType.Preprint }, - }); - }); - - it('should navigate to search page with null search value', () => { - component.searchControl.setValue(null); - - component.redirectToSearchPageWithValue(); - - expect(routerMock.navigate).toHaveBeenCalledWith(['/search'], { - queryParams: { search: null, tab: ResourceType.Preprint }, - }); - }); - - it('should handle search control value changes', () => { - const testValue = 'new search term'; - component.searchControl.setValue(testValue); - expect(component.searchControl.value).toBe(testValue); - }); - - it('should have readonly properties', () => { - expect(component.supportEmail).toBeDefined(); - expect(typeof component.supportEmail).toBe('string'); - }); - - it('should initialize form control correctly', () => { - expect(component.searchControl).toBeDefined(); - expect(component.searchControl.value).toBe(''); - }); -}); diff --git a/src/app/features/preprints/pages/my-preprints/my-preprints.component.html b/src/app/features/preprints/pages/my-preprints/my-preprints.component.html index 3bb682bad..ba97b2d2a 100644 --- a/src/app/features/preprints/pages/my-preprints/my-preprints.component.html +++ b/src/app/features/preprints/pages/my-preprints/my-preprints.component.html @@ -4,7 +4,7 @@ [icon]="'custom-icon-preprints-dark'" [showButton]="true" [buttonLabel]="'preprints.addPreprint' | translate: { preprintWord: 'preprint' | titlecase }" - (buttonClick)="addPreprintBtnClicked()" + (buttonClick)="navigateToAddPreprint()" />
@@ -52,7 +52,7 @@ @if (item?.id) { - {{ item.title | fixSpecialChar }} + {{ item.title }} @@ -60,7 +60,7 @@ } @else { - + @@ -68,7 +68,7 @@ - {{ 'common.search.noResultsFound' | translate }} + {{ 'common.search.noResultsFound' | translate }} diff --git a/src/app/features/preprints/pages/my-preprints/my-preprints.component.spec.ts b/src/app/features/preprints/pages/my-preprints/my-preprints.component.spec.ts index b907ff6a1..06b3456ac 100644 --- a/src/app/features/preprints/pages/my-preprints/my-preprints.component.spec.ts +++ b/src/app/features/preprints/pages/my-preprints/my-preprints.component.spec.ts @@ -1,3 +1,5 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockPipe, MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; @@ -12,28 +14,26 @@ 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 { PreprintShortInfo } from '../../models'; -import { MyPreprintsSelectors } from '../../store/my-preprints'; +import { FetchMyPreprints, MyPreprintsSelectors } from '../../store/my-preprints'; import { MyPreprintsComponent } from './my-preprints.component'; import { PREPRINT_SHORT_INFO_ARRAY_MOCK } from '@testing/mocks/preprint-short-info.mock'; -import { TranslationServiceMock } from '@testing/mocks/translation.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('MyPreprintsComponent', () => { let component: MyPreprintsComponent; let fixture: ComponentFixture; - let routerMock: ReturnType; - let activatedRouteMock: ReturnType; + let store: Store; + let routerMock: RouterMockType; let queryParamsSubject: BehaviorSubject>; - const mockPreprints: PreprintShortInfo[] = PREPRINT_SHORT_INFO_ARRAY_MOCK; + const mockPreprints = PREPRINT_SHORT_INFO_ARRAY_MOCK; - beforeEach(async () => { + beforeEach(() => { queryParamsSubject = new BehaviorSubject>({}); routerMock = RouterMockBuilder.create() @@ -41,103 +41,78 @@ describe('MyPreprintsComponent', () => { .withNavigateByUrl(jest.fn().mockResolvedValue(true)) .build(); - activatedRouteMock = ActivatedRouteMockBuilder.create() - .withQueryParams({ page: '1', size: '10', search: '' }) - .build(); - - Object.defineProperty(activatedRouteMock, 'queryParams', { - value: queryParamsSubject.asObservable(), - writable: true, - }); + const activatedRouteMock = ActivatedRouteMockBuilder.create().build(); + activatedRouteMock.queryParams = queryParamsSubject.asObservable(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ MyPreprintsComponent, - OSFTestingModule, ...MockComponents(SubHeaderComponent, SearchInputComponent, ContributorsListShortenerComponent), MockPipe(TitleCasePipe), ], providers: [ - TranslationServiceMock, + provideOSFCore(), MockProvider(Router, routerMock), MockProvider(ActivatedRoute, activatedRouteMock), provideMockStore({ signals: [ - { - selector: MyPreprintsSelectors.getMyPreprints, - value: mockPreprints, - }, - { - selector: MyPreprintsSelectors.getMyPreprintsTotalCount, - value: 5, - }, - { - selector: MyPreprintsSelectors.areMyPreprintsLoading, - value: false, - }, + { selector: MyPreprintsSelectors.getMyPreprints, value: mockPreprints }, + { selector: MyPreprintsSelectors.getMyPreprintsTotalCount, value: 5 }, + { selector: MyPreprintsSelectors.areMyPreprintsLoading, value: false }, ], }), ], - }).compileComponents(); + }); + + store = TestBed.inject(Store); + jest.spyOn(store, 'dispatch'); + store = TestBed.inject(Store); fixture = TestBed.createComponent(MyPreprintsComponent); component = fixture.componentInstance; fixture.detectChanges(); }); + afterEach(() => { + queryParamsSubject.complete(); + }); + it('should initialize with correct default values', () => { expect(component.searchControl.value).toBe(''); expect(component.sortColumn()).toBe(''); expect(component.sortOrder()).toBe(SortOrder.Desc); - expect(component.currentPage()).toBe(1); - expect(component.currentPageSize()).toBe(DEFAULT_TABLE_PARAMS.rows); - }); - - it('should return preprints from store', () => { - const preprints = component.preprints(); - expect(preprints).toBe(mockPreprints); - }); - - it('should return preprints total count from store', () => { - const totalCount = component.preprintsTotalCount(); - expect(totalCount).toBe(5); - }); - - it('should return loading state from store', () => { - const loading = component.areMyPreprintsLoading(); - expect(loading).toBe(false); - }); - - it('should have correct CSS classes', () => { expect(component.classes).toBe('flex-1 flex flex-column w-full'); + expect(component.skeletonData).toHaveLength(10); }); - it('should have skeleton data with correct length', () => { - expect(component.skeletonData).toHaveLength(10); - expect(component.skeletonData.every((item) => typeof item === 'object')).toBe(true); + it('should dispatch FetchMyPreprints on init', () => { + expect(store.dispatch).toHaveBeenCalledWith( + new FetchMyPreprints(1, 10, { + searchValue: '', + searchFields: ['title', 'tags', 'description'], + sortColumn: 'dateModified', + sortOrder: SortOrder.Desc, + }) + ); }); - it('should navigate to preprint details when navigateToPreprintDetails is called', () => { - const mockPreprint: PreprintShortInfo = { - id: 'preprint-1', - title: 'Test Preprint', - dateModified: '2024-01-01T00:00:00Z', - contributors: [], - providerId: 'provider-1', - }; + it('should have correct table parameters after init', () => { + expect(component.tableParams()).toEqual({ + ...DEFAULT_TABLE_PARAMS, + firstRowIndex: 0, + totalRecords: 5, + }); + }); + it('should navigate to preprint details', () => { + const mockPreprint = mockPreprints[0]; component.navigateToPreprintDetails(mockPreprint); - expect(routerMock.navigateByUrl).toHaveBeenCalledWith('/preprints/provider-1/preprint-1'); + expect(routerMock.navigateByUrl).toHaveBeenCalledWith(`/preprints/${mockPreprint.providerId}/${mockPreprint.id}`); }); - it('should handle page change correctly', () => { - const mockEvent = { - first: 20, - rows: 10, - }; - - component.onPageChange(mockEvent); + it('should update query params on page change', () => { + component.onPageChange({ first: 20, rows: 10 }); expect(routerMock.navigate).toHaveBeenCalledWith([], { relativeTo: expect.any(Object), @@ -146,13 +121,8 @@ describe('MyPreprintsComponent', () => { }); }); - it('should handle sort correctly for ascending order', () => { - const mockEvent = { - field: 'title', - order: 1, - }; - - component.onSort(mockEvent); + it('should update query params on ascending sort', () => { + component.onSort({ field: 'title', order: 1 }); expect(routerMock.navigate).toHaveBeenCalledWith([], { relativeTo: expect.any(Object), @@ -161,13 +131,8 @@ describe('MyPreprintsComponent', () => { }); }); - it('should handle sort correctly for descending order', () => { - const mockEvent = { - field: 'title', - order: -1, - }; - - component.onSort(mockEvent); + it('should update query params on descending sort', () => { + component.onSort({ field: 'title', order: -1 }); expect(routerMock.navigate).toHaveBeenCalledWith([], { relativeTo: expect.any(Object), @@ -177,90 +142,41 @@ describe('MyPreprintsComponent', () => { }); it('should not navigate when sort field is undefined', () => { - const mockEvent = { - field: undefined, - order: 1, - }; - - component.onSort(mockEvent); - + component.onSort({ field: undefined, order: 1 }); expect(routerMock.navigate).not.toHaveBeenCalled(); }); - it('should navigate to add preprint page when addPreprintBtnClicked is called', () => { - component.addPreprintBtnClicked(); - + it('should navigate to add preprint page', () => { + component.navigateToAddPreprint(); expect(routerMock.navigateByUrl).toHaveBeenCalledWith('/preprints/select'); }); - it('should handle search control value changes', () => { - const testValue = 'test search'; - component.searchControl.setValue(testValue); - expect(component.searchControl.value).toBe(testValue); - }); - - it('should update component state when query params change', () => { - queryParamsSubject.next({ page: '2', size: '20', search: 'test' }); - - fixture.detectChanges(); - - expect(component.currentPage()).toBe(2); - expect(component.currentPageSize()).toBe(20); - expect(component.searchControl.value).toBe('test'); - }); - - it('should initialize form control correctly', () => { - expect(component.searchControl).toBeDefined(); - expect(component.searchControl.value).toBe(''); - }); - - it('should have correct table parameters', () => { - const tableParams = component.tableParams(); - expect(tableParams).toEqual({ - ...DEFAULT_TABLE_PARAMS, - firstRowIndex: 0, - totalRecords: 5, - }); - }); - - it('should update table parameters when total records change', () => { - const newTableParams = { totalRecords: 100 }; - component['updateTableParams'](newTableParams); - - const updatedParams = component.tableParams(); - expect(updatedParams.totalRecords).toBe(100); - }); - - it('should create filters correctly', () => { - const mockParams = { - page: 1, - size: 10, - search: 'test search', - sortColumn: 'title', - sortOrder: SortOrder.Desc, - }; - - const filters = component['createFilters'](mockParams); + it('should update state and re-dispatch when query params change', () => { + (store.dispatch as jest.Mock).mockClear(); - expect(filters).toEqual({ - searchValue: 'test search', - searchFields: ['title', 'tags', 'description'], + queryParamsSubject.next({ + page: '2', + size: '20', + search: 'test', sortColumn: 'title', - sortOrder: SortOrder.Desc, + sortOrder: 'asc', }); - }); - it('should handle empty search value in filters', () => { - const mockParams = { - page: 1, - size: 10, - search: '', - sortColumn: 'title', - sortOrder: SortOrder.Asc, - }; - - const filters = component['createFilters'](mockParams); + queryParamsSubject.next({ page: '2', size: '20', search: 'test', sortColumn: 'title', sortOrder: 'asc' }); + fixture.detectChanges(); - expect(filters.searchValue).toBe(''); + expect(component.searchControl.value).toBe('test'); + expect(component.sortColumn()).toBe('title'); + expect(component.sortOrder()).toBe(SortOrder.Asc); + expect(component.tableParams().rows).toBe(20); + expect(component.tableParams().firstRowIndex).toBe(20); + expect(store.dispatch).toHaveBeenCalledWith( + new FetchMyPreprints(2, 20, { + searchValue: 'test', + searchFields: ['title', 'tags', 'description'], + sortColumn: 'title', + sortOrder: SortOrder.Asc, + }) + ); }); }); diff --git a/src/app/features/preprints/pages/my-preprints/my-preprints.component.ts b/src/app/features/preprints/pages/my-preprints/my-preprints.component.ts index f85efb139..79a9f5cac 100644 --- a/src/app/features/preprints/pages/my-preprints/my-preprints.component.ts +++ b/src/app/features/preprints/pages/my-preprints/my-preprints.component.ts @@ -32,7 +32,6 @@ import { parseQueryFilterParams } from '@osf/shared/helpers/http.helper'; import { QueryParams } from '@osf/shared/models/query-params.model'; import { SearchFilters } from '@osf/shared/models/search-filters.model'; import { TableParameters } from '@osf/shared/models/table-parameters.model'; -import { FixSpecialCharPipe } from '@osf/shared/pipes/fix-special-char.pipe'; import { PreprintShortInfo } from '../../models'; import { FetchMyPreprints, MyPreprintsSelectors } from '../../store/my-preprints'; @@ -40,15 +39,14 @@ import { FetchMyPreprints, MyPreprintsSelectors } from '../../store/my-preprints @Component({ selector: 'osf-my-preprints', imports: [ - SubHeaderComponent, - SearchInputComponent, - TranslatePipe, TableModule, Skeleton, - DatePipe, + SearchInputComponent, + SubHeaderComponent, ContributorsListShortenerComponent, + DatePipe, + TranslatePipe, TitleCasePipe, - FixSpecialCharPipe, ], templateUrl: './my-preprints.component.html', styleUrl: './my-preprints.component.scss', @@ -56,19 +54,17 @@ import { FetchMyPreprints, MyPreprintsSelectors } from '../../store/my-preprints }) export class MyPreprintsComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; + private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); private readonly actions = createDispatchMap({ fetchMyPreprints: FetchMyPreprints }); - private readonly defaultSortColumn = 'dateModified'; - searchControl = new FormControl(''); + searchControl = new FormControl('', { nonNullable: true }); - queryParams = toSignal(this.route.queryParams); + private readonly queryParams = toSignal(this.route.queryParams); sortColumn = signal(''); sortOrder = signal(SortOrder.Desc); - currentPage = signal(1); - currentPageSize = signal(DEFAULT_TABLE_PARAMS.rows); tableParams = signal({ ...DEFAULT_TABLE_PARAMS, firstRowIndex: 0 }); preprints = select(MyPreprintsSelectors.getMyPreprints); @@ -99,12 +95,16 @@ export class MyPreprintsComponent { if (event.field) { this.updateQueryParams({ sortColumn: event.field, - sortOrder: event.order as SortOrder.Asc, + sortOrder: event.order === 1 ? SortOrder.Asc : SortOrder.Desc, }); } } - setupQueryParamsEffect(): void { + navigateToAddPreprint(): void { + this.router.navigateByUrl('/preprints/select'); + } + + private setupQueryParamsEffect(): void { effect(() => { const rawQueryParams = this.queryParams(); if (!rawQueryParams) return; @@ -119,16 +119,16 @@ export class MyPreprintsComponent { private setupSearchSubscription(): void { this.searchControl.valueChanges - .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef), skip(1)) - .subscribe((searchControl) => { + .pipe(skip(1), debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { this.updateQueryParams({ - search: searchControl ?? '', + search: value, page: 1, }); }); } - private setupTotalRecordsEffect() { + private setupTotalRecordsEffect(): void { effect(() => { const totalRecords = this.preprintsTotalCount(); untracked(() => { @@ -169,8 +169,6 @@ export class MyPreprintsComponent { private updateComponentState(params: QueryParams): void { untracked(() => { - this.currentPage.set(params.page); - this.currentPageSize.set(params.size); this.searchControl.setValue(params.search); this.sortColumn.set(params.sortColumn); this.sortOrder.set(params.sortOrder); @@ -186,12 +184,8 @@ export class MyPreprintsComponent { return { searchValue: params.search, searchFields: ['title', 'tags', 'description'], - sortColumn: params.sortColumn || this.defaultSortColumn, + sortColumn: params.sortColumn || 'dateModified', sortOrder: params.sortOrder, }; } - - addPreprintBtnClicked() { - this.router.navigateByUrl('/preprints/select'); - } } diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.html b/src/app/features/preprints/pages/preprint-details/preprint-details.component.html index 54e5ac20f..ba30e4bd3 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.html +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.html @@ -13,11 +13,11 @@ > Provider Logo -

{{ preprint()?.title | fixSpecialChar }}

+

{{ preprint()?.title }}

} @@ -53,7 +53,7 @@

{{ preprint()?.title | fixSpecialChar }}

class="w-full md:w-11rem" styleClass="w-full" [label]="editButtonLabel() | translate" - (click)="editPreprintClicked()" + (onClick)="editPreprintClicked()" /> } @if (createNewVersionButtonVisible()) { @@ -61,7 +61,7 @@

{{ preprint()?.title | fixSpecialChar }}

class="w-full md:w-11rem" styleClass="w-full" [label]="'common.buttons.createNewVersion' | translate" - (click)="createNewVersionClicked()" + (onClick)="createNewVersionClicked()" /> } @if (withdrawalButtonVisible()) { @@ -70,7 +70,7 @@

{{ preprint()?.title | fixSpecialChar }}

styleClass="w-full" [label]="'common.buttons.withdraw' | translate" severity="danger" - (click)="handleWithdrawClicked()" + (onClick)="handleWithdrawClicked()" /> } diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts index 67bae534f..c497d6821 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts @@ -2,9 +2,9 @@ import { Store } from '@ngxs/store'; import { MockComponents, MockProvider } from 'ng-mocks'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; -import { HttpTestingController } from '@angular/common/http/testing'; +import { HttpErrorResponse } from '@angular/common/http'; import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideServerRendering } from '@angular/platform-server'; @@ -12,10 +12,13 @@ import { ActivatedRoute, Router } from '@angular/router'; import { HelpScoutService } from '@core/services/help-scout.service'; import { PrerenderReadyService } from '@core/services/prerender-ready.service'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { ClearCurrentProvider } from '@core/store/provider'; +import { MetaTagsData } from '@osf/shared/models/meta-tags/meta-tags-data.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; +import { MetaTagsBuilderService } from '@osf/shared/services/meta-tags-builder.service'; +import { ToastService } from '@osf/shared/services/toast.service'; import { ContributorsSelectors } from '@osf/shared/stores/contributors'; import { @@ -31,8 +34,16 @@ import { StatusBannerComponent, } from '../../components'; import { ReviewsState } from '../../enums'; -import { PreprintSelectors } from '../../store/preprint'; -import { PreprintProvidersSelectors } from '../../store/preprint-providers'; +import { + FetchPreprintDetails, + FetchPreprintRequestActions, + FetchPreprintRequests, + FetchPreprintReviewActions, + PreprintSelectors, + ResetPreprintState, +} from '../../store/preprint'; +import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; +import { CreateNewVersion } from '../../store/preprint-stepper'; import { PreprintDetailsComponent } from './preprint-details.component'; @@ -42,59 +53,109 @@ 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 { REVIEW_ACTION_MOCK } from '@testing/mocks/review-action.mock'; -import { ToastServiceMock } from '@testing/mocks/toast.service.mock'; -import { TranslationServiceMock } from '@testing/mocks/translation.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; import { HelpScoutServiceMockFactory } from '@testing/providers/help-scout.service.mock'; import { MetaTagsServiceMockFactory } from '@testing/providers/meta-tags.service.mock'; +import { MetaTagsBuilderServiceMockFactory } from '@testing/providers/meta-tags-builder.service.mock'; import { PrerenderReadyServiceMockFactory } from '@testing/providers/prerender-ready.service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('PreprintDetailsComponent', () => { let component: PreprintDetailsComponent; let fixture: ComponentFixture; - let routerMock: ReturnType; - let activatedRouteMock: ReturnType; - let dataciteService: jest.Mocked; - let metaTagsService: jest.Mocked; - let mockCustomDialogService: ReturnType; + let store: Store; + let routerMock: RouterMockType; + let helpScoutServiceMock: jest.Mocked; + let prerenderReadyServiceMock: jest.Mocked; + let dataciteServiceMock: ReturnType; + let metaTagsServiceMock: ReturnType; + let metaTagsBuilderServiceMock: ReturnType; + let customDialogServiceMock: ReturnType; + let toastService: ToastServiceMockType; - const mockPreprint = PREPRINT_MOCK; const mockProvider = PREPRINT_PROVIDER_DETAILS_MOCK; + const mockPreprint = PREPRINT_MOCK; const mockReviewActions = [REVIEW_ACTION_MOCK]; - const mockWithdrawalRequests = [PREPRINT_REQUEST_MOCK]; - const mockRequestActions = [REVIEW_ACTION_MOCK]; + const mockRequests = [PREPRINT_REQUEST_MOCK]; const mockContributors = [MOCK_CONTRIBUTOR]; - beforeEach(async () => { + const defaultSignals: SignalOverride[] = [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails('osf'), value: mockProvider }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, + { selector: PreprintSelectors.getPreprint, value: mockPreprint }, + { selector: PreprintSelectors.isPreprintLoading, value: false }, + { selector: ContributorsSelectors.getBibliographicContributors, value: mockContributors }, + { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, + { selector: PreprintSelectors.getPreprintReviewActions, value: mockReviewActions }, + { selector: PreprintSelectors.arePreprintReviewActionsLoading, value: false }, + { selector: PreprintSelectors.getPreprintRequests, value: mockRequests }, + { selector: PreprintSelectors.arePreprintRequestsLoading, value: false }, + { selector: PreprintSelectors.getPreprintRequestActions, value: mockReviewActions }, + { selector: PreprintSelectors.arePreprintRequestActionsLoading, value: false }, + { selector: PreprintSelectors.hasAdminAccess, value: false }, + { selector: PreprintSelectors.hasWriteAccess, value: true }, + { selector: PreprintSelectors.getPreprintMetrics, value: null }, + { selector: PreprintSelectors.arePreprintMetricsLoading, value: false }, + ]; + + function setup(overrides?: { + selectorOverrides?: SignalOverride[]; + routeParams?: { providerId: string; id: string }; + queryParams?: Record; + routerUrl?: string; + dialogReturnsCloseValue?: boolean; + }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + const routeParams = overrides?.routeParams ?? { providerId: 'osf', id: 'preprint-1' }; + const queryParams = overrides?.queryParams ?? { mode: 'moderator' }; + routerMock = RouterMockBuilder.create() + .withUrl(overrides?.routerUrl ?? '/preprints/osf/preprint-1') .withNavigate(jest.fn().mockResolvedValue(true)) .withNavigateByUrl(jest.fn().mockResolvedValue(true)) .build(); - - activatedRouteMock = ActivatedRouteMockBuilder.create() - .withParams({ providerId: 'osf', id: 'preprint-1' }) - .withQueryParams({ mode: 'moderator' }) + const activatedRouteMock = ActivatedRouteMockBuilder.create() + .withParams(routeParams) + .withQueryParams(queryParams) .build(); - - mockCustomDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - - dataciteService = { - logIdentifiableView: jest.fn().mockReturnValue(of(void 0)), - logIdentifiableDownload: jest.fn().mockReturnValue(of(void 0)), - } as any; - - metaTagsService = { - updateMetaTags: jest.fn(), - } as any; - - await TestBed.configureTestingModule({ + helpScoutServiceMock = HelpScoutServiceMockFactory(); + prerenderReadyServiceMock = PrerenderReadyServiceMockFactory(); + dataciteServiceMock = DataciteMockFactory(); + metaTagsServiceMock = MetaTagsServiceMockFactory(); + metaTagsBuilderServiceMock = MetaTagsBuilderServiceMockFactory(); + metaTagsBuilderServiceMock.buildPreprintMetaTagsData.mockImplementation( + ({ providerId, preprint }) => + ({ + canonicalUrl: `http://localhost:4200/preprints/${providerId}/${preprint?.id}`, + }) as MetaTagsData + ); + toastService = ToastServiceMock.simple(); + customDialogServiceMock = + overrides?.dialogReturnsCloseValue === false + ? CustomDialogServiceMockBuilder.create() + .withOpen( + jest.fn().mockReturnValue({ + onClose: of(false), + close: jest.fn(), + } as any) + ) + .build() + : CustomDialogServiceMockBuilder.create() + .withOpen( + jest.fn().mockReturnValue({ + onClose: of(true), + close: jest.fn(), + } as any) + ) + .build(); + + TestBed.configureTestingModule({ imports: [ PreprintDetailsComponent, - OSFTestingModule, ...MockComponents( PreprintFileSectionComponent, ShareAndDownloadComponent, @@ -109,263 +170,370 @@ describe('PreprintDetailsComponent', () => { ), ], providers: [ - TranslationServiceMock, - ToastServiceMock, + provideOSFCore(), + MockProvider(ToastService, toastService), MockProvider(Router, routerMock), MockProvider(ActivatedRoute, activatedRouteMock), - MockProvider(DataciteService, dataciteService), - MockProvider(MetaTagsService, metaTagsService), - MockProvider(CustomDialogService, mockCustomDialogService), - provideMockStore({ - signals: [ - { - selector: PreprintProvidersSelectors.getPreprintProviderDetails('osf'), - value: mockProvider, - }, - { - selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, - value: false, - }, - { - selector: PreprintSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintSelectors.isPreprintLoading, - value: false, - }, - { - selector: ContributorsSelectors.getBibliographicContributors, - value: mockContributors, - }, - { - selector: ContributorsSelectors.isBibliographicContributorsLoading, - value: false, - }, - { - selector: PreprintSelectors.getPreprintReviewActions, - value: mockReviewActions, - }, - { - selector: PreprintSelectors.arePreprintReviewActionsLoading, - value: false, - }, - { - selector: PreprintSelectors.getPreprintRequests, - value: mockWithdrawalRequests, - }, - { - selector: PreprintSelectors.arePreprintRequestsLoading, - value: false, - }, - { - selector: PreprintSelectors.getPreprintRequestActions, - value: mockRequestActions, - }, - { - selector: PreprintSelectors.arePreprintRequestActionsLoading, - value: false, - }, - { - selector: PreprintSelectors.hasAdminAccess, - value: false, - }, - { - selector: PreprintSelectors.hasWriteAccess, - value: false, - }, - ], - }), + MockProvider(HelpScoutService, helpScoutServiceMock), + MockProvider(PrerenderReadyService, prerenderReadyServiceMock), + MockProvider(DataciteService, dataciteServiceMock), + MockProvider(MetaTagsService, metaTagsServiceMock), + MockProvider(MetaTagsBuilderService, metaTagsBuilderServiceMock), + MockProvider(CustomDialogService, customDialogServiceMock), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintDetailsComponent); component = fixture.componentInstance; fixture.detectChanges(); + } + + it('should dispatch initial fetch actions on creation', () => { + setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProviderById('osf')); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintDetails('preprint-1')); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintReviewActions()); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintRequests()); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintRequestActions(PREPRINT_REQUEST_MOCK.id)); }); - it('should return preprint from store', () => { - const preprint = component.preprint(); - expect(preprint).toBe(mockPreprint); + it('should set HelpScout and datacite tracking on initialization', () => { + setup(); + + expect(helpScoutServiceMock.setResourceType).toHaveBeenCalledWith('preprint'); + expect(dataciteServiceMock.logIdentifiableView).toHaveBeenCalledTimes(1); + expect(prerenderReadyServiceMock.setNotReady).toHaveBeenCalled(); }); - it('should return preprint provider from store', () => { - const provider = component.preprintProvider(); - expect(provider).toBe(mockProvider); + it('should update meta tags when preprint and contributors are loaded', () => { + setup(); + + expect(metaTagsBuilderServiceMock.buildPreprintMetaTagsData).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: 'osf', + preprint: expect.objectContaining({ id: 'preprint-1' }), + }) + ); + + expect(metaTagsServiceMock.updateMetaTags).toHaveBeenCalledWith( + expect.objectContaining({ + canonicalUrl: 'http://localhost:4200/preprints/osf/preprint-1', + }), + expect.anything() + ); }); - it('should return loading states from store', () => { - expect(component.isPreprintLoading()).toBe(false); - expect(component.isPreprintProviderLoading()).toBe(false); - expect(component.areReviewActionsLoading()).toBe(false); - expect(component.areWithdrawalRequestsLoading()).toBe(false); - expect(component.areRequestActionsLoading()).toBe(false); + it('should not fetch moderation actions when not moderator and no permissions', () => { + setup({ + queryParams: {}, + selectorOverrides: [ + { + selector: PreprintProvidersSelectors.getPreprintProviderDetails('osf'), + value: { ...mockProvider, permissions: [] }, + }, + ], + }); + + expect(store.dispatch).not.toHaveBeenCalledWith(new FetchPreprintReviewActions()); + expect(store.dispatch).not.toHaveBeenCalledWith(new FetchPreprintRequests()); }); - it('should return review actions from store', () => { - const actions = component.reviewActions(); - expect(actions).toBe(mockReviewActions); + it('should navigate to canonical version id when url id differs', () => { + setup({ routeParams: { providerId: 'osf', id: 'old-id' }, routerUrl: '/preprints/osf/old-id' }); + + expect(routerMock.navigate).toHaveBeenCalledWith(['../', 'preprint-1'], { + relativeTo: expect.anything(), + replaceUrl: true, + queryParamsHandling: 'preserve', + }); }); - it('should return withdrawal requests from store', () => { - const requests = component.withdrawalRequests(); - expect(requests).toBe(mockWithdrawalRequests); + it('should navigate to edit page when edit is clicked', () => { + setup(); + + component.editPreprintClicked(); + + expect(routerMock.navigate).toHaveBeenCalledWith(['preprints', 'osf', 'edit', 'preprint-1']); }); - it('should return request actions from store', () => { - const actions = component.requestActions(); - expect(actions).toBe(mockRequestActions); + it('should create new version and navigate to new version route', () => { + setup(); + jest.spyOn(store, 'selectSnapshot').mockReturnValue({ id: 'new-version-id' } as any); + + component.createNewVersionClicked(); + + expect(store.dispatch).toHaveBeenCalledWith(new CreateNewVersion('preprint-1')); + expect(routerMock.navigate).toHaveBeenCalledWith(['preprints', 'osf', 'new-version', 'new-version-id']); }); - it('should return contributors from store', () => { - const contributors = component.contributors(); - expect(contributors).toBe(mockContributors); + it('should return early in createNewVersionClicked when preprint id is missing', () => { + setup({ routeParams: { providerId: 'osf', id: '' } }); + (store.dispatch as jest.Mock).mockClear(); + + component.createNewVersionClicked(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(CreateNewVersion)); }); - it('should return contributors loading state from store', () => { - const loading = component.areContributorsLoading(); - expect(loading).toBe(false); + it('should show toast error for 409 on create new version', () => { + setup(); + const errorResponse = new HttpErrorResponse({ + status: 409, + error: { errors: [{ detail: 'Version already exists' }] }, + }); + + (store.dispatch as jest.Mock).mockReturnValueOnce(throwError(() => errorResponse)); + + component.createNewVersionClicked(); + + expect(toastService.showError).toHaveBeenCalledWith('Version already exists'); + expect(routerMock.navigate).not.toHaveBeenCalled(); }); - it('should compute latest action correctly', () => { - const latestAction = component.latestAction(); - expect(latestAction).toBe(mockReviewActions[0]); + it('should refetch preprint after successful withdraw dialog close', () => { + setup(); + const fetchSpy = jest.spyOn(component, 'fetchPreprint'); + + component.handleWithdrawClicked(); + + expect(customDialogServiceMock.open).toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledWith('preprint-1'); }); - it('should compute latest withdrawal request correctly', () => { - const latestRequest = component.latestWithdrawalRequest(); - expect(latestRequest).toBe(mockWithdrawalRequests[0]); + it('should navigate to pending moderation page on 403 "pending moderation" error', () => { + setup(); + const preprintId = 'preprint-1'; + const errorResponse = new HttpErrorResponse({ + status: 403, + error: { + errors: [{ detail: 'This preprint is pending moderation and is not yet publicly available.' }], + }, + }); + + jest.spyOn(store, 'dispatch').mockReturnValue(throwError(() => errorResponse)); + + component.fetchPreprint(preprintId); + + expect(routerMock.navigate).toHaveBeenCalledWith(['/preprints', 'osf', preprintId, 'pending-moderation']); }); - it('should compute latest request action correctly', () => { - const latestAction = component.latestRequestAction(); - expect(latestAction).toBe(mockRequestActions[0]); + it('should return early in fetchPreprint when preprint id is missing', () => { + setup(); + const prerenderSpy = prerenderReadyServiceMock.setNotReady as jest.Mock; + prerenderSpy.mockClear(); + (store.dispatch as jest.Mock).mockClear(); + + component.fetchPreprint(''); + + expect(prerenderSpy).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchPreprintDetails)); }); - it('should compute isOsfPreprint correctly', () => { - const isOsf = component.isOsfPreprint(); - expect(isOsf).toBe(true); + it('should reset state and provider on destroy in browser', () => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(store.dispatch).toHaveBeenCalledWith(new ResetPreprintState()); + expect(store.dispatch).toHaveBeenCalledWith(new ClearCurrentProvider()); + expect(helpScoutServiceMock.unsetResourceType).toHaveBeenCalled(); }); - it('should compute moderation mode correctly', () => { - const moderationMode = component.moderationMode(); - expect(moderationMode).toBe(true); + it('should expose expected computed values for default mocks', () => { + setup(); + + expect(component.latestAction()).toBe(mockReviewActions[0]); + expect(component.latestWithdrawalRequest()).toBe(mockRequests[0]); + expect(component.latestRequestAction()).toBe(mockReviewActions[0]); + expect(component.isPendingWithdrawal()).toBe(true); + expect(component.isWithdrawalRejected()).toBe(false); + expect(component.moderationMode()).toBe(true); + expect(component.isOsfPreprint()).toBe(true); }); - it('should compute create new version button visibility', () => { - const visible = component.createNewVersionButtonVisible(); - expect(typeof visible).toBe('boolean'); + it('should mark preprint withdrawable for pending and accepted states', () => { + setup(); + jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Pending } as any); + expect(component['preprintWithdrawableState']()).toBe(true); + + jest.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Accepted } as any); + expect(component['preprintWithdrawableState']()).toBe(true); }); - it('should compute edit button visibility', () => { - const visible = component.editButtonVisible(); - expect(typeof visible).toBe('boolean'); + it('should hide edit button when preprint is withdrawn', () => { + setup({ + selectorOverrides: [ + { selector: PreprintSelectors.getPreprint, value: { ...mockPreprint, dateWithdrawn: '2024-01-01' } }, + ], + }); + + expect(component.editButtonVisible()).toBe(false); }); - it('should compute edit button label', () => { - const label = component.editButtonLabel(); - expect(typeof label).toBe('string'); + it('should hide edit button when user does not have write access', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.hasWriteAccess, value: false }], + }); + + expect(component.editButtonVisible()).toBe(false); }); - it('should compute withdrawal button visibility', () => { - const visible = component.withdrawalButtonVisible(); - expect(typeof visible).toBe('boolean'); + it('should show edit button for initial preprint', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintSelectors.getPreprint, + value: { ...mockPreprint, isLatestVersion: false, reviewsState: ReviewsState.Initial }, + }, + ], + }); + + expect(component.editButtonVisible()).toBe(true); }); - it('should compute is pending withdrawal', () => { - const pending = component.isPendingWithdrawal(); - expect(typeof pending).toBe('boolean'); + it('should show edit button for latest preprint', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintSelectors.getPreprint, + value: { ...mockPreprint, isLatestVersion: true }, + }, + ], + }); + + expect(component.editButtonVisible()).toBe(true); }); - it('should compute is withdrawal rejected', () => { - const rejected = component.isWithdrawalRejected(); - expect(typeof rejected).toBe('boolean'); + it('should show edit button for pending premoderation preprint', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintSelectors.getPreprint, + value: { ...mockPreprint, isLatestVersion: false, reviewsState: ReviewsState.Pending }, + }, + ], + }); + + expect(component.editButtonVisible()).toBe(true); }); - it('should compute moderation status banner visibility', () => { - const visible = component.moderationStatusBannerVisible(); - expect(typeof visible).toBe('boolean'); + it('should show edit-and-resubmit for rejected premoderation preprint with admin access', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintSelectors.getPreprint, + value: { ...mockPreprint, isLatestVersion: false, reviewsState: ReviewsState.Rejected }, + }, + { selector: PreprintSelectors.hasAdminAccess, value: true }, + ], + }); + + expect(component.editButtonVisible()).toBe(true); }); - it('should compute status banner visibility', () => { - const visible = component.statusBannerVisible(); - expect(typeof visible).toBe('boolean'); + it('should hide edit button when none of edit visibility conditions are met', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintSelectors.getPreprint, + value: { ...mockPreprint, isLatestVersion: false, reviewsState: ReviewsState.Rejected }, + }, + ], + }); + + expect(component.editButtonVisible()).toBe(false); }); - it('should navigate to edit page when editPreprintClicked is called', () => { - component.editPreprintClicked(); + it('should return false for statusBannerVisible when provider is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintProvidersSelectors.getPreprintProviderDetails('osf'), value: null }], + }); - expect(routerMock.navigate).toHaveBeenCalledWith(['preprints', 'osf', 'edit', 'preprint-1']); + expect(component.statusBannerVisible()).toBe(false); }); - it('should handle create new version clicked', () => { - expect(() => component.createNewVersionClicked()).not.toThrow(); + it('should return false for statusBannerVisible when preprint is missing', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.getPreprint, value: null }], + }); + + expect(component.statusBannerVisible()).toBe(false); }); - it('should handle preprint with different states', () => { - const acceptedPreprint = { ...mockPreprint, reviewsState: ReviewsState.Accepted }; - jest.spyOn(component, 'preprint').mockReturnValue(acceptedPreprint); + it('should return false for statusBannerVisible when related request data is loading', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.arePreprintRequestsLoading, value: true }], + }); - const withdrawable = component['preprintWithdrawableState'](); - expect(typeof withdrawable).toBe('boolean'); + expect(component.statusBannerVisible()).toBe(false); }); - it('should handle preprint with pending state', () => { - const pendingPreprint = { ...mockPreprint, reviewsState: ReviewsState.Pending }; - jest.spyOn(component, 'preprint').mockReturnValue(pendingPreprint); + it('should return false for isPendingWithdrawal when no withdrawal request exists', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.getPreprintRequests, value: [] }], + }); - const withdrawable = component['preprintWithdrawableState'](); - expect(withdrawable).toBe(true); + expect(component.isPendingWithdrawal()).toBe(false); }); - it('should handle preprint with accepted state', () => { - const acceptedPreprint = { ...mockPreprint, reviewsState: ReviewsState.Accepted }; - jest.spyOn(component, 'preprint').mockReturnValue(acceptedPreprint); + it('should return false for isWithdrawalRejected when no request action exists', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.getPreprintRequestActions, value: [] }], + }); - const withdrawable = component['preprintWithdrawableState'](); - expect(withdrawable).toBe(true); + expect(component.isWithdrawalRejected()).toBe(false); }); - it('should handle preprint with pending state', () => { - const pendingPreprint = { ...mockPreprint, reviewsState: ReviewsState.Pending }; - jest.spyOn(component, 'preprint').mockReturnValue(pendingPreprint); + it('should return false for withdrawalButtonVisible while withdrawal data is loading', () => { + setup({ + selectorOverrides: [{ selector: PreprintSelectors.arePreprintRequestsLoading, value: true }], + }); - const withdrawable = component['preprintWithdrawableState'](); - expect(withdrawable).toBe(true); + expect(component.withdrawalButtonVisible()).toBe(false); }); - it('should handle preprint without write permissions', () => { - const preprintWithoutWrite = { - ...mockPreprint, - currentUserPermissions: [UserPermissions.Read], - }; - jest.spyOn(component, 'preprint').mockReturnValue(preprintWithoutWrite); + it('should return early in editPreprintClicked when route ids are missing', () => { + setup({ routeParams: { providerId: '', id: '' } }); + (routerMock.navigate as jest.Mock).mockClear(); - const hasAccess = component['hasWriteAccess'](); - expect(hasAccess).toBe(false); + component.editPreprintClicked(); + + expect(routerMock.navigate).not.toHaveBeenCalled(); }); }); -describe('PreprintDetailsComponent SSR Tests', () => { +describe('PreprintDetailsComponent SSR', () => { let component: PreprintDetailsComponent; let fixture: ComponentFixture; - let httpMock: HttpTestingController; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; let store: Store; + let helpScoutServiceMock: jest.Mocked; + + const defaultSignals = [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails('osf'), value: PREPRINT_PROVIDER_DETAILS_MOCK }, + { selector: PreprintSelectors.getPreprint, value: PREPRINT_MOCK }, + { selector: ContributorsSelectors.getBibliographicContributors, value: [MOCK_CONTRIBUTOR] }, + { selector: PreprintSelectors.isPreprintLoading, value: false }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, + { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, + { selector: PreprintSelectors.getPreprintReviewActions, value: [] }, + { selector: PreprintSelectors.getPreprintRequests, value: [] }, + { selector: PreprintSelectors.getPreprintRequestActions, value: [] }, + ]; + + beforeEach(() => { + const routerMock = RouterMockBuilder.create().build(); + const activatedRouteMock = ActivatedRouteMockBuilder.create() + .withParams({ providerId: 'osf', id: 'preprint-1' }) + .build(); + helpScoutServiceMock = HelpScoutServiceMockFactory(); - const mockPreprint = PREPRINT_MOCK; - const mockProvider = PREPRINT_PROVIDER_DETAILS_MOCK; - const mockContributors = [MOCK_CONTRIBUTOR]; - - beforeEach(async () => { - mockRouter = RouterMockBuilder.create().build(); - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ providerId: 'osf', id: 'preprint-1' }).build(); - - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ PreprintDetailsComponent, - OSFTestingModule, ...MockComponents( PreprintFileSectionComponent, ShareAndDownloadComponent, @@ -381,98 +549,29 @@ describe('PreprintDetailsComponent SSR Tests', () => { ], providers: [ provideServerRendering(), - { provide: PLATFORM_ID, useValue: 'server' }, - MockProvider(ActivatedRoute, mockActivatedRoute), - MockProvider(Router, mockRouter), - MockProvider(CustomDialogService, CustomDialogServiceMockBuilder.create().build()), + provideOSFCore(), + MockProvider(PLATFORM_ID, 'server'), + MockProvider(ToastService, ToastServiceMock.simple()), + MockProvider(ActivatedRoute, activatedRouteMock), + MockProvider(Router, routerMock), + MockProvider(CustomDialogService, CustomDialogServiceMockBuilder.create().withDefaultOpen().build()), MockProvider(DataciteService, DataciteMockFactory()), + MockProvider(MetaTagsBuilderService, MetaTagsBuilderServiceMockFactory()), MockProvider(MetaTagsService, MetaTagsServiceMockFactory()), MockProvider(PrerenderReadyService, PrerenderReadyServiceMockFactory()), - MockProvider(HelpScoutService, HelpScoutServiceMockFactory()), - TranslationServiceMock, - ToastServiceMock, - provideMockStore({ - signals: [ - { - selector: PreprintProvidersSelectors.getPreprintProviderDetails('osf'), - value: mockProvider, - }, - { - selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, - value: false, - }, - { - selector: PreprintSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintSelectors.isPreprintLoading, - value: false, - }, - { - selector: ContributorsSelectors.getBibliographicContributors, - value: mockContributors, - }, - { - selector: ContributorsSelectors.isBibliographicContributorsLoading, - value: false, - }, - { - selector: PreprintSelectors.getPreprintReviewActions, - value: [], - }, - { - selector: PreprintSelectors.arePreprintReviewActionsLoading, - value: false, - }, - { - selector: PreprintSelectors.getPreprintRequests, - value: [], - }, - { - selector: PreprintSelectors.arePreprintRequestsLoading, - value: false, - }, - { - selector: PreprintSelectors.getPreprintRequestActions, - value: [], - }, - { - selector: PreprintSelectors.arePreprintRequestActionsLoading, - value: false, - }, - { - selector: PreprintSelectors.hasAdminAccess, - value: false, - }, - { - selector: PreprintSelectors.hasWriteAccess, - value: false, - }, - { - selector: PreprintSelectors.getPreprintMetrics, - value: null, - }, - { - selector: PreprintSelectors.arePreprintMetricsLoading, - value: false, - }, - ], - }), + MockProvider(HelpScoutService, helpScoutServiceMock), + provideMockStore({ signals: defaultSignals }), ], - }).compileComponents(); + }); - httpMock = TestBed.inject(HttpTestingController); store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintDetailsComponent); component = fixture.componentInstance; document.head.innerHTML = ''; }); - it('should render PreprintDetailsComponent server-side without errors', () => { - expect(() => { - fixture.detectChanges(); - }).not.toThrow(); + it('should render successfully on the server without throwing errors', () => { + expect(() => fixture.detectChanges()).not.toThrow(); expect(component).toBeTruthy(); }); @@ -491,20 +590,11 @@ describe('PreprintDetailsComponent SSR Tests', () => { const platformId = TestBed.inject(PLATFORM_ID); expect(platformId).toBe('server'); fixture.detectChanges(); - expect(component).toBeTruthy(); - }); + (store.dispatch as jest.Mock).mockClear(); - it('should not call browser-only actions in ngOnDestroy during SSR', () => { - const dispatchSpy = jest.spyOn(store, 'dispatch'); - - fixture.detectChanges(); - dispatchSpy.mockClear(); component.ngOnDestroy(); - expect(dispatchSpy).not.toHaveBeenCalled(); - }); - - afterEach(() => { - httpMock.verify(); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(helpScoutServiceMock.unsetResourceType).toHaveBeenCalled(); }); }); diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts index 63cc1772f..279e56a90 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts @@ -5,9 +5,9 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; -import { catchError, EMPTY, filter, map, of } from 'rxjs'; +import { catchError, EMPTY, filter, map } from 'rxjs'; -import { DatePipe, isPlatformBrowser } from '@angular/common'; +import { isPlatformBrowser } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, @@ -30,11 +30,10 @@ import { PrerenderReadyService } from '@core/services/prerender-ready.service'; import { ClearCurrentProvider } from '@core/store/provider'; import { UserSelectors } from '@core/store/user'; import { ReviewPermissions } from '@osf/shared/enums/review-permissions.enum'; -import { pathJoin } from '@osf/shared/helpers/path-join.helper'; -import { FixSpecialCharPipe } from '@osf/shared/pipes/fix-special-char.pipe'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; +import { MetaTagsBuilderService } from '@osf/shared/services/meta-tags-builder.service'; import { SignpostingService } from '@osf/shared/services/signposting.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ContributorsSelectors } from '@osf/shared/stores/contributors'; @@ -67,25 +66,23 @@ import { CreateNewVersion, PreprintStepperSelectors } from '../../store/preprint @Component({ selector: 'osf-preprint-details', imports: [ + Button, Skeleton, PreprintFileSectionComponent, - Button, ShareAndDownloadComponent, GeneralInformationComponent, AdditionalInfoComponent, StatusBannerComponent, - TranslatePipe, PreprintTombstoneComponent, PreprintWarningBannerComponent, ModerationStatusBannerComponent, PreprintMakeDecisionComponent, PreprintMetricsInfoComponent, RouterLink, - FixSpecialCharPipe, + TranslatePipe, ], templateUrl: './preprint-details.component.html', styleUrl: './preprint-details.component.scss', - providers: [DatePipe], changeDetection: ChangeDetectionStrategy.OnPush, }) export class PreprintDetailsComponent implements OnInit, OnDestroy { @@ -100,18 +97,18 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { private readonly customDialogService = inject(CustomDialogService); private readonly translateService = inject(TranslateService); private readonly metaTags = inject(MetaTagsService); - private readonly datePipe = inject(DatePipe); + private readonly metaTagsBuilder = inject(MetaTagsBuilderService); private readonly dataciteService = inject(DataciteService); private readonly prerenderReady = inject(PrerenderReadyService); - private readonly platformId = inject(PLATFORM_ID); - private readonly isBrowser = isPlatformBrowser(this.platformId); private readonly signpostingService = inject(SignpostingService); private readonly environment = inject(ENVIRONMENT); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); - private preprintId = toSignal(this.route.params.pipe(map((params) => params['id'])) ?? of(undefined)); + readonly providerId = toSignal(this.route.params.pipe(map((params) => params['providerId']))); + private readonly preprintId = toSignal(this.route.params.pipe(map((params) => params['id']))); - private actions = createDispatchMap({ + private readonly actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, resetState: ResetPreprintState, fetchPreprintById: FetchPreprintDetails, @@ -122,12 +119,11 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { clearCurrentProvider: ClearCurrentProvider, }); - providerId = toSignal(this.route.params.pipe(map((params) => params['providerId'])) ?? of(undefined)); currentUser = select(UserSelectors.getCurrentUser); preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); preprint = select(PreprintSelectors.getPreprint); - preprint$ = toObservable(select(PreprintSelectors.getPreprint)); + preprint$ = toObservable(this.preprint); isPreprintLoading = select(PreprintSelectors.isPreprintLoading); contributors = select(ContributorsSelectors.getBibliographicContributors); areContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); @@ -152,26 +148,17 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { latestAction = computed(() => { const actions = this.reviewActions(); - - if (actions.length < 1) return null; - - return actions[0]; + return actions.length > 0 ? actions[0] : null; }); latestWithdrawalRequest = computed(() => { const requests = this.withdrawalRequests(); - - if (requests.length < 1) return null; - - return requests[0]; + return requests.length > 0 ? requests[0] : null; }); latestRequestAction = computed(() => { const actions = this.requestActions(); - - if (actions.length < 1) return null; - - return actions[0]; + return actions.length > 0 ? actions[0] : null; }); constructor() { @@ -221,6 +208,7 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { if (preprint.isLatestVersion || preprint.reviewsState === ReviewsState.Initial) { return true; } + if (providerIsPremod) { if (preprint.reviewsState === ReviewsState.Pending) { return true; @@ -230,6 +218,7 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { return true; } } + return false; }); @@ -252,7 +241,7 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { isWithdrawalRejected = computed(() => { const latestRequestActions = this.latestRequestAction(); if (!latestRequestActions) return false; - return latestRequestActions?.trigger === 'reject'; + return latestRequestActions.trigger === 'reject'; }); withdrawalButtonVisible = computed(() => { @@ -302,16 +291,24 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { ); }); - ngOnInit() { - this.actions.getPreprintProviderById(this.providerId()); - this.fetchPreprint(this.preprintId()); + ngOnInit(): void { + const providerId = this.providerId(); + const preprintId = this.preprintId(); + + if (providerId) { + this.actions.getPreprintProviderById(providerId); + } + + if (preprintId) { + this.fetchPreprint(preprintId); + } this.signpostingService.addSignposting(this.preprintId()); this.dataciteService.logIdentifiableView(this.preprint$).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); } - ngOnDestroy() { + ngOnDestroy(): void { if (this.isBrowser) { this.actions.resetState(); this.actions.clearCurrentProvider(); @@ -322,7 +319,7 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { this.helpScoutService.unsetResourceType(); } - handleWithdrawClicked() { + handleWithdrawClicked(): void { this.customDialogService .open(PreprintWithdrawDialogComponent, { header: this.translateService.instant('preprints.details.withdrawDialog.title', { @@ -335,20 +332,29 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { }, }) .onClose.pipe(takeUntilDestroyed(this.destroyRef), filter(Boolean)) - .subscribe({ - next: () => { - this.fetchPreprint(this.preprintId()); - }, - }); + .subscribe(() => this.fetchPreprint(this.preprintId())); } - editPreprintClicked() { - this.router.navigate(['preprints', this.providerId(), 'edit', this.preprintId()]); + editPreprintClicked(): void { + const providerId = this.providerId(); + const preprintId = this.preprintId(); + + if (!providerId || !preprintId) { + return; + } + + this.router.navigate(['preprints', providerId, 'edit', preprintId]); } - createNewVersionClicked() { + createNewVersionClicked(): void { + const preprintId = this.preprintId(); + + if (!preprintId) { + return; + } + this.actions - .createNewVersion(this.preprintId()) + .createNewVersion(preprintId) .pipe( catchError((e) => { if (e instanceof HttpErrorResponse && e.status === 409) { @@ -361,12 +367,18 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { .subscribe({ complete: () => { const newVersionPreprint = this.store.selectSnapshot(PreprintStepperSelectors.getPreprint); - this.router.navigate(['preprints', this.providerId(), 'new-version', newVersionPreprint!.id]); + if (newVersionPreprint?.id) { + this.router.navigate(['preprints', this.providerId(), 'new-version', newVersionPreprint.id]); + } }, }); } - fetchPreprint(preprintId: string) { + fetchPreprint(preprintId: string): void { + if (!preprintId) { + return; + } + this.prerenderReady.setNotReady(); this.actions.fetchPreprintById(preprintId).subscribe({ @@ -402,31 +414,22 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { } private setMetaTags() { - this.metaTags.updateMetaTags( - { - osfGuid: this.preprint()?.id, - title: this.preprint()?.title, - description: this.preprint()?.description, - publishedDate: this.datePipe.transform(this.preprint()?.datePublished, 'yyyy-MM-dd'), - modifiedDate: this.datePipe.transform(this.preprint()?.dateModified, 'yyyy-MM-dd'), - url: pathJoin(this.environment.webUrl, this.preprint()?.id ?? ''), - doi: this.preprint()?.doi, - keywords: this.preprint()?.tags, - siteName: 'OSF', - license: this.preprint()?.embeddedLicense?.name, - contributors: this.contributors().map((contributor) => ({ - fullName: contributor.fullName, - givenName: contributor.givenName, - familyName: contributor.familyName, - })), - }, - this.destroyRef - ); + const metaTags = this.metaTagsBuilder.buildPreprintMetaTagsData({ + providerId: this.providerId(), + preprint: this.preprint(), + contributors: this.contributors(), + }); + + this.metaTags.updateMetaTags(metaTags, this.destroyRef); } private checkAndSetVersionToTheUrl() { const currentUrl = this.router.url; - const newPreprintId = this.preprint()!.id; + const newPreprintId = this.preprint()?.id; + + if (!newPreprintId) { + return; + } const urlSegments = currentUrl.split('/'); const preprintIdFromUrl = urlSegments[urlSegments.length - 1]; diff --git a/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.spec.ts b/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.spec.ts new file mode 100644 index 000000000..1aabf7386 --- /dev/null +++ b/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.spec.ts @@ -0,0 +1,82 @@ +import { MockProvider } from 'ng-mocks'; + +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { SocialShareService } from '@osf/shared/services/social-share.service'; + +import { PreprintDownloadRedirectComponent } from './preprint-download-redirect.component'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; + +const MOCK_ID = 'test-preprint-id'; +const MOCK_DOWNLOAD_URL = 'https://osf.io/download/test-preprint-id'; + +describe('PreprintDownloadRedirectComponent', () => { + let locationReplaceMock: jest.Mock; + + beforeEach(() => { + locationReplaceMock = jest.fn(); + Object.defineProperty(window, 'location', { + value: { replace: locationReplaceMock }, + writable: true, + configurable: true, + }); + }); + + function setup(overrides: { id?: string | null; isBrowser?: boolean } = {}) { + const { id = MOCK_ID, isBrowser = true } = overrides; + + const mockRoute = ActivatedRouteMockBuilder.create() + .withParams(id ? { id } : {}) + .build(); + + const mockSocialShareService = { + createDownloadUrl: jest.fn().mockReturnValue(MOCK_DOWNLOAD_URL), + }; + + TestBed.configureTestingModule({ + imports: [PreprintDownloadRedirectComponent], + providers: [ + provideOSFCore(), + MockProvider(ActivatedRoute, mockRoute), + MockProvider(SocialShareService, mockSocialShareService), + MockProvider(PLATFORM_ID, isBrowser ? 'browser' : 'server'), + ], + }); + + const fixture = TestBed.createComponent(PreprintDownloadRedirectComponent); + return { fixture, component: fixture.componentInstance, mockSocialShareService }; + } + + it('should create', () => { + const { component } = setup(); + expect(component).toBeTruthy(); + }); + + it('should render download message', () => { + const { fixture } = setup(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('p').textContent).toContain('preprints.downloadRedirect.message'); + }); + + it('should redirect to download URL when id is present in browser', () => { + const { mockSocialShareService } = setup({ id: MOCK_ID }); + expect(mockSocialShareService.createDownloadUrl).toHaveBeenCalledWith(MOCK_ID); + expect(locationReplaceMock).toHaveBeenCalledWith(MOCK_DOWNLOAD_URL); + }); + + it('should not redirect when id is missing', () => { + const { mockSocialShareService } = setup({ id: null }); + expect(mockSocialShareService.createDownloadUrl).not.toHaveBeenCalled(); + expect(locationReplaceMock).not.toHaveBeenCalled(); + }); + + it('should not redirect when not in browser', () => { + const { mockSocialShareService } = setup({ isBrowser: false }); + expect(mockSocialShareService.createDownloadUrl).not.toHaveBeenCalled(); + expect(locationReplaceMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.ts b/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.ts new file mode 100644 index 000000000..aa8bfc451 --- /dev/null +++ b/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.ts @@ -0,0 +1,30 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { isPlatformBrowser } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject, PLATFORM_ID } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { SocialShareService } from '@osf/shared/services/social-share.service'; + +@Component({ + selector: 'osf-preprint-download-redirect', + template: `

{{ 'preprints.downloadRedirect.message' | translate }}

`, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TranslatePipe], +}) +export class PreprintDownloadRedirectComponent { + private readonly route = inject(ActivatedRoute); + private readonly socialShareService = inject(SocialShareService); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + + constructor() { + const id = this.route.snapshot.paramMap.get('id') ?? ''; + + if (!id || !this.isBrowser) { + return; + } + + const url = this.socialShareService.createDownloadUrl(id); + window.location.replace(url); + } +} diff --git a/src/app/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component.spec.ts b/src/app/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component.spec.ts index 27b3a577d..540ed9876 100644 --- a/src/app/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component.spec.ts @@ -2,16 +2,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { PreprintPendingModerationComponent } from './preprint-pending-moderation.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('PreprintPendingModerationComponent', () => { let component: PreprintPendingModerationComponent; let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PreprintPendingModerationComponent, OSFTestingModule], - }).compileComponents(); + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [PreprintPendingModerationComponent], + providers: [provideOSFCore()], + }); fixture = TestBed.createComponent(PreprintPendingModerationComponent); component = fixture.componentInstance; @@ -21,4 +22,18 @@ describe('PreprintPendingModerationComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should render pending moderation title', () => { + const title = fixture.nativeElement.querySelector('h2'); + + expect(title).toBeTruthy(); + expect(title.textContent).toContain('preprints.details.moderationStatusBanner.pendingDetails.title'); + }); + + it('should render pending moderation body', () => { + const body = fixture.nativeElement.querySelector('p'); + + expect(body).toBeTruthy(); + expect(body.textContent).toContain('preprints.details.moderationStatusBanner.pendingDetails.body'); + }); }); diff --git a/src/app/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component.ts b/src/app/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component.ts index c6e482486..97de0f1bf 100644 --- a/src/app/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component.ts +++ b/src/app/features/preprints/pages/preprint-pending-moderation/preprint-pending-moderation.component.ts @@ -4,9 +4,9 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; @Component({ selector: 'osf-preprint-pending-moderation', + imports: [TranslatePipe], templateUrl: './preprint-pending-moderation.component.html', styleUrl: './preprint-pending-moderation.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TranslatePipe], }) export class PreprintPendingModerationComponent {} diff --git a/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.spec.ts b/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.spec.ts index 21ccc8848..aba5aa33b 100644 --- a/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.spec.ts @@ -1,135 +1,131 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormControl } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { BrandService } from '@osf/shared/services/brand.service'; import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; import { HeaderStyleService } from '@osf/shared/services/header-style.service'; +import { SetDefaultFilterValue, SetResourceType } from '@osf/shared/stores/global-search'; import { PreprintProviderHeroComponent } from '../../components'; import { PreprintProviderDetails } from '../../models'; -import { PreprintProvidersSelectors } from '../../store/preprint-providers'; +import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; import { PreprintProviderDiscoverComponent } from './preprint-provider-discover.component'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { BrandServiceMock, BrandServiceMockType } from '@testing/providers/brand-service.mock'; +import { BrowserTabServiceMock, BrowserTabServiceMockType } from '@testing/providers/browser-tab-service.mock'; +import { HeaderStyleServiceMock, HeaderStyleServiceMockType } from '@testing/providers/header-style-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; describe('PreprintProviderDiscoverComponent', () => { let component: PreprintProviderDiscoverComponent; let fixture: ComponentFixture; - let routeMock: ReturnType; + let store: Store; + let brandServiceMock: BrandServiceMockType; + let headerStyleMock: HeaderStyleServiceMockType; + let browserTabMock: BrowserTabServiceMockType; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; const mockProviderId = 'osf'; - beforeEach(async () => { - routeMock = ActivatedRouteMockBuilder.create() - .withParams({ providerId: mockProviderId }) - .withQueryParams({}) - .build(); + const defaultSignals: SignalOverride[] = [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: mockProvider }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, + ]; + + function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + const routeMock = ActivatedRouteMockBuilder.create().withParams({ providerId: mockProviderId }).build(); + brandServiceMock = BrandServiceMock.simple(); + headerStyleMock = HeaderStyleServiceMock.simple(); + browserTabMock = BrowserTabServiceMock.simple(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ PreprintProviderDiscoverComponent, - OSFTestingModule, ...MockComponents(PreprintProviderHeroComponent, GlobalSearchComponent), ], providers: [ - MockProvider(BrandService), - MockProvider(BrowserTabService), - MockProvider(HeaderStyleService), + provideOSFCore(), MockProvider(ActivatedRoute, routeMock), - provideMockStore({ - signals: [ - { - selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), - value: mockProvider, - }, - { - selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, - value: false, - }, - ], - }), + MockProvider(BrandService, brandServiceMock), + MockProvider(HeaderStyleService, headerStyleMock), + MockProvider(BrowserTabService, browserTabMock), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintProviderDiscoverComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + } it('should initialize with correct default values', () => { + setup(); + expect(component.providerId).toBe(mockProviderId); expect(component.classes).toBe('flex-1 flex flex-column w-full h-full'); - expect(component.searchControl).toBeDefined(); expect(component.searchControl.value).toBe(''); + expect(component.defaultSearchFiltersInitialized()).toBe(true); }); - it('should return preprint provider from store', () => { - const provider = component.preprintProvider(); - expect(provider).toBe(mockProvider); - }); + it('should dispatch provider fetch on creation', () => { + setup(); - it('should return loading state from store', () => { - const loading = component.isPreprintProviderLoading(); - expect(loading).toBe(false); + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProviderById(mockProviderId)); }); - it('should initialize search control correctly', () => { - expect(component.searchControl).toBeDefined(); - expect(component.searchControl.value).toBe(''); - }); + it('should initialize global search filters when provider is available', () => { + setup(); - it('should handle search control value changes', () => { - const testValue = 'test search'; - component.searchControl.setValue(testValue); - expect(component.searchControl.value).toBe(testValue); + expect(store.dispatch).toHaveBeenCalledWith(new SetDefaultFilterValue('publisher', mockProvider.iri)); + expect(store.dispatch).toHaveBeenCalledWith(new SetResourceType(ResourceType.Preprint)); + expect(component.defaultSearchFiltersInitialized()).toBe(true); }); - it('should initialize signals correctly', () => { - expect(component.preprintProvider).toBeDefined(); - expect(component.isPreprintProviderLoading).toBeDefined(); - }); + it('should not initialize global search filters when provider is unavailable', () => { + setup({ + selectorOverrides: [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: null }, + ], + }); - it('should handle provider data correctly', () => { - const provider = component.preprintProvider(); - expect(provider).toBe(mockProvider); - expect(provider?.id).toBe(mockProvider.id); - expect(provider?.name).toBe(mockProvider.name); - }); + const dispatchedActions = (store.dispatch as jest.Mock).mock.calls.map(([action]) => action); - it('should handle loading state correctly', () => { - const loading = component.isPreprintProviderLoading(); - expect(typeof loading).toBe('boolean'); - expect(loading).toBe(false); + expect(dispatchedActions.some((action) => action instanceof SetDefaultFilterValue)).toBe(false); + expect(dispatchedActions.some((action) => action instanceof SetResourceType)).toBe(false); + expect(component.defaultSearchFiltersInitialized()).toBe(false); }); - it('should handle search control initialization', () => { - expect(component.searchControl).toBeInstanceOf(FormControl); - expect(component.searchControl.value).toBe(''); - }); + it('should apply branding when provider is available', () => { + setup(); - it('should handle search control updates', () => { - const newValue = 'new search term'; - component.searchControl.setValue(newValue); - expect(component.searchControl.value).toBe(newValue); + expect(brandServiceMock.applyBranding).toHaveBeenCalledWith(mockProvider.brand); + expect(headerStyleMock.applyHeaderStyles).toHaveBeenCalledWith( + mockProvider.brand.primaryColor, + mockProvider.brand.secondaryColor, + mockProvider.brand.heroBackgroundImageUrl + ); + expect(browserTabMock.updateTabStyles).toHaveBeenCalledWith(mockProvider.faviconUrl, mockProvider.name); }); - it('should handle search control reset', () => { - component.searchControl.setValue('some value'); - component.searchControl.setValue(''); - expect(component.searchControl.value).toBe(''); - }); + it('should reset styles on destroy', () => { + setup(); + + component.ngOnDestroy(); - it('should handle search control with null value', () => { - component.searchControl.setValue(null); - expect(component.searchControl.value).toBe(null); + expect(headerStyleMock.resetToDefaults).toHaveBeenCalled(); + expect(brandServiceMock.resetBranding).toHaveBeenCalled(); + expect(browserTabMock.resetToDefaults).toHaveBeenCalled(); }); }); diff --git a/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.ts b/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.ts index 577947d2f..ff52d233e 100644 --- a/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.ts +++ b/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.ts @@ -1,6 +1,6 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { ChangeDetectionStrategy, Component, HostBinding, inject, OnDestroy, OnInit, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, HostBinding, inject, OnDestroy, signal } from '@angular/core'; import { FormControl } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; @@ -21,7 +21,7 @@ import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store styleUrl: './preprint-provider-discover.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class PreprintProviderDiscoverComponent implements OnInit, OnDestroy { +export class PreprintProviderDiscoverComponent implements OnDestroy { @HostBinding('class') classes = 'flex-1 flex flex-column w-full h-full'; private readonly activatedRoute = inject(ActivatedRoute); @@ -29,39 +29,43 @@ export class PreprintProviderDiscoverComponent implements OnInit, OnDestroy { private readonly headerStyleHelper = inject(HeaderStyleService); private readonly browserTabHelper = inject(BrowserTabService); - private actions = createDispatchMap({ + private readonly actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, setDefaultFilterValue: SetDefaultFilterValue, setResourceType: SetResourceType, }); - providerId = this.activatedRoute.snapshot.params['providerId']; + readonly providerId = this.activatedRoute.snapshot.params['providerId']; preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId)); isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); - searchControl = new FormControl(''); + searchControl = new FormControl('', { nonNullable: true }); defaultSearchFiltersInitialized = signal(false); - ngOnInit() { - this.actions.getPreprintProviderById(this.providerId).subscribe({ - next: () => { - const provider = this.preprintProvider(); + constructor() { + this.actions.getPreprintProviderById(this.providerId); - if (provider) { - this.actions.setDefaultFilterValue('publisher', provider.iri); - this.actions.setResourceType(ResourceType.Preprint); - this.defaultSearchFiltersInitialized.set(true); + effect(() => { + const provider = this.preprintProvider(); - this.brandService.applyBranding(provider.brand); - this.headerStyleHelper.applyHeaderStyles( - provider.brand.primaryColor, - provider.brand.secondaryColor, - provider.brand.heroBackgroundImageUrl - ); - this.browserTabHelper.updateTabStyles(provider.faviconUrl, provider.name); - } - }, + if (!provider) { + return; + } + + if (!this.defaultSearchFiltersInitialized()) { + this.actions.setDefaultFilterValue('publisher', provider.iri); + this.actions.setResourceType(ResourceType.Preprint); + this.defaultSearchFiltersInitialized.set(true); + } + + this.brandService.applyBranding(provider.brand); + this.headerStyleHelper.applyHeaderStyles( + provider.brand.primaryColor, + provider.brand.secondaryColor, + provider.brand.heroBackgroundImageUrl + ); + this.browserTabHelper.updateTabStyles(provider.faviconUrl, provider.name); }); } diff --git a/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.html b/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.html index e4757725c..6f4ef7bcf 100644 --- a/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.html +++ b/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.html @@ -10,6 +10,6 @@ [isProviderLoading]="isPreprintProviderLoading()" /> - + diff --git a/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.spec.ts b/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.spec.ts index 8c307f9ab..f524e9436 100644 --- a/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.spec.ts @@ -1,3 +1,5 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -14,38 +16,48 @@ import { PreprintProviderHeroComponent, } from '../../components'; import { PreprintProviderDetails } from '../../models'; -import { PreprintProvidersSelectors } from '../../store/preprint-providers'; +import { + GetHighlightedSubjectsByProviderId, + GetPreprintProviderById, + PreprintProvidersSelectors, +} from '../../store/preprint-providers'; import { PreprintProviderOverviewComponent } from './preprint-provider-overview.component'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; import { SUBJECTS_MOCK } from '@testing/mocks/subject.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { BrandServiceMock, BrandServiceMockType } from '@testing/providers/brand-service.mock'; +import { BrowserTabServiceMock, BrowserTabServiceMockType } from '@testing/providers/browser-tab-service.mock'; +import { HeaderStyleServiceMock, HeaderStyleServiceMockType } from '@testing/providers/header-style-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('PreprintProviderOverviewComponent', () => { let component: PreprintProviderOverviewComponent; let fixture: ComponentFixture; - let routerMock: ReturnType; + let store: Store; + let routerMock: RouterMockType; let routeMock: ReturnType; + let brandServiceMock: BrandServiceMockType; + let headerStyleMock: HeaderStyleServiceMockType; + let browserTabMock: BrowserTabServiceMockType; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; const mockSubjects = SUBJECTS_MOCK; const mockProviderId = 'osf'; - beforeEach(async () => { + beforeEach(() => { routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); - routeMock = ActivatedRouteMockBuilder.create() - .withParams({ providerId: mockProviderId }) - .withQueryParams({}) - .build(); + routeMock = ActivatedRouteMockBuilder.create().withParams({ providerId: mockProviderId }).build(); + brandServiceMock = BrandServiceMock.simple(); + headerStyleMock = HeaderStyleServiceMock.simple(); + browserTabMock = BrowserTabServiceMock.simple(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ PreprintProviderOverviewComponent, - OSFTestingModule, ...MockComponents( PreprintProviderHeroComponent, PreprintProviderFooterComponent, @@ -54,135 +66,75 @@ describe('PreprintProviderOverviewComponent', () => { ), ], providers: [ - MockProvider(BrandService), - MockProvider(BrowserTabService), - MockProvider(HeaderStyleService), + provideOSFCore(), MockProvider(Router, routerMock), MockProvider(ActivatedRoute, routeMock), + MockProvider(BrandService, brandServiceMock), + MockProvider(HeaderStyleService, headerStyleMock), + MockProvider(BrowserTabService, browserTabMock), provideMockStore({ signals: [ - { - selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), - value: mockProvider, - }, - { - selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, - value: false, - }, - { - selector: PreprintProvidersSelectors.getHighlightedSubjectsForProvider, - value: mockSubjects, - }, - { - selector: PreprintProvidersSelectors.areSubjectsLoading, - value: false, - }, + { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: mockProvider }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, + { selector: PreprintProvidersSelectors.getHighlightedSubjectsForProvider, value: mockSubjects }, + { selector: PreprintProvidersSelectors.areSubjectsLoading, value: false }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(PreprintProviderOverviewComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should initialize with correct default values', () => { - expect(component.preprintProvider).toBeDefined(); - expect(component.isPreprintProviderLoading).toBeDefined(); - expect(component.highlightedSubjectsByProviderId).toBeDefined(); - expect(component.areSubjectsLoading).toBeDefined(); - }); - - it('should return preprint provider from store', () => { - const provider = component.preprintProvider(); - expect(provider).toBe(mockProvider); - }); - - it('should return loading state from store', () => { - const loading = component.isPreprintProviderLoading(); - expect(loading).toBe(false); - }); - - it('should return highlighted subjects from store', () => { - const subjects = component.highlightedSubjectsByProviderId(); - expect(subjects).toBe(mockSubjects); - }); - - it('should return subjects loading state from store', () => { - const loading = component.areSubjectsLoading(); - expect(loading).toBe(false); - }); - - it('should handle provider data correctly', () => { - const provider = component.preprintProvider(); - expect(provider).toBe(mockProvider); - expect(provider?.id).toBe(mockProvider.id); - expect(provider?.name).toBe(mockProvider.name); + it('should dispatch initial actions on creation', () => { + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProviderById(mockProviderId)); + expect(store.dispatch).toHaveBeenCalledWith(new GetHighlightedSubjectsByProviderId(mockProviderId)); }); - it('should handle subjects data correctly', () => { - const subjects = component.highlightedSubjectsByProviderId(); - expect(subjects).toBe(mockSubjects); - expect(Array.isArray(subjects)).toBe(true); + it('should apply branding when provider is available', () => { + expect(brandServiceMock.applyBranding).toHaveBeenCalledWith(mockProvider.brand); + expect(headerStyleMock.applyHeaderStyles).toHaveBeenCalledWith( + mockProvider.brand.primaryColor, + mockProvider.brand.secondaryColor, + mockProvider.brand.heroBackgroundImageUrl + ); + expect(browserTabMock.updateTabStyles).toHaveBeenCalledWith(mockProvider.faviconUrl, mockProvider.name); }); - it('should handle loading states correctly', () => { - const providerLoading = component.isPreprintProviderLoading(); - const subjectsLoading = component.areSubjectsLoading(); + it('should reset branding and header styles on destroy', () => { + component.ngOnDestroy(); - expect(typeof providerLoading).toBe('boolean'); - expect(typeof subjectsLoading).toBe('boolean'); - expect(providerLoading).toBe(false); - expect(subjectsLoading).toBe(false); + expect(headerStyleMock.resetToDefaults).toHaveBeenCalled(); + expect(brandServiceMock.resetBranding).toHaveBeenCalled(); + expect(browserTabMock.resetToDefaults).toHaveBeenCalled(); }); it('should navigate to discover page with search value', () => { const searchValue = 'test search'; + component.redirectToDiscoverPageWithValue(searchValue); expect(routerMock.navigate).toHaveBeenCalledWith(['discover'], { - relativeTo: expect.any(Object), + relativeTo: expect.anything(), queryParams: { search: searchValue }, }); }); it('should navigate to discover page with empty search value', () => { const searchValue = ''; - component.redirectToDiscoverPageWithValue(searchValue); - - expect(routerMock.navigate).toHaveBeenCalledWith(['discover'], { - relativeTo: expect.any(Object), - queryParams: { search: searchValue }, - }); - }); - it('should navigate to discover page with null search value', () => { - const searchValue = null as any; component.redirectToDiscoverPageWithValue(searchValue); expect(routerMock.navigate).toHaveBeenCalledWith(['discover'], { - relativeTo: expect.any(Object), + relativeTo: expect.anything(), queryParams: { search: searchValue }, }); }); - it('should initialize signals correctly', () => { - expect(component.preprintProvider).toBeDefined(); - expect(component.isPreprintProviderLoading).toBeDefined(); - expect(component.highlightedSubjectsByProviderId).toBeDefined(); - expect(component.areSubjectsLoading).toBeDefined(); - }); - - it('should handle provider data with null values', () => { - const provider = component.preprintProvider(); - expect(provider).toBeDefined(); - expect(provider).toBe(mockProvider); - }); - - it('should handle subjects data with empty array', () => { - const subjects = component.highlightedSubjectsByProviderId(); - expect(subjects).toBeDefined(); - expect(Array.isArray(subjects)).toBe(true); + it('should expose highlighted subjects from store', () => { + expect(component.highlightedSubjectsByProviderId()).toBe(mockSubjects); }); }); diff --git a/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.ts b/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.ts index c38ab13f4..5eb4560c2 100644 --- a/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.ts +++ b/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.ts @@ -1,8 +1,8 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { map, of } from 'rxjs'; +import { map } from 'rxjs'; -import { ChangeDetectionStrategy, Component, effect, inject, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, inject, OnDestroy } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; @@ -23,7 +23,7 @@ import { } from '../../store/preprint-providers'; @Component({ - selector: 'osf-provider-overview', + selector: 'osf-preprint-provider-overview', imports: [ AdvisoryBoardComponent, BrowseBySubjectsComponent, @@ -34,15 +34,16 @@ import { styleUrl: './preprint-provider-overview.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class PreprintProviderOverviewComponent implements OnInit, OnDestroy { +export class PreprintProviderOverviewComponent implements OnDestroy { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly brandService = inject(BrandService); private readonly headerStyleHelper = inject(HeaderStyleService); private readonly browserTabHelper = inject(BrowserTabService); - private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId'])) ?? of(undefined)); - private actions = createDispatchMap({ + private readonly providerId = toSignal(this.route.params.pipe(map((params) => params['providerId']))); + + private readonly actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, getHighlightedSubjectsByProviderId: GetHighlightedSubjectsByProviderId, }); @@ -53,6 +54,9 @@ export class PreprintProviderOverviewComponent implements OnInit, OnDestroy { areSubjectsLoading = select(PreprintProvidersSelectors.areSubjectsLoading); constructor() { + this.actions.getPreprintProviderById(this.providerId()); + this.actions.getHighlightedSubjectsByProviderId(this.providerId()); + effect(() => { const provider = this.preprintProvider(); @@ -68,18 +72,13 @@ export class PreprintProviderOverviewComponent implements OnInit, OnDestroy { }); } - ngOnInit() { - this.actions.getPreprintProviderById(this.providerId()); - this.actions.getHighlightedSubjectsByProviderId(this.providerId()); - } - ngOnDestroy() { this.headerStyleHelper.resetToDefaults(); this.brandService.resetBranding(); this.browserTabHelper.resetToDefaults(); } - redirectToDiscoverPageWithValue(searchValue: string) { + redirectToDiscoverPageWithValue(searchValue: string): void { this.router.navigate(['discover'], { relativeTo: this.route, queryParams: { search: searchValue }, diff --git a/src/app/features/preprints/pages/landing/preprints-landing.component.html b/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.html similarity index 77% rename from src/app/features/preprints/pages/landing/preprints-landing.component.html rename to src/app/features/preprints/pages/preprints-landing/preprints-landing.component.html index 5d1517fe1..10735e837 100644 --- a/src/app/features/preprints/pages/landing/preprints-landing.component.html +++ b/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.html @@ -1,29 +1,31 @@ +@let providerDetails = provider(); +

{{ 'preprints.title' | translate }}

- @if (isPreprintProviderLoading()) { + @if (isProviderLoading()) { } @else { }
- @if (isPreprintProviderLoading()) { + @if (isProviderLoading()) {
} @else { -
+
} {{ 'preprints.poweredBy' | translate }}
@@ -32,34 +34,30 @@

{{ 'preprints.title' | translate }}

class="w-full" [control]="searchControl" [placeholder]="'preprints.searchPlaceholder' | translate: { preprintWord: 'preprint' } | titlecase" - (triggerSearch)="redirectToSearchPageWithValue()" + (triggerSearch)="submitSearch()" /> - @if (isPreprintProviderLoading()) { + @if (isProviderLoading()) { - } @else if (osfPreprintProvider()!.examplePreprintId) { + } @else if (providerDetails?.examplePreprintId) { {{ 'preprints.showExample' | translate }} }
- +
{ + let component: PreprintsLandingComponent; + let fixture: ComponentFixture; + let store: Store; + let routerMock: RouterMockType; + let brandServiceMock: BrandServiceMockType; + + const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; + const mockProvidersToAdvertise = [PREPRINT_PROVIDER_SHORT_INFO_MOCK]; + const mockHighlightedSubjects = SUBJECTS_MOCK; + const mockDefaultProvider = 'osf'; + + beforeEach(() => { + routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); + brandServiceMock = BrandServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [ + PreprintsLandingComponent, + ...MockComponents( + SearchInputComponent, + AdvisoryBoardComponent, + PreprintServicesComponent, + BrowseBySubjectsComponent + ), + MockPipe(TitleCasePipe), + ], + providers: [ + provideOSFCore(), + MockProvider(BrandService, brandServiceMock), + MockProvider(Router, routerMock), + provideMockStore({ + signals: [ + { + selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockDefaultProvider), + value: mockProvider, + }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, + { selector: PreprintProvidersSelectors.getPreprintProvidersToAdvertise, value: mockProvidersToAdvertise }, + { selector: PreprintProvidersSelectors.getHighlightedSubjectsForProvider, value: mockHighlightedSubjects }, + { selector: PreprintProvidersSelectors.areSubjectsLoading, value: false }, + ], + }), + ], + }); + + store = TestBed.inject(Store); + fixture = TestBed.createComponent(PreprintsLandingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should initialize with correct default values', () => { + expect(component.searchControl.value).toBe(''); + expect(component.supportEmail).toBeDefined(); + expect(component.classes).toBe('flex-1 flex flex-column w-full h-full'); + }); + + it('should dispatch initial actions on creation', () => { + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProviderById(mockDefaultProvider)); + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProvidersToAdvertise()); + expect(store.dispatch).toHaveBeenCalledWith(new GetHighlightedSubjectsByProviderId(mockDefaultProvider)); + }); + + it('should apply branding when provider is available', () => { + expect(brandServiceMock.applyBranding).toHaveBeenCalledWith(mockProvider.brand); + }); + + it('should reset branding on destroy', () => { + component.ngOnDestroy(); + + expect(brandServiceMock.resetBranding).toHaveBeenCalled(); + }); + + it('should navigate to search page with search value', () => { + component.searchControl.setValue('test search'); + + component.submitSearch(); + + expect(routerMock.navigate).toHaveBeenCalledWith(['/search'], { + queryParams: { search: 'test search', tab: ResourceType.Preprint }, + }); + }); + + it('should not navigate when search value is empty', () => { + component.searchControl.setValue(''); + + component.submitSearch(); + + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); + + it('should not navigate when search value is whitespace only', () => { + component.searchControl.setValue(' '); + + component.submitSearch(); + + expect(routerMock.navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/preprints/pages/landing/preprints-landing.component.ts b/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.ts similarity index 72% rename from src/app/features/preprints/pages/landing/preprints-landing.component.ts rename to src/app/features/preprints/pages/preprints-landing/preprints-landing.component.ts index b6bcfc29d..0f66c9315 100644 --- a/src/app/features/preprints/pages/landing/preprints-landing.component.ts +++ b/src/app/features/preprints/pages/preprints-landing/preprints-landing.component.ts @@ -6,7 +6,7 @@ import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; import { TitleCasePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, effect, HostBinding, inject, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, HostBinding, inject, OnDestroy } from '@angular/core'; import { FormControl } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; @@ -25,15 +25,15 @@ import { } from '../../store/preprint-providers'; @Component({ - selector: 'osf-overview', + selector: 'osf-preprints-landing', imports: [ Button, - SearchInputComponent, + Skeleton, RouterLink, + SearchInputComponent, AdvisoryBoardComponent, PreprintServicesComponent, BrowseBySubjectsComponent, - Skeleton, TranslatePipe, TitleCasePipe, ], @@ -41,33 +41,37 @@ import { styleUrl: './preprints-landing.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class PreprintsLandingComponent implements OnInit, OnDestroy { +export class PreprintsLandingComponent implements OnDestroy { @HostBinding('class') classes = 'flex-1 flex flex-column w-full h-full'; - searchControl = new FormControl(''); - private readonly environment = inject(ENVIRONMENT); private readonly brandService = inject(BrandService); + private readonly router = inject(Router); readonly supportEmail = this.environment.supportEmail; - private readonly OSF_PROVIDER_ID = this.environment.defaultProvider; + private readonly defaultProviderId = this.environment.defaultProvider; + + searchControl = new FormControl('', { nonNullable: true }); - private readonly router = inject(Router); private readonly actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, getPreprintProvidersToAdvertise: GetPreprintProvidersToAdvertise, getHighlightedSubjectsByProviderId: GetHighlightedSubjectsByProviderId, }); - osfPreprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.OSF_PROVIDER_ID)); - isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); + provider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.defaultProviderId)); + isProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); preprintProvidersToAdvertise = select(PreprintProvidersSelectors.getPreprintProvidersToAdvertise); - highlightedSubjectsByProviderId = select(PreprintProvidersSelectors.getHighlightedSubjectsForProvider); + highlightedSubjects = select(PreprintProvidersSelectors.getHighlightedSubjectsForProvider); areSubjectsLoading = select(PreprintProvidersSelectors.areSubjectsLoading); constructor() { + this.actions.getPreprintProviderById(this.defaultProviderId); + this.actions.getPreprintProvidersToAdvertise(); + this.actions.getHighlightedSubjectsByProviderId(this.defaultProviderId); + effect(() => { - const provider = this.osfPreprintProvider(); + const provider = this.provider(); if (provider) { this.brandService.applyBranding(provider.brand); @@ -75,21 +79,17 @@ export class PreprintsLandingComponent implements OnInit, OnDestroy { }); } - ngOnInit(): void { - this.actions.getPreprintProviderById(this.OSF_PROVIDER_ID); - this.actions.getPreprintProvidersToAdvertise(); - this.actions.getHighlightedSubjectsByProviderId(this.OSF_PROVIDER_ID); - } - ngOnDestroy() { this.brandService.resetBranding(); } - redirectToSearchPageWithValue() { - const searchValue = normalizeQuotes(this.searchControl.value); + submitSearch(): void { + const searchValue = normalizeQuotes(this.searchControl.value)?.trim(); - this.router.navigate(['/search'], { - queryParams: { search: searchValue, tab: ResourceType.Preprint }, - }); + if (!searchValue) { + return; + } + + this.router.navigate(['/search'], { queryParams: { search: searchValue, tab: ResourceType.Preprint } }); } } diff --git a/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.html b/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.html index d181f2b74..3cbea61e9 100644 --- a/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.html +++ b/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.html @@ -24,14 +24,14 @@

{{ 'preprints.selectService.sectionTitle' | translate }}

} } @else { - @for (provider of preprintProvidersAllowingSubmissions(); track $index) { + @for (provider of preprintProvidersAllowingSubmissions(); track provider.id) {
{{ provider.name }}

-
+
} } diff --git a/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.spec.ts b/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.spec.ts index fa17b37e7..12b7e4720 100644 --- a/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.spec.ts +++ b/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.spec.ts @@ -1,137 +1,89 @@ -import { TranslatePipe } from '@ngx-translate/core'; -import { MockComponents, MockPipe, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { PreprintProviderShortInfo } from '../../models'; -import { PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { PreprintStepperSelectors } from '../../store/preprint-stepper'; +import { GetPreprintProvidersAllowingSubmissions, PreprintProvidersSelectors } from '../../store/preprint-providers'; import { SelectPreprintServiceComponent } from './select-preprint-service.component'; import { PREPRINT_PROVIDER_SHORT_INFO_MOCK } from '@testing/mocks/preprint-provider-short-info.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; describe('SelectPreprintServiceComponent', () => { let component: SelectPreprintServiceComponent; let fixture: ComponentFixture; - let routerMock: ReturnType; - let routeMock: ReturnType; - - const mockProviders: PreprintProviderShortInfo[] = [PREPRINT_PROVIDER_SHORT_INFO_MOCK]; - const mockSelectedProviderId = 'osf'; - - beforeEach(async () => { - routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); - routeMock = ActivatedRouteMockBuilder.create().withParams({}).withQueryParams({}).build(); - - await TestBed.configureTestingModule({ - imports: [ - SelectPreprintServiceComponent, - OSFTestingModule, - ...MockComponents(SubHeaderComponent), - MockPipe(TranslatePipe), - ], + let store: Store; + + const mockProvider: PreprintProviderShortInfo = PREPRINT_PROVIDER_SHORT_INFO_MOCK; + const mockProviders: PreprintProviderShortInfo[] = [mockProvider]; + + const defaultSignals: SignalOverride[] = [ + { selector: PreprintProvidersSelectors.getPreprintProvidersAllowingSubmissions, value: mockProviders }, + { selector: PreprintProvidersSelectors.arePreprintProvidersAllowingSubmissionsLoading, value: false }, + ]; + + function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + + TestBed.configureTestingModule({ + imports: [SelectPreprintServiceComponent, ...MockComponents(SubHeaderComponent)], providers: [ - MockProvider(Router, routerMock), - MockProvider(ActivatedRoute, routeMock), - provideMockStore({ - signals: [ - { - selector: PreprintProvidersSelectors.getPreprintProvidersAllowingSubmissions, - value: mockProviders, - }, - { - selector: PreprintProvidersSelectors.arePreprintProvidersAllowingSubmissionsLoading, - value: false, - }, - { - selector: PreprintStepperSelectors.getSelectedProviderId, - value: mockSelectedProviderId, - }, - ], - }), + provideOSFCore(), + MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().build()), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(SelectPreprintServiceComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + } - it('should create', () => { - expect(component).toBeTruthy(); - }); + it('should initialize with correct default values', () => { + setup(); - it('should return preprint providers from store', () => { - const providers = component.preprintProvidersAllowingSubmissions(); - expect(providers).toBe(mockProviders); + expect(component.classes).toBe('flex-1 flex flex-column w-full'); + expect(component.skeletonArray.length).toBe(8); }); - it('should return loading state from store', () => { - const loading = component.areProvidersLoading(); - expect(loading).toBe(false); - }); + it('should dispatch fetch action on creation', () => { + setup(); - it('should return selected provider ID from store', () => { - const selectedId = component.selectedProviderId(); - expect(selectedId).toBe(mockSelectedProviderId); + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProvidersAllowingSubmissions()); }); - it('should handle provider data correctly', () => { - const providers = component.preprintProvidersAllowingSubmissions(); - expect(providers).toBe(mockProviders); - expect(Array.isArray(providers)).toBe(true); - expect(providers.length).toBe(1); - expect(providers[0].id).toBe(mockProviders[0].id); - }); + it('should dispatch select action when toggling an unselected provider', () => { + setup(); - it('should handle loading states correctly', () => { - const loading = component.areProvidersLoading(); - expect(typeof loading).toBe('boolean'); - expect(loading).toBe(false); - }); + component.toggleProviderSelection(mockProvider); - it('should handle selected provider ID correctly', () => { - const selectedId = component.selectedProviderId(); - expect(selectedId).toBe(mockSelectedProviderId); - expect(typeof selectedId).toBe('string'); + expect(component.selectedProviderId()).toBe(mockProvider.id); }); - it('should initialize skeleton array correctly', () => { - expect(component.skeletonArray).toBeDefined(); - expect(Array.isArray(component.skeletonArray)).toBe(true); - expect(component.skeletonArray.length).toBe(8); - expect(component.skeletonArray).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); - }); + it('should deselect when toggling the already selected provider', () => { + setup(); + component.selectedProviderId.set(mockProvider.id); - it('should handle provider selection when provider is not selected', () => { - const provider = mockProviders[0]; - component.selectDeselectProvider(provider); + component.toggleProviderSelection(mockProvider); - expect(component.selectedProviderId()).toBe(mockSelectedProviderId); + expect(component.selectedProviderId()).toBeNull(); }); - it('should handle provider deselection when provider is already selected', () => { - const provider = mockProviders[0]; - - expect(() => component.selectDeselectProvider(provider)).not.toThrow(); - }); + it('should select when toggling a different provider', () => { + setup(); + component.selectedProviderId.set('other-provider'); - it('should handle empty providers array', () => { - const providers = component.preprintProvidersAllowingSubmissions(); - expect(providers).toBeDefined(); - expect(Array.isArray(providers)).toBe(true); - }); + component.toggleProviderSelection(mockProvider); - it('should handle null selected provider ID', () => { - const selectedId = component.selectedProviderId(); - expect(selectedId).toBeDefined(); + expect(component.selectedProviderId()).toBe(mockProvider.id); }); }); diff --git a/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.ts b/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.ts index c726d4ac5..842d11f5e 100644 --- a/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.ts +++ b/src/app/features/preprints/pages/select-preprint-service/select-preprint-service.component.ts @@ -8,45 +8,45 @@ import { Skeleton } from 'primeng/skeleton'; import { Tooltip } from 'primeng/tooltip'; import { NgClass } from '@angular/common'; -import { ChangeDetectionStrategy, Component, HostBinding, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, HostBinding, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { SafeHtmlPipe } from '@osf/shared/pipes/safe-html.pipe'; import { PreprintProviderShortInfo } from '../../models'; import { GetPreprintProvidersAllowingSubmissions, PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { PreprintStepperSelectors, SetSelectedPreprintProviderId } from '../../store/preprint-stepper'; @Component({ selector: 'osf-select-preprint-service', - imports: [SubHeaderComponent, Card, Button, NgClass, Tooltip, Skeleton, TranslatePipe, RouterLink], + imports: [SubHeaderComponent, Card, Button, NgClass, Tooltip, Skeleton, RouterLink, TranslatePipe, SafeHtmlPipe], templateUrl: './select-preprint-service.component.html', styleUrl: './select-preprint-service.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SelectPreprintServiceComponent implements OnInit { +export class SelectPreprintServiceComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; private actions = createDispatchMap({ getPreprintProvidersAllowingSubmissions: GetPreprintProvidersAllowingSubmissions, - setSelectedPreprintProviderId: SetSelectedPreprintProviderId, }); preprintProvidersAllowingSubmissions = select(PreprintProvidersSelectors.getPreprintProvidersAllowingSubmissions); areProvidersLoading = select(PreprintProvidersSelectors.arePreprintProvidersAllowingSubmissionsLoading); - selectedProviderId = select(PreprintStepperSelectors.getSelectedProviderId); - skeletonArray = Array.from({ length: 8 }, (_, i) => i + 1); - ngOnInit(): void { + selectedProviderId = signal(null); + skeletonArray = new Array(8); + + constructor() { this.actions.getPreprintProvidersAllowingSubmissions(); } - selectDeselectProvider(provider: PreprintProviderShortInfo) { + toggleProviderSelection(provider: PreprintProviderShortInfo): void { if (provider.id === this.selectedProviderId()) { - this.actions.setSelectedPreprintProviderId(null); + this.selectedProviderId.set(null); return; } - this.actions.setSelectedPreprintProviderId(provider.id); + this.selectedProviderId.set(provider.id); } } diff --git a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.html b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.html index fd53f63ae..38a89d790 100644 --- a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.html +++ b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.html @@ -6,9 +6,9 @@ } @else {

{{ 'preprints.addPreprint' | translate: { preprintWord: preprintProvider()!.preprintWord } }} @@ -22,7 +22,7 @@

} @else { @@ -30,33 +30,33 @@

} -
- @switch (currentStep().value) { - @case (SubmitStepsEnum.TitleAndAbstract) { - - } - @case (SubmitStepsEnum.File) { - - } - @case (SubmitStepsEnum.Metadata) { - - } - @case (SubmitStepsEnum.AuthorAssertions) { - - } - @case (SubmitStepsEnum.Supplements) { - - } - @case (SubmitStepsEnum.Review) { - +@let provider = preprintProvider(); + +@if (provider) { +
+ @switch (currentStep().value) { + @case (PreprintSteps.TitleAndAbstract) { + + } + @case (PreprintSteps.File) { + + } + @case (PreprintSteps.Metadata) { + + } + @case (PreprintSteps.AuthorAssertions) { + + } + @case (PreprintSteps.Supplements) { + + } + @case (PreprintSteps.Review) { + + } } - } -
+
+} diff --git a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts index 50920be2a..9803536df 100644 --- a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts +++ b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts @@ -1,13 +1,14 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; -import { StepOption } from '@osf/shared/models/step-option.model'; import { BrandService } from '@osf/shared/services/brand.service'; import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; import { HeaderStyleService } from '@osf/shared/services/header-style.service'; @@ -23,37 +24,48 @@ import { import { submitPreprintSteps } from '../../constants'; import { PreprintSteps } from '../../enums'; import { PreprintProviderDetails } from '../../models'; -import { PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { PreprintStepperSelectors } from '../../store/preprint-stepper'; +import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; +import { DeletePreprint, PreprintStepperSelectors, ResetPreprintStepperState } from '../../store/preprint-stepper'; import { SubmitPreprintStepperComponent } from './submit-preprint-stepper.component'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { BrandServiceMock, BrandServiceMockType } from '@testing/providers/brand-service.mock'; +import { BrowserTabServiceMock, BrowserTabServiceMockType } from '@testing/providers/browser-tab-service.mock'; +import { HeaderStyleServiceMock, HeaderStyleServiceMockType } from '@testing/providers/header-style-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; describe('SubmitPreprintStepperComponent', () => { let component: SubmitPreprintStepperComponent; let fixture: ComponentFixture; - let routerMock: ReturnType; - let routeMock: ReturnType; + let store: Store; + let brandServiceMock: BrandServiceMockType; + let headerStyleMock: HeaderStyleServiceMockType; + let browserTabMock: BrowserTabServiceMockType; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; const mockProviderId = 'osf'; - beforeEach(async () => { - routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); - routeMock = ActivatedRouteMockBuilder.create() - .withParams({ providerId: mockProviderId }) - .withQueryParams({}) - .build(); + const defaultSignals: SignalOverride[] = [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: mockProvider }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, + { selector: PreprintStepperSelectors.hasBeenSubmitted, value: false }, + ]; + + function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + + const routeMock = ActivatedRouteMockBuilder.create().withParams({ providerId: mockProviderId }).build(); + + brandServiceMock = BrandServiceMock.simple(); + headerStyleMock = HeaderStyleServiceMock.simple(); + browserTabMock = BrowserTabServiceMock.simple(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ SubmitPreprintStepperComponent, - OSFTestingModule, ...MockComponents( StepperComponent, TitleAndAbstractStepComponent, @@ -61,128 +73,193 @@ describe('SubmitPreprintStepperComponent', () => { PreprintsMetadataStepComponent, AuthorAssertionsStepComponent, SupplementsStepComponent, - AuthorAssertionsStepComponent, ReviewStepComponent ), ], providers: [ - MockProvider(BrandService), - MockProvider(BrowserTabService), - MockProvider(HeaderStyleService), - MockProvider(Router, routerMock), + provideOSFCore(), MockProvider(ActivatedRoute, routeMock), + MockProvider(BrandService, brandServiceMock), + MockProvider(HeaderStyleService, headerStyleMock), + MockProvider(BrowserTabService, browserTabMock), MockProvider(IS_WEB, of(true)), - provideMockStore({ - signals: [ - { - selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), - value: mockProvider, - }, - { - selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, - value: false, - }, - { - selector: PreprintStepperSelectors.hasBeenSubmitted, - value: false, - }, - ], - }), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(SubmitPreprintStepperComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + } it('should initialize with correct default values', () => { - expect(component.SubmitStepsEnum).toBe(PreprintSteps); + setup(); + + expect(component.PreprintSteps).toBe(PreprintSteps); expect(component.classes).toBe('flex-1 flex flex-column w-full'); expect(component.currentStep()).toEqual(submitPreprintSteps[0]); }); - it('should return submission state from store', () => { - const submitted = component.hasBeenSubmitted(); - expect(submitted).toBe(false); + it('should dispatch initial action on creation', () => { + setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProviderById(mockProviderId)); }); - it('should return web environment state', () => { - const isWeb = component.isWeb(); - expect(typeof isWeb).toBe('boolean'); + it('should apply branding when provider is available', () => { + setup(); + + expect(brandServiceMock.applyBranding).toHaveBeenCalledWith(mockProvider.brand); + expect(headerStyleMock.applyHeaderStyles).toHaveBeenCalledWith( + mockProvider.brand.primaryColor, + mockProvider.brand.secondaryColor, + mockProvider.brand.heroBackgroundImageUrl + ); + expect(browserTabMock.updateTabStyles).toHaveBeenCalledWith(mockProvider.faviconUrl, mockProvider.name); }); - it('should initialize with first step as current step', () => { - expect(component.currentStep()).toEqual(submitPreprintSteps[0]); + it('should reset services and delete preprint on destroy', () => { + setup(); + + component.ngOnDestroy(); + + expect(headerStyleMock.resetToDefaults).toHaveBeenCalled(); + expect(brandServiceMock.resetBranding).toHaveBeenCalled(); + expect(browserTabMock.resetToDefaults).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new DeletePreprint()); + expect(store.dispatch).toHaveBeenCalledWith(new ResetPreprintStepperState()); }); - it('should compute submitPreprintSteps correctly', () => { - const steps = component.submitPreprintSteps(); - expect(steps).toBeDefined(); - expect(Array.isArray(steps)).toBe(true); + it('should filter out AuthorAssertions step when assertions are disabled', () => { + setup(); + + const stepValues = component.steps().map((s) => s.value); + + expect(stepValues).not.toContain(PreprintSteps.AuthorAssertions); + expect(stepValues).toContain(PreprintSteps.TitleAndAbstract); + expect(stepValues).toContain(PreprintSteps.File); + expect(stepValues).toContain(PreprintSteps.Metadata); + expect(stepValues).toContain(PreprintSteps.Supplements); + expect(stepValues).toContain(PreprintSteps.Review); + }); + + it('should include AuthorAssertions step when assertions are enabled', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), + value: { ...mockProvider, assertionsEnabled: true }, + }, + ], + }); + + const stepValues = component.steps().map((s) => s.value); + expect(stepValues).toContain(PreprintSteps.AuthorAssertions); }); - it('should handle step change when moving to previous step', () => { - const previousStep = submitPreprintSteps[0]; + it('should re-index steps sequentially', () => { + setup(); + + const steps = component.steps(); + steps.forEach((step, i) => expect(step.index).toBe(i)); + }); - component.stepChange(previousStep); + it('should return empty steps when provider is unavailable', () => { + setup({ + selectorOverrides: [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: null }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: true }, + ], + }); - expect(component.currentStep()).toEqual(previousStep); + expect(component.steps()).toEqual([]); }); - it('should not change step when moving to next step', () => { - const currentStep = component.currentStep(); - const nextStep = submitPreprintSteps[1]; + it('should prevent beforeunload when not submitted', () => { + setup(); + const event = { preventDefault: jest.fn() } as unknown as BeforeUnloadEvent; - component.stepChange(nextStep); + component.onBeforeUnload(event); - expect(component.currentStep()).toEqual(currentStep); + expect(event.preventDefault).toHaveBeenCalled(); }); - it('should move to next step', () => { - const currentIndex = component.currentStep()?.index ?? 0; - const nextStep = component.submitPreprintSteps()[currentIndex + 1]; + it('should not prevent beforeunload when submitted', () => { + setup({ selectorOverrides: [{ selector: PreprintStepperSelectors.hasBeenSubmitted, value: true }] }); + const event = { preventDefault: jest.fn() } as unknown as BeforeUnloadEvent; + + component.onBeforeUnload(event); - if (nextStep) { - component.moveToNextStep(); - expect(component.currentStep()).toEqual(nextStep); - } + expect(event.preventDefault).not.toHaveBeenCalled(); }); - it('should move to previous step', () => { + it('should prevent deactivation when not submitted', () => { + setup(); + + expect(component.canDeactivate()).toBe(false); + }); + + it('should allow deactivation when submitted', () => { + setup({ selectorOverrides: [{ selector: PreprintStepperSelectors.hasBeenSubmitted, value: true }] }); + + expect(component.canDeactivate()).toBe(true); + }); + + it('should ignore stepping forward via stepper', () => { + setup(); + + component.stepChange(component.steps()[1]); + + expect(component.currentStep()).toEqual(submitPreprintSteps[0]); + }); + + it('should allow stepping back via stepper', () => { + setup(); component.moveToNextStep(); - const nextStep = component.currentStep(); - component.moveToPreviousStep(); - const previousStep = component.currentStep(); + component.stepChange(component.steps()[0]); - expect(previousStep?.index).toBeLessThan(nextStep?.index ?? 0); + expect(component.currentStep()).toEqual(component.steps()[0]); }); - it('should handle beforeunload event', () => { - const event = { - preventDefault: jest.fn(), - } as unknown as BeforeUnloadEvent; + it('should move to next step', () => { + setup(); + const expectedNext = component.steps()[1]; - const result = component.onBeforeUnload(event); + component.moveToNextStep(); - expect(event.preventDefault).toHaveBeenCalled(); - expect(result).toBe(false); + expect(component.currentStep()).toEqual(expectedNext); + }); + + it('should not move past the last step', () => { + setup(); + const steps = component.steps(); + const lastStep = steps[steps.length - 1]; + component.currentStep.set(lastStep); + + component.moveToNextStep(); + + expect(component.currentStep()).toEqual(lastStep); }); - it('should handle step navigation correctly', () => { + it('should move to previous step', () => { + setup(); component.moveToNextStep(); - const nextStep = component.currentStep(); - expect(nextStep).toBeDefined(); + const firstStep = component.steps()[0]; component.moveToPreviousStep(); - const previousStep = component.currentStep(); - expect(previousStep).toBeDefined(); + + expect(component.currentStep()).toEqual(firstStep); }); - it('should handle edge case when moving to next step with undefined current step', () => { - component.currentStep.set({} as StepOption); + it('should not move before the first step', () => { + setup(); + const firstStep = component.steps()[0]; + component.currentStep.set(firstStep); + + component.moveToPreviousStep(); - expect(() => component.moveToNextStep()).not.toThrow(); + expect(component.currentStep()).toEqual(firstStep); }); }); diff --git a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts index 6bfcfb1ea..bb0e6f7a2 100644 --- a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts +++ b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts @@ -4,8 +4,9 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Skeleton } from 'primeng/skeleton'; -import { map, Observable, of } from 'rxjs'; +import { map } from 'rxjs'; +import { DOCUMENT } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -15,7 +16,6 @@ import { HostListener, inject, OnDestroy, - OnInit, signal, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -40,12 +40,7 @@ import { import { submitPreprintSteps } from '../../constants'; import { PreprintSteps } from '../../enums'; import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { - DeletePreprint, - PreprintStepperSelectors, - ResetPreprintStepperState, - SetSelectedPreprintProviderId, -} from '../../store/preprint-stepper'; +import { DeletePreprint, PreprintStepperSelectors, ResetPreprintStepperState } from '../../store/preprint-stepper'; @Component({ selector: 'osf-submit-preprint-stepper', @@ -57,7 +52,6 @@ import { PreprintsMetadataStepComponent, AuthorAssertionsStepComponent, SupplementsStepComponent, - AuthorAssertionsStepComponent, ReviewStepComponent, TranslatePipe, ], @@ -65,24 +59,24 @@ import { styleUrl: './submit-preprint-stepper.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SubmitPreprintStepperComponent implements OnInit, OnDestroy, CanDeactivateComponent { +export class SubmitPreprintStepperComponent implements OnDestroy, CanDeactivateComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; private readonly route = inject(ActivatedRoute); + private readonly document = inject(DOCUMENT); private readonly brandService = inject(BrandService); private readonly headerStyleHelper = inject(HeaderStyleService); private readonly browserTabHelper = inject(BrowserTabService); - private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId'])) ?? of(undefined)); + private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId']))); private actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, - setSelectedPreprintProviderId: SetSelectedPreprintProviderId, resetState: ResetPreprintStepperState, deletePreprint: DeletePreprint, }); - readonly SubmitStepsEnum = PreprintSteps; + readonly PreprintSteps = PreprintSteps; preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); @@ -90,7 +84,7 @@ export class SubmitPreprintStepperComponent implements OnInit, OnDestroy, CanDea currentStep = signal(submitPreprintSteps[0]); isWeb = toSignal(inject(IS_WEB)); - readonly submitPreprintSteps = computed(() => { + readonly steps = computed(() => { const provider = this.preprintProvider(); if (!provider) { @@ -98,26 +92,17 @@ export class SubmitPreprintStepperComponent implements OnInit, OnDestroy, CanDea } return submitPreprintSteps - .map((step) => { - if (!provider.assertionsEnabled && step.value === PreprintSteps.AuthorAssertions) { - return null; - } - - return step; - }) - .filter((step) => step !== null) - .map((step, index) => ({ - ...step, - index, - })); + .filter((step) => step.value !== PreprintSteps.AuthorAssertions || provider.assertionsEnabled) + .map((step, index) => ({ ...step, index })); }); constructor() { + this.actions.getPreprintProviderById(this.providerId()); + effect(() => { const provider = this.preprintProvider(); if (provider) { - this.actions.setSelectedPreprintProviderId(provider.id); this.brandService.applyBranding(provider.brand); this.headerStyleHelper.applyHeaderStyles( provider.brand.primaryColor, @@ -129,12 +114,15 @@ export class SubmitPreprintStepperComponent implements OnInit, OnDestroy, CanDea }); } - canDeactivate(): Observable | boolean { - return this.hasBeenSubmitted(); + @HostListener('window:beforeunload', ['$event']) + onBeforeUnload($event: BeforeUnloadEvent): void { + if (!this.hasBeenSubmitted()) { + $event.preventDefault(); + } } - ngOnInit() { - this.actions.getPreprintProviderById(this.providerId()); + canDeactivate(): boolean { + return this.hasBeenSubmitted(); } ngOnDestroy() { @@ -146,8 +134,7 @@ export class SubmitPreprintStepperComponent implements OnInit, OnDestroy, CanDea } stepChange(step: StepOption): void { - const currentStepIndex = this.currentStep()?.index ?? 0; - if (step.index >= currentStepIndex) { + if (step.index >= this.currentStep().index) { return; } @@ -155,27 +142,29 @@ export class SubmitPreprintStepperComponent implements OnInit, OnDestroy, CanDea this.scrollToTop(); } - moveToNextStep() { - this.currentStep.set(this.submitPreprintSteps()[this.currentStep()?.index + 1]); - this.scrollToTop(); + moveToNextStep(): void { + const nextStep = this.steps()[this.currentStep().index + 1]; + + if (nextStep) { + this.currentStep.set(nextStep); + this.scrollToTop(); + } } - moveToPreviousStep() { - this.currentStep.set(this.submitPreprintSteps()[this.currentStep()?.index - 1]); - this.scrollToTop(); + moveToPreviousStep(): void { + const prevStep = this.steps()[this.currentStep().index - 1]; + + if (prevStep) { + this.currentStep.set(prevStep); + this.scrollToTop(); + } } - scrollToTop() { - const contentWrapper = document.querySelector('.content-wrapper') as HTMLElement; + private scrollToTop(): void { + const contentWrapper = this.document.querySelector('.content-wrapper') as HTMLElement; if (contentWrapper) { contentWrapper.scrollTo({ top: 0, behavior: 'instant' }); } } - - @HostListener('window:beforeunload', ['$event']) - public onBeforeUnload($event: BeforeUnloadEvent): boolean { - $event.preventDefault(); - return false; - } } diff --git a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.html b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.html index 44991c051..6e19e1bd8 100644 --- a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.html +++ b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.html @@ -6,9 +6,9 @@ } @else {

{{ 'preprints.updatePreprint' | translate: { preprintWord: preprintProvider()!.preprintWord } }} @@ -30,33 +30,33 @@

} -
- @switch (currentStep().value) { - @case (PreprintSteps.TitleAndAbstract) { - - } - @case (SubmitStepsEnum.File) { - - } - @case (PreprintSteps.Metadata) { - - } - @case (PreprintSteps.AuthorAssertions) { - - } - @case (PreprintSteps.Supplements) { - - } - @case (PreprintSteps.Review) { - +@let provider = preprintProvider(); + +@if (provider) { +
+ @switch (currentStep().value) { + @case (PreprintSteps.TitleAndAbstract) { + + } + @case (PreprintSteps.File) { + + } + @case (PreprintSteps.Metadata) { + + } + @case (PreprintSteps.AuthorAssertions) { + + } + @case (PreprintSteps.Supplements) { + + } + @case (PreprintSteps.Review) { + + } } - } -
+
+} diff --git a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts index 7a57296bf..95c980ceb 100644 --- a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts +++ b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts @@ -1,13 +1,14 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; -import { StepOption } from '@osf/shared/models/step-option.model'; import { BrandService } from '@osf/shared/services/brand.service'; import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; import { HeaderStyleService } from '@osf/shared/services/header-style.service'; @@ -21,42 +22,57 @@ import { TitleAndAbstractStepComponent, } from '../../components'; import { submitPreprintSteps } from '../../constants'; -import { PreprintSteps } from '../../enums'; +import { PreprintSteps, ReviewsState } from '../../enums'; import { PreprintProviderDetails } from '../../models'; -import { PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { PreprintStepperSelectors } from '../../store/preprint-stepper'; +import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; +import { FetchPreprintById, PreprintStepperSelectors, ResetPreprintStepperState } from '../../store/preprint-stepper'; import { UpdatePreprintStepperComponent } from './update-preprint-stepper.component'; 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 { provideOSFCore } from '@testing/osf.testing.provider'; +import { BrandServiceMock, BrandServiceMockType } from '@testing/providers/brand-service.mock'; +import { BrowserTabServiceMock, BrowserTabServiceMockType } from '@testing/providers/browser-tab-service.mock'; +import { HeaderStyleServiceMock, HeaderStyleServiceMockType } from '@testing/providers/header-style-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; describe('UpdatePreprintStepperComponent', () => { let component: UpdatePreprintStepperComponent; let fixture: ComponentFixture; - let routerMock: ReturnType; - let routeMock: ReturnType; + let store: Store; + let brandServiceMock: BrandServiceMockType; + let headerStyleMock: HeaderStyleServiceMockType; + let browserTabMock: BrowserTabServiceMockType; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; const mockPreprint = PREPRINT_MOCK; const mockProviderId = 'osf'; const mockPreprintId = 'test_preprint_123'; - beforeEach(async () => { - routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); - routeMock = ActivatedRouteMockBuilder.create() + const defaultSignals: SignalOverride[] = [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: mockProvider }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, + { selector: PreprintStepperSelectors.getPreprint, value: mockPreprint }, + { selector: PreprintStepperSelectors.hasBeenSubmitted, value: false }, + { selector: PreprintStepperSelectors.hasAdminAccess, value: false }, + ]; + + function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides); + + const routeMock = ActivatedRouteMockBuilder.create() .withParams({ providerId: mockProviderId, preprintId: mockPreprintId }) - .withQueryParams({}) .build(); - await TestBed.configureTestingModule({ + brandServiceMock = BrandServiceMock.simple(); + headerStyleMock = HeaderStyleServiceMock.simple(); + browserTabMock = BrowserTabServiceMock.simple(); + + TestBed.configureTestingModule({ imports: [ UpdatePreprintStepperComponent, - OSFTestingModule, ...MockComponents( AuthorAssertionsStepComponent, StepperComponent, @@ -68,159 +84,229 @@ describe('UpdatePreprintStepperComponent', () => { ), ], providers: [ - MockProvider(BrandService), - MockProvider(BrowserTabService), - MockProvider(HeaderStyleService), - MockProvider(Router, routerMock), + provideOSFCore(), MockProvider(ActivatedRoute, routeMock), + MockProvider(BrandService, brandServiceMock), + MockProvider(HeaderStyleService, headerStyleMock), + MockProvider(BrowserTabService, browserTabMock), MockProvider(IS_WEB, of(true)), - provideMockStore({ - signals: [ - { - selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), - value: mockProvider, - }, - { - selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, - value: false, - }, - { - selector: PreprintStepperSelectors.getPreprint, - value: mockPreprint, - }, - { - selector: PreprintStepperSelectors.hasBeenSubmitted, - value: false, - }, - ], - }), + provideMockStore({ signals }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(UpdatePreprintStepperComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + } it('should initialize with correct default values', () => { + setup(); + expect(component.PreprintSteps).toBe(PreprintSteps); expect(component.classes).toBe('flex-1 flex flex-column w-full'); expect(component.currentStep()).toEqual(submitPreprintSteps[0]); }); - it('should return preprint provider from store', () => { - const provider = component.preprintProvider(); - expect(provider).toBe(mockProvider); + it('should dispatch initial actions on creation', () => { + setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new GetPreprintProviderById(mockProviderId)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchPreprintById(mockPreprintId)); }); - it('should return preprint from store', () => { - const preprint = component.preprint(); - expect(preprint).toBe(mockPreprint); + it('should apply branding when provider is available', () => { + setup(); + + expect(brandServiceMock.applyBranding).toHaveBeenCalledWith(mockProvider.brand); + expect(headerStyleMock.applyHeaderStyles).toHaveBeenCalledWith( + mockProvider.brand.primaryColor, + mockProvider.brand.secondaryColor, + mockProvider.brand.heroBackgroundImageUrl + ); + expect(browserTabMock.updateTabStyles).toHaveBeenCalledWith(mockProvider.faviconUrl, mockProvider.name); }); - it('should return web environment state', () => { - const isWeb = component.isWeb(); - expect(typeof isWeb).toBe('boolean'); + it('should reset services on destroy', () => { + setup(); + + component.ngOnDestroy(); + + expect(headerStyleMock.resetToDefaults).toHaveBeenCalled(); + expect(brandServiceMock.resetBranding).toHaveBeenCalled(); + expect(browserTabMock.resetToDefaults).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new ResetPreprintStepperState()); }); - it('should initialize with first step as current step', () => { - expect(component.currentStep()).toEqual(submitPreprintSteps[0]); + it('should filter out File and AuthorAssertions steps by default', () => { + setup(); + + const steps = component.updateSteps(); + const stepValues = steps.map((s) => s.value); + + expect(stepValues).not.toContain(PreprintSteps.File); + expect(stepValues).not.toContain(PreprintSteps.AuthorAssertions); + expect(stepValues).toContain(PreprintSteps.TitleAndAbstract); + expect(stepValues).toContain(PreprintSteps.Metadata); + expect(stepValues).toContain(PreprintSteps.Supplements); + expect(stepValues).toContain(PreprintSteps.Review); }); - it('should compute updateSteps correctly', () => { + it('should re-index steps sequentially', () => { + setup(); + const steps = component.updateSteps(); - expect(steps).toBeDefined(); - expect(Array.isArray(steps)).toBe(true); + steps.forEach((step, i) => expect(step.index).toBe(i)); }); - it('should compute currentUserIsAdmin correctly', () => { - const isAdmin = component.currentUserIsAdmin(); - expect(typeof isAdmin).toBe('boolean'); + it('should return empty steps when provider is unavailable', () => { + setup({ + selectorOverrides: [ + { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: null }, + { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: true }, + ], + }); + + expect(component.updateSteps()).toEqual([]); }); - it('should compute editAndResubmitMode correctly', () => { - const editMode = component.editAndResubmitMode(); - expect(typeof editMode).toBe('boolean'); + it('should return empty steps when preprint is unavailable', () => { + setup({ + selectorOverrides: [{ selector: PreprintStepperSelectors.getPreprint, value: null }], + }); + + expect(component.updateSteps()).toEqual([]); }); - it('should handle step change when moving to previous step', () => { - const previousStep = submitPreprintSteps[0]; + it('should include File step in edit-and-resubmit mode', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintStepperSelectors.getPreprint, + value: { ...mockPreprint, reviewsState: ReviewsState.Rejected }, + }, + ], + }); - component.stepChange(previousStep); + const stepValues = component.updateSteps().map((s) => s.value); + expect(stepValues).toContain(PreprintSteps.File); + }); + + it('should include AuthorAssertions step when enabled and user has admin access', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), + value: { ...mockProvider, assertionsEnabled: true }, + }, + { selector: PreprintStepperSelectors.hasAdminAccess, value: true }, + ], + }); - expect(component.currentStep()).toEqual(previousStep); + const stepValues = component.updateSteps().map((s) => s.value); + expect(stepValues).toContain(PreprintSteps.AuthorAssertions); }); - it('should not change step when moving to next step', () => { - const currentStep = component.currentStep(); - const nextStep = submitPreprintSteps[1]; + it('should prevent beforeunload when not submitted', () => { + setup(); + const event = { preventDefault: jest.fn() } as unknown as BeforeUnloadEvent; - component.stepChange(nextStep); + component.onBeforeUnload(event); - expect(component.currentStep()).toEqual(currentStep); + expect(event.preventDefault).toHaveBeenCalled(); }); - it('should move to next step', () => { - const currentIndex = component.currentStep()?.index ?? 0; - const nextStep = component.updateSteps()[currentIndex + 1]; + it('should not prevent beforeunload when submitted', () => { + setup({ selectorOverrides: [{ selector: PreprintStepperSelectors.hasBeenSubmitted, value: true }] }); + const event = { preventDefault: jest.fn() } as unknown as BeforeUnloadEvent; + + component.onBeforeUnload(event); - if (nextStep) { - component.moveToNextStep(); - expect(component.currentStep()).toEqual(nextStep); - } + expect(event.preventDefault).not.toHaveBeenCalled(); }); - it('should move to previous step', () => { - component.moveToNextStep(); - const nextStep = component.currentStep(); + it('should not prevent beforeunload when preprint is accepted', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintStepperSelectors.getPreprint, + value: { ...mockPreprint, reviewsState: ReviewsState.Accepted }, + }, + ], + }); + const event = { preventDefault: jest.fn() } as unknown as BeforeUnloadEvent; - component.moveToPreviousStep(); - const previousStep = component.currentStep(); + component.onBeforeUnload(event); - expect(previousStep?.index).toBeLessThan(nextStep?.index ?? 0); + expect(event.preventDefault).not.toHaveBeenCalled(); }); - it('should handle beforeunload event', () => { - const event = { - preventDefault: jest.fn(), - } as unknown as BeforeUnloadEvent; + it('should prevent deactivation when not submitted', () => { + setup(); + + expect(component.canDeactivate()).toBe(false); + }); - const result = component.onBeforeUnload(event); + it('should allow deactivation when submitted', () => { + setup({ selectorOverrides: [{ selector: PreprintStepperSelectors.hasBeenSubmitted, value: true }] }); - expect(event.preventDefault).toHaveBeenCalled(); - expect(result).toBe(false); + expect(component.canDeactivate()).toBe(true); }); - it('should handle step navigation correctly', () => { - component.moveToNextStep(); - const nextStep = component.currentStep(); - expect(nextStep).toBeDefined(); + it('should ignore stepping forward via stepper', () => { + setup(); - component.moveToPreviousStep(); - const previousStep = component.currentStep(); - expect(previousStep).toBeDefined(); + component.stepChange(component.updateSteps()[1]); + + expect(component.currentStep()).toEqual(submitPreprintSteps[0]); }); - it('should handle edge case when moving to next step with undefined current step', () => { - component.currentStep.set({} as StepOption); + it('should allow stepping back via stepper', () => { + setup(); + component.moveToNextStep(); + + component.stepChange(component.updateSteps()[0]); - expect(() => component.moveToNextStep()).not.toThrow(); + expect(component.currentStep()).toEqual(component.updateSteps()[0]); }); - it('should handle edge case when moving to previous step with undefined current step', () => { - component.currentStep.set({} as StepOption); + it('should move to next step', () => { + setup(); + const expectedNext = component.updateSteps()[1]; + + component.moveToNextStep(); - expect(() => component.moveToPreviousStep()).not.toThrow(); + expect(component.currentStep()).toEqual(expectedNext); }); - it('should handle empty updateSteps array', () => { + it('should not move past the last step', () => { + setup(); const steps = component.updateSteps(); - expect(steps).toBeDefined(); - expect(Array.isArray(steps)).toBe(true); + const lastStep = steps[steps.length - 1]; + component.currentStep.set(lastStep); + + component.moveToNextStep(); + + expect(component.currentStep()).toEqual(lastStep); }); - it('should handle null preprint provider', () => { - const provider = component.preprintProvider(); - expect(provider).toBeDefined(); + it('should move to previous step', () => { + setup(); + component.moveToNextStep(); + const firstStep = component.updateSteps()[0]; + + component.moveToPreviousStep(); + + expect(component.currentStep()).toEqual(firstStep); + }); + + it('should not move before the first step', () => { + setup(); + const firstStep = component.updateSteps()[0]; + component.currentStep.set(firstStep); + + component.moveToPreviousStep(); + + expect(component.currentStep()).toEqual(firstStep); }); }); diff --git a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts index f7023e75a..eacb8c11e 100644 --- a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts +++ b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts @@ -4,7 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Skeleton } from 'primeng/skeleton'; -import { map, Observable, of } from 'rxjs'; +import { map } from 'rxjs'; import { ChangeDetectionStrategy, @@ -15,14 +15,12 @@ import { HostListener, inject, OnDestroy, - OnInit, signal, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; import { BrandService } from '@osf/shared/services/brand.service'; import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; @@ -41,18 +39,13 @@ import { import { submitPreprintSteps } from '../../constants'; import { PreprintSteps, ProviderReviewsWorkflow, ReviewsState } from '../../enums'; import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { - FetchPreprintById, - PreprintStepperSelectors, - ResetPreprintStepperState, - SetSelectedPreprintProviderId, -} from '../../store/preprint-stepper'; +import { FetchPreprintById, PreprintStepperSelectors, ResetPreprintStepperState } from '../../store/preprint-stepper'; @Component({ selector: 'osf-update-preprint-stepper', imports: [ - AuthorAssertionsStepComponent, Skeleton, + AuthorAssertionsStepComponent, StepperComponent, TitleAndAbstractStepComponent, PreprintsMetadataStepComponent, @@ -65,7 +58,7 @@ import { styleUrl: './update-preprint-stepper.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class UpdatePreprintStepperComponent implements OnInit, OnDestroy, CanDeactivateComponent { +export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; private readonly route = inject(ActivatedRoute); @@ -73,12 +66,11 @@ export class UpdatePreprintStepperComponent implements OnInit, OnDestroy, CanDea private readonly headerStyleHelper = inject(HeaderStyleService); private readonly browserTabHelper = inject(BrowserTabService); - private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId'])) ?? of(undefined)); - private preprintId = toSignal(this.route.params.pipe(map((params) => params['preprintId'])) ?? of(undefined)); + private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId']))); + private preprintId = toSignal(this.route.params.pipe(map((params) => params['preprintId']))); private actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, - setSelectedPreprintProviderId: SetSelectedPreprintProviderId, resetState: ResetPreprintStepperState, fetchPreprint: FetchPreprintById, }); @@ -87,8 +79,13 @@ export class UpdatePreprintStepperComponent implements OnInit, OnDestroy, CanDea preprint = select(PreprintStepperSelectors.getPreprint); isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted); + hasAdminAccess = select(PreprintStepperSelectors.hasAdminAccess); + + isWeb = toSignal(inject(IS_WEB)); + + currentStep = signal(submitPreprintSteps[0]); - currentUserIsAdmin = computed(() => this.preprint()?.currentUserPermissions.includes(UserPermissions.Admin) || false); + readonly PreprintSteps = PreprintSteps; editAndResubmitMode = computed(() => { const providerIsPremod = this.preprintProvider()?.reviewsWorkflow === ProviderReviewsWorkflow.PreModeration; @@ -106,44 +103,26 @@ export class UpdatePreprintStepperComponent implements OnInit, OnDestroy, CanDea } return submitPreprintSteps - .map((step) => { - if (step.value !== PreprintSteps.File) { - return step; + .filter((step) => { + if (step.value === PreprintSteps.File) { + return this.editAndResubmitMode(); } - - return this.editAndResubmitMode() ? step : null; - }) - .filter((step) => step !== null) - .map((step) => { - if (step.value !== PreprintSteps.AuthorAssertions) { - return step; - } - - if (!provider.assertionsEnabled || !this.currentUserIsAdmin()) { - return null; + if (step.value === PreprintSteps.AuthorAssertions) { + return provider.assertionsEnabled && this.hasAdminAccess(); } - - return step; + return true; }) - .filter((step) => step !== null) - .map((step, index) => ({ - ...step, - index, - })); + .map((step, index) => ({ ...step, index })); }); - currentStep = signal(submitPreprintSteps[0]); - isWeb = toSignal(inject(IS_WEB)); - - readonly SubmitStepsEnum = PreprintSteps; - readonly PreprintSteps = PreprintSteps; - constructor() { + this.actions.getPreprintProviderById(this.providerId()); + this.actions.fetchPreprint(this.preprintId()); + effect(() => { const provider = this.preprintProvider(); if (provider) { - this.actions.setSelectedPreprintProviderId(provider.id); this.brandService.applyBranding(provider.brand); this.headerStyleHelper.applyHeaderStyles( provider.brand.primaryColor, @@ -156,20 +135,16 @@ export class UpdatePreprintStepperComponent implements OnInit, OnDestroy, CanDea } @HostListener('window:beforeunload', ['$event']) - onBeforeUnload($event: BeforeUnloadEvent): boolean { - $event.preventDefault(); - return false; + onBeforeUnload($event: BeforeUnloadEvent): void { + if (!this.canDeactivate()) { + $event.preventDefault(); + } } - canDeactivate(): Observable | boolean { + canDeactivate(): boolean { return this.hasBeenSubmitted() || this.preprint()?.reviewsState === ReviewsState.Accepted; } - ngOnInit() { - this.actions.getPreprintProviderById(this.providerId()); - this.actions.fetchPreprint(this.preprintId()); - } - ngOnDestroy() { this.headerStyleHelper.resetToDefaults(); this.brandService.resetBranding(); @@ -178,19 +153,26 @@ export class UpdatePreprintStepperComponent implements OnInit, OnDestroy, CanDea } stepChange(step: StepOption): void { - const currentStepIndex = this.currentStep()?.index ?? 0; - if (step.index >= currentStepIndex) { + if (step.index >= this.currentStep().index) { return; } this.currentStep.set(step); } - moveToNextStep() { - this.currentStep.set(this.updateSteps()[this.currentStep()?.index + 1]); + moveToNextStep(): void { + const nextStep = this.updateSteps()[this.currentStep().index + 1]; + + if (nextStep) { + this.currentStep.set(nextStep); + } } - moveToPreviousStep() { - this.currentStep.set(this.updateSteps()[this.currentStep()?.index - 1]); + moveToPreviousStep(): void { + const prevStep = this.updateSteps()[this.currentStep().index - 1]; + + if (prevStep) { + this.currentStep.set(prevStep); + } } } diff --git a/src/app/features/preprints/preprints.component.spec.ts b/src/app/features/preprints/preprints.component.spec.ts index 48f6ec95d..7ae801441 100644 --- a/src/app/features/preprints/preprints.component.spec.ts +++ b/src/app/features/preprints/preprints.component.spec.ts @@ -1,23 +1,25 @@ +import { MockProvider } from 'ng-mocks'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { HelpScoutService } from '@core/services/help-scout.service'; import { PreprintsComponent } from './preprints.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { HelpScoutServiceMockFactory } from '@testing/providers/help-scout.service.mock'; describe('Component: Preprint', () => { let fixture: ComponentFixture; let helpScoutService: HelpScoutService; - beforeEach(async () => { + beforeEach(() => { helpScoutService = HelpScoutServiceMockFactory(); - await TestBed.configureTestingModule({ - imports: [PreprintsComponent, OSFTestingModule], - providers: [{ provide: HelpScoutService, useValue: helpScoutService }], - }).compileComponents(); + TestBed.configureTestingModule({ + imports: [PreprintsComponent], + providers: [provideOSFCore(), MockProvider(HelpScoutService, helpScoutService)], + }); helpScoutService = TestBed.inject(HelpScoutService); fixture = TestBed.createComponent(PreprintsComponent); diff --git a/src/app/features/preprints/preprints.routes.ts b/src/app/features/preprints/preprints.routes.ts index 71732e2fb..77d2991a4 100644 --- a/src/app/features/preprints/preprints.routes.ts +++ b/src/app/features/preprints/preprints.routes.ts @@ -3,18 +3,19 @@ import { provideStates } from '@ngxs/store'; import { Routes } from '@angular/router'; import { authGuard } from '@core/guards/auth.guard'; -import { preprintsModeratorGuard } from '@osf/features/preprints/guards'; -import { PreprintsComponent } from '@osf/features/preprints/preprints.component'; -import { PreprintState } from '@osf/features/preprints/store/preprint'; -import { PreprintProvidersState } from '@osf/features/preprints/store/preprint-providers'; -import { PreprintStepperState } from '@osf/features/preprints/store/preprint-stepper'; -import { ConfirmLeavingGuard } from '@shared/guards'; -import { CitationsState } from '@shared/stores/citations'; -import { ProjectsState } from '@shared/stores/projects'; -import { SubjectsState } from '@shared/stores/subjects'; +import { ConfirmLeavingGuard } from '@osf/shared/guards'; +import { CitationsState } from '@osf/shared/stores/citations'; +import { ProjectsState } from '@osf/shared/stores/projects'; +import { SubjectsState } from '@osf/shared/stores/subjects'; import { PreprintModerationState } from '../moderation/store/preprint-moderation'; +import { preprintsModeratorGuard } from './guards/preprints-moderator.guard'; +import { PreprintState } from './store/preprint'; +import { PreprintProvidersState } from './store/preprint-providers'; +import { PreprintStepperState } from './store/preprint-stepper'; +import { PreprintsComponent } from './preprints.component'; + export const preprintsRoutes: Routes = [ { path: '', @@ -31,7 +32,7 @@ export const preprintsRoutes: Routes = [ { path: 'discover', loadComponent: () => - import('@osf/features/preprints/pages/landing/preprints-landing.component').then( + import('@osf/features/preprints/pages/preprints-landing/preprints-landing.component').then( (c) => c.PreprintsLandingComponent ), }, diff --git a/src/app/features/preprints/services/preprint-files.service.ts b/src/app/features/preprints/services/preprint-files.service.ts index aa2a0cad1..0f0de7945 100644 --- a/src/app/features/preprints/services/preprint-files.service.ts +++ b/src/app/features/preprints/services/preprint-files.service.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { map, Observable, switchMap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; @@ -18,7 +17,6 @@ import { FileFolderResponseJsonApi, FileFoldersResponseJsonApi, } from '@osf/shared/models/files/file-folder-json-api.model'; -import { FilesService } from '@osf/shared/services/files.service'; import { JsonApiService } from '@osf/shared/services/json-api.service'; import { FilesMapper } from '@shared/mappers/files/files.mapper'; @@ -26,8 +24,7 @@ import { FilesMapper } from '@shared/mappers/files/files.mapper'; providedIn: 'root', }) export class PreprintFilesService { - private filesService = inject(FilesService); - private jsonApiService = inject(JsonApiService); + private readonly jsonApiService = inject(JsonApiService); private readonly environment = inject(ENVIRONMENT); get apiUrl() { @@ -58,7 +55,7 @@ export class PreprintFilesService { } getPreprintFilesLinks(id: string): Observable { - return this.jsonApiService.get(`${this.apiUrl}/preprints/${id}/files/`).pipe( + return this.jsonApiService.get(`${this.apiUrl}/preprints/${id}/files/`).pipe( map((response) => { const rel = response.data[0].relationships; const links = response.data[0].links; @@ -72,12 +69,14 @@ export class PreprintFilesService { } getProjectRootFolder(projectId: string): Observable { - return this.jsonApiService.get(`${this.apiUrl}/nodes/${projectId}/files/`).pipe( - switchMap((response: FileFoldersResponseJsonApi) => { - return this.jsonApiService - .get(response.data[0].relationships.root_folder.links.related.href) - .pipe(map((folder) => FilesMapper.getFileFolder(folder.data))); - }) - ); + return this.jsonApiService + .get(`${this.apiUrl}/nodes/${projectId}/files/`) + .pipe( + switchMap((response) => + this.jsonApiService + .get(response.data[0].relationships.root_folder.links.related.href) + .pipe(map((folder) => FilesMapper.getFileFolder(folder.data))) + ) + ); } } diff --git a/src/app/features/preprints/services/preprints.service.ts b/src/app/features/preprints/services/preprints.service.ts index 15e11e7bc..0b037250a 100644 --- a/src/app/features/preprints/services/preprints.service.ts +++ b/src/app/features/preprints/services/preprints.service.ts @@ -1,12 +1,13 @@ -import { map, Observable } from 'rxjs'; +import { catchError, map, Observable, throwError } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { Router } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { RegistryModerationMapper } from '@osf/features/moderation/mappers'; import { ReviewActionsResponseJsonApi } from '@osf/features/moderation/models'; import { PreprintRequestActionsMapper } from '@osf/features/preprints/mappers/preprint-request-actions.mapper'; -import { PreprintRequestAction } from '@osf/features/preprints/models/preprint-request-action.models'; +import { PreprintRequestAction } from '@osf/features/preprints/models/preprint-request-action.model'; import { searchPreferencesToJsonApiQueryParams } from '@osf/shared/helpers/search-pref-to-json-api-query-params.helper'; import { StringOrNull } from '@osf/shared/helpers/types.helper'; import { @@ -38,6 +39,7 @@ import { export class PreprintsService { private readonly jsonApiService = inject(JsonApiService); private readonly environment = inject(ENVIRONMENT); + private readonly router = inject(Router); get apiUrl() { return `${this.environment.apiDomainUrl}/v2`; @@ -95,7 +97,15 @@ export class PreprintsService { null > >(`${this.apiUrl}/preprints/${id}/`, params) - .pipe(map((response) => PreprintsMapper.fromPreprintWithEmbedsJsonApi(response))); + .pipe( + map((response) => PreprintsMapper.fromPreprintWithEmbedsJsonApi(response)), + catchError((error) => { + if (error.status === 410) { + this.router.navigate(['/spam-content']); + } + return throwError(() => error); + }) + ); } getPreprintMetrics(id: string) { diff --git a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.actions.ts b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.actions.ts index 23d62a456..4e35cfd0c 100644 --- a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.actions.ts +++ b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.actions.ts @@ -6,12 +6,6 @@ import { LicenseOptions } from '@osf/shared/models/license/license.model'; import { PreprintFileSource } from '../../enums'; import { PreprintModel } from '../../models'; -export class SetSelectedPreprintProviderId { - static readonly type = '[Preprint Stepper] Set Selected Preprint Provider Id'; - - constructor(public id: StringOrNull) {} -} - export class CreatePreprint { static readonly type = '[Preprint Stepper] Create Preprint'; @@ -98,6 +92,8 @@ export class FetchProjectFilesByLink { export class FetchLicenses { static readonly type = '[Preprint Stepper] Fetch Licenses'; + + constructor(public providerId: string) {} } export class SaveLicense { diff --git a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.model.ts b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.model.ts index dc64f8289..7fdbee39c 100644 --- a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.model.ts +++ b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.model.ts @@ -1,4 +1,3 @@ -import { StringOrNull } from '@osf/shared/helpers/types.helper'; import { IdNameModel } from '@osf/shared/models/common/id-name.model'; import { FileModel } from '@osf/shared/models/files/file.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; @@ -10,7 +9,6 @@ import { PreprintFileSource } from '../../enums'; import { PreprintFilesLinks, PreprintModel } from '../../models'; export interface PreprintStepperStateModel { - selectedProviderId: StringOrNull; preprint: AsyncStateModel; fileSource: PreprintFileSource; preprintFilesLinks: AsyncStateModel; @@ -25,7 +23,6 @@ export interface PreprintStepperStateModel { } export const DEFAULT_PREPRINT_STEPPER_STATE: PreprintStepperStateModel = { - selectedProviderId: null, preprint: { data: null, isLoading: false, diff --git a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.selectors.ts b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.selectors.ts index 09b9cb09a..f88c8eaf9 100644 --- a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.selectors.ts +++ b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.selectors.ts @@ -1,13 +1,9 @@ import { Selector } from '@ngxs/store'; import { PreprintStepperState, PreprintStepperStateModel } from '@osf/features/preprints/store/preprint-stepper'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; export class PreprintStepperSelectors { - @Selector([PreprintStepperState]) - static getSelectedProviderId(state: PreprintStepperStateModel) { - return state.selectedProviderId; - } - @Selector([PreprintStepperState]) static getPreprint(state: PreprintStepperStateModel) { return state.preprint.data; @@ -88,6 +84,11 @@ export class PreprintStepperSelectors { return state.hasBeenSubmitted; } + @Selector([PreprintStepperState]) + static hasAdminAccess(state: PreprintStepperStateModel) { + return state.preprint.data?.currentUserPermissions.includes(UserPermissions.Admin) || false; + } + @Selector([PreprintStepperState]) static getCurrentFolder(state: PreprintStepperStateModel) { return state.currentFolder.data; diff --git a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.state.ts b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.state.ts index d915e5782..cc817558d 100644 --- a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.state.ts +++ b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.state.ts @@ -41,7 +41,6 @@ import { SetPreprintStepperCurrentFolder, SetProjectRootFolder, SetSelectedPreprintFileSource, - SetSelectedPreprintProviderId, SubmitPreprint, UpdatePreprint, UpdatePrimaryFileRelationship, @@ -61,11 +60,6 @@ export class PreprintStepperState { private licensesService = inject(PreprintLicensesService); private preprintProjectsService = inject(PreprintsProjectsService); - @Action(SetSelectedPreprintProviderId) - setSelectedPreprintProviderId(ctx: StateContext, action: SetSelectedPreprintProviderId) { - ctx.patchState({ selectedProviderId: action.id }); - } - @Action(CreatePreprint) createPreprint(ctx: StateContext, action: CreatePreprint) { ctx.setState(patch({ preprint: patch({ isSubmitting: true }) })); @@ -87,7 +81,6 @@ export class PreprintStepperState { if (action.payload.isPublished) { ctx.setState(patch({ hasBeenSubmitted: true })); } - ctx.setState(patch({ preprint: patch({ isSubmitting: false, data: preprint }) })); }), catchError((error) => handleSectionError(ctx, 'preprint', error)) @@ -324,12 +317,10 @@ export class PreprintStepperState { } @Action(FetchLicenses) - fetchLicenses(ctx: StateContext) { - const providerId = ctx.getState().selectedProviderId; - if (!providerId) return; + fetchLicenses(ctx: StateContext, action: FetchLicenses) { ctx.setState(patch({ licenses: patch({ isLoading: true }) })); - return this.licensesService.getLicenses(providerId).pipe( + return this.licensesService.getLicenses(action.providerId).pipe( tap((licenses) => { ctx.setState(patch({ licenses: patch({ isLoading: false, data: licenses }) })); }), diff --git a/src/app/features/profile/components/profile-information/profile-information.component.html b/src/app/features/profile/components/profile-information/profile-information.component.html index b4636126f..80ae98a4a 100644 --- a/src/app/features/profile/components/profile-information/profile-information.component.html +++ b/src/app/features/profile/components/profile-information/profile-information.component.html @@ -35,6 +35,25 @@

{{ currentUser()?.fullName }}

}

+
+ @for (institution of currentUserInstitutions(); track $index) { + + + + } +
+ @if (!isMedium() && showEdit()) {
{ it('should initialize with default inputs', () => { expect(component.currentUser()).toBeUndefined(); expect(component.showEdit()).toBe(false); + expect(component.currentUserInstitutions()).toBeUndefined(); }); it('should accept user input', () => { @@ -172,4 +175,28 @@ describe('ProfileInformationComponent', () => { component.toProfileSettings(); expect(component.editProfile.emit).toHaveBeenCalled(); }); + + it('should accept currentUserInstitutions input', () => { + const mockInstitutions: Institution[] = [MOCK_INSTITUTION]; + fixture.componentRef.setInput('currentUserInstitutions', mockInstitutions); + fixture.detectChanges(); + expect(component.currentUserInstitutions()).toEqual(mockInstitutions); + }); + + it('should not render institution logos when currentUserInstitutions is undefined', () => { + fixture.componentRef.setInput('currentUserInstitutions', undefined); + fixture.detectChanges(); + const logos = fixture.nativeElement.querySelectorAll('img.fit-contain'); + expect(logos.length).toBe(0); + }); + + it('should render institution logos when currentUserInstitutions is provided', () => { + const institutions: Institution[] = [MOCK_INSTITUTION]; + fixture.componentRef.setInput('currentUserInstitutions', institutions); + fixture.detectChanges(); + + const logos = fixture.nativeElement.querySelectorAll('img.fit-contain'); + expect(logos.length).toBe(institutions.length); + expect(logos[0].alt).toBe(institutions[0].name); + }); }); diff --git a/src/app/features/profile/components/profile-information/profile-information.component.ts b/src/app/features/profile/components/profile-information/profile-information.component.ts index 3304a8ce2..da555cac9 100644 --- a/src/app/features/profile/components/profile-information/profile-information.component.ts +++ b/src/app/features/profile/components/profile-information/profile-information.component.ts @@ -5,12 +5,14 @@ import { Button } from 'primeng/button'; import { DatePipe, NgOptimizedImage } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; +import { RouterLink } from '@angular/router'; import { EducationHistoryComponent } from '@osf/shared/components/education-history/education-history.component'; import { EmploymentHistoryComponent } from '@osf/shared/components/employment-history/employment-history.component'; import { SOCIAL_LINKS } from '@osf/shared/constants/social-links.const'; import { IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens'; -import { UserModel } from '@osf/shared/models/user/user.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; +import { UserModel } from '@osf/shared/models/user/user.model'; import { SortByDatePipe } from '@osf/shared/pipes/sort-by-date.pipe'; import { mapUserSocials } from '../../helpers'; @@ -25,6 +27,7 @@ import { mapUserSocials } from '../../helpers'; DatePipe, NgOptimizedImage, SortByDatePipe, + RouterLink, ], templateUrl: './profile-information.component.html', styleUrl: './profile-information.component.scss', @@ -32,6 +35,8 @@ import { mapUserSocials } from '../../helpers'; }) export class ProfileInformationComponent { currentUser = input(); + + currentUserInstitutions = input(); showEdit = input(false); editProfile = output(); diff --git a/src/app/features/profile/profile.component.html b/src/app/features/profile/profile.component.html index 4176e33fc..958d22e05 100644 --- a/src/app/features/profile/profile.component.html +++ b/src/app/features/profile/profile.component.html @@ -13,7 +13,12 @@ } - +
@if (defaultSearchFiltersInitialized()) { diff --git a/src/app/features/profile/profile.component.ts b/src/app/features/profile/profile.component.ts index fb7c186a8..907ca38d7 100644 --- a/src/app/features/profile/profile.component.ts +++ b/src/app/features/profile/profile.component.ts @@ -23,8 +23,9 @@ import { GlobalSearchComponent } from '@osf/shared/components/global-search/glob import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SEARCH_TAB_OPTIONS } from '@osf/shared/constants/search-tab-options.const'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { UserModel } from '@osf/shared/models/user/user.models'; +import { UserModel } from '@osf/shared/models/user/user.model'; import { SetDefaultFilterValue } from '@osf/shared/stores/global-search'; +import { FetchUserInstitutions, InstitutionsSelectors } from '@shared/stores/institutions'; import { ProfileInformationComponent } from './components'; import { FetchUserProfile, ProfileSelectors, SetUserProfile } from './store'; @@ -46,11 +47,13 @@ export class ProfileComponent implements OnInit, OnDestroy { fetchUserProfile: FetchUserProfile, setDefaultFilterValue: SetDefaultFilterValue, setUserProfile: SetUserProfile, + fetchUserInstitutions: FetchUserInstitutions, }); loggedInUser = select(UserSelectors.getCurrentUser); userProfile = select(ProfileSelectors.getUserProfile); isUserLoading = select(ProfileSelectors.isUserProfileLoading); + institutions = select(InstitutionsSelectors.getUserInstitutions); resourceTabOptions = SEARCH_TAB_OPTIONS.filter((x) => x.value !== ResourceType.Agent); @@ -67,6 +70,8 @@ export class ProfileComponent implements OnInit, OnDestroy { } else if (currentUser) { this.setupMyProfile(currentUser); } + + this.actions.fetchUserInstitutions(userId || currentUser?.id); } ngOnDestroy(): void { diff --git a/src/app/features/profile/store/profile.actions.ts b/src/app/features/profile/store/profile.actions.ts index 61269ae9e..edcdf0d64 100644 --- a/src/app/features/profile/store/profile.actions.ts +++ b/src/app/features/profile/store/profile.actions.ts @@ -1,4 +1,4 @@ -import { UserModel } from '@osf/shared/models/user/user.models'; +import { UserModel } from '@osf/shared/models/user/user.model'; export class FetchUserProfile { static readonly type = '[Profile] Fetch User Profile'; diff --git a/src/app/features/profile/store/profile.model.ts b/src/app/features/profile/store/profile.model.ts index 87d4feee1..3d11d531d 100644 --- a/src/app/features/profile/store/profile.model.ts +++ b/src/app/features/profile/store/profile.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 ProfileStateModel { userProfile: AsyncStateModel; diff --git a/src/app/features/profile/store/profile.selectors.ts b/src/app/features/profile/store/profile.selectors.ts index 48869e8c3..db39632b9 100644 --- a/src/app/features/profile/store/profile.selectors.ts +++ b/src/app/features/profile/store/profile.selectors.ts @@ -1,6 +1,6 @@ import { Selector } from '@ngxs/store'; -import { UserModel } from '@osf/shared/models/user/user.models'; +import { UserModel } from '@osf/shared/models/user/user.model'; import { ProfileStateModel } from './profile.model'; import { ProfileState } from '.'; diff --git a/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.spec.ts b/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.spec.ts index 5538fd4f3..5fceb0708 100644 --- a/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.spec.ts @@ -1,26 +1,182 @@ +import { Store } from '@ngxs/store'; + import { MockComponent } from 'ng-mocks'; +import { of } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { UserSelectors } from '@core/store/user'; import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component'; +import { ComponentFormControls } from '@osf/shared/enums/create-component-form-controls.enum'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { FetchUserInstitutions, InstitutionsSelectors } from '@osf/shared/stores/institutions'; +import { FetchRegions, RegionsSelectors } from '@osf/shared/stores/regions'; + +import { CreateComponent, GetComponents, ProjectOverviewSelectors } from '../../store'; import { AddComponentDialogComponent } from './add-component-dialog.component'; -describe.skip('AddComponentComponent', () => { +import { MOCK_INSTITUTION } from '@testing/mocks/institution.mock'; +import { MOCK_PROJECT } from '@testing/mocks/project.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('AddComponentDialogComponent', () => { let component: AddComponentDialogComponent; let fixture: ComponentFixture; + let store: Store; + + const mockRegions = [{ id: 'region-1', name: 'Region 1' }]; + const mockUser = { id: 'user-1', defaultRegionId: 'user-region' } as any; + const mockProject = { ...MOCK_PROJECT, id: 'proj-1', title: 'Project', tags: ['tag1'] }; + const mockInstitutions = [MOCK_INSTITUTION]; + const mockUserInstitutions = [MOCK_INSTITUTION, { ...MOCK_INSTITUTION, id: 'inst-2', name: 'Inst 2' }]; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [AddComponentDialogComponent, MockComponent(AffiliatedInstitutionSelectComponent)], + imports: [AddComponentDialogComponent, OSFTestingModule, MockComponent(AffiliatedInstitutionSelectComponent)], + providers: [ + provideMockStore({ + signals: [ + { selector: RegionsSelectors.getRegions, value: mockRegions }, + { selector: UserSelectors.getCurrentUser, value: mockUser }, + { selector: ProjectOverviewSelectors.getProject, value: mockProject }, + { selector: ProjectOverviewSelectors.getInstitutions, value: mockInstitutions }, + { selector: RegionsSelectors.areRegionsLoading, value: false }, + { selector: ProjectOverviewSelectors.getComponentsSubmitting, value: false }, + { selector: InstitutionsSelectors.getUserInstitutions, value: mockUserInstitutions }, + { selector: InstitutionsSelectors.areUserInstitutionsLoading, value: false }, + ], + }), + ], }).compileComponents(); fixture = TestBed.createComponent(AddComponentDialogComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); + (store.dispatch as jest.Mock).mockReturnValue(of(void 0)); fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should initialize form with default values', () => { + expect(component.componentForm.get(ComponentFormControls.Title)?.value).toBe(''); + expect(Array.isArray(component.componentForm.get(ComponentFormControls.Affiliations)?.value)).toBe(true); + expect(component.componentForm.get(ComponentFormControls.Description)?.value).toBe(''); + expect(component.componentForm.get(ComponentFormControls.AddContributors)?.value).toBe(false); + expect(component.componentForm.get(ComponentFormControls.AddTags)?.value).toBe(false); + expect(['', 'user-region']).toContain(component.componentForm.get(ComponentFormControls.StorageLocation)?.value); + }); + + it('should dispatch FetchRegions and FetchUserInstitutions on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(expect.any(FetchRegions)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(FetchUserInstitutions)); + }); + + it('should return store values from selectors', () => { + expect(component.storageLocations()).toEqual(mockRegions); + expect(component.currentUser()).toEqual(mockUser); + expect(component.currentProject()).toEqual(mockProject); + expect(component.institutions()).toEqual(mockInstitutions); + expect(component.areRegionsLoading()).toBe(false); + expect(component.isSubmitting()).toBe(false); + expect(component.userInstitutions()).toEqual(mockUserInstitutions); + expect(component.areUserInstitutionsLoading()).toBe(false); + }); + + it('should set affiliations form control from selected institutions', () => { + const institutions = [MOCK_INSTITUTION]; + component.setSelectedInstitutions(institutions); + expect(component.componentForm.get(ComponentFormControls.Affiliations)?.value).toEqual([MOCK_INSTITUTION.id]); + }); + + it('should mark form as touched and not dispatch when submitForm with invalid form', () => { + (store.dispatch as jest.Mock).mockClear(); + component.componentForm.get(ComponentFormControls.Title)?.setValue(''); + component.submitForm(); + expect(component.componentForm.touched).toBe(true); + const createCalls = (store.dispatch as jest.Mock).mock.calls.filter((c) => c[0] instanceof CreateComponent); + expect(createCalls.length).toBe(0); + }); + + it('should dispatch CreateComponent and on success close dialog, getComponents, showSuccess', () => { + component.componentForm.get(ComponentFormControls.Title)?.setValue('New Component'); + component.componentForm.get(ComponentFormControls.StorageLocation)?.setValue('region-1'); + component.componentForm.get(ComponentFormControls.Affiliations)?.setValue([MOCK_INSTITUTION.id]); + (store.dispatch as jest.Mock).mockClear(); + + component.submitForm(); + + expect(store.dispatch).toHaveBeenCalledWith( + new CreateComponent(mockProject.id, 'New Component', '', [], 'region-1', [MOCK_INSTITUTION.id], false) + ); + expect(component.dialogRef.close).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetComponents)); + expect(TestBed.inject(ToastService).showSuccess).toHaveBeenCalledWith( + 'project.overview.dialog.toast.addComponent.success' + ); + }); + + it('should pass project tags when addTags is true', () => { + component.componentForm.get(ComponentFormControls.Title)?.setValue('With Tags'); + component.componentForm.get(ComponentFormControls.StorageLocation)?.setValue('region-1'); + component.componentForm.get(ComponentFormControls.Affiliations)?.setValue([]); + component.componentForm.get(ComponentFormControls.AddTags)?.setValue(true); + (store.dispatch as jest.Mock).mockClear(); + + component.submitForm(); + + expect(store.dispatch).toHaveBeenCalledWith( + new CreateComponent(mockProject.id, 'With Tags', '', mockProject.tags, 'region-1', [], false) + ); + }); + + it('should set storage location to user default region when control empty and regions loaded', () => { + fixture = TestBed.createComponent(AddComponentDialogComponent); + component = fixture.componentInstance; + component.componentForm.get(ComponentFormControls.StorageLocation)?.setValue(''); + fixture.detectChanges(); + expect(component.componentForm.get(ComponentFormControls.StorageLocation)?.value).toBe('user-region'); + }); +}); + +describe('AddComponentDialogComponent when user has no default region', () => { + let component: AddComponentDialogComponent; + let fixture: ComponentFixture; + + const mockRegions = [{ id: 'region-1', name: 'Region 1' }]; + const mockProject = { ...MOCK_PROJECT, id: 'proj-1', title: 'Project', tags: ['tag1'] }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AddComponentDialogComponent, OSFTestingModule, MockComponent(AffiliatedInstitutionSelectComponent)], + providers: [ + provideMockStore({ + signals: [ + { selector: RegionsSelectors.getRegions, value: mockRegions }, + { selector: UserSelectors.getCurrentUser, value: null }, + { selector: ProjectOverviewSelectors.getProject, value: mockProject }, + { selector: ProjectOverviewSelectors.getInstitutions, value: [] }, + { selector: RegionsSelectors.areRegionsLoading, value: false }, + { selector: ProjectOverviewSelectors.getComponentsSubmitting, value: false }, + { selector: InstitutionsSelectors.getUserInstitutions, value: [] }, + { selector: InstitutionsSelectors.areUserInstitutionsLoading, value: false }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AddComponentDialogComponent); + component = fixture.componentInstance; + component.componentForm.get(ComponentFormControls.StorageLocation)?.setValue(''); + fixture.detectChanges(); + }); + + it('should set storage location to first region when control empty', () => { + expect(component.componentForm.get(ComponentFormControls.StorageLocation)?.value).toBe('region-1'); + }); }); diff --git a/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.ts b/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.ts index 85fc28603..ea91f887f 100644 --- a/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.ts +++ b/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.ts @@ -17,11 +17,11 @@ import { UserSelectors } from '@core/store/user'; import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component'; import { ComponentFormControls } from '@osf/shared/enums/create-component-form-controls.enum'; import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { ToastService } from '@osf/shared/services/toast.service'; import { FetchUserInstitutions, InstitutionsSelectors } from '@osf/shared/stores/institutions'; import { FetchRegions, RegionsSelectors } from '@osf/shared/stores/regions'; import { ComponentForm } from '@shared/models/create-component-form.model'; -import { Institution } from '@shared/models/institutions/institutions.models'; import { CreateComponent, GetComponents, ProjectOverviewSelectors } from '../../store'; diff --git a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts index 73c88f51e..b2954f518 100644 --- a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts @@ -1,22 +1,312 @@ +import { Store } from '@ngxs/store'; + +import { DynamicDialogConfig } from 'primeng/dynamicdialog'; + +import { of } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DeleteProject, SettingsSelectors } from '@osf/features/project/settings/store'; +import { RegistrySelectors } from '@osf/features/registry/store/registry'; +import { ScientistsNames } from '@osf/shared/constants/scientists.const'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; + +import { GetComponents, ProjectOverviewSelectors } from '../../store'; + import { DeleteComponentDialogComponent } from './delete-component-dialog.component'; -describe.skip('DeleteComponentDialogComponent', () => { +import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +const mockComponentsWithAdmin = [ + { id: 'comp-1', title: 'Component 1', isPublic: true, permissions: [UserPermissions.Admin] }, + { id: 'comp-2', title: 'Component 2', isPublic: false, permissions: [UserPermissions.Admin] }, +]; + +const mockComponentsWithoutAdmin = [ + { id: 'comp-1', title: 'Component 1', isPublic: true, permissions: [UserPermissions.Read] }, +]; + +describe('DeleteComponentDialogComponent', () => { let component: DeleteComponentDialogComponent; let fixture: ComponentFixture; + let store: Store; + let dialogConfig: DynamicDialogConfig; + + const mockProject = { ...MOCK_NODE_WITH_ADMIN, id: 'proj-1' }; beforeEach(async () => { + dialogConfig = { data: { resourceType: ResourceType.Project } }; + await TestBed.configureTestingModule({ - imports: [DeleteComponentDialogComponent], + imports: [DeleteComponentDialogComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: mockProject }, + { selector: RegistrySelectors.getRegistry, value: null }, + { selector: SettingsSelectors.isSettingsSubmitting, value: false }, + { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, + { selector: CurrentResourceSelectors.getResourceWithChildren, value: mockComponentsWithAdmin }, + ], + }), + { provide: DynamicDialogConfig, useValue: dialogConfig }, + ], }).compileComponents(); fixture = TestBed.createComponent(DeleteComponentDialogComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); + (store.dispatch as jest.Mock).mockReturnValue(of(void 0)); fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should return store values from selectors', () => { + expect(component.project()).toEqual(mockProject); + expect(component.registration()).toBeNull(); + expect(component.isSubmitting()).toBe(false); + expect(component.isLoading()).toBe(false); + expect(component.components()).toEqual(mockComponentsWithAdmin); + }); + + it('should have selectedScientist as one of ScientistsNames', () => { + expect(ScientistsNames).toContain(component.selectedScientist()); + }); + + it('should compute currentResource as project when resourceType is Project', () => { + expect(component.currentResource()).toEqual(mockProject); + }); + + it('should compute hasAdminAccessForAllComponents true when all components have Admin', () => { + expect(component.hasAdminAccessForAllComponents()).toBe(true); + }); + + it('should compute hasSubComponents true when more than one component', () => { + expect(component.hasSubComponents()).toBe(true); + }); + + it('should return isInputValid true when userInput matches selectedScientist', () => { + const scientist = component.selectedScientist(); + component.onInputChange(scientist); + expect(component.isInputValid()).toBe(true); + }); + + it('should return isInputValid false when userInput does not match', () => { + component.onInputChange('wrong'); + expect(component.isInputValid()).toBe(false); + }); + + it('should set userInput on onInputChange', () => { + component.onInputChange('test'); + expect(component.userInput()).toBe('test'); + }); + + it('should dispatch DeleteProject with components and on success close, getComponents, showSuccess', () => { + const scientist = component.selectedScientist(); + component.onInputChange(scientist); + (store.dispatch as jest.Mock).mockClear(); + + component.handleDeleteComponent(); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(DeleteProject)); + const deleteCall = (store.dispatch as jest.Mock).mock.calls.find((c) => c[0] instanceof DeleteProject); + expect(deleteCall[0].projects).toEqual(mockComponentsWithAdmin); + expect(component.dialogRef.close).toHaveBeenCalledWith({ success: true }); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetComponents)); + expect(TestBed.inject(ToastService).showSuccess).toHaveBeenCalledWith( + 'project.overview.dialog.toast.deleteComponent.success' + ); + }); +}); + +describe('DeleteComponentDialogComponent when not all components have Admin', () => { + let component: DeleteComponentDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeleteComponentDialogComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: { ...MOCK_NODE_WITH_ADMIN, id: 'proj-1' } }, + { selector: RegistrySelectors.getRegistry, value: null }, + { selector: SettingsSelectors.isSettingsSubmitting, value: false }, + { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, + { selector: CurrentResourceSelectors.getResourceWithChildren, value: mockComponentsWithoutAdmin }, + ], + }), + { provide: DynamicDialogConfig, useValue: { data: { resourceType: ResourceType.Project } } }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(DeleteComponentDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should compute hasAdminAccessForAllComponents false', () => { + expect(component.hasAdminAccessForAllComponents()).toBe(false); + }); +}); + +describe('DeleteComponentDialogComponent when single component', () => { + let component: DeleteComponentDialogComponent; + let fixture: ComponentFixture; + + const singleComponent = [ + { id: 'comp-1', title: 'Component 1', isPublic: true, permissions: [UserPermissions.Admin] }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeleteComponentDialogComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: { ...MOCK_NODE_WITH_ADMIN, id: 'proj-1' } }, + { selector: RegistrySelectors.getRegistry, value: null }, + { selector: SettingsSelectors.isSettingsSubmitting, value: false }, + { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, + { selector: CurrentResourceSelectors.getResourceWithChildren, value: singleComponent }, + ], + }), + { provide: DynamicDialogConfig, useValue: { data: { resourceType: ResourceType.Project } } }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(DeleteComponentDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should compute hasSubComponents false', () => { + expect(component.hasSubComponents()).toBe(false); + }); +}); + +describe('DeleteComponentDialogComponent when no components', () => { + let component: DeleteComponentDialogComponent; + let fixture: ComponentFixture; + let store: Store; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeleteComponentDialogComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: { ...MOCK_NODE_WITH_ADMIN, id: 'proj-1' } }, + { selector: RegistrySelectors.getRegistry, value: null }, + { selector: SettingsSelectors.isSettingsSubmitting, value: false }, + { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, + { selector: CurrentResourceSelectors.getResourceWithChildren, value: [] }, + ], + }), + { provide: DynamicDialogConfig, useValue: { data: { resourceType: ResourceType.Project } } }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(DeleteComponentDialogComponent); + component = fixture.componentInstance; + store = TestBed.inject(Store); + (store.dispatch as jest.Mock).mockClear(); + fixture.detectChanges(); + }); + + it('should not dispatch when handleDeleteComponent', () => { + component.handleDeleteComponent(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); +}); + +describe('DeleteComponentDialogComponent when resourceType is Registration', () => { + let component: DeleteComponentDialogComponent; + let fixture: ComponentFixture; + + const mockRegistration = { ...MOCK_NODE_WITH_ADMIN, id: 'reg-1' }; + const mockComponentsWithAdmin = [ + { id: 'comp-1', title: 'Component 1', isPublic: true, permissions: [UserPermissions.Admin] }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeleteComponentDialogComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: null }, + { selector: RegistrySelectors.getRegistry, value: mockRegistration }, + { selector: SettingsSelectors.isSettingsSubmitting, value: false }, + { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, + { selector: CurrentResourceSelectors.getResourceWithChildren, value: mockComponentsWithAdmin }, + ], + }), + { provide: DynamicDialogConfig, useValue: { data: { resourceType: ResourceType.Registration } } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DeleteComponentDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should compute currentResource as registration', () => { + expect(component.currentResource()).toEqual(mockRegistration); + }); +}); + +describe('DeleteComponentDialogComponent isForksContext', () => { + let component: DeleteComponentDialogComponent; + let fixture: ComponentFixture; + let store: Store; + + const mockProject = { ...MOCK_NODE_WITH_ADMIN, id: 'proj-1' }; + const mockComponentsWithAdmin = [ + { id: 'comp-1', title: 'Component 1', isPublic: true, permissions: [UserPermissions.Admin] }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeleteComponentDialogComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: mockProject }, + { selector: RegistrySelectors.getRegistry, value: null }, + { selector: SettingsSelectors.isSettingsSubmitting, value: false }, + { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, + { selector: CurrentResourceSelectors.getResourceWithChildren, value: mockComponentsWithAdmin }, + ], + }), + { + provide: DynamicDialogConfig, + useValue: { data: { resourceType: ResourceType.Project, isForksContext: true } }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DeleteComponentDialogComponent); + component = fixture.componentInstance; + store = TestBed.inject(Store); + (store.dispatch as jest.Mock).mockReturnValue(of(void 0)); + fixture.detectChanges(); + }); + + it('should not dispatch GetComponents when isForksContext', () => { + const scientist = component.selectedScientist(); + component.onInputChange(scientist); + (store.dispatch as jest.Mock).mockClear(); + + component.handleDeleteComponent(); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(DeleteProject)); + const getComponentsCalls = (store.dispatch as jest.Mock).mock.calls.filter((c) => c[0] instanceof GetComponents); + expect(getComponentsCalls.length).toBe(0); + }); }); diff --git a/src/app/features/project/overview/components/duplicate-dialog/duplicate-dialog.component.spec.ts b/src/app/features/project/overview/components/duplicate-dialog/duplicate-dialog.component.spec.ts index 5d78cf850..62f5c009a 100644 --- a/src/app/features/project/overview/components/duplicate-dialog/duplicate-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/duplicate-dialog/duplicate-dialog.component.spec.ts @@ -1,22 +1,95 @@ +import { Store } from '@ngxs/store'; + +import { of } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { DuplicateProject, ProjectOverviewSelectors } from '../../store'; + import { DuplicateDialogComponent } from './duplicate-dialog.component'; -describe.skip('DuplicateDialogComponent', () => { +import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('DuplicateDialogComponent', () => { let component: DuplicateDialogComponent; let fixture: ComponentFixture; + let store: Store; + + const mockProject = { ...MOCK_NODE_WITH_ADMIN, id: 'proj-1', title: 'Test Project' }; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [DuplicateDialogComponent], + imports: [DuplicateDialogComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: mockProject }, + { selector: ProjectOverviewSelectors.getDuplicateProjectSubmitting, value: false }, + ], + }), + ], }).compileComponents(); fixture = TestBed.createComponent(DuplicateDialogComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); + (store.dispatch as jest.Mock).mockReturnValue(of(void 0)); fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should return project and isSubmitting from store', () => { + expect(component.project()).toEqual(mockProject); + expect(component.isSubmitting()).toBe(false); + }); + + it('should dispatch DuplicateProject and on success close dialog and showSuccess', () => { + (store.dispatch as jest.Mock).mockClear(); + + component.handleDuplicateConfirm(); + + expect(store.dispatch).toHaveBeenCalledWith(new DuplicateProject(mockProject.id, mockProject.title)); + expect(component.dialogRef.close).toHaveBeenCalled(); + expect(TestBed.inject(ToastService).showSuccess).toHaveBeenCalledWith( + 'project.overview.dialog.toast.duplicate.success' + ); + }); +}); + +describe('DuplicateDialogComponent when no project', () => { + let component: DuplicateDialogComponent; + let fixture: ComponentFixture; + let store: Store; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DuplicateDialogComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: null }, + { selector: ProjectOverviewSelectors.getDuplicateProjectSubmitting, value: false }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DuplicateDialogComponent); + component = fixture.componentInstance; + store = TestBed.inject(Store); + (store.dispatch as jest.Mock).mockClear(); + fixture.detectChanges(); + }); + + it('should not dispatch when handleDuplicateConfirm', () => { + component.handleDuplicateConfirm(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/features/project/overview/components/files-widget/files-widget.component.ts b/src/app/features/project/overview/components/files-widget/files-widget.component.ts index c907027ac..535b01a43 100644 --- a/src/app/features/project/overview/components/files-widget/files-widget.component.ts +++ b/src/app/features/project/overview/components/files-widget/files-widget.component.ts @@ -38,7 +38,7 @@ import { FileModel } from '@osf/shared/models/files/file.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { FileLabelModel } from '@osf/shared/models/files/file-label.model'; import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; -import { ProjectModel } from '@osf/shared/models/projects/projects.models'; +import { ProjectModel } from '@osf/shared/models/projects/projects.model'; import { SelectOption } from '@osf/shared/models/select-option.model'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts index f94d9f4c9..b74d844cf 100644 --- a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts @@ -9,9 +9,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; import { ResourceSearchMode } from '@osf/shared/enums/resource-search-mode.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.model'; import { MyResourcesSelectors } from '@osf/shared/stores/my-resources'; import { NodeLinksSelectors } from '@osf/shared/stores/node-links'; -import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models'; import { ProjectOverviewSelectors } from '../../store'; diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts index fe5542a89..279a2bc55 100644 --- a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts +++ b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts @@ -29,10 +29,10 @@ import { SearchInputComponent } from '@osf/shared/components/search-input/search import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; import { ResourceSearchMode } from '@osf/shared/enums/resource-search-mode.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.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 { GetMyProjects, GetMyRegistrations, MyResourcesSelectors } from '@osf/shared/stores/my-resources'; import { CreateNodeLink, DeleteNodeLink, NodeLinksSelectors } from '@osf/shared/stores/node-links'; -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'; import { ProjectOverviewSelectors } from '../../store'; diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts b/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts index cbc3f5cf2..3ac5256fb 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts @@ -1,14 +1,26 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { collectionFilterNames } from '@osf/features/collections/constants'; +import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; + import { OverviewCollectionsComponent } from './overview-collections.component'; -describe.skip('OverviewCollectionsComponent', () => { +import { + MOCK_COLLECTION_SUBMISSION_EMPTY_FILTERS, + MOCK_COLLECTION_SUBMISSION_SINGLE_FILTER, + MOCK_COLLECTION_SUBMISSION_STRINGIFY, + MOCK_COLLECTION_SUBMISSION_WITH_FILTERS, + MOCK_COLLECTION_SUBMISSIONS, +} from '@testing/mocks/collections-submissions.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('OverviewCollectionsComponent', () => { let component: OverviewCollectionsComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [OverviewCollectionsComponent], + imports: [OverviewCollectionsComponent, OSFTestingModule], }).compileComponents(); fixture = TestBed.createComponent(OverviewCollectionsComponent); @@ -19,4 +31,63 @@ describe.skip('OverviewCollectionsComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should have default input values', () => { + expect(component.projectSubmissions()).toBeNull(); + expect(component.isProjectSubmissionsLoading()).toBe(false); + }); + + it('should accept projectSubmissions and isProjectSubmissionsLoading via setInput', () => { + const submissions: CollectionSubmission[] = MOCK_COLLECTION_SUBMISSIONS.map((s) => ({ + ...s, + collectionTitle: s.title, + collectionId: `col-${s.id}`, + })) as CollectionSubmission[]; + fixture.componentRef.setInput('projectSubmissions', submissions); + fixture.componentRef.setInput('isProjectSubmissionsLoading', true); + fixture.detectChanges(); + expect(component.projectSubmissions()).toEqual(submissions); + expect(component.isProjectSubmissionsLoading()).toBe(true); + }); + + it('should return empty array from getSubmissionAttributes when submission has no filter values', () => { + expect(component.getSubmissionAttributes(MOCK_COLLECTION_SUBMISSION_EMPTY_FILTERS)).toEqual([]); + }); + + it('should return attributes for truthy filter keys from getSubmissionAttributes', () => { + const result = component.getSubmissionAttributes(MOCK_COLLECTION_SUBMISSION_WITH_FILTERS); + const programAreaFilter = collectionFilterNames.find((f) => f.key === 'programArea'); + const collectedTypeFilter = collectionFilterNames.find((f) => f.key === 'collectedType'); + const statusFilter = collectionFilterNames.find((f) => f.key === 'status'); + expect(result).toContainEqual({ + key: 'programArea', + label: programAreaFilter?.label, + value: 'Health', + }); + expect(result).toContainEqual({ + key: 'collectedType', + label: collectedTypeFilter?.label, + value: 'Article', + }); + expect(result).toContainEqual({ + key: 'status', + label: statusFilter?.label, + value: 'Published', + }); + expect(result.length).toBe(3); + }); + + it('should exclude falsy values from getSubmissionAttributes', () => { + const result = component.getSubmissionAttributes(MOCK_COLLECTION_SUBMISSION_SINGLE_FILTER); + expect(result).toHaveLength(1); + expect(result[0].key).toBe('collectedType'); + expect(result[0].value).toBe('Article'); + }); + + it('should stringify numeric-like values in getSubmissionAttributes', () => { + const result = component.getSubmissionAttributes(MOCK_COLLECTION_SUBMISSION_STRINGIFY); + const statusAttr = result.find((a) => a.key === 'status'); + expect(statusAttr?.value).toBe('1'); + expect(typeof statusAttr?.value).toBe('string'); + }); }); diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts b/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts index 328898474..168a3530b 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts @@ -10,7 +10,7 @@ import { RouterLink } from '@angular/router'; import { collectionFilterNames } from '@osf/features/collections/constants'; import { StopPropagationDirective } from '@osf/shared/directives/stop-propagation.directive'; -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/project/overview/models/index.ts b/src/app/features/project/overview/models/index.ts index ffa3996aa..b2f55ace8 100644 --- a/src/app/features/project/overview/models/index.ts +++ b/src/app/features/project/overview/models/index.ts @@ -1,4 +1,4 @@ export * from './addon-tree-item.model'; export * from './formatted-citation-item.model'; export * from './privacy-status.model'; -export * from './project-overview.models'; +export * from './project-overview.model'; diff --git a/src/app/features/project/overview/models/project-overview.models.ts b/src/app/features/project/overview/models/project-overview.model.ts similarity index 100% rename from src/app/features/project/overview/models/project-overview.models.ts rename to src/app/features/project/overview/models/project-overview.model.ts diff --git a/src/app/features/project/overview/services/project-overview.service.ts b/src/app/features/project/overview/services/project-overview.service.ts index f923aa0a1..7133193be 100644 --- a/src/app/features/project/overview/services/project-overview.service.ts +++ b/src/app/features/project/overview/services/project-overview.service.ts @@ -15,6 +15,7 @@ import { NodeStorageMapper } from '@osf/shared/mappers/nodes/node-storage.mapper import { JsonApiResponse } from '@osf/shared/models/common/json-api.model'; import { IdentifiersResponseJsonApi } from '@osf/shared/models/identifiers/identifier-json-api.model'; import { InstitutionsJsonApiResponse } from '@osf/shared/models/institutions/institution-json-api.model'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { LicenseResponseJsonApi } from '@osf/shared/models/license/licenses-json-api.model'; import { BaseNodeModel, NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { BaseNodeDataJsonApi } from '@osf/shared/models/nodes/base-node-data-json-api.model'; @@ -26,7 +27,6 @@ import { NodeResponseJsonApi, NodesResponseJsonApi } from '@osf/shared/models/no import { PaginatedData } from '@osf/shared/models/paginated-data.model'; import { JsonApiService } from '@osf/shared/services/json-api.service'; import { IdentifierModel } from '@shared/models/identifiers/identifier.model'; -import { Institution } from '@shared/models/institutions/institutions.models'; import { LicenseModel } from '@shared/models/license/license.model'; import { ProjectOverviewMapper } from '../mappers'; diff --git a/src/app/features/project/overview/store/project-overview.model.ts b/src/app/features/project/overview/store/project-overview.model.ts index 8675ce272..51abff1ba 100644 --- a/src/app/features/project/overview/store/project-overview.model.ts +++ b/src/app/features/project/overview/store/project-overview.model.ts @@ -1,10 +1,10 @@ +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { BaseNodeModel, NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { NodePreprintModel } from '@osf/shared/models/nodes/node-preprint.model'; import { NodeStorageModel } from '@osf/shared/models/nodes/node-storage.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model'; import { IdentifierModel } from '@shared/models/identifiers/identifier.model'; -import { Institution } from '@shared/models/institutions/institutions.models'; import { LicenseModel } from '@shared/models/license/license.model'; import { ProjectOverviewModel } from '../models'; diff --git a/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts b/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts index fa9eaa3cb..2ba414289 100644 --- a/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts +++ b/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts @@ -25,8 +25,8 @@ import { OperationNames } from '@osf/shared/enums/operation-names.enum'; import { ProjectAddonsStepperValue } from '@osf/shared/enums/profile-addons-stepper.enum'; import { getAddonTypeString } from '@osf/shared/helpers/addon-type.helper'; import { AddonModel } from '@osf/shared/models/addons/addon.model'; -import { AuthorizedAddonRequestJsonApi } from '@osf/shared/models/addons/addon-json-api.models'; -import { AddonTerm } from '@osf/shared/models/addons/addon-utils.models'; +import { AuthorizedAddonRequestJsonApi } from '@osf/shared/models/addons/addon-json-api.model'; +import { AddonTerm } from '@osf/shared/models/addons/addon-utils.model'; import { AuthorizedAccountModel } from '@osf/shared/models/addons/authorized-account.model'; import { AddonFormService } from '@osf/shared/services/addons/addon-form.service'; import { AddonOAuthService } from '@osf/shared/services/addons/addon-oauth.service'; diff --git a/src/app/features/project/project.component.spec.ts b/src/app/features/project/project.component.spec.ts index 72d6774ba..88b558a8c 100644 --- a/src/app/features/project/project.component.spec.ts +++ b/src/app/features/project/project.component.spec.ts @@ -1,83 +1,248 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { Store } from '@ngxs/store'; + +import { MockProvider } from 'ng-mocks'; + +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { HelpScoutService } from '@core/services/help-scout.service'; import { PrerenderReadyService } from '@core/services/prerender-ready.service'; +import { ProjectOverviewModel } from '@osf/features/project/overview/models'; +import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; +import { MetaTagsData } from '@osf/shared/models/meta-tags/meta-tags-data.model'; +import { AnalyticsService } from '@osf/shared/services/analytics.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 { ContributorsSelectors } from '@osf/shared/stores/contributors'; import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; -import { ProjectOverviewSelectors } from './overview/store'; +import { GetProjectById, GetProjectIdentifiers, GetProjectLicense, ProjectOverviewSelectors } from './overview/store'; import { ProjectComponent } from './project.component'; import { DataciteMockFactory } from '@testing/mocks/datacite.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { MOCK_PROJECT_OVERVIEW } from '@testing/mocks/project-overview.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { AnalyticsServiceMockFactory } from '@testing/providers/analytics.service.mock'; import { HelpScoutServiceMockFactory } from '@testing/providers/help-scout.service.mock'; import { MetaTagsServiceMockFactory } from '@testing/providers/meta-tags.service.mock'; +import { MetaTagsBuilderServiceMockFactory } from '@testing/providers/meta-tags-builder.service.mock'; import { PrerenderReadyServiceMockFactory } from '@testing/providers/prerender-ready.service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; -describe('Component: Project', () => { - let component: ProjectComponent; - let fixture: ComponentFixture; - let helpScoutService: ReturnType; - let metaTagsService: ReturnType; - let dataciteService: ReturnType; - let prerenderReadyService: ReturnType; - let mockActivatedRoute: ReturnType; - - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'project-1' }).build(); - - helpScoutService = HelpScoutServiceMockFactory(); - metaTagsService = MetaTagsServiceMockFactory(); - dataciteService = DataciteMockFactory(); - prerenderReadyService = PrerenderReadyServiceMockFactory(); - - await TestBed.configureTestingModule({ - imports: [ProjectComponent, OSFTestingModule], - providers: [ - { provide: HelpScoutService, useValue: helpScoutService }, - { provide: MetaTagsService, useValue: metaTagsService }, - { provide: DataciteService, useValue: dataciteService }, - { provide: PrerenderReadyService, useValue: prerenderReadyService }, - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - provideMockStore({ - signals: [ - { selector: ProjectOverviewSelectors.getProject, value: null }, - { selector: ProjectOverviewSelectors.getProjectLoading, value: false }, - { selector: ProjectOverviewSelectors.getIdentifiers, value: [] }, - { selector: ProjectOverviewSelectors.getLicense, value: null }, - { selector: ProjectOverviewSelectors.isLicenseLoading, value: false }, - { selector: ContributorsSelectors.getBibliographicContributors, value: [] }, - { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, - { selector: CurrentResourceSelectors.getCurrentResource, value: null }, - ], - }), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(ProjectComponent); - component = fixture.componentInstance; - fixture.detectChanges(); +interface SetupOverrides extends BaseSetupOverrides { + projectId?: string; + selectorOverrides?: SignalOverride[]; + childCanonicalPath?: string; + childCanonicalPathTemplate?: string; + childParams?: Record; +} + +function setup(overrides: SetupOverrides = {}) { + const projectId = overrides.projectId ?? 'project-1'; + const helpScoutService = HelpScoutServiceMockFactory(); + const analyticsService = AnalyticsServiceMockFactory(); + const metaTagsService = MetaTagsServiceMockFactory(); + const metaTagsBuilderService = MetaTagsBuilderServiceMockFactory(); + const dataciteService = DataciteMockFactory(); + const prerenderReadyService = PrerenderReadyServiceMockFactory(); + const routerBuilder = RouterMockBuilder.create(); + const routerMock = routerBuilder.build(); + + const routeBuilder = ActivatedRouteMockBuilder.create().withParams(overrides.routeParams ?? { id: projectId }); + + if (overrides.hasParent === false) { + routeBuilder.withNoParent(); + } + + if (overrides.childCanonicalPath || overrides.childCanonicalPathTemplate || overrides.childParams) { + routeBuilder.withFirstChild((builder) => { + if (overrides.childCanonicalPath) { + builder.withData({ canonicalPath: overrides.childCanonicalPath }); + } + if (overrides.childCanonicalPathTemplate) { + builder.withData({ canonicalPathTemplate: overrides.childCanonicalPathTemplate }); + } + if (overrides.childParams) { + builder.withParams(overrides.childParams); + } + }); + } + + const mockActivatedRoute = routeBuilder.build(); + + metaTagsBuilderService.buildProjectMetaTagsData.mockImplementation( + ({ project, canonicalPath }): MetaTagsData => + ({ + osfGuid: project.id, + canonicalUrl: `http://localhost:4200/${project.id}/${canonicalPath}`, + }) as MetaTagsData + ); + + const defaultSignals: SignalOverride[] = [ + { selector: ProjectOverviewSelectors.getProject, value: null }, + { selector: ProjectOverviewSelectors.getProjectLoading, value: false }, + { selector: ProjectOverviewSelectors.getIdentifiers, value: [] }, + { selector: ProjectOverviewSelectors.getLicense, value: { name: 'MIT' } }, + { selector: ProjectOverviewSelectors.isLicenseLoading, value: false }, + { selector: ContributorsSelectors.getBibliographicContributors, value: [] }, + { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, + { selector: CurrentResourceSelectors.getCurrentResource, value: null }, + ]; + + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); + + TestBed.configureTestingModule({ + imports: [ProjectComponent], + providers: [ + provideOSFCore(), + MockProvider(HelpScoutService, helpScoutService), + MockProvider(AnalyticsService, analyticsService), + MockProvider(MetaTagsService, metaTagsService), + MockProvider(MetaTagsBuilderService, metaTagsBuilderService), + MockProvider(DataciteService, dataciteService), + MockProvider(PrerenderReadyService, prerenderReadyService), + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, routerMock), + provideMockStore({ signals }), + ], }); - it('should call the helpScoutService', () => { + const store = TestBed.inject(Store); + const fixture = TestBed.createComponent(ProjectComponent); + const component = fixture.componentInstance; + fixture.detectChanges(); + + return { + component, + fixture, + store, + routerBuilder, + routerMock, + helpScoutService, + analyticsService, + metaTagsService, + metaTagsBuilderService, + dataciteService, + prerenderReadyService, + }; +} + +describe('Component: Project', () => { + it('should set helpScout resource type and prerender not ready on init', () => { + const { helpScoutService, prerenderReadyService } = setup(); + expect(helpScoutService.setResourceType).toHaveBeenCalledWith('project'); + expect(prerenderReadyService.setNotReady).toHaveBeenCalled(); }); - it('should call unsetResourceType on destroy', () => { - component.ngOnDestroy(); - expect(helpScoutService.unsetResourceType).toHaveBeenCalled(); + it('should dispatch init actions when project id is available', () => { + const { store } = setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new GetProjectById('project-1')); + expect(store.dispatch).toHaveBeenCalledWith(new GetProjectIdentifiers('project-1')); }); - it('should call prerenderReady.setNotReady in constructor', () => { - expect(prerenderReadyService.setNotReady).toHaveBeenCalled(); + it('should dispatch get license when project has licenseId', () => { + const project = { ...MOCK_PROJECT_OVERVIEW, licenseId: 'license-1' } as ProjectOverviewModel; + const { store } = setup({ + selectorOverrides: [{ selector: ProjectOverviewSelectors.getProject, value: project }], + }); + + expect(store.dispatch).toHaveBeenCalledWith(new GetProjectLicense('license-1')); }); - it('should call dataciteService.logIdentifiableView', () => { + it('should call datacite tracking on init', () => { + const { dataciteService } = setup(); + expect(dataciteService.logIdentifiableView).toHaveBeenCalled(); }); + + it('should map identifiers to null when identifiers are empty', () => { + const { dataciteService } = setup(); + const identifiers$ = (dataciteService.logIdentifiableView as jest.Mock).mock.calls[0][0]; + let emitted: unknown; + + identifiers$.subscribe((value: unknown) => { + emitted = value; + }); + + expect(emitted).toBeNull(); + }); + + it('should map identifiers to payload when identifiers exist', () => { + const identifiers = [ + { + id: 'identifier-1', + type: 'identifiers', + category: 'doi', + value: '10.1234/osf.test', + }, + ] as IdentifierModel[]; + const { dataciteService } = setup({ + selectorOverrides: [{ selector: ProjectOverviewSelectors.getIdentifiers, value: identifiers }], + }); + const identifiers$ = (dataciteService.logIdentifiableView as jest.Mock).mock.calls[0][0]; + let emitted: unknown; + + identifiers$.subscribe((value: unknown) => { + emitted = value; + }); + + expect(emitted).toEqual({ identifiers }); + }); + + it('should build and update meta tags with canonical path from active subroute', () => { + const { metaTagsBuilderService, metaTagsService } = setup({ + selectorOverrides: [{ selector: ProjectOverviewSelectors.getProject, value: MOCK_PROJECT_OVERVIEW }], + childCanonicalPath: 'files/osfstorage', + }); + + expect(metaTagsBuilderService.buildProjectMetaTagsData).toHaveBeenCalledWith( + expect.objectContaining({ + project: expect.objectContaining({ id: 'project-1' }), + canonicalPath: 'files/osfstorage', + }) + ); + expect(metaTagsService.updateMetaTags).toHaveBeenCalledWith( + expect.objectContaining({ + canonicalUrl: 'http://localhost:4200/project-1/files/osfstorage', + }), + expect.anything() + ); + expect(metaTagsService.updateMetaTags).toHaveBeenCalledTimes(1); + }); + + it('should not build or update meta tags when current project is null', () => { + const { metaTagsBuilderService, metaTagsService } = setup(); + + expect(metaTagsBuilderService.buildProjectMetaTagsData).not.toHaveBeenCalled(); + expect(metaTagsService.updateMetaTags).not.toHaveBeenCalled(); + }); + + it('should send analytics on NavigationEnd', () => { + const { routerBuilder, analyticsService } = setup(); + + routerBuilder.emit(new NavigationEnd(1, '/project-1', '/project-1/overview')); + + expect(analyticsService.sendCountedUsageForRegistrationAndProjects).toHaveBeenCalledWith( + '/project-1/overview', + null + ); + }); + + it('should call unsetResourceType on destroy', () => { + const { component, helpScoutService } = setup(); + + component.ngOnDestroy(); + + expect(helpScoutService.unsetResourceType).toHaveBeenCalled(); + }); }); diff --git a/src/app/features/project/project.component.ts b/src/app/features/project/project.component.ts index 0ec8ced6b..bbcec1410 100644 --- a/src/app/features/project/project.component.ts +++ b/src/app/features/project/project.component.ts @@ -2,7 +2,6 @@ import { createDispatchMap, select } from '@ngxs/store'; import { filter, map } from 'rxjs'; -import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -20,8 +19,13 @@ import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/ro import { HelpScoutService } from '@core/services/help-scout.service'; import { PrerenderReadyService } from '@core/services/prerender-ready.service'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { + getDeepestCanonicalPathTemplateFromSnapshot, + resolveCanonicalPathFromSnapshot, +} from '@osf/shared/helpers/canonical-path.helper'; 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 { ContributorsSelectors, GetBibliographicContributors } from '@osf/shared/stores/contributors'; import { AnalyticsService } from '@shared/services/analytics.service'; import { CurrentResourceSelectors } from '@shared/stores/current-resource'; @@ -34,26 +38,21 @@ import { GetProjectById, GetProjectIdentifiers, GetProjectLicense, ProjectOvervi templateUrl: './project.component.html', styleUrl: './project.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [DatePipe], }) export class ProjectComponent implements OnDestroy { @HostBinding('class') classes = 'flex flex-1 flex-column w-full'; private readonly helpScoutService = inject(HelpScoutService); private readonly metaTags = inject(MetaTagsService); + private readonly metaTagsBuilder = inject(MetaTagsBuilderService); private readonly dataciteService = inject(DataciteService); private readonly destroyRef = inject(DestroyRef); private readonly route = inject(ActivatedRoute); - private readonly datePipe = inject(DatePipe); private readonly prerenderReady = inject(PrerenderReadyService); private readonly router = inject(Router); private readonly analyticsService = inject(AnalyticsService); - private readonly currentResource = select(CurrentResourceSelectors.getCurrentResource); - - readonly identifiersForDatacite$ = toObservable(select(ProjectOverviewSelectors.getIdentifiers)).pipe( - map((identifiers) => (identifiers?.length ? { identifiers } : null)) - ); + readonly currentResource = select(CurrentResourceSelectors.getCurrentResource); readonly currentProject = select(ProjectOverviewSelectors.getProject); readonly isProjectLoading = select(ProjectOverviewSelectors.getProjectLoading); readonly bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors); @@ -61,8 +60,21 @@ export class ProjectComponent implements OnDestroy { readonly license = select(ProjectOverviewSelectors.getLicense); readonly isLicenseLoading = select(ProjectOverviewSelectors.isLicenseLoading); - private readonly lastMetaTagsProjectId = signal(null); + readonly identifiersForDatacite$ = toObservable(select(ProjectOverviewSelectors.getIdentifiers)).pipe( + map((identifiers) => (identifiers?.length ? { identifiers } : null)) + ); + + private readonly actions = createDispatchMap({ + getProject: GetProjectById, + getLicense: GetProjectLicense, + getIdentifiers: GetProjectIdentifiers, + getBibliographicContributors: GetBibliographicContributors, + }); + private readonly projectId = toSignal(this.route.params.pipe(map((params) => params['id']))); + private readonly canonicalPath = signal(this.getCanonicalPathFromSnapshot()); + private readonly isFileDetailRoute = signal(this.isFileDetailRouteFromSnapshot()); + private readonly lastMetaTagsKey = signal(null); private readonly allDataLoaded = computed( () => @@ -72,13 +84,6 @@ export class ProjectComponent implements OnDestroy { !!this.currentProject() ); - private readonly actions = createDispatchMap({ - getProject: GetProjectById, - getLicense: GetProjectLicense, - getIdentifiers: GetProjectIdentifiers, - getBibliographicContributors: GetBibliographicContributors, - }); - constructor() { this.prerenderReady.setNotReady(); this.helpScoutService.setResourceType('project'); @@ -94,22 +99,35 @@ export class ProjectComponent implements OnDestroy { }); effect(() => { - const project = this.currentProject(); + const licenseId = this.currentProject()?.licenseId; - if (project?.licenseId) { - this.actions.getLicense(project.licenseId); + if (licenseId) { + this.actions.getLicense(licenseId); } }); effect(() => { - if (this.allDataLoaded()) { - const currentProjectId = this.projectId(); - const lastSetProjectId = this.lastMetaTagsProjectId(); + if (!this.allDataLoaded()) { + this.lastMetaTagsKey.set(null); + return; + } + + const currentProjectId = this.currentProject()?.id; + const currentCanonicalPath = this.canonicalPath(); - if (currentProjectId && currentProjectId !== lastSetProjectId) { - this.setMetaTags(); - } + if (!currentProjectId || !currentCanonicalPath || this.isFileDetailRoute()) { + this.lastMetaTagsKey.set(null); + return; } + + const metaTagsKey = `${currentProjectId}:${currentCanonicalPath}`; + + if (this.lastMetaTagsKey() === metaTagsKey) { + return; + } + + this.lastMetaTagsKey.set(metaTagsKey); + this.setMetaTags(); }); this.dataciteService @@ -123,6 +141,8 @@ export class ProjectComponent implements OnDestroy { takeUntilDestroyed(this.destroyRef) ) .subscribe((event: NavigationEnd) => { + this.canonicalPath.set(this.getCanonicalPathFromSnapshot()); + this.isFileDetailRoute.set(this.isFileDetailRouteFromSnapshot()); this.analyticsService.sendCountedUsageForRegistrationAndProjects( event.urlAfterRedirects, this.currentResource() @@ -135,29 +155,29 @@ export class ProjectComponent implements OnDestroy { } private setMetaTags(): void { - const project = this.currentProject(); - if (!project) return; - - const keywords = [...(project.tags || []), ...(project.category ? [project.category] : [])]; - - const metaTagsData = { - osfGuid: project.id, - title: project.title, - description: project.description, - url: project.links?.iri, - license: this.license.name, - publishedDate: this.datePipe.transform(project.dateCreated, 'yyyy-MM-dd'), - modifiedDate: this.datePipe.transform(project.dateModified, 'yyyy-MM-dd'), - keywords, - contributors: this.bibliographicContributors().map((contributor) => ({ - fullName: contributor.fullName, - givenName: contributor.givenName, - familyName: contributor.familyName, - })), - }; + const project = this.currentProject()!; + + const metaTagsData = this.metaTagsBuilder.buildProjectMetaTagsData({ + project, + canonicalPath: this.canonicalPath(), + contributors: this.bibliographicContributors(), + licenseName: this.license.name, + }); this.metaTags.updateMetaTags(metaTagsData, this.destroyRef); + } + + private getCanonicalPathFromSnapshot(): string { + return resolveCanonicalPathFromSnapshot(this.route.snapshot, { + fallbackPath: 'overview', + paramDefaults: { + fileProvider: 'osfstorage', + recordId: 'osf', + }, + }); + } - this.lastMetaTagsProjectId.set(project.id); + private isFileDetailRouteFromSnapshot(): boolean { + return getDeepestCanonicalPathTemplateFromSnapshot(this.route.snapshot) === 'files/:fileGuid'; } } diff --git a/src/app/features/project/project.routes.ts b/src/app/features/project/project.routes.ts index 8c2861788..a4f4b153d 100644 --- a/src/app/features/project/project.routes.ts +++ b/src/app/features/project/project.routes.ts @@ -36,6 +36,7 @@ export const projectRoutes: Routes = [ path: 'overview', loadComponent: () => import('../project/overview/project-overview.component').then((mod) => mod.ProjectOverviewComponent), + data: { canonicalPath: 'overview' }, providers: [ provideStates([ NodeLinksState, @@ -52,13 +53,13 @@ export const projectRoutes: Routes = [ path: 'metadata', loadChildren: () => import('@osf/features/metadata/metadata.routes').then((mod) => mod.metadataRoutes), providers: [provideStates([SubjectsState, CollectionsState])], - data: { resourceType: ResourceType.Project }, + data: { resourceType: ResourceType.Project, canonicalPath: 'metadata/osf' }, canActivate: [viewOnlyGuard], }, { path: 'files', loadChildren: () => import('@osf/features/files/files.routes').then((mod) => mod.filesRoutes), - data: { resourceType: ResourceType.Project }, + data: { resourceType: ResourceType.Project, canonicalPath: 'files/osfstorage' }, }, { path: 'registrations', @@ -66,29 +67,31 @@ export const projectRoutes: Routes = [ providers: [provideStates([RegistrationsState])], loadComponent: () => import('../project/registrations/registrations.component').then((mod) => mod.RegistrationsComponent), + data: { canonicalPath: 'registrations' }, }, { path: 'settings', canActivate: [viewOnlyGuard], loadComponent: () => import('../project/settings/settings.component').then((mod) => mod.SettingsComponent), + data: { canonicalPath: 'settings' }, providers: [provideStates([SettingsState, ViewOnlyLinkState])], }, { path: 'contributors', canActivate: [viewOnlyGuard], loadComponent: () => import('../contributors/contributors.component').then((mod) => mod.ContributorsComponent), - data: { resourceType: ResourceType.Project }, + data: { resourceType: ResourceType.Project, canonicalPath: 'contributors' }, providers: [provideStates([ViewOnlyLinkState])], }, { path: 'analytics', loadComponent: () => import('../analytics/analytics.component').then((mod) => mod.AnalyticsComponent), - data: { resourceType: ResourceType.Project }, + data: { resourceType: ResourceType.Project, canonicalPath: 'analytics' }, providers: [provideStates([AnalyticsState])], }, { path: 'analytics/linked-projects', - data: { resourceType: ResourceType.Project }, + data: { resourceType: ResourceType.Project, canonicalPath: 'analytics/linked-projects' }, loadComponent: () => import('../analytics/components/view-linked-projects/view-linked-projects.component').then( (mod) => mod.ViewLinkedProjectsComponent @@ -97,7 +100,7 @@ export const projectRoutes: Routes = [ }, { path: 'analytics/duplicates', - data: { resourceType: ResourceType.Project }, + data: { resourceType: ResourceType.Project, canonicalPath: 'analytics/duplicates' }, loadComponent: () => import('../analytics/components/view-duplicates/view-duplicates.component').then( (mod) => mod.ViewDuplicatesComponent @@ -108,20 +111,24 @@ export const projectRoutes: Routes = [ path: 'wiki/:wikiName', loadComponent: () => import('../project/wiki/legacy-wiki-redirect.component').then((m) => m.LegacyWikiRedirectComponent), + data: { canonicalPath: 'wiki' }, }, { path: 'wiki', loadComponent: () => import('../project/wiki/wiki.component').then((mod) => mod.WikiComponent), + data: { canonicalPath: 'wiki' }, }, { path: 'addons', canActivate: [viewOnlyGuard], + data: { canonicalPath: 'addons' }, loadChildren: () => import('@osf/features/project/project-addons/project-addons.routes').then((mod) => mod.projectAddonsRoutes), }, { path: 'links', canActivate: [viewOnlyGuard], + data: { canonicalPath: 'links' }, loadComponent: () => import('../project/linked-services/linked-services.component').then((mod) => mod.LinkedServicesComponent), }, diff --git a/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.spec.ts b/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.spec.ts index 6e12a2741..f13917010 100644 --- a/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.spec.ts +++ b/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +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 { SettingsProjectAffiliationComponent } from './settings-project-affiliation.component'; diff --git a/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.ts b/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.ts index de8f86c66..112511600 100644 --- a/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.ts +++ b/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.ts @@ -8,7 +8,7 @@ import { Card } from 'primeng/card'; import { NgOptimizedImage } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, input, OnInit, output } from '@angular/core'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { FetchUserInstitutions, InstitutionsSelectors } from '@shared/stores/institutions'; @Component({ diff --git a/src/app/features/project/settings/models/node-details.model.ts b/src/app/features/project/settings/models/node-details.model.ts index 483791011..2f3e8bc24 100644 --- a/src/app/features/project/settings/models/node-details.model.ts +++ b/src/app/features/project/settings/models/node-details.model.ts @@ -1,6 +1,6 @@ import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { IdNameModel } from '@shared/models/common/id-name.model'; -import { Institution } from '@shared/models/institutions/institutions.models'; export interface NodeDetailsModel { id: string; diff --git a/src/app/features/project/settings/settings.component.ts b/src/app/features/project/settings/settings.component.ts index 5ad55cc42..d697fe844 100644 --- a/src/app/features/project/settings/settings.component.ts +++ b/src/app/features/project/settings/settings.component.ts @@ -15,7 +15,7 @@ import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { SubscriptionEvent } from '@osf/shared/enums/subscriptions/subscription-event.enum'; import { SubscriptionFrequency } from '@osf/shared/enums/subscriptions/subscription-frequency.enum'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { UpdateNodeRequestModel } from '@osf/shared/models/nodes/nodes-json-api.model'; import { ViewOnlyLinkModel } from '@osf/shared/models/view-only-links/view-only-link.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; diff --git a/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.html b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.html index 8a74c891e..9608fd083 100644 --- a/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.html +++ b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.html @@ -10,13 +10,13 @@ class="w-12rem btn-full-width" [label]="'common.buttons.cancel' | translate" severity="info" - (click)="dialogRef.close()" + (onClick)="dialogRef.close()" />

diff --git a/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.spec.ts b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.spec.ts index d3130fff6..5d1af3d25 100644 --- a/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.spec.ts +++ b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.spec.ts @@ -1,46 +1,41 @@ +import { Store } from '@ngxs/store'; + import { MockProvider } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { of } from 'rxjs'; - import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SchemaActionTrigger } from '@osf/features/registries/enums'; +import { HandleSchemaResponse } from '@osf/features/registries/store'; import { ConfirmContinueEditingDialogComponent } from './confirm-continue-editing-dialog.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('ConfirmContinueEditingDialogComponent', () => { let component: ConfirmContinueEditingDialogComponent; let fixture: ComponentFixture; - let mockDialogRef: DynamicDialogRef; - let mockDialogConfig: jest.Mocked; + let store: Store; + let dialogRef: DynamicDialogRef; const MOCK_REVISION_ID = 'test-revision-id'; - beforeEach(async () => { - mockDialogRef = { - close: jest.fn(), - } as any; - - mockDialogConfig = { - data: { revisionId: MOCK_REVISION_ID }, - } as jest.Mocked; - - await TestBed.configureTestingModule({ - imports: [ConfirmContinueEditingDialogComponent, OSFTestingModule], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ConfirmContinueEditingDialogComponent], providers: [ - MockProvider(DynamicDialogRef, mockDialogRef), - MockProvider(DynamicDialogConfig, mockDialogConfig), - provideMockStore({ - signals: [], - }), + provideOSFCore(), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, { data: { revisionId: MOCK_REVISION_ID } }), + provideMockStore(), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); + dialogRef = TestBed.inject(DynamicDialogRef); fixture = TestBed.createComponent(ConfirmContinueEditingDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -54,87 +49,27 @@ describe('ConfirmContinueEditingDialogComponent', () => { expect(component.isSubmitting).toBe(false); }); - it('should submit with comment', () => { - const testComment = 'Test comment'; - component.form.patchValue({ comment: testComment }); - - const mockActions = { - handleSchemaResponse: jest.fn().mockReturnValue(of({})), - }; - - Object.defineProperty(component, 'actions', { - value: mockActions, - writable: true, - }); + it('should dispatch handleSchemaResponse with comment on submit', () => { + component.form.patchValue({ comment: 'Test comment' }); component.submit(); - expect(mockActions.handleSchemaResponse).toHaveBeenCalledWith( - MOCK_REVISION_ID, - SchemaActionTrigger.AdminReject, - testComment + expect(store.dispatch).toHaveBeenCalledWith( + new HandleSchemaResponse(MOCK_REVISION_ID, SchemaActionTrigger.AdminReject, 'Test comment') ); + expect(dialogRef.close).toHaveBeenCalledWith(true); }); - it('should submit with empty comment', () => { - const mockActions = { - handleSchemaResponse: jest.fn().mockReturnValue(of({})), - }; - - Object.defineProperty(component, 'actions', { - value: mockActions, - writable: true, - }); - + it('should dispatch handleSchemaResponse with empty comment on submit', () => { component.submit(); - expect(mockActions.handleSchemaResponse).toHaveBeenCalledWith( - MOCK_REVISION_ID, - SchemaActionTrigger.AdminReject, - '' + expect(store.dispatch).toHaveBeenCalledWith( + new HandleSchemaResponse(MOCK_REVISION_ID, SchemaActionTrigger.AdminReject, '') ); }); - it('should set isSubmitting to true when submitting', () => { - const mockActions = { - handleSchemaResponse: jest.fn().mockReturnValue(of({}).pipe()), - }; - - Object.defineProperty(component, 'actions', { - value: mockActions, - writable: true, - }); - - component.submit(); - expect(mockActions.handleSchemaResponse).toHaveBeenCalled(); - }); - it('should update comment value', () => { - const testComment = 'New comment'; - component.form.patchValue({ comment: testComment }); - - expect(component.form.get('comment')?.value).toBe(testComment); - }); - - it('should handle different revision IDs', () => { - const differentRevisionId = 'different-revision-id'; - (component as any).config.data = { revisionId: differentRevisionId } as any; - - const mockActions = { - handleSchemaResponse: jest.fn().mockReturnValue(of({})), - }; - - Object.defineProperty(component, 'actions', { - value: mockActions, - writable: true, - }); - - component.submit(); - - expect(mockActions.handleSchemaResponse).toHaveBeenCalledWith( - differentRevisionId, - SchemaActionTrigger.AdminReject, - '' - ); + component.form.patchValue({ comment: 'New comment' }); + expect(component.form.get('comment')?.value).toBe('New comment'); }); }); diff --git a/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.ts b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.ts index 87b307f68..b24087d4e 100644 --- a/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.ts +++ b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.ts @@ -23,20 +23,16 @@ import { HandleSchemaResponse } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ConfirmContinueEditingDialogComponent { - readonly dialogRef = inject(DynamicDialogRef); - private readonly fb = inject(FormBuilder); readonly config = inject(DynamicDialogConfig); - private readonly destroyRef = inject(DestroyRef); + readonly dialogRef = inject(DynamicDialogRef); + readonly destroyRef = inject(DestroyRef); + readonly fb = inject(FormBuilder); - actions = createDispatchMap({ - handleSchemaResponse: HandleSchemaResponse, - }); + actions = createDispatchMap({ handleSchemaResponse: HandleSchemaResponse }); isSubmitting = false; - form: FormGroup = this.fb.group({ - comment: [''], - }); + form: FormGroup = this.fb.group({ comment: [''] }); submit(): void { const comment = this.form.value.comment; diff --git a/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.spec.ts b/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.spec.ts index 3cd46f5fb..781dbc459 100644 --- a/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.spec.ts +++ b/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.spec.ts @@ -1,47 +1,50 @@ +import { Store } from '@ngxs/store'; + import { MockProvider } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { of } from 'rxjs'; +import { throwError } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SubmitType } from '@osf/features/registries/enums'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { RegisterDraft, RegistriesSelectors } from '@osf/features/registries/store'; import { ConfirmRegistrationDialogComponent } from './confirm-registration-dialog.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('ConfirmRegistrationDialogComponent', () => { let component: ConfirmRegistrationDialogComponent; let fixture: ComponentFixture; - let mockDialogRef: DynamicDialogRef; - let mockDialogConfig: jest.Mocked; + let store: Store; + let dialogRef: DynamicDialogRef; const MOCK_CONFIG_DATA = { draftId: 'draft-1', providerId: 'provider-1', projectId: 'project-1', - components: [], + components: [] as string[], }; - beforeEach(async () => { - mockDialogRef = { close: jest.fn() } as any; - mockDialogConfig = { data: { ...MOCK_CONFIG_DATA } } as any; - - await TestBed.configureTestingModule({ - imports: [ConfirmRegistrationDialogComponent, OSFTestingModule], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ConfirmRegistrationDialogComponent], providers: [ - MockProvider(DynamicDialogRef, mockDialogRef), - MockProvider(DynamicDialogConfig, mockDialogConfig), + provideOSFCore(), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, { data: { ...MOCK_CONFIG_DATA } }), provideMockStore({ signals: [{ selector: RegistriesSelectors.isRegistrationSubmitting, value: false }], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); + dialogRef = TestBed.inject(DynamicDialogRef); fixture = TestBed.createComponent(ConfirmRegistrationDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -78,44 +81,60 @@ describe('ConfirmRegistrationDialogComponent', () => { expect(embargoControl?.value).toBeNull(); }); - it('should submit with immediate option and close dialog', () => { - const mockActions = { - registerDraft: jest.fn().mockReturnValue(of({})), - }; - Object.defineProperty(component, 'actions', { value: mockActions, writable: true }); - + it('should dispatch registerDraft with immediate option and close dialog', () => { component.form.get('submitOption')?.setValue(SubmitType.Public); + component.submit(); - expect(mockActions.registerDraft).toHaveBeenCalledWith( - MOCK_CONFIG_DATA.draftId, - '', - MOCK_CONFIG_DATA.providerId, - MOCK_CONFIG_DATA.projectId, - MOCK_CONFIG_DATA.components + expect(store.dispatch).toHaveBeenCalledWith( + new RegisterDraft( + MOCK_CONFIG_DATA.draftId, + '', + MOCK_CONFIG_DATA.providerId, + MOCK_CONFIG_DATA.projectId, + MOCK_CONFIG_DATA.components + ) ); - expect(mockDialogRef.close).toHaveBeenCalledWith(true); + expect(dialogRef.close).toHaveBeenCalledWith(true); }); - it('should submit with embargo and include ISO embargoDate', () => { - const mockActions = { - registerDraft: jest.fn().mockReturnValue(of({})), - }; - Object.defineProperty(component, 'actions', { value: mockActions, writable: true }); - + it('should dispatch registerDraft with embargo and include ISO embargoDate', () => { const date = new Date('2025-01-01T00:00:00Z'); component.form.get('submitOption')?.setValue(SubmitType.Embargo); component.form.get('embargoDate')?.setValue(date); component.submit(); - expect(mockActions.registerDraft).toHaveBeenCalledWith( - MOCK_CONFIG_DATA.draftId, - date.toISOString(), - MOCK_CONFIG_DATA.providerId, - MOCK_CONFIG_DATA.projectId, - MOCK_CONFIG_DATA.components + expect(store.dispatch).toHaveBeenCalledWith( + new RegisterDraft( + MOCK_CONFIG_DATA.draftId, + date.toISOString(), + MOCK_CONFIG_DATA.providerId, + MOCK_CONFIG_DATA.projectId, + MOCK_CONFIG_DATA.components + ) ); - expect(mockDialogRef.close).toHaveBeenCalledWith(true); + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); + + it('should return a date 3 days in the future for minEmbargoDate', () => { + const expected = new Date(); + expected.setDate(expected.getDate() + 3); + + const result = component.minEmbargoDate(); + + expect(result.getFullYear()).toBe(expected.getFullYear()); + expect(result.getMonth()).toBe(expected.getMonth()); + expect(result.getDate()).toBe(expected.getDate()); + }); + + it('should re-enable form on submit error', () => { + (store.dispatch as jest.Mock).mockReturnValueOnce(throwError(() => new Error('fail'))); + + component.form.get('submitOption')?.setValue(SubmitType.Public); + component.submit(); + + expect(component.form.enabled).toBe(true); + expect(dialogRef.close).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.ts b/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.ts index 874b1896f..56ee36981 100644 --- a/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.ts +++ b/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.ts @@ -7,7 +7,8 @@ import { DatePicker } from 'primeng/datepicker'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { RadioButton } from 'primeng/radiobutton'; -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { SubmitType } from '../../enums'; @@ -21,14 +22,13 @@ import { RegisterDraft, RegistriesSelectors } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ConfirmRegistrationDialogComponent { - readonly dialogRef = inject(DynamicDialogRef); - private readonly fb = inject(FormBuilder); readonly config = inject(DynamicDialogConfig); + readonly dialogRef = inject(DynamicDialogRef); + readonly destroyRef = inject(DestroyRef); + readonly fb = inject(FormBuilder); readonly isRegistrationSubmitting = select(RegistriesSelectors.isRegistrationSubmitting); - actions = createDispatchMap({ - registerDraft: RegisterDraft, - }); + actions = createDispatchMap({ registerDraft: RegisterDraft }); SubmitType = SubmitType; showDateControl = false; minEmbargoDate = computed(() => { @@ -43,21 +43,24 @@ export class ConfirmRegistrationDialogComponent { }); constructor() { - this.form.get('submitOption')!.valueChanges.subscribe((value) => { - this.showDateControl = value === SubmitType.Embargo; - const dateControl = this.form.get('embargoDate'); + this.form + .get('submitOption') + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + this.showDateControl = value === SubmitType.Embargo; + const dateControl = this.form.get('embargoDate'); - if (this.showDateControl) { - dateControl!.enable(); - dateControl!.setValidators(Validators.required); - } else { - dateControl!.disable(); - dateControl!.clearValidators(); - dateControl!.reset(); - } + if (this.showDateControl) { + dateControl!.enable(); + dateControl!.setValidators(Validators.required); + } else { + dateControl!.disable(); + dateControl!.clearValidators(); + dateControl!.reset(); + } - dateControl!.updateValueAndValidity(); - }); + dateControl!.updateValueAndValidity(); + }); } submit(): void { diff --git a/src/app/features/registries/components/custom-step/custom-step.component.spec.ts b/src/app/features/registries/components/custom-step/custom-step.component.spec.ts index 1b287987e..338a3fab0 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.spec.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.spec.ts @@ -1,76 +1,319 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; +import { FormGroup } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { InfoIconComponent } from '@osf/shared/components/info-icon/info-icon.component'; +import { FieldType } from '@osf/shared/enums/field-type.enum'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { FileModel } from '@shared/models/files/file.model'; +import { FilePayloadJsonApi } from '@shared/models/files/file-payload-json-api.model'; +import { PageSchema } from '@shared/models/registration/page-schema.model'; -import { RegistriesSelectors } from '../../store'; +import { RegistriesSelectors, SetUpdatedFields, UpdateStepState } from '../../store'; import { FilesControlComponent } from '../files-control/files-control.component'; import { CustomStepComponent } from './custom-step.component'; import { MOCK_REGISTRIES_PAGE, MOCK_STEPS_DATA } from '@testing/mocks/registries.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; + +type StepsState = Record; + +interface SetupOverrides extends BaseSetupOverrides { + pages?: PageSchema[]; + stepsState?: StepsState; + stepsData?: Record; + filesLink?: string; + projectId?: string; + provider?: string; +} describe('CustomStepComponent', () => { - let component: CustomStepComponent; - let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; + function createPage( + questions: PageSchema['questions'] = [], + sections: PageSchema['sections'] = undefined + ): PageSchema { + return { id: 'p', title: 'P', questions, sections }; + } - const MOCK_PAGE = MOCK_REGISTRIES_PAGE; + function setup(overrides: SetupOverrides = {}) { + const routeBuilder = ActivatedRouteMockBuilder.create().withParams(overrides.routeParams ?? { step: 1 }); + if (overrides.hasParent === false) { + routeBuilder.withNoParent(); + } - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ step: 1 }).build(); - mockRouter = RouterMockBuilder.create().withUrl('/registries/drafts/id/1').build(); + const mockRouter = RouterMockBuilder.create().withUrl('/registries/drafts/id/1').build(); + const toastMock = ToastServiceMock.simple(); - await TestBed.configureTestingModule({ - imports: [CustomStepComponent, OSFTestingModule, ...MockComponents(InfoIconComponent, FilesControlComponent)], + const defaultSignals: SignalOverride[] = [ + { selector: RegistriesSelectors.getPagesSchema, value: overrides.pages ?? [MOCK_REGISTRIES_PAGE] }, + { selector: RegistriesSelectors.getStepsState, value: overrides.stepsState ?? {} }, + ]; + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); + + TestBed.configureTestingModule({ + imports: [CustomStepComponent, ...MockComponents(InfoIconComponent, FilesControlComponent)], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), + provideOSFCore(), + MockProvider(ToastService, toastMock), + MockProvider(ActivatedRoute, routeBuilder.build()), MockProvider(Router, mockRouter), - provideMockStore({ - signals: [ - { selector: RegistriesSelectors.getPagesSchema, value: [MOCK_PAGE] }, - { selector: RegistriesSelectors.getStepsState, value: {} }, - ], - }), + provideMockStore({ signals }), ], - }).compileComponents(); - - fixture = TestBed.createComponent(CustomStepComponent); - component = fixture.componentInstance; + }); - fixture.componentRef.setInput('stepsData', MOCK_STEPS_DATA); - fixture.componentRef.setInput('filesLink', 'files-link'); - fixture.componentRef.setInput('projectId', 'project'); - fixture.componentRef.setInput('provider', 'provider'); + const store = TestBed.inject(Store); + const fixture = TestBed.createComponent(CustomStepComponent); + const component = fixture.componentInstance; + fixture.componentRef.setInput('stepsData', overrides.stepsData ?? MOCK_STEPS_DATA); + fixture.componentRef.setInput('filesLink', overrides.filesLink ?? 'files-link'); + fixture.componentRef.setInput('projectId', overrides.projectId ?? 'project'); + fixture.componentRef.setInput('provider', overrides.provider ?? 'provider'); fixture.detectChanges(); - }); + return { component, fixture, store, routeBuilder, mockRouter, toastMock }; + } it('should create', () => { + const { component } = setup(); expect(component).toBeTruthy(); }); - it('should initialize stepForm when page available', () => { - expect(component['stepForm']).toBeDefined(); - expect(Object.keys(component['stepForm'].controls)).toContain('field1'); - expect(Object.keys(component['stepForm'].controls)).toContain('field2'); - }); - - it('should navigate back when goBack called on first step', () => { + it('should emit back on first step', () => { + const { component } = setup(); const backSpy = jest.spyOn(component.back, 'emit'); component.goBack(); expect(backSpy).toHaveBeenCalled(); }); - it('should navigate next when goNext called with within pages', () => { - Object.defineProperty(component, 'pages', { value: () => [MOCK_REGISTRIES_PAGE, MOCK_REGISTRIES_PAGE] }); + it('should navigate to previous step on step > 1', () => { + const { component, mockRouter } = setup(); + component.step.set(2); + component.goBack(); + expect(mockRouter.navigate).toHaveBeenCalledWith(['../', 1], { relativeTo: expect.anything() }); + }); + + it('should navigate to next step within pages', () => { + const { component, mockRouter } = setup({ pages: [MOCK_REGISTRIES_PAGE, MOCK_REGISTRIES_PAGE] }); + component.goNext(); + expect(mockRouter.navigate).toHaveBeenCalledWith(['../', 2], { relativeTo: expect.anything() }); + }); + + it('should emit next on last step', () => { + const { component } = setup(); + const nextSpy = jest.spyOn(component.next, 'emit'); + component.step.set(1); component.goNext(); - expect(mockRouter.navigate).toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalled(); + }); + + it('should dispatch updateStepState on ngOnDestroy', () => { + const { component, store } = setup(); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(UpdateStepState)); + }); + + it('should emit updateAction and dispatch setUpdatedFields when fields changed', () => { + const { component, store } = setup(); + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + component['stepForm'].get('field1')?.setValue('changed'); + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(emitSpy).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new SetUpdatedFields({ field1: 'changed' })); + }); + + it('should not emit updateAction when no fields changed', () => { + const { component, store } = setup(); + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(emitSpy).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SetUpdatedFields)); + }); + + it('should skip saveStepState when form has no controls', () => { + const { component, store } = setup(); + component.stepForm = new FormGroup({}); + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should attach file and emit updateAction', () => { + const { component } = setup(); + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + const mockFile = { + id: 'new-file', + name: 'new.txt', + links: { html: 'http://html', download: 'http://dl' }, + extra: { hashes: { sha256: 'abc' } }, + } as FileModel; + + component.onAttachFile(mockFile, 'field1'); + + expect(component.attachedFiles['field1'].length).toBe(1); + expect(emitSpy).toHaveBeenCalled(); + expect(emitSpy.mock.calls[0][0]['field1'][0].file_id).toBe('new-file'); + }); + + it('should not attach duplicate file', () => { + const { component } = setup(); + component.attachedFiles['field1'] = [{ file_id: 'file-1', name: 'existing.txt' }]; + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + + component.onAttachFile({ id: 'file-1' } as FileModel, 'field1'); + + expect(component.attachedFiles['field1'].length).toBe(1); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should show warning when attachment limit reached', () => { + const { component, toastMock } = setup(); + component.attachedFiles['field1'] = Array.from({ length: 5 }, (_, i) => ({ file_id: `f-${i}`, name: `f-${i}` })); + + const mockFile = { + id: 'new', + name: 'new.txt', + links: { html: '', download: '' }, + extra: { hashes: { sha256: '', md5: '' } }, + } as FileModel; + component.onAttachFile(mockFile, 'field1'); + + expect(toastMock.showWarn).toHaveBeenCalledWith('shared.files.limitText'); + expect(component.attachedFiles['field1'].length).toBe(5); + }); + + it('should remove file and emit updateAction', () => { + const { component } = setup(); + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + component.attachedFiles['field1'] = [ + { file_id: 'f1', name: 'a' }, + { file_id: 'f2', name: 'b' }, + ]; + + component.removeFromAttachedFiles({ file_id: 'f1', name: 'a' }, 'field1'); + + expect(component.attachedFiles['field1'].length).toBe(1); + expect(component.attachedFiles['field1'][0].file_id).toBe('f2'); + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should skip non-existent questionKey', () => { + const { component } = setup(); + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + component.removeFromAttachedFiles({ file_id: 'f1' }, 'nonexistent'); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should save step state and update step on route param change', () => { + const { component, store, routeBuilder } = setup(); + (store.dispatch as jest.Mock).mockClear(); + routeBuilder.withParams({ step: 2 }); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(UpdateStepState)); + expect(component.step()).toBe(2); + }); + + it('should mark form touched when stepsState has invalid for current step', () => { + const { component } = setup({ + stepsState: { 1: { invalid: true, touched: true } }, + }); + expect(component['stepForm'].get('field1')?.touched).toBe(true); + }); + + it('should initialize checkbox control with empty array default', () => { + const { component } = setup({ + pages: [ + createPage([ + { id: 'q', displayText: '', responseKey: 'cbField', fieldType: FieldType.Checkbox, required: true }, + ]), + ], + stepsData: {}, + }); + expect(component['stepForm'].get('cbField')?.value).toEqual([]); + }); + + it('should initialize radio control with required validator', () => { + const { component } = setup({ + pages: [ + createPage([ + { id: 'q', displayText: '', responseKey: 'radioField', fieldType: FieldType.Radio, required: true }, + ]), + ], + stepsData: {}, + }); + expect(component['stepForm'].get('radioField')?.valid).toBe(false); + }); + + it('should initialize file control and populate attachedFiles', () => { + const files: FilePayloadJsonApi[] = [ + { file_id: 'f1', file_name: 'doc.pdf', file_urls: { html: '', download: '' }, file_hashes: { sha256: '' } }, + ]; + const { component } = setup({ + pages: [ + createPage([ + { id: 'q', displayText: '', responseKey: 'fileField', fieldType: FieldType.File, required: false }, + ]), + ], + stepsData: { fileField: files }, + }); + + expect(component.attachedFiles['fileField'].length).toBe(1); + expect(component.attachedFiles['fileField'][0].name).toBe('doc.pdf'); + }); + + it('should skip unknown field types', () => { + const { component } = setup({ + pages: [ + createPage([ + { id: 'q', displayText: '', responseKey: 'unknownField', fieldType: 'unknown' as FieldType, required: false }, + ]), + ], + stepsData: {}, + }); + expect(component['stepForm'].get('unknownField')).toBeNull(); + }); + + it('should include section questions', () => { + const { component } = setup({ + pages: [ + createPage( + [], + [ + { + id: 's1', + title: 'S', + questions: [ + { id: 'q', displayText: '', responseKey: 'secField', fieldType: FieldType.Text, required: false }, + ], + }, + ] + ), + ], + stepsData: { secField: 'val' }, + }); + + expect(component['stepForm'].get('secField')).toBeDefined(); + expect(component['stepForm'].get('secField')?.value).toBe('val'); }); }); diff --git a/src/app/features/registries/components/custom-step/custom-step.component.ts b/src/app/features/registries/components/custom-step/custom-step.component.ts index 98900e02c..357bc71b5 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.ts @@ -26,7 +26,7 @@ import { signal, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { InfoIconComponent } from '@osf/shared/components/info-icon/info-icon.component'; @@ -41,28 +41,27 @@ import { FilePayloadJsonApi } from '@shared/models/files/file-payload-json-api.m import { PageSchema } from '@shared/models/registration/page-schema.model'; import { FilesMapper } from '../../mappers/files.mapper'; +import { AttachedFile } from '../../models/attached-file.model'; import { RegistriesSelectors, SetUpdatedFields, UpdateStepState } from '../../store'; import { FilesControlComponent } from '../files-control/files-control.component'; @Component({ selector: 'osf-custom-step', imports: [ + Button, Card, - Textarea, - RadioButton, - FormsModule, Checkbox, - TranslatePipe, + Chip, + Inplace, InputText, + Message, + RadioButton, + Textarea, + ReactiveFormsModule, NgTemplateOutlet, - Inplace, - TranslatePipe, InfoIconComponent, - Button, - ReactiveFormsModule, - Message, FilesControlComponent, - Chip, + TranslatePipe, ], templateUrl: './custom-step.component.html', styleUrl: './custom-step.component.scss', @@ -80,38 +79,100 @@ export class CustomStepComponent implements OnDestroy { updateAction = output>(); back = output(); next = output(); + private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly fb = inject(FormBuilder); private readonly destroyRef = inject(DestroyRef); - private toastService = inject(ToastService); + private readonly toastService = inject(ToastService); readonly pages = select(RegistriesSelectors.getPagesSchema); - readonly FieldType = FieldType; readonly stepsState = select(RegistriesSelectors.getStepsState); - readonly actions = createDispatchMap({ + private readonly actions = createDispatchMap({ updateStepState: UpdateStepState, setUpdatedFields: SetUpdatedFields, }); + readonly FieldType = FieldType; readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; step = signal(this.route.snapshot.params['step']); currentPage = computed(() => this.pages()[this.step() - 1]); - radio = null; + stepForm: FormGroup = this.fb.group({}); + attachedFiles: Record = {}; - stepForm!: FormGroup; + constructor() { + this.setupRouteWatcher(); + this.setupPageFormInit(); + } - attachedFiles: Record[]> = {}; + ngOnDestroy(): void { + this.saveStepState(); + } - constructor() { + onAttachFile(file: FileModel, questionKey: string): void { + this.attachedFiles[questionKey] = this.attachedFiles[questionKey] || []; + + if (this.attachedFiles[questionKey].some((f) => f.file_id === file.id)) { + return; + } + + if (this.attachedFiles[questionKey].length >= FILE_COUNT_ATTACHMENTS_LIMIT) { + this.toastService.showWarn('shared.files.limitText'); + return; + } + + this.attachedFiles[questionKey] = [...this.attachedFiles[questionKey], file]; + this.stepForm.patchValue({ [questionKey]: this.attachedFiles[questionKey] }); + + const otherFormValues = { ...this.stepForm.value }; + delete otherFormValues[questionKey]; + this.updateAction.emit({ + [questionKey]: this.mapFilesToPayload(this.attachedFiles[questionKey]), + ...otherFormValues, + }); + } + + removeFromAttachedFiles(file: AttachedFile, questionKey: string): void { + if (!this.attachedFiles[questionKey]) { + return; + } + + this.attachedFiles[questionKey] = this.attachedFiles[questionKey].filter((f) => f.file_id !== file.file_id); + this.stepForm.patchValue({ [questionKey]: this.attachedFiles[questionKey] }); + this.updateAction.emit({ + [questionKey]: this.mapFilesToPayload(this.attachedFiles[questionKey]), + }); + } + + goBack(): void { + const previousStep = this.step() - 1; + if (previousStep > 0) { + this.router.navigate(['../', previousStep], { relativeTo: this.route }); + } else { + this.back.emit(); + } + } + + goNext(): void { + const nextStep = this.step() + 1; + if (nextStep <= this.pages().length) { + this.router.navigate(['../', nextStep], { relativeTo: this.route }); + } else { + this.next.emit(); + } + } + + private setupRouteWatcher() { this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { - this.updateStepState(); + this.saveStepState(); this.step.set(+params['step']); }); + } + private setupPageFormInit() { effect(() => { const page = this.currentPage(); if (page) { @@ -122,138 +183,78 @@ export class CustomStepComponent implements OnDestroy { private initStepForm(page: PageSchema): void { this.stepForm = this.fb.group({}); - let questions = page.questions || []; - if (page.sections?.length) { - questions = [...questions, ...page.sections.flatMap((section) => section.questions ?? [])]; - } - questions?.forEach((q) => { + const questions = [ + ...(page.questions || []), + ...(page.sections?.flatMap((section) => section.questions ?? []) ?? []), + ]; + + questions.forEach((q) => { const controlName = q.responseKey as string; - let control: FormControl; - - switch (q.fieldType) { - case FieldType.Text: - case FieldType.TextArea: - control = this.fb.control(this.stepsData()[controlName], { - validators: q.required ? [CustomValidators.requiredTrimmed()] : [], - }); - break; - - case FieldType.Checkbox: - control = this.fb.control(this.stepsData()[controlName] || [], { - validators: q.required ? [Validators.required] : [], - }); - break; - - case FieldType.Radio: - case FieldType.Select: - control = this.fb.control(this.stepsData()[controlName], { - validators: q.required ? [Validators.required] : [], - }); - break; - - case FieldType.File: - control = this.fb.control(this.stepsData()[controlName] || [], { - validators: q.required ? [Validators.required] : [], - }); - this.attachedFiles[controlName] = - this.stepsData()[controlName]?.map((file: FilePayloadJsonApi) => ({ ...file, name: file.file_name })) || []; - break; - - default: - return; + const control = this.createControl(q.fieldType!, controlName, q.required); + if (!control) return; + + if (q.fieldType === FieldType.File) { + this.attachedFiles[controlName] = + this.stepsData()[controlName]?.map((file: FilePayloadJsonApi) => ({ ...file, name: file.file_name })) || []; } this.stepForm.addControl(controlName, control); }); + if (this.stepsState()?.[this.step()]?.invalid) { this.stepForm.markAllAsTouched(); } } - private updateDraft() { - const changedFields = findChangedFields(this.stepForm.value, this.stepsData()); - if (Object.keys(changedFields).length > 0) { - this.actions.setUpdatedFields(changedFields); - this.updateAction.emit(this.stepForm.value); - } - } + private createControl(fieldType: FieldType, controlName: string, required: boolean): FormControl | null { + const value = this.stepsData()[controlName]; - private updateStepState() { - if (this.stepForm) { - this.updateDraft(); - this.stepForm.markAllAsTouched(); - this.actions.updateStepState(this.step(), this.stepForm.invalid, true); - } - } + switch (fieldType) { + case FieldType.Text: + case FieldType.TextArea: + return this.fb.control(value, { + validators: required ? [CustomValidators.requiredTrimmed()] : [], + }); - onAttachFile(file: FileModel, questionKey: string): void { - this.attachedFiles[questionKey] = this.attachedFiles[questionKey] || []; + case FieldType.Checkbox: + case FieldType.File: + return this.fb.control(value || [], { + validators: required ? [Validators.required] : [], + }); - if (!this.attachedFiles[questionKey].some((f) => f.file_id === file.id)) { - if (this.attachedFiles[questionKey].length >= FILE_COUNT_ATTACHMENTS_LIMIT) { - this.toastService.showWarn('shared.files.limitText'); - return; - } - this.attachedFiles[questionKey].push(file); - this.stepForm.patchValue({ - [questionKey]: [...(this.attachedFiles[questionKey] || []), file], - }); - const otherFormValues = { ...this.stepForm.value }; - delete otherFormValues[questionKey]; - this.updateAction.emit({ - [questionKey]: [ - ...this.attachedFiles[questionKey].map((f) => { - if (f.file_id) { - const { name: _, ...payload } = f; - return payload; - } - return FilesMapper.toFilePayload(f as FileModel); - }), - ], - ...otherFormValues, - }); - } - } + case FieldType.Radio: + case FieldType.Select: + return this.fb.control(value, { + validators: required ? [Validators.required] : [], + }); - removeFromAttachedFiles(file: Partial, questionKey: string): void { - if (this.attachedFiles[questionKey]) { - this.attachedFiles[questionKey] = this.attachedFiles[questionKey].filter((f) => f.file_id !== file.file_id); - this.stepForm.patchValue({ - [questionKey]: this.attachedFiles[questionKey], - }); - this.updateAction.emit({ - [questionKey]: [ - ...this.attachedFiles[questionKey].map((f) => { - if (f.file_id) { - const { name: _, ...payload } = f; - return payload; - } - return FilesMapper.toFilePayload(f as FileModel); - }), - ], - }); + default: + return null; } } - goBack(): void { - const previousStep = this.step() - 1; - if (previousStep > 0) { - this.router.navigate(['../', previousStep], { relativeTo: this.route }); - } else { - this.back.emit(); + private saveStepState() { + if (!this.stepForm.controls || !Object.keys(this.stepForm.controls).length) { + return; } - } - goNext(): void { - const nextStep = this.step() + 1; - if (nextStep <= this.pages().length) { - this.router.navigate(['../', nextStep], { relativeTo: this.route }); - } else { - this.next.emit(); + const changedFields = findChangedFields(this.stepForm.value, this.stepsData()); + if (Object.keys(changedFields).length > 0) { + this.actions.setUpdatedFields(changedFields); + this.updateAction.emit(this.stepForm.value); } + + this.stepForm.markAllAsTouched(); + this.actions.updateStepState(this.step(), this.stepForm.invalid, true); } - ngOnDestroy(): void { - this.updateStepState(); + private mapFilesToPayload(files: AttachedFile[]): FilePayloadJsonApi[] { + return files.map((f) => { + if (f.file_id) { + const { name: _, ...payload } = f; + return payload as FilePayloadJsonApi; + } + return FilesMapper.toFilePayload(f as FileModel); + }); } } diff --git a/src/app/features/registries/components/drafts/drafts.component.spec.ts b/src/app/features/registries/components/drafts/drafts.component.spec.ts index 98221ba90..7325770a2 100644 --- a/src/app/features/registries/components/drafts/drafts.component.spec.ts +++ b/src/app/features/registries/components/drafts/drafts.component.spec.ts @@ -1,76 +1,452 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { LoaderService } from '@osf/shared/services/loader.service'; import { ContributorsSelectors } from '@osf/shared/stores/contributors'; import { SubjectsSelectors } from '@osf/shared/stores/subjects'; -import { RegistriesSelectors } from '../../store'; +import { ClearState, RegistriesSelectors } from '../../store'; import { DraftsComponent } from './drafts.component'; -import { MOCK_DRAFT_REGISTRATION, MOCK_PAGES_SCHEMA } from '@testing/mocks/registries.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { + MOCK_DRAFT_REGISTRATION, + MOCK_PAGES_SCHEMA, + MOCK_REGISTRIES_PAGE_WITH_SECTIONS, + MOCK_STEPS_DATA, +} from '@testing/mocks/registries.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -describe('DraftsComponent', () => { - let component: DraftsComponent; - let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; +interface SetupOverrides { + routeParams?: Record; + firstChildParams?: Record | null; + routerUrl?: string; + routerEvents?: unknown; + selectorOverrides?: { selector: unknown; value: unknown }[]; +} - const MOCK_PAGES = MOCK_PAGES_SCHEMA; - const MOCK_DRAFT = MOCK_DRAFT_REGISTRATION; +function setup(overrides: SetupOverrides = {}) { + const routeBuilder = ActivatedRouteMockBuilder.create().withParams(overrides.routeParams ?? { id: 'reg-1' }); + const mockActivatedRoute = routeBuilder.build(); - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'reg-1' }).build(); - mockRouter = RouterMockBuilder.create().withUrl('/registries/drafts/reg-1/1').build(); + if (overrides.firstChildParams === null) { + (mockActivatedRoute as unknown as Record)['firstChild'] = null; + (mockActivatedRoute.snapshot as unknown as Record)['firstChild'] = null; + } else { + const childParams = overrides.firstChildParams ?? { id: 'reg-1', step: '1' }; + (mockActivatedRoute.snapshot as unknown as Record)['firstChild'] = { params: childParams }; + (mockActivatedRoute as unknown as Record)['firstChild'] = { snapshot: { params: childParams } }; + } + + const mockRouter = RouterMockBuilder.create() + .withUrl(overrides.routerUrl ?? '/registries/drafts/reg-1/1') + .build(); + if (overrides.routerEvents !== undefined) { + mockRouter.events = overrides.routerEvents as RouterMockType['events']; + } else { mockRouter.events = of(new NavigationEnd(1, '/', '/')); + } - await TestBed.configureTestingModule({ - imports: [DraftsComponent, OSFTestingModule, ...MockComponents(StepperComponent, SubHeaderComponent)], - providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), - MockProvider(Router, mockRouter), - provideMockStore({ - signals: [ - { selector: RegistriesSelectors.getPagesSchema, value: MOCK_PAGES }, - { selector: RegistriesSelectors.getDraftRegistration, value: MOCK_DRAFT }, - { selector: RegistriesSelectors.getStepsState, value: {} }, - { selector: RegistriesSelectors.getStepsData, value: {} }, - { selector: ContributorsSelectors.getContributors, value: [] }, - { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, - ], - }), - ], - }).compileComponents(); + const defaultSignals: { selector: unknown; value: unknown }[] = [ + { selector: RegistriesSelectors.getPagesSchema, value: MOCK_PAGES_SCHEMA }, + { selector: RegistriesSelectors.getDraftRegistration, value: MOCK_DRAFT_REGISTRATION }, + { selector: RegistriesSelectors.getRegistrationLicense, value: { id: 'mit' } }, + { selector: RegistriesSelectors.getStepsState, value: {} }, + { selector: RegistriesSelectors.getStepsData, value: {} }, + { selector: ContributorsSelectors.getContributors, value: [{ id: 'c1' }] }, + { selector: SubjectsSelectors.getSelectedSubjects, value: [{ id: 's1' }] }, + ]; + + const signals = overrides.selectorOverrides + ? defaultSignals.map((s) => { + const override = overrides.selectorOverrides!.find((o) => o.selector === s.selector); + return override ? { ...s, value: override.value } : s; + }) + : defaultSignals; + + TestBed.configureTestingModule({ + imports: [DraftsComponent, ...MockComponents(StepperComponent, SubHeaderComponent)], + providers: [ + provideOSFCore(), + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + MockProvider(LoaderService, new LoaderServiceMock()), + provideMockStore({ signals }), + ], + }); + + const store = TestBed.inject(Store); + const fixture = TestBed.createComponent(DraftsComponent); + const component = fixture.componentInstance; + + return { + fixture, + component, + store, + mockRouter: TestBed.inject(Router) as unknown as RouterMockType, + mockActivatedRoute, + }; +} + +describe('DraftsComponent', () => { + let component: DraftsComponent; + let fixture: ComponentFixture; + let store: Store; + let mockRouter: RouterMockType; - fixture = TestBed.createComponent(DraftsComponent); - component = fixture.componentInstance; + beforeEach(() => { + const result = setup(); + fixture = result.fixture; + component = result.component; + store = result.store; + mockRouter = result.mockRouter; }); it('should create', () => { expect(component).toBeTruthy(); }); + it('should resolve registrationId from route firstChild', () => { + expect(component.registrationId).toBe('reg-1'); + }); + it('should compute isReviewPage from router url', () => { expect(component.isReviewPage).toBe(false); - const router = TestBed.inject(Router) as any; - router.url = '/registries/drafts/reg-1/review'; + (mockRouter as unknown as Record)['url'] = '/registries/drafts/reg-1/review'; expect(component.isReviewPage).toBe(true); }); it('should build steps from pages and defaults', () => { const steps = component.steps(); - expect(Array.isArray(steps)).toBe(true); expect(steps.length).toBe(3); + expect(steps[0].routeLink).toBe('metadata'); + expect(steps[1].label).toBe('Page 1'); + expect(steps[2].routeLink).toBe('review'); + }); + + it('should set currentStepIndex from route params', () => { + expect(component.currentStepIndex()).toBe(1); + }); + + it('should compute currentStep from steps and currentStepIndex', () => { expect(component.currentStep()).toBeDefined(); + expect(component.currentStep().label).toBe('Page 1'); + }); + + it('should compute isMetaDataInvalid as false when all fields present', () => { + expect(component.isMetaDataInvalid()).toBe(false); + }); + + it('should navigate and update currentStepIndex on stepChange', () => { + component.stepChange({ index: 0, label: 'Metadata', value: '' }); + + expect(component.currentStepIndex()).toBe(0); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/drafts/reg-1/', 'metadata']); + }); + + it('should dispatch clearState on destroy', () => { + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(store.dispatch).toHaveBeenCalledWith(new ClearState()); + }); + + it('should compute isMetaDataInvalid as true when title is missing', () => { + const { component: c } = setup({ + selectorOverrides: [ + { selector: RegistriesSelectors.getDraftRegistration, value: { ...MOCK_DRAFT_REGISTRATION, title: '' } }, + ], + }); + + expect(c.isMetaDataInvalid()).toBe(true); + }); + + it('should compute isMetaDataInvalid as true when subjects are empty', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: SubjectsSelectors.getSelectedSubjects, value: [] }], + }); + + expect(c.isMetaDataInvalid()).toBe(true); + }); + + it('should set metadata step as invalid when license is missing', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationLicense, value: null }], + }); + + const steps = c.steps(); + expect(steps[0].invalid).toBe(true); + }); + + it('should dispatch getDraftRegistration when draftRegistration is null', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getDraftRegistration, value: null }], + }); + + expect(c).toBeTruthy(); + }); + + it('should dispatch getContributors when contributors list is empty', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: ContributorsSelectors.getContributors, value: [] }], + }); + + expect(c).toBeTruthy(); + }); + + it('should dispatch getSubjects when selectedSubjects list is empty', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: SubjectsSelectors.getSelectedSubjects, value: [] }], + }); + + expect(c).toBeTruthy(); + }); + + it('should dispatch all actions when all initial data is missing', () => { + const { component: c } = setup({ + selectorOverrides: [ + { selector: RegistriesSelectors.getDraftRegistration, value: null }, + { selector: ContributorsSelectors.getContributors, value: [] }, + { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, + ], + }); + + expect(c).toBeTruthy(); + }); + + it('should hide loader after schema blocks are fetched', fakeAsync(() => { + fixture.detectChanges(); + tick(); + + const loaderService = TestBed.inject(LoaderService); + expect(loaderService.hide).toHaveBeenCalled(); + })); + + it('should not fetch schema blocks when draft has no registrationSchemaId', () => { + const { fixture: f } = setup({ + selectorOverrides: [ + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...MOCK_DRAFT_REGISTRATION, registrationSchemaId: '' }, + }, + ], + }); + + f.detectChanges(); + + const loaderService = TestBed.inject(LoaderService); + expect(loaderService.hide).not.toHaveBeenCalled(); + }); + + it('should set currentStepIndex to pages.length + 1 on review navigation', () => { + const { component: c } = setup({ + routerUrl: '/registries/drafts/reg-1/review', + firstChildParams: null, + }); + + expect(c.currentStepIndex()).toBe(MOCK_PAGES_SCHEMA.length + 1); + }); + + it('should reset currentStepIndex to 0 when no step and not review', () => { + const { component: c } = setup({ + routerUrl: '/registries/drafts/reg-1/metadata', + firstChildParams: { id: 'reg-1' }, + }); + + expect(c.currentStepIndex()).toBe(0); + }); + + it('should set currentStepIndex from step param on navigation', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '2' }, + }); + + expect(c.currentStepIndex()).toBe(2); + }); + + it('should sync currentStepIndex to review step when on review page', () => { + const { component: c } = setup({ + routerUrl: '/registries/drafts/reg-1/review', + firstChildParams: null, + }); + + expect(c.currentStepIndex()).toBe(MOCK_PAGES_SCHEMA.length + 1); + }); + + it('should include questions from sections when building steps', () => { + const pagesWithSections = [...MOCK_PAGES_SCHEMA, MOCK_REGISTRIES_PAGE_WITH_SECTIONS]; + + const { component: c } = setup({ + selectorOverrides: [ + { selector: RegistriesSelectors.getPagesSchema, value: pagesWithSections }, + { selector: RegistriesSelectors.getStepsData, value: { field1: 'v1', field3: 'v3' } }, + ], + }); + + const steps = c.steps(); + expect(steps.length).toBe(4); + expect(steps[2].label).toBe('Page 2'); + expect(steps[2].touched).toBe(true); + }); + + it('should not mark section step as touched when no data for section questions', () => { + const pagesWithSections = [...MOCK_PAGES_SCHEMA, MOCK_REGISTRIES_PAGE_WITH_SECTIONS]; + + const { component: c } = setup({ + selectorOverrides: [ + { selector: RegistriesSelectors.getPagesSchema, value: pagesWithSections }, + { selector: RegistriesSelectors.getStepsData, value: {} }, + ], + }); + + const steps = c.steps(); + expect(steps[2].touched).toBe(false); + }); + + it('should mark step as invalid when required field has empty array', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '2' }, + selectorOverrides: [ + { selector: RegistriesSelectors.getStepsData, value: { field1: [], field2: 'v2' } }, + { selector: RegistriesSelectors.getStepsState, value: { 1: { invalid: true, touched: true } } }, + ], + }); + + const steps = c.steps(); + expect(steps[1].invalid).toBe(true); + }); + + it('should not mark step as invalid when required field has non-empty array', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '2' }, + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: { field1: ['item'], field2: 'v2' } }], + }); + + const steps = c.steps(); + expect(steps[1].invalid).toBe(false); + }); + + it('should not mark step as invalid when required field has truthy value', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '2' }, + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: { field1: 'value', field2: '' } }], + }); + + const steps = c.steps(); + expect(steps[1].invalid).toBe(false); + }); + + it('should mark step as invalid when required field is falsy', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '2' }, + selectorOverrides: [ + { selector: RegistriesSelectors.getStepsData, value: { field1: '', field2: 'v2' } }, + { selector: RegistriesSelectors.getStepsState, value: { 1: { invalid: true, touched: true } } }, + ], + }); + + const steps = c.steps(); + expect(steps[1].invalid).toBe(true); + }); + + it('should detect hasStepData with array data', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: { field1: ['item1'] } }], + }); + + const steps = c.steps(); + expect(steps[1].touched).toBe(true); + }); + + it('should not detect hasStepData with empty array', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: { field1: [] } }], + }); + + const steps = c.steps(); + expect(steps[1].touched).toBe(false); + }); + + it('should validate previous steps when currentStepIndex > 0', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '1' }, + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: { field1: 'v1' } }], + }); + + expect(c.currentStepIndex()).toBe(1); + expect(c).toBeTruthy(); + }); + + it('should not validate steps when currentStepIndex is 0', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '0' }, + routerUrl: '/registries/drafts/reg-1/metadata', + }); + + expect(c.currentStepIndex()).toBe(0); + }); + + it('should validate metadata step as invalid when license is missing', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '1' }, + selectorOverrides: [ + { selector: RegistriesSelectors.getRegistrationLicense, value: null }, + { selector: RegistriesSelectors.getStepsData, value: { field1: 'v1' } }, + ], + }); + + expect(c.isMetaDataInvalid()).toBe(true); + }); + + it('should validate metadata step as invalid when description is missing', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '1' }, + selectorOverrides: [ + { selector: RegistriesSelectors.getDraftRegistration, value: { ...MOCK_DRAFT_REGISTRATION, description: '' } }, + { selector: RegistriesSelectors.getStepsData, value: { field1: 'v1' } }, + ], + }); + + expect(c.isMetaDataInvalid()).toBe(true); + }); + + it('should default registrationId to empty string when no firstChild', () => { + const { component: c } = setup({ + routerUrl: '/registries/drafts/', + firstChildParams: null, + }); + + expect(c.registrationId).toBe(''); + }); + + it('should default currentStepIndex to 0 when step param is absent', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1' }, + routerUrl: '/registries/drafts/reg-1/metadata', + }); + + expect(c.currentStepIndex()).toBe(0); + }); + + it('should mark step as touched when stepsData has matching keys', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: MOCK_STEPS_DATA }], + }); + + const steps = c.steps(); + expect(steps[1].touched).toBe(true); }); }); diff --git a/src/app/features/registries/components/drafts/drafts.component.ts b/src/app/features/registries/components/drafts/drafts.component.ts index a1d9fd448..f1579427a 100644 --- a/src/app/features/registries/components/drafts/drafts.component.ts +++ b/src/app/features/registries/components/drafts/drafts.component.ts @@ -2,7 +2,7 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { filter, tap } from 'rxjs'; +import { filter, switchMap, take } from 'rxjs'; import { ChangeDetectionStrategy, @@ -12,11 +12,10 @@ import { effect, inject, OnDestroy, - Signal, signal, untracked, } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; @@ -37,7 +36,6 @@ import { ClearState, FetchDraft, FetchSchemaBlocks, RegistriesSelectors, UpdateS templateUrl: './drafts.component.html', styleUrl: './drafts.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [TranslateService], }) export class DraftsComponent implements OnDestroy { private readonly router = inject(Router); @@ -48,13 +46,12 @@ export class DraftsComponent implements OnDestroy { readonly pages = select(RegistriesSelectors.getPagesSchema); readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); - stepsState = select(RegistriesSelectors.getStepsState); - readonly stepsData = select(RegistriesSelectors.getStepsData); - selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); - initialContributors = select(ContributorsSelectors.getContributors); - readonly contributors = select(ContributorsSelectors.getContributors); - readonly subjects = select(SubjectsSelectors.getSelectedSubjects); - readonly registrationLicense = select(RegistriesSelectors.getRegistrationLicense); + readonly stepsState = select(RegistriesSelectors.getStepsState); + + private readonly stepsData = select(RegistriesSelectors.getStepsData); + private readonly registrationLicense = select(RegistriesSelectors.getRegistrationLicense); + private readonly selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); + private readonly contributors = select(ContributorsSelectors.getContributors); private readonly actions = createDispatchMap({ getSchemaBlocks: FetchSchemaBlocks, @@ -69,146 +66,161 @@ export class DraftsComponent implements OnDestroy { return this.router.url.includes('/review'); } - isMetaDataInvalid = computed(() => { - return ( + isMetaDataInvalid = computed( + () => !this.draftRegistration()?.title || !this.draftRegistration()?.description || !this.registrationLicense() || !this.selectedSubjects()?.length - ); - }); - - defaultSteps: StepOption[] = []; - - isLoaded = false; + ); - steps: Signal = computed(() => { + steps = computed(() => { const stepState = this.stepsState(); const stepData = this.stepsData(); - this.defaultSteps = DEFAULT_STEPS.map((step) => ({ - ...step, - label: this.translateService.instant(step.label), - invalid: stepState?.[step.index]?.invalid || false, + + const metadataStep: StepOption = { + ...DEFAULT_STEPS[0], + label: this.translateService.instant(DEFAULT_STEPS[0].label), + invalid: this.isMetaDataInvalid(), + touched: true, + }; + + const customSteps: StepOption[] = this.pages().map((page, index) => ({ + index: index + 1, + label: page.title, + value: page.id, + routeLink: `${index + 1}`, + invalid: stepState?.[index + 1]?.invalid || false, + touched: stepState?.[index + 1]?.touched || this.hasStepData(page, stepData), })); - this.defaultSteps[0].invalid = this.isMetaDataInvalid(); - this.defaultSteps[0].touched = true; - const customSteps = this.pages().map((page, index) => { - const pageStep = this.pages()[index]; - const allQuestions = this.getAllQuestions(pageStep); - const wasTouched = - allQuestions?.some((question) => { - const questionData = stepData[question.responseKey!]; - return Array.isArray(questionData) ? questionData.length : questionData; - }) || false; - return { - index: index + 1, - label: page.title, - value: page.id, - routeLink: `${index + 1}`, - invalid: stepState?.[index + 1]?.invalid || false, - touched: stepState?.[index + 1]?.touched || wasTouched, - }; - }); - return [ - this.defaultSteps[0], - ...customSteps, - { ...this.defaultSteps[1], index: customSteps.length + 1, invalid: false }, - ]; + const reviewStep: StepOption = { + ...DEFAULT_STEPS[1], + label: this.translateService.instant(DEFAULT_STEPS[1].label), + index: customSteps.length + 1, + invalid: false, + }; + + return [metadataStep, ...customSteps, reviewStep]; }); + registrationId = this.route.snapshot.firstChild?.params['id'] || ''; + currentStepIndex = signal( this.route.snapshot.firstChild?.params['step'] ? +this.route.snapshot.firstChild?.params['step'] : 0 ); currentStep = computed(() => this.steps()[this.currentStepIndex()]); - registrationId = this.route.snapshot.firstChild?.params['id'] || ''; - constructor() { + this.loadInitialData(); + this.setupSchemaLoader(); + this.setupRouteWatcher(); + this.setupReviewStepSync(); + this.setupStepValidation(); + } + + ngOnDestroy(): void { + this.actions.clearState(); + } + + stepChange(step: StepOption): void { + this.currentStepIndex.set(step.index); + this.router.navigate([`/registries/drafts/${this.registrationId}/`, this.steps()[step.index].routeLink]); + } + + private loadInitialData() { + this.loaderService.show(); + + if (!this.draftRegistration()) { + this.actions.getDraftRegistration(this.registrationId); + } + + if (!this.contributors()?.length) { + this.actions.getContributors(this.registrationId, ResourceType.DraftRegistration); + } + + if (!this.selectedSubjects()?.length) { + this.actions.getSubjects(this.registrationId, ResourceType.DraftRegistration); + } + } + + private setupSchemaLoader() { + toObservable(this.draftRegistration) + .pipe( + filter((draft) => !!draft?.registrationSchemaId), + take(1), + switchMap((draft) => this.actions.getSchemaBlocks(draft!.registrationSchemaId)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.loaderService.hide()); + } + + private setupRouteWatcher() { this.router.events .pipe( - takeUntilDestroyed(this.destroyRef), - filter((event): event is NavigationEnd => event instanceof NavigationEnd) + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef) ) .subscribe(() => { const step = this.route.firstChild?.snapshot.params['step']; if (step) { this.currentStepIndex.set(+step); } else if (this.isReviewPage) { - const reviewStepIndex = this.pages().length + 1; - this.currentStepIndex.set(reviewStepIndex); + this.currentStepIndex.set(this.pages().length + 1); } else { this.currentStepIndex.set(0); } }); + } - this.loaderService.show(); - if (!this.draftRegistration()) { - this.actions.getDraftRegistration(this.registrationId); - } - if (!this.contributors()?.length) { - this.actions.getContributors(this.registrationId, ResourceType.DraftRegistration); - } - if (!this.subjects()?.length) { - this.actions.getSubjects(this.registrationId, ResourceType.DraftRegistration); - } - effect(() => { - const registrationSchemaId = this.draftRegistration()?.registrationSchemaId; - if (registrationSchemaId && !this.isLoaded) { - this.actions - .getSchemaBlocks(registrationSchemaId || '') - .pipe( - tap(() => { - this.isLoaded = true; - this.loaderService.hide(); - }) - ) - .subscribe(); - } - }); - + private setupReviewStepSync() { effect(() => { const reviewStepIndex = this.pages().length + 1; if (this.isReviewPage) { this.currentStepIndex.set(reviewStepIndex); } }); + } + private setupStepValidation() { effect(() => { const stepState = untracked(() => this.stepsState()); - if (this.currentStepIndex() > 0) { + const currentIndex = this.currentStepIndex(); + + if (currentIndex > 0) { this.actions.updateStepState('0', this.isMetaDataInvalid(), stepState?.[0]?.touched || false); } - if (this.pages().length && this.currentStepIndex() > 0 && this.stepsData()) { - for (let i = 1; i < this.currentStepIndex(); i++) { - const pageStep = this.pages()[i - 1]; - const allQuestions = this.getAllQuestions(pageStep); - const isStepInvalid = - allQuestions?.some((question) => { - const questionData = this.stepsData()[question.responseKey!]; - return question.required && (Array.isArray(questionData) ? !questionData.length : !questionData); - }) || false; - this.actions.updateStepState(i.toString(), isStepInvalid, stepState?.[i]?.touched || false); + + if (this.pages().length && currentIndex > 0 && this.stepsData()) { + for (let i = 1; i < currentIndex; i++) { + const page = this.pages()[i - 1]; + const invalid = this.isPageInvalid(page, this.stepsData()); + this.actions.updateStepState(i.toString(), invalid, stepState?.[i]?.touched || false); } } }); } - stepChange(step: StepOption): void { - this.currentStepIndex.set(step.index); - const pageLink = this.steps()[step.index].routeLink; - this.router.navigate([`/registries/drafts/${this.registrationId}/`, pageLink]); + private getAllQuestions(page: PageSchema): Question[] { + return [...(page?.questions ?? []), ...(page?.sections?.flatMap((section) => section.questions ?? []) ?? [])]; } - private getAllQuestions(pageStep: PageSchema): Question[] { - return [ - ...(pageStep?.questions ?? []), - ...(pageStep?.sections?.flatMap((section) => section.questions ?? []) ?? []), - ]; + private hasStepData(page: PageSchema, stepData: Record): boolean { + return ( + this.getAllQuestions(page).some((question) => { + const data = stepData[question.responseKey!]; + return Array.isArray(data) ? data.length : data; + }) || false + ); } - ngOnDestroy(): void { - this.actions.clearState(); + private isPageInvalid(page: PageSchema, stepData: Record): boolean { + return ( + this.getAllQuestions(page).some((question) => { + const data = stepData[question.responseKey!]; + return question.required && (Array.isArray(data) ? !data.length : !data); + }) || false + ); } } diff --git a/src/app/features/registries/components/files-control/files-control.component.html b/src/app/features/registries/components/files-control/files-control.component.html index bf53c0a2f..8d3350ae2 100644 --- a/src/app/features/registries/components/files-control/files-control.component.html +++ b/src/app/features/registries/components/files-control/files-control.component.html @@ -13,7 +13,7 @@ severity="success" [icon]="'fas fa-plus'" [label]="'files.actions.createFolder' | translate" - (click)="createFolder()" + (onClick)="createFolder()" > @@ -24,7 +24,7 @@ severity="success" [icon]="'fas fa-upload'" [label]="'files.actions.uploadFile' | translate" - (click)="fileInput.click()" + (onClick)="fileInput.click()" > @@ -50,6 +50,8 @@ [viewOnly]="filesViewOnly()" [resourceId]="projectId()" [provider]="provider()" + [selectedFiles]="filesSelection" + (selectFile)="onFileTreeSelected($event)" (entryFileClicked)="selectFile($event)" (uploadFilesConfirmed)="uploadFiles($event)" (loadFiles)="onLoadFiles($event)" diff --git a/src/app/features/registries/components/files-control/files-control.component.spec.ts b/src/app/features/registries/components/files-control/files-control.component.spec.ts index 79257199d..e1a26b51e 100644 --- a/src/app/features/registries/components/files-control/files-control.component.spec.ts +++ b/src/app/features/registries/components/files-control/files-control.component.spec.ts @@ -1,13 +1,26 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { of, Subject } from 'rxjs'; +import { HttpEventType } from '@angular/common/http'; +import { signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { HelpScoutService } from '@core/services/help-scout.service'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { + CreateFolder, + GetFiles, + RegistriesSelectors, + SetFilesIsLoading, + SetRegistriesCurrentFolder, +} from '@osf/features/registries/store'; import { FileUploadDialogComponent } from '@osf/shared/components/file-upload-dialog/file-upload-dialog.component'; +import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; +import { FILE_SIZE_LIMIT } from '@osf/shared/constants/files-limits.const'; +import { FileModel } from '@osf/shared/models/files/file.model'; +import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { FilesService } from '@osf/shared/services/files.service'; @@ -15,57 +28,73 @@ import { ToastService } from '@osf/shared/services/toast.service'; import { FilesControlComponent } from './files-control.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; -import { HelpScoutServiceMockFactory } from '@testing/providers/help-scout.service.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { MockComponentWithSignal } from '@testing/providers/component-provider.mock'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; -describe('Component: File Control', () => { +describe('FilesControlComponent', () => { let component: FilesControlComponent; let fixture: ComponentFixture; - let helpScoutService: HelpScoutService; - let mockFilesService: jest.Mocked; - let mockDialogService: ReturnType; - let mockToastService: ReturnType; - let mockCustomConfirmationService: ReturnType; - const currentFolder = { - links: { newFolder: '/new-folder', upload: '/upload' }, - relationships: { filesLink: '/files-link' }, - } as any; - - beforeEach(async () => { - mockFilesService = { uploadFile: jest.fn(), getFileGuid: jest.fn() } as any; + let store: Store; + let mockFilesService: { uploadFile: jest.Mock; getFileGuid: jest.Mock }; + let mockDialogService: CustomDialogServiceMockType; + let currentFolderSignal: WritableSignal; + let toastService: ToastServiceMockType; + + const CURRENT_FOLDER = { + links: { newFolder: '/new-folder', upload: '/upload', filesLink: '/files-link' }, + } as FileFolderModel; + + beforeEach(() => { + mockFilesService = { uploadFile: jest.fn(), getFileGuid: jest.fn() }; mockDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - mockToastService = ToastServiceMockBuilder.create().build(); - mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); - helpScoutService = HelpScoutServiceMockFactory(); - - await TestBed.configureTestingModule({ - imports: [ - FilesControlComponent, - OSFTestingModule, - ...MockComponents(LoadingSpinnerComponent, FileUploadDialogComponent), - ], + currentFolderSignal = signal(CURRENT_FOLDER); + toastService = ToastServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [FilesControlComponent, ...MockComponents(LoadingSpinnerComponent, FileUploadDialogComponent)], providers: [ + provideOSFCore(), + MockProvider(ToastService, toastService), + MockProvider(CustomConfirmationService), MockProvider(FilesService, mockFilesService), MockProvider(CustomDialogService, mockDialogService), - MockProvider(ToastService, mockToastService), - MockProvider(CustomConfirmationService, mockCustomConfirmationService), - { provide: HelpScoutService, useValue: helpScoutService }, provideMockStore({ signals: [ { selector: RegistriesSelectors.getFiles, value: [] }, { selector: RegistriesSelectors.getFilesTotalCount, value: 0 }, { selector: RegistriesSelectors.isFilesLoading, value: false }, - { selector: RegistriesSelectors.getCurrentFolder, value: currentFolder }, + { selector: RegistriesSelectors.getCurrentFolder, value: currentFolderSignal }, ], }), ], - }).compileComponents(); + }).overrideComponent(FilesControlComponent, { + remove: { imports: [FilesTreeComponent] }, + add: { + imports: [ + MockComponentWithSignal('osf-files-tree', [ + 'files', + 'selectionMode', + 'totalCount', + 'storage', + 'currentFolder', + 'isLoading', + 'scrollHeight', + 'viewOnly', + 'resourceId', + 'provider', + 'selectedFiles', + ]), + ], + }, + }); - helpScoutService = TestBed.inject(HelpScoutService); + store = TestBed.inject(Store); fixture = TestBed.createComponent(FilesControlComponent); component = fixture.componentInstance; fixture.componentRef.setInput('attachedFiles', []); @@ -76,47 +105,148 @@ describe('Component: File Control', () => { fixture.detectChanges(); }); - it('should have a default value', () => { - expect(component.fileIsUploading()).toBeFalsy(); + it('should create with default signal values', () => { + expect(component).toBeTruthy(); + expect(component.fileIsUploading()).toBe(false); + expect(component.progress()).toBe(0); + expect(component.fileName()).toBe(''); }); - it('should called the helpScoutService', () => { - expect(helpScoutService.setResourceType).toHaveBeenCalledWith('files'); + it('should do nothing when no file is selected', () => { + const event = { target: { files: [] } } as unknown as Event; + const uploadSpy = jest.spyOn(component, 'uploadFiles'); + + component.onFileSelected(event); + + expect(uploadSpy).not.toHaveBeenCalled(); + }); + + it('should show warning when file exceeds size limit', () => { + const oversizedFile = new File([''], 'big.bin'); + Object.defineProperty(oversizedFile, 'size', { value: FILE_SIZE_LIMIT }); + const event = { target: { files: [oversizedFile] } } as unknown as Event; + + component.onFileSelected(event); + + expect(toastService.showWarn).toHaveBeenCalledWith('shared.files.limitText'); + }); + + it('should upload valid file', () => { + const file = new File(['data'], 'test.txt'); + const event = { target: { files: [file] } } as unknown as Event; + const uploadSpy = jest.spyOn(component, 'uploadFiles').mockImplementation(); + + component.onFileSelected(event); + + expect(uploadSpy).toHaveBeenCalledWith(file); }); - it('should open create folder dialog and trigger files update', () => { + it('should open dialog and dispatch createFolder on confirm', () => { const onClose$ = new Subject(); - (mockDialogService.open as any).mockReturnValue({ onClose: onClose$ }); - const updateSpy = jest.spyOn(component, 'updateFilesList').mockReturnValue(of(void 0)); + mockDialogService.open.mockReturnValue({ onClose: onClose$ } as any); + (store.dispatch as jest.Mock).mockClear(); component.createFolder(); expect(mockDialogService.open).toHaveBeenCalled(); onClose$.next('New Folder'); - expect(updateSpy).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new CreateFolder('/new-folder', 'New Folder')); }); - it('should upload files, update progress and select uploaded file', () => { - const file = new File(['data'], 'test.txt', { type: 'text/plain' }); - const progress = { type: 1, loaded: 50, total: 100 } as any; - const response = { type: 4, body: { data: { id: 'files/abc' } } } as any; + it('should upload file, track progress, and select uploaded file', () => { + const file = new File(['data'], 'test.txt'); + const progress = { type: HttpEventType.UploadProgress, loaded: 50, total: 100 }; + const response = { type: HttpEventType.Response, body: { data: { id: 'files/abc' } } }; - (mockFilesService.uploadFile as any).mockReturnValue(of(progress, response)); - (mockFilesService.getFileGuid as any).mockReturnValue(of({ id: 'abc' })); + mockFilesService.uploadFile.mockReturnValue(of(progress, response)); + mockFilesService.getFileGuid.mockReturnValue(of({ id: 'abc' } as FileModel)); const selectSpy = jest.spyOn(component, 'selectFile'); component.uploadFiles(file); + expect(mockFilesService.uploadFile).toHaveBeenCalledWith(file, '/upload'); - expect(selectSpy).toHaveBeenCalledWith({ id: 'abc' } as any); + expect(component.progress()).toBe(50); + expect(selectSpy).toHaveBeenCalledWith({ id: 'abc' } as FileModel); + }); + + it('should not upload when no upload link', () => { + currentFolderSignal.set({ links: {} } as FileFolderModel); + + const file = new File(['data'], 'test.txt'); + component.uploadFiles(file); + + expect(mockFilesService.uploadFile).not.toHaveBeenCalled(); }); - it('should emit attachFile when selectFile and not view-only', (done) => { - const file = { id: 'file-1' } as any; + it('should handle File array input', () => { + const file = new File(['data'], 'test.txt'); + mockFilesService.uploadFile.mockReturnValue(of({ type: HttpEventType.Sent })); + + component.uploadFiles([file]); + + expect(mockFilesService.uploadFile).toHaveBeenCalledWith(file, '/upload'); + }); + + it('should emit attachFile when not view-only', (done) => { + const file = { id: 'file-1' } as FileModel; component.attachFile.subscribe((f) => { expect(f).toEqual(file); done(); }); component.selectFile(file); }); + + it('should not emit attachFile when filesViewOnly is true', () => { + fixture.componentRef.setInput('filesViewOnly', true); + fixture.detectChanges(); + + const emitSpy = jest.spyOn(component.attachFile, 'emit'); + component.selectFile({ id: 'file-1' } as FileModel); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should dispatch getFiles on onLoadFiles', () => { + (store.dispatch as jest.Mock).mockClear(); + + component.onLoadFiles({ link: '/files', page: 2 }); + + expect(store.dispatch).toHaveBeenCalledWith(new GetFiles('/files', 2)); + }); + + it('should dispatch setCurrentFolder', () => { + const folder = { id: 'folder-1' } as FileFolderModel; + (store.dispatch as jest.Mock).mockClear(); + + component.setCurrentFolder(folder); + + expect(store.dispatch).toHaveBeenCalledWith(new SetRegistriesCurrentFolder(folder)); + }); + + it('should add file to filesSelection and deduplicate', () => { + const file = { id: 'file-1' } as FileModel; + + component.onFileTreeSelected(file); + component.onFileTreeSelected(file); + + expect(component.filesSelection).toEqual([file]); + }); + + it('should not open dialog when no newFolder link', () => { + currentFolderSignal.set({ links: {} } as FileFolderModel); + + component.createFolder(); + + expect(mockDialogService.open).not.toHaveBeenCalled(); + }); + + it('should not dispatch getFiles when currentFolder has no filesLink', () => { + (store.dispatch as jest.Mock).mockClear(); + currentFolderSignal.set({ links: {} } as FileFolderModel); + fixture.detectChanges(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SetFilesIsLoading)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetFiles)); + }); }); diff --git a/src/app/features/registries/components/files-control/files-control.component.ts b/src/app/features/registries/components/files-control/files-control.component.ts index ba1b578d8..423a65d45 100644 --- a/src/app/features/registries/components/files-control/files-control.component.ts +++ b/src/app/features/registries/components/files-control/files-control.component.ts @@ -5,24 +5,12 @@ import { TranslatePipe } from '@ngx-translate/core'; import { TreeDragDropService } from 'primeng/api'; import { Button } from 'primeng/button'; -import { EMPTY, filter, finalize, Observable, shareReplay, take } from 'rxjs'; +import { filter, finalize, switchMap, take } from 'rxjs'; import { HttpEventType } from '@angular/common/http'; -import { - ChangeDetectionStrategy, - Component, - DestroyRef, - effect, - inject, - input, - OnDestroy, - output, - signal, -} from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; - -import { HelpScoutService } from '@core/services/help-scout.service'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, input, output, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; + import { CreateFolderDialogComponent } from '@osf/features/files/components'; import { FileUploadDialogComponent } from '@osf/shared/components/file-upload-dialog/file-upload-dialog.component'; import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; @@ -47,12 +35,10 @@ import { @Component({ selector: 'osf-files-control', imports: [ - FilesTreeComponent, Button, + FilesTreeComponent, LoadingSpinnerComponent, FileUploadDialogComponent, - FormsModule, - ReactiveFormsModule, TranslatePipe, ClearFileDirective, ], @@ -61,19 +47,18 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, providers: [TreeDragDropService], }) -export class FilesControlComponent implements OnDestroy { +export class FilesControlComponent { attachedFiles = input.required[]>(); - attachFile = output(); filesLink = input.required(); projectId = input.required(); provider = input.required(); filesViewOnly = input(false); + attachFile = output(); private readonly filesService = inject(FilesService); private readonly customDialogService = inject(CustomDialogService); private readonly destroyRef = inject(DestroyRef); private readonly toastService = inject(ToastService); - private readonly helpScoutService = inject(HelpScoutService); readonly files = select(RegistriesSelectors.getFiles); readonly filesTotalCount = select(RegistriesSelectors.getFilesTotalCount); @@ -85,6 +70,7 @@ export class FilesControlComponent implements OnDestroy { readonly dataLoaded = signal(false); fileIsUploading = signal(false); + filesSelection: FileModel[] = []; private readonly actions = createDispatchMap({ createFolder: CreateFolder, @@ -95,44 +81,26 @@ export class FilesControlComponent implements OnDestroy { }); constructor() { - this.helpScoutService.setResourceType('files'); - effect(() => { - const filesLink = this.filesLink(); - if (filesLink) { - this.actions - .getRootFolders(filesLink) - .pipe(shareReplay(), takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.dataLoaded.set(true); - }); - } - }); - - effect(() => { - const currentFolder = this.currentFolder(); - if (currentFolder) { - this.updateFilesList().subscribe(); - } - }); + this.setupRootFoldersLoader(); + this.setupCurrentFolderWatcher(); } onFileSelected(event: Event): void { const input = event.target as HTMLInputElement; const file = input.files?.[0]; - if (file && file.size >= FILE_SIZE_LIMIT) { + if (!file) return; + + if (file.size >= FILE_SIZE_LIMIT) { this.toastService.showWarn('shared.files.limitText'); return; } - if (!file) return; this.uploadFiles(file); } createFolder(): void { - const currentFolder = this.currentFolder(); - const newFolderLink = currentFolder?.links.newFolder; - + const newFolderLink = this.currentFolder()?.links.newFolder; if (!newFolderLink) return; this.customDialogService @@ -140,35 +108,18 @@ export class FilesControlComponent implements OnDestroy { header: 'files.dialogs.createFolder.title', width: '448px', }) - .onClose.pipe(filter((folderName: string) => !!folderName)) - .subscribe((folderName) => { - this.actions - .createFolder(newFolderLink, folderName) - .pipe( - take(1), - finalize(() => { - this.updateFilesList().subscribe(() => this.fileIsUploading.set(false)); - }) - ) - .subscribe(); - }); - } - - updateFilesList(): Observable { - const currentFolder = this.currentFolder(); - if (currentFolder?.links.filesLink) { - this.actions.setFilesIsLoading(true); - return this.actions.getFiles(currentFolder?.links.filesLink, 1).pipe(take(1)); - } - - return EMPTY; + .onClose.pipe( + filter((folderName: string) => !!folderName), + switchMap((folderName) => this.actions.createFolder(newFolderLink, folderName)), + finalize(() => this.fileIsUploading.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.refreshFilesList()); } uploadFiles(files: File | File[]): void { - const fileArray = Array.isArray(files) ? files : [files]; - const file = fileArray[0]; - const currentFolder = this.currentFolder(); - const uploadLink = currentFolder?.links.upload; + const file = Array.isArray(files) ? files[0] : files; + const uploadLink = this.currentFolder()?.links.upload; if (!uploadLink) return; this.fileName.set(file.name); @@ -181,7 +132,7 @@ export class FilesControlComponent implements OnDestroy { finalize(() => { this.fileIsUploading.set(false); this.fileName.set(''); - this.updateFilesList(); + this.refreshFilesList(); }) ) .subscribe((event) => { @@ -189,17 +140,14 @@ export class FilesControlComponent implements OnDestroy { this.progress.set(Math.round((event.loaded / event.total) * 100)); } - if (event.type === HttpEventType.Response) { - if (event.body) { - const fileId = event?.body?.data?.id?.split('/').pop(); - if (fileId) { - this.filesService - .getFileGuid(fileId) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((file) => { - this.selectFile(file); - }); - } + if (event.type === HttpEventType.Response && event.body) { + const fileId = event.body.data?.id?.split('/').pop(); + + if (fileId) { + this.filesService + .getFileGuid(fileId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((uploadedFile) => this.selectFile(uploadedFile)); } } }); @@ -210,6 +158,11 @@ export class FilesControlComponent implements OnDestroy { this.attachFile.emit(file); } + onFileTreeSelected(file: FileModel): void { + this.filesSelection.push(file); + this.filesSelection = [...new Set(this.filesSelection)]; + } + onLoadFiles(event: { link: string; page: number }) { this.actions.getFiles(event.link, event.page); } @@ -218,7 +171,31 @@ export class FilesControlComponent implements OnDestroy { this.actions.setCurrentFolder(folder); } - ngOnDestroy(): void { - this.helpScoutService.unsetResourceType(); + private setupRootFoldersLoader() { + toObservable(this.filesLink) + .pipe( + filter((link) => !!link), + take(1), + switchMap((link) => this.actions.getRootFolders(link)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.dataLoaded.set(true)); + } + + private setupCurrentFolderWatcher() { + toObservable(this.currentFolder) + .pipe( + filter((folder) => !!folder), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.refreshFilesList()); + } + + private refreshFilesList(): void { + const filesLink = this.currentFolder()?.links.filesLink; + if (!filesLink) return; + + this.actions.setFilesIsLoading(true); + this.actions.getFiles(filesLink, 1); } } diff --git a/src/app/features/registries/components/justification-review/justification-review.component.html b/src/app/features/registries/components/justification-review/justification-review.component.html index a282a0fcc..8cf6e62e8 100644 --- a/src/app/features/registries/components/justification-review/justification-review.component.html +++ b/src/app/features/registries/components/justification-review/justification-review.component.html @@ -60,7 +60,7 @@

{{ section.title }}

}
- @if (inProgress) { + @if (inProgress()) { {{ section.title }}

(onClick)="submit()" [loading]="isSchemaResponseLoading()" >
- } @else if (isUnapproved) { + } @else if (isUnapproved()) { { let component: JustificationReviewComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; - let mockCustomDialogService: ReturnType; - let mockCustomConfirmationService: ReturnType; - let mockToastService: ReturnType; - - const MOCK_SCHEMA_RESPONSE = { - id: 'rev-1', + let store: Store; + let mockRouter: RouterMockType; + let mockCustomDialogService: CustomDialogServiceMockType; + let customConfirmationService: CustomConfirmationServiceMockType; + let toastService: ToastServiceMockType; + + const MOCK_SCHEMA_RESPONSE: Partial = { registrationId: 'reg-1', - reviewsState: RevisionReviewStates.RevisionInProgress, updatedResponseKeys: ['field1'], - } as any; + }; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'rev-1' }).build(); + beforeEach(() => { + const mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'rev-1' }).build(); mockRouter = RouterMockBuilder.create().withUrl('/x').build(); mockCustomDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); - mockToastService = ToastServiceMockBuilder.create().build(); + toastService = ToastServiceMock.simple(); + customConfirmationService = CustomConfirmationServiceMock.simple(); - await TestBed.configureTestingModule({ - imports: [JustificationReviewComponent, OSFTestingModule, MockComponent(RegistrationBlocksDataComponent)], + TestBed.configureTestingModule({ + imports: [JustificationReviewComponent, MockComponent(RegistrationBlocksDataComponent)], providers: [ + provideOSFCore(), MockProvider(ActivatedRoute, mockActivatedRoute), MockProvider(Router, mockRouter), + MockProvider(ToastService, toastService), + MockProvider(CustomConfirmationService, customConfirmationService), MockProvider(CustomDialogService, mockCustomDialogService), - MockProvider(CustomConfirmationService, mockCustomConfirmationService), - MockProvider(ToastService, mockToastService), provideMockStore({ signals: [ { selector: RegistriesSelectors.getPagesSchema, value: MOCK_PAGES_SCHEMA }, @@ -65,8 +77,9 @@ describe('JustificationReviewComponent', () => { ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(JustificationReviewComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -85,55 +98,46 @@ describe('JustificationReviewComponent', () => { expect(mockRouter.navigate).toHaveBeenCalled(); }); - it('should submit revision for review', () => { - const mockActions = { - handleSchemaResponse: jest.fn().mockReturnValue(of({})), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); + it('should dispatch handleSchemaResponse on submit', () => { + (store.dispatch as jest.Mock).mockClear(); component.submit(); - expect(mockActions.handleSchemaResponse).toHaveBeenCalledWith('rev-1', SchemaActionTrigger.Submit); - expect(mockToastService.showSuccess).toHaveBeenCalledWith('registries.justification.successSubmit'); + expect(store.dispatch).toHaveBeenCalledWith(new HandleSchemaResponse('rev-1', SchemaActionTrigger.Submit)); + expect(toastService.showSuccess).toHaveBeenCalledWith('registries.justification.successSubmit'); }); - it('should accept changes', () => { - const mockActions = { - handleSchemaResponse: jest.fn().mockReturnValue(of({})), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); + it('should dispatch handleSchemaResponse on acceptChanges', () => { + (store.dispatch as jest.Mock).mockClear(); component.acceptChanges(); - expect(mockActions.handleSchemaResponse).toHaveBeenCalledWith('rev-1', SchemaActionTrigger.Approve); - expect(mockToastService.showSuccess).toHaveBeenCalledWith('registries.justification.successAccept'); + expect(store.dispatch).toHaveBeenCalledWith(new HandleSchemaResponse('rev-1', SchemaActionTrigger.Approve)); + expect(toastService.showSuccess).toHaveBeenCalledWith('registries.justification.successAccept'); expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/reg-1/overview'); }); - it('should continue editing and show decision recorded toast when confirmed', () => { - jest.spyOn(mockCustomDialogService, 'open').mockReturnValue({ onClose: of(true) } as any); + it('should show decision recorded toast when continueEditing confirmed', () => { + mockCustomDialogService.open.mockReturnValue({ onClose: of(true) } as any); component.continueEditing(); expect(mockCustomDialogService.open).toHaveBeenCalled(); - expect(mockToastService.showSuccess).toHaveBeenCalledWith('registries.justification.decisionRecorded'); + expect(toastService.showSuccess).toHaveBeenCalledWith('registries.justification.decisionRecorded'); }); - it('should delete draft update after confirmation', () => { - const mockActions = { - deleteSchemaResponse: jest.fn().mockReturnValue(of({})), - clearState: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); + it('should dispatch deleteSchemaResponse and clearState after confirmation', () => { + (store.dispatch as jest.Mock).mockClear(); component.deleteDraftUpdate(); - expect(mockCustomConfirmationService.confirmDelete).toHaveBeenCalled(); - const call = (mockCustomConfirmationService.confirmDelete as any).mock.calls[0][0]; + + expect(customConfirmationService.confirmDelete).toHaveBeenCalled(); + const call = customConfirmationService.confirmDelete.mock.calls[0][0]; call.onConfirm(); - expect(mockActions.deleteSchemaResponse).toHaveBeenCalledWith('rev-1'); - expect(mockToastService.showSuccess).toHaveBeenCalledWith('registries.justification.successDeleteDraft'); - expect(mockActions.clearState).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteSchemaResponse('rev-1')); + expect(toastService.showSuccess).toHaveBeenCalledWith('registries.justification.successDeleteDraft'); + expect(store.dispatch).toHaveBeenCalledWith(new ClearState()); expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/reg-1/overview'); }); }); diff --git a/src/app/features/registries/components/justification-review/justification-review.component.ts b/src/app/features/registries/components/justification-review/justification-review.component.ts index 26cb214f7..050fc1fa1 100644 --- a/src/app/features/registries/components/justification-review/justification-review.component.ts +++ b/src/app/features/registries/components/justification-review/justification-review.component.ts @@ -6,12 +6,14 @@ import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; import { Message } from 'primeng/message'; -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { filter } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { RegistrationBlocksDataComponent } from '@osf/shared/components/registration-blocks-data/registration-blocks-data.component'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const'; -import { FieldType } from '@osf/shared/enums/field-type.enum'; import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; @@ -34,6 +36,7 @@ export class JustificationReviewComponent { private readonly customConfirmationService = inject(CustomConfirmationService); private readonly customDialogService = inject(CustomDialogService); private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); readonly pages = select(RegistriesSelectors.getPagesSchema); readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); @@ -42,10 +45,8 @@ export class JustificationReviewComponent { readonly isSchemaResponseLoading = select(RegistriesSelectors.getSchemaResponseLoading); readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - readonly FieldType = FieldType; - readonly RevisionReviewStates = RevisionReviewStates; - actions = createDispatchMap({ + private readonly actions = createDispatchMap({ deleteSchemaResponse: DeleteSchemaResponse, handleSchemaResponse: HandleSchemaResponse, clearState: ClearState, @@ -53,50 +54,27 @@ export class JustificationReviewComponent { private readonly revisionId = this.route.snapshot.params['id']; - get isUnapproved() { - return this.schemaResponse()?.reviewsState === RevisionReviewStates.Unapproved; - } - - get inProgress() { - return this.schemaResponse()?.reviewsState === RevisionReviewStates.RevisionInProgress; - } + readonly isUnapproved = computed(() => this.schemaResponse()?.reviewsState === RevisionReviewStates.Unapproved); + readonly inProgress = computed(() => this.schemaResponse()?.reviewsState === RevisionReviewStates.RevisionInProgress); changes = computed(() => { - let questions: Record = {}; - this.pages().forEach((page) => { - if (page.sections?.length) { - questions = { - ...questions, - ...Object.fromEntries( - page.sections.flatMap( - (section) => section.questions?.map((q) => [q.responseKey, q.displayText || '']) || [] - ) - ), - }; - } else { - questions = { - ...questions, - ...Object.fromEntries(page.questions?.map((q) => [q.responseKey, q.displayText]) || []), - }; - } - }); - const updatedFields = this.updatedFields(); + const questions = this.buildQuestionMap(); const updatedResponseKeys = this.schemaResponse()?.updatedResponseKeys || []; - const uniqueKeys = new Set([...updatedResponseKeys, ...Object.keys(updatedFields)]); + const uniqueKeys = new Set([...updatedResponseKeys, ...Object.keys(this.updatedFields())]); return Array.from(uniqueKeys).map((key) => questions[key]); }); submit(): void { - this.actions.handleSchemaResponse(this.revisionId, SchemaActionTrigger.Submit).subscribe({ - next: () => { + this.actions + .handleSchemaResponse(this.revisionId, SchemaActionTrigger.Submit) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { this.toastService.showSuccess('registries.justification.successSubmit'); - }, - }); + }); } goBack(): void { - const previousStep = this.pages().length; - this.router.navigate(['../', previousStep], { relativeTo: this.route }); + this.router.navigate(['../', this.pages().length], { relativeTo: this.route }); } deleteDraftUpdate() { @@ -105,24 +83,26 @@ export class JustificationReviewComponent { messageKey: 'registries.justification.confirmDeleteUpdate.message', onConfirm: () => { const registrationId = this.schemaResponse()?.registrationId || ''; - this.actions.deleteSchemaResponse(this.revisionId).subscribe({ - next: () => { + this.actions + .deleteSchemaResponse(this.revisionId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { this.toastService.showSuccess('registries.justification.successDeleteDraft'); this.actions.clearState(); this.router.navigateByUrl(`/${registrationId}/overview`); - }, - }); + }); }, }); } acceptChanges() { - this.actions.handleSchemaResponse(this.revisionId, SchemaActionTrigger.Approve).subscribe({ - next: () => { + this.actions + .handleSchemaResponse(this.revisionId, SchemaActionTrigger.Approve) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { this.toastService.showSuccess('registries.justification.successAccept'); this.router.navigateByUrl(`/${this.schemaResponse()?.registrationId}/overview`); - }, - }); + }); } continueEditing() { @@ -130,14 +110,23 @@ export class JustificationReviewComponent { .open(ConfirmContinueEditingDialogComponent, { header: 'registries.justification.confirmContinueEditing.header', width: '552px', - data: { - revisionId: this.revisionId, - }, + data: { revisionId: this.revisionId }, }) - .onClose.subscribe((result) => { - if (result) { - this.toastService.showSuccess('registries.justification.decisionRecorded'); - } - }); + .onClose.pipe( + filter((result) => !!result), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.toastService.showSuccess('registries.justification.decisionRecorded')); + } + + private buildQuestionMap(): Record { + return Object.fromEntries( + this.pages().flatMap((page) => { + const questions = page.sections?.length + ? page.sections.flatMap((section) => section.questions || []) + : page.questions || []; + return questions.map((q) => [q.responseKey, q.displayText || '']); + }) + ); } } diff --git a/src/app/features/registries/components/justification-step/justification-step.component.html b/src/app/features/registries/components/justification-step/justification-step.component.html index e1265453e..7b3be3e89 100644 --- a/src/app/features/registries/components/justification-step/justification-step.component.html +++ b/src/app/features/registries/components/justification-step/justification-step.component.html @@ -12,7 +12,7 @@

{{ 'registries.justification.title' | translate }}

pTextarea formControlName="justification" > - @if (isJustificationValid) { + @if (showJustificationError) { {{ INPUT_VALIDATION_MESSAGES.required | translate }} @@ -25,7 +25,7 @@

{{ 'registries.justification.title' | translate }}

diff --git a/src/app/features/registries/components/justification-step/justification-step.component.spec.ts b/src/app/features/registries/components/justification-step/justification-step.component.spec.ts index c3b98e705..5e7c73b47 100644 --- a/src/app/features/registries/components/justification-step/justification-step.component.spec.ts +++ b/src/app/features/registries/components/justification-step/justification-step.component.spec.ts @@ -1,57 +1,67 @@ -import { MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { of } from 'rxjs'; +import { MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { + ClearState, + DeleteSchemaResponse, + RegistriesSelectors, + UpdateSchemaResponse, + UpdateStepState, +} from '@osf/features/registries/store'; +import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { JustificationStepComponent } from './justification-step.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('JustificationStepComponent', () => { let component: JustificationStepComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: jest.Mocked; - let mockCustomConfirmationService: ReturnType; - let mockToastService: ReturnType; + let store: Store; + let mockRouter: RouterMockType; + let toastService: ToastServiceMockType; + let customConfirmationService: CustomConfirmationServiceMockType; - const MOCK_SCHEMA_RESPONSE = { + const MOCK_SCHEMA_RESPONSE: Partial = { registrationId: 'reg-1', revisionJustification: 'reason', - } as any; + }; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'rev-1' }).build(); - mockRouter = { navigate: jest.fn(), navigateByUrl: jest.fn(), url: '/x' } as any; - mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); - mockToastService = ToastServiceMockBuilder.create().build(); + beforeEach(() => { + const mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'rev-1' }).build(); + mockRouter = RouterMockBuilder.create().withUrl('/x').build(); + toastService = ToastServiceMock.simple(); + customConfirmationService = CustomConfirmationServiceMock.simple(); - await TestBed.configureTestingModule({ - imports: [JustificationStepComponent, OSFTestingModule], + TestBed.configureTestingModule({ + imports: [JustificationStepComponent], providers: [ + provideOSFCore(), + MockProvider(ToastService, toastService), MockProvider(ActivatedRoute, mockActivatedRoute), MockProvider(Router, mockRouter), - MockProvider(CustomConfirmationService, mockCustomConfirmationService as any), - MockProvider(ToastService, mockToastService), + MockProvider(CustomConfirmationService, customConfirmationService), provideMockStore({ - signals: [ - { selector: RegistriesSelectors.getSchemaResponse, value: MOCK_SCHEMA_RESPONSE }, - { selector: RegistriesSelectors.getStepsState, value: {} }, - ], + signals: [{ selector: RegistriesSelectors.getSchemaResponse, value: MOCK_SCHEMA_RESPONSE }], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(JustificationStepComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -66,16 +76,12 @@ describe('JustificationStepComponent', () => { }); it('should submit justification and navigate to first step', () => { - const mockActions = { - updateRevision: jest.fn().mockReturnValue(of({})), - updateStepState: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); - component.justificationForm.patchValue({ justification: 'new reason' }); + (store.dispatch as jest.Mock).mockClear(); + component.submit(); - expect(mockActions.updateRevision).toHaveBeenCalledWith('rev-1', 'new reason'); + expect(store.dispatch).toHaveBeenCalledWith(new UpdateSchemaResponse('rev-1', 'new reason')); expect(mockRouter.navigate).toHaveBeenCalledWith(['../1'], { relativeTo: expect.any(Object), onSameUrlNavigation: 'reload', @@ -83,21 +89,36 @@ describe('JustificationStepComponent', () => { }); it('should delete draft update after confirmation', () => { - const mockActions = { - deleteSchemaResponse: jest.fn().mockReturnValue(of({})), - clearState: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); + (store.dispatch as jest.Mock).mockClear(); component.deleteDraftUpdate(); - expect(mockCustomConfirmationService.confirmDelete).toHaveBeenCalled(); - const call = (mockCustomConfirmationService.confirmDelete as any).mock.calls[0][0]; + expect(customConfirmationService.confirmDelete).toHaveBeenCalled(); + const call = customConfirmationService.confirmDelete.mock.calls[0][0]; call.onConfirm(); - expect(mockActions.deleteSchemaResponse).toHaveBeenCalledWith('rev-1'); - expect(mockToastService.showSuccess).toHaveBeenCalledWith('registries.justification.successDeleteDraft'); - expect(mockActions.clearState).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteSchemaResponse('rev-1')); + expect(toastService.showSuccess).toHaveBeenCalledWith('registries.justification.successDeleteDraft'); + expect(store.dispatch).toHaveBeenCalledWith(new ClearState()); expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/reg-1/overview'); }); + + it('should dispatch updateStepState and updateRevision on destroy when form changed', () => { + component.justificationForm.patchValue({ justification: 'changed reason' }); + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdateStepState('0', false, true)); + expect(store.dispatch).toHaveBeenCalledWith(new UpdateSchemaResponse('rev-1', 'changed reason')); + }); + + it('should not dispatch updateRevision on destroy when form is unchanged', () => { + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdateStepState('0', false, true)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdateSchemaResponse)); + }); }); diff --git a/src/app/features/registries/components/justification-step/justification-step.component.ts b/src/app/features/registries/components/justification-step/justification-step.component.ts index eee067b61..0ba540a0f 100644 --- a/src/app/features/registries/components/justification-step/justification-step.component.ts +++ b/src/app/features/registries/components/justification-step/justification-step.component.ts @@ -6,9 +6,10 @@ import { Button } from 'primeng/button'; import { Message } from 'primeng/message'; import { Textarea } from 'primeng/textarea'; -import { tap } from 'rxjs'; +import { filter, take } from 'rxjs'; -import { ChangeDetectionStrategy, Component, effect, inject, OnDestroy } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnDestroy, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; @@ -40,13 +41,13 @@ export class JustificationStepComponent implements OnDestroy { private readonly router = inject(Router); private readonly customConfirmationService = inject(CustomConfirmationService); private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); - readonly stepsState = select(RegistriesSelectors.getStepsState); readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - actions = createDispatchMap({ + private readonly actions = createDispatchMap({ updateStepState: UpdateStepState, updateRevision: UpdateSchemaResponse, deleteSchemaResponse: DeleteSchemaResponse, @@ -54,40 +55,51 @@ export class JustificationStepComponent implements OnDestroy { }); private readonly revisionId = this.route.snapshot.params['id']; + private readonly isDraftDeleted = signal(false); - justificationForm = this.fb.group({ + readonly justificationForm = this.fb.group({ justification: ['', [Validators.maxLength(InputLimits.description.maxLength), CustomValidators.requiredTrimmed()]], }); - get isJustificationValid(): boolean { + get showJustificationError(): boolean { const control = this.justificationForm.controls['justification']; return control.errors?.['required'] && (control.touched || control.dirty); } - isDraftDeleted = false; - constructor() { - effect(() => { - const revisionJustification = this.schemaResponse()?.revisionJustification; - if (revisionJustification) { - this.justificationForm.patchValue({ justification: revisionJustification }); - } - }); + this.setupInitialJustification(); + } + + ngOnDestroy(): void { + if (this.isDraftDeleted()) { + return; + } + + this.actions.updateStepState('0', this.justificationForm.invalid, true); + + const changes = findChangedFields( + { justification: this.justificationForm.value.justification! }, + { justification: this.schemaResponse()?.revisionJustification } + ); + + if (Object.keys(changes).length > 0) { + this.actions.updateRevision(this.revisionId, this.justificationForm.value.justification!); + } + + this.justificationForm.markAllAsTouched(); } submit(): void { this.actions .updateRevision(this.revisionId, this.justificationForm.value.justification!) - .pipe( - tap(() => { - this.justificationForm.markAllAsTouched(); - this.router.navigate(['../1'], { - relativeTo: this.route, - onSameUrlNavigation: 'reload', - }); - }) - ) - .subscribe(); + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.justificationForm.markAllAsTouched(); + this.router.navigate(['../1'], { + relativeTo: this.route, + onSameUrlNavigation: 'reload', + }); + }); } deleteDraftUpdate() { @@ -96,29 +108,25 @@ export class JustificationStepComponent implements OnDestroy { messageKey: 'registries.justification.confirmDeleteUpdate.message', onConfirm: () => { const registrationId = this.schemaResponse()?.registrationId || ''; - this.actions.deleteSchemaResponse(this.revisionId).subscribe({ - next: () => { - this.isDraftDeleted = true; + this.actions + .deleteSchemaResponse(this.revisionId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.isDraftDeleted.set(true); this.actions.clearState(); this.toastService.showSuccess('registries.justification.successDeleteDraft'); this.router.navigateByUrl(`/${registrationId}/overview`); - }, - }); + }); }, }); } - ngOnDestroy(): void { - if (!this.isDraftDeleted) { - this.actions.updateStepState('0', this.justificationForm.invalid, true); - const changes = findChangedFields( - { justification: this.justificationForm.value.justification! }, - { justification: this.schemaResponse()?.revisionJustification } - ); - if (Object.keys(changes).length > 0) { - this.actions.updateRevision(this.revisionId, this.justificationForm.value.justification!); - } - this.justificationForm.markAllAsTouched(); - } + private setupInitialJustification() { + toObservable(this.schemaResponse) + .pipe( + filter((response) => !!response?.revisionJustification), + take(1) + ) + .subscribe((response) => this.justificationForm.patchValue({ justification: response!.revisionJustification })); } } diff --git a/src/app/features/registries/components/new-registration/new-registration.component.html b/src/app/features/registries/components/new-registration/new-registration.component.html index 8ce2cb21f..2037844a7 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.html +++ b/src/app/features/registries/components/new-registration/new-registration.component.html @@ -1,88 +1,92 @@ -
- +@if (canShowForm()) { +
+ -
-

- {{ 'registries.new.infoText1' | translate }} - {{ 'common.links.clickHere' | translate }} - {{ 'registries.new.infoText2' | translate }} -

-
+
+

+ {{ 'registries.new.infoText1' | translate }} + {{ 'common.links.clickHere' | translate }} + {{ 'registries.new.infoText2' | translate }} +

+
+ +
+ +

{{ 'registries.new.steps.title' | translate }} 1

+

{{ 'registries.new.steps.step1' | translate }}

+
+ + +
+
-
- -

{{ 'registries.new.steps.title' | translate }} 1

-

{{ 'registries.new.steps.step1' | translate }}

-
- - -
-
+
+ @if (fromProject()) { + +

{{ 'registries.new.steps.title' | translate }} 2

+

{{ 'registries.new.steps.step2' | translate }}

+

{{ 'registries.new.steps.step2InfoText' | translate }}

+
+ +
+
+ } - - @if (fromProject) { -

{{ 'registries.new.steps.title' | translate }} 2

-

{{ 'registries.new.steps.step2' | translate }}

-

{{ 'registries.new.steps.step2InfoText' | translate }}

+

{{ 'registries.new.steps.title' | translate }} {{ fromProject() ? '3' : '2' }}

+

{{ 'registries.new.steps.step3' | translate }}

- } - -

{{ 'registries.new.steps.title' | translate }} {{ fromProject ? '3' : '2' }}

-

{{ 'registries.new.steps.step3' | translate }}

-
- +
-
- -
- -
-
+ +
-
+} @else { +
+ +
+} diff --git a/src/app/features/registries/components/new-registration/new-registration.component.spec.ts b/src/app/features/registries/components/new-registration/new-registration.component.spec.ts index ff8f9c3ee..80246925c 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.spec.ts +++ b/src/app/features/registries/components/new-registration/new-registration.component.spec.ts @@ -1,103 +1,212 @@ -import { MockComponent, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { of } from 'rxjs'; +import { MockComponent, MockProvider } from 'ng-mocks'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { UserSelectors } from '@core/store/user'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { CreateDraft, GetProjects, GetProviderSchemas, RegistriesSelectors } from '@osf/features/registries/store'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { GetRegistryProvider, RegistrationProviderSelectors } from '@shared/stores/registration-provider'; import { NewRegistrationComponent } from './new-registration.component'; import { MOCK_PROVIDER_SCHEMAS } from '@testing/mocks/registries.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('NewRegistrationComponent', () => { let component: NewRegistrationComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; - const PROJECTS = [{ id: 'p1', title: 'P1' }]; - const PROVIDER_SCHEMAS = MOCK_PROVIDER_SCHEMAS; - - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create() - .withParams({ providerId: 'prov-1' }) + let store: Store; + let mockRouter: RouterMockType; + let toastService: ToastServiceMockType; + + interface SetupOverrides extends BaseSetupOverrides { + selectorOverrides?: SignalOverride[]; + } + + const defaultSignals: SignalOverride[] = [ + { selector: RegistriesSelectors.getProjects, value: [{ id: 'p1', title: 'P1' }] }, + { selector: RegistriesSelectors.getProviderSchemas, value: MOCK_PROVIDER_SCHEMAS }, + { selector: RegistriesSelectors.isDraftSubmitting, value: false }, + { selector: RegistriesSelectors.getDraftRegistration, value: { id: 'draft-1' } }, + { selector: RegistriesSelectors.isProvidersLoading, value: false }, + { selector: RegistriesSelectors.isProjectsLoading, value: false }, + { selector: UserSelectors.getCurrentUser, value: { id: 'user-1' } }, + { selector: RegistrationProviderSelectors.getBrandedProvider, value: { id: 'prov-1', allowSubmissions: true } }, + ]; + + const setup = (overrides?: SetupOverrides) => { + const mockActivatedRoute = ActivatedRouteMockBuilder.create() + .withParams(overrides?.routeParams || { providerId: 'prov-1' }) .withQueryParams({ projectId: 'proj-1' }) .build(); mockRouter = RouterMockBuilder.create().withUrl('/x').build(); + toastService = ToastServiceMock.simple(); - await TestBed.configureTestingModule({ - imports: [NewRegistrationComponent, OSFTestingModule, MockComponent(SubHeaderComponent)], + TestBed.configureTestingModule({ + imports: [NewRegistrationComponent, MockComponent(SubHeaderComponent)], providers: [ + provideOSFCore(), MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(ToastService, toastService), MockProvider(Router, mockRouter), provideMockStore({ - signals: [ - { selector: RegistriesSelectors.getProjects, value: PROJECTS }, - { selector: RegistriesSelectors.getProviderSchemas, value: PROVIDER_SCHEMAS }, - { selector: RegistriesSelectors.isDraftSubmitting, value: false }, - { selector: RegistriesSelectors.getDraftRegistration, value: { id: 'draft-1' } }, - { selector: RegistriesSelectors.isProvidersLoading, value: false }, - { selector: RegistriesSelectors.isProjectsLoading, value: false }, - { selector: UserSelectors.getCurrentUser, value: { id: 'user-1' } }, - ], + signals: mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides), }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(NewRegistrationComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + }; it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should init with provider and project ids from route', () => { - expect(component.providerId).toBe('prov-1'); - expect(component.projectId).toBe('proj-1'); - expect(component.fromProject).toBe(true); + it('should allow submissions when provider allows it', () => { + setup(); + expect(component.canShowForm()).toBe(true); + expect(toastService.showError).not.toHaveBeenCalled(); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('should redirect and show error when submissions are not allowed', () => { + setup({ + selectorOverrides: [ + { + selector: RegistrationProviderSelectors.getBrandedProvider, + value: { id: 'prov-1', allowSubmissions: false }, + }, + ], + }); + + expect(component.canShowForm()).toBe(false); + expect(toastService.showError).toHaveBeenCalledWith('registries.new.registryClosedForSubmissions'); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries', 'prov-1']); + }); + + it('should redirect and show error when allowSubmissions is undefined', () => { + setup({ + selectorOverrides: [{ selector: RegistrationProviderSelectors.getBrandedProvider, value: { id: 'prov-1' } }], + }); + + expect(component.canShowForm()).toBe(false); + expect(toastService.showError).toHaveBeenCalledWith('registries.new.registryClosedForSubmissions'); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries', 'prov-1']); }); - it('should default providerSchema when empty', () => { - expect(component['draftForm'].get('providerSchema')?.value).toBe('schema-1'); + it('should dispatch initial data fetching on init', () => { + setup(); + expect(store.dispatch).toHaveBeenCalledWith(new GetProjects('user-1', '')); + expect(store.dispatch).toHaveBeenCalledWith(new GetRegistryProvider('prov-1')); + expect(store.dispatch).toHaveBeenCalledWith(new GetProviderSchemas('prov-1')); }); - it('should update project on selection', () => { - component.onSelectProject('p1'); - expect(component['draftForm'].get('project')?.value).toBe('p1'); + it('should init fromProject as true when projectId is present', () => { + setup(); + expect(component.fromProject()).toBe(true); + }); + + it('should init form with project id from route', () => { + setup(); + expect(component.draftForm.get('project')?.value).toBe('proj-1'); + }); + + it('should default providerSchema when schemas are available', () => { + setup(); + expect(component.draftForm.get('providerSchema')?.value).toBe('schema-1'); }); it('should toggle fromProject and add/remove validator', () => { - component.fromProject = false; + setup(); + component.fromProject.set(false); component.toggleFromProject(); - expect(component.fromProject).toBe(true); + expect(component.fromProject()).toBe(true); + expect(component.draftForm.get('project')?.validator).toBeTruthy(); + component.toggleFromProject(); - expect(component.fromProject).toBe(false); + expect(component.fromProject()).toBe(false); + expect(component.draftForm.get('project')?.validator).toBeNull(); }); - it('should create draft when form valid', () => { - const mockActions = { - createDraft: jest.fn().mockReturnValue(of({})), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); - + it('should dispatch createDraft and navigate when form is valid', () => { + setup(); component.draftForm.patchValue({ providerSchema: 'schema-1', project: 'proj-1' }); - component.fromProject = true; + component.fromProject.set(true); + (store.dispatch as jest.Mock).mockClear(); + component.createDraft(); - expect(mockActions.createDraft).toHaveBeenCalledWith({ - registrationSchemaId: 'schema-1', - provider: 'prov-1', - projectId: 'proj-1', - }); + expect(store.dispatch).toHaveBeenCalledWith( + new CreateDraft({ registrationSchemaId: 'schema-1', provider: 'prov-1', projectId: 'proj-1' }) + ); expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/drafts/', 'draft-1', 'metadata']); }); + + it('should not dispatch createDraft when form is invalid', () => { + setup(); + component.draftForm.patchValue({ providerSchema: '' }); + (store.dispatch as jest.Mock).mockClear(); + + component.createDraft(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(CreateDraft)); + }); + + it('should dispatch getProjects after debounced filter', fakeAsync(() => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + + component.onProjectFilter('abc'); + tick(300); + + expect(store.dispatch).toHaveBeenCalledWith(new GetProjects('user-1', 'abc')); + })); + + it('should not dispatch duplicate getProjects for same filter value', fakeAsync(() => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + + component.onProjectFilter('abc'); + tick(300); + component.onProjectFilter('abc'); + tick(300); + + const getProjectsCalls = (store.dispatch as jest.Mock).mock.calls.filter( + ([action]: [unknown]) => action instanceof GetProjects + ); + expect(getProjectsCalls.length).toBe(1); + })); + + it('should debounce rapid filter calls and dispatch only the last value', fakeAsync(() => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + + component.onProjectFilter('a'); + component.onProjectFilter('ab'); + component.onProjectFilter('abc'); + tick(300); + + const getProjectsCalls = (store.dispatch as jest.Mock).mock.calls.filter( + ([action]: [unknown]) => action instanceof GetProjects + ); + expect(getProjectsCalls.length).toBe(1); + expect(getProjectsCalls[0][0]).toEqual(new GetProjects('user-1', 'abc')); + })); }); diff --git a/src/app/features/registries/components/new-registration/new-registration.component.ts b/src/app/features/registries/components/new-registration/new-registration.component.ts index 952ee73f8..8fc36948b 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.ts +++ b/src/app/features/registries/components/new-registration/new-registration.component.ts @@ -6,23 +6,24 @@ import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; import { Select } from 'primeng/select'; -import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, filter, Subject, take } from 'rxjs'; -import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { UserSelectors } from '@core/store/user'; +import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { ToastService } from '@osf/shared/services/toast.service'; -import { GetRegistryProvider } from '@shared/stores/registration-provider'; +import { GetRegistryProvider, RegistrationProviderSelectors } from '@shared/stores/registration-provider'; import { CreateDraft, GetProjects, GetProviderSchemas, RegistriesSelectors } from '../../store'; @Component({ selector: 'osf-new-registration', - imports: [SubHeaderComponent, TranslatePipe, Card, Button, ReactiveFormsModule, Select], + imports: [Button, Card, Select, ReactiveFormsModule, LoadingSpinnerComponent, SubHeaderComponent, TranslatePipe], templateUrl: './new-registration.component.html', styleUrl: './new-registration.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -32,93 +33,110 @@ export class NewRegistrationComponent { private readonly toastService = inject(ToastService); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); - private destroyRef = inject(DestroyRef); + private readonly destroyRef = inject(DestroyRef); + readonly user = select(UserSelectors.getCurrentUser); readonly projects = select(RegistriesSelectors.getProjects); readonly providerSchemas = select(RegistriesSelectors.getProviderSchemas); + readonly provider = select(RegistrationProviderSelectors.getBrandedProvider); readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting); - readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); readonly isProvidersLoading = select(RegistriesSelectors.isProvidersLoading); readonly isProjectsLoading = select(RegistriesSelectors.isProjectsLoading); - readonly user = select(UserSelectors.getCurrentUser); - actions = createDispatchMap({ + private readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); + + readonly canShowForm = computed(() => !this.isProvidersLoading() && !!this.provider()?.allowSubmissions); + + private readonly actions = createDispatchMap({ getProvider: GetRegistryProvider, getProjects: GetProjects, getProviderSchemas: GetProviderSchemas, createDraft: CreateDraft, }); + private readonly providerId = this.route.snapshot.params['providerId']; + private readonly projectId = this.route.snapshot.queryParams['projectId']; + private readonly filter$ = new Subject(); - readonly providerId = this.route.snapshot.params['providerId']; - readonly projectId = this.route.snapshot.queryParams['projectId']; - - fromProject = this.projectId !== undefined; - - draftForm = this.fb.group({ + readonly fromProject = signal(this.projectId !== undefined); + readonly draftForm = this.fb.group({ providerSchema: ['', Validators.required], project: [this.projectId || ''], }); - private filter$ = new Subject(); - constructor() { + this.loadInitialData(); + this.setupDefaultSchema(); + this.setupProjectFilter(); + this.setupSubmissionsAccessCheck(); + } + + onProjectFilter(value: string) { + this.filter$.next(value); + } + + toggleFromProject() { + this.fromProject.update((v) => !v); + const projectControl = this.draftForm.get('project'); + projectControl?.setValidators(this.fromProject() ? Validators.required : null); + projectControl?.updateValueAndValidity(); + } + + createDraft() { + if (this.draftForm.invalid) { + return; + } + + const { providerSchema, project } = this.draftForm.value; + + this.actions + .createDraft({ + registrationSchemaId: providerSchema!, + provider: this.providerId, + projectId: this.fromProject() ? (project ?? undefined) : undefined, + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.toastService.showSuccess('registries.new.createdSuccessfully'); + this.router.navigate(['/registries/drafts/', this.draftRegistration()!.id, 'metadata']); + }); + } + + private loadInitialData() { const userId = this.user()?.id; if (userId) { this.actions.getProjects(userId, ''); } this.actions.getProvider(this.providerId); this.actions.getProviderSchemas(this.providerId); - effect(() => { - const providerSchema = this.draftForm.get('providerSchema')?.value; - if (!providerSchema) { - this.draftForm.get('providerSchema')?.setValue(this.providerSchemas()[0]?.id); - } - }); + } + + private setupDefaultSchema() { + toObservable(this.providerSchemas) + .pipe( + filter((schemas) => schemas.length > 0), + take(1) + ) + .subscribe((schemas) => this.draftForm.get('providerSchema')?.setValue(schemas[0].id)); + } + private setupProjectFilter() { this.filter$ .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) .subscribe((value: string) => { - if (userId) { - this.actions.getProjects(userId, value); + const currentUserId = this.user()?.id; + if (currentUserId) { + this.actions.getProjects(currentUserId, value); } }); } - onSelectProject(projectId: string) { - this.draftForm.patchValue({ - project: projectId, - }); - } - - onProjectFilter(value: string) { - this.filter$.next(value); - } + private setupSubmissionsAccessCheck() { + effect(() => { + const provider = this.provider(); - onSelectProviderSchema(providerSchemaId: string) { - this.draftForm.patchValue({ - providerSchema: providerSchemaId, + if (provider && !provider.allowSubmissions) { + this.toastService.showError('registries.new.registryClosedForSubmissions'); + this.router.navigate(['/registries', provider.id]); + } }); } - - toggleFromProject() { - this.fromProject = !this.fromProject; - this.draftForm.get('project')?.setValidators(this.fromProject ? Validators.required : null); - this.draftForm.get('project')?.updateValueAndValidity(); - } - - createDraft() { - const { providerSchema, project } = this.draftForm.value; - - if (this.draftForm.valid) { - this.actions - .createDraft({ - registrationSchemaId: providerSchema!, - provider: this.providerId, - projectId: this.fromProject ? (project ?? undefined) : undefined, - }) - .subscribe(() => { - this.toastService.showSuccess('registries.new.createdSuccessfully'); - this.router.navigate(['/registries/drafts/', this.draftRegistration()?.id, 'metadata']); - }); - } - } } diff --git a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.spec.ts index 385ae946b..40e29ec95 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.spec.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.spec.ts @@ -1,47 +1,55 @@ +import { Store } from '@ngxs/store'; + import { MockComponent } from 'ng-mocks'; +import { signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component'; -import { InstitutionsSelectors } from '@osf/shared/stores/institutions'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; +import { + FetchResourceInstitutions, + FetchUserInstitutions, + InstitutionsSelectors, + UpdateResourceInstitutions, +} from '@osf/shared/stores/institutions'; import { RegistriesAffiliatedInstitutionComponent } from './registries-affiliated-institution.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { MOCK_INSTITUTION } from '@testing/mocks/institution.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistriesAffiliatedInstitutionComponent', () => { let component: RegistriesAffiliatedInstitutionComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; + let store: Store; + let resourceInstitutionsSignal: WritableSignal; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + beforeEach(() => { + resourceInstitutionsSignal = signal([]); - await TestBed.configureTestingModule({ - imports: [ - RegistriesAffiliatedInstitutionComponent, - OSFTestingModule, - MockComponent(AffiliatedInstitutionSelectComponent), - ], + TestBed.configureTestingModule({ + imports: [RegistriesAffiliatedInstitutionComponent, MockComponent(AffiliatedInstitutionSelectComponent)], providers: [ - { provide: ActivatedRoute, useValue: mockActivatedRoute }, + provideOSFCore(), provideMockStore({ signals: [ { selector: InstitutionsSelectors.getUserInstitutions, value: [] }, { selector: InstitutionsSelectors.areUserInstitutionsLoading, value: false }, - { selector: InstitutionsSelectors.getResourceInstitutions, value: [] }, + { selector: InstitutionsSelectors.getResourceInstitutions, value: resourceInstitutionsSignal }, { selector: InstitutionsSelectors.areResourceInstitutionsLoading, value: false }, { selector: InstitutionsSelectors.areResourceInstitutionsSubmitting, value: false }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesAffiliatedInstitutionComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('draftId', 'draft-1'); fixture.detectChanges(); }); @@ -49,27 +57,26 @@ describe('RegistriesAffiliatedInstitutionComponent', () => { expect(component).toBeTruthy(); }); - it('should dispatch updateResourceInstitutions on selection', () => { - const actionsMock = { - updateResourceInstitutions: jest.fn(), - fetchUserInstitutions: jest.fn(), - fetchResourceInstitutions: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - const selected = [{ id: 'i2' }] as any; - component.institutionsSelected(selected); - expect(actionsMock.updateResourceInstitutions).toHaveBeenCalledWith('draft-1', 8, selected); + it('should dispatch fetchUserInstitutions and fetchResourceInstitutions on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(new FetchUserInstitutions()); + expect(store.dispatch).toHaveBeenCalledWith( + new FetchResourceInstitutions('draft-1', ResourceType.DraftRegistration) + ); }); - it('should fetch user and resource institutions on init', () => { - const actionsMock = { - updateResourceInstitutions: jest.fn(), - fetchUserInstitutions: jest.fn(), - fetchResourceInstitutions: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - component.ngOnInit(); - expect(actionsMock.fetchUserInstitutions).toHaveBeenCalled(); - expect(actionsMock.fetchResourceInstitutions).toHaveBeenCalledWith('draft-1', 8); + it('should sync selectedInstitutions when resourceInstitutions emits', () => { + const institutions: Institution[] = [MOCK_INSTITUTION as Institution]; + resourceInstitutionsSignal.set(institutions); + fixture.detectChanges(); + expect(component.selectedInstitutions()).toEqual(institutions); + }); + + it('should dispatch updateResourceInstitutions on selection', () => { + (store.dispatch as jest.Mock).mockClear(); + const selected: Institution[] = [MOCK_INSTITUTION as Institution]; + component.institutionsSelected(selected); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateResourceInstitutions('draft-1', ResourceType.DraftRegistration, selected) + ); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts index 11741ffba..a16d71d2d 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts @@ -4,12 +4,11 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; -import { ChangeDetectionStrategy, Component, effect, inject, OnInit, signal } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ChangeDetectionStrategy, Component, effect, input, OnInit, signal } from '@angular/core'; import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { FetchResourceInstitutions, FetchUserInstitutions, @@ -25,8 +24,7 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistriesAffiliatedInstitutionComponent implements OnInit { - private readonly route = inject(ActivatedRoute); - private readonly draftId = this.route.snapshot.params['id']; + draftId = input.required(); selectedInstitutions = signal([]); @@ -53,10 +51,10 @@ export class RegistriesAffiliatedInstitutionComponent implements OnInit { ngOnInit() { this.actions.fetchUserInstitutions(); - this.actions.fetchResourceInstitutions(this.draftId, ResourceType.DraftRegistration); + this.actions.fetchResourceInstitutions(this.draftId(), ResourceType.DraftRegistration); } institutionsSelected(institutions: Institution[]) { - this.actions.updateResourceInstitutions(this.draftId, ResourceType.DraftRegistration, institutions); + this.actions.updateResourceInstitutions(this.draftId(), ResourceType.DraftRegistration, institutions); } } diff --git a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.spec.ts index 1ee6a31b7..703f8e7ba 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.spec.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.spec.ts @@ -1,133 +1,211 @@ +import { Store } from '@ngxs/store'; + import { MockComponent, MockProvider } from 'ng-mocks'; -import { of } from 'rxjs'; +import { Subject } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; import { UserSelectors } from '@core/store/user'; +import { AddContributorType } from '@osf/shared/enums/contributors/add-contributor-type.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { ContributorsSelectors } from '@osf/shared/stores/contributors/contributors.selectors'; +import { + BulkAddContributors, + BulkUpdateContributors, + ContributorsSelectors, + DeleteContributor, + GetAllContributors, + LoadMoreContributors, + ResetContributorsState, +} from '@osf/shared/stores/contributors'; import { ContributorsTableComponent } from '@shared/components/contributors/contributors-table/contributors-table.component'; +import { ContributorModel } from '@shared/models/contributors/contributor.model'; +import { ContributorDialogAddModel } from '@shared/models/contributors/contributor-dialog-add.model'; import { RegistriesContributorsComponent } from './registries-contributors.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { + MOCK_CONTRIBUTOR, + MOCK_CONTRIBUTOR_ADD, + MOCK_CONTRIBUTOR_WITHOUT_HISTORY, +} from '@testing/mocks/contributors.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMockBuilder, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { ToastServiceMockBuilder, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('RegistriesContributorsComponent', () => { let component: RegistriesContributorsComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockCustomDialogService: ReturnType; - let mockCustomConfirmationService: ReturnType; - let mockToast: ReturnType; - - const initialContributors = [ - { id: '1', userId: 'u1', fullName: 'A', permission: 2 }, - { id: '2', userId: 'u2', fullName: 'B', permission: 1 }, - ] as any[]; - - 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)), - }); - } - }); + let store: Store; + let mockCustomDialogService: CustomDialogServiceMockType; + let mockCustomConfirmationService: CustomConfirmationServiceMockType; + let mockToast: ToastServiceMockType; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + const initialContributors: ContributorModel[] = [MOCK_CONTRIBUTOR, MOCK_CONTRIBUTOR_WITHOUT_HISTORY]; + + beforeEach(() => { mockCustomDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); mockToast = ToastServiceMockBuilder.create().build(); - await TestBed.configureTestingModule({ - imports: [RegistriesContributorsComponent, OSFTestingModule, MockComponent(ContributorsTableComponent)], + TestBed.configureTestingModule({ + imports: [RegistriesContributorsComponent, MockComponent(ContributorsTableComponent)], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), + provideOSFCore(), MockProvider(CustomDialogService, mockCustomDialogService), MockProvider(CustomConfirmationService, mockCustomConfirmationService), MockProvider(ToastService, mockToast), provideMockStore({ signals: [ - { selector: UserSelectors.getCurrentUser, value: { id: 'u1' } }, + { selector: UserSelectors.getCurrentUser, value: { id: MOCK_CONTRIBUTOR.userId } }, { selector: ContributorsSelectors.getContributors, value: initialContributors }, { selector: ContributorsSelectors.isContributorsLoading, value: false }, + { selector: ContributorsSelectors.getContributorsTotalCount, value: 2 }, + { selector: ContributorsSelectors.isContributorsLoadingMore, value: false }, + { selector: ContributorsSelectors.getContributorsPageSize, value: 10 }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesContributorsComponent); component = fixture.componentInstance; fixture.componentRef.setInput('control', new FormControl([])); - const mockActions = { - getContributors: jest.fn().mockReturnValue(of({})), - updateContributor: jest.fn().mockReturnValue(of({})), - addContributor: jest.fn().mockReturnValue(of({})), - deleteContributor: jest.fn().mockReturnValue(of({})), - bulkUpdateContributors: jest.fn().mockReturnValue(of({})), - bulkAddContributors: jest.fn().mockReturnValue(of({})), - resetContributorsState: jest.fn().mockRejectedValue(of({})), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); + fixture.componentRef.setInput('draftId', 'draft-1'); fixture.detectChanges(); }); - it('should request contributors on init', () => { - const actions = (component as any).actions; - expect(actions.getContributors).toHaveBeenCalledWith('draft-1', ResourceType.DraftRegistration); + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should dispatch getContributors on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(new GetAllContributors('draft-1', ResourceType.DraftRegistration)); }); it('should cancel changes and reset local contributors', () => { - (component as any).contributors.set([{ id: '3' }]); + component.contributors.set([{ ...MOCK_CONTRIBUTOR, id: 'changed' }]); component.cancel(); expect(component.contributors()).toEqual(JSON.parse(JSON.stringify(initialContributors))); }); it('should save changed contributors and show success toast', () => { - (component as any).contributors.set([{ ...initialContributors[0] }, { ...initialContributors[1], permission: 2 }]); + const changedContributor = { ...MOCK_CONTRIBUTOR_WITHOUT_HISTORY, permission: MOCK_CONTRIBUTOR.permission }; + component.contributors.set([{ ...MOCK_CONTRIBUTOR }, changedContributor]); + (store.dispatch as jest.Mock).mockClear(); component.save(); - expect(mockToast.showSuccess).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith( + new BulkUpdateContributors('draft-1', ResourceType.DraftRegistration, [changedContributor]) + ); + expect(mockToast.showSuccess).toHaveBeenCalledWith( + 'project.contributors.toastMessages.multipleUpdateSuccessMessage' + ); }); - it('should open add contributor dialog', () => { + it('should bulk add registered contributors and show toast when add dialog closes', () => { + const dialogClose$ = new Subject(); + mockCustomDialogService.open.mockReturnValue({ onClose: dialogClose$, close: jest.fn() } as any); + (store.dispatch as jest.Mock).mockClear(); + component.openAddContributorDialog(); - expect(mockCustomDialogService.open).toHaveBeenCalled(); + dialogClose$.next({ type: AddContributorType.Registered, data: [MOCK_CONTRIBUTOR_ADD] }); + + expect(store.dispatch).toHaveBeenCalledWith( + new BulkAddContributors('draft-1', ResourceType.DraftRegistration, [MOCK_CONTRIBUTOR_ADD]) + ); + expect(mockToast.showSuccess).toHaveBeenCalledWith('project.contributors.toastMessages.multipleAddSuccessMessage'); }); - it('should open add unregistered contributor dialog', () => { + it('should switch to unregistered dialog when add dialog closes with unregistered type', () => { + const dialogClose$ = new Subject(); + mockCustomDialogService.open.mockReturnValue({ onClose: dialogClose$, close: jest.fn() } as any); + const spy = jest.spyOn(component, 'openAddUnregisteredContributorDialog').mockImplementation(() => {}); + + component.openAddContributorDialog(); + dialogClose$.next({ type: AddContributorType.Unregistered, data: [] }); + + expect(spy).toHaveBeenCalled(); + }); + + it('should bulk add unregistered contributor and show toast with name param', () => { + const dialogClose$ = new Subject(); + const unregisteredAdd = { ...MOCK_CONTRIBUTOR_ADD, fullName: 'Test User' }; + mockCustomDialogService.open.mockReturnValue({ onClose: dialogClose$, close: jest.fn() } as any); + (store.dispatch as jest.Mock).mockClear(); + component.openAddUnregisteredContributorDialog(); - expect(mockCustomDialogService.open).toHaveBeenCalled(); + dialogClose$.next({ type: AddContributorType.Unregistered, data: [unregisteredAdd] }); + + expect(store.dispatch).toHaveBeenCalledWith( + new BulkAddContributors('draft-1', ResourceType.DraftRegistration, [unregisteredAdd]) + ); + expect(mockToast.showSuccess).toHaveBeenCalledWith('project.contributors.toastMessages.addSuccessMessage', { + name: 'Test User', + }); + }); + + it('should switch to registered dialog when unregistered dialog closes with registered type', () => { + const dialogClose$ = new Subject(); + mockCustomDialogService.open.mockReturnValue({ onClose: dialogClose$, close: jest.fn() } as any); + const spy = jest.spyOn(component, 'openAddContributorDialog').mockImplementation(() => {}); + + component.openAddUnregisteredContributorDialog(); + dialogClose$.next({ type: AddContributorType.Registered, data: [] }); + + expect(spy).toHaveBeenCalled(); }); it('should remove contributor after confirmation and show success toast', () => { - const contributor = { id: '2', userId: 'u2', fullName: 'B' } as any; - component.removeContributor(contributor); + (store.dispatch as jest.Mock).mockClear(); + component.removeContributor(MOCK_CONTRIBUTOR_WITHOUT_HISTORY); expect(mockCustomConfirmationService.confirmDelete).toHaveBeenCalled(); - const call = (mockCustomConfirmationService.confirmDelete as any).mock.calls[0][0]; + const call = (mockCustomConfirmationService.confirmDelete as jest.Mock).mock.calls[0][0]; call.onConfirm(); - expect(mockToast.showSuccess).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith( + new DeleteContributor('draft-1', ResourceType.DraftRegistration, MOCK_CONTRIBUTOR_WITHOUT_HISTORY.userId) + ); + expect(mockToast.showSuccess).toHaveBeenCalledWith('project.contributors.removeDialog.successMessage', { + name: MOCK_CONTRIBUTOR_WITHOUT_HISTORY.fullName, + }); + }); + + it('should return true for hasChanges when contributors differ from initial', () => { + component.contributors.set([{ ...MOCK_CONTRIBUTOR, id: 'changed' }]); + expect(component.hasChanges).toBe(true); + }); + + it('should return false for hasChanges when contributors match initial', () => { + expect(component.hasChanges).toBe(false); + }); + + it('should dispatch resetContributorsState on destroy', () => { + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith(new ResetContributorsState()); + }); + + it('should dispatch loadMoreContributors', () => { + (store.dispatch as jest.Mock).mockClear(); + component.loadMoreContributors(); + expect(store.dispatch).toHaveBeenCalledWith(new LoadMoreContributors('draft-1', ResourceType.DraftRegistration)); }); it('should mark control touched and dirty on focus out', () => { - const control = new FormControl([]); - const spy = jest.spyOn(control, 'updateValueAndValidity'); - fixture.componentRef.setInput('control', control); component.onFocusOut(); - expect(control.touched).toBe(true); - expect(control.dirty).toBe(true); - expect(spy).toHaveBeenCalled(); + expect(component.control().touched).toBe(true); + expect(component.control().dirty).toBe(true); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts index c69bc4e41..af89f0281 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts @@ -4,9 +4,8 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; -import { TableModule } from 'primeng/table'; -import { filter, map, of } from 'rxjs'; +import { EMPTY, filter, switchMap, tap } from 'rxjs'; import { ChangeDetectionStrategy, @@ -20,9 +19,8 @@ import { OnInit, signal, } from '@angular/core'; -import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; -import { FormControl, FormsModule } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl } from '@angular/forms'; import { AddContributorDialogComponent, @@ -51,21 +49,19 @@ import { TableParameters } from '@shared/models/table-parameters.model'; @Component({ selector: 'osf-registries-contributors', - imports: [FormsModule, TableModule, ContributorsTableComponent, TranslatePipe, Card, Button], + imports: [ContributorsTableComponent, TranslatePipe, Card, Button], templateUrl: './registries-contributors.component.html', styleUrl: './registries-contributors.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistriesContributorsComponent implements OnInit, OnDestroy { control = input.required(); + draftId = input.required(); - readonly destroyRef = inject(DestroyRef); - readonly customDialogService = inject(CustomDialogService); - readonly toastService = inject(ToastService); - readonly customConfirmationService = inject(CustomConfirmationService); - - private readonly route = inject(ActivatedRoute); - private readonly draftId = toSignal(this.route.params.pipe(map((params) => params['id'])) ?? of(undefined)); + private readonly destroyRef = inject(DestroyRef); + private readonly customDialogService = inject(CustomDialogService); + private readonly toastService = inject(ToastService); + private readonly customConfirmationService = inject(CustomConfirmationService); initialContributors = select(ContributorsSelectors.getContributors); contributors = signal([]); @@ -112,11 +108,10 @@ export class RegistriesContributorsComponent implements OnInit, OnDestroy { } onFocusOut() { - if (this.control()) { - this.control().markAsTouched(); - this.control().markAsDirty(); - this.control().updateValueAndValidity(); - } + const control = this.control(); + control.markAsTouched(); + control.markAsDirty(); + control.updateValueAndValidity(); } cancel() { @@ -142,20 +137,21 @@ export class RegistriesContributorsComponent implements OnInit, OnDestroy { }) .onClose.pipe( filter((res: ContributorDialogAddModel) => !!res), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe((res: ContributorDialogAddModel) => { - if (res.type === AddContributorType.Unregistered) { - this.openAddUnregisteredContributorDialog(); - } else { - this.actions + switchMap((res: ContributorDialogAddModel) => { + if (res.type === AddContributorType.Unregistered) { + this.openAddUnregisteredContributorDialog(); + return EMPTY; + } + + return this.actions .bulkAddContributors(this.draftId(), ResourceType.DraftRegistration, res.data) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => - this.toastService.showSuccess('project.contributors.toastMessages.multipleAddSuccessMessage') + .pipe( + tap(() => this.toastService.showSuccess('project.contributors.toastMessages.multipleAddSuccessMessage')) ); - } - }); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); } openAddUnregisteredContributorDialog() { @@ -166,19 +162,22 @@ export class RegistriesContributorsComponent implements OnInit, OnDestroy { }) .onClose.pipe( filter((res: ContributorDialogAddModel) => !!res), + switchMap((res) => { + if (res.type === AddContributorType.Registered) { + this.openAddContributorDialog(); + return EMPTY; + } + + const params = { name: res.data[0].fullName }; + return this.actions + .bulkAddContributors(this.draftId(), ResourceType.DraftRegistration, res.data) + .pipe( + tap(() => this.toastService.showSuccess('project.contributors.toastMessages.addSuccessMessage', params)) + ); + }), takeUntilDestroyed(this.destroyRef) ) - .subscribe((res: ContributorDialogAddModel) => { - if (res.type === AddContributorType.Registered) { - this.openAddContributorDialog(); - } else { - const params = { name: res.data[0].fullName }; - - this.actions.bulkAddContributors(this.draftId(), ResourceType.DraftRegistration, res.data).subscribe({ - next: () => this.toastService.showSuccess('project.contributors.toastMessages.addSuccessMessage', params), - }); - } - }); + .subscribe(); } removeContributor(contributor: ContributorModel) { diff --git a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.html b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.html index da782b1e3..0366f69b3 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.html +++ b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.html @@ -11,7 +11,7 @@

{{ 'shared.license.title' | translate }}

{ let component: RegistriesLicenseComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; + let store: Store; + let licensesSignal: WritableSignal; + let selectedLicenseSignal: WritableSignal<{ id: string; options?: Record } | null>; + let draftRegistrationSignal: WritableSignal | null>; + + const mockLicense: LicenseModel = { id: 'lic-1', name: 'MIT', requiredFields: [], url: '', text: '' }; + const mockDefaultLicense: LicenseModel = { id: 'default-1', name: 'Default', requiredFields: [], url: '', text: '' }; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + beforeEach(() => { + licensesSignal = signal([]); + selectedLicenseSignal = signal<{ id: string; options?: Record } | null>(null); + draftRegistrationSignal = signal | null>({ + providerId: 'osf', + }); - await TestBed.configureTestingModule({ - imports: [RegistriesLicenseComponent, OSFTestingModule, MockComponent(LicenseComponent)], + TestBed.configureTestingModule({ + imports: [RegistriesLicenseComponent, MockComponent(LicenseComponent)], providers: [ - { provide: ActivatedRoute, useValue: mockActivatedRoute }, + provideOSFCore(), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getLicenses, value: [] }, - { selector: RegistriesSelectors.getSelectedLicense, value: null }, - { selector: RegistriesSelectors.getDraftRegistration, value: { providerId: 'osf' } }, + { selector: RegistriesSelectors.getLicenses, value: licensesSignal }, + { selector: RegistriesSelectors.getSelectedLicense, value: selectedLicenseSignal }, + { selector: RegistriesSelectors.getDraftRegistration, value: draftRegistrationSignal }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesLicenseComponent); component = fixture.componentInstance; fixture.componentRef.setInput('control', new FormGroup({ id: new FormControl('') })); - const mockActions = { - fetchLicenses: jest.fn().mockReturnValue({}), - saveLicense: jest.fn().mockReturnValue({}), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); + fixture.componentRef.setInput('draftId', 'draft-1'); fixture.detectChanges(); }); @@ -50,36 +59,90 @@ describe('RegistriesLicenseComponent', () => { expect(component).toBeTruthy(); }); - it('should fetch licenses on init when draft present', () => { - expect((component as any).actions.fetchLicenses).toHaveBeenCalledWith('osf'); + it('should dispatch fetchLicenses on init when draft present', () => { + expect(store.dispatch).toHaveBeenCalledWith(new FetchLicenses('osf')); + }); + + it('should fetch licenses only once even if draft re-emits', () => { + (store.dispatch as jest.Mock).mockClear(); + draftRegistrationSignal.set({ providerId: 'other' }); + fixture.detectChanges(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchLicenses)); + }); + + it('should sync selected license to control when license exists in list', () => { + licensesSignal.set([mockLicense]); + selectedLicenseSignal.set({ id: 'lic-1' }); + fixture.detectChanges(); + expect(component.control().get('id')?.value).toBe('lic-1'); + }); + + it('should apply default license and save when no selected license', () => { + (store.dispatch as jest.Mock).mockClear(); + draftRegistrationSignal.set({ providerId: 'osf', defaultLicenseId: 'default-1' }); + licensesSignal.set([mockDefaultLicense]); + fixture.detectChanges(); + expect(component.control().get('id')?.value).toBe('default-1'); + expect(store.dispatch).toHaveBeenCalledWith(new SaveLicense('draft-1', 'default-1')); + }); + + it('should apply default license but not save when it has required fields', () => { + (store.dispatch as jest.Mock).mockClear(); + const licenseWithFields: LicenseModel = { + id: 'default-2', + name: 'CC-BY', + requiredFields: ['year'], + url: '', + text: '', + }; + draftRegistrationSignal.set({ providerId: 'osf', defaultLicenseId: 'default-2' }); + licensesSignal.set([licenseWithFields]); + fixture.detectChanges(); + expect(component.control().get('id')?.value).toBe('default-2'); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SaveLicense)); + }); + + it('should prefer selected license over default license', () => { + licensesSignal.set([mockDefaultLicense, mockLicense]); + draftRegistrationSignal.set({ providerId: 'osf', defaultLicenseId: 'default-1' }); + selectedLicenseSignal.set({ id: 'lic-1' }); + fixture.detectChanges(); + expect(component.control().get('id')?.value).toBe('lic-1'); }); it('should set control id and save license when selecting simple license', () => { - const saveSpy = jest.spyOn((component as any).actions, 'saveLicense'); - component.selectLicense({ id: 'lic-1', requiredFields: [] } as any); - expect((component.control() as FormGroup).get('id')?.value).toBe('lic-1'); - expect(saveSpy).toHaveBeenCalledWith('draft-1', 'lic-1'); + (store.dispatch as jest.Mock).mockClear(); + component.selectLicense(mockLicense); + expect(component.control().get('id')?.value).toBe('lic-1'); + expect(store.dispatch).toHaveBeenCalledWith(new SaveLicense('draft-1', 'lic-1')); }); it('should not save when license has required fields', () => { - const saveSpy = jest.spyOn((component as any).actions, 'saveLicense'); - component.selectLicense({ id: 'lic-2', requiredFields: ['year'] } as any); - expect(saveSpy).not.toHaveBeenCalled(); + (store.dispatch as jest.Mock).mockClear(); + component.selectLicense({ id: 'lic-2', name: 'CC-BY', requiredFields: ['year'], url: '', text: '' }); + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should create license with options', () => { - const saveSpy = jest.spyOn((component as any).actions, 'saveLicense'); - component.createLicense({ id: 'lic-3', licenseOptions: { year: '2024', copyrightHolders: 'Me' } as any }); - expect(saveSpy).toHaveBeenCalledWith('draft-1', 'lic-3', { year: '2024', copyrightHolders: 'Me' }); + it('should dispatch saveLicense with options on createLicense', () => { + (store.dispatch as jest.Mock).mockClear(); + component.createLicense({ id: 'lic-3', licenseOptions: { year: '2024', copyrightHolders: 'Me' } }); + expect(store.dispatch).toHaveBeenCalledWith( + new SaveLicense('draft-1', 'lic-3', { year: '2024', copyrightHolders: 'Me' }) + ); + }); + + it('should not apply default license when defaultLicenseId is not in the list', () => { + (store.dispatch as jest.Mock).mockClear(); + draftRegistrationSignal.set({ providerId: 'osf', defaultLicenseId: 'non-existent' }); + licensesSignal.set([mockLicense]); + fixture.detectChanges(); + expect(component.control().get('id')?.value).toBe(''); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SaveLicense)); }); it('should mark control on focus out', () => { - const control = new FormGroup({ id: new FormControl('') }); - fixture.componentRef.setInput('control', control); - const spy = jest.spyOn(control, 'updateValueAndValidity'); component.onFocusOut(); - expect(control.touched).toBe(true); - expect(control.dirty).toBe(true); - expect(spy).toHaveBeenCalled(); + expect(component.control().touched).toBe(true); + expect(component.control().dirty).toBe(true); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.ts index fdfaac181..7225338ca 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.ts @@ -5,102 +5,100 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; import { Message } from 'primeng/message'; -import { ChangeDetectionStrategy, Component, effect, inject, input, untracked } from '@angular/core'; -import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; +import { ChangeDetectionStrategy, Component, effect, inject, input, signal } from '@angular/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { FetchLicenses, RegistriesSelectors, SaveLicense } from '@osf/features/registries/store'; import { LicenseComponent } from '@osf/shared/components/license/license.component'; -import { InputLimits } from '@osf/shared/constants/input-limits.const'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const'; import { LicenseModel, LicenseOptions } from '@shared/models/license/license.model'; @Component({ selector: 'osf-registries-license', - imports: [FormsModule, ReactiveFormsModule, LicenseComponent, Card, TranslatePipe, Message], + imports: [ReactiveFormsModule, LicenseComponent, Card, TranslatePipe, Message], templateUrl: './registries-license.component.html', styleUrl: './registries-license.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistriesLicenseComponent { control = input.required(); + draftId = input.required(); - private readonly route = inject(ActivatedRoute); private readonly environment = inject(ENVIRONMENT); - private readonly draftId = this.route.snapshot.params['id']; actions = createDispatchMap({ fetchLicenses: FetchLicenses, saveLicense: SaveLicense }); - licenses = select(RegistriesSelectors.getLicenses); - inputLimits = InputLimits; + licenses = select(RegistriesSelectors.getLicenses); selectedLicense = select(RegistriesSelectors.getSelectedLicense); draftRegistration = select(RegistriesSelectors.getDraftRegistration); - currentYear = new Date(); - readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - private isLoaded = false; + private readonly licensesLoaded = signal(false); constructor() { effect(() => { - if (this.draftRegistration() && !this.isLoaded) { + if (this.draftRegistration() && !this.licensesLoaded()) { this.actions.fetchLicenses(this.draftRegistration()?.providerId ?? this.environment.defaultProvider); - this.isLoaded = true; - } - }); - - effect(() => { - const selectedLicense = this.selectedLicense(); - if (!selectedLicense) { - return; + this.licensesLoaded.set(true); } - - this.control().patchValue({ - id: selectedLicense.id, - }); }); effect(() => { + const control = this.control(); const licenses = this.licenses(); - const selectedLicense = untracked(() => this.selectedLicense()); + const selectedLicense = this.selectedLicense(); + const defaultLicenseId = this.draftRegistration()?.defaultLicenseId; - if (!licenses.length || !selectedLicense) { + if (selectedLicense && licenses.some((l) => l.id === selectedLicense.id)) { + control.patchValue({ id: selectedLicense.id }); return; } - if (!licenses.find((license) => license.id === selectedLicense.id)) { - this.control().patchValue({ - id: null, - }); - this.control().markAsTouched(); - this.control().updateValueAndValidity(); - } + this.applyDefaultLicense(control, licenses, defaultLicenseId); }); } createLicense(licenseDetails: { id: string; licenseOptions: LicenseOptions }) { - this.actions.saveLicense(this.draftId, licenseDetails.id, licenseDetails.licenseOptions); + this.actions.saveLicense(this.draftId(), licenseDetails.id, licenseDetails.licenseOptions); } selectLicense(license: LicenseModel) { if (license.requiredFields.length) { return; } - this.control().patchValue({ - id: license.id, - }); - this.control().markAsTouched(); - this.control().updateValueAndValidity(); - this.actions.saveLicense(this.draftId, license.id); + + const control = this.control(); + control.patchValue({ id: license.id }); + control.markAsTouched(); + control.updateValueAndValidity(); + this.actions.saveLicense(this.draftId(), license.id); } onFocusOut() { - if (this.control()) { - this.control().markAsTouched(); - this.control().markAsDirty(); - this.control().updateValueAndValidity(); + const control = this.control(); + control.markAsTouched(); + control.markAsDirty(); + control.updateValueAndValidity(); + } + + private applyDefaultLicense(control: FormGroup, licenses: LicenseModel[], defaultLicenseId?: string) { + if (!licenses.length || !defaultLicenseId) { + return; + } + + const defaultLicense = licenses.find((license) => license.id === defaultLicenseId); + if (!defaultLicense) { + return; + } + + control.patchValue({ id: defaultLicense.id }); + control.markAsTouched(); + control.updateValueAndValidity(); + + if (!defaultLicense.requiredFields.length) { + this.actions.saveLicense(this.draftId(), defaultLicense.id); } } } diff --git a/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.html b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.html index 98f184e8f..04af77dbc 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.html +++ b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.html @@ -39,7 +39,6 @@

{{ 'common.labels.description' | translate }}

rows="5" cols="30" pTextarea - [ariaLabel]="'common.labels.description' | translate" > @if ( metadataForm.controls['description'].errors?.['required'] && @@ -54,11 +53,17 @@

{{ 'common.labels.description' | translate }}

- - - - - + + + + +
{ +describe('RegistriesMetadataStepComponent', () => { + ngMocks.faster(); + let component: RegistriesMetadataStepComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; + let store: Store; + let mockRouter: RouterMockType; + let stepsStateSignal: WritableSignal<{ invalid: boolean }[]>; + let customConfirmationService: CustomConfirmationServiceMockType; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + const mockDraft = { ...MOCK_DRAFT_REGISTRATION, title: 'Test Title', description: 'Test Description' }; + + beforeEach(() => { + const mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); mockRouter = RouterMockBuilder.create().withUrl('/registries/osf/draft/draft-1/metadata').build(); + stepsStateSignal = signal<{ invalid: boolean }[]>([{ invalid: true }]); + customConfirmationService = CustomConfirmationServiceMock.simple(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ RegistriesMetadataStepComponent, - OSFTestingModule, + MockModule(TextareaModule), ...MockComponents( TextInputComponent, RegistriesContributorsComponent, @@ -47,20 +64,23 @@ describe.skip('RegistriesMetadataStepComponent', () => { ), ], providers: [ + provideOSFCore(), MockProvider(ActivatedRoute, mockActivatedRoute), MockProvider(Router, mockRouter), - MockProvider(CustomConfirmationService, { confirmDelete: jest.fn() }), + MockProvider(CustomConfirmationService, customConfirmationService), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false } } }, + { selector: RegistriesSelectors.getDraftRegistration, value: mockDraft }, + { selector: RegistriesSelectors.getStepsState, value: stepsStateSignal }, + { selector: RegistriesSelectors.hasDraftAdminAccess, value: true }, { selector: ContributorsSelectors.getContributors, value: [] }, { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, - { selector: InstitutionsSelectors.getResourceInstitutions, value: [] }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesMetadataStepComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -70,66 +90,97 @@ describe.skip('RegistriesMetadataStepComponent', () => { expect(component).toBeTruthy(); }); - it('should initialize form with draft data', () => { - expect(component.metadataForm.value.title).toBe(' My Title '); - expect(component.metadataForm.value.description).toBe(' Description '); - expect(component.metadataForm.value.license).toEqual({ id: 'mit' }); + it('should initialize metadataForm with required controls', () => { + expect(component.metadataForm.get('title')).toBeTruthy(); + expect(component.metadataForm.get('description')).toBeTruthy(); + expect(component.metadataForm.get('contributors')).toBeTruthy(); + expect(component.metadataForm.get('subjects')).toBeTruthy(); + expect(component.metadataForm.get('tags')).toBeTruthy(); + expect(component.metadataForm.get('license.id')).toBeTruthy(); }); - it('should compute hasAdminAccess', () => { - expect(component.hasAdminAccess()).toBe(true); + it('should have form invalid when title is empty', () => { + component.metadataForm.patchValue({ title: '', description: 'Valid' }); + expect(component.metadataForm.get('title')?.valid).toBe(false); }); - it('should submit metadata, trim values and navigate to first step', () => { - const actionsMock = { - updateDraft: jest.fn().mockReturnValue({ pipe: () => ({ subscribe: jest.fn() }) }), - deleteDraft: jest.fn(), - clearState: jest.fn(), - updateStepState: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + it('should submit metadata and navigate to step 1', () => { + component.metadataForm.patchValue({ title: 'New Title', description: 'New Desc' }); + (store.dispatch as jest.Mock).mockClear(); component.submitMetadata(); - expect(actionsMock.updateDraft).toHaveBeenCalledWith('draft-1', { - title: 'My Title', - description: 'Description', - }); - expect(navSpy).toHaveBeenCalledWith(['../1'], { - relativeTo: TestBed.inject(ActivatedRoute), - onSameUrlNavigation: 'reload', - }); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateDraft('draft-1', { title: 'New Title', description: 'New Desc' }) + ); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../1'], + expect.objectContaining({ onSameUrlNavigation: 'reload' }) + ); }); - it('should delete draft on confirm and navigate to new registration', () => { - const confirmService = TestBed.inject(CustomConfirmationService) as jest.Mocked as any; - const actionsMock = { - deleteDraft: jest.fn().mockReturnValue({ subscribe: ({ next }: any) => next() }), - clearState: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigateByUrl'); + it('should trim title and description on submit', () => { + component.metadataForm.patchValue({ title: ' Padded Title ', description: ' Padded Desc ' }); + (store.dispatch as jest.Mock).mockClear(); - (confirmService.confirmDelete as jest.Mock).mockImplementation(({ onConfirm }) => onConfirm()); + component.submitMetadata(); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateDraft('draft-1', { title: 'Padded Title', description: 'Padded Desc' }) + ); + }); + + it('should call confirmDelete when deleteDraft is called', () => { component.deleteDraft(); + expect(customConfirmationService.confirmDelete).toHaveBeenCalledWith( + expect.objectContaining({ + headerKey: 'registries.deleteDraft', + messageKey: 'registries.confirmDeleteDraft', + }) + ); + }); - expect(actionsMock.clearState).toHaveBeenCalled(); - expect(navSpy).toHaveBeenCalledWith('/registries/osf/new'); + it('should set isDraftDeleted and navigate on deleteDraft confirm', () => { + customConfirmationService.confirmDelete.mockImplementation(({ onConfirm }: any) => onConfirm()); + (store.dispatch as jest.Mock).mockClear(); + + component.deleteDraft(); + + expect(component.isDraftDeleted).toBe(true); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteDraft('draft-1')); + expect(store.dispatch).toHaveBeenCalledWith(new ClearState()); + expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/registries/osf/new'); + }); + + it('should skip updates on destroy when isDraftDeleted is true', () => { + (store.dispatch as jest.Mock).mockClear(); + component.isDraftDeleted = true; + component.ngOnDestroy(); + + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should update step state and draft on destroy if changed', () => { - const actionsMock = { - updateStepState: jest.fn(), - updateDraft: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); + it('should update step state on destroy when fields are unchanged', () => { + component.metadataForm.patchValue({ title: 'Test Title', description: 'Test Description' }); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdateStepState('0', expect.any(Boolean), true)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdateDraft)); + }); - component.metadataForm.patchValue({ title: 'Changed', description: 'Changed desc' }); - fixture.destroy(); + it('should dispatch updateDraft on destroy when fields have changed', () => { + component.metadataForm.patchValue({ title: 'Changed Title', description: 'Test Description' }); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdateStepState('0', expect.any(Boolean), true)); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateDraft('draft-1', expect.objectContaining({ title: 'Changed Title' })) + ); + }); - expect(actionsMock.updateStepState).toHaveBeenCalledWith('0', true, true); - expect(actionsMock.updateDraft).toHaveBeenCalledWith('draft-1', { title: 'Changed', description: 'Changed desc' }); + it('should mark form as touched when step state is invalid on init', () => { + expect(component.metadataForm.touched).toBe(true); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.ts index bfcc1e5f0..589ec9174 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.ts @@ -7,9 +7,10 @@ import { Card } from 'primeng/card'; import { Message } from 'primeng/message'; import { TextareaModule } from 'primeng/textarea'; -import { tap } from 'rxjs'; +import { filter, take, tap } from 'rxjs'; -import { ChangeDetectionStrategy, Component, computed, effect, inject, OnDestroy } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnDestroy } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; @@ -21,7 +22,6 @@ import { findChangedFields } from '@osf/shared/helpers/find-changed-fields'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ContributorsSelectors } from '@osf/shared/stores/contributors'; import { SubjectsSelectors } from '@osf/shared/stores/subjects'; -import { UserPermissions } from '@shared/enums/user-permissions.enum'; import { ContributorModel } from '@shared/models/contributors/contributor.model'; import { DraftRegistrationModel } from '@shared/models/registration/draft-registration.model'; import { SubjectModel } from '@shared/models/subject/subject.model'; @@ -58,21 +58,18 @@ export class RegistriesMetadataStepComponent implements OnDestroy { private readonly fb = inject(FormBuilder); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); private readonly customConfirmationService = inject(CustomConfirmationService); readonly titleLimit = InputLimits.title.maxLength; - private readonly draftId = this.route.snapshot.params['id']; - readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); + readonly draftId = this.route.snapshot.params['id']; + + draftRegistration = select(RegistriesSelectors.getDraftRegistration); selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); initialContributors = select(ContributorsSelectors.getContributors); stepsState = select(RegistriesSelectors.getStepsState); - - hasAdminAccess = computed(() => { - const registry = this.draftRegistration(); - if (!registry) return false; - return registry.currentUserPermissions.includes(UserPermissions.Admin); - }); + hasAdminAccess = select(RegistriesSelectors.hasDraftAdminAccess); actions = createDispatchMap({ deleteDraft: DeleteDraft, @@ -89,35 +86,29 @@ export class RegistriesMetadataStepComponent implements OnDestroy { contributors: [[] as ContributorModel[], Validators.required], subjects: [[] as SubjectModel[], Validators.required], tags: [[]], - license: this.fb.group({ - id: ['', Validators.required], - }), + license: this.fb.group({ id: ['', Validators.required] }), }); isDraftDeleted = false; - isFormUpdated = false; constructor() { - effect(() => { - const draft = this.draftRegistration(); - // TODO: This shouldn't be an effect() - if (draft && !this.isFormUpdated) { - this.updateFormValue(draft); - this.isFormUpdated = true; - } - }); + toObservable(this.draftRegistration) + .pipe(filter(Boolean), take(1), takeUntilDestroyed(this.destroyRef)) + .subscribe((draft) => this.updateFormValue(draft)); } - private updateFormValue(data: DraftRegistrationModel): void { - this.metadataForm.patchValue({ - title: data.title, - description: data.description, - license: data.license, - contributors: this.initialContributors(), - subjects: this.selectedSubjects(), - }); - if (this.stepsState()?.[0]?.invalid) { - this.metadataForm.markAllAsTouched(); + ngOnDestroy(): void { + if (!this.isDraftDeleted) { + this.actions.updateStepState('0', this.metadataForm.invalid, true); + const changedFields = findChangedFields( + { title: this.metadataForm.value.title!, description: this.metadataForm.value.description! }, + { title: this.draftRegistration()?.title, description: this.draftRegistration()?.description } + ); + + if (Object.keys(changedFields).length > 0) { + this.actions.updateDraft(this.draftId, changedFields); + this.metadataForm.markAllAsTouched(); + } } } @@ -156,17 +147,17 @@ export class RegistriesMetadataStepComponent implements OnDestroy { }); } - ngOnDestroy(): void { - if (!this.isDraftDeleted) { - this.actions.updateStepState('0', this.metadataForm.invalid, true); - const changedFields = findChangedFields( - { title: this.metadataForm.value.title!, description: this.metadataForm.value.description! }, - { title: this.draftRegistration()?.title, description: this.draftRegistration()?.description } - ); - if (Object.keys(changedFields).length > 0) { - this.actions.updateDraft(this.draftId, changedFields); - this.metadataForm.markAllAsTouched(); - } + private updateFormValue(data: DraftRegistrationModel): void { + this.metadataForm.patchValue({ + title: data.title, + description: data.description, + license: data.license, + contributors: this.initialContributors(), + subjects: this.selectedSubjects(), + }); + + if (this.stepsState()?.[0]?.invalid) { + this.metadataForm.markAllAsTouched(); } } } diff --git a/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.spec.ts index 9c8ecbff4..f86e440b3 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.spec.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.spec.ts @@ -1,57 +1,58 @@ -import { MockComponent, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { of } from 'rxjs'; +import { MockComponent } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormControl } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; +import { FormControl, Validators } from '@angular/forms'; import { RegistriesSelectors } from '@osf/features/registries/store'; import { SubjectsComponent } from '@osf/shared/components/subjects/subjects.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { SubjectsSelectors } from '@osf/shared/stores/subjects'; +import { + FetchChildrenSubjects, + FetchSelectedSubjects, + FetchSubjects, + SubjectsSelectors, + UpdateResourceSubjects, +} from '@osf/shared/stores/subjects'; +import { SubjectModel } from '@shared/models/subject/subject.model'; import { RegistriesSubjectsComponent } from './registries-subjects.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { MOCK_DRAFT_REGISTRATION } from '@testing/mocks/draft-registration.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistriesSubjectsComponent', () => { let component: RegistriesSubjectsComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; + let store: Store; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); - await TestBed.configureTestingModule({ - imports: [RegistriesSubjectsComponent, OSFTestingModule, MockComponent(SubjectsComponent)], + const mockSubjects: SubjectModel[] = [ + { id: 'sub-1', name: 'Subject 1' }, + { id: 'sub-2', name: 'Subject 2' }, + ]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RegistriesSubjectsComponent, MockComponent(SubjectsComponent)], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), + provideOSFCore(), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getDraftRegistration, value: { providerId: 'prov-1' } }, { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, - { selector: SubjectsSelectors.getSubjects, value: [] }, - { selector: SubjectsSelectors.getSearchedSubjects, value: [] }, - { selector: SubjectsSelectors.getSubjectsLoading, value: false }, - { selector: SubjectsSelectors.getSearchedSubjectsLoading, value: false }, { selector: SubjectsSelectors.areSelectedSubjectsLoading, value: false }, + { selector: RegistriesSelectors.getDraftRegistration, value: MOCK_DRAFT_REGISTRATION }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesSubjectsComponent); component = fixture.componentInstance; - fixture.componentRef.setInput('control', new FormControl([])); - const mockActions = { - fetchSubjects: jest.fn().mockReturnValue(of({})), - fetchSelectedSubjects: jest.fn().mockReturnValue(of({})), - fetchChildrenSubjects: jest.fn().mockReturnValue(of({})), - updateResourceSubjects: jest.fn().mockReturnValue(of({})), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); + fixture.componentRef.setInput('control', new FormControl(null, Validators.required)); + fixture.componentRef.setInput('draftId', 'draft-1'); fixture.detectChanges(); }); @@ -59,33 +60,54 @@ describe('RegistriesSubjectsComponent', () => { expect(component).toBeTruthy(); }); - it('should fetch subjects and selected subjects on init', () => { - const actions = (component as any).actions; - expect(actions.fetchSubjects).toHaveBeenCalledWith(ResourceType.Registration, 'prov-1'); - expect(actions.fetchSelectedSubjects).toHaveBeenCalledWith('draft-1', ResourceType.DraftRegistration); + it('should dispatch fetchSubjects and fetchSelectedSubjects on init', () => { + expect(store.dispatch).toHaveBeenCalledWith( + new FetchSubjects(ResourceType.Registration, MOCK_DRAFT_REGISTRATION.providerId) + ); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSelectedSubjects('draft-1', ResourceType.DraftRegistration)); }); - it('should fetch children on demand', () => { - const actions = (component as any).actions; + it('should dispatch fetchChildrenSubjects on getSubjectChildren', () => { + (store.dispatch as jest.Mock).mockClear(); component.getSubjectChildren('parent-1'); - expect(actions.fetchChildrenSubjects).toHaveBeenCalledWith('parent-1'); + expect(store.dispatch).toHaveBeenCalledWith(new FetchChildrenSubjects('parent-1')); }); - it('should search subjects', () => { - const actions = (component as any).actions; - component.searchSubjects('term'); - expect(actions.fetchSubjects).toHaveBeenCalledWith(ResourceType.Registration, 'prov-1', 'term'); + it('should dispatch fetchSubjects with search term on searchSubjects', () => { + (store.dispatch as jest.Mock).mockClear(); + component.searchSubjects('biology'); + expect(store.dispatch).toHaveBeenCalledWith( + new FetchSubjects(ResourceType.Registration, MOCK_DRAFT_REGISTRATION.providerId, 'biology') + ); }); - it('should update selected subjects and control state', () => { - const actions = (component as any).actions; - const nextSubjects = [{ id: 's1' } as any]; - component.updateSelectedSubjects(nextSubjects); - expect(actions.updateResourceSubjects).toHaveBeenCalledWith( - 'draft-1', - ResourceType.DraftRegistration, - nextSubjects + it('should dispatch updateResourceSubjects and update control on updateSelectedSubjects', () => { + (store.dispatch as jest.Mock).mockClear(); + component.updateSelectedSubjects(mockSubjects); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateResourceSubjects('draft-1', ResourceType.DraftRegistration, mockSubjects) ); - expect(component.control().value).toEqual(nextSubjects); + expect(component.control().value).toEqual(mockSubjects); + expect(component.control().touched).toBe(true); + expect(component.control().dirty).toBe(true); + }); + + it('should mark control as touched and dirty on focusout', () => { + component.onFocusOut(); + expect(component.control().touched).toBe(true); + expect(component.control().dirty).toBe(true); + }); + + it('should have invalid control when value is null', () => { + component.control().markAsTouched(); + component.control().updateValueAndValidity(); + expect(component.control().valid).toBe(false); + expect(component.control().errors?.['required']).toBeTruthy(); + }); + + it('should have valid control when subjects are set', () => { + component.updateControlState(mockSubjects); + expect(component.control().valid).toBe(true); + expect(component.control().errors).toBeNull(); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.ts index 01915ab96..9e0f38db6 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.ts @@ -5,9 +5,8 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; import { Message } from 'primeng/message'; -import { ChangeDetectionStrategy, Component, effect, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, input, signal } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; import { RegistriesSelectors } from '@osf/features/registries/store'; import { SubjectsComponent } from '@osf/shared/components/subjects/subjects.component'; @@ -31,8 +30,7 @@ import { SubjectModel } from '@shared/models/subject/subject.model'; }) export class RegistriesSubjectsComponent { control = input.required(); - private readonly route = inject(ActivatedRoute); - private readonly draftId = this.route.snapshot.params['id']; + draftId = input.required(); selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); isSubjectsUpdating = select(SubjectsSelectors.areSelectedSubjectsLoading); @@ -47,14 +45,14 @@ export class RegistriesSubjectsComponent { readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - private isLoaded = false; + private readonly isLoaded = signal(false); constructor() { effect(() => { - if (this.draftRegistration() && !this.isLoaded) { + if (this.draftRegistration() && !this.isLoaded()) { this.actions.fetchSubjects(ResourceType.Registration, this.draftRegistration()?.providerId); - this.actions.fetchSelectedSubjects(this.draftId, ResourceType.DraftRegistration); - this.isLoaded = true; + this.actions.fetchSelectedSubjects(this.draftId(), ResourceType.DraftRegistration); + this.isLoaded.set(true); } }); } @@ -69,23 +67,21 @@ export class RegistriesSubjectsComponent { updateSelectedSubjects(subjects: SubjectModel[]) { this.updateControlState(subjects); - this.actions.updateResourceSubjects(this.draftId, ResourceType.DraftRegistration, subjects); + this.actions.updateResourceSubjects(this.draftId(), ResourceType.DraftRegistration, subjects); } onFocusOut() { - if (this.control()) { - this.control().markAsTouched(); - this.control().markAsDirty(); - this.control().updateValueAndValidity(); - } + const control = this.control(); + control.markAsTouched(); + control.markAsDirty(); + control.updateValueAndValidity(); } updateControlState(value: SubjectModel[]) { - if (this.control()) { - this.control().setValue(value); - this.control().markAsTouched(); - this.control().markAsDirty(); - this.control().updateValueAndValidity(); - } + const control = this.control(); + control.setValue(value); + control.markAsTouched(); + control.markAsDirty(); + control.updateValueAndValidity(); } } diff --git a/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.spec.ts index 4072f1ed6..914396af1 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.spec.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.spec.ts @@ -1,36 +1,34 @@ -import { of } from 'rxjs'; +import { Store } from '@ngxs/store'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { RegistriesSelectors, UpdateDraft } from '@osf/features/registries/store'; import { RegistriesTagsComponent } from './registries-tags.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistriesTagsComponent', () => { let component: RegistriesTagsComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; + let store: Store; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'someId' }).build(); - - await TestBed.configureTestingModule({ - imports: [RegistriesTagsComponent, OSFTestingModule], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RegistriesTagsComponent], providers: [ - { provide: ActivatedRoute, useValue: mockActivatedRoute }, + provideOSFCore(), provideMockStore({ signals: [{ selector: RegistriesSelectors.getSelectedTags, value: [] }], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesTagsComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('draftId', 'someId'); fixture.detectChanges(); }); @@ -44,11 +42,7 @@ describe('RegistriesTagsComponent', () => { }); it('should update tags on change', () => { - const mockActions = { - updateDraft: jest.fn().mockReturnValue(of({})), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); component.onTagsChanged(['a', 'b']); - expect(mockActions.updateDraft).toHaveBeenCalledWith('someId', { tags: ['a', 'b'] }); + expect(store.dispatch).toHaveBeenCalledWith(new UpdateDraft('someId', { tags: ['a', 'b'] })); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.ts index 5c8c32cd1..dcba22a36 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.ts @@ -4,8 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { RegistriesSelectors, UpdateDraft } from '@osf/features/registries/store'; import { TagsInputComponent } from '@osf/shared/components/tags-input/tags-input.component'; @@ -18,15 +17,13 @@ import { TagsInputComponent } from '@osf/shared/components/tags-input/tags-input changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistriesTagsComponent { - private readonly route = inject(ActivatedRoute); - private readonly draftId = this.route.snapshot.params['id']; - selectedTags = select(RegistriesSelectors.getSelectedTags); + draftId = input.required(); + + actions = createDispatchMap({ updateDraft: UpdateDraft }); - actions = createDispatchMap({ - updateDraft: UpdateDraft, - }); + selectedTags = select(RegistriesSelectors.getSelectedTags); onTagsChanged(tags: string[]): void { - this.actions.updateDraft(this.draftId, { tags }); + this.actions.updateDraft(this.draftId(), { tags }); } } diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html index a79223195..e8f7a46ba 100644 --- a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html @@ -5,15 +5,21 @@ } @else { - Provider Logo + }
- + @if (!isProviderLoading() && provider()!.allowSubmissions) { + + }
diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts index dd8165953..bc9d6e446 100644 --- a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts @@ -4,31 +4,58 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; +import { BrandService } from '@osf/shared/services/brand.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { HeaderStyleService } from '@osf/shared/services/header-style.service'; +import { RegistryProviderDetails } from '@shared/models/provider/registry-provider.model'; import { RegistryProviderHeroComponent } from './registry-provider-hero.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; describe('RegistryProviderHeroComponent', () => { let component: RegistryProviderHeroComponent; let fixture: ComponentFixture; - let mockCustomDialogService: ReturnType; + let mockRouter: RouterMockType; + let mockDialog: CustomDialogServiceMockType; + let mockBrandService: { applyBranding: jest.Mock; resetBranding: jest.Mock }; + let mockHeaderStyleService: { applyHeaderStyles: jest.Mock; resetToDefaults: jest.Mock }; - beforeEach(async () => { - const mockRouter = RouterMockBuilder.create().withUrl('/x').build(); - mockCustomDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - await TestBed.configureTestingModule({ - imports: [RegistryProviderHeroComponent, OSFTestingModule, MockComponent(SearchInputComponent)], - providers: [MockProvider(Router, mockRouter), MockProvider(CustomDialogService, mockCustomDialogService)], - }).compileComponents(); + const mockProvider: RegistryProviderDetails = { + id: 'prov-1', + name: 'Provider', + descriptionHtml: '', + permissions: [], + brand: null, + iri: '', + reviewsWorkflow: '', + }; + + beforeEach(() => { + mockRouter = RouterMockBuilder.create().withUrl('/x').build(); + mockDialog = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); + mockBrandService = { applyBranding: jest.fn(), resetBranding: jest.fn() }; + mockHeaderStyleService = { applyHeaderStyles: jest.fn(), resetToDefaults: jest.fn() }; + + TestBed.configureTestingModule({ + imports: [RegistryProviderHeroComponent, MockComponent(SearchInputComponent)], + providers: [ + provideOSFCore(), + MockProvider(Router, mockRouter), + MockProvider(CustomDialogService, mockDialog), + MockProvider(BrandService, mockBrandService), + MockProvider(HeaderStyleService, mockHeaderStyleService), + ], + }); fixture = TestBed.createComponent(RegistryProviderHeroComponent); component = fixture.componentInstance; - - fixture.componentRef.setInput('provider', { id: 'prov-1', title: 'Provider', brand: undefined } as any); + fixture.componentRef.setInput('provider', mockProvider); fixture.componentRef.setInput('isProviderLoading', false); fixture.detectChanges(); }); @@ -45,24 +72,47 @@ describe('RegistryProviderHeroComponent', () => { it('should open help dialog', () => { component.openHelpDialog(); - expect(mockCustomDialogService.open).toHaveBeenCalledWith(expect.any(Function), { + expect(mockDialog.open).toHaveBeenCalledWith(expect.any(Function), { header: 'preprints.helpDialog.header', }); }); it('should navigate to create page when provider id present', () => { - const router = TestBed.inject(Router); - const navSpy = jest.spyOn(router, 'navigate'); - fixture.componentRef.setInput('provider', { id: 'prov-1', title: 'Provider', brand: undefined } as any); component.navigateToCreatePage(); - expect(navSpy).toHaveBeenCalledWith(['/registries/prov-1/new']); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/prov-1/new']); }); it('should not navigate when provider id missing', () => { - const router = TestBed.inject(Router); - const navSpy = jest.spyOn(router, 'navigate'); - fixture.componentRef.setInput('provider', { id: undefined, title: 'Provider', brand: undefined } as any); + fixture.componentRef.setInput('provider', { ...mockProvider, id: undefined }); component.navigateToCreatePage(); - expect(navSpy).not.toHaveBeenCalled(); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('should apply branding and header styles when provider has brand', () => { + const brand = { + primaryColor: '#111', + secondaryColor: '#222', + backgroundColor: '#333', + topNavLogoImageUrl: 'logo.png', + heroBackgroundImageUrl: 'hero.png', + }; + + fixture.componentRef.setInput('provider', { ...mockProvider, brand }); + fixture.detectChanges(); + + expect(mockBrandService.applyBranding).toHaveBeenCalledWith(brand); + expect(mockHeaderStyleService.applyHeaderStyles).toHaveBeenCalledWith('#ffffff', '#111', 'hero.png'); + }); + + it('should not apply branding when provider has no brand', () => { + expect(mockBrandService.applyBranding).not.toHaveBeenCalled(); + expect(mockHeaderStyleService.applyHeaderStyles).not.toHaveBeenCalled(); + }); + + it('should reset branding and header styles on destroy', () => { + component.ngOnDestroy(); + + expect(mockHeaderStyleService.resetToDefaults).toHaveBeenCalled(); + expect(mockBrandService.resetBranding).toHaveBeenCalled(); }); }); diff --git a/src/app/features/registries/components/registry-services/registry-services.component.spec.ts b/src/app/features/registries/components/registry-services/registry-services.component.spec.ts index bf13f3b1d..a5878279c 100644 --- a/src/app/features/registries/components/registry-services/registry-services.component.spec.ts +++ b/src/app/features/registries/components/registry-services/registry-services.component.spec.ts @@ -1,17 +1,21 @@ +import { MockProvider } from 'ng-mocks'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; import { RegistryServicesComponent } from './registry-services.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('RegistryServicesComponent', () => { let component: RegistryServicesComponent; let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RegistryServicesComponent, OSFTestingModule], - }).compileComponents(); + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RegistryServicesComponent], + providers: [provideOSFCore(), MockProvider(ActivatedRoute)], + }); fixture = TestBed.createComponent(RegistryServicesComponent); component = fixture.componentInstance; diff --git a/src/app/features/registries/components/review/review.component.html b/src/app/features/registries/components/review/review.component.html index 2cd5eeba4..89eaa06cc 100644 --- a/src/app/features/registries/components/review/review.component.html +++ b/src/app/features/registries/components/review/review.component.html @@ -4,7 +4,7 @@

{{ 'navigation.metadata' | translate }}

{{ 'common.labels.title' | translate }}

-

{{ draftRegistration()?.title | fixSpecialChar }}

+

{{ draftRegistration()?.title }}

@if (!draftRegistration()?.title) {

{{ 'common.labels.title' | translate }}

{{ 'common.labels.noData' | translate }}

@@ -16,7 +16,7 @@

{{ 'common.labels.title' | translate }}

{{ 'common.labels.description' | translate }}

-

{{ draftRegistration()?.description | fixSpecialChar }}

+

{{ draftRegistration()?.description }}

@if (!draftRegistration()?.description) {

{{ 'common.labels.noData' | translate }}

@@ -120,13 +120,13 @@

{{ section.title }}

[label]="'common.buttons.back' | translate" severity="info" class="mr-2" - (click)="goBack()" + (onClick)="goBack()" > @@ -135,7 +135,7 @@

{{ section.title }}

data-test-goto-register [label]="'registries.review.register' | translate" [disabled]="registerButtonDisabled()" - (click)="confirmRegistration()" + (onClick)="confirmRegistration()" >
diff --git a/src/app/features/registries/components/review/review.component.spec.ts b/src/app/features/registries/components/review/review.component.spec.ts index 510605975..1771c7971 100644 --- a/src/app/features/registries/components/review/review.component.spec.ts +++ b/src/app/features/registries/components/review/review.component.spec.ts @@ -1,119 +1,435 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; -import { of } from 'rxjs'; +import { Subject } from 'rxjs'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { + ClearState, + DeleteDraft, + FetchLicenses, + FetchProjectChildren, + RegistriesSelectors, +} from '@osf/features/registries/store'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { LicenseDisplayComponent } from '@osf/shared/components/license-display/license-display.component'; import { RegistrationBlocksDataComponent } from '@osf/shared/components/registration-blocks-data/registration-blocks-data.component'; -import { FieldType } from '@osf/shared/enums/field-type.enum'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { ContributorsSelectors } from '@osf/shared/stores/contributors'; -import { SubjectsSelectors } from '@osf/shared/stores/subjects'; +import { + ContributorsSelectors, + GetAllContributors, + LoadMoreContributors, + ResetContributorsState, +} from '@osf/shared/stores/contributors'; +import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; import { ReviewComponent } from './review.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; + +const DEFAULT_DRAFT = { + id: 'draft-1', + providerId: 'prov-1', + currentUserPermissions: [], + hasProject: false, + license: { options: {} }, + branchedFrom: { id: 'proj-1', type: 'nodes' }, +}; + +function createDefaultSignals(overrides: { selector: any; value: any }[] = []) { + const defaults = [ + { selector: RegistriesSelectors.getPagesSchema, value: [] }, + { selector: RegistriesSelectors.getDraftRegistration, value: DEFAULT_DRAFT }, + { selector: RegistriesSelectors.isDraftSubmitting, value: false }, + { selector: RegistriesSelectors.isDraftLoading, value: false }, + { selector: RegistriesSelectors.getStepsData, value: {} }, + { selector: RegistriesSelectors.getRegistrationComponents, value: [] }, + { selector: RegistriesSelectors.getRegistrationLicense, value: null }, + { selector: RegistriesSelectors.getRegistration, value: { id: 'new-reg-1' } }, + { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false } } }, + { selector: RegistriesSelectors.hasDraftAdminAccess, value: true }, + { selector: ContributorsSelectors.getContributors, value: [] }, + { selector: ContributorsSelectors.isContributorsLoading, value: false }, + { selector: ContributorsSelectors.hasMoreContributors, value: false }, + { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, + ]; + + return overrides.length + ? defaults.map((s) => { + const override = overrides.find((o) => o.selector === s.selector); + return override ? { ...s, value: override.value } : s; + }) + : defaults; +} + +function setup( + opts: { + selectorOverrides?: { selector: any; value: any }[]; + dialogCloseSubject?: Subject; + } = {} +) { + const mockRouter = RouterMockBuilder.create().withUrl('/registries/123/review').build(); + const mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + + const dialogClose$ = opts.dialogCloseSubject ?? new Subject(); + const mockDialog = CustomDialogServiceMockBuilder.create() + .withOpen( + jest.fn().mockReturnValue({ + onClose: dialogClose$.pipe(), + close: jest.fn(), + }) + ) + .build(); + + const mockToast = ToastServiceMock.simple(); + const mockConfirmation = CustomConfirmationServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [ + ReviewComponent, + ...MockComponents(RegistrationBlocksDataComponent, ContributorsListComponent, LicenseDisplayComponent), + ], + providers: [ + provideOSFCore(), + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + MockProvider(CustomDialogService, mockDialog), + MockProvider(CustomConfirmationService, mockConfirmation), + MockProvider(ToastService, mockToast), + provideMockStore({ signals: createDefaultSignals(opts.selectorOverrides) }), + ], + }); + + const store = TestBed.inject(Store); + const fixture = TestBed.createComponent(ReviewComponent); + const component = fixture.componentInstance; + fixture.detectChanges(); + + return { fixture, component, store, mockRouter, mockDialog, mockToast, mockConfirmation, dialogClose$ }; +} describe('ReviewComponent', () => { let component: ReviewComponent; - let fixture: ComponentFixture; - let mockRouter: ReturnType; - let mockActivatedRoute: ReturnType; - let mockDialog: ReturnType; - let mockConfirm: ReturnType; - let mockToast: ReturnType; - - beforeEach(async () => { - mockRouter = RouterMockBuilder.create().withUrl('/registries/123/review').build(); - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); - - mockDialog = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - mockConfirm = CustomConfirmationServiceMockBuilder.create() - .withConfirmDelete(jest.fn((opts) => opts.onConfirm && opts.onConfirm())) - .build(); - mockToast = ToastServiceMockBuilder.create().build(); - - await TestBed.configureTestingModule({ - imports: [ - ReviewComponent, - OSFTestingModule, - ...MockComponents(RegistrationBlocksDataComponent, ContributorsListComponent, LicenseDisplayComponent), - ], - providers: [ - MockProvider(Router, mockRouter), - MockProvider(ActivatedRoute, mockActivatedRoute), - MockProvider(CustomDialogService, mockDialog), - MockProvider(CustomConfirmationService, mockConfirm), - MockProvider(ToastService, mockToast), - provideMockStore({ - signals: [ - { selector: RegistriesSelectors.getPagesSchema, value: [] }, - { - selector: RegistriesSelectors.getDraftRegistration, - value: { id: 'draft-1', providerId: 'prov-1', currentUserPermissions: [], hasProject: false }, - }, - { selector: RegistriesSelectors.isDraftSubmitting, value: false }, - { selector: RegistriesSelectors.isDraftLoading, value: false }, - { selector: RegistriesSelectors.getStepsData, value: {} }, - { selector: RegistriesSelectors.getRegistrationComponents, value: [] }, - { selector: RegistriesSelectors.getRegistrationLicense, value: null }, - { selector: RegistriesSelectors.getRegistration, value: { id: 'new-reg-1' } }, - { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false } } }, - { selector: ContributorsSelectors.getContributors, value: [] }, - { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, - ], - }), - ], - }).compileComponents(); + let store: Store; + let mockRouter: RouterMockType; + let mockDialog: CustomDialogServiceMockType; + let mockToast: ToastServiceMockType; + let mockConfirmation: CustomConfirmationServiceMockType; + let dialogClose$: Subject; - fixture = TestBed.createComponent(ReviewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + beforeEach(() => { + const result = setup(); + component = result.component; + store = result.store; + mockRouter = result.mockRouter; + mockDialog = result.mockDialog; + mockToast = result.mockToast; + mockConfirmation = result.mockConfirmation; + dialogClose$ = result.dialogClose$; }); it('should create', () => { expect(component).toBeTruthy(); - expect(component.FieldType).toBe(FieldType); }); - it('should navigate back to previous step', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + it('should dispatch getContributors, getSubjects and fetchLicenses on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(new GetAllContributors('draft-1', ResourceType.DraftRegistration)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSelectedSubjects('draft-1', ResourceType.DraftRegistration)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchLicenses('prov-1')); + }); + + it('should navigate to previous step on goBack', () => { + const { component: c, mockRouter: router } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getPagesSchema, value: [{ id: '1' }, { id: '2' }] }], + }); + + c.goBack(); + + expect(router.navigate).toHaveBeenCalledWith( + ['../', 2], + expect.objectContaining({ relativeTo: expect.anything() }) + ); + }); + + it('should navigate to step 0 when pages is empty on goBack', () => { component.goBack(); - expect(navSpy).toHaveBeenCalledWith(['../', 0], { relativeTo: TestBed.inject(ActivatedRoute) }); + + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../', 0], + expect.objectContaining({ relativeTo: expect.anything() }) + ); }); - it('should open confirmation dialog when deleting draft and navigate on confirm', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigateByUrl'); - (component as any).actions = { - ...component.actions, - deleteDraft: jest.fn().mockReturnValue(of({})), - clearState: jest.fn(), - }; + it('should dispatch deleteDraft and navigate on confirm', () => { + mockConfirmation.confirmDelete.mockImplementation(({ onConfirm }: any) => onConfirm()); + (store.dispatch as jest.Mock).mockClear(); component.deleteDraft(); - expect(mockConfirm.confirmDelete).toHaveBeenCalled(); - expect(navSpy).toHaveBeenCalledWith('/registries/prov-1/new'); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteDraft('draft-1')); + expect(store.dispatch).toHaveBeenCalledWith(new ClearState()); + expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/registries/prov-1/new'); + }); + + it('should open select components dialog when components exist', () => { + const { component: c, mockDialog: dialog } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationComponents, value: [{ id: 'comp-1' }] }], + }); + + c.confirmRegistration(); + + expect(dialog.open).toHaveBeenCalled(); + const firstCallArgs = (dialog.open as jest.Mock).mock.calls[0]; + expect(firstCallArgs[1].header).toBe('registries.review.selectComponents.title'); }); - it('should open select components dialog when components exist and chain to confirm', () => { - (component as any).components = () => ['c1', 'c2']; - (mockDialog.open as jest.Mock).mockReturnValueOnce({ onClose: of(['c1']) } as any); + it('should open confirm registration dialog when no components', () => { component.confirmRegistration(); expect(mockDialog.open).toHaveBeenCalled(); - expect((mockDialog.open as jest.Mock).mock.calls.length).toBeGreaterThan(1); + const firstCallArgs = (mockDialog.open as jest.Mock).mock.calls[0]; + expect(firstCallArgs[1].header).toBe('registries.review.confirmation.title'); + }); + + it('should show success toast and navigate on successful registration', () => { + component.openConfirmRegistrationDialog(); + dialogClose$.next(true); + + expect(mockToast.showSuccess).toHaveBeenCalledWith('registries.review.confirmation.successMessage'); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/new-reg-1/overview']); + }); + + it('should reopen select components dialog when confirm dialog closed with falsy result and components exist', () => { + const { + component: c, + mockDialog: dialog, + dialogClose$: close$, + } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationComponents, value: [{ id: 'comp-1' }] }], + }); + + c.openConfirmRegistrationDialog(['comp-1']); + close$.next(false); + + expect(dialog.open).toHaveBeenCalledTimes(2); + }); + + it('should not navigate when confirm dialog closed with falsy result and no components', () => { + component.openConfirmRegistrationDialog(); + dialogClose$.next(false); + + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('should pass selected components from select dialog to confirm dialog', () => { + const selectClose$ = new Subject(); + const confirmClose$ = new Subject(); + let callCount = 0; + + const { component: c, mockDialog: dialog } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationComponents, value: [{ id: 'comp-1' }] }], + }); + + (dialog.open as jest.Mock).mockImplementation(() => { + callCount++; + const subj = callCount === 1 ? selectClose$ : confirmClose$; + return { onClose: subj.pipe(), close: jest.fn() }; + }); + + c.openSelectComponentsForRegistrationDialog(); + selectClose$.next(['comp-1']); + + expect(dialog.open).toHaveBeenCalledTimes(2); + const secondCallArgs = (dialog.open as jest.Mock).mock.calls[1]; + expect(secondCallArgs[1].data.components).toEqual(['comp-1']); + }); + + it('should not open confirm dialog when select components dialog returns falsy', () => { + const selectClose$ = new Subject(); + + const { component: c, mockDialog: dialog } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationComponents, value: [{ id: 'comp-1' }] }], + }); + + (dialog.open as jest.Mock).mockReturnValue({ + onClose: selectClose$.pipe(), + close: jest.fn(), + }); + + c.openSelectComponentsForRegistrationDialog(); + selectClose$.next(null); + + expect(dialog.open).toHaveBeenCalledTimes(1); + }); + + it('should dispatch loadMoreContributors', () => { + (store.dispatch as jest.Mock).mockClear(); + component.loadMoreContributors(); + expect(store.dispatch).toHaveBeenCalledWith(new LoadMoreContributors('draft-1', ResourceType.DraftRegistration)); + }); + + it('should dispatch resetContributorsState on destroy', () => { + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith(new ResetContributorsState()); + }); + + it('should compute isDraftInvalid as false when all steps are valid', () => { + expect(component.isDraftInvalid()).toBe(false); + }); + + it('should compute isDraftInvalid as true when any step is invalid', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: true } } }], + }); + expect(c.isDraftInvalid()).toBe(true); + }); + + it('should compute registerButtonDisabled as false when valid and has admin access', () => { + expect(component.registerButtonDisabled()).toBe(false); + }); + + it('should compute registerButtonDisabled as true when draft is loading', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.isDraftLoading, value: true }], + }); + expect(c.registerButtonDisabled()).toBe(true); + }); + + it('should compute registerButtonDisabled as true when draft is invalid', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: true } } }], + }); + expect(c.registerButtonDisabled()).toBe(true); + }); + + it('should compute registerButtonDisabled as true when no admin access', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.hasDraftAdminAccess, value: false }], + }); + expect(c.registerButtonDisabled()).toBe(true); + }); + + it('should compute licenseOptionsRecord from draft license options', () => { + const { component: c } = setup({ + selectorOverrides: [ + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...DEFAULT_DRAFT, license: { options: { year: '2026', copyright: 'Test' } } }, + }, + ], + }); + expect(c.licenseOptionsRecord()).toEqual({ year: '2026', copyright: 'Test' }); + }); + + it('should compute licenseOptionsRecord as empty when no license options', () => { + expect(component.licenseOptionsRecord()).toEqual({}); + }); + + it('should pass draftId and providerId to confirm registration dialog data', () => { + component.openConfirmRegistrationDialog(); + + const callArgs = (mockDialog.open as jest.Mock).mock.calls[0]; + expect(callArgs[1].data.draftId).toBe('draft-1'); + expect(callArgs[1].data.providerId).toBe('prov-1'); + expect(callArgs[1].data.projectId).toBe('proj-1'); + }); + + it('should set projectId to null when branchedFrom type is not nodes', () => { + const { component: c, mockDialog: dialog } = setup({ + selectorOverrides: [ + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...DEFAULT_DRAFT, branchedFrom: { id: 'proj-1', type: 'registrations' } }, + }, + ], + }); + + c.openConfirmRegistrationDialog(); + + const callArgs = (dialog.open as jest.Mock).mock.calls[0]; + expect(callArgs[1].data.projectId).toBeNull(); + }); + + it('should pass components array to confirm registration dialog', () => { + component.openConfirmRegistrationDialog(['comp-1', 'comp-2']); + + const callArgs = (mockDialog.open as jest.Mock).mock.calls[0]; + expect(callArgs[1].data.components).toEqual(['comp-1', 'comp-2']); + }); + + it('should not navigate after registration when newRegistration has no id', () => { + const { + component: c, + mockRouter: router, + mockToast: toast, + dialogClose$: close$, + } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistration, value: { id: null } }], + }); + + c.openConfirmRegistrationDialog(); + close$.next(true); + + expect(toast.showSuccess).toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('should dispatch getProjectsComponents when draft hasProject is true', () => { + const { store: s } = setup({ + selectorOverrides: [ + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...DEFAULT_DRAFT, hasProject: true }, + }, + ], + }); + + expect(s.dispatch).toHaveBeenCalledWith(new FetchProjectChildren('proj-1')); + }); + + it('should dispatch getProjectsComponents with empty string when branchedFrom has no id', () => { + const { store: s } = setup({ + selectorOverrides: [ + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...DEFAULT_DRAFT, hasProject: true, branchedFrom: null }, + }, + ], + }); + + expect(s.dispatch).toHaveBeenCalledWith(new FetchProjectChildren('')); + }); + + it('should not dispatch getProjectsComponents when isDraftSubmitting is true', () => { + const { store: s } = setup({ + selectorOverrides: [ + { selector: RegistriesSelectors.isDraftSubmitting, value: true }, + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...DEFAULT_DRAFT, hasProject: true }, + }, + ], + }); + + expect(s.dispatch).not.toHaveBeenCalledWith(expect.any(FetchProjectChildren)); }); }); diff --git a/src/app/features/registries/components/review/review.component.ts b/src/app/features/registries/components/review/review.component.ts index 0d9f2c339..bc634acbb 100644 --- a/src/app/features/registries/components/review/review.component.ts +++ b/src/app/features/registries/components/review/review.component.ts @@ -7,10 +7,19 @@ import { Card } from 'primeng/card'; import { Message } from 'primeng/message'; import { Tag } from 'primeng/tag'; -import { map, of } from 'rxjs'; +import { filter, map } from 'rxjs'; -import { ChangeDetectionStrategy, Component, computed, effect, inject, OnDestroy } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + OnDestroy, + signal, +} from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; @@ -18,10 +27,7 @@ import { ContributorsListComponent } from '@osf/shared/components/contributors-l import { LicenseDisplayComponent } from '@osf/shared/components/license-display/license-display.component'; import { RegistrationBlocksDataComponent } from '@osf/shared/components/registration-blocks-data/registration-blocks-data.component'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const'; -import { FieldType } from '@osf/shared/enums/field-type.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; -import { FixSpecialCharPipe } from '@osf/shared/pipes/fix-special-char.pipe'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; @@ -33,14 +39,7 @@ import { } from '@osf/shared/stores/contributors'; import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; -import { - ClearState, - DeleteDraft, - FetchLicenses, - FetchProjectChildren, - RegistriesSelectors, - UpdateStepState, -} from '../../store'; +import { ClearState, DeleteDraft, FetchLicenses, FetchProjectChildren, RegistriesSelectors } from '../../store'; import { ConfirmRegistrationDialogComponent } from '../confirm-registration-dialog/confirm-registration-dialog.component'; import { SelectComponentsDialogComponent } from '../select-components-dialog/select-components-dialog.component'; @@ -55,7 +54,6 @@ import { SelectComponentsDialogComponent } from '../select-components-dialog/sel RegistrationBlocksDataComponent, ContributorsListComponent, LicenseDisplayComponent, - FixSpecialCharPipe, ], templateUrl: './review.component.html', styleUrl: './review.component.scss', @@ -67,6 +65,7 @@ export class ReviewComponent implements OnDestroy { private readonly customConfirmationService = inject(CustomConfirmationService); private readonly customDialogService = inject(CustomDialogService); private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); private readonly environment = inject(ENVIRONMENT); readonly pages = select(RegistriesSelectors.getPagesSchema); @@ -74,68 +73,65 @@ export class ReviewComponent implements OnDestroy { readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting); readonly isDraftLoading = select(RegistriesSelectors.isDraftLoading); readonly stepsData = select(RegistriesSelectors.getStepsData); - readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; + readonly components = select(RegistriesSelectors.getRegistrationComponents); + readonly license = select(RegistriesSelectors.getRegistrationLicense); + readonly newRegistration = select(RegistriesSelectors.getRegistration); + readonly stepsState = select(RegistriesSelectors.getStepsState); readonly contributors = select(ContributorsSelectors.getContributors); readonly areContributorsLoading = select(ContributorsSelectors.isContributorsLoading); readonly hasMoreContributors = select(ContributorsSelectors.hasMoreContributors); readonly subjects = select(SubjectsSelectors.getSelectedSubjects); - readonly components = select(RegistriesSelectors.getRegistrationComponents); - readonly license = select(RegistriesSelectors.getRegistrationLicense); - readonly newRegistration = select(RegistriesSelectors.getRegistration); + readonly hasAdminAccess = select(RegistriesSelectors.hasDraftAdminAccess); - readonly FieldType = FieldType; - - actions = createDispatchMap({ + private readonly actions = createDispatchMap({ getContributors: GetAllContributors, getSubjects: FetchSelectedSubjects, deleteDraft: DeleteDraft, clearState: ClearState, getProjectsComponents: FetchProjectChildren, fetchLicenses: FetchLicenses, - updateStepState: UpdateStepState, loadMoreContributors: LoadMoreContributors, resetContributorsState: ResetContributorsState, }); - private readonly draftId = toSignal(this.route.params.pipe(map((params) => params['id'])) ?? of(undefined)); - - stepsState = select(RegistriesSelectors.getStepsState); - - isDraftInvalid = computed(() => Object.values(this.stepsState()).some((step) => step.invalid)); + readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - licenseOptionsRecord = computed(() => (this.draftRegistration()?.license.options ?? {}) as Record); + private readonly draftId = toSignal(this.route.params.pipe(map((params) => params['id']))); - hasAdminAccess = computed(() => { - const registry = this.draftRegistration(); - if (!registry) return false; - return registry.currentUserPermissions.includes(UserPermissions.Admin); + private readonly resolvedProviderId = computed(() => { + const draft = this.draftRegistration(); + return draft ? (draft.providerId ?? this.environment.defaultProvider) : undefined; }); + private readonly componentsLoaded = signal(false); + + isDraftInvalid = computed(() => Object.values(this.stepsState()).some((step) => step.invalid)); + licenseOptionsRecord = computed(() => (this.draftRegistration()?.license.options ?? {}) as Record); registerButtonDisabled = computed(() => this.isDraftLoading() || this.isDraftInvalid() || !this.hasAdminAccess()); constructor() { if (!this.contributors()?.length) { this.actions.getContributors(this.draftId(), ResourceType.DraftRegistration); } + if (!this.subjects()?.length) { this.actions.getSubjects(this.draftId(), ResourceType.DraftRegistration); } effect(() => { - if (this.draftRegistration()) { - this.actions.fetchLicenses(this.draftRegistration()?.providerId ?? this.environment.defaultProvider); + const providerId = this.resolvedProviderId(); + + if (providerId) { + this.actions.fetchLicenses(providerId); } }); - let componentsLoaded = false; effect(() => { - if (!this.isDraftSubmitting()) { - const draftRegistrations = this.draftRegistration(); - if (draftRegistrations?.hasProject) { - if (!componentsLoaded) { - this.actions.getProjectsComponents(draftRegistrations?.branchedFrom?.id ?? ''); - componentsLoaded = true; - } + if (!this.isDraftSubmitting() && !this.componentsLoaded()) { + const draft = this.draftRegistration(); + if (draft?.hasProject) { + this.actions.getProjectsComponents(draft.branchedFrom?.id ?? ''); + this.componentsLoaded.set(true); } } }); @@ -156,12 +152,13 @@ export class ReviewComponent implements OnDestroy { messageKey: 'registries.confirmDeleteDraft', onConfirm: () => { const providerId = this.draftRegistration()?.providerId; - this.actions.deleteDraft(this.draftId()).subscribe({ - next: () => { + this.actions + .deleteDraft(this.draftId()) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { this.actions.clearState(); this.router.navigateByUrl(`/registries/${providerId}/new`); - }, - }); + }); }, }); } @@ -184,11 +181,11 @@ export class ReviewComponent implements OnDestroy { components: this.components(), }, }) - .onClose.subscribe((selectedComponents) => { - if (selectedComponents) { - this.openConfirmRegistrationDialog(selectedComponents); - } - }); + .onClose.pipe( + filter((selectedComponents) => !!selectedComponents), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((selectedComponents) => this.openConfirmRegistrationDialog(selectedComponents)); } openConfirmRegistrationDialog(components?: string[]): void { @@ -206,14 +203,16 @@ export class ReviewComponent implements OnDestroy { components, }, }) - .onClose.subscribe((res) => { + .onClose.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((res) => { if (res) { this.toastService.showSuccess('registries.review.confirmation.successMessage'); - this.router.navigate([`/${this.newRegistration()?.id}/overview`]); - } else { - if (this.components()?.length) { - this.openSelectComponentsForRegistrationDialog(); + const id = this.newRegistration()?.id; + if (id) { + this.router.navigate([`/${id}/overview`]); } + } else if (this.components()?.length) { + this.openSelectComponentsForRegistrationDialog(); } }); } diff --git a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html index 334e43284..bd927b1c2 100644 --- a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html +++ b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html @@ -13,7 +13,7 @@ class="w-12rem btn-full-width" [label]="'common.buttons.back' | translate" severity="info" - (click)="dialogRef.close()" + (onClick)="dialogRef.close()" /> - +
diff --git a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.spec.ts b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.spec.ts index 69346c419..e698bf519 100644 --- a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.spec.ts +++ b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.spec.ts @@ -1,37 +1,39 @@ import { MockProvider } from 'ng-mocks'; +import { TreeNode } from 'primeng/api'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ProjectShortInfoModel } from '../../models/project-short-info.model'; + import { SelectComponentsDialogComponent } from './select-components-dialog.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('SelectComponentsDialogComponent', () => { let component: SelectComponentsDialogComponent; let fixture: ComponentFixture; - let dialogRefMock: { close: jest.Mock }; - let dialogConfigMock: DynamicDialogConfig; + let dialogRef: DynamicDialogRef; - const parent = { id: 'p1', title: 'Parent Project' } as any; - const components = [ + const parent: ProjectShortInfoModel = { id: 'p1', title: 'Parent Project' }; + const components: ProjectShortInfoModel[] = [ { id: 'c1', title: 'Child 1', children: [{ id: 'c1a', title: 'Child 1A' }] }, { id: 'c2', title: 'Child 2' }, - ] as any; - - beforeEach(async () => { - dialogRefMock = { close: jest.fn() } as any; - dialogConfigMock = { data: { parent, components } } as any; + ]; - await TestBed.configureTestingModule({ - imports: [SelectComponentsDialogComponent, OSFTestingModule], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [SelectComponentsDialogComponent], providers: [ - MockProvider(DynamicDialogRef, dialogRefMock as any), - MockProvider(DynamicDialogConfig, dialogConfigMock as any), + provideOSFCore(), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, { data: { parent, components } }), ], - }).compileComponents(); + }); + dialogRef = TestBed.inject(DynamicDialogRef); fixture = TestBed.createComponent(SelectComponentsDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -43,17 +45,14 @@ describe('SelectComponentsDialogComponent', () => { const root = component.components[0]; expect(root.label).toBe('Parent Project'); expect(root.children?.length).toBe(2); - const selectedKeys = new Set(component.selectedComponents.map((n) => n.key)); - expect(selectedKeys.has('p1')).toBe(true); - expect(selectedKeys.has('c1')).toBe(true); - expect(selectedKeys.has('c1a')).toBe(true); - expect(selectedKeys.has('c2')).toBe(true); + const selectedKeys = new Set(component.selectedComponents.map((n: TreeNode) => n.key)); + expect(selectedKeys).toEqual(new Set(['p1', 'c1', 'c1a', 'c2'])); }); it('should close with unique selected component ids including parent on continue', () => { component.continue(); - expect(dialogRefMock.close).toHaveBeenCalledWith(expect.arrayContaining(['p1', 'c1', 'c1a', 'c2'])); - const passed = (dialogRefMock.close as jest.Mock).mock.calls[0][0] as string[]; + expect(dialogRef.close).toHaveBeenCalledWith(expect.arrayContaining(['p1', 'c1', 'c1a', 'c2'])); + const passed = (dialogRef.close as jest.Mock).mock.calls[0][0] as string[]; expect(new Set(passed).size).toBe(passed.length); }); }); diff --git a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts index 25350b20b..5fc736156 100644 --- a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts +++ b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts @@ -7,7 +7,7 @@ import { Tree } from 'primeng/tree'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { ProjectShortInfoModel } from '../../models'; +import { ProjectShortInfoModel } from '../../models/project-short-info.model'; @Component({ selector: 'osf-select-components-dialog', @@ -19,6 +19,7 @@ import { ProjectShortInfoModel } from '../../models'; export class SelectComponentsDialogComponent { readonly dialogRef = inject(DynamicDialogRef); readonly config = inject(DynamicDialogConfig); + selectedComponents: TreeNode[] = []; parent: ProjectShortInfoModel = this.config.data.parent; components: TreeNode[] = []; @@ -37,10 +38,14 @@ export class SelectComponentsDialogComponent { this.selectedComponents.push({ key: this.parent.id }); } + continue() { + const selectedComponentsSet = new Set([...this.selectedComponents.map((c) => c.key!), this.parent.id]); + this.dialogRef.close([...selectedComponentsSet]); + } + private mapProjectToTreeNode = (project: ProjectShortInfoModel): TreeNode => { - this.selectedComponents.push({ - key: project.id, - }); + this.selectedComponents.push({ key: project.id }); + return { label: project.title, data: project.id, @@ -49,9 +54,4 @@ export class SelectComponentsDialogComponent { children: project.children?.map(this.mapProjectToTreeNode) ?? [], }; }; - - continue() { - const selectedComponentsSet = new Set([...this.selectedComponents.map((c) => c.key!), this.parent.id]); - this.dialogRef.close([...selectedComponentsSet]); - } } diff --git a/src/app/features/registries/constants/registrations-tabs.ts b/src/app/features/registries/constants/registrations-tabs.ts index accf00f00..067163eec 100644 --- a/src/app/features/registries/constants/registrations-tabs.ts +++ b/src/app/features/registries/constants/registrations-tabs.ts @@ -1,8 +1,8 @@ -import { TabOption } from '@osf/shared/models/tab-option.model'; +import { CustomOption } from '@osf/shared/models/select-option.model'; import { RegistrationTab } from '../enums'; -export const REGISTRATIONS_TABS: TabOption[] = [ +export const REGISTRATIONS_TABS: CustomOption[] = [ { label: 'common.labels.drafts', value: RegistrationTab.Drafts, diff --git a/src/app/features/registries/enums/registration-tab.enum.ts b/src/app/features/registries/enums/registration-tab.enum.ts index 67eeac498..c7270c341 100644 --- a/src/app/features/registries/enums/registration-tab.enum.ts +++ b/src/app/features/registries/enums/registration-tab.enum.ts @@ -1,4 +1,4 @@ export enum RegistrationTab { - Drafts, - Submitted, + Drafts = 'drafts', + Submitted = 'submitted', } diff --git a/src/app/features/registries/models/attached-file.model.ts b/src/app/features/registries/models/attached-file.model.ts new file mode 100644 index 000000000..458dac9cf --- /dev/null +++ b/src/app/features/registries/models/attached-file.model.ts @@ -0,0 +1,3 @@ +import { FileModel } from '@osf/shared/models/files/file.model'; + +export type AttachedFile = Partial; diff --git a/src/app/features/registries/models/index.ts b/src/app/features/registries/models/index.ts deleted file mode 100644 index 101e1aeac..000000000 --- a/src/app/features/registries/models/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './project-short-info.model'; diff --git a/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts b/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts index b446b84b5..bd6fc8631 100644 --- a/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts +++ b/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts @@ -1,81 +1,108 @@ -import { MockComponent } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockComponent, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { RegistriesSelectors, UpdateDraft } from '@osf/features/registries/store'; +import { DraftRegistrationModel } from '@osf/shared/models/registration/draft-registration.model'; import { CustomStepComponent } from '../../components/custom-step/custom-step.component'; import { DraftRegistrationCustomStepComponent } from './draft-registration-custom-step.component'; import { MOCK_REGISTRIES_PAGE } from '@testing/mocks/registries.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -describe.skip('DraftRegistrationCustomStepComponent', () => { +const MOCK_DRAFT: Partial = { + id: 'draft-1', + providerId: 'prov-1', + branchedFrom: { id: 'node-1', filesLink: '/files' }, +}; +const MOCK_STEPS_DATA: Record = { 'question-1': 'answer-1' }; + +describe('DraftRegistrationCustomStepComponent', () => { let component: DraftRegistrationCustomStepComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; + let store: Store; + let mockRouter: RouterMockType; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1', step: '1' }).build(); + function setup( + draft: Partial | null = MOCK_DRAFT, + stepsData: Record = MOCK_STEPS_DATA + ) { + const mockRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1', step: '1' }).build(); mockRouter = RouterMockBuilder.create().withUrl('/registries/prov-1/draft/draft-1/custom').build(); - await TestBed.configureTestingModule({ - imports: [DraftRegistrationCustomStepComponent, OSFTestingModule, MockComponent(CustomStepComponent)], + TestBed.configureTestingModule({ + imports: [DraftRegistrationCustomStepComponent, MockComponent(CustomStepComponent)], providers: [ - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - { provide: Router, useValue: mockRouter }, + provideOSFCore(), + MockProvider(ActivatedRoute, mockRoute), + MockProvider(Router, mockRouter), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getStepsData, value: {} }, - { - selector: RegistriesSelectors.getDraftRegistration, - value: { id: 'draft-1', providerId: 'prov-1', branchedFrom: { id: 'node-1', filesLink: '/files' } }, - }, + { selector: RegistriesSelectors.getStepsData, value: stepsData }, + { selector: RegistriesSelectors.getDraftRegistration, value: draft }, { selector: RegistriesSelectors.getPagesSchema, value: [MOCK_REGISTRIES_PAGE] }, { selector: RegistriesSelectors.getStepsState, value: { 1: { invalid: false } } }, ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(DraftRegistrationCustomStepComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); it('should compute inputs from draft registration', () => { + setup(); expect(component.filesLink()).toBe('/files'); expect(component.provider()).toBe('prov-1'); expect(component.projectId()).toBe('node-1'); }); - it('should dispatch updateDraft on onUpdateAction', () => { - const actionsMock = { updateDraft: jest.fn() } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); + it('should return empty strings when draftRegistration is null', () => { + setup(null, {}); + expect(component.filesLink()).toBe(''); + expect(component.provider()).toBe(''); + expect(component.projectId()).toBe(''); + }); - component.onUpdateAction({ a: 1 } as any); - expect(actionsMock.updateDraft).toHaveBeenCalledWith('draft-1', { registration_responses: { a: 1 } }); + it('should dispatch updateDraft with wrapped registration_responses', () => { + setup(); + component.onUpdateAction({ field1: 'value1', field2: ['a', 'b'] } as any); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateDraft('draft-1', { registration_responses: { field1: 'value1', field2: ['a', 'b'] } }) + ); }); it('should navigate back to metadata on onBack', () => { - const navigateSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + setup(); component.onBack(); - expect(navigateSpy).toHaveBeenCalledWith(['../', 'metadata'], { relativeTo: TestBed.inject(ActivatedRoute) }); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../', 'metadata'], + expect.objectContaining({ relativeTo: expect.anything() }) + ); }); it('should navigate to review on onNext', () => { - const navigateSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + setup(); component.onNext(); - expect(navigateSpy).toHaveBeenCalledWith(['../', 'review'], { relativeTo: TestBed.inject(ActivatedRoute) }); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../', 'review'], + expect.objectContaining({ relativeTo: expect.anything() }) + ); }); }); diff --git a/src/app/features/registries/pages/justification/justification.component.spec.ts b/src/app/features/registries/pages/justification/justification.component.spec.ts index ddbee0e57..c69986e1e 100644 --- a/src/app/features/registries/pages/justification/justification.component.spec.ts +++ b/src/app/features/registries/pages/justification/justification.component.spec.ts @@ -1,87 +1,241 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; -import { RegistriesSelectors } from '@osf/features/registries/store'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; +import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; +import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; import { LoaderService } from '@osf/shared/services/loader.service'; +import { ClearState, FetchSchemaBlocks, FetchSchemaResponse, RegistriesSelectors } from '../../store'; + import { JustificationComponent } from './justification.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { createMockSchemaResponse } from '@testing/mocks/schema-response.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +const MOCK_SCHEMA_RESPONSE = createMockSchemaResponse('resp-1', RevisionReviewStates.RevisionInProgress); + +const MOCK_PAGES: PageSchema[] = [ + { id: 'page-1', title: 'Page One', questions: [{ id: 'q1', displayText: 'Q1', required: true, responseKey: 'q1' }] }, + { id: 'page-2', title: 'Page Two', questions: [{ id: 'q2', displayText: 'Q2', required: false, responseKey: 'q2' }] }, +]; + +interface SetupOptions { + routeParams?: Record; + routerUrl?: string; + schemaResponse?: SchemaResponse | null; + pages?: PageSchema[]; + stepsState?: Record; + revisionData?: Record; +} + describe('JustificationComponent', () => { let component: JustificationComponent; let fixture: ComponentFixture; - let mockActivatedRoute: Partial; - let mockRouter: ReturnType; - - beforeEach(async () => { - mockActivatedRoute = { - snapshot: { - firstChild: { params: { id: 'rev-1', step: '0' } } as any, - } as any, - firstChild: { snapshot: { params: { id: 'rev-1', step: '0' } } } as any, - } as Partial; - mockRouter = RouterMockBuilder.create().withUrl('/registries/revisions/rev-1/justification').build(); - - await TestBed.configureTestingModule({ - imports: [JustificationComponent, OSFTestingModule, ...MockComponents(StepperComponent, SubHeaderComponent)], + let store: Store; + let mockRouter: RouterMockType; + let routerBuilder: RouterMockBuilder; + let loaderService: LoaderServiceMock; + + function setup(options: SetupOptions = {}) { + const { + routeParams = { id: 'rev-1' }, + routerUrl = '/registries/revisions/rev-1/justification', + schemaResponse = MOCK_SCHEMA_RESPONSE, + pages = MOCK_PAGES, + stepsState = {}, + revisionData = MOCK_SCHEMA_RESPONSE.revisionResponses, + } = options; + + routerBuilder = RouterMockBuilder.create().withUrl(routerUrl); + mockRouter = routerBuilder.build(); + loaderService = new LoaderServiceMock(); + + const mockRoute = ActivatedRouteMockBuilder.create() + .withFirstChild((child) => child.withParams(routeParams)) + .build(); + + TestBed.configureTestingModule({ + imports: [JustificationComponent, ...MockComponents(StepperComponent, SubHeaderComponent)], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), + provideOSFCore(), + MockProvider(ActivatedRoute, mockRoute), MockProvider(Router, mockRouter), - MockProvider(LoaderService, { show: jest.fn(), hide: jest.fn() }), + MockProvider(LoaderService, loaderService), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getPagesSchema, value: [] }, - { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false, touched: false } } }, - { - selector: RegistriesSelectors.getSchemaResponse, - value: { - registrationSchemaId: 'schema-1', - revisionJustification: 'Reason', - reviewsState: 'revision_in_progress', - }, - }, - { selector: RegistriesSelectors.getSchemaResponseRevisionData, value: {} }, + { selector: RegistriesSelectors.getSchemaResponse, value: schemaResponse }, + { selector: RegistriesSelectors.getPagesSchema, value: pages }, + { selector: RegistriesSelectors.getStepsState, value: stepsState }, + { selector: RegistriesSelectors.getSchemaResponseRevisionData, value: revisionData }, ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(JustificationComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should compute steps with justification and review', () => { + it('should extract revisionId from route params', () => { + setup({ routeParams: { id: 'rev-42' } }); + expect(component.revisionId).toBe('rev-42'); + }); + + it('should default revisionId to empty string when no id param', () => { + setup({ routeParams: {} }); + expect(component.revisionId).toBe(''); + }); + + it('should build justification as first and review as last step with custom steps in between', () => { + setup(); + const steps = component.steps(); + expect(steps.length).toBe(4); + expect(steps[0]).toEqual(expect.objectContaining({ index: 0, value: 'justification', routeLink: 'justification' })); + expect(steps[1]).toEqual(expect.objectContaining({ index: 1, label: 'Page One', value: 'page-1', routeLink: '1' })); + expect(steps[2]).toEqual(expect.objectContaining({ index: 2, label: 'Page Two', value: 'page-2', routeLink: '2' })); + expect(steps[3]).toEqual( + expect.objectContaining({ index: 3, value: 'review', routeLink: 'review', invalid: false }) + ); + }); + + it('should mark justification step as invalid when revisionJustification is empty', () => { + setup({ schemaResponse: { ...MOCK_SCHEMA_RESPONSE, revisionJustification: '' } }); + const step = component.steps()[0]; + expect(step.invalid).toBe(true); + expect(step.touched).toBe(false); + }); + + it('should disable steps when reviewsState is not RevisionInProgress', () => { + setup({ schemaResponse: createMockSchemaResponse('resp-1', RevisionReviewStates.Approved) }); + const steps = component.steps(); + expect(steps[0].disabled).toBe(true); + expect(steps[1].disabled).toBe(true); + }); + + it('should apply stepsState invalid/touched to custom steps', () => { + setup({ stepsState: { 1: { invalid: true, touched: true }, 2: { invalid: false, touched: false } } }); + const steps = component.steps(); + expect(steps[1]).toEqual(expect.objectContaining({ invalid: true, touched: true })); + expect(steps[2]).toEqual(expect.objectContaining({ invalid: false, touched: false })); + }); + + it('should handle null schemaResponse gracefully', () => { + setup({ schemaResponse: null }); + const step = component.steps()[0]; + expect(step.invalid).toBe(true); + expect(step.disabled).toBe(true); + }); + + it('should produce only justification and review when no pages', () => { + setup({ pages: [] }); const steps = component.steps(); expect(steps.length).toBe(2); expect(steps[0].value).toBe('justification'); - expect(steps[1].value).toBe('review'); + expect(steps[1]).toEqual(expect.objectContaining({ index: 1, value: 'review' })); + }); + + it('should initialize currentStepIndex from route step param', () => { + setup({ routeParams: { id: 'rev-1', step: '2' } }); + expect(component.currentStepIndex()).toBe(2); + }); + + it('should default currentStepIndex to 0 when no step param', () => { + setup(); + expect(component.currentStepIndex()).toBe(0); + }); + + it('should return the step at currentStepIndex', () => { + setup(); + component.currentStepIndex.set(0); + expect(component.currentStep().value).toBe('justification'); + }); + + it('should update currentStepIndex and navigate on stepChange', () => { + setup(); + component.stepChange({ index: 1, label: 'Page One', value: 'page-1' } as any); + expect(component.currentStepIndex()).toBe(1); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/revisions/rev-1/', '1']); + }); + + it('should navigate to review route for last step', () => { + setup(); + const reviewIndex = component.steps().length - 1; + component.stepChange({ index: reviewIndex, label: 'Review', value: 'review' } as any); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/revisions/rev-1/', 'review']); + }); + + it('should update currentStepIndex on NavigationEnd', () => { + setup({ routeParams: { id: 'rev-1', step: '2' }, routerUrl: '/registries/revisions/rev-1/2' }); + routerBuilder.emit(new NavigationEnd(1, '/test', '/test')); + expect(component.currentStepIndex()).toBe(2); + }); + + it('should show loader on init', () => { + setup(); + expect(loaderService.show).toHaveBeenCalled(); + }); + + it('should dispatch FetchSchemaResponse when not already loaded', () => { + setup({ schemaResponse: null }); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSchemaResponse('rev-1')); + }); + + it('should not dispatch FetchSchemaResponse when already loaded', () => { + setup(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchSchemaResponse)); + }); + + it('should dispatch FetchSchemaBlocks when schemaResponse has registrationSchemaId', () => { + setup(); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSchemaBlocks(MOCK_SCHEMA_RESPONSE.registrationSchemaId)); + }); + + it('should dispatch clearState on destroy', () => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith(new ClearState()); + }); + + it('should detect review page from URL', () => { + setup({ routerUrl: '/registries/revisions/rev-1/review' }); + expect(component['isReviewPage']).toBe(true); + }); + + it('should return false for isReviewPage when not on review', () => { + setup(); + expect(component['isReviewPage']).toBe(false); }); - it('should navigate on stepChange', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); - component.stepChange({ index: 1, routeLink: '1', value: 'p1', label: 'Page 1' } as any); - expect(navSpy).toHaveBeenCalledWith(['/registries/revisions/rev-1/', 'review']); + it('should set currentStepIndex to last step on NavigationEnd when on review page without step param', () => { + setup({ routeParams: { id: 'rev-1' }, routerUrl: '/registries/revisions/rev-1/review' }); + component.currentStepIndex.set(0); + routerBuilder.emit(new NavigationEnd(2, '/review', '/review')); + expect(component.currentStepIndex()).toBe(MOCK_PAGES.length + 1); }); - it('should clear state on destroy', () => { - const actionsMock = { - clearState: jest.fn(), - getSchemaBlocks: jest.fn().mockReturnValue({ pipe: () => ({ subscribe: () => {} }) }), - } as any; - Object.defineProperty(component as any, 'actions', { value: actionsMock }); - fixture.destroy(); - expect(actionsMock.clearState).toHaveBeenCalled(); + it('should reset currentStepIndex to 0 on NavigationEnd when not on review and no step param', () => { + setup({ routeParams: { id: 'rev-1' }, routerUrl: '/registries/revisions/rev-1/justification' }); + component.currentStepIndex.set(2); + routerBuilder.emit(new NavigationEnd(2, '/justification', '/justification')); + expect(component.currentStepIndex()).toBe(0); }); }); diff --git a/src/app/features/registries/pages/justification/justification.component.ts b/src/app/features/registries/pages/justification/justification.component.ts index e196e9038..610dfd668 100644 --- a/src/app/features/registries/pages/justification/justification.component.ts +++ b/src/app/features/registries/pages/justification/justification.component.ts @@ -2,7 +2,7 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { filter, tap } from 'rxjs'; +import { filter } from 'rxjs'; import { ChangeDetectionStrategy, @@ -12,7 +12,6 @@ import { effect, inject, OnDestroy, - Signal, signal, untracked, } from '@angular/core'; @@ -33,21 +32,14 @@ import { ClearState, FetchSchemaBlocks, FetchSchemaResponse, RegistriesSelectors templateUrl: './justification.component.html', styleUrl: './justification.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [TranslateService], }) export class JustificationComponent implements OnDestroy { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); - private readonly loaderService = inject(LoaderService); private readonly translateService = inject(TranslateService); - readonly pages = select(RegistriesSelectors.getPagesSchema); - readonly stepsState = select(RegistriesSelectors.getStepsState); - readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); - readonly schemaResponseRevisionData = select(RegistriesSelectors.getSchemaResponseRevisionData); - private readonly actions = createDispatchMap({ getSchemaBlocks: FetchSchemaBlocks, clearState: ClearState, @@ -55,61 +47,79 @@ export class JustificationComponent implements OnDestroy { updateStepState: UpdateStepState, }); + readonly pages = select(RegistriesSelectors.getPagesSchema); + readonly stepsState = select(RegistriesSelectors.getStepsState); + readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); + readonly schemaResponseRevisionData = select(RegistriesSelectors.getSchemaResponseRevisionData); + + readonly revisionId = this.route.snapshot.firstChild?.params['id'] || ''; + get isReviewPage(): boolean { return this.router.url.includes('/review'); } - reviewStep!: StepOption; - justificationStep!: StepOption; - revisionId = this.route.snapshot.firstChild?.params['id'] || ''; + readonly steps = computed(() => { + const response = this.schemaResponse(); + const isJustificationValid = !!response?.revisionJustification; + const isDisabled = response?.reviewsState !== RevisionReviewStates.RevisionInProgress; + const stepState = this.stepsState(); + const pages = this.pages(); - steps: Signal = computed(() => { - const isJustificationValid = !!this.schemaResponse()?.revisionJustification; - this.justificationStep = { + const justificationStep: StepOption = { index: 0, value: 'justification', label: this.translateService.instant('registries.justification.step'), invalid: !isJustificationValid, touched: isJustificationValid, routeLink: 'justification', - disabled: this.schemaResponse()?.reviewsState !== RevisionReviewStates.RevisionInProgress, + disabled: isDisabled, }; - this.reviewStep = { - index: 1, + const customSteps: StepOption[] = pages.map((page, index) => ({ + index: index + 1, + label: page.title, + value: page.id, + routeLink: `${index + 1}`, + invalid: stepState?.[index + 1]?.invalid || false, + touched: stepState?.[index + 1]?.touched || false, + disabled: isDisabled, + })); + + const reviewStep: StepOption = { + index: customSteps.length + 1, value: 'review', label: this.translateService.instant('registries.review.step'), invalid: false, routeLink: 'review', }; - const stepState = this.stepsState(); - const customSteps = this.pages().map((page, index) => { - return { - index: index + 1, - label: page.title, - value: page.id, - routeLink: `${index + 1}`, - invalid: stepState?.[index + 1]?.invalid || false, - touched: stepState?.[index + 1]?.touched || false, - disabled: this.schemaResponse()?.reviewsState !== RevisionReviewStates.RevisionInProgress, - }; - }); - return [ - { ...this.justificationStep }, - ...customSteps, - { ...this.reviewStep, index: customSteps.length + 1, invalid: false }, - ]; + + return [justificationStep, ...customSteps, reviewStep]; }); currentStepIndex = signal( this.route.snapshot.firstChild?.params['step'] ? +this.route.snapshot.firstChild?.params['step'] : 0 ); - currentStep = computed(() => { - return this.steps()[this.currentStepIndex()]; - }); + currentStep = computed(() => this.steps()[this.currentStepIndex()]); constructor() { + this.initRouterListener(); + this.initDataFetching(); + this.initReviewPageSync(); + this.initStepValidation(); + } + + ngOnDestroy(): void { + this.actions.clearState(); + } + + stepChange(step: StepOption): void { + this.currentStepIndex.set(step.index); + const pageLink = this.steps()[step.index].routeLink; + this.router.navigate([`/registries/revisions/${this.revisionId}/`, pageLink]); + } + + private initRouterListener(): void { this.router.events .pipe( takeUntilDestroyed(this.destroyRef), @@ -120,47 +130,56 @@ export class JustificationComponent implements OnDestroy { if (step) { this.currentStepIndex.set(+step); } else if (this.isReviewPage) { - const reviewStepIndex = this.pages().length + 1; - this.currentStepIndex.set(reviewStepIndex); + this.currentStepIndex.set(this.pages().length + 1); } else { this.currentStepIndex.set(0); } }); + } + private initDataFetching(): void { this.loaderService.show(); + if (!this.schemaResponse()) { this.actions.getSchemaResponse(this.revisionId); } effect(() => { const registrationSchemaId = this.schemaResponse()?.registrationSchemaId; + if (registrationSchemaId) { - this.actions - .getSchemaBlocks(registrationSchemaId) - .pipe(tap(() => this.loaderService.hide())) - .subscribe(); + this.actions.getSchemaBlocks(registrationSchemaId).subscribe(() => this.loaderService.hide()); } }); + } + private initReviewPageSync(): void { effect(() => { const reviewStepIndex = this.pages().length + 1; + if (this.isReviewPage) { this.currentStepIndex.set(reviewStepIndex); } }); + } + private initStepValidation(): void { effect(() => { + const currentIndex = this.currentStepIndex(); + const pages = this.pages(); + const revisionData = this.schemaResponseRevisionData(); const stepState = untracked(() => this.stepsState()); - if (this.currentStepIndex() > 0) { + if (currentIndex > 0) { this.actions.updateStepState('0', true, stepState?.[0]?.touched || false); } - if (this.pages().length && this.currentStepIndex() > 0 && this.schemaResponseRevisionData()) { - for (let i = 1; i < this.currentStepIndex(); i++) { - const pageStep = this.pages()[i - 1]; + + if (pages.length && currentIndex > 0 && revisionData) { + for (let i = 1; i < currentIndex; i++) { + const pageStep = pages[i - 1]; const isStepInvalid = pageStep?.questions?.some((question) => { - const questionData = this.schemaResponseRevisionData()[question.responseKey!]; + const questionData = revisionData[question.responseKey!]; return question.required && (Array.isArray(questionData) ? !questionData.length : !questionData); }) || false; this.actions.updateStepState(i.toString(), isStepInvalid, stepState?.[i]?.touched || false); @@ -168,14 +187,4 @@ export class JustificationComponent implements OnDestroy { } }); } - - stepChange(step: StepOption): void { - this.currentStepIndex.set(step.index); - const pageLink = this.steps()[step.index].routeLink; - this.router.navigate([`/registries/revisions/${this.revisionId}/`, pageLink]); - } - - ngOnDestroy(): void { - this.actions.clearState(); - } } diff --git a/src/app/features/registries/pages/my-registrations-redirect/my-registrations-redirect.component.spec.ts b/src/app/features/registries/pages/my-registrations-redirect/my-registrations-redirect.component.spec.ts index d4f40e53f..abd94750e 100644 --- a/src/app/features/registries/pages/my-registrations-redirect/my-registrations-redirect.component.spec.ts +++ b/src/app/features/registries/pages/my-registrations-redirect/my-registrations-redirect.component.spec.ts @@ -1,3 +1,5 @@ +import { MockProvider } from 'ng-mocks'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; @@ -10,13 +12,13 @@ describe('MyRegistrationsRedirectComponent', () => { let fixture: ComponentFixture; let router: jest.Mocked; - beforeEach(async () => { + beforeEach(() => { const routerMock = RouterMock.create().build(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [MyRegistrationsRedirectComponent], - providers: [{ provide: Router, useValue: routerMock }], - }).compileComponents(); + providers: [MockProvider(Router, routerMock)], + }); fixture = TestBed.createComponent(MyRegistrationsRedirectComponent); component = fixture.componentInstance; @@ -28,24 +30,10 @@ describe('MyRegistrationsRedirectComponent', () => { expect(component).toBeTruthy(); }); - it('should be an instance of MyRegistrationsRedirectComponent', () => { - expect(component).toBeInstanceOf(MyRegistrationsRedirectComponent); - }); - it('should navigate to /my-registrations on component creation', () => { expect(router.navigate).toHaveBeenCalledWith(['/my-registrations'], { queryParamsHandling: 'preserve', replaceUrl: true, }); }); - - it('should preserve query parameters during navigation', () => { - const navigationOptions = router.navigate.mock.calls[0][1]; - expect(navigationOptions?.queryParamsHandling).toBe('preserve'); - }); - - it('should replace the current URL in browser history', () => { - const navigationOptions = router.navigate.mock.calls[0][1]; - expect(navigationOptions?.replaceUrl).toBe(true); - }); }); diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.html b/src/app/features/registries/pages/my-registrations/my-registrations.component.html index d9197ccaa..45a6b4e5f 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.html +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.html @@ -10,7 +10,7 @@
- + @if (!isMobile()) { @for (tab of tabOptions; track tab.value) { diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts b/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts index 5f1c0f4e4..1d2557ed9 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts @@ -1,6 +1,6 @@ -import { MockComponents, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { of } from 'rxjs'; +import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; @@ -15,35 +15,41 @@ import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ToastService } from '@osf/shared/services/toast.service'; +import { DeleteDraft, FetchDraftRegistrations, FetchSubmittedRegistrations } from '../../store'; + import { MyRegistrationsComponent } from './my-registrations.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { CustomConfirmationServiceMock } from '@testing/providers/custom-confirmation-provider.mock'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; describe('MyRegistrationsComponent', () => { let component: MyRegistrationsComponent; let fixture: ComponentFixture; - let mockRouter: ReturnType; - let mockActivatedRoute: Partial; + let store: Store; + let mockRoute: ReturnType; + let mockRouter: RouterMockType; let customConfirmationService: jest.Mocked; let toastService: jest.Mocked; - beforeEach(async () => { + function setup(queryParams: Record = {}) { mockRouter = RouterMockBuilder.create().withUrl('/registries/me').build(); - mockActivatedRoute = { snapshot: { queryParams: {} } } as any; + mockRoute = ActivatedRouteMockBuilder.create().withQueryParams(queryParams).build(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ MyRegistrationsComponent, - OSFTestingModule, ...MockComponents(SubHeaderComponent, SelectComponent, RegistrationCardComponent, CustomPaginatorComponent), ], providers: [ - { provide: Router, useValue: mockRouter }, - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - MockProvider(CustomConfirmationService, { confirmDelete: jest.fn() }), - MockProvider(ToastService, { showSuccess: jest.fn(), showWarn: jest.fn(), showError: jest.fn() }), + provideOSFCore(), + MockProvider(Router, mockRouter), + MockProvider(ActivatedRoute, mockRoute), + MockProvider(CustomConfirmationService, CustomConfirmationServiceMock.simple()), + MockProvider(ToastService, ToastServiceMock.simple()), provideMockStore({ signals: [ { selector: RegistriesSelectors.getDraftRegistrations, value: [] }, @@ -56,130 +62,109 @@ describe('MyRegistrationsComponent', () => { ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(MyRegistrationsComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); customConfirmationService = TestBed.inject(CustomConfirmationService) as jest.Mocked; toastService = TestBed.inject(ToastService) as jest.Mocked; fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should default to submitted tab when no query param', () => { + it('should default to submitted tab and fetch submitted registrations', () => { + setup(); expect(component.selectedTab()).toBe(RegistrationTab.Submitted); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSubmittedRegistrations()); }); - it('should switch to drafts tab when query param is drafts', () => { - (mockActivatedRoute.snapshot as any).queryParams = { tab: 'drafts' }; - - fixture = TestBed.createComponent(MyRegistrationsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - + it('should switch to drafts tab from query param and fetch drafts', () => { + setup({ tab: 'drafts' }); expect(component.selectedTab()).toBe(RegistrationTab.Drafts); + expect(store.dispatch).toHaveBeenCalledWith(new FetchDraftRegistrations()); }); - it('should switch to submitted tab when query param is submitted', () => { - (mockActivatedRoute.snapshot as any).queryParams = { tab: 'submitted' }; - - fixture = TestBed.createComponent(MyRegistrationsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - - expect(component.selectedTab()).toBe(RegistrationTab.Submitted); - }); - - it('should handle tab change and update query params', () => { - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - const navigateSpy = jest.spyOn(mockRouter, 'navigate'); + it('should change tab to drafts, reset pagination, fetch data, and update query params', () => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + (mockRouter.navigate as jest.Mock).mockClear(); component.onTabChange(RegistrationTab.Drafts); expect(component.selectedTab()).toBe(RegistrationTab.Drafts); expect(component.draftFirst).toBe(0); - expect(actionsMock.getDraftRegistrations).toHaveBeenCalledWith(); - expect(navigateSpy).toHaveBeenCalledWith([], { - relativeTo: mockActivatedRoute, + expect(store.dispatch).toHaveBeenCalledWith(new FetchDraftRegistrations()); + expect(mockRouter.navigate).toHaveBeenCalledWith([], { + relativeTo: TestBed.inject(ActivatedRoute), queryParams: { tab: 'drafts' }, queryParamsHandling: 'merge', }); }); - it('should handle tab change to submitted and update query params', () => { - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - const navigateSpy = jest.spyOn(mockRouter, 'navigate'); + it('should change tab to submitted, reset pagination, fetch data, and update query params', () => { + setup(); + component.onTabChange(RegistrationTab.Drafts); + (store.dispatch as jest.Mock).mockClear(); + (mockRouter.navigate as jest.Mock).mockClear(); component.onTabChange(RegistrationTab.Submitted); expect(component.selectedTab()).toBe(RegistrationTab.Submitted); expect(component.submittedFirst).toBe(0); - expect(actionsMock.getSubmittedRegistrations).toHaveBeenCalledWith(); - expect(navigateSpy).toHaveBeenCalledWith([], { - relativeTo: mockActivatedRoute, + expect(store.dispatch).toHaveBeenCalledWith(new FetchSubmittedRegistrations()); + expect(mockRouter.navigate).toHaveBeenCalledWith([], { + relativeTo: TestBed.inject(ActivatedRoute), queryParams: { tab: 'submitted' }, queryParamsHandling: 'merge', }); }); - it('should not process tab change if tab is not a number', () => { - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); + it('should ignore invalid tab values', () => { + setup(); + (store.dispatch as jest.Mock).mockClear(); const initialTab = component.selectedTab(); - component.onTabChange('invalid' as any); + component.onTabChange('invalid'); + component.onTabChange(0); expect(component.selectedTab()).toBe(initialTab); - expect(actionsMock.getDraftRegistrations).not.toHaveBeenCalled(); - expect(actionsMock.getSubmittedRegistrations).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); }); it('should navigate to create registration page', () => { - const navSpy = jest.spyOn(mockRouter, 'navigate'); + setup(); component.goToCreateRegistration(); - expect(navSpy).toHaveBeenLastCalledWith(['/registries', 'osf', 'new']); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries', 'osf', 'new']); }); it('should handle drafts pagination', () => { - const actionsMock = { getDraftRegistrations: jest.fn() } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - component.onDraftsPageChange({ page: 2, first: 20 } as any); - expect(actionsMock.getDraftRegistrations).toHaveBeenCalledWith(3); + setup(); + (store.dispatch as jest.Mock).mockClear(); + + component.onDraftsPageChange({ page: 2, first: 20 }); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchDraftRegistrations(3)); expect(component.draftFirst).toBe(20); }); it('should handle submitted pagination', () => { - const actionsMock = { getSubmittedRegistrations: jest.fn() } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - component.onSubmittedPageChange({ page: 1, first: 10 } as any); - expect(actionsMock.getSubmittedRegistrations).toHaveBeenCalledWith(2); + setup(); + (store.dispatch as jest.Mock).mockClear(); + + component.onSubmittedPageChange({ page: 1, first: 10 }); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchSubmittedRegistrations(2)); expect(component.submittedFirst).toBe(10); }); it('should delete draft after confirmation', () => { - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(() => of({})), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); + setup(); + (store.dispatch as jest.Mock).mockClear(); customConfirmationService.confirmDelete.mockImplementation(({ onConfirm }) => { onConfirm(); }); @@ -191,53 +176,21 @@ describe('MyRegistrationsComponent', () => { messageKey: 'registries.confirmDeleteDraft', onConfirm: expect.any(Function), }); - expect(actionsMock.deleteDraft).toHaveBeenCalledWith('draft-123'); - expect(actionsMock.getDraftRegistrations).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteDraft('draft-123')); + expect(store.dispatch).toHaveBeenCalledWith(new FetchDraftRegistrations()); expect(toastService.showSuccess).toHaveBeenCalledWith('registries.successDeleteDraft'); }); it('should not delete draft if confirmation is cancelled', () => { - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); + setup(); + (store.dispatch as jest.Mock).mockClear(); + toastService.showSuccess.mockClear(); customConfirmationService.confirmDelete.mockImplementation(() => {}); component.onDeleteDraft('draft-123'); expect(customConfirmationService.confirmDelete).toHaveBeenCalled(); - expect(actionsMock.deleteDraft).not.toHaveBeenCalled(); - expect(actionsMock.getDraftRegistrations).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); expect(toastService.showSuccess).not.toHaveBeenCalled(); }); - - it('should reset draftFirst when switching to drafts tab', () => { - component.draftFirst = 20; - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - - component.onTabChange(RegistrationTab.Drafts); - - expect(component.draftFirst).toBe(0); - }); - - it('should reset submittedFirst when switching to submitted tab', () => { - component.submittedFirst = 20; - const actionsMock = { - getDraftRegistrations: jest.fn(), - getSubmittedRegistrations: jest.fn(), - deleteDraft: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - - component.onTabChange(RegistrationTab.Submitted); - - expect(component.submittedFirst).toBe(0); - }); }); diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.ts b/src/app/features/registries/pages/my-registrations/my-registrations.component.ts index 106db16e9..95179b7b7 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.ts +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.ts @@ -10,7 +10,6 @@ import { TabsModule } from 'primeng/tabs'; import { NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; @@ -30,17 +29,16 @@ import { DeleteDraft, FetchDraftRegistrations, FetchSubmittedRegistrations, Regi @Component({ selector: 'osf-my-registrations', imports: [ - SubHeaderComponent, - TranslatePipe, - TabsModule, - FormsModule, - SelectComponent, - RegistrationCardComponent, - CustomPaginatorComponent, - Skeleton, Button, + Skeleton, + TabsModule, RouterLink, NgTemplateOutlet, + CustomPaginatorComponent, + RegistrationCardComponent, + SelectComponent, + SubHeaderComponent, + TranslatePipe, ], templateUrl: './my-registrations.component.html', styleUrl: './my-registrations.component.scss', @@ -82,26 +80,28 @@ export class MyRegistrationsComponent { constructor() { const initialTab = this.route.snapshot.queryParams['tab']; - const selectedTab = initialTab == 'drafts' ? RegistrationTab.Drafts : RegistrationTab.Submitted; + const selectedTab = initialTab === RegistrationTab.Drafts ? RegistrationTab.Drafts : RegistrationTab.Submitted; this.onTabChange(selectedTab); } onTabChange(tab: Primitive): void { - if (typeof tab !== 'number') { + if (typeof tab !== 'string' || !Object.values(RegistrationTab).includes(tab as RegistrationTab)) { return; } - this.selectedTab.set(tab); - this.loadTabData(tab); + const validTab = tab as RegistrationTab; + + this.selectedTab.set(validTab); + this.loadTabData(validTab); this.router.navigate([], { relativeTo: this.route, - queryParams: { tab: tab === RegistrationTab.Drafts ? 'drafts' : 'submitted' }, + queryParams: { tab }, queryParamsHandling: 'merge', }); } - private loadTabData(tab: number): void { + private loadTabData(tab: RegistrationTab): void { if (tab === RegistrationTab.Drafts) { this.draftFirst = 0; this.actions.getDraftRegistrations(); diff --git a/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts b/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts index cf4780553..d6f71b0b6 100644 --- a/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts +++ b/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts @@ -1,36 +1,40 @@ -import { MockComponents } from 'ng-mocks'; +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 { Router } from '@angular/router'; import { ScheduledBannerComponent } from '@core/components/osf-banners/scheduled-banner/scheduled-banner.component'; +import { ClearCurrentProvider } from '@core/store/provider'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { ResourceCardComponent } from '@osf/shared/components/resource-card/resource-card.component'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; -import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; +import { ClearRegistryProvider, GetRegistryProvider } from '@osf/shared/stores/registration-provider'; import { RegistryServicesComponent } from '../../components/registry-services/registry-services.component'; -import { RegistriesSelectors } from '../../store'; +import { GetRegistries, RegistriesSelectors } from '../../store'; import { RegistriesLandingComponent } from './registries-landing.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistriesLandingComponent', () => { let component: RegistriesLandingComponent; let fixture: ComponentFixture; - let mockRouter: ReturnType; + let store: Store; + let mockRouter: RouterMockType; - beforeEach(async () => { + beforeEach(() => { mockRouter = RouterMockBuilder.create().withUrl('/registries').build(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ RegistriesLandingComponent, - OSFTestingModule, ...MockComponents( SearchInputComponent, RegistryServicesComponent, @@ -41,20 +45,21 @@ describe('RegistriesLandingComponent', () => { ), ], providers: [ - { provide: Router, useValue: mockRouter }, + provideOSFCore(), + MockProvider(Router, mockRouter), + MockProvider(PLATFORM_ID, 'browser'), provideMockStore({ signals: [ - { selector: RegistrationProviderSelectors.getBrandedProvider, value: null }, - { selector: RegistrationProviderSelectors.isBrandedProviderLoading, value: false }, { selector: RegistriesSelectors.getRegistries, value: [] }, { selector: RegistriesSelectors.isRegistriesLoading, value: false }, ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(RegistriesLandingComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); fixture.detectChanges(); }); @@ -62,51 +67,31 @@ describe('RegistriesLandingComponent', () => { expect(component).toBeTruthy(); }); - it('should dispatch get registries and provider on init', () => { - const actionsMock = { - getRegistries: jest.fn(), - getProvider: jest.fn(), - clearCurrentProvider: jest.fn(), - clearRegistryProvider: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - - component.ngOnInit(); - - expect(actionsMock.getRegistries).toHaveBeenCalled(); - expect(actionsMock.getProvider).toHaveBeenCalledWith(component.defaultProvider); + it('should dispatch getRegistries and getProvider on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(new GetRegistries()); + expect(store.dispatch).toHaveBeenCalledWith(new GetRegistryProvider(component.defaultProvider)); }); - it('should clear providers on destroy', () => { - const actionsMock = { - getRegistries: jest.fn(), - getProvider: jest.fn(), - clearCurrentProvider: jest.fn(), - clearRegistryProvider: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - + it('should dispatch clear actions on destroy', () => { + (store.dispatch as jest.Mock).mockClear(); fixture.destroy(); - expect(actionsMock.clearCurrentProvider).toHaveBeenCalled(); - expect(actionsMock.clearRegistryProvider).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new ClearCurrentProvider()); + expect(store.dispatch).toHaveBeenCalledWith(new ClearRegistryProvider()); }); it('should navigate to search with value', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); component.searchControl.setValue('abc'); component.redirectToSearchPageWithValue(); - expect(navSpy).toHaveBeenCalledWith(['/search'], { queryParams: { search: 'abc', tab: 3 } }); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/search'], { queryParams: { search: 'abc', tab: 3 } }); }); it('should navigate to search registrations tab', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); component.redirectToSearchPageRegistrations(); - expect(navSpy).toHaveBeenCalledWith(['/search'], { queryParams: { tab: 3 } }); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/search'], { queryParams: { tab: 3 } }); }); it('should navigate to create page', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); component.goToCreateRegistration(); - expect(navSpy).toHaveBeenCalledWith(['/registries/osf/new']); + expect(mockRouter.navigate).toHaveBeenCalledWith([`/registries/${component.defaultProvider}/new`]); }); }); diff --git a/src/app/features/registries/pages/registries-landing/registries-landing.component.ts b/src/app/features/registries/pages/registries-landing/registries-landing.component.ts index 1aa9c22c8..917caf2fc 100644 --- a/src/app/features/registries/pages/registries-landing/registries-landing.component.ts +++ b/src/app/features/registries/pages/registries-landing/registries-landing.component.ts @@ -18,11 +18,7 @@ import { SearchInputComponent } from '@osf/shared/components/search-input/search import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { normalizeQuotes } from '@osf/shared/helpers/normalize-quotes'; -import { - ClearRegistryProvider, - GetRegistryProvider, - RegistrationProviderSelectors, -} from '@osf/shared/stores/registration-provider'; +import { ClearRegistryProvider, GetRegistryProvider } from '@osf/shared/stores/registration-provider'; import { RegistryServicesComponent } from '../../components/registry-services/registry-services.component'; import { GetRegistries, RegistriesSelectors } from '../../store'; @@ -44,10 +40,9 @@ import { GetRegistries, RegistriesSelectors } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistriesLandingComponent implements OnInit, OnDestroy { - private router = inject(Router); + private readonly router = inject(Router); private readonly environment = inject(ENVIRONMENT); - private readonly platformId = inject(PLATFORM_ID); - private readonly isBrowser = isPlatformBrowser(this.platformId); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); private actions = createDispatchMap({ getRegistries: GetRegistries, @@ -56,8 +51,6 @@ export class RegistriesLandingComponent implements OnInit, OnDestroy { clearRegistryProvider: ClearRegistryProvider, }); - provider = select(RegistrationProviderSelectors.getBrandedProvider); - isProviderLoading = select(RegistrationProviderSelectors.isBrandedProviderLoading); registries = select(RegistriesSelectors.getRegistries); isRegistriesLoading = select(RegistriesSelectors.isRegistriesLoading); diff --git a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts index 6498fed94..80746fa87 100644 --- a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts +++ b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts @@ -1,67 +1,116 @@ +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 { ActivatedRoute } from '@angular/router'; -import { RegistryProviderHeroComponent } from '@osf/features/registries/components/registry-provider-hero/registry-provider-hero.component'; +import { ClearCurrentProvider } from '@core/store/provider'; import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component'; -import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { RegistryProviderDetails } from '@osf/shared/models/provider/registry-provider.model'; +import { SetDefaultFilterValue, SetResourceType } from '@osf/shared/stores/global-search'; +import { + ClearRegistryProvider, + GetRegistryProvider, + RegistrationProviderSelectors, +} from '@osf/shared/stores/registration-provider'; + +import { RegistryProviderHeroComponent } from '../../components/registry-provider-hero/registry-provider-hero.component'; import { RegistriesProviderSearchComponent } from './registries-provider-search.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +const MOCK_PROVIDER: RegistryProviderDetails = { + id: 'provider-1', + name: 'Test Provider', + descriptionHtml: '', + permissions: [], + brand: null, + iri: 'http://iri.example.com', + reviewsWorkflow: 'pre-moderation', +}; + describe('RegistriesProviderSearchComponent', () => { let component: RegistriesProviderSearchComponent; let fixture: ComponentFixture; + let store: Store; - beforeEach(async () => { - const routeMock = ActivatedRouteMockBuilder.create().withParams({ name: 'osf' }).build(); + const PROVIDER_ID = 'provider-1'; - await TestBed.configureTestingModule({ + function setup(params: Record = { providerId: PROVIDER_ID }, platformId = 'browser') { + const mockRoute = ActivatedRouteMockBuilder.create().withParams(params).build(); + + TestBed.configureTestingModule({ imports: [ RegistriesProviderSearchComponent, - OSFTestingModule, - ...MockComponents(GlobalSearchComponent, RegistryProviderHeroComponent), + ...MockComponents(RegistryProviderHeroComponent, GlobalSearchComponent), ], providers: [ - { provide: ActivatedRoute, useValue: routeMock }, - MockProvider(CustomDialogService, { open: jest.fn() }), + provideOSFCore(), + MockProvider(ActivatedRoute, mockRoute), + MockProvider(PLATFORM_ID, platformId), provideMockStore({ signals: [ - { selector: RegistrationProviderSelectors.getBrandedProvider, value: { iri: 'http://iri/provider' } }, + { selector: RegistrationProviderSelectors.getBrandedProvider, value: MOCK_PROVIDER }, { selector: RegistrationProviderSelectors.isBrandedProviderLoading, value: false }, ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(RegistriesProviderSearchComponent); component = fixture.componentInstance; - }); + store = TestBed.inject(Store); + fixture.detectChanges(); + } it('should create', () => { - fixture.detectChanges(); + setup(); expect(component).toBeTruthy(); }); - it('should clear providers on destroy', () => { - fixture.detectChanges(); + it('should fetch provider and initialize search filters on init', () => { + setup(); + expect(store.dispatch).toHaveBeenCalledWith(new GetRegistryProvider(PROVIDER_ID)); + expect(store.dispatch).toHaveBeenCalledWith(new SetDefaultFilterValue('publisher', MOCK_PROVIDER.iri)); + expect(store.dispatch).toHaveBeenCalledWith(new SetResourceType(ResourceType.Registration)); + expect(component.defaultSearchFiltersInitialized()).toBe(true); + }); + + it('should initialize searchControl with empty string', () => { + setup(); + expect(component.searchControl.value).toBe(''); + }); + + it('should expose provider and isProviderLoading from store', () => { + setup(); + expect(component.provider()).toEqual(MOCK_PROVIDER); + expect(component.isProviderLoading()).toBe(false); + }); + + it('should dispatch clear actions on destroy in browser', () => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith(new ClearCurrentProvider()); + expect(store.dispatch).toHaveBeenCalledWith(new ClearRegistryProvider()); + }); + + it('should not fetch provider or initialize filters when providerId is missing', () => { + setup({}); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetRegistryProvider)); + expect(component.defaultSearchFiltersInitialized()).toBe(false); + }); - const actionsMock = { - getProvider: jest.fn(), - setDefaultFilterValue: jest.fn(), - setResourceType: jest.fn(), - clearCurrentProvider: jest.fn(), - clearRegistryProvider: jest.fn(), - } as any; - Object.defineProperty(component as any, 'actions', { value: actionsMock }); - - fixture.destroy(); - expect(actionsMock.clearCurrentProvider).toHaveBeenCalled(); - expect(actionsMock.clearRegistryProvider).toHaveBeenCalled(); + it('should not dispatch clear actions on destroy on server', () => { + setup({}, 'server'); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts index 6411524f3..6aa227fc6 100644 --- a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts +++ b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts @@ -1,32 +1,35 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { CustomStepComponent } from '../../components/custom-step/custom-step.component'; -import { RegistriesSelectors } from '../../store'; +import { RegistriesSelectors, UpdateSchemaResponse } from '../../store'; import { RevisionsCustomStepComponent } from './revisions-custom-step.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RevisionsCustomStepComponent', () => { let component: RevisionsCustomStepComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; + let store: Store; + let mockRouter: RouterMockType; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'rev-1', step: '1' }).build(); + beforeEach(() => { + const mockRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'rev-1', step: '1' }).build(); mockRouter = RouterMockBuilder.create().withUrl('/registries/revisions/rev-1/1').build(); - await TestBed.configureTestingModule({ - imports: [RevisionsCustomStepComponent, OSFTestingModule, MockComponents(CustomStepComponent)], + TestBed.configureTestingModule({ + imports: [RevisionsCustomStepComponent, MockComponents(CustomStepComponent)], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), + provideOSFCore(), + MockProvider(ActivatedRoute, mockRoute), MockProvider(Router, mockRouter), provideMockStore({ signals: [ @@ -43,10 +46,11 @@ describe('RevisionsCustomStepComponent', () => { ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(RevisionsCustomStepComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); fixture.detectChanges(); }); @@ -62,21 +66,23 @@ describe('RevisionsCustomStepComponent', () => { }); it('should dispatch updateRevision on onUpdateAction', () => { - const actionsMock = { updateRevision: jest.fn() } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); component.onUpdateAction({ x: 2 }); - expect(actionsMock.updateRevision).toHaveBeenCalledWith('rev-1', 'because', { x: 2 }); + expect(store.dispatch).toHaveBeenCalledWith(new UpdateSchemaResponse('rev-1', 'because', { x: 2 })); }); it('should navigate back to justification on onBack', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); component.onBack(); - expect(navSpy).toHaveBeenCalledWith(['../', 'justification'], { relativeTo: TestBed.inject(ActivatedRoute) }); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../', 'justification'], + expect.objectContaining({ relativeTo: expect.anything() }) + ); }); it('should navigate to review on onNext', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); component.onNext(); - expect(navSpy).toHaveBeenCalledWith(['../', 'review'], { relativeTo: TestBed.inject(ActivatedRoute) }); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../', 'review'], + expect.objectContaining({ relativeTo: expect.anything() }) + ); }); }); diff --git a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.ts b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.ts index e59a55ef7..73b1a1c83 100644 --- a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.ts +++ b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.ts @@ -14,31 +14,18 @@ import { RegistriesSelectors, UpdateSchemaResponse } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class RevisionsCustomStepComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); readonly schemaResponseRevisionData = select(RegistriesSelectors.getSchemaResponseRevisionData); - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - actions = createDispatchMap({ - updateRevision: UpdateSchemaResponse, - }); - - filesLink = computed(() => { - return this.schemaResponse()?.filesLink || ' '; - }); - - provider = computed(() => { - return this.schemaResponse()?.registrationId || ''; - }); - - projectId = computed(() => { - return this.schemaResponse()?.registrationId || ''; - }); - - stepsData = computed(() => { - const schemaResponse = this.schemaResponse(); - return schemaResponse?.revisionResponses || {}; - }); + actions = createDispatchMap({ updateRevision: UpdateSchemaResponse }); + + filesLink = computed(() => this.schemaResponse()?.filesLink || ' '); + provider = computed(() => this.schemaResponse()?.registrationId || ''); + projectId = computed(() => this.schemaResponse()?.registrationId || ''); + stepsData = computed(() => this.schemaResponse()?.revisionResponses || {}); onUpdateAction(data: Record): void { const id: string = this.route.snapshot.params['id'] || ''; diff --git a/src/app/features/registries/registries.component.spec.ts b/src/app/features/registries/registries.component.spec.ts index 516e0091c..196543284 100644 --- a/src/app/features/registries/registries.component.spec.ts +++ b/src/app/features/registries/registries.component.spec.ts @@ -2,16 +2,14 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RegistriesComponent } from './registries.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; - describe('Component: Registries', () => { let fixture: ComponentFixture; let component: RegistriesComponent; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RegistriesComponent, OSFTestingModule], - }).compileComponents(); + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RegistriesComponent], + }); fixture = TestBed.createComponent(RegistriesComponent); component = fixture.componentInstance; diff --git a/src/app/features/registries/store/handlers/projects.handlers.ts b/src/app/features/registries/store/handlers/projects.handlers.ts index 9738d8442..88562a02a 100644 --- a/src/app/features/registries/store/handlers/projects.handlers.ts +++ b/src/app/features/registries/store/handlers/projects.handlers.ts @@ -5,7 +5,7 @@ import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { ProjectsService } from '@osf/shared/services/projects.service'; -import { ProjectShortInfoModel } from '../../models'; +import { ProjectShortInfoModel } from '../../models/project-short-info.model'; import { REGISTRIES_STATE_DEFAULTS, RegistriesStateModel } from '../registries.model'; @Injectable() diff --git a/src/app/features/registries/store/registries.model.ts b/src/app/features/registries/store/registries.model.ts index f83ee5291..4542aaa96 100644 --- a/src/app/features/registries/store/registries.model.ts +++ b/src/app/features/registries/store/registries.model.ts @@ -11,7 +11,7 @@ import { ResourceModel } from '@osf/shared/models/search/resource.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model'; -import { ProjectShortInfoModel } from '../models'; +import { ProjectShortInfoModel } from '../models/project-short-info.model'; export interface RegistriesStateModel { providerSchemas: AsyncStateModel; diff --git a/src/app/features/registries/store/registries.selectors.ts b/src/app/features/registries/store/registries.selectors.ts index 3335b9191..e75242bf2 100644 --- a/src/app/features/registries/store/registries.selectors.ts +++ b/src/app/features/registries/store/registries.selectors.ts @@ -1,5 +1,6 @@ import { Selector } from '@ngxs/store'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { FileModel } from '@osf/shared/models/files/file.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { LicenseModel } from '@osf/shared/models/license/license.model'; @@ -11,7 +12,7 @@ import { RegistrationCard } from '@osf/shared/models/registration/registration-c import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; import { ResourceModel } from '@osf/shared/models/search/resource.model'; -import { ProjectShortInfoModel } from '../models'; +import { ProjectShortInfoModel } from '../models/project-short-info.model'; import { RegistriesStateModel } from './registries.model'; import { RegistriesState } from './registries.state'; @@ -52,6 +53,11 @@ export class RegistriesSelectors { return state.draftRegistration.data; } + @Selector([RegistriesState]) + static hasDraftAdminAccess(state: RegistriesStateModel): boolean { + return state.draftRegistration.data?.currentUserPermissions?.includes(UserPermissions.Admin) || false; + } + @Selector([RegistriesState]) static getRegistrationLoading(state: RegistriesStateModel): boolean { return state.draftRegistration.isLoading || state.draftRegistration.isSubmitting || state.pagesSchema.isLoading; diff --git a/src/app/features/registries/store/registries.state.ts b/src/app/features/registries/store/registries.state.ts index 6a8ae7120..d24602bbf 100644 --- a/src/app/features/registries/store/registries.state.ts +++ b/src/app/features/registries/store/registries.state.ts @@ -54,11 +54,11 @@ import { REGISTRIES_STATE_DEFAULTS, RegistriesStateModel } from './registries.mo }) @Injectable() export class RegistriesState { - searchService = inject(GlobalSearchService); - registriesService = inject(RegistriesService); private readonly environment = inject(ENVIRONMENT); private readonly store = inject(Store); + searchService = inject(GlobalSearchService); + registriesService = inject(RegistriesService); providersHandler = inject(ProvidersHandlers); projectsHandler = inject(ProjectsHandlers); licensesHandler = inject(LicensesHandlers); @@ -238,7 +238,7 @@ export class RegistriesState { }, }); }), - catchError((error) => handleSectionError(ctx, 'draftRegistration', error)) + catchError((error) => handleSectionError(ctx, 'registration', error)) ); } diff --git a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html index 9f8a88dc4..5ca0e95f2 100644 --- a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html +++ b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html @@ -7,13 +7,12 @@ @let resourceType = currentResource()?.type; @let iconName = resourceType === 'analytic_code' ? 'code' : resourceType; @let icon = `custom-icon-${iconName} icon-resource-size`; - @let resourceName = resourceType === RegistryResourceType.Code ? 'Analytic Code' : resourceType;
-

{{ resourceName }}

+

{{ resourceTypeTranslationKey() | translate }}

{{ doiLink() }} diff --git a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.spec.ts b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.spec.ts index b934deae5..8ad13dace 100644 --- a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.spec.ts +++ b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.spec.ts @@ -1,89 +1,92 @@ import { Store } from '@ngxs/store'; -import { MockComponent, MockProvider } from 'ng-mocks'; +import { MockComponents, MockProvider } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; +import { RegistryResourceType } from '@osf/shared/enums/registry-resource.enum'; +import { RegistryResource } from '../../models'; import { RegistryResourcesSelectors } from '../../store/registry-resources'; import { ResourceFormComponent } from '../resource-form/resource-form.component'; import { AddResourceDialogComponent } from './add-resource-dialog.component'; -import { DynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; -import { TranslateServiceMock } from '@testing/mocks/translate.service.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; + +const MOCK_RESOURCE: RegistryResource = { + id: 'res-1', + description: 'Test', + finalized: false, + type: RegistryResourceType.Data, + pid: '10.1234/test', +}; + +interface SetupOverrides { + registryId?: string; + selectorOverrides?: SignalOverride[]; +} + +function setup(overrides: SetupOverrides = {}) { + const mockDialogConfig = { data: { id: overrides.registryId ?? 'registry-123' } }; + + const defaultSignals = [ + { selector: RegistryResourcesSelectors.getCurrentResource, value: null }, + { selector: RegistryResourcesSelectors.isCurrentResourceLoading, value: false }, + ]; + + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); + + TestBed.configureTestingModule({ + imports: [ + AddResourceDialogComponent, + ...MockComponents(LoadingSpinnerComponent, ResourceFormComponent, IconComponent), + ], + providers: [ + provideOSFCore(), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, mockDialogConfig), + provideMockStore({ signals }), + ], + }); + + const store = TestBed.inject(Store); + const dialogRef = TestBed.inject(DynamicDialogRef); + const fixture = TestBed.createComponent(AddResourceDialogComponent); + const component = fixture.componentInstance; + fixture.detectChanges(); + + return { fixture, component, store, dialogRef }; +} describe('AddResourceDialogComponent', () => { - let component: AddResourceDialogComponent; - let fixture: ComponentFixture; - let store: Store; - let dialogRef: jest.Mocked; - let mockDialogConfig: jest.Mocked; - - const mockRegistryId = 'registry-123'; - - beforeEach(async () => { - mockDialogConfig = { - data: { - id: mockRegistryId, - }, - } as jest.Mocked; - - await TestBed.configureTestingModule({ - imports: [ - AddResourceDialogComponent, - OSFTestingModule, - MockComponent(LoadingSpinnerComponent), - MockComponent(ResourceFormComponent), - MockComponent(IconComponent), - ], - providers: [ - DynamicDialogRefMock, - TranslateServiceMock, - MockProvider(DynamicDialogConfig, mockDialogConfig), - provideMockStore({ - signals: [ - { selector: RegistryResourcesSelectors.getCurrentResource, value: signal(null) }, - { selector: RegistryResourcesSelectors.isCurrentResourceLoading, value: signal(false) }, - ], - }), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(AddResourceDialogComponent); - component = fixture.componentInstance; - store = TestBed.inject(Store); - dialogRef = TestBed.inject(DynamicDialogRef) as jest.Mocked; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); + it('should create with default values', () => { + const { component } = setup(); - it('should initialize with default values', () => { + expect(component).toBeTruthy(); expect(component.doiDomain).toBe('https://doi.org/'); - expect(component.inputLimits).toBeDefined(); expect(component.isResourceConfirming()).toBe(false); expect(component.isPreviewMode()).toBe(false); - expect(component.resourceOptions()).toBeDefined(); }); it('should initialize form with empty values', () => { + const { component } = setup(); + expect(component.form.get('pid')?.value).toBe(''); expect(component.form.get('resourceType')?.value).toBe(''); expect(component.form.get('description')?.value).toBe(''); }); it('should validate pid with DOI validator', () => { + const { component } = setup(); const pidControl = component.form.get('pid'); + pidControl?.setValue('invalid-doi'); pidControl?.updateValueAndValidity(); @@ -92,53 +95,130 @@ describe('AddResourceDialogComponent', () => { }); it('should accept valid DOI format', () => { + const { component } = setup(); const pidControl = component.form.get('pid'); + pidControl?.setValue('10.1234/valid.doi'); expect(pidControl?.hasError('doi')).toBe(false); }); it('should not preview resource when form is invalid', () => { - const dispatchSpy = jest.spyOn(store, 'dispatch'); - component.form.get('pid')?.setValue(''); + const { component, store } = setup(); + + (store.dispatch as jest.Mock).mockClear(); + component.previewResource(); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(component.isPreviewMode()).toBe(false); + }); + + it('should not preview resource when currentResource is null', () => { + const { component, store } = setup(); + component.form.patchValue({ pid: '10.1234/test', resourceType: 'data' }); + (store.dispatch as jest.Mock).mockClear(); component.previewResource(); - expect(dispatchSpy).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); expect(component.isPreviewMode()).toBe(false); }); - it('should throw error when previewing resource without current resource', () => { - component.form.patchValue({ - pid: '10.1234/test', - resourceType: 'dataset', + it('should preview resource and set preview mode on success', () => { + const { component, store } = setup({ + selectorOverrides: [{ selector: RegistryResourcesSelectors.getCurrentResource, value: MOCK_RESOURCE }], }); - expect(() => component.previewResource()).toThrow(); + component.form.patchValue({ pid: '10.1234/test', resourceType: 'data', description: 'desc' }); + (store.dispatch as jest.Mock).mockClear(); + component.previewResource(); + + expect(store.dispatch).toHaveBeenCalled(); + expect(component.isPreviewMode()).toBe(true); }); it('should set isPreviewMode to false when backToEdit is called', () => { - component.isPreviewMode.set(true); + const { component } = setup(); + component.isPreviewMode.set(true); component.backToEdit(); expect(component.isPreviewMode()).toBe(false); }); - it('should throw error when adding resource without current resource', () => { - expect(() => component.onAddResource()).toThrow(); + it('should not add resource when currentResource is null', () => { + const { component, store, dialogRef } = setup(); + + (store.dispatch as jest.Mock).mockClear(); + component.onAddResource(); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); }); - it('should close dialog without deleting when closeDialog is called without current resource', () => { - const dispatchSpy = jest.spyOn(store, 'dispatch'); + it('should confirm add resource and close dialog on success', () => { + const { component, store, dialogRef } = setup({ + selectorOverrides: [{ selector: RegistryResourcesSelectors.getCurrentResource, value: MOCK_RESOURCE }], + }); + + (store.dispatch as jest.Mock).mockClear(); + component.onAddResource(); + expect(component.isResourceConfirming()).toBe(false); + expect(store.dispatch).toHaveBeenCalled(); + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); + + it('should close dialog without deleting when currentResource is null', () => { + const { component, store, dialogRef } = setup(); + + (store.dispatch as jest.Mock).mockClear(); + component.closeDialog(); + + expect(dialogRef.close).toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should delete resource and close dialog when currentResource exists', () => { + const { component, store, dialogRef } = setup({ + selectorOverrides: [{ selector: RegistryResourcesSelectors.getCurrentResource, value: MOCK_RESOURCE }], + }); + + (store.dispatch as jest.Mock).mockClear(); component.closeDialog(); + expect(store.dispatch).toHaveBeenCalled(); expect(dialogRef.close).toHaveBeenCalled(); - expect(dispatchSpy).not.toHaveBeenCalled(); }); - it('should compute doiLink as undefined when current resource does not exist', () => { - expect(component.doiLink()).toBe('https://doi.org/undefined'); + it('should compute doiLink from currentResource pid', () => { + const { component } = setup({ + selectorOverrides: [{ selector: RegistryResourcesSelectors.getCurrentResource, value: MOCK_RESOURCE }], + }); + + expect(component.doiLink()).toBe('https://doi.org/10.1234/test'); + }); + + it('should return empty string for resourceTypeTranslationKey when currentResource is null', () => { + const { component } = setup(); + + expect(component.resourceTypeTranslationKey()).toBe(''); + }); + + it('should return translation key for resourceTypeTranslationKey when resource type matches', () => { + const { component } = setup({ + selectorOverrides: [{ selector: RegistryResourcesSelectors.getCurrentResource, value: MOCK_RESOURCE }], + }); + + expect(component.resourceTypeTranslationKey()).toBe('resources.typeOptions.data'); + }); + + it('should return empty string for resourceTypeTranslationKey when type is unknown', () => { + const unknownResource = { ...MOCK_RESOURCE, type: 'unknown_type' as RegistryResourceType }; + const { component } = setup({ + selectorOverrides: [{ selector: RegistryResourcesSelectors.getCurrentResource, value: unknownResource }], + }); + + expect(component.resourceTypeTranslationKey()).toBe(''); }); }); diff --git a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.ts b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.ts index 814a8cdf8..7d69a244a 100644 --- a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.ts +++ b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.ts @@ -1,11 +1,11 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { finalize, take } from 'rxjs'; +import { finalize } from 'rxjs'; import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; @@ -38,7 +38,6 @@ export class AddResourceDialogComponent { readonly dialogRef = inject(DynamicDialogRef); readonly currentResource = select(RegistryResourcesSelectors.getCurrentResource); readonly isCurrentResourceLoading = select(RegistryResourcesSelectors.isCurrentResourceLoading); - private readonly translateService = inject(TranslateService); private dialogConfig = inject(DynamicDialogConfig); private registryId: string = this.dialogConfig.data.id; @@ -61,11 +60,20 @@ export class AddResourceDialogComponent { deleteResource: SilentDelete, }); - public resourceOptions = signal(resourceTypeOptions); - public isPreviewMode = signal(false); + resourceOptions = signal(resourceTypeOptions); + isPreviewMode = signal(false); readonly RegistryResourceType = RegistryResourceType; + readonly resourceTypeTranslationKey = computed(() => { + const type = this.currentResource()?.type; + const options = this.resourceOptions(); + + if (!type || !options.length) return ''; + + return options.find((opt) => opt.value === type)?.label ?? ''; + }); + previewResource(): void { if (this.form.invalid) { return; @@ -79,7 +87,7 @@ export class AddResourceDialogComponent { const currentResource = this.currentResource(); if (!currentResource) { - throw new Error(this.translateService.instant('resources.errors.noCurrentResource')); + return; } this.actions.previewResource(currentResource.id, addResource).subscribe(() => this.isPreviewMode.set(true)); @@ -94,28 +102,23 @@ export class AddResourceDialogComponent { const currentResource = this.currentResource(); if (!currentResource) { - throw new Error(this.translateService.instant('resources.errors.noRegistryId')); + return; } this.isResourceConfirming.set(true); this.actions .confirmAddResource(addResource, currentResource.id, this.registryId) - .pipe( - take(1), - finalize(() => { - this.dialogRef.close(true); - this.isResourceConfirming.set(false); - }) - ) - .subscribe({}); + .pipe(finalize(() => this.isResourceConfirming.set(false))) + .subscribe(() => this.dialogRef.close(true)); } closeDialog(): void { - this.dialogRef.close(); const currentResource = this.currentResource(); if (currentResource) { this.actions.deleteResource(currentResource.id); } + + this.dialogRef.close(); } } diff --git a/src/app/features/registry/components/archiving-message/archiving-message.component.spec.ts b/src/app/features/registry/components/archiving-message/archiving-message.component.spec.ts index 6cf0e635c..5afea533d 100644 --- a/src/app/features/registry/components/archiving-message/archiving-message.component.spec.ts +++ b/src/app/features/registry/components/archiving-message/archiving-message.component.spec.ts @@ -1,106 +1,38 @@ import { MockComponents } from 'ng-mocks'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { IconComponent } from '@osf/shared/components/icon/icon.component'; -import { RegistryStatus } from '@osf/shared/enums/registry-status.enum'; -import { RegistrationOverviewModel } from '../../models'; import { ShortRegistrationInfoComponent } from '../short-registration-info/short-registration-info.component'; import { ArchivingMessageComponent } from './archiving-message.component'; import { MOCK_REGISTRATION_OVERVIEW_MODEL } from '@testing/mocks/registration-overview-model.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('ArchivingMessageComponent', () => { - let component: ArchivingMessageComponent; - let fixture: ComponentFixture; - let environment: any; - - const mockRegistration: RegistrationOverviewModel = MOCK_REGISTRATION_OVERVIEW_MODEL; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - ArchivingMessageComponent, - OSFTestingModule, - ...MockComponents(IconComponent, ShortRegistrationInfoComponent), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(ArchivingMessageComponent); - component = fixture.componentInstance; - environment = TestBed.inject(ENVIRONMENT); - - fixture.componentRef.setInput('registration', mockRegistration); - fixture.detectChanges(); - }); - - it('should have support email from environment', () => { - expect(component.supportEmail).toBeDefined(); - expect(typeof component.supportEmail).toBe('string'); - expect(component.supportEmail).toBe(environment.supportEmail); - }); - - it('should receive registration input', () => { - expect(component.registration()).toEqual(mockRegistration); - }); - - it('should have registration as a required input', () => { - expect(component.registration()).toBeDefined(); - expect(component.registration()).toEqual(mockRegistration); - }); - - it('should handle different registration statuses', () => { - const statuses = [ - RegistryStatus.Accepted, - RegistryStatus.Pending, - RegistryStatus.Withdrawn, - RegistryStatus.Embargo, - ]; - - statuses.forEach((status) => { - const registrationWithStatus = { ...mockRegistration, status }; - fixture.componentRef.setInput('registration', registrationWithStatus); - fixture.detectChanges(); - - expect(component.registration().status).toBe(status); + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArchivingMessageComponent, ...MockComponents(ShortRegistrationInfoComponent)], + providers: [provideOSFCore()], }); }); - it('should be reactive to registration input changes', () => { - const updatedRegistration = { ...mockRegistration, title: 'Updated Title' }; - - fixture.componentRef.setInput('registration', updatedRegistration); + it('should create and receive registration input', () => { + const fixture = TestBed.createComponent(ArchivingMessageComponent); + fixture.componentRef.setInput('registration', MOCK_REGISTRATION_OVERVIEW_MODEL); fixture.detectChanges(); - expect(component.registration().title).toBe('Updated Title'); + expect(fixture.componentInstance).toBeTruthy(); + expect(fixture.componentInstance.registration()).toEqual(MOCK_REGISTRATION_OVERVIEW_MODEL); }); - it('should update when registration properties change', () => { - const updatedRegistration = { - ...mockRegistration, - title: 'New Title', - description: 'New Description', - }; - - fixture.componentRef.setInput('registration', updatedRegistration); - fixture.detectChanges(); - - expect(component.registration().title).toBe('New Title'); - expect(component.registration().description).toBe('New Description'); - }); - - it('should maintain supportEmail reference when registration changes', () => { - const initialSupportEmail = component.supportEmail; - const updatedRegistration = { ...mockRegistration, title: 'Different Title' }; - - fixture.componentRef.setInput('registration', updatedRegistration); + it('should have supportEmail from environment', () => { + const fixture = TestBed.createComponent(ArchivingMessageComponent); + fixture.componentRef.setInput('registration', MOCK_REGISTRATION_OVERVIEW_MODEL); fixture.detectChanges(); - expect(component.supportEmail).toBe(initialSupportEmail); - expect(component.supportEmail).toBe(environment.supportEmail); + expect(fixture.componentInstance.supportEmail).toBe(TestBed.inject(ENVIRONMENT).supportEmail); }); }); diff --git a/src/app/features/registry/components/archiving-message/archiving-message.component.ts b/src/app/features/registry/components/archiving-message/archiving-message.component.ts index 3525ed5aa..bf66ce905 100644 --- a/src/app/features/registry/components/archiving-message/archiving-message.component.ts +++ b/src/app/features/registry/components/archiving-message/archiving-message.component.ts @@ -13,7 +13,7 @@ import { ShortRegistrationInfoComponent } from '../short-registration-info/short @Component({ selector: 'osf-archiving-message', - imports: [TranslatePipe, Card, IconComponent, Divider, ShortRegistrationInfoComponent], + imports: [Card, Divider, IconComponent, ShortRegistrationInfoComponent, TranslatePipe], templateUrl: './archiving-message.component.html', styleUrl: './archiving-message.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.spec.ts b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.spec.ts index c4f9447ff..e334b961c 100644 --- a/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.spec.ts +++ b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.spec.ts @@ -2,11 +2,11 @@ import { Store } from '@ngxs/store'; import { MockComponents, MockProvider } from 'ng-mocks'; -import { DynamicDialogConfig } from 'primeng/dynamicdialog'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { RegistryResourceType } from '@osf/shared/enums/registry-resource.enum'; @@ -17,168 +17,94 @@ import { ResourceFormComponent } from '../resource-form/resource-form.component' import { EditResourceDialogComponent } from './edit-resource-dialog.component'; -import { DynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +const MOCK_RESOURCE: RegistryResource = { + id: 'resource-123', + pid: '10.1234/test.doi', + type: RegistryResourceType.Data, + description: 'Test resource description', + finalized: false, +}; + describe('EditResourceDialogComponent', () => { - let component: EditResourceDialogComponent; - let fixture: ComponentFixture; - let store: Store; - let mockDialogConfig: jest.Mocked; - - const mockRegistryId = 'registry-123'; - const mockResource: RegistryResource = { - id: 'resource-123', - pid: '10.1234/test.doi', - type: RegistryResourceType.Data, - description: 'Test resource description', - finalized: false, - }; - - beforeEach(async () => { - mockDialogConfig = { - data: { - id: mockRegistryId, - resource: mockResource, - }, - } as jest.Mocked; - - await TestBed.configureTestingModule({ - imports: [ - EditResourceDialogComponent, - OSFTestingModule, - ...MockComponents(LoadingSpinnerComponent, ResourceFormComponent), - ], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [EditResourceDialogComponent, ...MockComponents(LoadingSpinnerComponent, ResourceFormComponent)], providers: [ - DynamicDialogRefMock, - MockProvider(DynamicDialogConfig, mockDialogConfig), + provideOSFCore(), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, { data: { id: 'registry-123', resource: MOCK_RESOURCE } }), provideMockStore({ signals: [{ selector: RegistryResourcesSelectors.isCurrentResourceLoading, value: false }], }), ], - }).compileComponents(); - - fixture = TestBed.createComponent(EditResourceDialogComponent); - component = fixture.componentInstance; - store = TestBed.inject(Store); - fixture.detectChanges(); + }); }); it('should initialize form with resource data', () => { - expect(component.form.get('pid')?.value).toBe(mockResource.pid); - expect(component.form.get('resourceType')?.value).toBe(mockResource.type); - expect(component.form.get('description')?.value).toBe(mockResource.description); - }); - - it('should have required validators on pid and resourceType', () => { - const pidControl = component.form.get('pid'); - const resourceTypeControl = component.form.get('resourceType'); - - expect(pidControl?.hasError('required')).toBe(false); - expect(resourceTypeControl?.hasError('required')).toBe(false); - }); - - it('should validate pid with DOI validator when invalid format', () => { - const pidControl = component.form.get('pid'); - pidControl?.setValue('invalid-doi'); - pidControl?.updateValueAndValidity(); - - const hasDoiError = pidControl?.hasError('doi') || pidControl?.hasError('invalidDoi'); - expect(hasDoiError).toBe(true); - }); - - it('should accept valid DOI format', () => { - const pidControl = component.form.get('pid'); - pidControl?.setValue('10.1234/valid.doi'); + const fixture = TestBed.createComponent(EditResourceDialogComponent); + fixture.detectChanges(); - expect(pidControl?.hasError('doi')).toBe(false); + expect(fixture.componentInstance.form.value).toEqual({ + pid: MOCK_RESOURCE.pid, + resourceType: MOCK_RESOURCE.type, + description: MOCK_RESOURCE.description, + }); }); - it('should mark form as invalid when pid is empty', () => { - component.form.get('pid')?.setValue(''); - component.form.get('pid')?.markAsTouched(); - - expect(component.form.invalid).toBe(true); - }); + it('should not dispatch when form is invalid', () => { + const fixture = TestBed.createComponent(EditResourceDialogComponent); + fixture.detectChanges(); + const store = TestBed.inject(Store); + const dispatchSpy = jest.spyOn(store, 'dispatch'); - it('should mark form as invalid when resourceType is empty', () => { - component.form.get('resourceType')?.setValue(''); - component.form.get('resourceType')?.markAsTouched(); + fixture.componentInstance.form.get('pid')?.setValue(''); + fixture.componentInstance.save(); - expect(component.form.invalid).toBe(true); + expect(dispatchSpy).not.toHaveBeenCalled(); }); - it('should mark form as valid when all required fields are filled with valid values', () => { - component.form.patchValue({ - pid: '10.1234/test', - resourceType: 'dataset', - description: 'Test description', - }); - - expect(component.form.valid).toBe(true); - }); + it('should dispatch UpdateResource and close dialog on success', () => { + const fixture = TestBed.createComponent(EditResourceDialogComponent); + fixture.detectChanges(); + const store = TestBed.inject(Store); + const dialogRef = TestBed.inject(DynamicDialogRef); + jest.spyOn(store, 'dispatch').mockReturnValue(of(undefined)); - it('should dispatch UpdateResource action with correct parameters when form is valid', () => { - const dispatchSpy = jest.spyOn(store, 'dispatch').mockReturnValue(of()); - component.form.patchValue({ + fixture.componentInstance.form.patchValue({ pid: '10.1234/updated', resourceType: 'paper', description: 'Updated description', }); + fixture.componentInstance.save(); - component.save(); - - expect(dispatchSpy).toHaveBeenCalledWith( + expect(store.dispatch).toHaveBeenCalledWith( expect.objectContaining({ - registryId: mockRegistryId, - resourceId: mockResource.id, - resource: expect.objectContaining({ - pid: '10.1234/updated', - resource_type: 'paper', - description: 'Updated description', - }), + registryId: 'registry-123', + resourceId: MOCK_RESOURCE.id, + resource: { pid: '10.1234/updated', resource_type: 'paper', description: 'Updated description' }, }) ); + expect(dialogRef.close).toHaveBeenCalledWith(true); }); - it('should handle empty description', () => { - const dispatchSpy = jest.spyOn(store, 'dispatch').mockReturnValue(of()); - component.form.patchValue({ - pid: '10.1234/test', - resourceType: 'dataset', - description: '', - }); - - component.save(); - - expect(dispatchSpy).toHaveBeenCalledWith( - expect.objectContaining({ - resource: expect.objectContaining({ - description: '', - }), - }) - ); - }); + it('should not close dialog on dispatch error', () => { + const fixture = TestBed.createComponent(EditResourceDialogComponent); + fixture.detectChanges(); + const store = TestBed.inject(Store); + const dialogRef = TestBed.inject(DynamicDialogRef); + jest.spyOn(store, 'dispatch').mockReturnValue(throwError(() => new Error('fail'))); - it('should handle null form values by converting to empty strings', () => { - const dispatchSpy = jest.spyOn(store, 'dispatch').mockReturnValue(of()); - component.form.patchValue({ - pid: '10.1234/test', - resourceType: 'dataset', - description: null, + fixture.componentInstance.form.patchValue({ + pid: '10.1234/updated', + resourceType: 'paper', + description: '', }); + fixture.componentInstance.save(); - component.save(); - - expect(dispatchSpy).toHaveBeenCalledWith( - expect.objectContaining({ - resource: expect.objectContaining({ - pid: '10.1234/test', - resource_type: 'dataset', - description: '', - }), - }) - ); + expect(dialogRef.close).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts index a6e84c692..247da58b1 100644 --- a/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts +++ b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts @@ -2,8 +2,6 @@ import { createDispatchMap, select } from '@ngxs/store'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { finalize, take } from 'rxjs'; - import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; @@ -16,7 +14,7 @@ import { ResourceFormComponent } from '../resource-form/resource-form.component' @Component({ selector: 'osf-edit-resource-dialog', - imports: [LoadingSpinnerComponent, ReactiveFormsModule, ResourceFormComponent], + imports: [ReactiveFormsModule, LoadingSpinnerComponent, ResourceFormComponent], templateUrl: './edit-resource-dialog.component.html', styleUrl: './edit-resource-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -25,7 +23,7 @@ export class EditResourceDialogComponent { readonly dialogRef = inject(DynamicDialogRef); readonly isCurrentResourceLoading = select(RegistryResourcesSelectors.isCurrentResourceLoading); - private dialogConfig = inject(DynamicDialogConfig); + private readonly dialogConfig = inject(DynamicDialogConfig); private registryId: string = this.dialogConfig.data.id; private resource: RegistryResource = this.dialogConfig.data.resource as RegistryResource; @@ -58,12 +56,6 @@ export class EditResourceDialogComponent { this.actions .updateResource(this.registryId, this.resource.id, addResource) - .pipe( - take(1), - finalize(() => { - this.dialogRef.close(true); - }) - ) - .subscribe(); + .subscribe(() => this.dialogRef.close(true)); } } diff --git a/src/app/features/registry/components/registration-links-card/registration-links-card.component.html b/src/app/features/registry/components/registration-links-card/registration-links-card.component.html index 612052d22..6e2f582b3 100644 --- a/src/app/features/registry/components/registration-links-card/registration-links-card.component.html +++ b/src/app/features/registry/components/registration-links-card/registration-links-card.component.html @@ -9,7 +9,7 @@ styleClass="text-lg" link [label]="registrationData().title || 'project.registrations.card.noTitle' | translate" - (click)="reviewEmitRegistrationData.emit(registrationData()!.id)" + (onClick)="reviewEmitRegistrationData.emit(registrationData()!.id)" >
@@ -82,17 +82,13 @@ - @if ( - registrationDataTyped() && - registrationDataTyped()?.currentUserPermissions && - registrationDataTyped()!.currentUserPermissions.length > 1 - ) { + @if (hasWriteAccess()) { } diff --git a/src/app/features/registry/components/registration-links-card/registration-links-card.component.spec.ts b/src/app/features/registry/components/registration-links-card/registration-links-card.component.spec.ts index f10170db1..1155f3d51 100644 --- a/src/app/features/registry/components/registration-links-card/registration-links-card.component.spec.ts +++ b/src/app/features/registry/components/registration-links-card/registration-links-card.component.spec.ts @@ -1,6 +1,6 @@ import { MockComponents } from 'ng-mocks'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { DataResourcesComponent } from '@osf/shared/components/data-resources/data-resources.component'; @@ -11,110 +11,77 @@ import { RegistrationLinksCardComponent } from './registration-links-card.compon import { createMockLinkedNode } from '@testing/mocks/linked-node.mock'; import { createMockLinkedRegistration } from '@testing/mocks/linked-registration.mock'; import { createMockRegistryComponent } from '@testing/mocks/registry-component.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { MockComponentWithSignal } from '@testing/providers/component-provider.mock'; describe('RegistrationLinksCardComponent', () => { - let component: RegistrationLinksCardComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ + beforeEach(() => { + TestBed.configureTestingModule({ imports: [ RegistrationLinksCardComponent, - OSFTestingModule, ...MockComponents(DataResourcesComponent, IconComponent, ContributorsListComponent), + MockComponentWithSignal('osf-truncated-text'), ], - }).compileComponents(); - - fixture = TestBed.createComponent(RegistrationLinksCardComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.componentRef.setInput('registrationData', createMockLinkedRegistration()); - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should set registrationData input correctly with LinkedRegistration', () => { - const mockLinkedRegistration = createMockLinkedRegistration(); - fixture.componentRef.setInput('registrationData', mockLinkedRegistration); - fixture.detectChanges(); - - expect(component.registrationData()).toEqual(mockLinkedRegistration); - }); - - it('should set registrationData input correctly with LinkedNode', () => { - const mockLinkedNode = createMockLinkedNode(); - fixture.componentRef.setInput('registrationData', mockLinkedNode); - fixture.detectChanges(); - - expect(component.registrationData()).toEqual(mockLinkedNode); + providers: [provideOSFCore()], + }); }); - it('should set registrationData input correctly with RegistryComponentModel', () => { - const mockRegistryComponent = createMockRegistryComponent(); - fixture.componentRef.setInput('registrationData', mockRegistryComponent); - fixture.detectChanges(); - - expect(component.registrationData()).toEqual(mockRegistryComponent); - }); - - it('should return true when data has reviewsState property', () => { + it('should identify LinkedRegistration data', () => { + const fixture = TestBed.createComponent(RegistrationLinksCardComponent); fixture.componentRef.setInput('registrationData', createMockLinkedRegistration()); fixture.detectChanges(); - expect(component.isRegistrationData()).toBe(true); + expect(fixture.componentInstance.isRegistrationData()).toBe(true); + expect(fixture.componentInstance.registrationDataTyped()).toEqual(createMockLinkedRegistration()); }); - it('should return false when data does not have reviewsState property', () => { + it('should identify LinkedNode data', () => { + const fixture = TestBed.createComponent(RegistrationLinksCardComponent); fixture.componentRef.setInput('registrationData', createMockLinkedNode()); fixture.detectChanges(); - expect(component.isRegistrationData()).toBe(false); + expect(fixture.componentInstance.isRegistrationData()).toBe(false); + expect(fixture.componentInstance.isComponentData()).toBe(false); + expect(fixture.componentInstance.registrationDataTyped()).toBeNull(); + expect(fixture.componentInstance.componentsDataTyped()).toBeNull(); }); - it('should return true when data has registrationSupplement property', () => { + it('should identify RegistryComponentModel data', () => { + const fixture = TestBed.createComponent(RegistrationLinksCardComponent); fixture.componentRef.setInput('registrationData', createMockRegistryComponent()); fixture.detectChanges(); - expect(component.isComponentData()).toBe(true); - }); - - it('should return true for LinkedRegistration with registrationSupplement', () => { - fixture.componentRef.setInput('registrationData', createMockLinkedRegistration()); - fixture.detectChanges(); - - expect(component.isComponentData()).toBe(true); + expect(fixture.componentInstance.isComponentData()).toBe(true); + expect(fixture.componentInstance.componentsDataTyped()).toEqual(createMockRegistryComponent()); }); - it('should return false when data does not have registrationSupplement property', () => { - fixture.componentRef.setInput('registrationData', createMockLinkedNode()); + it('should return true for hasWriteAccess when user has write permission', () => { + const fixture = TestBed.createComponent(RegistrationLinksCardComponent); + fixture.componentRef.setInput( + 'registrationData', + createMockLinkedRegistration({ currentUserPermissions: ['read', 'write'] }) + ); fixture.detectChanges(); - expect(component.isComponentData()).toBe(false); + expect(fixture.componentInstance.hasWriteAccess()).toBe(true); }); - it('should return LinkedRegistration when data has reviewsState', () => { - const mockLinkedRegistration = createMockLinkedRegistration(); - fixture.componentRef.setInput('registrationData', mockLinkedRegistration); + it('should return false for hasWriteAccess when user has read-only permission', () => { + const fixture = TestBed.createComponent(RegistrationLinksCardComponent); + fixture.componentRef.setInput( + 'registrationData', + createMockLinkedRegistration({ currentUserPermissions: ['read'] }) + ); fixture.detectChanges(); - expect(component.registrationDataTyped()).toEqual(mockLinkedRegistration); + expect(fixture.componentInstance.hasWriteAccess()).toBe(false); }); - it('should return null when data does not have reviewsState', () => { + it('should return false for hasWriteAccess for non-registration data', () => { + const fixture = TestBed.createComponent(RegistrationLinksCardComponent); fixture.componentRef.setInput('registrationData', createMockLinkedNode()); fixture.detectChanges(); - expect(component.registrationDataTyped()).toBeNull(); - }); - - it('should return RegistryComponentModel when data has registrationSupplement', () => { - const mockRegistryComponent = createMockRegistryComponent(); - fixture.componentRef.setInput('registrationData', mockRegistryComponent); - fixture.detectChanges(); - - expect(component.componentsDataTyped()).toEqual(mockRegistryComponent); + expect(fixture.componentInstance.hasWriteAccess()).toBe(false); }); }); diff --git a/src/app/features/registry/components/registration-links-card/registration-links-card.component.ts b/src/app/features/registry/components/registration-links-card/registration-links-card.component.ts index a050662ce..f585d316a 100644 --- a/src/app/features/registry/components/registration-links-card/registration-links-card.component.ts +++ b/src/app/features/registry/components/registration-links-card/registration-links-card.component.ts @@ -10,7 +10,7 @@ import { ContributorsListComponent } from '@osf/shared/components/contributors-l import { DataResourcesComponent } from '@osf/shared/components/data-resources/data-resources.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; -import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { LinkedNode, LinkedRegistration, RegistryComponentModel } from '../../models'; @@ -36,8 +36,6 @@ export class RegistrationLinksCardComponent { readonly updateEmitRegistrationData = output(); readonly reviewEmitRegistrationData = output(); - readonly RevisionReviewStates = RevisionReviewStates; - readonly isRegistrationData = computed(() => { const data = this.registrationData(); return 'reviewsState' in data; @@ -57,4 +55,8 @@ export class RegistrationLinksCardComponent { const data = this.registrationData(); return this.isComponentData() ? (data as RegistryComponentModel) : null; }); + + readonly hasWriteAccess = computed( + () => this.registrationDataTyped()?.currentUserPermissions?.includes(UserPermissions.Write) ?? false + ); } diff --git a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.spec.ts b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.spec.ts index 56a81a601..765ac611f 100644 --- a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.spec.ts +++ b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.spec.ts @@ -4,7 +4,7 @@ import { MockComponent, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { UserSelectors } from '@core/store/user'; import { SocialsShareButtonComponent } from '@osf/shared/components/socials-share-button/socials-share-button.component'; @@ -14,121 +14,123 @@ import { BookmarksSelectors } from '@osf/shared/stores/bookmarks'; import { RegistrationOverviewToolbarComponent } from './registration-overview-toolbar.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; - -describe('RegistrationOverviewToolbarComponent', () => { - let component: RegistrationOverviewToolbarComponent; - let fixture: ComponentFixture; - let store: jest.Mocked; - let toastService: ReturnType; - - const mockResourceId = 'registration-123'; - const mockResourceTitle = 'Test Registration'; - const mockBookmarksCollectionId = 'bookmarks-123'; - - beforeEach(async () => { - toastService = ToastServiceMockBuilder.create().build(); - - await TestBed.configureTestingModule({ - imports: [RegistrationOverviewToolbarComponent, OSFTestingModule, MockComponent(SocialsShareButtonComponent)], - providers: [ - provideMockStore({ - signals: [ - { selector: BookmarksSelectors.getBookmarksCollectionId, value: mockBookmarksCollectionId }, - { selector: BookmarksSelectors.getBookmarks, value: [] }, - { selector: BookmarksSelectors.areBookmarksLoading, value: false }, - { selector: BookmarksSelectors.getBookmarksCollectionIdSubmitting, value: false }, - { selector: UserSelectors.isAuthenticated, value: true }, - ], - }), - MockProvider(ToastService, toastService), - ], - }).compileComponents(); - - store = TestBed.inject(Store) as jest.Mocked; - store.dispatch = jest.fn().mockReturnValue(of(true)); - - fixture = TestBed.createComponent(RegistrationOverviewToolbarComponent); - component = fixture.componentInstance; - - fixture.componentRef.setInput('resourceId', mockResourceId); - fixture.componentRef.setInput('resourceTitle', mockResourceTitle); - fixture.componentRef.setInput('isPublic', true); - }); - - it('should set resourceId input correctly', () => { - fixture.detectChanges(); - expect(component.resourceId()).toBe(mockResourceId); +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; + +const MOCK_RESOURCE_ID = 'registration-123'; +const MOCK_BOOKMARKS_COLLECTION_ID = 'bookmarks-123'; + +interface SetupOverrides { + bookmarks?: { id: string }[]; + bookmarksCollectionId?: string | null; + isAuthenticated?: boolean; + selectorOverrides?: SignalOverride[]; +} + +function setup(overrides: SetupOverrides = {}) { + const mockToastService = ToastServiceMock.simple(); + + const defaultSignals = [ + { + selector: BookmarksSelectors.getBookmarksCollectionId, + value: 'bookmarksCollectionId' in overrides ? overrides.bookmarksCollectionId : MOCK_BOOKMARKS_COLLECTION_ID, + }, + { selector: BookmarksSelectors.getBookmarks, value: overrides.bookmarks ?? [] }, + { selector: BookmarksSelectors.areBookmarksLoading, value: false }, + { selector: BookmarksSelectors.getBookmarksCollectionIdSubmitting, value: false }, + { selector: UserSelectors.isAuthenticated, value: overrides.isAuthenticated ?? true }, + ]; + + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); + + TestBed.configureTestingModule({ + imports: [RegistrationOverviewToolbarComponent, MockComponent(SocialsShareButtonComponent)], + providers: [provideOSFCore(), MockProvider(ToastService, mockToastService), provideMockStore({ signals })], }); - it('should set resourceTitle input correctly', () => { - fixture.detectChanges(); - expect(component.resourceTitle()).toBe(mockResourceTitle); - }); + const store = TestBed.inject(Store); + const fixture = TestBed.createComponent(RegistrationOverviewToolbarComponent); + fixture.componentRef.setInput('resourceId', MOCK_RESOURCE_ID); + fixture.componentRef.setInput('resourceTitle', 'Test Registration'); + fixture.componentRef.setInput('isPublic', true); + fixture.detectChanges(); - it('should set isPublic input correctly', () => { - fixture.detectChanges(); - expect(component.isPublic()).toBe(true); - }); + return { fixture, component: fixture.componentInstance, store, mockToastService }; +} - it('should dispatch GetResourceBookmark when bookmarksCollectionId and resourceId exist', () => { - fixture.detectChanges(); +describe('RegistrationOverviewToolbarComponent', () => { + it('should dispatch GetResourceBookmark on init', () => { + const { store } = setup(); expect(store.dispatch).toHaveBeenCalledWith( expect.objectContaining({ - bookmarkCollectionId: mockBookmarksCollectionId, - resourceId: mockResourceId, + bookmarkCollectionId: MOCK_BOOKMARKS_COLLECTION_ID, + resourceId: MOCK_RESOURCE_ID, resourceType: ResourceType.Registration, }) ); }); - it('should set isBookmarked to false when bookmarks array is empty', () => { - fixture.detectChanges(); - expect(component.isBookmarked()).toBe(false); + it('should not dispatch GetResourceBookmark when bookmarksCollectionId is null', () => { + const { store } = setup({ bookmarksCollectionId: null }); + + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should not do anything when resourceId is missing', () => { - fixture.componentRef.setInput('resourceId', ''); - fixture.detectChanges(); + it('should compute isBookmarked from bookmarks', () => { + const { component } = setup({ bookmarks: [{ id: MOCK_RESOURCE_ID }] }); - component.toggleBookmark(); + expect(component.isBookmarked()).toBe(true); + }); - expect(store.dispatch).not.toHaveBeenCalled(); - expect(toastService.showSuccess).not.toHaveBeenCalled(); + it('should compute isBookmarked as false when not in bookmarks', () => { + const { component } = setup({ bookmarks: [{ id: 'other-id' }] }); + + expect(component.isBookmarked()).toBe(false); }); - it('should add bookmark when isBookmarked is false', () => { - fixture.detectChanges(); - component.isBookmarked.set(false); - jest.clearAllMocks(); + it('should dispatch add bookmark when not bookmarked', () => { + const { component, store, mockToastService } = setup(); + jest.spyOn(store, 'dispatch').mockReturnValue(of(undefined)); component.toggleBookmark(); expect(store.dispatch).toHaveBeenCalledWith( expect.objectContaining({ - bookmarkCollectionId: mockBookmarksCollectionId, - resourceId: mockResourceId, + bookmarkCollectionId: MOCK_BOOKMARKS_COLLECTION_ID, + resourceId: MOCK_RESOURCE_ID, resourceType: ResourceType.Registration, }) ); + expect(mockToastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.bookmark.add'); }); - it('should remove bookmark when isBookmarked is true', () => { - fixture.detectChanges(); - component.isBookmarked.set(true); - jest.clearAllMocks(); + it('should dispatch remove bookmark when already bookmarked', () => { + const { component, store, mockToastService } = setup({ bookmarks: [{ id: MOCK_RESOURCE_ID }] }); + jest.spyOn(store, 'dispatch').mockReturnValue(of(undefined)); component.toggleBookmark(); expect(store.dispatch).toHaveBeenCalledWith( expect.objectContaining({ - bookmarkCollectionId: mockBookmarksCollectionId, - resourceId: mockResourceId, + bookmarkCollectionId: MOCK_BOOKMARKS_COLLECTION_ID, + resourceId: MOCK_RESOURCE_ID, resourceType: ResourceType.Registration, }) ); + expect(mockToastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.bookmark.remove'); + }); + + it('should not dispatch toggleBookmark when resourceId is empty', () => { + const { fixture, store, mockToastService } = setup(); + fixture.componentRef.setInput('resourceId', ''); + fixture.detectChanges(); + jest.spyOn(store, 'dispatch').mockClear().mockReturnValue(of(undefined)); + + fixture.componentInstance.toggleBookmark(); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(mockToastService.showSuccess).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.ts b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.ts index 0038252f3..00640ee98 100644 --- a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.ts +++ b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.ts @@ -5,7 +5,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Tooltip } from 'primeng/tooltip'; -import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, input } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { UserSelectors } from '@core/store/user'; @@ -27,23 +27,26 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistrationOverviewToolbarComponent { - private toastService = inject(ToastService); - private destroyRef = inject(DestroyRef); + private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); - resourceId = input.required(); - resourceTitle = input.required(); - isPublic = input(false); + readonly resourceId = input.required(); + readonly resourceTitle = input.required(); + readonly isPublic = input(false); - isBookmarked = signal(false); - resourceType = ResourceType.Registration; + readonly resourceType = ResourceType.Registration; - bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId); - bookmarks = select(BookmarksSelectors.getBookmarks); - isBookmarksLoading = select(BookmarksSelectors.areBookmarksLoading); - isBookmarksSubmitting = select(BookmarksSelectors.getBookmarksCollectionIdSubmitting); - isAuthenticated = select(UserSelectors.isAuthenticated); + readonly bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId); + readonly bookmarks = select(BookmarksSelectors.getBookmarks); + readonly isBookmarksLoading = select(BookmarksSelectors.areBookmarksLoading); + readonly isBookmarksSubmitting = select(BookmarksSelectors.getBookmarksCollectionIdSubmitting); + readonly isAuthenticated = select(UserSelectors.isAuthenticated); - actions = createDispatchMap({ + readonly isBookmarked = computed( + () => this.bookmarks()?.some((bookmark) => bookmark.id === this.resourceId()) ?? false + ); + + private readonly actions = createDispatchMap({ getResourceBookmark: GetResourceBookmark, addResourceToBookmarks: AddResourceToBookmarks, removeResourceFromBookmarks: RemoveResourceFromBookmarks, @@ -57,17 +60,6 @@ export class RegistrationOverviewToolbarComponent { this.actions.getResourceBookmark(bookmarksCollectionId, this.resourceId(), this.resourceType); }); - - effect(() => { - const bookmarks = this.bookmarks(); - - if (!this.resourceId() || !bookmarks?.length) { - this.isBookmarked.set(false); - return; - } - - this.isBookmarked.set(bookmarks.some((bookmark) => bookmark.id === this.resourceId())); - }); } toggleBookmark(): void { @@ -75,24 +67,14 @@ export class RegistrationOverviewToolbarComponent { if (!this.resourceId() || !bookmarksId) return; - const newBookmarkState = !this.isBookmarked(); - - if (newBookmarkState) { - this.actions - .addResourceToBookmarks(bookmarksId, this.resourceId(), this.resourceType) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.isBookmarked.set(newBookmarkState); - this.toastService.showSuccess('project.overview.dialog.toast.bookmark.add'); - }); - } else { - this.actions - .removeResourceFromBookmarks(bookmarksId, this.resourceId(), this.resourceType) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.isBookmarked.set(newBookmarkState); - this.toastService.showSuccess('project.overview.dialog.toast.bookmark.remove'); - }); - } + const action = this.isBookmarked() + ? this.actions.removeResourceFromBookmarks(bookmarksId, this.resourceId(), this.resourceType) + : this.actions.addResourceToBookmarks(bookmarksId, this.resourceId(), this.resourceType); + + const toastKey = this.isBookmarked() + ? 'project.overview.dialog.toast.bookmark.remove' + : 'project.overview.dialog.toast.bookmark.add'; + + action.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.toastService.showSuccess(toastKey)); } } diff --git a/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.html b/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.html index a615d5e11..508d4540c 100644 --- a/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.html +++ b/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.html @@ -15,7 +15,7 @@ severity="danger" [disabled]="form.invalid" (onClick)="withdrawRegistration()" - [loading]="submitting" + [loading]="submitting()" >
diff --git a/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.spec.ts b/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.spec.ts index 5e05c64ca..c9f30842c 100644 --- a/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.spec.ts +++ b/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.spec.ts @@ -1,74 +1,83 @@ +import { Store } from '@ngxs/store'; + import { MockComponent, MockProvider } from 'ng-mocks'; -import { DynamicDialogConfig } from 'primeng/dynamicdialog'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { of, throwError } from 'rxjs'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { TextInputComponent } from '@osf/shared/components/text-input/text-input.component'; import { RegistrationWithdrawDialogComponent } from './registration-withdraw-dialog.component'; -import { DynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -describe('RegistrationWithdrawDialogComponent', () => { - let component: RegistrationWithdrawDialogComponent; - let fixture: ComponentFixture; - let mockDialogConfig: jest.Mocked; - - beforeEach(async () => { - mockDialogConfig = { - data: { registryId: 'test-registry-id' }, - } as jest.Mocked; - - await TestBed.configureTestingModule({ - imports: [RegistrationWithdrawDialogComponent, OSFTestingModule, MockComponent(TextInputComponent)], - providers: [ - DynamicDialogRefMock, - MockProvider(DynamicDialogConfig, mockDialogConfig), - provideMockStore({ - signals: [], - }), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(RegistrationWithdrawDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); +function setup(registryId = 'reg-123') { + TestBed.configureTestingModule({ + imports: [RegistrationWithdrawDialogComponent, MockComponent(TextInputComponent)], + providers: [ + provideOSFCore(), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, { data: { registryId } }), + provideMockStore(), + ], }); - it('should create', () => { + const store = TestBed.inject(Store); + const dialogRef = TestBed.inject(DynamicDialogRef); + const fixture = TestBed.createComponent(RegistrationWithdrawDialogComponent); + fixture.detectChanges(); + + return { fixture, component: fixture.componentInstance, store, dialogRef }; +} + +describe('RegistrationWithdrawDialogComponent', () => { + it('should create with default form state', () => { + const { component } = setup(); + expect(component).toBeTruthy(); + expect(component.submitting()).toBe(false); + expect(component.form.controls.text.value).toBe(''); }); - it('should initialize with default values', () => { - expect(component.submitting).toBe(false); - expect(component.form.get('text')?.value).toBe(''); + it('should dispatch withdraw and close dialog on success', () => { + const { component, store, dialogRef } = setup(); + jest.spyOn(store, 'dispatch').mockReturnValue(of(undefined)); + + component.form.controls.text.setValue('Withdrawal reason'); + component.withdrawRegistration(); + + expect(store.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + registryId: 'reg-123', + justification: 'Withdrawal reason', + }) + ); + expect(component.submitting()).toBe(false); + expect(dialogRef.close).toHaveBeenCalled(); }); - it('should have form validators', () => { - const textControl = component.form.get('text'); + it('should not close dialog on dispatch error', () => { + const { component, store, dialogRef } = setup(); + jest.spyOn(store, 'dispatch').mockReturnValue(throwError(() => new Error('fail'))); - expect(textControl?.hasError('required')).toBe(true); + component.form.controls.text.setValue('Reason'); + component.withdrawRegistration(); - textControl?.setValue('Valid withdrawal reason'); - expect(textControl?.hasError('required')).toBe(false); + expect(component.submitting()).toBe(false); + expect(dialogRef.close).not.toHaveBeenCalled(); }); - it('should handle form validation state', () => { - expect(component.form.valid).toBe(false); - - component.form.patchValue({ - text: 'Valid withdrawal reason', - }); - - expect(component.form.valid).toBe(true); + it('should not dispatch when registryId is missing', () => { + const { component, store, dialogRef } = setup(''); - component.form.patchValue({ - text: '', - }); + component.withdrawRegistration(); - expect(component.form.valid).toBe(false); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.ts b/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.ts index 8ace79288..b189debd7 100644 --- a/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.ts +++ b/src/app/features/registry/components/registration-withdraw-dialog/registration-withdraw-dialog.component.ts @@ -5,9 +5,9 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { finalize, take } from 'rxjs'; +import { finalize } from 'rxjs'; -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { WithdrawRegistration } from '@osf/features/registry/store/registry'; @@ -17,7 +17,7 @@ import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.hel @Component({ selector: 'osf-registration-withdraw-dialog', - imports: [TranslatePipe, TextInputComponent, Button], + imports: [Button, TextInputComponent, TranslatePipe], templateUrl: './registration-withdraw-dialog.component.html', styleUrl: './registration-withdraw-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -32,22 +32,16 @@ export class RegistrationWithdrawDialogComponent { }); readonly inputLimits = InputLimits; - submitting = false; + readonly submitting = signal(false); withdrawRegistration(): void { const registryId = this.config.data.registryId; - if (registryId) { - this.submitting = true; - this.actions - .withdrawRegistration(registryId, this.form.controls.text.value ?? '') - .pipe( - take(1), - finalize(() => { - this.submitting = false; - this.dialogRef.close(); - }) - ) - .subscribe(); - } + if (!registryId) return; + + this.submitting.set(true); + this.actions + .withdrawRegistration(registryId, this.form.controls.text.value ?? '') + .pipe(finalize(() => this.submitting.set(false))) + .subscribe(() => this.dialogRef.close()); } } diff --git a/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.spec.ts b/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.spec.ts index 595364d11..9e835c38c 100644 --- a/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.spec.ts +++ b/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.spec.ts @@ -1,118 +1,52 @@ import { MockComponent } from 'ng-mocks'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { RegistrationBlocksDataComponent } from '@osf/shared/components/registration-blocks-data/registration-blocks-data.component'; import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; -import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; import { RegistryBlocksSectionComponent } from './registry-blocks-section.component'; import { createMockPageSchema } from '@testing/mocks/page-schema.mock'; import { createMockSchemaResponse } from '@testing/mocks/schema-response.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('RegistryBlocksSectionComponent', () => { - let component: RegistryBlocksSectionComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RegistryBlocksSectionComponent, OSFTestingModule, MockComponent(RegistrationBlocksDataComponent)], - }).compileComponents(); - - fixture = TestBed.createComponent(RegistryBlocksSectionComponent); - component = fixture.componentInstance; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RegistryBlocksSectionComponent, MockComponent(RegistrationBlocksDataComponent)], + providers: [provideOSFCore()], + }); }); - it('should create', () => { - fixture.componentRef.setInput('schemaBlocks', []); + it('should create with required inputs', () => { + const fixture = TestBed.createComponent(RegistryBlocksSectionComponent); + fixture.componentRef.setInput('schemaBlocks', [createMockPageSchema()]); fixture.componentRef.setInput('schemaResponse', null); fixture.detectChanges(); - expect(component).toBeTruthy(); + expect(fixture.componentInstance).toBeTruthy(); + expect(fixture.componentInstance.isLoading()).toBe(false); }); - it('should set schemaBlocks input correctly', () => { - const mockBlocks: PageSchema[] = [createMockPageSchema()]; - fixture.componentRef.setInput('schemaBlocks', mockBlocks); - fixture.componentRef.setInput('schemaResponse', null); - fixture.detectChanges(); - - expect(component.schemaBlocks()).toEqual(mockBlocks); - }); - - it('should set schemaResponse input correctly', () => { + it('should compute updatedFields from schemaResponse', () => { + const fixture = TestBed.createComponent(RegistryBlocksSectionComponent); const mockResponse = createMockSchemaResponse('response-1', RevisionReviewStates.Approved); - fixture.componentRef.setInput('schemaBlocks', []); - fixture.componentRef.setInput('schemaResponse', mockResponse); - fixture.detectChanges(); + mockResponse.updatedResponseKeys = ['key1', 'key2']; - expect(component.schemaResponse()).toEqual(mockResponse); - }); - - it('should default isLoading to false', () => { - fixture.componentRef.setInput('schemaBlocks', []); - fixture.componentRef.setInput('schemaResponse', null); - fixture.detectChanges(); - - expect(component.isLoading()).toBe(false); - }); - - it('should compute updatedFields from schemaResponse with updatedResponseKeys', () => { - const mockResponse = createMockSchemaResponse('response-1', RevisionReviewStates.Approved); - mockResponse.updatedResponseKeys = ['key1', 'key2', 'key3']; fixture.componentRef.setInput('schemaBlocks', []); fixture.componentRef.setInput('schemaResponse', mockResponse); fixture.detectChanges(); - expect(component.updatedFields()).toEqual(['key1', 'key2', 'key3']); + expect(fixture.componentInstance.updatedFields()).toEqual(['key1', 'key2']); }); - it('should return empty array when schemaResponse is null', () => { + it('should return empty updatedFields when schemaResponse is null', () => { + const fixture = TestBed.createComponent(RegistryBlocksSectionComponent); fixture.componentRef.setInput('schemaBlocks', []); fixture.componentRef.setInput('schemaResponse', null); fixture.detectChanges(); - expect(component.updatedFields()).toEqual([]); - }); - - it('should handle single updatedResponseKey', () => { - const mockResponse = createMockSchemaResponse('response-1', RevisionReviewStates.Approved); - mockResponse.updatedResponseKeys = ['single-key']; - fixture.componentRef.setInput('schemaBlocks', []); - fixture.componentRef.setInput('schemaResponse', mockResponse); - fixture.detectChanges(); - - expect(component.updatedFields()).toEqual(['single-key']); - }); - - it('should initialize with all required inputs', () => { - const mockBlocks: PageSchema[] = [createMockPageSchema()]; - const mockResponse = createMockSchemaResponse('response-1', RevisionReviewStates.Approved); - - fixture.componentRef.setInput('schemaBlocks', mockBlocks); - fixture.componentRef.setInput('schemaResponse', mockResponse); - fixture.detectChanges(); - - expect(component.schemaBlocks()).toEqual(mockBlocks); - expect(component.schemaResponse()).toEqual(mockResponse); - expect(component.isLoading()).toBe(false); - }); - - it('should handle all inputs being set together', () => { - const mockBlocks: PageSchema[] = [createMockPageSchema()]; - const mockResponse = createMockSchemaResponse('response-1', RevisionReviewStates.Approved); - mockResponse.updatedResponseKeys = ['test-key']; - - fixture.componentRef.setInput('schemaBlocks', mockBlocks); - fixture.componentRef.setInput('schemaResponse', mockResponse); - fixture.componentRef.setInput('isLoading', true); - fixture.detectChanges(); - - expect(component.schemaBlocks()).toEqual(mockBlocks); - expect(component.schemaResponse()).toEqual(mockResponse); - expect(component.isLoading()).toBe(true); - expect(component.updatedFields()).toEqual(['test-key']); + expect(fixture.componentInstance.updatedFields()).toEqual([]); }); }); diff --git a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.html b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.html index e079f1667..480e1d155 100644 --- a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.html +++ b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.html @@ -67,7 +67,7 @@

- {{ requestForm.controls[ModerationDecisionFormControls.Comment].value.length }}/{{ decisionCommentLimit }} + {{ requestForm.controls[ModerationDecisionFormControls.Comment].value?.length }}/{{ decisionCommentLimit }}