diff --git a/angular.json b/angular.json index 8a4e753dc..55f95b4fe 100644 --- a/angular.json +++ b/angular.json @@ -77,6 +77,7 @@ "outputMode": "static", "server": false, "ssr": false, + "sourceMap": false, "budgets": [ { "type": "initial", diff --git a/docs/error-handling.md b/docs/error-handling.md new file mode 100644 index 000000000..51e031cd3 --- /dev/null +++ b/docs/error-handling.md @@ -0,0 +1,163 @@ +# Error handling + +Two complementary layers handle failures from API-backed work: + +| Layer | Responsibility | Entry point | +| -------------------- | ------------------------------------------------------------------- | ---------------------- | +| **HTTP interceptor** | Global UX: toast, navigation, auth, maintenance, loader | `error.interceptor.ts` | +| **NGXS state** | Section bookkeeping: clear loading flags, store `error`, Sentry tag | `handleSectionError` | + +Both **rethrow**. For a store action that calls HTTP: interceptor runs first → rethrow → state’s `catchError` → `handleSectionError`. + +## Index + +- [HTTP interceptor](#http-interceptor) + - [Flow](#flow) + - [Status handling](#status-handling) + - [Bypass](#bypass) + - [Silent 403](#silent-403) + - [Messages](#messages) + - [Caller guidelines](#caller-guidelines) +- [NGXS state errors](#ngxs-state-errors) +- [Related files](#related-files) + +## HTTP interceptor + +Central HTTP UX lives in `src/app/core/interceptors/error.interceptor.ts`. Prefer the interceptor for shared UX; handle locally only when the UI needs a special message or flow. + +### Flow + +Order before the status `switch` matters: + +1. **SSR (`!isBrowser`)** — no toast, navigation, loader, auth, or Sentry. For non-bypassed `401` / `403` / `404` / `410` / `5xx`, set `RESPONSE_INIT.status` on the requested URL; then rethrow. Network/timeout errors (`status: 0`) map to `503`. First non-OK wins, except a later `401` / `403` / `404` / `410` may replace an earlier `5xx`. Silent requests (`X-No-Auth-Redirect` or request-access URL) do not set status for any code; pending-moderation `403`s do. GUID guards (`isFileGuard`, `isProjectGuard`, `isRegistryGuard`) catch errors and return `false`, so matching falls through to `**` (not-found page HTML with the HTTP status from `RESPONSE_INIT`). +2. **Bypass** — Sentry capture + rethrow; skip all UX. +3. **Maintenance** — `503` with `meta.maintenance_mode: true` → hide loader, activate maintenance mode, rethrow. +4. **Status `switch`** — `401` / `403` / `404` / `409` / `410` / default. + +After the `switch` (except early `409` return), the interceptor hides the loader and rethrows. `409` does **not** hide the loader; the caller owns that. + +```mermaid +flowchart TD + err[HttpErrorResponse] --> ssr{Browser?} + ssr -->|no| ssrStatus[Set RESPONSE_INIT status + rethrow] + ssr -->|yes| bypass{BYPASS?} + bypass -->|yes| sentry[Sentry + rethrow] + bypass -->|no| maint{Maintenance 503?} + maint -->|yes| activate[Hide loader + activate maintenance + rethrow] + maint -->|no| status{Status} + status -->|409| conflict[Rethrow only] + status -->|other| rethrow2[Hide loader + rethrow] + rethrow2 --> state[NGXS catchError / handleSectionError] + conflict --> state + ssrStatus --> state + sentry --> state + activate --> state +``` + +### Status handling + +| Status | Interceptor behavior | +| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **401** | Skip auth redirect on view-only (`?view_only=`). Else logout if authenticated, or navigate to sign-in. Hide loader. | +| **403** | Navigate unless silent / pending-moderation (see below). Node URL → `/request-access/:id` (skipped on draft registry routes). Other → `/forbidden`. Hide loader. No toast. | +| **404** | Navigate to `/not-found`. Hide loader. No toast. | +| **409** | Rethrow only (no toast, no loader hide). Caller handles conflict UX. | +| **410** | Navigate to `/content-deleted`. Hide loader. No toast. | +| **Other** | Toast via `getErrorMessage`, hide loader. | + +`/not-found` and `/content-deleted` share `PageNotFoundComponent` with different route data (`PAGE_NOT_FOUND_ROUTE_DATA` / `CONTENT_DELETED_ROUTE_DATA`). Browser navigates to those routes (and `/forbidden`) for UX; SSR does not — it only sets `RESPONSE_INIT.status` on the requested URL via `ssr-response-status.helper.ts`. + +### Bypass + +Use when the **caller** owns all error UX (or the failure must not navigate/toast). + +```ts +import { bypassErrorInterceptorContext } from '@core/interceptors/error-interceptor.tokens'; + +this.http.get(url, { context: bypassErrorInterceptorContext() }); +``` + +`bypassErrorInterceptorContext()` returns a **new** `HttpContext` each call (do not reuse one mutable instance). + +Bypass still reports to Sentry and rethrows. It skips toast, navigation, loader, auth, and maintenance activation. + +Current examples: maintenance status polling, parent project fetch, Datacite tracker, addon OAuth token refresh, external identity delete. + +### Silent 403 + +These skip `/forbidden` / request-access navigation (loader still hides; error still rethrows): + +| Case | How | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Metadata record fetch | `X-No-Auth-Redirect` on `MetadataRecordsService.getMetadataRecord` (optional schema.org; prefer migrating to `HttpContext` later) | +| Node access-request API | URL matches `/v2/nodes/:id/requests` | +| Preprint pending moderation | Detail equals `PREPRINT_PENDING_MODERATION_DETAIL` — component routes to pending-moderation | + +### Messages + +`getErrorMessage` resolution: + +1. Client `ErrorEvent.message` +2. JSON:API `error.errors[0].detail` +3. For 5xx: `error.message` or `common.errorMessages.serverError` +4. Else `ERROR_MESSAGES[status]` or `common.errorMessages.unknown` + +`ERROR_MESSAGES` maps statuses to i18n keys under `common.errorMessages.*`. Toasts run through the translate pipe. + +### Caller guidelines + +- **Default** — do not toast in the subscriber; the interceptor already does for unhandled statuses. +- **Custom toast / flow** — either `bypassErrorInterceptorContext()` **or** remove the local toast so users do not see two messages. +- **409** — handle in the caller (interceptor is silent). +- **Expected 404/410 on secondary requests** — use bypass; otherwise the interceptor navigates away from the current page. +- **Pending-moderation preprint** — leave interceptor special-case + component navigation; do not send that request with full bypass unless you also handle 404/410/5xx locally. +- **Loader** — interceptor hides for most statuses; for `409` (and bypass) hide in the caller (`finalize` is a good pattern). + +## NGXS state errors + +Store actions that hit HTTP should end failed sections with `handleSectionError` from `src/app/shared/helpers/state-error.handler.ts`. + +It does **not** replace the interceptor. It: + +1. Reports to Sentry with `state.section` / feature tags (a second report if the interceptor already captured on bypass) +2. Patches the named section: `isLoading: false`, `isSubmitting: false`, `error: error.message` +3. Rethrows so components / `dispatch` subscribers can still react + +```ts +.catchError((error) => handleSectionError(ctx, 'registry', error)) +``` + +`section` must be a key on the state model whose value follows `AsyncStateModel` (see [NGXS docs](./ngxs.md#state-models)): + +```ts +export interface AsyncStateModel { + data: T; + isLoading: boolean; + isSubmitting?: boolean; + error: string | null; +} +``` + +Guidelines: + +- Use `handleSectionError` in action `catchError` for async sections — do not only clear loading in an ad-hoc patch unless the section is not `AsyncStateModel`-shaped. +- Do not toast inside the state handler; leave shared toasts to the interceptor (or the component when bypassed / `409`). +- UI that needs an inline error can read `section.error` from the store; many screens rely on interceptor toasts instead. +- Components that `dispatch(...).subscribe({ error })` still receive the error after both layers. + +## Related files + +| File | Role | +| ------------------------------------------------------- | ------------------------------------------ | +| `src/app/core/interceptors/error.interceptor.ts` | HTTP UX handler | +| `src/app/core/interceptors/error-interceptor.tokens.ts` | Bypass token + factory | +| `src/app/core/helpers/ssr-response-status.helper.ts` | SSR `RESPONSE_INIT.status` helper | +| `src/app/shared/helpers/state-error.handler.ts` | NGXS section error helper | +| `src/app/shared/models/store/async-state.model.ts` | Async section shape (`error` field) | +| `src/app/core/constants/error-messages.ts` | Status → i18n keys | +| `src/app/core/constants/http-error-details.const.ts` | Shared API detail strings | +| `src/app/core/constants/page-not-found.constants.ts` | Route data for 404 / 410 pages | +| `src/app/core/services/maintenance-mode.service.ts` | Maintenance detection + activation | +| `src/app/core/components/page-not-found/` | Shared not-found / content-deleted UI | +| `docs/ngxs.md` | Store / `AsyncStateModel` conventions | +| `docs/sentry.md` | Which HTTP errors are filtered from Sentry | diff --git a/docs/ngxs.md b/docs/ngxs.md index cb3df1551..a57c9c5c9 100644 --- a/docs/ngxs.md +++ b/docs/ngxs.md @@ -87,7 +87,7 @@ export interface FilesStateModel { 1. `data` holds strongly typed domain data (not raw JSON:API payloads when a domain model exists). 2. `isLoading` indicates a read/fetch is in progress. 3. `isSubmitting` indicates a write (create/update/delete) is in progress. -4. `error` stores a failed request message for UI or logging. +4. `error` stores a failed request message for UI or logging. Set it via `handleSectionError` in action `catchError` — see [Error handling](./error-handling.md#ngxs-state-errors). Each domain state should be minimal and scoped to its feature. @@ -112,4 +112,5 @@ Each domain state should be minimal and scoped to its feature. - [Models Conventions](./models.md) - [Folder Structure](./arch.md) +- [Error handling](./error-handling.md) (HTTP interceptor + `handleSectionError`) - Official NGXS docs: [https://www.ngxs.io/docs](https://www.ngxs.io/docs) diff --git a/docs/sentry.md b/docs/sentry.md index 27a1ae607..33982b849 100644 --- a/docs/sentry.md +++ b/docs/sentry.md @@ -2,6 +2,17 @@ Sentry collects JavaScript errors from the OSF Angular app in the browser. Many of those events are not application bugs: flaky networks, cancelled requests, missing/deleted API resources, browser extensions, and stale tabs after a deploy. +## Setup + +`Sentry.init` runs in the browser from `src/app/core/provider/application.initialization.provider.ts` after `assets/config/config.json` loads. Set: + +| Key | Role | +| ------------------- | ------------------------------------------------------------------------------------------------ | +| `sentryDsn` | Enables Sentry when non-empty | +| `sentryEnvironment` | Passed as Sentry’s `environment` option (use the same name as other services for managed Sentry) | + +## Filtering + Filtering happens on the client when Sentry starts. Change the lists in `src/app/core/helpers/sentry-filter.helper.ts`. That file is passed into `Sentry.init` from `src/app/core/provider/application.initialization.provider.ts`. [Sentry filtering docs](https://docs.sentry.io/platforms/javascript/configuration/filtering/) diff --git a/docs/ssr.md b/docs/ssr.md index 47b8ba14e..58d93bf5d 100644 --- a/docs/ssr.md +++ b/docs/ssr.md @@ -153,6 +153,10 @@ Docker `start:docker` runs the **development** configuration (CSR), not the SSR --- +## HTTP status codes for failed resources + +When SSR fetches a resource and the API returns `401` / `403` / `404` / `410` / `5xx`, the error interceptor sets `RESPONSE_INIT.status` on the requested URL. Network/timeout errors (`status: 0`) map to `503`. First non-OK wins, except a later `401` / `403` / `404` / `410` may replace an earlier `5xx`. No navigation/toasts on the server. Bypass skips this. Silent requests (`X-No-Auth-Redirect` or request-access URL) do not set status for any code. Anonymous SSR usually sees `401` for private resources (not `403`). GUID guards catch errors and return `false`, so routing falls through to `**` (not-found page HTML with that HTTP status). See [Error handling](./error-handling.md). + ## Adding or changing SSR routes 1. Add the route in `app.routes.ts` (browser routes). diff --git a/package-lock.json b/package-lock.json index 23bdf0250..e7a7be911 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "osf", - "version": "26.19.0", + "version": "26.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "osf", - "version": "26.19.0", + "version": "26.21.0", "dependencies": { "@angular/animations": "^21.2.25", "@angular/cdk": "^21.2.14", @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^21.2.25", "@angular/platform-server": "^21.2.25", "@angular/router": "^21.2.25", - "@angular/ssr": "^21.2.24", + "@angular/ssr": "^21.2.26", "@centerforopenscience/markdown-it-atrules": "^0.1.2", "@citation-js/core": "^0.7.21", "@citation-js/plugin-csl": "^0.7.21", @@ -55,8 +55,8 @@ "@angular-eslint/eslint-plugin": "^21.4.0", "@angular-eslint/eslint-plugin-template": "^21.4.0", "@angular-eslint/template-parser": "^21.4.0", - "@angular/build": "^21.2.24", - "@angular/cli": "^21.2.24", + "@angular/build": "^21.2.26", + "@angular/cli": "^21.2.26", "@angular/compiler-cli": "^21.2.25", "@commitlint/cli": "^20.5.3", "@commitlint/config-conventional": "^20.5.3", @@ -309,13 +309,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.2102.24", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.24.tgz", - "integrity": "sha512-U/d5l/1rfrNSlbGjMTunyzwxh+2vJce3y0r0tIx/NOFZbnYhq+gx+e33mR6oxI4yk/1Jks67ltvstrJuBbVbYg==", + "version": "0.2102.26", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.26.tgz", + "integrity": "sha512-dyDI2c4taPBHc+XS//sGz0OGuaCmGlGHXm1SAMS+Wd3XMZn26XHdRyew00z2w8VNRaEmHd1alk3iNB98KxDpmQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.24", + "@angular-devkit/core": "21.2.26", "rxjs": "7.8.2" }, "bin": { @@ -328,9 +328,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "21.2.24", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.24.tgz", - "integrity": "sha512-QLYLd6OcfQS5qWTlzG1/d8lFTcJDFcsY6sOaGVtrruYiDxrbeM2iJttoIoKzvbNPROFyo3fANVQJOVhytpXWPg==", + "version": "21.2.26", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.26.tgz", + "integrity": "sha512-9X6hwij620GDWJtzuZ1awGolx42Zt0kY9IRhyTAE/UyLvIdcj5+eKbKeAv43KR9wbFIHIwWPWZ+R+8ehiOetow==", "dev": true, "license": "MIT", "dependencies": { @@ -356,13 +356,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "21.2.24", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.24.tgz", - "integrity": "sha512-mLOme2vfbLrHFmMyM6ybPuGCnCvPiemXWdHnptY2Q7Hzum72DabfgtTNsrMcI2F5zrQV2DDQunNjC5cXqtq4/w==", + "version": "21.2.26", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.26.tgz", + "integrity": "sha512-ZT7/R4OaBw2uST76SOZsf7W+wXQ6WF2xKb78AxRKRZMkqUqwSj9xhKVUeqz+jeUDnwwVw4GdzfOJvX3kdyoeAA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.24", + "@angular-devkit/core": "21.2.26", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.3.0", @@ -500,14 +500,14 @@ } }, "node_modules/@angular/build": { - "version": "21.2.24", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.24.tgz", - "integrity": "sha512-NurM2W7Q/lCGonkYgSNNxGrh9AYH3LksekYRVxl0KxVKa8Z5x6oMYlLfyI4Lv9Q4C1/x1SXv3xNxgYu/nIW4vA==", + "version": "21.2.26", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.26.tgz", + "integrity": "sha512-DAG5MvD58NxWG5byvXeIbuMuefr7aFk2VNjmGagE5PZ+dvXv5rvS+G2aVks4dqFxiXvSbJknZwJVVHuruKApXQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2102.24", + "@angular-devkit/architect": "0.2102.26", "@babel/core": "7.29.7", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", @@ -524,7 +524,7 @@ "mrmime": "2.0.1", "parse5-html-rewriting-stream": "8.0.0", "picomatch": "4.0.4", - "piscina": "5.2.0", + "piscina": "5.3.2", "rolldown": "1.0.0-rc.4", "sass": "1.97.3", "semver": "7.7.4", @@ -550,7 +550,7 @@ "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "@angular/service-worker": "^21.0.0", - "@angular/ssr": "^21.2.24", + "@angular/ssr": "^21.2.26", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^21.0.0", @@ -626,19 +626,19 @@ } }, "node_modules/@angular/cli": { - "version": "21.2.24", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.24.tgz", - "integrity": "sha512-lx8baI3gssBv+xuwEf5wvAoBi7XSIB1RRuAmSnuAO2E4CAlJmU4eiVhKxd/haXvONH6peYxFGH2N6mSrkkHfIQ==", + "version": "21.2.26", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.26.tgz", + "integrity": "sha512-YwgU3hIrAqspMcFdV5jtwLrc4OraQayCaMuPlCKZcwlads3ERBk+YNoCVwgyY1Lmt3PT9Juo109LXiBVAPS0Qw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2102.24", - "@angular-devkit/core": "21.2.24", - "@angular-devkit/schematics": "21.2.24", + "@angular-devkit/architect": "0.2102.26", + "@angular-devkit/core": "21.2.26", + "@angular-devkit/schematics": "21.2.26", "@inquirer/prompts": "7.10.1", "@listr2/prompt-adapter-inquirer": "3.0.5", - "@modelcontextprotocol/sdk": "1.30.0", - "@schematics/angular": "21.2.24", + "@modelcontextprotocol/sdk": "1.31.0", + "@schematics/angular": "21.2.26", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.48.1", "ini": "6.0.0", @@ -845,9 +845,9 @@ } }, "node_modules/@angular/ssr": { - "version": "21.2.24", - "resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-21.2.24.tgz", - "integrity": "sha512-BROnqzOJVIgLlPUIXEXrb1eJwbGfel/nFnYKt524oNR2QBv3ErxHe4J2tCX7r4IKBC9B8Jgh06PE/UoDNp3RYg==", + "version": "21.2.26", + "resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-21.2.26.tgz", + "integrity": "sha512-6UP24Q3MQu+1Xs4WUiFZp+beagNk+7Y3iWzLLrx6pD+o97/QhY6C2nW5+7PaKa7onQ6cWWhK4gJy+dfkL4DZKA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2495,16 +2495,16 @@ "optional": true }, "node_modules/@hono/node-server": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz", - "integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.4.tgz", + "integrity": "sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==", "dev": true, "license": "MIT", "engines": { "node": ">=20" }, "peerDependencies": { - "hono": "^4" + "hono": "^4 || ^5.0.0-0" } }, "node_modules/@humanfs/core": { @@ -3162,9 +3162,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.31.0.tgz", + "integrity": "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==", "dev": true, "license": "MIT", "dependencies": { @@ -3650,9 +3650,9 @@ } }, "node_modules/@newrelic/browser-agent": { - "version": "1.323.0", - "resolved": "https://registry.npmjs.org/@newrelic/browser-agent/-/browser-agent-1.323.0.tgz", - "integrity": "sha512-geevV3bzUXvSPTZT1GadH/6Lv0Sa7NJgQtrBEDI9+IFpbODVJIe9vOeVzAm2GJLnhxCAsQ0pvymVHk+EeccfYg==", + "version": "1.324.0", + "resolved": "https://registry.npmjs.org/@newrelic/browser-agent/-/browser-agent-1.324.0.tgz", + "integrity": "sha512-lq/j1c8nHrzO/DxvSGV03uCPUxMdhTtE7b9w+5bfTZNQxf0UlXLbNAsK1PXQjap/vp255ugPkcgGZ4jdl3zTgA==", "license": "Apache-2.0", "dependencies": { "@newrelic/rrweb": "^1.1.2", @@ -5011,14 +5011,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "21.2.24", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.24.tgz", - "integrity": "sha512-eGBr4Yhg/A8Lt8QGtWxmXAyKboX8QBwbDG1hSyApQfHAh9iNFbV7mgWSmk0qPWvRJM38s0t3EEkEg79QZ98dWQ==", + "version": "21.2.26", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.26.tgz", + "integrity": "sha512-kDhwozMH8KNWuMh07TA/1y192kvUj6YF+BA+e5zFW7Rqy3KwUOz3C3eSFICU3+waO5i4E8S6p80PGeXRiQioDA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.24", - "@angular-devkit/schematics": "21.2.24", + "@angular-devkit/core": "21.2.26", + "@angular-devkit/schematics": "21.2.26", "jsonc-parser": "3.3.1" }, "engines": { @@ -5261,6 +5261,31 @@ "katex": "^0.16.0" } }, + "node_modules/@traptitech/markdown-it-katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@traptitech/markdown-it-katex/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -7807,9 +7832,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", - "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz", + "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==", "dev": true, "license": "MIT", "dependencies": { @@ -8307,9 +8332,9 @@ } }, "node_modules/hono": { - "version": "4.13.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.12.tgz", - "integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==", + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", "dev": true, "license": "MIT", "engines": { @@ -9034,31 +9059,6 @@ ], "license": "MIT" }, - "node_modules/katex": { - "version": "0.16.45", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", - "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", - "funding": [ - "https://opencollective.com/katex", - "https://github.com/sponsors/katex" - ], - "license": "MIT", - "dependencies": { - "commander": "^8.3.0" - }, - "bin": { - "katex": "cli.js" - } - }, - "node_modules/katex/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", @@ -10754,9 +10754,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -10764,6 +10764,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/punycode": { @@ -11855,9 +11859,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index 8a4a28afa..d3341102d 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@angular/platform-browser-dynamic": "^21.2.25", "@angular/platform-server": "^21.2.25", "@angular/router": "^21.2.25", - "@angular/ssr": "^21.2.24", + "@angular/ssr": "^21.2.26", "@centerforopenscience/markdown-it-atrules": "^0.1.2", "@citation-js/core": "^0.7.21", "@citation-js/plugin-csl": "^0.7.21", @@ -78,8 +78,8 @@ "@angular-eslint/eslint-plugin": "^21.4.0", "@angular-eslint/eslint-plugin-template": "^21.4.0", "@angular-eslint/template-parser": "^21.4.0", - "@angular/build": "^21.2.24", - "@angular/cli": "^21.2.24", + "@angular/build": "^21.2.26", + "@angular/cli": "^21.2.26", "@angular/compiler-cli": "^21.2.25", "@commitlint/cli": "^20.5.3", "@commitlint/config-conventional": "^20.5.3", @@ -107,9 +107,6 @@ "typescript-eslint": "^8.71.0", "vitest": "^4.1.11" }, - "overrides": { - "piscina": "5.3.2" - }, "lint-staged": { "**/*.{ts,html,scss}": [ "prettier --write", diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index cc1f1679b..686f083da 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -173,11 +173,13 @@ describe('AppComponent', () => { expect(fixture.nativeElement.querySelector('.maintenance-overlay')).toBeFalsy(); }); - it('should not subscribe to router events on server', () => { + it('should not dispatch user session or subscribe to router events on server', () => { setup({ isBrowser: false }); routerBuilder.emit(new ResolveStart(1, '/next', '/next', {} as any)); routerBuilder.emit(new NavigationEnd(5, '/x', '/x')); fixture.detectChanges(); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(maintenanceModeServiceMock.checkOnce).not.toHaveBeenCalled(); expect(loaderServiceMock.show).not.toHaveBeenCalled(); expect(loaderServiceMock.hide).not.toHaveBeenCalled(); expect(gtmServiceMock.pushTag).not.toHaveBeenCalled(); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 932d7bd31..2d9c888fa 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -53,16 +53,15 @@ export class AppComponent implements OnInit { } ngOnInit(): void { - this.actions - .getCurrentUser() - .pipe( - takeUntilDestroyed(this.destroyRef), - switchMap(() => this.actions.getEmails()) - ) - .subscribe(); - if (this.isBrowser) { - // `GetCurrentUser` short-circuits on a cached user, so it cannot be relied on to surface an outage. + this.actions + .getCurrentUser() + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap(() => this.actions.getEmails()) + ) + .subscribe(); + this.maintenanceModeService.checkOnce(); this.router.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts index 6e31c3e26..9ffcdb6b4 100644 --- a/src/app/app.routes.server.ts +++ b/src/app/app.routes.server.ts @@ -25,6 +25,10 @@ export const serverRoutes: ServerRoute[] = [ path: 'not-found', renderMode: RenderMode.Prerender, }, + { + path: 'content-deleted', + renderMode: RenderMode.Prerender, + }, { path: 'forgotpassword', renderMode: RenderMode.Prerender, diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 36b92bc58..b61606a89 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -7,6 +7,7 @@ import { isFileGuard } from '@core/guards/is-file.guard'; import { isProjectGuard } from '@core/guards/is-project.guard'; import { isRegistryGuard } from '@core/guards/is-registry.guard'; import { redirectIfLoggedInGuard } from '@core/guards/redirect-if-logged-in.guard'; +import { CONTENT_DELETED_ROUTE_DATA, PAGE_NOT_FOUND_ROUTE_DATA } from '@osf/core/constants/page-not-found.constants'; import { MyPreprintsState } from './features/preprints/store/my-preprints'; import { ProfileState } from './features/profile/store'; @@ -195,7 +196,13 @@ export const routes: Routes = [ path: 'not-found', loadComponent: () => import('./core/components/page-not-found/page-not-found.component').then((mod) => mod.PageNotFoundComponent), - data: { skipBreadcrumbs: true }, + data: PAGE_NOT_FOUND_ROUTE_DATA, + }, + { + path: 'content-deleted', + loadComponent: () => + import('./core/components/page-not-found/page-not-found.component').then((mod) => mod.PageNotFoundComponent), + data: CONTENT_DELETED_ROUTE_DATA, }, { path: ':id/files/:fileGuid/preview', @@ -244,7 +251,7 @@ export const routes: Routes = [ path: '**', loadComponent: () => import('./core/components/page-not-found/page-not-found.component').then((mod) => mod.PageNotFoundComponent), - data: { skipBreadcrumbs: true }, + data: PAGE_NOT_FOUND_ROUTE_DATA, }, ], }, 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 f44b44d5e..53e3c2c95 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 @@ -1,53 +1,63 @@ -import { CookieService } from 'ngx-cookie-service'; import { MockComponent, MockProvider } from 'ng-mocks'; +import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CookieConsentService } from '@core/services/cookie-consent.service'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { CookieServiceMock } from '@testing/providers/cookie-service.mock'; +import { CookieConsentServiceMock, CookieConsentServiceMockType } from '@testing/providers/cookie-consent.service.mock'; import { CookieConsentBannerComponent } from './cookie-consent-banner.component'; describe('Component: Cookie Consent Banner', () => { let fixture: ComponentFixture; let component: CookieConsentBannerComponent; + let cookieConsentMock: CookieConsentServiceMockType; - const cookieServiceMock = CookieServiceMock.simple(); + function setup(overrides: { hasConsent?: boolean; isBrowser?: boolean } = {}) { + cookieConsentMock = CookieConsentServiceMock.simple(); + cookieConsentMock.hasConsent.mockReturnValue(overrides.hasConsent ?? false); - beforeEach(() => { TestBed.configureTestingModule({ imports: [CookieConsentBannerComponent, MockComponent(IconComponent)], - providers: [provideOSFCore(), MockProvider(CookieService, cookieServiceMock)], + providers: [ + provideOSFCore(), + MockProvider(CookieConsentService, cookieConsentMock), + MockProvider(PLATFORM_ID, overrides.isBrowser === false ? 'server' : 'browser'), + ], }); - }); - it('should show the banner if cookie is not set', () => { - cookieServiceMock.check.mockReturnValue(false); fixture = TestBed.createComponent(CookieConsentBannerComponent); component = fixture.componentInstance; + } + + it('should show the banner when consent is missing', () => { + setup(); expect(component.displayBanner()).toBe(true); }); - it('should hide the banner if cookie is set', () => { - cookieServiceMock.check.mockReturnValue(true); - fixture = TestBed.createComponent(CookieConsentBannerComponent); - component = fixture.componentInstance; + it('should hide the banner when consent is already recorded', () => { + setup({ hasConsent: true }); expect(component.displayBanner()).toBe(false); }); - it('should set cookie and hide banner on acceptCookies()', () => { - cookieServiceMock.check.mockReturnValue(false); - fixture = TestBed.createComponent(CookieConsentBannerComponent); - component = fixture.componentInstance; + it('should keep the banner hidden outside the browser', () => { + setup({ isBrowser: false }); - component.acceptCookies(); + expect(component.displayBanner()).toBe(false); + expect(cookieConsentMock.hasConsent).not.toHaveBeenCalled(); + }); - expect(cookieServiceMock.set).toHaveBeenCalledWith('cookie-consent', 'true', new Date('9999-12-31T23:59:59Z'), '/'); + it('should accept cookies and hide the banner', () => { + setup(); + + component.acceptCookies(); + expect(cookieConsentMock.accept).toHaveBeenCalled(); expect(component.displayBanner()).toBe(false); }); }); 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 c593da853..c2d207af9 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 @@ -1,4 +1,3 @@ -import { CookieService } from 'ngx-cookie-service'; import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; @@ -7,12 +6,13 @@ import { Message } from 'primeng/message'; import { isPlatformBrowser } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, PLATFORM_ID, signal } from '@angular/core'; +import { CookieConsentService } from '@core/services/cookie-consent.service'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; /** * Displays a cookie consent banner until the user accepts. * - * - Uses `ngx-cookie-service` to persist acceptance across sessions. + * - Persists acceptance through `CookieConsentService`. * - Automatically hides the banner if consent is already recorded. * - Supports translation via `TranslatePipe`. */ @@ -24,30 +24,22 @@ import { IconComponent } from '@osf/shared/components/icon/icon.component'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class CookieConsentBannerComponent { - /** - * The name of the cookie used to track whether the user accepted cookies. - */ - private readonly cookieName = 'cookie-consent'; - /** * Signal controlling the visibility of the cookie banner. * Set to `true` if the user has not accepted cookies yet. */ readonly displayBanner = signal(false); - /** - * Cookie service used to persist dismissal state in the browser. - */ - private readonly cookies = inject(CookieService); - private readonly platformId = inject(PLATFORM_ID); + private readonly cookieConsent = inject(CookieConsentService); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); /** * Initializes the component and sets the banner display * based on the existence of the cookie. */ constructor() { - if (isPlatformBrowser(this.platformId)) { - this.displayBanner.set(!this.cookies.check(this.cookieName)); + if (this.isBrowser) { + this.displayBanner.set(!this.cookieConsent.hasConsent()); } } @@ -57,10 +49,7 @@ export class CookieConsentBannerComponent { * - Hides the banner immediately. */ acceptCookies() { - if (isPlatformBrowser(this.platformId)) { - const expireDate = new Date('9999-12-31T23:59:59Z'); - this.cookies.set(this.cookieName, 'true', expireDate, '/'); - } + this.cookieConsent.accept(); this.displayBanner.set(false); } } 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 4a83b2f36..c597ed8a0 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 @@ -73,11 +73,11 @@ describe('MaintenanceBannerComponent', () => { expect(component.maintenance()).toEqual(activeMaintenance); }); - it('should fetch maintenance on server without cookie check', () => { + it('should not check cookie or fetch maintenance on server', () => { setup({ isBrowser: false, maintenance: activeMaintenance }); expect(cookieServiceMock.check).not.toHaveBeenCalled(); - expect(maintenanceServiceMock.fetchMaintenanceStatus).toHaveBeenCalledTimes(1); - expect(component.maintenance()).toEqual(activeMaintenance); + expect(maintenanceServiceMock.fetchMaintenanceStatus).not.toHaveBeenCalled(); + expect(component.maintenance()).toBeNull(); }); it('should dismiss and persist cookie in browser', () => { @@ -89,7 +89,7 @@ describe('MaintenanceBannerComponent', () => { }); it('should dismiss without setting cookie on server', () => { - setup({ isBrowser: false, maintenance: activeMaintenance }); + setup({ isBrowser: false }); component.dismiss(); expect(cookieServiceMock.set).not.toHaveBeenCalled(); expect(component.dismissed()).toBe(true); 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 b2980488b..70cf4b293 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 @@ -65,10 +65,12 @@ export class MaintenanceBannerComponent implements OnInit { * - If not dismissed, triggers a fetch of current maintenance status */ ngOnInit(): void { - if (isPlatformBrowser(this.platformId)) { - this.dismissed.set(this.cookies.check(this.cookieName)); + if (!isPlatformBrowser(this.platformId)) { + return; } + this.dismissed.set(this.cookies.check(this.cookieName)); + if (!this.dismissed()) { this.fetchMaintenanceStatus(); } diff --git a/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.spec.ts b/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.spec.ts index 0d54a92f3..ff421ca22 100644 --- a/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.spec.ts +++ b/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.spec.ts @@ -4,7 +4,7 @@ import { MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; -import { signal, WritableSignal } from '@angular/core'; +import { PLATFORM_ID, signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { BannerModel } from '@core/components/osf-banners/models/banner.model'; @@ -24,43 +24,51 @@ describe('Component: Scheduled Banner', () => { const currentBannerSignal: WritableSignal = signal(null); let isMobile$: BehaviorSubject; - beforeEach(() => { + function setup(overrides: { isBrowser?: boolean } = {}) { isMobile$ = new BehaviorSubject(false); + currentBannerSignal.set(null); TestBed.configureTestingModule({ imports: [ScheduledBannerComponent], providers: [ provideOSFCore(), MockProvider(IS_XSMALL, isMobile$), + MockProvider(PLATFORM_ID, overrides.isBrowser === false ? 'server' : 'browser'), provideMockStore({ signals: [{ selector: BannersSelector.getCurrentBanner, value: currentBannerSignal }], }), ], }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(ScheduledBannerComponent); component = fixture.componentInstance; fixture.detectChanges(); - - store = TestBed.inject(Store); - }); + } it('should create the component', () => { + setup(); expect(component).toBeInstanceOf(ScheduledBannerComponent); }); - it('should dispatch FetchCurrentScheduledBanner on init', () => { - const dispatchSpy = vi.spyOn(store, 'dispatch'); - component.ngOnInit(); - expect(dispatchSpy).toHaveBeenCalledWith(new GetCurrentScheduledBanner()); + it('should dispatch GetCurrentScheduledBanner on init in browser', () => { + setup(); + expect(store.dispatch).toHaveBeenCalledWith(new GetCurrentScheduledBanner()); + }); + + it('should not dispatch GetCurrentScheduledBanner on server', () => { + setup({ isBrowser: false }); + expect(store.dispatch).not.toHaveBeenCalled(); }); it('should return false if no banner is set', () => { + setup(); currentBannerSignal.set(null); expect(component.shouldShowBanner()).toBe(false); }); it('should return true if current time is within banner window', () => { + setup(); const showBanner = component.shouldShowBanner; expect(showBanner()).toBeFalsy(); @@ -87,6 +95,7 @@ describe('Component: Scheduled Banner', () => { }); it('should return false if current time is outside the banner window', () => { + setup(); const now = new Date(); currentBannerSignal.set( Object({ @@ -104,6 +113,7 @@ describe('Component: Scheduled Banner', () => { }); it('should reflect the isMobile signal', () => { + setup(); const now = new Date(); currentBannerSignal.set( Object({ diff --git a/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.ts b/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.ts index c2761b236..742359b6d 100644 --- a/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.ts +++ b/src/app/core/components/osf-banners/scheduled-banner/scheduled-banner.component.ts @@ -1,6 +1,7 @@ import { Store } from '@ngxs/store'; -import { ChangeDetectionStrategy, Component, computed, inject, OnInit } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, OnInit, PLATFORM_ID } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { IS_XSMALL } from '@osf/shared/helpers/breakpoints.tokens'; @@ -27,6 +28,7 @@ export class ScheduledBannerComponent implements OnInit { * Injected NGXS Store instance used to dispatch actions and select state. */ private readonly store = inject(Store); + private readonly platformId = inject(PLATFORM_ID); /** * Reactive signal of the currently scheduled banner fetched from the NGXS store. @@ -44,7 +46,9 @@ export class ScheduledBannerComponent implements OnInit { * when the component initializes. */ ngOnInit(): void { - this.store.dispatch(new GetCurrentScheduledBanner()); + if (isPlatformBrowser(this.platformId)) { + this.store.dispatch(new GetCurrentScheduledBanner()); + } } /** diff --git a/src/app/core/components/osf-banners/tos-consent-banner/tos-consent-banner.component.ts b/src/app/core/components/osf-banners/tos-consent-banner/tos-consent-banner.component.ts index 7d6024103..abbc247c3 100644 --- a/src/app/core/components/osf-banners/tos-consent-banner/tos-consent-banner.component.ts +++ b/src/app/core/components/osf-banners/tos-consent-banner/tos-consent-banner.component.ts @@ -6,7 +6,7 @@ import { Button } from 'primeng/button'; import { Checkbox } from 'primeng/checkbox'; import { Message } from 'primeng/message'; -import { Component, computed, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; @@ -32,6 +32,7 @@ import { IconComponent } from '@osf/shared/components/icon/icon.component'; selector: 'osf-tos-consent-banner', imports: [FormsModule, Checkbox, Button, Message, TranslatePipe, IconComponent, RouterLink], templateUrl: './tos-consent-banner.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, }) export class TosConsentBannerComponent { /** diff --git a/src/app/core/components/page-not-found/page-not-found.component.html b/src/app/core/components/page-not-found/page-not-found.component.html index 47db29983..bc1d5613f 100644 --- a/src/app/core/components/page-not-found/page-not-found.component.html +++ b/src/app/core/components/page-not-found/page-not-found.component.html @@ -1,8 +1,7 @@
-

{{ 'pageNotFound.title' | translate }}

-

- {{ 'pageNotFound.message' | translate }} - {{ supportEmail }}. -

+

{{ titleKey | translate }}

+

{{ messageKey | translate }}

+ + {{ 'pageNotFound.contactSupport' | translate }} +
diff --git a/src/app/core/components/page-not-found/page-not-found.component.spec.ts b/src/app/core/components/page-not-found/page-not-found.component.spec.ts index 9cf89eddf..757476f29 100644 --- a/src/app/core/components/page-not-found/page-not-found.component.spec.ts +++ b/src/app/core/components/page-not-found/page-not-found.component.spec.ts @@ -1,6 +1,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CONTENT_DELETED_ROUTE_DATA, PAGE_NOT_FOUND_ROUTE_DATA } from '@core/constants/page-not-found.constants'; + import { provideOSFCore } from '@testing/osf.testing.provider'; +import { ActivatedRouteMockBuilder, provideActivatedRouteMock } from '@testing/providers/route-provider.mock'; import { PageNotFoundComponent } from './page-not-found.component'; @@ -8,22 +11,51 @@ describe('PageNotFoundComponent', () => { let component: PageNotFoundComponent; let fixture: ComponentFixture; - beforeEach(() => { + function setup(data: Record = {}) { TestBed.configureTestingModule({ imports: [PageNotFoundComponent], - providers: [provideOSFCore()], + providers: [ + provideOSFCore(), + provideActivatedRouteMock(ActivatedRouteMockBuilder.create().withData(data).build()), + ], }); fixture = TestBed.createComponent(PageNotFoundComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); it('should expose support email from environment', () => { + setup(); expect(component.supportEmail).toBe('support@test.com'); }); + + it('should fall back to page not found keys when route data is empty', () => { + setup(); + expect(component.titleKey).toBe(PAGE_NOT_FOUND_ROUTE_DATA.titleKey); + expect(component.messageKey).toBe(PAGE_NOT_FOUND_ROUTE_DATA.messageKey); + }); + + it('should use page not found route data when provided', () => { + setup(PAGE_NOT_FOUND_ROUTE_DATA); + expect(component.titleKey).toBe(PAGE_NOT_FOUND_ROUTE_DATA.titleKey); + expect(component.messageKey).toBe(PAGE_NOT_FOUND_ROUTE_DATA.messageKey); + }); + + it('should use content deleted route data when provided', () => { + setup(CONTENT_DELETED_ROUTE_DATA); + expect(component.titleKey).toBe(CONTENT_DELETED_ROUTE_DATA.titleKey); + expect(component.messageKey).toBe(CONTENT_DELETED_ROUTE_DATA.messageKey); + }); + + it('should render a contact support mailto link', () => { + setup(); + const link = fixture.nativeElement.querySelector('a') as HTMLAnchorElement; + expect(link.getAttribute('href')).toBe('mailto:support@test.com'); + }); }); diff --git a/src/app/core/components/page-not-found/page-not-found.component.ts b/src/app/core/components/page-not-found/page-not-found.component.ts index b8e4aadf5..d0f941de1 100644 --- a/src/app/core/components/page-not-found/page-not-found.component.ts +++ b/src/app/core/components/page-not-found/page-not-found.component.ts @@ -1,9 +1,12 @@ import { TranslatePipe } from '@ngx-translate/core'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { PAGE_NOT_FOUND_ROUTE_DATA } from '../../constants/page-not-found.constants'; + @Component({ selector: 'osf-page-not-found', imports: [TranslatePipe], @@ -13,5 +16,9 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; }) export class PageNotFoundComponent { private readonly environment = inject(ENVIRONMENT); + private readonly route = inject(ActivatedRoute); + readonly supportEmail = this.environment.supportEmail; + readonly titleKey = this.route.snapshot.data['titleKey'] ?? PAGE_NOT_FOUND_ROUTE_DATA.titleKey; + readonly messageKey = this.route.snapshot.data['messageKey'] ?? PAGE_NOT_FOUND_ROUTE_DATA.messageKey; } diff --git a/src/app/core/components/request-access/request-access.component.spec.ts b/src/app/core/components/request-access/request-access.component.spec.ts index 48a7984a7..61b30d41c 100644 --- a/src/app/core/components/request-access/request-access.component.spec.ts +++ b/src/app/core/components/request-access/request-access.component.spec.ts @@ -118,8 +118,19 @@ describe('RequestAccessComponent', () => { expect(loaderServiceMock.show).toHaveBeenCalled(); expect(requestAccessServiceMock.requestAccessToProject).toHaveBeenCalledWith('project-123', 'please grant access'); expect(loaderServiceMock.hide).toHaveBeenCalled(); - expect(routerMock.navigate).toHaveBeenCalledWith(['/']); expect(toastServiceMock.showSuccess).toHaveBeenCalledWith('requestAccess.requestedSuccessMessage'); + expect(routerMock.navigate).toHaveBeenCalledWith(['/']); + expect(toastServiceMock.showSuccess.mock.invocationCallOrder[0]).toBeLessThan( + (routerMock.navigate as Mock).mock.invocationCallOrder[0] + ); + }); + + it('should not request access when route id is missing', () => { + setup({ routeId: '' }); + component.requestAccess(); + + expect(loaderServiceMock.show).not.toHaveBeenCalled(); + expect(requestAccessServiceMock.requestAccessToProject).not.toHaveBeenCalled(); }); it('should show already requested error on 409', () => { @@ -129,6 +140,7 @@ describe('RequestAccessComponent', () => { component.requestAccess(); expect(loaderServiceMock.show).toHaveBeenCalled(); + expect(loaderServiceMock.hide).toHaveBeenCalled(); expect(toastServiceMock.showError).toHaveBeenCalledWith('requestAccess.alreadyRequestedMessage'); expect(routerMock.navigate).not.toHaveBeenCalled(); }); @@ -139,6 +151,7 @@ describe('RequestAccessComponent', () => { }); component.requestAccess(); + expect(loaderServiceMock.hide).toHaveBeenCalled(); expect(toastServiceMock.showError).not.toHaveBeenCalled(); expect(routerMock.navigate).not.toHaveBeenCalled(); }); diff --git a/src/app/core/components/request-access/request-access.component.ts b/src/app/core/components/request-access/request-access.component.ts index fe8002095..ee817064f 100644 --- a/src/app/core/components/request-access/request-access.component.ts +++ b/src/app/core/components/request-access/request-access.component.ts @@ -5,7 +5,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Textarea } from 'primeng/textarea'; -import { map, of } from 'rxjs'; +import { finalize, map } from 'rxjs'; import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, computed, inject, model } from '@angular/core'; @@ -36,7 +36,7 @@ export class RequestAccessComponent { readonly commentLimit = InputLimits.requestAccessComment.maxLength; private readonly route = inject(ActivatedRoute); - private readonly id = toSignal(this.route?.params.pipe(map((params) => params['id'])) ?? of(undefined)); + private readonly id = toSignal(this.route.params.pipe(map((params) => params['id']))); private readonly router = inject(Router); private readonly requestAccessService = inject(RequestAccessService); @@ -55,19 +55,26 @@ export class RequestAccessComponent { ); requestAccess() { + const id = this.id(); + if (!id) { + return; + } + this.loaderService.show(); - this.requestAccessService.requestAccessToProject(this.id(), this.comment()).subscribe({ - next: () => { - this.loaderService.hide(); - this.router.navigate(['/']); - this.toastService.showSuccess('requestAccess.requestedSuccessMessage'); - }, - error: (error: HttpErrorResponse) => { - if (error.status === 409) { - this.toastService.showError('requestAccess.alreadyRequestedMessage'); - } - }, - }); + this.requestAccessService + .requestAccessToProject(id, this.comment()) + .pipe(finalize(() => this.loaderService.hide())) + .subscribe({ + next: () => { + this.toastService.showSuccess('requestAccess.requestedSuccessMessage'); + this.router.navigate(['/']); + }, + error: (error: HttpErrorResponse) => { + if (error.status === 409) { + this.toastService.showError('requestAccess.alreadyRequestedMessage'); + } + }, + }); } switchAccount() { diff --git a/src/app/core/constants/error-messages.ts b/src/app/core/constants/error-messages.ts index c3284b020..b7948442d 100644 --- a/src/app/core/constants/error-messages.ts +++ b/src/app/core/constants/error-messages.ts @@ -1,9 +1,9 @@ export const ERROR_MESSAGES = { - 400: 'Invalid request. Please check your input.', - 401: 'You are not authorized to perform this action.', - 403: `You don't have permission to access this resource.`, - 404: 'The requested resource was not found.', - 500: 'An unexpected error occurred. Please try again later.', - 0: 'An unexpected error occurred. Please try again later.', - default: 'An unknown error occurred. Please try again.', -}; + 0: 'common.errorMessages.serverError', + 400: 'common.errorMessages.badRequest', + 401: 'common.errorMessages.unauthorized', + 403: 'common.errorMessages.forbidden', + 404: 'common.errorMessages.notFound', + 500: 'common.errorMessages.serverError', + default: 'common.errorMessages.unknown', +} as const; diff --git a/src/app/core/constants/http-error-details.const.ts b/src/app/core/constants/http-error-details.const.ts new file mode 100644 index 000000000..afed22ea3 --- /dev/null +++ b/src/app/core/constants/http-error-details.const.ts @@ -0,0 +1,2 @@ +export const PREPRINT_PENDING_MODERATION_DETAIL = + 'This preprint is pending moderation and is not yet publicly available.'; diff --git a/src/app/core/constants/page-not-found.constants.ts b/src/app/core/constants/page-not-found.constants.ts new file mode 100644 index 000000000..c2bd8b2e1 --- /dev/null +++ b/src/app/core/constants/page-not-found.constants.ts @@ -0,0 +1,11 @@ +export const PAGE_NOT_FOUND_ROUTE_DATA = { + skipBreadcrumbs: true, + titleKey: 'pageNotFound.title', + messageKey: 'pageNotFound.message', +} as const; + +export const CONTENT_DELETED_ROUTE_DATA = { + skipBreadcrumbs: true, + titleKey: 'contentDeleted.title', + messageKey: 'contentDeleted.message', +} as const; diff --git a/src/app/core/guards/is-file.guard.ts b/src/app/core/guards/is-file.guard.ts index 482e257f4..76eabbf44 100644 --- a/src/app/core/guards/is-file.guard.ts +++ b/src/app/core/guards/is-file.guard.ts @@ -1,6 +1,6 @@ import { Store } from '@ngxs/store'; -import { map, switchMap } from 'rxjs/operators'; +import { catchError, map, of, switchMap } from 'rxjs'; import { isPlatformBrowser } from '@angular/common'; import { inject, PLATFORM_ID } from '@angular/core'; @@ -58,6 +58,7 @@ export const isFileGuard: CanMatchFn = (route: Route, segments: UrlSegment[]) => } return resource.type === CurrentResourceType.Files; - }) + }), + catchError(() => of(false)) ); }; diff --git a/src/app/core/guards/is-project.guard.ts b/src/app/core/guards/is-project.guard.ts index d3b3e11c3..69638b3ed 100644 --- a/src/app/core/guards/is-project.guard.ts +++ b/src/app/core/guards/is-project.guard.ts @@ -1,6 +1,6 @@ import { Store } from '@ngxs/store'; -import { map, switchMap } from 'rxjs/operators'; +import { catchError, map, of, switchMap } from 'rxjs'; import { inject } from '@angular/core'; import { CanMatchFn, Route, Router, UrlSegment } from '@angular/router'; @@ -49,6 +49,7 @@ export const isProjectGuard: CanMatchFn = (route: Route, segments: UrlSegment[]) } return resource.type === CurrentResourceType.Projects; - }) + }), + catchError(() => of(false)) ); }; diff --git a/src/app/core/guards/is-registry.guard.ts b/src/app/core/guards/is-registry.guard.ts index db23c3f8f..f531ca78d 100644 --- a/src/app/core/guards/is-registry.guard.ts +++ b/src/app/core/guards/is-registry.guard.ts @@ -1,6 +1,6 @@ import { Store } from '@ngxs/store'; -import { map, switchMap } from 'rxjs/operators'; +import { catchError, map, of, switchMap } from 'rxjs'; import { inject } from '@angular/core'; import { CanMatchFn, Route, Router, UrlSegment } from '@angular/router'; @@ -49,6 +49,7 @@ export const isRegistryGuard: CanMatchFn = (route: Route, segments: UrlSegment[] } return resource.type === CurrentResourceType.Registrations; - }) + }), + catchError(() => of(false)) ); }; diff --git a/src/app/core/helpers/ssr-response-status.helper.spec.ts b/src/app/core/helpers/ssr-response-status.helper.spec.ts new file mode 100644 index 000000000..4e1baad10 --- /dev/null +++ b/src/app/core/helpers/ssr-response-status.helper.spec.ts @@ -0,0 +1,46 @@ +import { setSsrResponseStatus, shouldPropagateHttpStatusToSsr } from './ssr-response-status.helper'; + +describe('ssr-response-status.helper', () => { + it('should propagate 0, 401, 403, 404, 410, and 5xx statuses', () => { + expect(shouldPropagateHttpStatusToSsr(0)).toBe(true); + expect(shouldPropagateHttpStatusToSsr(401)).toBe(true); + expect(shouldPropagateHttpStatusToSsr(403)).toBe(true); + expect(shouldPropagateHttpStatusToSsr(404)).toBe(true); + expect(shouldPropagateHttpStatusToSsr(410)).toBe(true); + expect(shouldPropagateHttpStatusToSsr(500)).toBe(true); + expect(shouldPropagateHttpStatusToSsr(503)).toBe(true); + }); + + it('should not propagate 409 or other 4xx statuses', () => { + expect(shouldPropagateHttpStatusToSsr(409)).toBe(false); + expect(shouldPropagateHttpStatusToSsr(422)).toBe(false); + }); + + it('should set status when response init is available and unset', () => { + const responseInit: ResponseInit = {}; + setSsrResponseStatus(responseInit, 404); + expect(responseInit.status).toBe(404); + }); + + it('should map network status 0 to 503', () => { + const responseInit: ResponseInit = {}; + setSsrResponseStatus(responseInit, 0); + expect(responseInit.status).toBe(503); + }); + + it('should keep an existing non-OK status when the next status is 5xx', () => { + const responseInit: ResponseInit = { status: 404 }; + setSsrResponseStatus(responseInit, 500); + expect(responseInit.status).toBe(404); + }); + + it('should replace an earlier 5xx with a later resource status', () => { + const responseInit: ResponseInit = { status: 503 }; + setSsrResponseStatus(responseInit, 404); + expect(responseInit.status).toBe(404); + }); + + it('should no-op when response init is missing', () => { + expect(() => setSsrResponseStatus(null, 404)).not.toThrow(); + }); +}); diff --git a/src/app/core/helpers/ssr-response-status.helper.ts b/src/app/core/helpers/ssr-response-status.helper.ts new file mode 100644 index 000000000..87deec39c --- /dev/null +++ b/src/app/core/helpers/ssr-response-status.helper.ts @@ -0,0 +1,23 @@ +export const isServerError = (status: number): boolean => status >= 500 && status < 600; + +const isResourceHttpStatus = (status: number): boolean => + status === 401 || status === 403 || status === 404 || status === 410; + +const toSsrHttpStatus = (status: number): number => (status === 0 ? 503 : status); + +export function setSsrResponseStatus(responseInit: ResponseInit | null, status: number): void { + if (!responseInit) { + return; + } + + const next = toSsrHttpStatus(status); + const current = responseInit.status; + + if (!current || current === 200 || (isServerError(current) && isResourceHttpStatus(next))) { + responseInit.status = next; + } +} + +export function shouldPropagateHttpStatusToSsr(status: number): boolean { + return status === 0 || isResourceHttpStatus(status) || isServerError(status); +} diff --git a/src/app/core/interceptors/error-interceptor.tokens.ts b/src/app/core/interceptors/error-interceptor.tokens.ts index 83b2c9e1b..395d3381d 100644 --- a/src/app/core/interceptors/error-interceptor.tokens.ts +++ b/src/app/core/interceptors/error-interceptor.tokens.ts @@ -1,3 +1,7 @@ -import { HttpContextToken } from '@angular/common/http'; +import { HttpContext, HttpContextToken } from '@angular/common/http'; export const BYPASS_ERROR_INTERCEPTOR = new HttpContextToken(() => false); + +export function bypassErrorInterceptorContext(): HttpContext { + return new HttpContext().set(BYPASS_ERROR_INTERCEPTOR, true); +} diff --git a/src/app/core/interceptors/error.interceptor.spec.ts b/src/app/core/interceptors/error.interceptor.spec.ts index a082c289d..951e22394 100644 --- a/src/app/core/interceptors/error.interceptor.spec.ts +++ b/src/app/core/interceptors/error.interceptor.spec.ts @@ -3,10 +3,12 @@ import { MockProvider } from 'ng-mocks'; import { firstValueFrom, throwError } from 'rxjs'; import { HttpContext, HttpErrorResponse, HttpHeaders, HttpRequest } from '@angular/common/http'; -import { PLATFORM_ID } from '@angular/core'; +import { PLATFORM_ID, RESPONSE_INIT } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; +import { ERROR_MESSAGES } from '@core/constants/error-messages'; +import { PREPRINT_PENDING_MODERATION_DETAIL } from '@core/constants/http-error-details.const'; import { SENTRY_TOKEN } from '@core/provider/sentry.provider'; import { AuthService } from '@core/services/auth.service'; import { MaintenanceModeService } from '@core/services/maintenance-mode.service'; @@ -28,7 +30,7 @@ import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast import { ViewOnlyLinkHelperMock, ViewOnlyLinkHelperMockType } from '@testing/providers/view-only-link-helper.mock'; import { errorInterceptor } from './error.interceptor'; -import { BYPASS_ERROR_INTERCEPTOR } from './error-interceptor.tokens'; +import { bypassErrorInterceptorContext } from './error-interceptor.tokens'; describe('errorInterceptor', () => { let router: RouterMockType; @@ -38,6 +40,7 @@ describe('errorInterceptor', () => { let maintenanceModeServiceMock: MaintenanceModeServiceMockType; let viewOnlyHelperMock: ViewOnlyLinkHelperMockType; let sentryMock: SentryMockType; + let responseInit: ResponseInit; function setup( platformId: 'browser' | 'server' = 'browser', @@ -52,6 +55,7 @@ describe('errorInterceptor', () => { maintenanceModeServiceMock = MaintenanceModeServiceMock.simple(); viewOnlyHelperMock = ViewOnlyLinkHelperMock.simple(viewOnly); sentryMock = SentryMock.simple(); + responseInit = {}; TestBed.configureTestingModule({ providers: [ @@ -67,12 +71,13 @@ describe('errorInterceptor', () => { MockProvider(ViewOnlyLinkHelperService, viewOnlyHelperMock), MockProvider(PLATFORM_ID, platformId), { provide: SENTRY_TOKEN, useValue: sentryMock }, + { provide: RESPONSE_INIT, useValue: responseInit }, ], }); } function createRequest(url = '/api/v2/nodes/abc', bypass = false, noAuthRedirect = false) { - const context = bypass ? new HttpContext().set(BYPASS_ERROR_INTERCEPTOR, true) : new HttpContext(); + const context = bypass ? bypassErrorInterceptorContext() : new HttpContext(); const headers = noAuthRedirect ? new HttpHeaders({ 'X-No-Auth-Redirect': 'true' }) : new HttpHeaders(); return new HttpRequest('GET', url, { context, headers } as unknown); } @@ -113,6 +118,118 @@ describe('errorInterceptor', () => { expect(toastServiceMock.showError).toHaveBeenCalledWith('server failed'); }); + it('should set SSR response status without navigation on server platform', async () => { + setup('server'); + const request = createRequest('/api/v2/nodes/abc'); + const error = new HttpErrorResponse({ status: 404, error: {}, url: request.url }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(404); + expect(responseInit.status).toBe(404); + expect(router.navigate).not.toHaveBeenCalled(); + expect(loaderServiceMock.hide).not.toHaveBeenCalled(); + expect(toastServiceMock.showError).not.toHaveBeenCalled(); + expect(sentryMock.captureException).not.toHaveBeenCalled(); + }); + + it('should set SSR response status for 401 without navigation', async () => { + setup('server'); + const request = createRequest('/api/v2/guids/abc'); + const error = new HttpErrorResponse({ status: 401, error: {}, url: request.url }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(401); + expect(responseInit.status).toBe(401); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('should set SSR response status for pending-moderation 403', async () => { + setup('server'); + const request = createRequest('/api/v2/preprints/abc'); + const error = new HttpErrorResponse({ + status: 403, + error: { errors: [{ detail: PREPRINT_PENDING_MODERATION_DETAIL }] }, + url: request.url, + }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(403); + expect(responseInit.status).toBe(403); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('should not set SSR response status for silent 403', async () => { + setup('server'); + const request = createRequest('/api/v2/nodes/abc', false, true); + const error = new HttpErrorResponse({ status: 403, error: {}, url: request.url }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(403); + expect(responseInit.status).toBeUndefined(); + }); + + it('should not set SSR response status for bypassed requests on server platform', async () => { + setup('server'); + const request = createRequest('/api/v2/nodes/abc', true); + const error = new HttpErrorResponse({ status: 404, error: {}, url: request.url }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(404); + expect(responseInit.status).toBeUndefined(); + }); + + it('should keep the first non-OK SSR response status', async () => { + setup('server'); + responseInit.status = 404; + + const request = createRequest('/api/v2/nodes/abc'); + const error = new HttpErrorResponse({ status: 500, error: {}, url: request.url }); + + await runInterceptor(request, error); + + expect(responseInit.status).toBe(404); + }); + + it('should replace an earlier 5xx SSR response status with a resource status', async () => { + setup('server'); + responseInit.status = 503; + + const request = createRequest('/api/v2/nodes/abc'); + const error = new HttpErrorResponse({ status: 404, error: {}, url: request.url }); + + await runInterceptor(request, error); + + expect(responseInit.status).toBe(404); + }); + + it('should map network status 0 to 503 on server platform', async () => { + setup('server'); + const request = createRequest('/api/v2/nodes/abc'); + const error = new HttpErrorResponse({ status: 0, error: {}, url: request.url }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(0); + expect(responseInit.status).toBe(503); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('should not set SSR response status for silent network errors', async () => { + setup('server'); + const request = createRequest('/api/v2/nodes/abc', false, true); + const error = new HttpErrorResponse({ status: 0, error: {}, url: request.url }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(0); + expect(responseInit.status).toBeUndefined(); + }); + it('should rethrow 409 without toast or loader handling', async () => { setup(); const request = createRequest('/api/v2/nodes/abc'); @@ -135,7 +252,7 @@ describe('errorInterceptor', () => { expect(caught?.status).toBe(401); expect(authServiceMock.navigateToSignIn).toHaveBeenCalled(); expect(authServiceMock.logout).not.toHaveBeenCalled(); - expect(loaderServiceMock.hide).not.toHaveBeenCalled(); + expect(loaderServiceMock.hide).toHaveBeenCalled(); expect(toastServiceMock.showError).not.toHaveBeenCalled(); }); @@ -149,7 +266,7 @@ describe('errorInterceptor', () => { expect(caught?.status).toBe(401); expect(authServiceMock.logout).toHaveBeenCalledWith(window.location.href); expect(authServiceMock.navigateToSignIn).not.toHaveBeenCalled(); - expect(loaderServiceMock.hide).not.toHaveBeenCalled(); + expect(loaderServiceMock.hide).toHaveBeenCalled(); expect(toastServiceMock.showError).not.toHaveBeenCalled(); }); @@ -162,9 +279,10 @@ describe('errorInterceptor', () => { expect(caught?.status).toBe(401); expect(authServiceMock.logout).not.toHaveBeenCalled(); + expect(loaderServiceMock.hide).toHaveBeenCalled(); }); - it('should navigate to request-access for 403 node URL', async () => { + it('should navigate to request-access for 403 node URL without toast', async () => { setup('browser', false, '/projects/abc'); const request = createRequest('/api/v2/nodes/abc'); const error = new HttpErrorResponse({ status: 403, error: {}, url: '/api/v2/nodes/abc' }); @@ -172,9 +290,35 @@ describe('errorInterceptor', () => { const caught = await runInterceptor(request, error); expect(caught?.status).toBe(403); - expect(router.navigate).toHaveBeenCalledWith(['/request-access/abc']); + expect(router.navigate).toHaveBeenCalledWith(['/request-access', 'abc']); expect(loaderServiceMock.hide).toHaveBeenCalled(); - expect(toastServiceMock.showError).toHaveBeenCalled(); + expect(toastServiceMock.showError).not.toHaveBeenCalled(); + }); + + it('should navigate to forbidden for 403 without a node URL', async () => { + setup(); + const request = createRequest('/api/v2/users/me/'); + const error = new HttpErrorResponse({ status: 403, error: {}, url: '/api/v2/users/me/' }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(403); + expect(router.navigate).toHaveBeenCalledWith(['/forbidden']); + expect(loaderServiceMock.hide).toHaveBeenCalled(); + expect(toastServiceMock.showError).not.toHaveBeenCalled(); + }); + + it('should not redirect on 403 when current route is a draft registry', async () => { + setup('browser', false, '/registries/drafts/abc'); + const request = createRequest('/api/v2/nodes/abc'); + const error = new HttpErrorResponse({ status: 403, error: {}, url: '/api/v2/nodes/abc' }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(403); + expect(router.navigate).not.toHaveBeenCalled(); + expect(loaderServiceMock.hide).toHaveBeenCalled(); + expect(toastServiceMock.showError).not.toHaveBeenCalled(); }); it('should not redirect on 403 when no-auth-redirect header is present', async () => { @@ -190,6 +334,36 @@ describe('errorInterceptor', () => { expect(toastServiceMock.showError).not.toHaveBeenCalled(); }); + it('should not redirect on 403 for node request-access URL', async () => { + setup(); + const request = createRequest('/api/v2/nodes/abc/requests/'); + const error = new HttpErrorResponse({ status: 403, error: {}, url: '/api/v2/nodes/abc/requests/' }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(403); + expect(router.navigate).not.toHaveBeenCalled(); + expect(loaderServiceMock.hide).toHaveBeenCalled(); + expect(toastServiceMock.showError).not.toHaveBeenCalled(); + }); + + it('should not redirect on 403 when preprint is pending moderation', async () => { + setup(); + const request = createRequest('/api/v2/preprints/abc'); + const error = new HttpErrorResponse({ + status: 403, + error: { errors: [{ detail: PREPRINT_PENDING_MODERATION_DETAIL }] }, + url: request.url, + }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(403); + expect(router.navigate).not.toHaveBeenCalled(); + expect(loaderServiceMock.hide).toHaveBeenCalled(); + expect(toastServiceMock.showError).not.toHaveBeenCalled(); + }); + it('should activate maintenance mode on 503 maintenance response', async () => { setup('browser', false); const request = createRequest('/api/v2/'); @@ -206,4 +380,58 @@ describe('errorInterceptor', () => { expect(loaderServiceMock.hide).toHaveBeenCalled(); expect(toastServiceMock.showError).not.toHaveBeenCalled(); }); + + it('should navigate to not-found for 404 without toast', async () => { + setup(); + const request = createRequest('/api/v2/nodes/abc'); + const error = new HttpErrorResponse({ status: 404, error: {}, url: request.url }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(404); + expect(router.navigate).toHaveBeenCalledWith(['/not-found']); + expect(loaderServiceMock.hide).toHaveBeenCalled(); + expect(toastServiceMock.showError).not.toHaveBeenCalled(); + }); + + it('should navigate to content-deleted for 410 without toast', async () => { + setup(); + const request = createRequest('/api/v2/nodes/abc'); + const error = new HttpErrorResponse({ status: 410, error: {}, url: request.url }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(410); + expect(router.navigate).toHaveBeenCalledWith(['/content-deleted']); + expect(loaderServiceMock.hide).toHaveBeenCalled(); + expect(toastServiceMock.showError).not.toHaveBeenCalled(); + }); + + it('should toast API detail for default status codes', async () => { + setup(); + const request = createRequest('/api/v2/nodes/abc'); + const error = new HttpErrorResponse({ + status: 400, + error: { errors: [{ detail: 'Invalid title' }] }, + url: request.url, + }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(400); + expect(toastServiceMock.showError).toHaveBeenCalledWith('Invalid title'); + expect(loaderServiceMock.hide).toHaveBeenCalled(); + }); + + it('should toast mapped i18n key when no API detail is present', async () => { + setup(); + const request = createRequest('/api/v2/nodes/abc'); + const error = new HttpErrorResponse({ status: 400, error: {}, url: request.url }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(400); + expect(toastServiceMock.showError).toHaveBeenCalledWith(ERROR_MESSAGES[400]); + expect(loaderServiceMock.hide).toHaveBeenCalled(); + }); }); diff --git a/src/app/core/interceptors/error.interceptor.ts b/src/app/core/interceptors/error.interceptor.ts index 4a620df46..1709438d0 100644 --- a/src/app/core/interceptors/error.interceptor.ts +++ b/src/app/core/interceptors/error.interceptor.ts @@ -1,15 +1,19 @@ import { Store } from '@ngxs/store'; -import { throwError } from 'rxjs'; -import { catchError } from 'rxjs/operators'; +import { catchError, throwError } from 'rxjs'; import { isPlatformBrowser } from '@angular/common'; -import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; -import { inject, PLATFORM_ID } from '@angular/core'; +import { HttpErrorResponse, HttpInterceptorFn, HttpRequest } from '@angular/common/http'; +import { inject, PLATFORM_ID, RESPONSE_INIT } from '@angular/core'; import { Router } from '@angular/router'; import { ERROR_MESSAGES } from '@core/constants/error-messages'; -import { MaintenanceResponse } from '@core/models/maintenance-response.model'; +import { PREPRINT_PENDING_MODERATION_DETAIL } from '@core/constants/http-error-details.const'; +import { + isServerError, + setSsrResponseStatus, + shouldPropagateHttpStatusToSsr, +} from '@core/helpers/ssr-response-status.helper'; import { SENTRY_TOKEN } from '@core/provider/sentry.provider'; import { AuthService } from '@core/services/auth.service'; import { MaintenanceModeService } from '@core/services/maintenance-mode.service'; @@ -20,6 +24,33 @@ import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-h import { BYPASS_ERROR_INTERCEPTOR } from './error-interceptor.tokens'; +const NO_AUTH_REDIRECT_HEADER = 'X-No-Auth-Redirect'; +const REQUEST_ACCESS_URL_REGEX = /\/v2\/nodes\/[^/]+\/requests\/?$/i; +const NODE_ID_URL_REGEX = /\/v2\/nodes\/([^/]+)/; +const DRAFT_REGISTRY_ROUTE_REGEX = /\/registries\/drafts\/.+/i; + +const isSilentForbidden = (req: HttpRequest, error: HttpErrorResponse): boolean => + req.headers.has(NO_AUTH_REDIRECT_HEADER) || (!!error.url && REQUEST_ACCESS_URL_REGEX.test(error.url)); + +const isPendingModerationForbidden = (error: HttpErrorResponse): boolean => + error.error?.errors?.[0]?.detail === PREPRINT_PENDING_MODERATION_DETAIL; + +const getErrorMessage = (error: HttpErrorResponse): string => { + if (error.error instanceof ErrorEvent) { + return error.error.message; + } + + if (error.error?.errors?.[0]?.detail) { + return error.error.errors[0].detail; + } + + if (isServerError(error.status)) { + return error.error?.message || ERROR_MESSAGES[500]; + } + + return ERROR_MESSAGES[error.status as keyof typeof ERROR_MESSAGES] || ERROR_MESSAGES.default; +}; + export const errorInterceptor: HttpInterceptorFn = (req, next) => { const toastService = inject(ToastService); const loaderService = inject(LoaderService); @@ -27,87 +58,89 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => { const authService = inject(AuthService); const maintenanceModeService = inject(MaintenanceModeService); const sentry = inject(SENTRY_TOKEN); - const platformId = inject(PLATFORM_ID); const viewOnlyHelper = inject(ViewOnlyLinkHelperService); const store = inject(Store); + const responseInit = inject(RESPONSE_INIT, { optional: true }); + const isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + + const handleUnauthorized = () => { + if (viewOnlyHelper.hasViewOnlyParam(router)) { + return; + } + + if (store.selectSnapshot(UserSelectors.isAuthenticated)) { + authService.logout(window.location.href); + } else { + authService.navigateToSignIn(); + } + }; + + const handleForbidden = (url: string | null) => { + const nodeId = url?.match(NODE_ID_URL_REGEX)?.[1]; + + if (!nodeId) { + router.navigate(['/forbidden']); + } else if (!DRAFT_REGISTRY_ROUTE_REGEX.test(router.url)) { + router.navigate(['/request-access', nodeId]); + } + }; + + const applySsrStatus = (error: HttpErrorResponse) => { + if ( + req.context.get(BYPASS_ERROR_INTERCEPTOR) || + !shouldPropagateHttpStatusToSsr(error.status) || + isSilentForbidden(req, error) + ) { + return; + } + + setSsrResponseStatus(responseInit, error.status); + }; return next(req).pipe( catchError((error: HttpErrorResponse) => { - let errorMessage: string; - if (req.context.get(BYPASS_ERROR_INTERCEPTOR)) { - sentry.captureException(error); - return throwError(() => error); - } - - if (isPlatformBrowser(platformId) && error.error instanceof ErrorEvent) { - errorMessage = error.error.message; - } else { - if (error.error?.errors?.[0]?.detail) { - errorMessage = error.error.errors[0].detail; - } else { - errorMessage = ERROR_MESSAGES[error.status as keyof typeof ERROR_MESSAGES] || ERROR_MESSAGES.default; - } - } + const rethrow = () => throwError(() => error); - const serverErrorRegex = /5\d{2}/; - const maintenanceResponse = error.error as MaintenanceResponse | null; - - const maintenanceMode = error.status === 503 && maintenanceResponse?.meta?.maintenance_mode === true; - - if (maintenanceMode) { - loaderService.hide(); - if (isPlatformBrowser(platformId)) { - maintenanceModeService.activate(); - } - return throwError(() => error); + if (!isBrowser) { + applySsrStatus(error); + return rethrow(); } - if (serverErrorRegex.test(error.status.toString())) { - errorMessage = error.error.message || 'common.errorMessages.serverError'; - } - - if (error.status === 409) { - return throwError(() => error); + if (req.context.get(BYPASS_ERROR_INTERCEPTOR)) { + sentry.captureException(error); + return rethrow(); } - if (error.status === 401) { - if (!viewOnlyHelper.hasViewOnlyParam(router)) { - if (isPlatformBrowser(platformId)) { - if (store.selectSnapshot(UserSelectors.isAuthenticated)) { - authService.logout(window.location.href); - } else { - authService.navigateToSignIn(); - } - } - } - return throwError(() => error); + if (maintenanceModeService.isMaintenanceError(error)) { + loaderService.hide(); + maintenanceModeService.activate(); + return rethrow(); } - if (error.status === 403) { - const requestAccessRegex = /\/v2\/nodes\/[^/]+\/requests\/?$/i; - - if (error.url && (requestAccessRegex.test(error.url) || req.headers.has('X-No-Auth-Redirect'))) { - loaderService.hide(); - return throwError(() => error); - } - - if (error.url?.includes('v2/nodes/')) { - const match = error.url.match(/\/nodes\/([^/]+)/); - const id = match ? match[1] : null; - const draftRegistryUrlRegex = /\/registries\/drafts\/.+/i; - if (!draftRegistryUrlRegex.test(router.url)) { - router.navigate([`/request-access/${id}`]); + switch (error.status) { + case 401: + handleUnauthorized(); + break; + case 403: + if (!isSilentForbidden(req, error) && !isPendingModerationForbidden(error)) { + handleForbidden(error.url); } - } else { - router.navigate(['/forbidden']); - } + break; + case 404: + router.navigate(['/not-found']); + break; + case 409: + return rethrow(); + case 410: + router.navigate(['/content-deleted']); + break; + default: + toastService.showError(getErrorMessage(error)); + break; } loaderService.hide(); - - toastService.showError(errorMessage); - - return throwError(() => error); + return rethrow(); }) ); }; diff --git a/src/app/core/models/config.model.ts b/src/app/core/models/config.model.ts index de810b2ef..0b0290457 100644 --- a/src/app/core/models/config.model.ts +++ b/src/app/core/models/config.model.ts @@ -16,6 +16,14 @@ export interface ConfigModel { */ sentryDsn: string; + /** + * Sentry environment name sent with captured events. + * Names are case-sensitive and cannot contain spaces, newlines, or forward slashes. + * + * @example "staging" + */ + sentryEnvironment: string; + /** * The Google Tag Manager ID used to embed GTM scripts for analytics tracking. * This ID typically starts with "GTM-". diff --git a/src/app/core/provider/application.initialization.provider.spec.ts b/src/app/core/provider/application.initialization.provider.spec.ts index 05899ea1d..9e6f41cbc 100644 --- a/src/app/core/provider/application.initialization.provider.spec.ts +++ b/src/app/core/provider/application.initialization.provider.spec.ts @@ -7,11 +7,9 @@ import { OSFConfigService } from '@core/services/osf-config.service'; import { EnvironmentModel } from '@osf/shared/models/environment.model'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { SentryMock, SentryMockType } from '@testing/providers/sentry-provider.mock'; import { initializeApplication } from './application.initialization.provider'; import { ENVIRONMENT } from './environment.provider'; -import { SENTRY_TOKEN } from './sentry.provider'; import * as Sentry from '@sentry/angular'; import { GoogleTagManagerConfiguration } from 'angular-google-tag-manager'; @@ -29,12 +27,10 @@ describe('initializeApplication', () => { let googleTagManagerConfigurationMock: { set: ReturnType }; let environment: EnvironmentModel; let sentryInitMock: ReturnType; - let sentryMock: SentryMockType; function setup(platformId: 'browser' | 'server', environmentOverrides: Partial = {}) { configServiceMock = { load: vi.fn().mockResolvedValue(undefined) }; googleTagManagerConfigurationMock = { set: vi.fn() }; - sentryMock = SentryMock.simple(); TestBed.configureTestingModule({ providers: [ @@ -42,7 +38,6 @@ describe('initializeApplication', () => { MockProvider(PLATFORM_ID, platformId), { provide: OSFConfigService, useValue: configServiceMock }, { provide: GoogleTagManagerConfiguration, useValue: googleTagManagerConfigurationMock }, - { provide: SENTRY_TOKEN, useValue: sentryMock }, ], }); @@ -56,7 +51,7 @@ describe('initializeApplication', () => { setup('browser', { googleTagManagerId: 'GTM-TEST', sentryDsn: 'https://dsn.example/123', - production: true, + sentryEnvironment: 'staging', }); await TestBed.runInInjectionContext(async () => initializeApplication()()); @@ -65,7 +60,7 @@ describe('initializeApplication', () => { expect(sentryInitMock).toHaveBeenCalledWith( expect.objectContaining({ dsn: 'https://dsn.example/123', - environment: 'production', + environment: 'staging', }) ); }); @@ -84,7 +79,7 @@ describe('initializeApplication', () => { setup('server', { googleTagManagerId: 'GTM-TEST', sentryDsn: 'https://dsn.example/123', - production: true, + sentryEnvironment: 'staging', }); await TestBed.runInInjectionContext(async () => initializeApplication()()); diff --git a/src/app/core/provider/application.initialization.provider.ts b/src/app/core/provider/application.initialization.provider.ts index ed08425c7..d12ffe39a 100644 --- a/src/app/core/provider/application.initialization.provider.ts +++ b/src/app/core/provider/application.initialization.provider.ts @@ -10,13 +10,13 @@ import * as Sentry from '@sentry/angular'; import { GoogleTagManagerConfiguration } from 'angular-google-tag-manager'; /** - * Asynchronous initializer function that loads the Sentry DSN from the config service - * and initializes Sentry at application bootstrap. + * App initializer factory used with `provideAppInitializer`. * - * This function is meant to be used with `provideAppInitializer`, which blocks Angular - * bootstrap until the Promise resolves. This avoids race conditions when reading config. + * Loads runtime config via `OSFConfigService`, then on the browser configures + * Google Tag Manager, Sentry, and New Relic when their config values are present. + * Bootstrap waits until this Promise resolves so config is available before startup. * - * @returns A Promise that resolves once Sentry is initialized (or skipped if no DSN) + * @returns An async initializer that resolves after config load and optional browser setup */ export function initializeApplication() { return async () => { @@ -27,35 +27,30 @@ export function initializeApplication() { await configService.load(); - if (isPlatformBrowser(platformId)) { - const googleTagManagerId = environment.googleTagManagerId; - - if (googleTagManagerId) { - googleTagManagerConfiguration.set({ id: googleTagManagerId }); - } + if (!isPlatformBrowser(platformId)) { + return; + } - const dsn = environment.sentryDsn; + if (environment.googleTagManagerId) { + googleTagManagerConfiguration.set({ id: environment.googleTagManagerId }); + } - if (dsn) { - // More Options - // https://docs.sentry.io/platforms/javascript/guides/angular/configuration/options/ - Sentry.init({ - dsn, - environment: environment.production ? 'production' : 'development', - maxBreadcrumbs: 50, - sampleRate: 1.0, - ignoreErrors: SENTRY_IGNORE_ERRORS, - denyUrls: SENTRY_DENY_URLS, - beforeSend: sentryBeforeSend, - }); - } + if (environment.sentryDsn) { + Sentry.init({ + dsn: environment.sentryDsn, + environment: environment.sentryEnvironment, + maxBreadcrumbs: 50, + sampleRate: 1.0, + ignoreErrors: SENTRY_IGNORE_ERRORS, + denyUrls: SENTRY_DENY_URLS, + beforeSend: sentryBeforeSend, + }); } - if (environment.newRelicEnabled && isPlatformBrowser(platformId)) { + if (environment.newRelicEnabled) { const { BrowserAgent } = await import('@newrelic/browser-agent/loaders/browser-agent'); - const newRelicConfig = { - enabled: environment.newRelicEnabled, + new BrowserAgent({ init: { distributed_tracing: { enabled: environment.newRelicInitDistributedTracingEnabled }, performance: { capture_measures: environment.newRelicInitPerformanceCaptureMeasures }, @@ -76,22 +71,18 @@ export function initializeApplication() { licenseKey: environment.newRelicLoaderConfigLicenseKey, applicationID: environment.newRelicLoaderConfigApplicationID, }, - }; - new BrowserAgent(newRelicConfig); + }); } }; } /** - * Provides the Sentry initialization logic during Angular's application bootstrap phase. - * - * This uses `provideAppInitializer` to block application startup until Sentry is initialized. - * It ensures that the Sentry DSN (fetched from OSFConfigService) is available and configured - * before any errors are handled or reported by the app. + * Registers application bootstrap initialization via `provideAppInitializer`. * - * `initializeSentry` is a function that returns a Promise which resolves after Sentry is fully initialized. + * Blocks startup until runtime config is loaded and browser integrations + * (GTM, Sentry, New Relic) are configured when enabled. * * @see https://docs.sentry.io/platforms/javascript/guides/angular/ - * @see Angular's `provideAppInitializer`: https://angular.io/api/core/provideAppInitializer + * @see https://angular.io/api/core/provideAppInitializer */ export const APPLICATION_INITIALIZATION_PROVIDER = provideAppInitializer(initializeApplication()); diff --git a/src/app/core/provider/environment.provider.spec.ts b/src/app/core/provider/environment.provider.spec.ts index c51c140dd..80978a9f5 100644 --- a/src/app/core/provider/environment.provider.spec.ts +++ b/src/app/core/provider/environment.provider.spec.ts @@ -37,14 +37,6 @@ describe('Provider: Environment', () => { expect(environment.dataciteTrackerRepoId).toBeNull(); }); - it('should allow updating an optional nested object', () => { - environment.activityLogs = { pageSize: 100 }; - expect(environment.activityLogs).toEqual({ pageSize: 100 }); - environment.activityLogs.pageSize = 200; - expect(environment.activityLogs).toEqual({ pageSize: 200 }); - expect(environment.activityLogs.pageSize).toBe(200); - }); - it('should reflect changes in subsequent reads', () => { environment.facebookAppId = 'abc123'; expect(environment.facebookAppId).toBe('abc123'); diff --git a/src/app/core/provider/sentry.provider.spec.ts b/src/app/core/provider/sentry.provider.spec.ts index 85d9b609c..b76e1149e 100644 --- a/src/app/core/provider/sentry.provider.spec.ts +++ b/src/app/core/provider/sentry.provider.spec.ts @@ -4,8 +4,6 @@ import { provideOSFCore } from '@testing/osf.testing.provider'; import { SENTRY_PROVIDER, SENTRY_TOKEN } from './sentry.provider'; -import * as Sentry from '@sentry/angular'; - describe('Provider: Sentry', () => { beforeEach(() => { TestBed.configureTestingModule({ @@ -15,7 +13,7 @@ describe('Provider: Sentry', () => { it('should provide the Sentry module via the injection token', () => { const provided = TestBed.inject(SENTRY_TOKEN); - expect(provided).toBe(Sentry); + expect(provided).toBe(SENTRY_PROVIDER.useValue); expect(typeof provided.captureException).toBe('function'); }); }); diff --git a/src/app/core/services/auth.service.spec.ts b/src/app/core/services/auth.service.spec.ts index 642191d49..219c6ccd2 100644 --- a/src/app/core/services/auth.service.spec.ts +++ b/src/app/core/services/auth.service.spec.ts @@ -1,3 +1,5 @@ +import { Store } from '@ngxs/store'; + import { CookieService } from 'ngx-cookie-service'; import { MockProvider } from 'ng-mocks'; @@ -6,10 +8,13 @@ import { TestBed } from '@angular/core/testing'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { WINDOW } from '@core/provider/window.provider'; +import { CookieConsentService } from '@core/services/cookie-consent.service'; +import { ClearCurrentUser } from '@osf/core/store/user'; import { JsonApiService } from '@osf/shared/services/json-api.service'; import { LoaderService } from '@osf/shared/services/loader.service'; -import { CookieServiceMock } from '@testing/providers/cookie-service.mock'; +import { CookieConsentServiceMock, CookieConsentServiceMockType } from '@testing/providers/cookie-consent.service.mock'; +import { CookieServiceMock, CookieServiceMockType } from '@testing/providers/cookie-service.mock'; import { JsonApiServiceMock } from '@testing/providers/json-api.service.mock'; import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; @@ -22,10 +27,15 @@ describe('AuthService', () => { const origin = 'https://osf.test'; let service: AuthService; + let store: Store; let loaderService: LoaderServiceMock; + let cookieServiceMock: CookieServiceMockType; + let cookieConsentMock: CookieConsentServiceMockType; let locationMock: { href: string; pathname: string; origin: string }; - function setup(overrides: { pathname?: string; href?: string; isBrowser?: boolean } = {}) { + function setup( + overrides: { pathname?: string; href?: string; isBrowser?: boolean; hasCookieConsent?: boolean } = {} + ) { const pathname = overrides.pathname ?? '/dashboard'; locationMock = { href: overrides.href ?? `${origin}${pathname}`, @@ -34,13 +44,17 @@ describe('AuthService', () => { }; loaderService = new LoaderServiceMock(); + cookieServiceMock = CookieServiceMock.simple(); + cookieConsentMock = CookieConsentServiceMock.simple(); + cookieConsentMock.hasConsent.mockReturnValue(overrides.hasCookieConsent ?? false); TestBed.configureTestingModule({ providers: [ AuthService, provideMockStore(), MockProvider(JsonApiService, JsonApiServiceMock.simple()), - MockProvider(CookieService, CookieServiceMock.simple()), + MockProvider(CookieService, cookieServiceMock), + MockProvider(CookieConsentService, cookieConsentMock), MockProvider(LoaderService, loaderService), MockProvider(WINDOW, { location: locationMock } as Window), MockProvider(ENVIRONMENT, { webUrl, casUrl, apiDomainUrl: 'https://api.test' }), @@ -49,6 +63,7 @@ describe('AuthService', () => { }); service = TestBed.inject(AuthService); + store = TestBed.inject(Store); } function nextFromServiceParam(href: string): string | null { @@ -101,4 +116,41 @@ describe('AuthService', () => { expect(loaderService.show).not.toHaveBeenCalled(); expect(locationMock.href).toBe(`${origin}/dashboard`); }); + + it('should clear session cookies and restore cookie consent on logout', () => { + setup({ hasCookieConsent: true }); + + service.logout(); + + expect(loaderService.show).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new ClearCurrentUser()); + expect(cookieConsentMock.hasConsent.mock.invocationCallOrder[0]).toBeLessThan( + cookieServiceMock.deleteAll.mock.invocationCallOrder[0] + ); + expect(cookieServiceMock.deleteAll.mock.invocationCallOrder[0]).toBeLessThan( + cookieConsentMock.accept.mock.invocationCallOrder[0] + ); + expect(locationMock.href).toBe(`${webUrl}/logout/?next=${encodeURIComponent(`${origin}/`)}`); + }); + + it('should not restore cookie consent on logout when the user has not accepted', () => { + setup(); + + service.logout(); + + expect(cookieServiceMock.deleteAll).toHaveBeenCalled(); + expect(cookieConsentMock.accept).not.toHaveBeenCalled(); + }); + + it('should not clear cookies when logging out outside the browser', () => { + setup({ isBrowser: false, hasCookieConsent: true, href: `${origin}/dashboard` }); + + service.logout(); + + expect(loaderService.show).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new ClearCurrentUser()); + expect(cookieServiceMock.deleteAll).not.toHaveBeenCalled(); + expect(cookieConsentMock.hasConsent).not.toHaveBeenCalled(); + expect(locationMock.href).toBe(`${origin}/dashboard`); + }); }); diff --git a/src/app/core/services/auth.service.ts b/src/app/core/services/auth.service.ts index 70a3328e6..06ad1a995 100644 --- a/src/app/core/services/auth.service.ts +++ b/src/app/core/services/auth.service.ts @@ -8,6 +8,7 @@ import { inject, Injectable, PLATFORM_ID } from '@angular/core'; import { SignUpModel } from '@core/models/sign-up.model'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { WINDOW } from '@core/provider/window.provider'; +import { CookieConsentService } from '@core/services/cookie-consent.service'; import { ClearCurrentUser } from '@osf/core/store/user'; import { urlParam } from '@osf/shared/helpers/url-param.helper'; import { JsonApiService } from '@osf/shared/services/json-api.service'; @@ -19,10 +20,11 @@ import { LoaderService } from '@osf/shared/services/loader.service'; export class AuthService { private readonly jsonApiService = inject(JsonApiService); private readonly cookieService = inject(CookieService); + private readonly cookieConsent = inject(CookieConsentService); private readonly loaderService = inject(LoaderService); private readonly environment = inject(ENVIRONMENT); private readonly window = inject(WINDOW); - private readonly platformId = inject(PLATFORM_ID); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); private readonly actions = createDispatchMap({ clearCurrentUser: ClearCurrentUser }); get apiUrl() { @@ -38,7 +40,7 @@ export class AuthService { } navigateToSignIn(): void { - if (!isPlatformBrowser(this.platformId)) { + if (!this.isBrowser) { return; } @@ -54,7 +56,7 @@ export class AuthService { } navigateToOrcidSignIn(): void { - if (!isPlatformBrowser(this.platformId)) { + if (!this.isBrowser) { return; } @@ -68,7 +70,7 @@ export class AuthService { } navigateToInstitutionSignIn(): void { - if (!isPlatformBrowser(this.platformId)) { + if (!this.isBrowser) { return; } @@ -85,8 +87,15 @@ export class AuthService { this.loaderService.show(); this.actions.clearCurrentUser(); - if (isPlatformBrowser(this.platformId)) { + if (this.isBrowser) { + const hasCookieConsent = this.cookieConsent.hasConsent(); + this.cookieService.deleteAll(); + + if (hasCookieConsent) { + this.cookieConsent.accept(); + } + this.window.location.href = `${this.webUrl}/logout/?next=${encodeURIComponent(nextUrl || `${this.window.location.origin}/`)}`; } } diff --git a/src/app/core/services/cookie-consent.service.spec.ts b/src/app/core/services/cookie-consent.service.spec.ts new file mode 100644 index 000000000..8625975ce --- /dev/null +++ b/src/app/core/services/cookie-consent.service.spec.ts @@ -0,0 +1,41 @@ +import { CookieService } from 'ngx-cookie-service'; +import { MockProvider } from 'ng-mocks'; + +import { TestBed } from '@angular/core/testing'; + +import { CookieServiceMock, CookieServiceMockType } from '@testing/providers/cookie-service.mock'; + +import { CookieConsentService } from './cookie-consent.service'; + +describe('CookieConsentService', () => { + let service: CookieConsentService; + let cookieServiceMock: CookieServiceMockType; + + beforeEach(() => { + cookieServiceMock = CookieServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [CookieConsentService, MockProvider(CookieService, cookieServiceMock)], + }); + + service = TestBed.inject(CookieConsentService); + }); + + it('should report consent when the cookie exists', () => { + cookieServiceMock.check.mockReturnValue(true); + + expect(service.hasConsent()).toBe(true); + expect(cookieServiceMock.check).toHaveBeenCalledWith('cookie-consent'); + }); + + it('should report no consent when the cookie is missing', () => { + expect(service.hasConsent()).toBe(false); + expect(cookieServiceMock.check).toHaveBeenCalledWith('cookie-consent'); + }); + + it('should store consent until year 9999', () => { + service.accept(); + + expect(cookieServiceMock.set).toHaveBeenCalledWith('cookie-consent', 'true', new Date('9999-12-31T23:59:59Z'), '/'); + }); +}); diff --git a/src/app/core/services/cookie-consent.service.ts b/src/app/core/services/cookie-consent.service.ts new file mode 100644 index 000000000..bae74b5dd --- /dev/null +++ b/src/app/core/services/cookie-consent.service.ts @@ -0,0 +1,17 @@ +import { CookieService } from 'ngx-cookie-service'; + +import { inject, Injectable } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class CookieConsentService { + private readonly cookies = inject(CookieService); + private readonly cookieName = 'cookie-consent'; + + hasConsent(): boolean { + return this.cookies.check(this.cookieName); + } + + accept(): void { + this.cookies.set(this.cookieName, 'true', new Date('9999-12-31T23:59:59Z'), '/'); + } +} diff --git a/src/app/core/services/maintenance-mode.service.spec.ts b/src/app/core/services/maintenance-mode.service.spec.ts index 7c82286ec..3209df559 100644 --- a/src/app/core/services/maintenance-mode.service.spec.ts +++ b/src/app/core/services/maintenance-mode.service.spec.ts @@ -1,3 +1,4 @@ +import { HttpErrorResponse } from '@angular/common/http'; import { HttpTestingController, TestRequest } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; @@ -85,6 +86,36 @@ describe('MaintenanceModeService', () => { expect(service.isActive()).toBe(false); }); + it('should detect maintenance from a 503 maintenance error response', () => { + const error = new HttpErrorResponse({ + status: 503, + error: maintenanceOn, + statusText: 'Service Unavailable', + }); + + expect(service.isMaintenanceError(error)).toBe(true); + }); + + it('should not detect maintenance from a 503 without maintenance_mode', () => { + const error = new HttpErrorResponse({ + status: 503, + error: maintenanceOff, + statusText: 'Service Unavailable', + }); + + expect(service.isMaintenanceError(error)).toBe(false); + }); + + it('should not detect maintenance from a non-503 error', () => { + const error = new HttpErrorResponse({ + status: 500, + error: maintenanceOn, + statusText: 'Server Error', + }); + + expect(service.isMaintenanceError(error)).toBe(false); + }); + it('should deactivate when a poll receives a successful non-maintenance response', () => { vi.useFakeTimers(); service.activate(); diff --git a/src/app/core/services/maintenance-mode.service.ts b/src/app/core/services/maintenance-mode.service.ts index bb92be21c..f5a501d76 100644 --- a/src/app/core/services/maintenance-mode.service.ts +++ b/src/app/core/services/maintenance-mode.service.ts @@ -1,13 +1,13 @@ import { catchError, map, Observable, of, Subscription, switchMap, timer } from 'rxjs'; -import { HttpClient, HttpContext, HttpErrorResponse } from '@angular/common/http'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { inject, Injectable, OnDestroy, signal } from '@angular/core'; import { MaintenanceStatus } from '@core/enums/maintenance-status.enum'; import { MaintenanceResponse } from '@core/models/maintenance-response.model'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { BYPASS_ERROR_INTERCEPTOR } from '../interceptors/error-interceptor.tokens'; +import { bypassErrorInterceptorContext } from '../interceptors/error-interceptor.tokens'; @Injectable({ providedIn: 'root', @@ -18,7 +18,6 @@ export class MaintenanceModeService implements OnDestroy { private readonly POLL_INTERVAL_MS = 5 * 60 * 1_000; private readonly _isActive = signal(false); - private readonly bypassContext = new HttpContext().set(BYPASS_ERROR_INTERCEPTOR, true); private pollingSubscription: Subscription | null = null; @@ -45,6 +44,11 @@ export class MaintenanceModeService implements OnDestroy { }); } + isMaintenanceError(error: HttpErrorResponse): boolean { + const response = error.error as MaintenanceResponse | null; + return error.status === 503 && response?.meta?.maintenance_mode === true; + } + activate(): void { this._isActive.set(true); if (this.isForcedByConfig || this.pollingSubscription) { @@ -83,7 +87,9 @@ export class MaintenanceModeService implements OnDestroy { private checkMaintenanceStatus(): Observable { return this.http - .get(`${this.environment.apiDomainUrl}/v2/`, { context: this.bypassContext }) + .get(`${this.environment.apiDomainUrl}/v2/`, { + context: bypassErrorInterceptorContext(), + }) .pipe( map((response) => response.meta?.maintenance_mode === true ? MaintenanceStatus.Active : MaintenanceStatus.Inactive @@ -93,9 +99,6 @@ export class MaintenanceModeService implements OnDestroy { } private statusFromError(error: HttpErrorResponse): MaintenanceStatus { - const response = error.error as MaintenanceResponse | null; - return error.status === 503 && response?.meta?.maintenance_mode === true - ? MaintenanceStatus.Active - : MaintenanceStatus.Unknown; + return this.isMaintenanceError(error) ? MaintenanceStatus.Active : MaintenanceStatus.Unknown; } } diff --git a/src/app/core/services/osf-config.service.spec.ts b/src/app/core/services/osf-config.service.spec.ts index 2ad6d0587..7a31b61e1 100644 --- a/src/app/core/services/osf-config.service.spec.ts +++ b/src/app/core/services/osf-config.service.spec.ts @@ -19,6 +19,7 @@ describe('OSFConfigService', () => { const mockConfig: ConfigModel = { sentryDsn: 'https://sentry.example.com/123', + sentryEnvironment: 'staging', googleTagManagerId: 'GTM-TEST', googleFilePickerApiKey: '', googleFilePickerAppId: 0, diff --git a/src/app/features/analytics/analytics.component.ts b/src/app/features/analytics/analytics.component.ts index 33f98ab62..924e32c3f 100644 --- a/src/app/features/analytics/analytics.component.ts +++ b/src/app/features/analytics/analytics.component.ts @@ -33,9 +33,9 @@ import { Primitive } from '@osf/shared/helpers/types.helper'; import { DatasetInput } from '@osf/shared/models/charts/dataset-input.model'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; +import { DateRange } from './enums/date-range.enum'; import { AnalyticsKpiComponent } from './components'; import { DATE_RANGE_OPTIONS } from './constants'; -import { DateRange } from './enums'; import { AnalyticsSelectors, ClearAnalytics, GetMetrics, GetRelatedCounts } from './store'; @Component({ diff --git a/src/app/features/analytics/components/duplicate-card/duplicate-card.component.html b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.html new file mode 100644 index 000000000..853062595 --- /dev/null +++ b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.html @@ -0,0 +1,59 @@ +
+
+

+ + {{ duplicate().title }} +

+ + @if (canManageDuplicate()) { + + } +
+ +
+
+ {{ 'common.labels.forked' | translate }}: + {{ duplicate().dateCreated | date: dateFormat }} +
+ +
+ {{ 'common.labels.lastUpdated' | translate }}: + {{ duplicate().dateModified | date: dateFormat }} +
+ +
+ {{ 'common.labels.contributors' | translate }}: + + +
+ + @if (duplicate().description) { + + } +
+ + +
diff --git a/src/app/features/analytics/components/duplicate-card/duplicate-card.component.scss b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.scss new file mode 100644 index 000000000..ce262f3c7 --- /dev/null +++ b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.scss @@ -0,0 +1,5 @@ +.duplicate-wrapper { + border: 1px solid var(--grey-2); + border-radius: 0.75rem; + color: var(--dark-blue-1); +} diff --git a/src/app/features/analytics/components/duplicate-card/duplicate-card.component.spec.ts b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.spec.ts new file mode 100644 index 000000000..e1de937cb --- /dev/null +++ b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.spec.ts @@ -0,0 +1,94 @@ +import { MockComponents } from 'ng-mocks'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; + +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 { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; + +import { MOCK_NODE_WITH_ADMIN, MOCK_NODE_WITHOUT_ADMIN } from '@testing/mocks/node.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; + +import { DuplicateCardComponent } from './duplicate-card.component'; + +describe('DuplicateCardComponent', () => { + let component: DuplicateCardComponent; + let fixture: ComponentFixture; + + function setup(duplicate: NodeModel = MOCK_NODE_WITH_ADMIN) { + TestBed.configureTestingModule({ + imports: [ + DuplicateCardComponent, + ...MockComponents(IconComponent, TruncatedTextComponent, ContributorsListComponent), + ], + providers: [provideOSFCore(), provideRouter([])], + }); + + fixture = TestBed.createComponent(DuplicateCardComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('duplicate', duplicate); + fixture.detectChanges(); + } + + it('should create', () => { + setup(); + expect(component).toBeTruthy(); + }); + + it('should expose dateFormat', () => { + setup(); + expect(component.dateFormat).toBe('MMM d, y, h:mm a'); + }); + + it('should expose menu items', () => { + setup(); + expect(component.menuItems).toEqual([ + { + label: 'project.overview.actions.manageContributors', + action: NodeMenuAction.ManageContributors, + }, + { + label: 'project.overview.actions.settings', + action: NodeMenuAction.Settings, + }, + { + label: 'common.labels.delete', + action: NodeMenuAction.Delete, + }, + ]); + }); + + it('should allow manage when user has admin permission', () => { + setup(MOCK_NODE_WITH_ADMIN); + expect(component.canManageDuplicate()).toBe(true); + }); + + it('should allow manage when user has write permission', () => { + setup(MOCK_NODE_WITHOUT_ADMIN); + expect(component.canManageDuplicate()).toBe(true); + }); + + it('should not allow manage when user has only read permission', () => { + const readOnly: NodeModel = { + ...MOCK_NODE_WITHOUT_ADMIN, + currentUserPermissions: [UserPermissions.Read], + }; + setup(readOnly); + expect(component.canManageDuplicate()).toBe(false); + }); + + it('should emit menuAction when handleMenuAction is called', () => { + setup(); + const emitted: NodeMenuAction[] = []; + component.menuAction.subscribe((action) => emitted.push(action)); + + component.handleMenuAction(NodeMenuAction.Settings); + component.handleMenuAction(NodeMenuAction.Delete); + + expect(emitted).toEqual([NodeMenuAction.Settings, NodeMenuAction.Delete]); + }); +}); diff --git a/src/app/features/analytics/components/duplicate-card/duplicate-card.component.ts b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.ts new file mode 100644 index 000000000..c1e3c5aae --- /dev/null +++ b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.ts @@ -0,0 +1,63 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Menu } from 'primeng/menu'; + +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +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 { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; + +@Component({ + selector: 'osf-duplicate-card', + imports: [ + Button, + Menu, + RouterLink, + IconComponent, + TruncatedTextComponent, + ContributorsListComponent, + DatePipe, + TranslatePipe, + ], + templateUrl: './duplicate-card.component.html', + styleUrl: './duplicate-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DuplicateCardComponent { + readonly duplicate = input.required(); + + readonly menuAction = output(); + + readonly dateFormat = 'MMM d, y, h:mm a'; + + readonly canManageDuplicate = computed(() => { + const permissions = this.duplicate().currentUserPermissions; + return permissions.includes(UserPermissions.Admin) || permissions.includes(UserPermissions.Write); + }); + + readonly menuItems = [ + { + label: 'project.overview.actions.manageContributors', + action: NodeMenuAction.ManageContributors, + }, + { + label: 'project.overview.actions.settings', + action: NodeMenuAction.Settings, + }, + { + label: 'common.labels.delete', + action: NodeMenuAction.Delete, + }, + ]; + + handleMenuAction(action: NodeMenuAction): void { + this.menuAction.emit(action); + } +} diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html index 6e4fbcfae..552636591 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html @@ -1,84 +1,21 @@
- @if (!isDuplicatesLoading() && currentResource()) { + @if (!isDuplicatesLoading()) { @if (!duplicates().length) {

{{ 'project.overview.dialog.fork.noForksMessage' | translate }}

} @else {

{{ 'project.overview.dialog.fork.forksMessage' | translate }}

@for (duplicate of duplicates(); track duplicate.id) { -
-
-

- - {{ duplicate.title }} -

- - @if (showMoreOptions(duplicate)) { - - } -
- -
-
- {{ 'common.labels.forked' | translate }}: -

{{ duplicate.dateCreated | date: 'MMM d, y, h:mm a' }}

-
- -
- {{ 'common.labels.lastUpdated' | translate }}: -

{{ duplicate.dateModified | date: 'MMM d, y, h:mm a' }}

-
- -
- {{ 'common.labels.contributors' | translate }}: - - -
- - @if (duplicate.description) { - - } -
- -
+ } @if (totalDuplicates() > pageSize) { diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.scss b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.scss index 32a20a323..28e822eea 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.scss +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.scss @@ -2,10 +2,5 @@ display: flex; flex-direction: column; flex: 1; -} - -.duplicate-wrapper { - border: 1px solid var(--grey-2); - border-radius: 0.75rem; - color: var(--dark-blue-1); + height: 100%; } diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts index 83d466144..fd73ecb16 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts @@ -1,91 +1,96 @@ -import { MockComponents, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { PaginatorState } from 'primeng/paginator'; +import { MockComponents, MockProvider } from 'ng-mocks'; -import { of } from 'rxjs'; +import { Mock } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { UserSelectors } from '@osf/core/store/user/user.selectors'; -import { ProjectOverviewSelectors } from '@osf/features/project/overview/store'; -import { RegistrySelectors } from '@osf/features/registry/store/registry'; -import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; +import { ForkProject } from '@osf/features/project/overview/store'; +import { DeleteResourceService } from '@osf/features/project/settings/services/delete-resource.service'; +import { ForkRegistry } from '@osf/features/registry/store/registry'; import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; -import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; -import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import { DuplicatesSelectors } from '@osf/shared/stores/duplicates'; +import { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { ForkDialogService } from '@osf/shared/services/fork-dialog.service'; +import { ClearDuplicates, DuplicatesSelectors, GetAllDuplicates } from '@osf/shared/stores/duplicates'; -import { MOCK_PROJECT_OVERVIEW } from '@testing/mocks/project-overview.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { + DeleteResourceServiceMock, + DeleteResourceServiceMockType, +} from '@testing/providers/delete-resource-provider.mock'; +import { ForkDialogServiceMock, ForkDialogServiceMockType } from '@testing/providers/fork-dialog.service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; +import { DuplicateCardComponent } from '../duplicate-card/duplicate-card.component'; + import { ViewDuplicatesComponent } from './view-duplicates.component'; interface SetupOverrides extends BaseSetupOverrides { - selectors?: any[]; + resourceType?: ResourceType; } -describe('Component: View Duplicates', () => { +describe('ViewDuplicatesComponent', () => { let component: ViewDuplicatesComponent; let fixture: ComponentFixture; + let store: Store; let routerMock: ReturnType; - let activatedRouteMock: ReturnType; - let mockCustomDialogService: ReturnType; + let forkDialogService: ForkDialogServiceMockType; + let deleteResourceService: DeleteResourceServiceMockType; function setup(overrides: SetupOverrides = {}) { - mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); + forkDialogService = ForkDialogServiceMock.simple(); + deleteResourceService = DeleteResourceServiceMock.simple(); routerMock = RouterMockBuilder.create().build(); - activatedRouteMock = ActivatedRouteMockBuilder.create() - .withParams({ id: 'rid' }) - .withData({ resourceType: ResourceType.Project }) - .build(); + + const routeBuilder = ActivatedRouteMockBuilder.create() + .withParams(overrides.routeParams ?? { id: 'rid' }) + .withData({ resourceType: overrides.resourceType ?? ResourceType.Project }); + + if (overrides.hasParent === false) { + routeBuilder.withNoParent(); + } const defaultSelectors = [ { selector: DuplicatesSelectors.getDuplicates, value: [] }, { selector: DuplicatesSelectors.getDuplicatesLoading, value: false }, { selector: DuplicatesSelectors.getDuplicatesTotalCount, value: 0 }, - { selector: ProjectOverviewSelectors.getProject, value: MOCK_PROJECT_OVERVIEW }, - { selector: ProjectOverviewSelectors.isProjectAnonymous, value: false }, - { selector: RegistrySelectors.getRegistry, value: undefined }, - { selector: RegistrySelectors.isRegistryAnonymous, value: false }, + { selector: UserSelectors.isAuthenticated, value: true }, { selector: UserSelectors.isProjectCreationDisabled, value: false }, ]; - const signals = mergeSignalOverrides(defaultSelectors, overrides.selectors || []); TestBed.configureTestingModule({ imports: [ ViewDuplicatesComponent, ...MockComponents( SubHeaderComponent, - TruncatedTextComponent, LoadingSpinnerComponent, CustomPaginatorComponent, - IconComponent, - ContributorsListComponent + DuplicateCardComponent ), ], providers: [ provideOSFCore(), provideMockStore({ - signals, + signals: mergeSignalOverrides(defaultSelectors, overrides.selectorOverrides), }), - MockProvider(CustomDialogService, mockCustomDialogService), + MockProvider(ForkDialogService, forkDialogService), + MockProvider(DeleteResourceService, deleteResourceService), MockProvider(Router, routerMock), - MockProvider(ActivatedRoute, activatedRouteMock), + MockProvider(ActivatedRoute, routeBuilder.build()), ], }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(ViewDuplicatesComponent); component = fixture.componentInstance; - fixture.detectChanges(); } @@ -94,40 +99,168 @@ describe('Component: View Duplicates', () => { expect(component).toBeTruthy(); }); - it('should disable fork button and show tooltip when isProjectCreationDisabled is true', () => { + it('should dispatch getDuplicates on init for a project', () => { + setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new GetAllDuplicates('rid', CurrentResourceType.Projects, 1, 10)); + }); + + it('should dispatch getDuplicates on init for a registration', () => { + setup({ resourceType: ResourceType.Registration }); + + expect(store.dispatch).toHaveBeenCalledWith(new GetAllDuplicates('rid', CurrentResourceType.Registrations, 1, 10)); + }); + + it('should not dispatch getDuplicates when route id is missing', () => { + setup({ hasParent: false }); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetAllDuplicates)); + }); + + it('should clear duplicates on destroy', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + fixture.destroy(); + + expect(store.dispatch).toHaveBeenCalledWith(new ClearDuplicates()); + }); + + it('should compute firstIndex from currentPage', () => { + setup(); + component.currentPage.set(3); + expect(component.firstIndex()).toBe(20); + }); + + it('should use the projects resource type for a project', () => { + setup(); + + expect(component.apiResourceType()).toBe(CurrentResourceType.Projects); + expect(component.duplicateButtonLabel()).toBe('project.overview.actions.forkProjectLabel'); + }); + + it('should use the registrations resource type for a registration', () => { + setup({ resourceType: ResourceType.Registration }); + + expect(component.apiResourceType()).toBe(CurrentResourceType.Registrations); + expect(component.duplicateButtonLabel()).toBe('project.overview.actions.duplicateRegistration'); + }); + + it('should compute duplicate button tooltip when project creation is disabled', () => { setup({ - selectors: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], + selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], }); + expect(component.preventDuplicateCreation()).toBe(true); expect(component.duplicateButtonTooltip()).toBe('project.overview.actions.duplicatingProjectsNotAllowed'); }); - it('should open ForkDialog with width 450px when small and not refresh on failure', () => { + it('should return empty tooltip when project creation is allowed', () => { setup(); - (component as any).actions = { ...component.actions, getDuplicates: vi.fn() }; + expect(component.duplicateButtonTooltip()).toBe(''); + }); - const openSpy = vi - .spyOn(mockCustomDialogService, 'open') - .mockReturnValue({ onClose: of({ success: false }) } as any); + it('should navigate to contributors for the manageContributors action', () => { + setup(); - component.handleForkResource(); + component.handleMenuAction(NodeMenuAction.ManageContributors, 'node-1'); - expect(openSpy).toHaveBeenCalledWith(expect.any(Function), expect.objectContaining({ width: '450px' })); - expect((component as any).actions.getDuplicates).not.toHaveBeenCalled(); + expect(routerMock.navigate).toHaveBeenCalledWith(['node-1', 'contributors']); }); - it('should update currentPage when page is defined', () => { + it('should navigate to settings for the settings action', () => { setup(); - const event: PaginatorState = { page: 1 } as PaginatorState; - component.onPageChange(event); - expect(component.currentPage()).toBe(2); + + component.handleMenuAction(NodeMenuAction.Settings, 'node-1'); + + expect(routerMock.navigate).toHaveBeenCalledWith(['node-1', 'settings']); + }); + + it('should delete the fork and refresh duplicates on success', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.handleMenuAction(NodeMenuAction.Delete, 'fork-1'); + deleteResourceService.deleteProject.mock.calls[0][0].onSuccess(); + + expect(deleteResourceService.deleteProject).toHaveBeenCalledWith( + expect.objectContaining({ + rootParentId: 'fork-1', + resourceId: 'fork-1', + }) + ); + expect(store.dispatch).toHaveBeenCalledWith(new GetAllDuplicates('rid', CurrentResourceType.Projects, 1, 10)); + }); + + it('should confirm fork for the current project', () => { + setup(); + + component.handleFork(); + + expect(forkDialogService.confirmFork).toHaveBeenCalledWith( + expect.objectContaining({ resourceType: CurrentResourceType.Projects }) + ); + }); + + it('should confirm fork for the current registration', () => { + setup({ resourceType: ResourceType.Registration }); + + component.handleFork(); + + expect(forkDialogService.confirmFork).toHaveBeenCalledWith( + expect.objectContaining({ resourceType: CurrentResourceType.Registrations }) + ); + }); + + it('should not confirm fork when route id is missing', () => { + setup({ hasParent: false }); + + component.handleFork(); + + expect(forkDialogService.confirmFork).not.toHaveBeenCalled(); + }); + + it('should dispatch ForkProject and refresh duplicates from confirmFork callbacks', () => { + setup(); + component.handleFork(); + const [options] = forkDialogService.confirmFork.mock.calls[0]; + (store.dispatch as Mock).mockClear(); + + options.fork(); + options.onSuccess?.(); + + expect(store.dispatch).toHaveBeenCalledWith(new ForkProject('rid')); + expect(store.dispatch).toHaveBeenCalledWith(new GetAllDuplicates('rid', CurrentResourceType.Projects, 1, 10)); + }); + + it('should dispatch ForkRegistry from confirmFork when resource is a registration', () => { + setup({ resourceType: ResourceType.Registration }); + component.handleFork(); + const [options] = forkDialogService.confirmFork.mock.calls[0]; + (store.dispatch as Mock).mockClear(); + + options.fork(); + + expect(store.dispatch).toHaveBeenCalledWith(new ForkRegistry('rid')); }); it('should not update currentPage when page is undefined', () => { setup(); component.currentPage.set(5); - const event: PaginatorState = { page: undefined } as PaginatorState; - component.onPageChange(event); + + component.onPageChange({ page: undefined }); + expect(component.currentPage()).toBe(5); }); + + it('should reload duplicates when page changes', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.onPageChange({ page: 2 }); + fixture.detectChanges(); + + expect(component.currentPage()).toBe(3); + expect(store.dispatch).toHaveBeenCalledWith(new GetAllDuplicates('rid', CurrentResourceType.Projects, 3, 10)); + }); }); diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts index 11ecd3444..9e75ef452 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts @@ -2,13 +2,11 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; -import { Button } from 'primeng/button'; -import { Menu } from 'primeng/menu'; import { PaginatorState } from 'primeng/paginator'; import { map, of } from 'rxjs'; -import { DatePipe, isPlatformBrowser } from '@angular/common'; +import { isPlatformBrowser } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -20,41 +18,30 @@ import { Signal, signal, } from '@angular/core'; -import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; import { UserSelectors } from '@core/store/user'; -import { DeleteComponentDialogComponent } from '@osf/features/project/overview/components/delete-component-dialog/delete-component-dialog.component'; -import { ForkDialogComponent } from '@osf/features/project/overview/components/fork-dialog/fork-dialog.component'; -import { ClearProjectOverview, GetProjectById, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; -import { ClearRegistry, GetRegistryById, RegistrySelectors } from '@osf/features/registry/store/registry'; -import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; +import { ForkProject } from '@osf/features/project/overview/store'; +import { DeleteResourceService } from '@osf/features/project/settings/services/delete-resource.service'; +import { ForkRegistry } from '@osf/features/registry/store/registry'; import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; -import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; -import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; -import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import { LoaderService } from '@osf/shared/services/loader.service'; -import { GetResourceWithChildren } from '@osf/shared/stores/current-resource'; +import { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { ForkDialogService } from '@osf/shared/services/fork-dialog.service'; import { ClearDuplicates, DuplicatesSelectors, GetAllDuplicates } from '@osf/shared/stores/duplicates'; -import { BaseNodeModel } from '@shared/models/nodes/base-node.model'; + +import { DuplicateCardComponent } from '../duplicate-card/duplicate-card.component'; @Component({ selector: 'osf-view-duplicates', imports: [ - Button, - Menu, - RouterLink, - IconComponent, SubHeaderComponent, - TruncatedTextComponent, LoadingSpinnerComponent, CustomPaginatorComponent, - ContributorsListComponent, - DatePipe, + DuplicateCardComponent, TranslatePipe, ], templateUrl: './view-duplicates.component.html', @@ -62,138 +49,97 @@ import { BaseNodeModel } from '@shared/models/nodes/base-node.model'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ViewDuplicatesComponent { - private customDialogService = inject(CustomDialogService); - private loaderService = inject(LoaderService); - private route = inject(ActivatedRoute); - private router = inject(Router); - private destroyRef = inject(DestroyRef); - private platformId = inject(PLATFORM_ID); - private isBrowser = isPlatformBrowser(this.platformId); - - private project = select(ProjectOverviewSelectors.getProject); - private registration = select(RegistrySelectors.getRegistry); - - duplicates = select(DuplicatesSelectors.getDuplicates); - isDuplicatesLoading = select(DuplicatesSelectors.getDuplicatesLoading); - totalDuplicates = select(DuplicatesSelectors.getDuplicatesTotalCount); - isAuthenticated = select(UserSelectors.isAuthenticated); - preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled); + private readonly forkDialogService = inject(ForkDialogService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + private readonly deleteResourceService = inject(DeleteResourceService); + + readonly duplicates = select(DuplicatesSelectors.getDuplicates); + readonly isDuplicatesLoading = select(DuplicatesSelectors.getDuplicatesLoading); + readonly totalDuplicates = select(DuplicatesSelectors.getDuplicatesTotalCount); + readonly isAuthenticated = select(UserSelectors.isAuthenticated); + readonly preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled); + + readonly actions = createDispatchMap({ + getDuplicates: GetAllDuplicates, + clearDuplicates: ClearDuplicates, + forkProject: ForkProject, + forkRegistry: ForkRegistry, + }); readonly pageSize = 10; + readonly currentPage = signal(1); + readonly firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize); - currentPage = signal(1); - firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize); - duplicateButtonTooltip = computed(() => + readonly duplicateButtonLabel = computed(() => + this.resourceType() === ResourceType.Registration + ? 'project.overview.actions.duplicateRegistration' + : 'project.overview.actions.forkProjectLabel' + ); + readonly duplicateButtonTooltip = computed(() => this.preventDuplicateCreation() ? 'project.overview.actions.duplicatingProjectsNotAllowed' : '' ); - readonly forkActionItems = (resourceId: string) => [ - { - label: 'project.overview.actions.manageContributors', - action: 'manageContributors', - resourceId, - }, - { - label: 'project.overview.actions.settings', - action: 'settings', - resourceId, - }, - { - label: 'common.labels.delete', - action: 'delete', - resourceId, - }, - ]; - readonly resourceId = toSignal(this.route.parent?.params.pipe(map((params) => params['id'])) ?? of(undefined)); readonly resourceType: Signal = toSignal( this.route.data.pipe(map((params) => params['resourceType'])) ?? of(undefined) ); - readonly currentResource = computed(() => { - const resourceType = this.resourceType(); - - if (resourceType) { - if (resourceType === ResourceType.Project) return this.project(); - - if (resourceType === ResourceType.Registration) return this.registration(); - } - - return null; - }); - - actions = createDispatchMap({ - getProject: GetProjectById, - getRegistration: GetRegistryById, - getDuplicates: GetAllDuplicates, - clearDuplicates: ClearDuplicates, - clearProject: ClearProjectOverview, - clearRegistration: ClearRegistry, - getComponentsTree: GetResourceWithChildren, - }); + readonly apiResourceType = computed(() => + this.resourceType() === ResourceType.Registration ? CurrentResourceType.Registrations : CurrentResourceType.Projects + ); constructor() { effect(() => { - const resourceId = this.resourceId(); - const resourceType = this.resourceType(); + const id = this.resourceId(); + const type = this.apiResourceType(); + const page = this.currentPage(); - if (resourceId) { - if (resourceType === ResourceType.Project) this.actions.getProject(resourceId); - if (resourceType === ResourceType.Registration) this.actions.getRegistration(resourceId); - } - }); + if (!id || !type) return; - effect(() => { - const resource = this.currentResource(); - - if (resource) { - this.actions.getDuplicates(resource.id, resource.type, this.currentPage(), this.pageSize); - } + this.actions.getDuplicates(id, type, page, this.pageSize); }); this.setupCleanup(); } - showMoreOptions(duplicate: BaseNodeModel) { - return ( - duplicate.currentUserPermissions.includes(UserPermissions.Admin) || - duplicate.currentUserPermissions.includes(UserPermissions.Write) - ); + setupCleanup(): void { + this.destroyRef.onDestroy(() => { + if (this.isBrowser) { + this.actions.clearDuplicates(); + } + }); } - handleMenuAction(action: string, resourceId: string): void { + handleMenuAction(action: NodeMenuAction, resourceId: string): void { switch (action) { - case 'manageContributors': + case NodeMenuAction.ManageContributors: this.router.navigate([resourceId, 'contributors']); break; - case 'settings': + case NodeMenuAction.Settings: this.router.navigate([resourceId, 'settings']); break; - case 'delete': + case NodeMenuAction.Delete: this.handleDeleteFork(resourceId); break; } } - handleForkResource(): void { - const currentResource = this.currentResource(); - - if (currentResource) { - this.customDialogService - .open(ForkDialogComponent, { - header: 'project.overview.dialog.fork.headerProject', - width: '450px', - data: { - resourceId: currentResource.id, - resourceType: this.resourceType(), - }, - }) - .onClose.pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((result) => { - if (result?.success) { - this.actions.getDuplicates(currentResource.id, currentResource.type, this.currentPage(), this.pageSize); - } - }); - } + handleFork(): void { + const resourceId = this.resourceId(); + const apiResourceType = this.apiResourceType(); + + if (!resourceId) return; + + const isRegistration = apiResourceType === CurrentResourceType.Registrations; + + this.forkDialogService.confirmFork({ + resourceType: apiResourceType, + destroyRef: this.destroyRef, + fork: () => (isRegistration ? this.actions.forkRegistry(resourceId) : this.actions.forkProject(resourceId)), + onSuccess: () => this.actions.getDuplicates(resourceId, apiResourceType, this.currentPage(), this.pageSize), + }); } onPageChange(event: PaginatorState): void { @@ -203,49 +149,13 @@ export class ViewDuplicatesComponent { } } - setupCleanup(): void { - this.destroyRef.onDestroy(() => { - if (this.isBrowser) { - this.actions.clearDuplicates(); - this.actions.clearProject(); - this.actions.clearRegistration(); - } - }); - } - private handleDeleteFork(id: string): void { - const resourceType = this.resourceType(); - if (!resourceType) return; - - this.loaderService.show(); - - this.actions.getComponentsTree(id, id, resourceType).subscribe({ - next: () => { - this.loaderService.hide(); - this.customDialogService - .open(DeleteComponentDialogComponent, { - header: 'project.overview.dialog.deleteComponent.header', - width: '650px', - data: { - componentId: id, - resourceType: resourceType, - isForksContext: true, - currentPage: this.currentPage(), - pageSize: this.pageSize, - }, - }) - .onClose.pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((result) => { - if (result?.success) { - const resource = this.currentResource(); - if (resource) { - this.actions.getDuplicates(resource.id, resource.type, this.currentPage(), this.pageSize); - } - } - }); - }, - error: () => { - this.loaderService.hide(); + this.deleteResourceService.deleteProject({ + rootParentId: id, + resourceId: id, + destroyRef: this.destroyRef, + onSuccess: () => { + this.actions.getDuplicates(this.resourceId(), this.apiResourceType(), this.currentPage(), this.pageSize); }, }); } diff --git a/src/app/features/analytics/constants/analytics-constants.ts b/src/app/features/analytics/constants/analytics-constants.ts index e06c3039b..9993e3891 100644 --- a/src/app/features/analytics/constants/analytics-constants.ts +++ b/src/app/features/analytics/constants/analytics-constants.ts @@ -1,5 +1,5 @@ -import { DateRange } from '../enums'; -import { DateRangeOption } from '../models'; +import { DateRange } from '../enums/date-range.enum'; +import { DateRangeOption } from '../models/date-range-option.model'; export const DATE_RANGE_OPTIONS: DateRangeOption[] = [ { label: 'project.analytics.charts.pastWeek', value: DateRange.PastWeek }, diff --git a/src/app/features/analytics/enums/index.ts b/src/app/features/analytics/enums/index.ts deleted file mode 100644 index c984e6081..000000000 --- a/src/app/features/analytics/enums/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DateRange } from './date-range.enum'; diff --git a/src/app/features/analytics/models/date-range-option.model.ts b/src/app/features/analytics/models/date-range-option.model.ts index 0aadb078c..e8a6d8d45 100644 --- a/src/app/features/analytics/models/date-range-option.model.ts +++ b/src/app/features/analytics/models/date-range-option.model.ts @@ -1,4 +1,4 @@ -import { DateRange } from '../enums'; +import { DateRange } from '../enums/date-range.enum'; export interface DateRangeOption { label: string; diff --git a/src/app/features/analytics/store/analytics.actions.ts b/src/app/features/analytics/store/analytics.actions.ts index 5a4e2c274..a09419a63 100644 --- a/src/app/features/analytics/store/analytics.actions.ts +++ b/src/app/features/analytics/store/analytics.actions.ts @@ -1,6 +1,6 @@ import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { DateRange } from '../enums'; +import { DateRange } from '../enums/date-range.enum'; export class GetMetrics { static readonly type = '[Analytics] Get Metrics'; diff --git a/src/app/features/collections/collections.routes.ts b/src/app/features/collections/collections.routes.ts index ee9fe1831..e7827146e 100644 --- a/src/app/features/collections/collections.routes.ts +++ b/src/app/features/collections/collections.routes.ts @@ -3,6 +3,7 @@ import { provideStates } from '@ngxs/store'; import { Routes } from '@angular/router'; import { authGuard } from '@core/guards/auth.guard'; +import { PAGE_NOT_FOUND_ROUTE_DATA } from '@osf/core/constants/page-not-found.constants'; import { ConfirmLeavingGuard } from '@osf/shared/guards'; import { ActivityLogsState } from '@osf/shared/stores/activity-logs'; import { BookmarksState } from '@osf/shared/stores/bookmarks'; @@ -27,7 +28,7 @@ export const collectionsRoutes: Routes = [ pathMatch: 'full', loadComponent: () => import('@core/components/page-not-found/page-not-found.component').then((m) => m.PageNotFoundComponent), - data: { skipBreadcrumbs: true }, + data: PAGE_NOT_FOUND_ROUTE_DATA, }, { path: ':providerId', diff --git a/src/app/features/moderation/components/preprint-submission-item/preprint-submission-item.component.html b/src/app/features/moderation/components/preprint-submission-item/preprint-submission-item.component.html index 6542e9d12..e11a446ff 100644 --- a/src/app/features/moderation/components/preprint-submission-item/preprint-submission-item.component.html +++ b/src/app/features/moderation/components/preprint-submission-item/preprint-submission-item.component.html @@ -13,7 +13,7 @@ [label]="submission().title | fixSpecialChar" (onClick)="selected.emit()" /> - @for (action of showAll ? submission().actions : submission().actions.slice(0, limitValue); track $index) { + @for (action of showAll ? visibleActions() : visibleActions().slice(0, limitValue); track $index) {
{{ actionLabel[action.toState] | translate }} {{ action.dateModified | dateAgo }} @@ -26,7 +26,7 @@
} - @if (submission().actions.length > 1) { + @if (visibleActions().length > 1) { + this.submission().actions.filter((action) => action.trigger !== ReviewActionTrigger.ReportSpam) + ); + limitValue = 1; showAll = false; 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 8bc4d4a6e..88f2a9ef1 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 @@ -29,7 +29,7 @@

{{ submission().title }}

} - @for (action of showAll ? submission().actions : submission().actions.slice(0, limitValue); track $index) { + @for (action of showAll ? visibleActions() : visibleActions().slice(0, limitValue); track $index) {
{{ registryActionLabel[action.toState] | translate }} {{ action.dateModified | dateAgo }} @@ -46,7 +46,7 @@

{{ submission().title }}

} - @if (submission().actions.length > 1) { + @if (visibleActions().length > 1) { + this.submission().actions.filter((action) => action.trigger !== ReviewActionTrigger.ReportSpam) + ); + limitValue = 1; showAll = false; diff --git a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.html b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.html index f4f6f0f1a..855f1cf74 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.html +++ b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.html @@ -97,7 +97,13 @@

{{ 'preprints.details.decision.decline.label' | transl
- +
- +
+ +
+ + +
diff --git a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.spec.ts b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.spec.ts index e0e30c6f3..a232fac62 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.spec.ts @@ -13,6 +13,7 @@ import { SubmitRequestsDecision, SubmitReviewsDecision, } from '@osf/features/preprints/store/preprint'; +import { ToastService } from '@osf/shared/services/toast.service'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; @@ -21,6 +22,7 @@ import { REVIEW_ACTION_MOCK } from '@testing/mocks/review-action.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'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; import { PreprintMakeDecisionComponent } from './preprint-make-decision.component'; @@ -44,6 +46,7 @@ describe('PreprintMakeDecisionComponent', () => { providers: [ provideOSFCore(), MockProvider(Router, routerMock), + MockProvider(ToastService, ToastServiceMock.simple()), provideMockStore({ signals: [{ selector: PreprintSelectors.getPreprint, value: mockPreprint }], }), diff --git a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.ts b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.ts index 3ae7f4608..93d4eb0db 100644 --- a/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.ts +++ b/src/app/features/preprints/components/preprint-details/preprint-make-decision/preprint-make-decision.component.ts @@ -26,7 +26,9 @@ import { SubmitReviewsDecision, } from '@osf/features/preprints/store/preprint'; import { InputLimits } from '@osf/shared/constants/input-limits.const'; +import { ReviewActionTrigger } from '@osf/shared/enums/trigger-action.enum'; import { StringOrNull } from '@osf/shared/helpers/types.helper'; +import { ToastService } from '@osf/shared/services/toast.service'; @Component({ selector: 'osf-preprint-make-decision', @@ -38,6 +40,7 @@ import { StringOrNull } from '@osf/shared/helpers/types.helper'; export class PreprintMakeDecisionComponent { private readonly translateService = inject(TranslateService); private readonly router = inject(Router); + private readonly toastService = inject(ToastService); private readonly actions = createDispatchMap({ submitReviewsDecision: SubmitReviewsDecision, @@ -53,7 +56,7 @@ export class PreprintMakeDecisionComponent { dialogVisible = false; didValidate = signal(false); - decision = signal(ReviewsState.Accepted); + decision = signal(ReviewsState.Accepted); initialReviewerComment = signal(null); reviewerComment = signal(null); requestDecisionJustification = signal(null); @@ -61,6 +64,7 @@ export class PreprintMakeDecisionComponent { readonly decisionCommentLimit = InputLimits.decisionComment.maxLength; readonly ReviewsState = ReviewsState; + readonly ReviewActionTrigger = ReviewActionTrigger; labelDecisionButton = computed(() => { const preprint = this.preprint(); @@ -99,7 +103,11 @@ export class PreprintMakeDecisionComponent { const preprint = this.preprint(); const reviewsState = preprint?.reviewsState; - if (this.isPendingWithdrawal() || reviewsState === ReviewsState.Pending) { + if ( + this.decision() === ReviewActionTrigger.ReportSpam || + this.isPendingWithdrawal() || + reviewsState === ReviewsState.Pending + ) { return 'preprints.details.decision.submitButton.submitDecision'; } @@ -231,6 +239,11 @@ export class PreprintMakeDecisionComponent { const preprint = this.preprint(); if (!preprint) return; + if (this.decision() === ReviewActionTrigger.ReportSpam) { + this.reportSpam(); + return; + } + let trigger; if (preprint.reviewsState !== ReviewsState.Pending && this.commentEdited() && !this.decisionChanged()) { // If the submission is not pending, @@ -306,6 +319,32 @@ export class PreprintMakeDecisionComponent { } } + decisionToggled() { + if (this.decision() === ReviewActionTrigger.ReportSpam) { + this.reviewerComment.set(null); + } else { + this.reviewerComment.set(this.initialReviewerComment()); + } + } + + private reportSpam() { + const preprint = this.preprint(); + if (!preprint) return; + + this.saving.set(true); + this.actions + .submitReviewsDecision(ReviewActionTrigger.ReportSpam, this.reviewerComment()?.trim() || null) + .pipe(finalize(() => this.saving.set(false))) + .subscribe({ + next: () => { + this.toastService.showSuccess('preprints.details.decision.reportSpam.success'); + this.dialogVisible = false; + this.decision.set(preprint.reviewsState); + this.reviewerComment.set(this.initialReviewerComment()); + }, + }); + } + requestDecisionToggled() { if (!this.isPendingWithdrawal()) { return; diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.html b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.html index cee666a6f..a993ab77f 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.html +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.html @@ -1,11 +1,13 @@

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

-
- - {{ 'preprints.preprintContributorsWarning' | translate }} - -
+ @if (hasAdminAccess()) { +
+ + {{ 'preprints.preprintContributorsWarning' | translate }} + +
+ } {{ 'common.labels.contributors' | translate }}

[isLoading]="isContributorsLoading()" [showLoadMore]="hasMoreContributors()" [isLoadingMore]="isLoadingMore()" + [hasAdminAccess]="hasAdminAccess()" (remove)="removeContributor($event)" (loadMore)="loadMoreContributors()" /> -
- @if (hasChanges) { -
- - -
- } - -
+ @if (hasAdminAccess()) { +
+ @if (hasChanges) { +
+ + +
+ } + +
+ } 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 3dc5a0070..2126f6615 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,11 +1,12 @@ import { Store } from '@ngxs/store'; -import { MockComponent, MockProvider } from 'ng-mocks'; +import { MockComponent, MockProvider, ngMocks } from 'ng-mocks'; import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; import { ContributorsTableComponent } from '@osf/shared/components/contributors/contributors-table/contributors-table.component'; import { AddContributorType } from '@osf/shared/enums/contributors/add-contributor-type.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; @@ -49,6 +50,7 @@ describe('PreprintsContributorsComponent', () => { { selector: ContributorsSelectors.isContributorsLoadingMore, value: false }, { selector: ContributorsSelectors.getContributorsPageSize, value: 10 }, { selector: ContributorsSelectors.getContributorsTotalCount, value: 1 }, + { selector: PreprintStepperSelectors.hasAdminAccess, value: true }, ]; function setup(overrides?: { @@ -109,6 +111,24 @@ describe('PreprintsContributorsComponent', () => { expect(component.contributors()).not.toBe(mockContributors); }); + it('should allow admin to manage contributors', () => { + setup(); + + expect(ngMocks.input(ngMocks.find(ContributorsTableComponent), 'hasAdminAccess')).toBe(true); + expect(fixture.nativeElement.querySelector('p-message')).toBeTruthy(); + expect(fixture.nativeElement.querySelectorAll('p-button').length).toBe(1); + }); + + it('should show contributors as read-only without admin access', () => { + setup({ selectorOverrides: [{ selector: PreprintStepperSelectors.hasAdminAccess, value: false }] }); + component.contributors.set([{ ...MOCK_CONTRIBUTOR, fullName: 'Updated Name' }]); + fixture.detectChanges(); + + expect(ngMocks.input(ngMocks.find(ContributorsTableComponent), 'hasAdminAccess')).toBe(false); + expect(fixture.nativeElement.querySelector('p-message')).toBeNull(); + expect(fixture.nativeElement.querySelectorAll('p-button').length).toBe(0); + }); + it('should compute hasChanges correctly', () => { setup(); expect(component.hasChanges).toBe(false); 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 e36795893..64fa98966 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 @@ -21,6 +21,7 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { PreprintStepperSelectors } from '@osf/features/preprints/store/preprint-stepper'; import { AddContributorDialogComponent } from '@osf/shared/components/contributors/add-contributor-dialog/add-contributor-dialog.component'; import { AddUnregisteredContributorDialogComponent } from '@osf/shared/components/contributors/add-unregistered-contributor-dialog/add-unregistered-contributor-dialog.component'; import { ContributorsTableComponent } from '@osf/shared/components/contributors/contributors-table/contributors-table.component'; @@ -65,6 +66,7 @@ export class PreprintsContributorsComponent implements OnInit { readonly hasMoreContributors = select(ContributorsSelectors.hasMoreContributors); readonly isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); readonly pageSize = select(ContributorsSelectors.getContributorsPageSize); + readonly hasAdminAccess = select(PreprintStepperSelectors.hasAdminAccess); readonly actions = createDispatchMap({ getContributors: GetAllContributors, 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 ba30e4bd3..0c60a008f 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 @@ -7,18 +7,21 @@
} @else { - - +
+ + + +

{{ preprint()?.title }}

- +
} @if (moderationMode()) { 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 b23e85952..e09726031 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 @@ -11,6 +11,7 @@ import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; +import { PREPRINT_PENDING_MODERATION_DETAIL } from '@core/constants/http-error-details.const'; import { HelpScoutService } from '@core/services/help-scout.service'; import { PrerenderReadyService } from '@core/services/prerender-ready.service'; import { ClearCurrentProvider } from '@core/store/provider'; @@ -49,7 +50,7 @@ import { PreprintTombstoneComponent } from '../../components/preprint-details/pr import { PreprintWarningBannerComponent } from '../../components/preprint-details/preprint-warning-banner/preprint-warning-banner.component'; import { ShareAndDownloadComponent } from '../../components/preprint-details/share-and-download/share-and-download.component'; import { StatusBannerComponent } from '../../components/preprint-details/status-banner/status-banner.component'; -import { ReviewsState } from '../../enums'; +import { ProviderReviewsWorkflow, ReviewsState } from '../../enums'; import { FetchPreprintDetails, FetchPreprintRequestActions, @@ -308,7 +309,7 @@ describe('PreprintDetailsComponent', () => { const errorResponse = new HttpErrorResponse({ status: 403, error: { - errors: [{ detail: 'This preprint is pending moderation and is not yet publicly available.' }], + errors: [{ detail: PREPRINT_PENDING_MODERATION_DETAIL }], }, }); @@ -354,6 +355,17 @@ describe('PreprintDetailsComponent', () => { expect(component.isOsfPreprint()).toBe(true); }); + it('should render the title outside of the provider logo link', () => { + setup(); + + const title: HTMLElement = fixture.nativeElement.querySelector('h1'); + const logoImage: HTMLElement = fixture.nativeElement.querySelector('img.fit-contain'); + + expect(title.textContent?.trim()).toBe(mockPreprint.title); + expect(title.closest('a')).toBeNull(); + expect(logoImage.closest('a')).not.toBeNull(); + }); + it('should mark preprint withdrawable for pending and accepted states', () => { setup(); vi.spyOn(component, 'preprint').mockReturnValue({ ...mockPreprint, reviewsState: ReviewsState.Pending } as any); @@ -407,6 +419,68 @@ describe('PreprintDetailsComponent', () => { expect(component.editButtonVisible()).toBe(true); }); + it('should show edit button for previous version of preprint', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintSelectors.getPreprint, + value: { ...mockPreprint, isLatestVersion: false, reviewsState: ReviewsState.Accepted }, + }, + ], + }); + + expect(component.editButtonVisible()).toBe(true); + }); + + it('should show edit button for previous version of postmoderation preprint', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintProvidersSelectors.getPreprintProviderDetails('osf'), + value: { ...mockProvider, reviewsWorkflow: ProviderReviewsWorkflow.PostModeration }, + }, + { + selector: PreprintSelectors.getPreprint, + value: { ...mockPreprint, isLatestVersion: false, reviewsState: ReviewsState.Accepted }, + }, + ], + }); + + expect(component.editButtonVisible()).toBe(true); + }); + + it('should hide edit button for previous version when user does not have write access', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintSelectors.getPreprint, + value: { ...mockPreprint, isLatestVersion: false, reviewsState: ReviewsState.Accepted }, + }, + { selector: PreprintSelectors.hasWriteAccess, value: false }, + ], + }); + + expect(component.editButtonVisible()).toBe(false); + }); + + it('should hide edit button for withdrawn previous version', () => { + setup({ + selectorOverrides: [ + { + selector: PreprintSelectors.getPreprint, + value: { + ...mockPreprint, + isLatestVersion: false, + reviewsState: ReviewsState.Withdrawn, + dateWithdrawn: '2024-01-01', + }, + }, + ], + }); + + expect(component.editButtonVisible()).toBe(false); + }); + it('should show edit button for pending premoderation preprint', () => { setup({ selectorOverrides: [ 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 760b76ce1..d454e1bb2 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 @@ -24,12 +24,13 @@ import { import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { PREPRINT_PENDING_MODERATION_DETAIL } from '@core/constants/http-error-details.const'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { HelpScoutService } from '@core/services/help-scout.service'; 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 { ReviewActionTrigger } from '@osf/shared/enums/trigger-action.enum'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { LoaderService } from '@osf/shared/services/loader.service'; @@ -119,7 +120,6 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { clearCurrentProvider: ClearCurrentProvider, }); - currentUser = select(UserSelectors.getCurrentUser); preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); preprint = select(PreprintSelectors.getPreprint); @@ -147,7 +147,8 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { }); latestAction = computed(() => { - const actions = this.reviewActions(); + // banners and the decision dialog describe the last decision, and a spam report is not one + const actions = this.reviewActions().filter((action) => action.trigger !== ReviewActionTrigger.ReportSpam); return actions.length > 0 ? actions[0] : null; }); @@ -221,7 +222,7 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { } } - return false; + return !preprintIsRejected; }); editButtonLabel = computed(() => { @@ -360,11 +361,11 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { this.actions .createNewVersion(preprintId) .pipe( - catchError((e) => { + catchError((error: HttpErrorResponse) => { this.loaderService.hide(); - if (e instanceof HttpErrorResponse && e.status === 409) { - this.toastService.showError(e.error.errors[0].detail); + if (error.status === 409) { + this.toastService.showError(error?.error?.errors[0]?.detail); } return EMPTY; @@ -409,12 +410,8 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { } } }, - error: (error) => { - if ( - error instanceof HttpErrorResponse && - error.status === 403 && - error?.error?.errors[0]?.detail === 'This preprint is pending moderation and is not yet publicly available.' - ) { + error: (error: HttpErrorResponse) => { + if (error?.status === 403 && error.error?.errors?.[0]?.detail === PREPRINT_PENDING_MODERATION_DETAIL) { this.router.navigate(['/preprints', this.providerId(), preprintId, 'pending-moderation']); } }, diff --git a/src/app/features/project/overview/components/component-card/component-card.component.spec.ts b/src/app/features/project/overview/components/component-card/component-card.component.spec.ts index 30f527e6f..d44a7195d 100644 --- a/src/app/features/project/overview/components/component-card/component-card.component.spec.ts +++ b/src/app/features/project/overview/components/component-card/component-card.component.spec.ts @@ -4,6 +4,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; 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 { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; import { MOCK_NODE_WITH_ADMIN, MOCK_NODE_WITHOUT_ADMIN } from '@testing/mocks/node.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; @@ -16,7 +18,10 @@ describe('ComponentCardComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ComponentCardComponent, ...MockComponents(IconComponent, ContributorsListComponent)], + imports: [ + ComponentCardComponent, + ...MockComponents(IconComponent, ContributorsListComponent, TruncatedTextComponent), + ], providers: [provideOSFCore()], }); @@ -27,53 +32,54 @@ describe('ComponentCardComponent', () => { fixture.detectChanges(); }); - it('should emit navigate when handleNavigate is called', () => { + it('should emit navigate when the title is clicked', () => { const emitSpy = vi.spyOn(component.navigate, 'emit'); - component.handleNavigate('test-id'); - expect(emitSpy).toHaveBeenCalledWith('test-id'); + + fixture.nativeElement.querySelector('.node-title').click(); + + expect(emitSpy).toHaveBeenCalledWith(MOCK_NODE_WITH_ADMIN.id); }); - it('should emit menuAction when handleMenuAction is called', () => { + it('should emit menuAction when a menu action is selected', () => { const emitSpy = vi.spyOn(component.menuAction, 'emit'); - component.handleMenuAction('settings'); - expect(emitSpy).toHaveBeenCalledWith('settings'); + + component.handleMenuAction(NodeMenuAction.Settings); + + expect(emitSpy).toHaveBeenCalledWith(NodeMenuAction.Settings); }); - describe('componentActionItems', () => { - it('should return base items for any component', () => { - fixture.componentRef.setInput('component', MOCK_NODE_WITHOUT_ADMIN); - fixture.detectChanges(); - const items = component.componentActionItems(); - expect(items).toHaveLength(2); - expect(items[0].action).toBe('manageContributors'); - expect(items[1].action).toBe('settings'); - }); + it('should hide the menu when the user is not a contributor', () => { + fixture.componentRef.setInput('component', { ...MOCK_NODE_WITH_ADMIN, currentUserIsContributor: false }); + fixture.detectChanges(); - it('should include delete action when component has Admin permission', () => { - fixture.componentRef.setInput('component', MOCK_NODE_WITH_ADMIN); - fixture.detectChanges(); - const items = component.componentActionItems(); - expect(items).toHaveLength(3); - expect(items[0].action).toBe('manageContributors'); - expect(items[1].action).toBe('settings'); - expect(items[2].action).toBe('delete'); - }); + expect(fixture.nativeElement.querySelector('p-button')).toBeNull(); + }); - it('should exclude delete action when component does not have Admin permission', () => { - fixture.componentRef.setInput('component', MOCK_NODE_WITHOUT_ADMIN); - fixture.detectChanges(); - const items = component.componentActionItems(); - expect(items).toHaveLength(2); - expect(items.every((item) => item.action !== 'delete')).toBe(true); - }); + it('should show contributors and settings actions without delete when the user is not an admin', () => { + fixture.componentRef.setInput('component', MOCK_NODE_WITHOUT_ADMIN); + fixture.detectChanges(); - it('should exclude delete action when hideDeleteAction is true', () => { - fixture.componentRef.setInput('component', MOCK_NODE_WITH_ADMIN); - fixture.componentRef.setInput('hideDeleteAction', true); - fixture.detectChanges(); - const items = component.componentActionItems(); - expect(items).toHaveLength(2); - expect(items.every((item) => item.action !== 'delete')).toBe(true); - }); + expect(component.componentActionItems().map((item) => item.action)).toEqual([ + NodeMenuAction.ManageContributors, + NodeMenuAction.Settings, + ]); + }); + + it('should include delete when the user is an admin', () => { + expect(component.componentActionItems().map((item) => item.action)).toEqual([ + NodeMenuAction.ManageContributors, + NodeMenuAction.Settings, + NodeMenuAction.Delete, + ]); + }); + + it('should hide delete when hideDeleteAction is true', () => { + fixture.componentRef.setInput('hideDeleteAction', true); + fixture.detectChanges(); + + expect(component.componentActionItems().map((item) => item.action)).toEqual([ + NodeMenuAction.ManageContributors, + NodeMenuAction.Settings, + ]); }); }); diff --git a/src/app/features/project/overview/components/component-card/component-card.component.ts b/src/app/features/project/overview/components/component-card/component-card.component.ts index a88609484..1cfdfa12d 100644 --- a/src/app/features/project/overview/components/component-card/component-card.component.ts +++ b/src/app/features/project/overview/components/component-card/component-card.component.ts @@ -8,6 +8,7 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@an 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 { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; @@ -24,7 +25,7 @@ export class ComponentCardComponent { hideDeleteAction = input(false); navigate = output(); - menuAction = output(); + menuAction = output(); readonly componentActionItems = computed(() => { const component = this.component(); @@ -32,18 +33,18 @@ export class ComponentCardComponent { const baseItems = [ { label: 'project.overview.actions.manageContributors', - action: 'manageContributors', + action: NodeMenuAction.ManageContributors, }, { label: 'project.overview.actions.settings', - action: 'settings', + action: NodeMenuAction.Settings, }, ]; if (!this.hideDeleteAction() && component.currentUserPermissions.includes(UserPermissions.Admin)) { baseItems.push({ label: 'common.labels.delete', - action: 'delete', + action: NodeMenuAction.Delete, }); } @@ -54,7 +55,7 @@ export class ComponentCardComponent { this.navigate.emit(componentId); } - handleMenuAction(action: string): void { + handleMenuAction(action: NodeMenuAction): void { this.menuAction.emit(action); } } diff --git a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.html b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.html deleted file mode 100644 index 28ab10be0..000000000 --- a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.html +++ /dev/null @@ -1,45 +0,0 @@ -@if (hasAdminAccessForAllComponents()) { - @if (hasSubComponents()) { -

{{ 'project.overview.dialog.deleteComponent.listMessage' | translate }}

- @if (isLoading()) { - - } @else { -
    - @for (component of components(); track component.id) { -
  • {{ component.title }}
  • - } -
- } - -

{{ 'project.overview.dialog.deleteComponent.warningMessage' | translate }}

- } @else { -

{{ 'project.overview.dialog.deleteComponent.message' | translate }}

- } - -

- {{ 'project.overview.dialog.deleteComponent.confirmation' | translate }} - {{ selectedScientist() }} -

- - -} @else { -

-} - -
- - -
diff --git a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.scss b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.scss deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index aabe577e7..000000000 --- a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { MockProvider } from 'ng-mocks'; - -import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; - -import { Mock } from 'vitest'; - -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 { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; -import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; -import { ToastService } from '@osf/shared/services/toast.service'; -import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; - -import { MOCK_PROJECT_OVERVIEW } from '@testing/mocks/project-overview.mock'; -import { provideOSFCore } from '@testing/osf.testing.provider'; -import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; -import { - BaseSetupOverrides, - mergeSignalOverrides, - provideMockStore, - SignalOverride, -} from '@testing/providers/store-provider.mock'; -import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; - -import { ProjectOverviewModel } from '../../models'; -import { GetComponents, ProjectOverviewSelectors } from '../../store'; - -import { DeleteComponentDialogComponent } from './delete-component-dialog.component'; - -interface SetupOverrides extends BaseSetupOverrides { - resourceType?: ResourceType; - isForksContext?: boolean; -} - -describe('DeleteComponentDialogComponent', () => { - let component: DeleteComponentDialogComponent; - let fixture: ComponentFixture; - let store: Store; - let dialogRef: DynamicDialogRef; - let toastService: ToastServiceMockType; - - const mockProject: ProjectOverviewModel = { - ...MOCK_PROJECT_OVERVIEW, - id: 'project-1', - }; - - const adminComponents: NodeShortInfoModel[] = [ - { - id: 'c1', - title: 'Component 1', - isPublic: true, - permissions: [UserPermissions.Admin], - }, - { - id: 'c2', - title: 'Component 2', - isPublic: true, - permissions: [UserPermissions.Admin, UserPermissions.Write], - }, - ]; - - const defaultSignals: SignalOverride[] = [ - { selector: ProjectOverviewSelectors.getProject, value: mockProject }, - { selector: RegistrySelectors.getRegistry, value: null }, - { selector: SettingsSelectors.isSettingsSubmitting, value: false }, - { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, - { selector: CurrentResourceSelectors.getResourceWithChildren, value: adminComponents }, - ]; - - function setup(overrides: SetupOverrides = {}) { - const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); - toastService = ToastServiceMock.simple(); - - TestBed.configureTestingModule({ - imports: [DeleteComponentDialogComponent], - providers: [ - provideOSFCore(), - provideDynamicDialogRefMock(), - MockProvider(DynamicDialogConfig, { - data: { - resourceType: overrides.resourceType ?? ResourceType.Project, - isForksContext: overrides.isForksContext ?? false, - }, - }), - MockProvider(ToastService, toastService), - provideMockStore({ signals }), - ], - }); - - store = TestBed.inject(Store); - dialogRef = TestBed.inject(DynamicDialogRef); - fixture = TestBed.createComponent(DeleteComponentDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - } - - it('should create', () => { - setup(); - - expect(component).toBeTruthy(); - }); - - it('should compute current resource from project when resource type is project', () => { - setup({ resourceType: ResourceType.Project }); - - expect(component.currentResource()?.id).toBe('project-1'); - }); - - it('should return false for hasAdminAccessForAllComponents when components are empty', () => { - setup({ - selectorOverrides: [{ selector: CurrentResourceSelectors.getResourceWithChildren, value: [] }], - }); - - expect(component.hasAdminAccessForAllComponents()).toBe(false); - }); - - it('should return true for hasAdminAccessForAllComponents when all components have admin access', () => { - setup(); - - expect(component.hasAdminAccessForAllComponents()).toBe(true); - }); - - it('should return true for hasSubComponents when there are multiple components', () => { - setup(); - - expect(component.hasSubComponents()).toBe(true); - }); - - it('should return false for hasSubComponents when there is one component', () => { - setup({ - selectorOverrides: [{ selector: CurrentResourceSelectors.getResourceWithChildren, value: [adminComponents[0]] }], - }); - - expect(component.hasSubComponents()).toBe(false); - }); - - it('should update userInput on input change and validate exact match', () => { - vi.spyOn(Math, 'random').mockReturnValue(0); - setup(); - - component.onInputChange(component.selectedScientist()); - - expect(component.userInput()).toBe(component.selectedScientist()); - expect(component.isInputValid()).toBe(true); - vi.restoreAllMocks(); - }); - - it('should not dispatch delete action when there are no components', () => { - setup({ - selectorOverrides: [{ selector: CurrentResourceSelectors.getResourceWithChildren, value: [] }], - }); - (store.dispatch as Mock).mockClear(); - - component.handleDeleteComponent(); - - expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(DeleteProject)); - }); - - it('should delete components, refresh list, close dialog and show success when not forks context', () => { - setup({ isForksContext: false }); - (store.dispatch as Mock).mockClear(); - - component.handleDeleteComponent(); - - expect(store.dispatch).toHaveBeenCalledWith(new DeleteProject(adminComponents)); - expect(store.dispatch).toHaveBeenCalledWith(new GetComponents('project-1')); - expect(dialogRef.close).toHaveBeenCalledWith({ success: true }); - expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.deleteComponent.success'); - }); - - it('should skip components refresh after delete in forks context', () => { - setup({ isForksContext: true }); - (store.dispatch as Mock).mockClear(); - - component.handleDeleteComponent(); - - expect(store.dispatch).toHaveBeenCalledWith(new DeleteProject(adminComponents)); - expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetComponents)); - expect(dialogRef.close).toHaveBeenCalledWith({ success: true }); - }); -}); diff --git a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.ts b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.ts deleted file mode 100644 index 3e50feae8..000000000 --- a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { createDispatchMap, select } from '@ngxs/store'; - -import { TranslatePipe } from '@ngx-translate/core'; - -import { Button } from 'primeng/button'; -import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { InputText } from 'primeng/inputtext'; -import { Skeleton } from 'primeng/skeleton'; - -import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; - -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'; - -@Component({ - selector: 'osf-delete-component-dialog', - imports: [TranslatePipe, Button, InputText, FormsModule, Skeleton], - templateUrl: './delete-component-dialog.component.html', - styleUrl: './delete-component-dialog.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class DeleteComponentDialogComponent { - private dialogConfig = inject(DynamicDialogConfig); - private toastService = inject(ToastService); - - dialogRef = inject(DynamicDialogRef); - destroyRef = inject(DestroyRef); - - scientistNames = ScientistsNames; - - project = select(ProjectOverviewSelectors.getProject); - registration = select(RegistrySelectors.getRegistry); - isSubmitting = select(SettingsSelectors.isSettingsSubmitting); - isLoading = select(CurrentResourceSelectors.isResourceWithChildrenLoading); - components = select(CurrentResourceSelectors.getResourceWithChildren); - userInput = signal(''); - - selectedScientist = computed(() => { - const names = Object.values(this.scientistNames); - return names[Math.floor(Math.random() * names.length)]; - }); - - readonly currentResource = computed(() => { - const resourceType = this.dialogConfig.data.resourceType; - - if (resourceType) { - if (resourceType === ResourceType.Project) return this.project(); - - if (resourceType === ResourceType.Registration) return this.registration(); - } - - return null; - }); - - hasAdminAccessForAllComponents = computed(() => { - const components = this.components(); - if (!components || !components.length) return false; - - return components.every((component) => component.permissions?.includes(UserPermissions.Admin)); - }); - - hasSubComponents = computed(() => { - const components = this.components(); - return components && components.length > 1; - }); - - actions = createDispatchMap({ - getComponents: GetComponents, - deleteComponent: DeleteProject, - }); - - isInputValid(): boolean { - return this.userInput() === this.selectedScientist(); - } - - onInputChange(value: string): void { - this.userInput.set(value); - } - - handleDeleteComponent(): void { - const components = this.components(); - - if (!components?.length) return; - - this.actions - .deleteComponent(components) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: () => { - this.dialogRef.close({ success: true }); - - const isForksContext = this.dialogConfig.data.isForksContext; - const resource = this.currentResource(); - - if (!isForksContext && resource) { - this.actions.getComponents(resource.id); - } - - this.toastService.showSuccess('project.overview.dialog.toast.deleteComponent.success'); - }, - }); - } -} diff --git a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.scss b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.spec.ts b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.spec.ts deleted file mode 100644 index 6389d4f8a..000000000 --- a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { MockProvider } from 'ng-mocks'; - -import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; - -import { throwError } from 'rxjs'; - -import { Mock } from 'vitest'; - -import { HttpErrorResponse } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { ToastService } from '@osf/shared/services/toast.service'; - -import { provideOSFCore } from '@testing/osf.testing.provider'; -import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; -import { - BaseSetupOverrides, - mergeSignalOverrides, - provideMockStore, - SignalOverride, -} from '@testing/providers/store-provider.mock'; -import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; - -import { ForkResource, ProjectOverviewSelectors } from '../../store'; - -import { ForkDialogComponent } from './fork-dialog.component'; - -interface SetupOverrides extends BaseSetupOverrides { - resourceId?: string; - resourceType?: ResourceType | null; -} - -describe('ForkDialogComponent', () => { - let component: ForkDialogComponent; - let fixture: ComponentFixture; - let store: Store; - let dialogRef: DynamicDialogRef; - let toastService: ToastServiceMockType; - - const defaultSignals: SignalOverride[] = [ - { selector: ProjectOverviewSelectors.getForkProjectSubmitting, value: false }, - ]; - - function setup(overrides: SetupOverrides = {}) { - const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); - toastService = ToastServiceMock.simple(); - - TestBed.configureTestingModule({ - imports: [ForkDialogComponent], - providers: [ - provideOSFCore(), - provideDynamicDialogRefMock(), - MockProvider(DynamicDialogConfig, { - data: { - resourceId: overrides.resourceId ?? 'project-1', - resourceType: overrides.resourceType === undefined ? ResourceType.Project : overrides.resourceType, - }, - }), - MockProvider(ToastService, toastService), - provideMockStore({ signals }), - ], - }); - - store = TestBed.inject(Store); - dialogRef = TestBed.inject(DynamicDialogRef); - fixture = TestBed.createComponent(ForkDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - } - - it('should create', () => { - setup(); - - expect(component).toBeTruthy(); - }); - - it('should not dispatch fork action when resource id is missing', () => { - setup({ resourceId: '' }); - (store.dispatch as Mock).mockClear(); - - component.handleForkConfirm(); - - expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(ForkResource)); - expect(dialogRef.close).not.toHaveBeenCalled(); - expect(toastService.showSuccess).not.toHaveBeenCalled(); - }); - - it('should not dispatch fork action when resource type is missing', () => { - setup({ resourceType: null }); - (store.dispatch as Mock).mockClear(); - - component.handleForkConfirm(); - - expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(ForkResource)); - expect(dialogRef.close).not.toHaveBeenCalled(); - expect(toastService.showSuccess).not.toHaveBeenCalled(); - }); - - it('should dispatch fork action and close dialog with success toast', () => { - setup({ resourceId: 'project-1', resourceType: ResourceType.Project }); - (store.dispatch as Mock).mockClear(); - - component.handleForkConfirm(); - - expect(store.dispatch).toHaveBeenCalledWith(new ForkResource('project-1', ResourceType.Project)); - expect(dialogRef.close).toHaveBeenCalledWith(); - expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.fork.success'); - }); - - it('should keep dialog open and show toast when fork action errors', () => { - const errorDetail = 'Fork creation failed'; - setup({ resourceId: 'project-1', resourceType: ResourceType.Project }); - (store.dispatch as Mock).mockClear(); - (store.dispatch as Mock).mockReturnValueOnce( - throwError(() => new HttpErrorResponse({ status: 405, error: { errors: [{ detail: errorDetail }] } })) - ); - - component.handleForkConfirm(); - expect(store.dispatch).toHaveBeenCalledWith(new ForkResource('project-1', ResourceType.Project)); - expect(dialogRef.close).callCount(0); - expect(toastService.showError).toHaveBeenCalledWith(errorDetail); - }); -}); diff --git a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts deleted file mode 100644 index 495600c66..000000000 --- a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { createDispatchMap, select } from '@ngxs/store'; - -import { TranslatePipe } from '@ngx-translate/core'; - -import { Button } from 'primeng/button'; -import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; - -import { EMPTY } from 'rxjs'; -import { catchError } from 'rxjs/operators'; - -import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; - -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { ToastService } from '@osf/shared/services/toast.service'; - -import { ForkResource, ProjectOverviewSelectors } from '../../store'; - -@Component({ - selector: 'osf-fork-dialog', - imports: [TranslatePipe, Button], - templateUrl: './fork-dialog.component.html', - styleUrl: './fork-dialog.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ForkDialogComponent { - private toastService = inject(ToastService); - dialogRef = inject(DynamicDialogRef); - destroyRef = inject(DestroyRef); - readonly config = inject(DynamicDialogConfig); - - isSubmitting = select(ProjectOverviewSelectors.getForkProjectSubmitting); - - actions = createDispatchMap({ forkResource: ForkResource }); - - handleForkConfirm(): void { - const resourceId = this.config.data.resourceId as string; - const resourceType = this.config.data.resourceType as ResourceType; - - if (!resourceId || !resourceType) return; - - this.actions - .forkResource(resourceId, resourceType) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((e) => { - this.toastService.showError(e.error.errors[0].detail); - return EMPTY; - }) - ) - .subscribe({ - next: () => { - this.dialogRef.close(); - this.toastService.showSuccess('project.overview.dialog.toast.fork.success'); - }, - }); - } -} diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.scss b/src/app/features/project/overview/components/overview-components/overview-components.component.scss index a7950ea0b..702a1c223 100644 --- a/src/app/features/project/overview/components/overview-components/overview-components.component.scss +++ b/src/app/features/project/overview/components/overview-components/overview-components.component.scss @@ -6,6 +6,10 @@ .component-drag-item { cursor: move; + &.cdk-drag-disabled { + cursor: default; + } + &.cdk-drag-preview { background-color: var(--white); border-radius: 0.6rem; diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts b/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts index 0b84dfb28..b0cd3c4ef 100644 --- a/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts +++ b/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts @@ -2,48 +2,53 @@ import { Store } from '@ngxs/store'; import { MockComponent, MockProvider } from 'ng-mocks'; +import { throwError } from 'rxjs'; + import { Mock } from 'vitest'; import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Router } from '@angular/router'; +import { Router, UrlTree } from '@angular/router'; -import { UserSelectors } from '@osf/core/store/user/user.selectors'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { UserSelectors } from '@osf/core/store/user'; +import { DeleteResourceService } from '@osf/features/project/settings/services/delete-resource.service'; +import { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import { LoaderService } from '@osf/shared/services/loader.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { GetResourceWithChildren } from '@osf/shared/stores/current-resource'; import { MOCK_PROJECT_OVERVIEW } from '@testing/mocks/project-overview.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; -import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { + DeleteResourceServiceMock, + DeleteResourceServiceMockType, +} from '@testing/providers/delete-resource-provider.mock'; import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; -import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; -import { LoadMoreComponents, ProjectOverviewSelectors, ReorderComponents } from '../../store'; +import { GetComponents, LoadMoreComponents, ProjectOverviewSelectors, ReorderComponents } from '../../store'; import { AddComponentDialogComponent } from '../add-component-dialog/add-component-dialog.component'; import { ComponentCardComponent } from '../component-card/component-card.component'; -import { DeleteComponentDialogComponent } from '../delete-component-dialog/delete-component-dialog.component'; import { OverviewComponentsComponent } from './overview-components.component'; -interface SetupOverrides extends BaseSetupOverrides { - selectors?: any[]; -} - describe('OverviewComponentsComponent', () => { let component: OverviewComponentsComponent; let fixture: ComponentFixture; let store: Store; let routerMock: RouterMockType; let customDialogService: ReturnType; - let loaderService: LoaderServiceMock; let toastService: ToastServiceMockType; + let deleteResourceService: DeleteResourceServiceMockType; + const mockUrlTree = {} as UrlTree; const componentA = { id: 'comp-a', title: 'Component A' } as NodeModel; const componentB = { id: 'comp-b', title: 'Component B' } as NodeModel; const components = [componentA, componentB]; @@ -53,21 +58,23 @@ describe('OverviewComponentsComponent', () => { rootParentId: 'root-1', }; - function setup(overrides: SetupOverrides = {}) { - routerMock = RouterMockBuilder.create().build(); + const defaultSignals: SignalOverride[] = [ + { selector: ProjectOverviewSelectors.getComponents, value: components }, + { selector: ProjectOverviewSelectors.getComponentsLoading, value: false }, + { selector: ProjectOverviewSelectors.getComponentsSubmitting, value: false }, + { selector: ProjectOverviewSelectors.hasMoreComponents, value: true }, + { selector: ProjectOverviewSelectors.getProject, value: project }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, + ]; + + function setup(overrides: BaseSetupOverrides = {}) { + routerMock = RouterMockBuilder.create() + .withCreateUrlTree(vi.fn().mockReturnValue(mockUrlTree)) + .withSerializeUrl(vi.fn().mockReturnValue('/comp-a')) + .build(); customDialogService = CustomDialogServiceMockBuilder.create().build(); - loaderService = new LoaderServiceMock(); toastService = ToastServiceMock.simple(); - - const defaultSelectors = [ - { selector: ProjectOverviewSelectors.getComponents, value: components }, - { selector: ProjectOverviewSelectors.getComponentsLoading, value: false }, - { selector: ProjectOverviewSelectors.getComponentsSubmitting, value: false }, - { selector: ProjectOverviewSelectors.hasMoreComponents, value: true }, - { selector: ProjectOverviewSelectors.getProject, value: project }, - { selector: UserSelectors.isProjectCreationDisabled, value: false }, - ]; - const signals = mergeSignalOverrides(defaultSelectors, overrides.selectors); + deleteResourceService = DeleteResourceServiceMock.simple(); TestBed.configureTestingModule({ imports: [OverviewComponentsComponent, MockComponent(ComponentCardComponent)], @@ -75,11 +82,9 @@ describe('OverviewComponentsComponent', () => { provideOSFCore(), MockProvider(Router, routerMock), MockProvider(CustomDialogService, customDialogService), - MockProvider(LoaderService, loaderService), MockProvider(ToastService, toastService), - provideMockStore({ - signals, - }), + MockProvider(DeleteResourceService, deleteResourceService), + provideMockStore({ signals: mergeSignalOverrides(defaultSignals, overrides.selectorOverrides) }), ], }); @@ -92,16 +97,43 @@ describe('OverviewComponentsComponent', () => { it('should create', () => { setup(); + expect(component).toBeTruthy(); }); it('should initialize reorderedComponents from components selector', () => { setup(); + expect(component.reorderedComponents()).toEqual(components); }); + it('should disable drag when user cannot edit', () => { + setup(); + fixture.componentRef.setInput('canEdit', false); + fixture.detectChanges(); + + expect(component.isDragDisabled()).toBe(true); + }); + + it('should disable drag while components are submitting', () => { + setup({ + selectorOverrides: [{ selector: ProjectOverviewSelectors.getComponentsSubmitting, value: true }], + }); + + expect(component.isDragDisabled()).toBe(true); + }); + + it('should disable drag when there is only one component', () => { + setup({ + selectorOverrides: [{ selector: ProjectOverviewSelectors.getComponents, value: [componentA] }], + }); + + expect(component.isDragDisabled()).toBe(true); + }); + it('should open add component dialog', () => { setup(); + component.handleAddComponent(); expect(customDialogService.open).toHaveBeenCalledWith(AddComponentDialogComponent, { @@ -110,42 +142,59 @@ describe('OverviewComponentsComponent', () => { }); }); + it('should not open add component dialog when project creation is disabled', () => { + setup({ + selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], + }); + + component.handleAddComponent(); + + expect(component.preventComponentCreation()).toBe(true); + expect(component.createComponentTooltip()).toBe('project.overview.components.addComponentDisabled'); + expect(customDialogService.open).not.toHaveBeenCalled(); + }); + it('should navigate for manageContributors action', () => { setup(); - component.handleMenuAction('manageContributors', 'comp-a'); + + component.handleMenuAction(NodeMenuAction.ManageContributors, 'comp-a'); expect(routerMock.navigate).toHaveBeenCalledWith(['comp-a', 'contributors']); }); it('should navigate for settings action', () => { setup(); - component.handleMenuAction('settings', 'comp-a'); + + component.handleMenuAction(NodeMenuAction.Settings, 'comp-a'); expect(routerMock.navigate).toHaveBeenCalledWith(['comp-a', 'settings']); }); - it('should open delete component dialog through delete menu action', () => { + it('should run delete resource flow through delete menu action', () => { setup(); - component.handleMenuAction('delete', 'comp-a'); - - expect(loaderService.show).toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith(new GetResourceWithChildren('root-1', 'comp-a', ResourceType.Project)); - expect(customDialogService.open).toHaveBeenCalledWith(DeleteComponentDialogComponent, { - header: 'project.overview.dialog.deleteComponent.header', - width: '650px', - data: { componentId: 'comp-a', resourceType: ResourceType.Project }, - }); - expect(loaderService.hide).toHaveBeenCalled(); + (store.dispatch as Mock).mockClear(); + + component.handleMenuAction(NodeMenuAction.Delete, 'comp-a'); + deleteResourceService.deleteComponent.mock.calls[0][0].onSuccess(); + + expect(deleteResourceService.deleteComponent).toHaveBeenCalledWith( + expect.objectContaining({ + rootParentId: 'root-1', + resourceId: 'comp-a', + }) + ); + expect(store.dispatch).toHaveBeenCalledWith(new GetComponents('project-1')); }); it('should open component url in same tab on navigate', () => { setup(); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); - vi.spyOn(routerMock, 'createUrlTree').mockReturnValue({} as any); - vi.spyOn(routerMock, 'serializeUrl').mockReturnValue('/comp-a'); component.handleComponentNavigate('comp-a'); + expect(routerMock.createUrlTree).toHaveBeenCalledWith(['/', 'comp-a'], { queryParamsHandling: 'preserve' }); + expect(routerMock.serializeUrl).toHaveBeenCalledWith(mockUrlTree); expect(window.open).toHaveBeenCalledWith('/comp-a', '_self'); openSpy.mockRestore(); }); @@ -183,12 +232,14 @@ describe('OverviewComponentsComponent', () => { expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(ReorderComponents)); }); - it('should disable add component button and show tooltip when isProjectCreationDisabled flag is true', () => { - setup({ - selectors: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], - }); + it('should restore previous order when reorder fails', () => { + setup(); + (store.dispatch as Mock).mockReturnValueOnce(throwError(() => new Error('reorder failed'))); + const event = { previousIndex: 0, currentIndex: 1 } as CdkDragDrop; - expect(component.preventComponentCreation()).toBe(true); - expect(component.createComponentTooltip()).toBe('project.overview.components.addComponentDisabled'); + component.onReorder(event); + + expect(component.reorderedComponents().map((c) => c.id)).toEqual(['comp-a', 'comp-b']); + expect(toastService.showSuccess).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.ts b/src/app/features/project/overview/components/overview-components/overview-components.component.ts index e7b7bafd4..9ae653d50 100644 --- a/src/app/features/project/overview/components/overview-components/overview-components.component.ts +++ b/src/app/features/project/overview/components/overview-components/overview-components.component.ts @@ -7,21 +7,19 @@ import { Skeleton } from 'primeng/skeleton'; import { Tooltip } from 'primeng/tooltip'; import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop'; -import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, input, signal } from '@angular/core'; import { Router } from '@angular/router'; import { UserSelectors } from '@osf/core/store/user'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { DeleteResourceService } from '@osf/features/project/settings/services/delete-resource.service'; +import { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import { LoaderService } from '@osf/shared/services/loader.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { GetResourceWithChildren } from '@osf/shared/stores/current-resource'; -import { LoadMoreComponents, ProjectOverviewSelectors, ReorderComponents } from '../../store'; +import { GetComponents, LoadMoreComponents, ProjectOverviewSelectors, ReorderComponents } from '../../store'; import { AddComponentDialogComponent } from '../add-component-dialog/add-component-dialog.component'; import { ComponentCardComponent } from '../component-card/component-card.component'; -import { DeleteComponentDialogComponent } from '../delete-component-dialog/delete-component-dialog.component'; @Component({ selector: 'osf-project-components', @@ -31,34 +29,35 @@ import { DeleteComponentDialogComponent } from '../delete-component-dialog/delet changeDetection: ChangeDetectionStrategy.OnPush, }) export class OverviewComponentsComponent { - private router = inject(Router); - private customDialogService = inject(CustomDialogService); - private loaderService = inject(LoaderService); - private toastService = inject(ToastService); - - canEdit = input.required(); - anonymous = input(false); - - components = select(ProjectOverviewSelectors.getComponents); - isComponentsLoading = select(ProjectOverviewSelectors.getComponentsLoading); - isComponentsSubmitting = select(ProjectOverviewSelectors.getComponentsSubmitting); - hasMoreComponents = select(ProjectOverviewSelectors.hasMoreComponents); - project = select(ProjectOverviewSelectors.getProject); - preventComponentCreation = select(UserSelectors.isProjectCreationDisabled); - - reorderedComponents = signal([]); - - actions = createDispatchMap({ - getComponentsTree: GetResourceWithChildren, + private readonly router = inject(Router); + private readonly customDialogService = inject(CustomDialogService); + private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + private readonly deleteResourceService = inject(DeleteResourceService); + + readonly canEdit = input.required(); + readonly anonymous = input(false); + + readonly components = select(ProjectOverviewSelectors.getComponents); + readonly isComponentsLoading = select(ProjectOverviewSelectors.getComponentsLoading); + readonly isComponentsSubmitting = select(ProjectOverviewSelectors.getComponentsSubmitting); + readonly hasMoreComponents = select(ProjectOverviewSelectors.hasMoreComponents); + readonly project = select(ProjectOverviewSelectors.getProject); + readonly preventComponentCreation = select(UserSelectors.isProjectCreationDisabled); + + private readonly actions = createDispatchMap({ + getComponents: GetComponents, loadMoreComponents: LoadMoreComponents, reorderComponents: ReorderComponents, }); - isDragDisabled = computed( - () => this.isComponentsSubmitting() || (!this.canEdit() && this.reorderedComponents().length <= 1) + readonly reorderedComponents = signal([]); + + readonly isDragDisabled = computed( + () => this.isComponentsSubmitting() || !this.canEdit() || this.reorderedComponents().length <= 1 ); - createComponentTooltip = computed(() => + readonly createComponentTooltip = computed(() => this.preventComponentCreation() ? 'project.overview.components.addComponentDisabled' : '' ); @@ -69,15 +68,15 @@ export class OverviewComponentsComponent { }); } - handleMenuAction(action: string, componentId: string): void { + handleMenuAction(action: NodeMenuAction, componentId: string): void { switch (action) { - case 'manageContributors': + case NodeMenuAction.ManageContributors: this.router.navigate([componentId, 'contributors']); break; - case 'settings': + case NodeMenuAction.Settings: this.router.navigate([componentId, 'settings']); break; - case 'delete': + case NodeMenuAction.Delete: this.handleDeleteComponent(componentId); break; } @@ -111,7 +110,8 @@ export class OverviewComponentsComponent { const project = this.project(); if (!project || !this.canEdit()) return; - const components = [...this.reorderedComponents()]; + const previousComponents = [...this.reorderedComponents()]; + const components = [...previousComponents]; moveItemInArray(components, event.previousIndex, event.currentIndex); this.reorderedComponents.set(components); @@ -120,6 +120,9 @@ export class OverviewComponentsComponent { next: () => { this.toastService.showSuccess('project.overview.dialog.toast.reorderComponents.success'); }, + error: () => { + this.reorderedComponents.set(previousComponents); + }, }); } @@ -127,18 +130,11 @@ export class OverviewComponentsComponent { const project = this.project(); if (!project) return; - this.loaderService.show(); - - this.actions.getComponentsTree(project.rootParentId || project.id, componentId, ResourceType.Project).subscribe({ - next: () => { - this.loaderService.hide(); - this.customDialogService.open(DeleteComponentDialogComponent, { - header: 'project.overview.dialog.deleteComponent.header', - width: '650px', - data: { componentId, resourceType: ResourceType.Project }, - }); - }, - error: () => this.loaderService.hide(), + this.deleteResourceService.deleteComponent({ + rootParentId: project.rootParentId || project.id, + resourceId: componentId, + destroyRef: this.destroyRef, + onSuccess: () => this.actions.getComponents(project.id), }); } } diff --git a/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.spec.ts b/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.spec.ts index ab020357e..2a610ef59 100644 --- a/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.spec.ts +++ b/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.spec.ts @@ -1,13 +1,13 @@ import { MockComponents, MockProvider } from 'ng-mocks'; -import { Mock } from 'vitest'; - import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Router } from '@angular/router'; +import { Router, UrlTree } from '@angular/router'; + +import { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { ComponentCardComponent } from '../component-card/component-card.component'; @@ -16,20 +16,16 @@ import { OverviewParentProjectComponent } from './overview-parent-project.compon describe('OverviewParentProjectComponent', () => { let component: OverviewParentProjectComponent; let fixture: ComponentFixture; - let createUrlTreeSpy: Mock; - let serializeUrlSpy: Mock; - let navigateSpy: Mock; - - beforeEach(() => { - const mockUrlTree = {} as any; - createUrlTreeSpy = vi.fn().mockReturnValue(mockUrlTree); - serializeUrlSpy = vi.fn().mockReturnValue('/test-id'); - navigateSpy = vi.fn().mockResolvedValue(true); + let routerMock: RouterMockType; - const routerMock = RouterMockBuilder.create().withCreateUrlTree(createUrlTreeSpy).build(); + const mockUrlTree = {} as UrlTree; - routerMock.serializeUrl = serializeUrlSpy; - routerMock.navigate = navigateSpy; + beforeEach(() => { + routerMock = RouterMockBuilder.create() + .withCreateUrlTree(vi.fn().mockReturnValue(mockUrlTree)) + .withSerializeUrl(vi.fn().mockReturnValue('/test-id-1')) + .withNavigate(vi.fn().mockResolvedValue(true)) + .build(); TestBed.configureTestingModule({ imports: [OverviewParentProjectComponent, ...MockComponents(ComponentCardComponent)], @@ -42,57 +38,46 @@ describe('OverviewParentProjectComponent', () => { fixture.detectChanges(); }); - it('should create URL tree with correct path and queryParamsHandling', () => { + it('should open the parent project in the same window', () => { const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); component.navigateToParent(); - expect(createUrlTreeSpy).toHaveBeenCalledWith(['/', MOCK_NODE_WITH_ADMIN.id], { + expect(routerMock.createUrlTree).toHaveBeenCalledWith(['/', MOCK_NODE_WITH_ADMIN.id], { queryParamsHandling: 'preserve', }); - - windowOpenSpy.mockRestore(); - }); - - it('should serialize URL tree and open in same window', () => { - const mockUrlTree = {} as any; - createUrlTreeSpy.mockReturnValue(mockUrlTree); - const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); - - component.navigateToParent(); - - expect(serializeUrlSpy).toHaveBeenCalledWith(mockUrlTree); - expect(windowOpenSpy).toHaveBeenCalledWith('/test-id', '_self'); + expect(routerMock.serializeUrl).toHaveBeenCalledWith(mockUrlTree); + expect(windowOpenSpy).toHaveBeenCalledWith('/test-id-1', '_self'); windowOpenSpy.mockRestore(); }); it('should navigate to contributors route when action is manageContributors', () => { - component.handleMenuAction('manageContributors'); + component.handleMenuAction(NodeMenuAction.ManageContributors); - expect(navigateSpy).toHaveBeenCalledWith([MOCK_NODE_WITH_ADMIN.id, 'contributors']); + expect(routerMock.navigate).toHaveBeenCalledWith([MOCK_NODE_WITH_ADMIN.id, 'contributors']); }); it('should navigate to settings route when action is settings', () => { - component.handleMenuAction('settings'); + component.handleMenuAction(NodeMenuAction.Settings); - expect(navigateSpy).toHaveBeenCalledWith([MOCK_NODE_WITH_ADMIN.id, 'settings']); + expect(routerMock.navigate).toHaveBeenCalledWith([MOCK_NODE_WITH_ADMIN.id, 'settings']); }); - it('should return early if projectId is undefined', () => { - fixture.componentRef.setInput('project', { ...MOCK_NODE_WITH_ADMIN, id: undefined } as any); + it('should not navigate when the project id is empty', () => { + fixture.componentRef.setInput('project', { ...MOCK_NODE_WITH_ADMIN, id: '' }); fixture.detectChanges(); - component.handleMenuAction('manageContributors'); + component.handleMenuAction(NodeMenuAction.ManageContributors); - expect(navigateSpy).not.toHaveBeenCalled(); + expect(routerMock.navigate).not.toHaveBeenCalled(); }); - it('should not navigate for unknown actions', () => { - navigateSpy.mockClear(); - - component.handleMenuAction('unknownAction'); + it('should show a skeleton while the parent project is loading', () => { + fixture.componentRef.setInput('isLoading', true); + fixture.detectChanges(); - expect(navigateSpy).not.toHaveBeenCalled(); + expect(fixture.nativeElement.querySelector('p-skeleton')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('osf-component-card')).toBeNull(); }); }); diff --git a/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.ts b/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.ts index 406ff4391..c85cc8a29 100644 --- a/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.ts +++ b/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.ts @@ -5,6 +5,7 @@ import { Skeleton } from 'primeng/skeleton'; import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; import { Router } from '@angular/router'; +import { NodeMenuAction } from '@osf/shared/enums/node-menu-action.enum'; import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { ComponentCardComponent } from '../component-card/component-card.component'; @@ -31,18 +32,18 @@ export class OverviewParentProjectComponent { window.open(url, '_self'); } - handleMenuAction(action: string): void { - const projectId = this.project()?.id; + handleMenuAction(action: NodeMenuAction): void { + const projectId = this.project().id; if (!projectId) { return; } switch (action) { - case 'manageContributors': + case NodeMenuAction.ManageContributors: this.router.navigate([projectId, 'contributors']); break; - case 'settings': + case NodeMenuAction.Settings: this.router.navigate([projectId, 'settings']); break; } diff --git a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html index 9eb6149d4..1412e6ed5 100644 --- a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html +++ b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html @@ -40,9 +40,11 @@
- @if (storage()) { + @let storageData = storage(); + + @if (storageData) {

- {{ +storage()!.storageUsage | fileSize }} + {{ +storageData.storageUsage | fileSize }}

} @@ -62,39 +64,13 @@ } @if (!viewOnly()) { - @if (preventDuplicateCreation()) { - - {{ resource.forksCount }} - - - } @else { - - {{ resource.forksCount }} - - - - - {{ item.label | translate }} - - - - - } - } + - @if (!viewOnly()) { } diff --git a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts index d31f29dbf..6cdd2a3fe 100644 --- a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts +++ b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts @@ -2,65 +2,93 @@ import { Store } from '@ngxs/store'; import { MockComponents, MockProvider } from 'ng-mocks'; -import { of } from 'rxjs'; +import { Subject } from 'rxjs'; + +import { Mock } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, provideRouter, Router } from '@angular/router'; import { UserSelectors } from '@core/store/user'; -import { ProjectOverviewSelectors } from '@osf/features/project/overview/store'; +import { ClearDuplicatedProject, ForkProject, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; +import { ForkMenuButtonComponent } from '@osf/shared/components/fork-menu-button/fork-menu-button.component'; import { SocialsShareButtonComponent } from '@osf/shared/components/socials-share-button/socials-share-button.component'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources-item.model'; import { NodeStorageModel } from '@osf/shared/models/nodes/node-storage.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { ForkDialogService } from '@osf/shared/services/fork-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { BookmarksSelectors, GetResourceBookmark } from '@osf/shared/stores/bookmarks'; +import { + AddResourceToBookmarks, + BookmarksSelectors, + GetResourceBookmark, + RemoveResourceFromBookmarks, +} from '@osf/shared/stores/bookmarks'; import { MOCK_PROJECT_OVERVIEW } from '@testing/mocks/project-overview.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { + CustomDialogServiceMock, + CustomDialogServiceMockBuilder, +} from '@testing/providers/custom-dialog-provider.mock'; +import { ForkDialogServiceMock, ForkDialogServiceMockType } from '@testing/providers/fork-dialog.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 { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; import { ProjectOverviewModel } from '../../models'; +import { DuplicateDialogComponent } from '../duplicate-dialog/duplicate-dialog.component'; import { TogglePublicityDialogComponent } from '../toggle-publicity-dialog/toggle-publicity-dialog.component'; import { ProjectOverviewToolbarComponent } from './project-overview-toolbar.component'; interface SetupOverrides extends BaseSetupOverrides { - selectors?: any[]; + isPublic?: boolean; } describe('ProjectOverviewToolbarComponent', () => { let component: ProjectOverviewToolbarComponent; let fixture: ComponentFixture; let store: Store; - let routerMock: ReturnType; - let activatedRouteMock: ReturnType; + let routerMock: RouterMockType; let customDialogServiceMock: ReturnType; let toastService: ToastServiceMockType; - - const mockResource: ProjectOverviewModel = { - ...MOCK_PROJECT_OVERVIEW, - id: 'project-123', - title: 'Test Project', - isPublic: true, - } as ProjectOverviewModel; + let forkDialogService: ForkDialogServiceMockType; const mockStorage: NodeStorageModel = { id: 'storage-123', storageLimitStatus: 'ok', - storageUsage: '500MB', + storageUsage: '1500', + }; + + const bookmarkedItem: MyResourcesItem = { + id: 'project-123', + dateCreated: '2023-01-01', + dateModified: '2023-01-01', + isPublic: true, + contributors: [], + title: 'Test Project', + type: 'nodes', }; function setup(overrides: SetupOverrides = {}) { routerMock = RouterMockBuilder.create().build(); - activatedRouteMock = ActivatedRouteMockBuilder.create().build(); customDialogServiceMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); toastService = ToastServiceMock.simple(); - const defaultSelectors = [ + forkDialogService = ForkDialogServiceMock.simple(); + + const resource: ProjectOverviewModel = { + ...MOCK_PROJECT_OVERVIEW, + id: 'project-123', + title: 'Test Project', + isPublic: overrides.isPublic ?? true, + forksCount: 4, + viewOnlyLinksCount: 2, + }; + + const defaultSignals = [ { selector: BookmarksSelectors.getBookmarksCollectionId, value: 'bookmarks-123' }, { selector: BookmarksSelectors.getBookmarks, value: [] }, { selector: BookmarksSelectors.areBookmarksLoading, value: false }, @@ -70,207 +98,224 @@ describe('ProjectOverviewToolbarComponent', () => { { selector: UserSelectors.isProjectCreationDisabled, value: false }, { selector: UserSelectors.isProjectReadOnly, value: false }, ]; - const signals = mergeSignalOverrides(defaultSelectors, overrides.selectors); + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); TestBed.configureTestingModule({ - imports: [ProjectOverviewToolbarComponent, ...MockComponents(SocialsShareButtonComponent)], + imports: [ + ProjectOverviewToolbarComponent, + ...MockComponents(SocialsShareButtonComponent, ForkMenuButtonComponent), + ], providers: [ provideOSFCore(), - provideMockStore({ - signals, - }), + provideRouter([]), + provideMockStore({ signals }), MockProvider(Router, routerMock), - MockProvider(ActivatedRoute, activatedRouteMock), + MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().build()), MockProvider(CustomDialogService, customDialogServiceMock), MockProvider(ToastService, toastService), + MockProvider(ForkDialogService, forkDialogService), ], }); store = TestBed.inject(Store); - store.dispatch = vi.fn().mockReturnValue(of(true)); fixture = TestBed.createComponent(ProjectOverviewToolbarComponent); component = fixture.componentInstance; fixture.componentRef.setInput('canEdit', true); - fixture.componentRef.setInput('currentResource', mockResource); + fixture.componentRef.setInput('currentResource', resource); fixture.componentRef.setInput('storage', mockStorage); fixture.componentRef.setInput('viewOnly', false); + fixture.detectChanges(); } - it('should create', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('should read preventDuplicateCreation from the user state', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }] }); + + expect(component.preventDuplicateCreation()).toBe(true); + }); + + it('should dispatch GetResourceBookmark on init', () => { setup(); - expect(component).toBeTruthy(); + + expect(store.dispatch).toHaveBeenCalledWith( + new GetResourceBookmark('bookmarks-123', 'project-123', ResourceType.Project) + ); }); - describe('Input Bindings', () => { - it('should set canEdit input correctly', () => { - setup(); - fixture.componentRef.setInput('canEdit', false); - fixture.detectChanges(); + it('should not dispatch GetResourceBookmark without a bookmarks collection id', () => { + setup({ selectorOverrides: [{ selector: BookmarksSelectors.getBookmarksCollectionId, value: null }] }); - expect(component.canEdit()).toBe(false); - }); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetResourceBookmark)); + }); - it('should set currentResource input correctly', () => { - setup(); - expect(component.currentResource()).toEqual(mockResource); - }); + it('should mark the project as bookmarked when it is in the bookmarks list', () => { + setup({ selectorOverrides: [{ selector: BookmarksSelectors.getBookmarks, value: [bookmarkedItem] }] }); - it('should set storage input correctly', () => { - setup(); - expect(component.storage()).toEqual(mockStorage); - }); + expect(component.isBookmarked()).toBe(true); + }); - it('should default viewOnly to false', () => { - setup(); - expect(component.viewOnly()).toBe(false); + it('should leave the project unbookmarked when it is absent from the bookmarks list', () => { + setup({ + selectorOverrides: [{ selector: BookmarksSelectors.getBookmarks, value: [{ ...bookmarkedItem, id: 'other' }] }], }); - it('should set viewOnly input correctly', () => { - setup(); - fixture.componentRef.setInput('viewOnly', true); - fixture.detectChanges(); + expect(component.isBookmarked()).toBe(false); + }); - expect(component.viewOnly()).toBe(true); - }); + it('should add a bookmark and show a toast when the project is not bookmarked', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.toggleBookmark(); + + expect(store.dispatch).toHaveBeenCalledWith( + new AddResourceToBookmarks('bookmarks-123', 'project-123', ResourceType.Project) + ); + expect(component.isBookmarked()).toBe(true); + expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.bookmark.add'); }); - describe('Effects', () => { - it('should set isPublic from currentResource', () => { - setup(); - fixture.detectChanges(); + it('should remove a bookmark and show a toast when the project is bookmarked', () => { + setup({ selectorOverrides: [{ selector: BookmarksSelectors.getBookmarks, value: [bookmarkedItem] }] }); + (store.dispatch as Mock).mockClear(); - expect(component.isPublic()).toBe(true); - }); + component.toggleBookmark(); - it('should dispatch getResourceBookmark when bookmarksId and resource exist', () => { - setup(); - fixture.detectChanges(); + expect(store.dispatch).toHaveBeenCalledWith( + new RemoveResourceFromBookmarks('bookmarks-123', 'project-123', ResourceType.Project) + ); + expect(component.isBookmarked()).toBe(false); + expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.bookmark.remove'); + }); - expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetResourceBookmark)); - }); + it('should not toggle a bookmark without a bookmarks collection id', () => { + setup({ selectorOverrides: [{ selector: BookmarksSelectors.getBookmarksCollectionId, value: null }] }); + (store.dispatch as Mock).mockClear(); + + component.toggleBookmark(); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); }); - describe('handleToggleProjectPublicity', () => { - it('should open TogglePublicityDialogComponent with makePrivate header when project is public', () => { - setup(); - fixture.detectChanges(); + it('should open the make-private dialog for a public project', () => { + vi.useFakeTimers(); + setup(); - component.handleToggleProjectPublicity(); + component.handleToggleProjectPublicity(); + vi.advanceTimersByTime(100); - expect(customDialogServiceMock.open).toHaveBeenCalledWith(TogglePublicityDialogComponent, { - header: 'project.overview.dialog.makePrivate.header', - width: '600px', - data: { - projectId: 'project-123', - isCurrentlyPublic: true, - }, - }); + expect(customDialogServiceMock.open).toHaveBeenCalledWith(TogglePublicityDialogComponent, { + header: 'project.overview.dialog.makePrivate.header', + width: '600px', + data: { + projectId: 'project-123', + isCurrentlyPublic: true, + }, }); + expect(component.isPublic()).toBe(true); + }); - it('should open TogglePublicityDialogComponent with makePublic header when project is private', () => { - setup(); - fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false }); - fixture.detectChanges(); + it('should open the make-public dialog for a private project', () => { + vi.useFakeTimers(); + setup({ isPublic: false }); - component.handleToggleProjectPublicity(); + component.handleToggleProjectPublicity(); + vi.advanceTimersByTime(100); - expect(customDialogServiceMock.open).toHaveBeenCalledWith(TogglePublicityDialogComponent, { - header: 'project.overview.dialog.makePublic.header', - width: '600px', - data: { - projectId: 'project-123', - isCurrentlyPublic: false, - }, - }); + expect(customDialogServiceMock.open).toHaveBeenCalledWith(TogglePublicityDialogComponent, { + header: 'project.overview.dialog.makePublic.header', + width: '600px', + data: { + projectId: 'project-123', + isCurrentlyPublic: false, + }, }); + expect(component.isPublic()).toBe(false); + }); - it('should not open dialog when resource is null', () => { - setup(); - fixture.componentRef.setInput('currentResource', null as any); - fixture.detectChanges(); + it('should disable the privacy toggle when a public project is read only', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); - component.handleToggleProjectPublicity(); + expect(component.disableProjectPrivacyToggle()).toBe(true); + expect(component.projectReadOnlyTooltip()).toBe('common.errorMessages.actionUnavailable'); + }); - expect(customDialogServiceMock.open).not.toHaveBeenCalled(); + it('should keep the privacy toggle enabled when a private project is read only', () => { + setup({ + isPublic: false, + selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }], }); - it('should compute disableProjectPrivacyToggle when isProjectReadOnly is false', () => { - setup(); - fixture.detectChanges(); + expect(component.disableProjectPrivacyToggle()).toBe(false); + expect(component.projectReadOnlyTooltip()).toBe(''); + }); - expect(component.isPublic()).toBe(true); - expect(component.disableProjectPrivacyToggle()).toBe(false); + it('should navigate to the duplicates view', () => { + setup(); - fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false }); - fixture.detectChanges(); + component.forkActionItems[2].command?.({}); - expect(component.isPublic()).toBe(false); - expect(component.disableProjectPrivacyToggle()).toBe(false); + expect(routerMock.navigate).toHaveBeenCalledWith(['../analytics/duplicates'], { + relativeTo: TestBed.inject(ActivatedRoute), }); + }); - it('should compute disableProjectPrivacyToggle when isProjectReadOnly is true', () => { - setup({ selectors: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); - fixture.detectChanges(); - - expect(component.isPublic()).toBe(true); - expect(component.disableProjectPrivacyToggle()).toBe(true); + it('should open fork confirmation for a project', () => { + setup(); - fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false }); - fixture.detectChanges(); + component.forkActionItems[0].command?.({}); - expect(component.isPublic()).toBe(false); - expect(component.disableProjectPrivacyToggle()).toBe(false); - }); + expect(forkDialogService.confirmFork).toHaveBeenCalledWith( + expect.objectContaining({ resourceType: CurrentResourceType.Projects }) + ); }); - describe('Properties', () => { - it('should have ResourceType property', () => { - setup(); - expect(component.ResourceType).toBe(ResourceType); - }); + it('should dispatch ForkProject when the fork is confirmed', () => { + setup(); + component.forkActionItems[0].command?.({}); + const [options] = forkDialogService.confirmFork.mock.calls[0]; + (store.dispatch as Mock).mockClear(); - it('should have resourceType set to Project', () => { - setup(); - expect(component.resourceType).toBe(ResourceType.Project); - }); + options.fork(); + + expect(store.dispatch).toHaveBeenCalledWith(new ForkProject('project-123')); }); - describe('preventDuplicateCreation', () => { - it('should return false when isProjectCreationDisabled is false', () => { - setup(); - expect(component.preventDuplicateCreation()).toBe(false); + it('should navigate to the duplicated project when the dialog closes', () => { + setup({ + selectorOverrides: [ + { + selector: ProjectOverviewSelectors.getDuplicatedProject, + value: { ...MOCK_PROJECT_OVERVIEW, id: 'copy-1' }, + }, + ], }); + const onClose$ = new Subject(); + customDialogServiceMock.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + + component.forkActionItems[1].command?.({}); + onClose$.next(); - it('should return true when isProjectCreationDisabled is true', () => { - setup({ - selectors: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], - }); - fixture.detectChanges(); - expect(component.preventDuplicateCreation()).toBe(true); + expect(customDialogServiceMock.open).toHaveBeenCalledWith(DuplicateDialogComponent, { + header: 'project.overview.dialog.duplicate.header', }); + expect(routerMock.navigate).toHaveBeenCalledWith(['/', 'copy-1']); }); - describe('projectReadOnlyTooltip', () => { - it('should return empty string when isProjectReadOnly is false', () => { - setup(); - expect(component.projectReadOnlyTooltip()).toBe(''); - - fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false }); - fixture.detectChanges(); - expect(component.projectReadOnlyTooltip()).toBe(''); - }); + it('should clear the duplicated project when the dialog completes', () => { + setup(); + const onClose$ = new Subject(); + customDialogServiceMock.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + (store.dispatch as Mock).mockClear(); - it('should return tooltip message when isProjectReadOnly is true', () => { - setup({ - selectors: [{ selector: UserSelectors.isProjectReadOnly, value: true }], - }); - fixture.detectChanges(); - expect(component.projectReadOnlyTooltip()).toBe('common.errorMessages.actionUnavailable'); + component.forkActionItems[1].command?.({}); + onClose$.complete(); - fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false }); - fixture.detectChanges(); - expect(component.projectReadOnlyTooltip()).toBe(''); - }); + expect(store.dispatch).toHaveBeenCalledWith(new ClearDuplicatedProject()); }); }); diff --git a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts index 01a7d2710..4a5b9d77d 100644 --- a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts +++ b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts @@ -2,8 +2,8 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; +import { MenuItem } from 'primeng/api'; import { Button } from 'primeng/button'; -import { Menu } from 'primeng/menu'; import { ToggleSwitch } from 'primeng/toggleswitch'; import { Tooltip } from 'primeng/tooltip'; @@ -15,12 +15,14 @@ import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { UserSelectors } from '@core/store/user'; -import { ClearDuplicatedProject, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; +import { ClearDuplicatedProject, ForkProject, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; +import { ForkMenuButtonComponent } from '@osf/shared/components/fork-menu-button/fork-menu-button.component'; import { SocialsShareButtonComponent } from '@osf/shared/components/socials-share-button/socials-share-button.component'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; import { NodeStorageModel } from '@osf/shared/models/nodes/node-storage.model'; import { FileSizePipe } from '@osf/shared/pipes/file-size.pipe'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { ForkDialogService } from '@osf/shared/services/fork-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { AddResourceToBookmarks, @@ -31,7 +33,6 @@ import { import { ProjectOverviewModel } from '../../models'; import { DuplicateDialogComponent } from '../duplicate-dialog/duplicate-dialog.component'; -import { ForkDialogComponent } from '../fork-dialog/fork-dialog.component'; import { TogglePublicityDialogComponent } from '../toggle-publicity-dialog/toggle-publicity-dialog.component'; @Component({ @@ -39,63 +40,63 @@ import { TogglePublicityDialogComponent } from '../toggle-publicity-dialog/toggl imports: [ ToggleSwitch, TranslatePipe, - Menu, Button, Tooltip, FormsModule, RouterLink, FileSizePipe, SocialsShareButtonComponent, + ForkMenuButtonComponent, ], templateUrl: './project-overview-toolbar.component.html', styleUrl: './project-overview-toolbar.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProjectOverviewToolbarComponent { - private customDialogService = inject(CustomDialogService); - private toastService = inject(ToastService); - private destroyRef = inject(DestroyRef); - + private readonly customDialogService = inject(CustomDialogService); + private readonly forkDialogService = inject(ForkDialogService); + private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); - canEdit = input.required(); - storage = input(); - viewOnly = input(false); - currentResource = input.required(); + readonly canEdit = input.required(); + readonly storage = input(); + readonly viewOnly = input(false); + readonly currentResource = input.required(); isPublic = signal(false); isBookmarked = signal(false); - resourceType = ResourceType.Project; - bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId); - bookmarks = select(BookmarksSelectors.getBookmarks); - isBookmarksLoading = select(BookmarksSelectors.areBookmarksLoading); - isBookmarksSubmitting = select(BookmarksSelectors.getBookmarksCollectionIdSubmitting); + readonly resourceType = ResourceType.Project; - duplicatedProject = select(ProjectOverviewSelectors.getDuplicatedProject); - isAuthenticated = select(UserSelectors.isAuthenticated); - preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled); - isProjectReadOnly = select(UserSelectors.isProjectReadOnly); + readonly bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId); + readonly bookmarks = select(BookmarksSelectors.getBookmarks); + readonly isBookmarksLoading = select(BookmarksSelectors.areBookmarksLoading); + readonly isBookmarksSubmitting = select(BookmarksSelectors.getBookmarksCollectionIdSubmitting); - disableProjectPrivacyToggle = computed(() => this.isProjectReadOnly() && this.isPublic()); - projectReadOnlyTooltip = computed(() => - this.disableProjectPrivacyToggle() ? 'common.errorMessages.actionUnavailable' : '' - ); + readonly duplicatedProject = select(ProjectOverviewSelectors.getDuplicatedProject); + readonly isAuthenticated = select(UserSelectors.isAuthenticated); + readonly preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled); + readonly isProjectReadOnly = select(UserSelectors.isProjectReadOnly); - actions = createDispatchMap({ + readonly actions = createDispatchMap({ getResourceBookmark: GetResourceBookmark, addResourceToBookmarks: AddResourceToBookmarks, removeResourceFromBookmarks: RemoveResourceFromBookmarks, clearDuplicatedProject: ClearDuplicatedProject, + forkProject: ForkProject, }); - readonly ResourceType = ResourceType; + readonly disableProjectPrivacyToggle = computed(() => this.isProjectReadOnly() && this.isPublic()); + readonly projectReadOnlyTooltip = computed(() => + this.disableProjectPrivacyToggle() ? 'common.errorMessages.actionUnavailable' : '' + ); - readonly forkActionItems = [ + readonly forkActionItems: MenuItem[] = [ { label: 'project.overview.actions.forkProject', - command: () => this.handleForkResource(), + command: () => this.handleForkProject(), }, { label: 'project.overview.actions.duplicateProject', @@ -186,21 +187,26 @@ export class ProjectOverviewToolbarComponent { } } - private handleForkResource(): void { + navigateToDuplicatesView(): void { + this.router.navigate(['../analytics/duplicates'], { relativeTo: this.route }); + } + + private handleForkProject(): void { const resource = this.currentResource(); + if (!resource) return; - if (resource) { - this.customDialogService.open(ForkDialogComponent, { - header: 'project.overview.dialog.fork.headerProject', - data: { resourceId: resource.id, resourceType: this.resourceType }, - }); - } + this.forkDialogService.confirmFork({ + resourceType: CurrentResourceType.Projects, + destroyRef: this.destroyRef, + fork: () => this.actions.forkProject(resource.id), + }); } private handleDuplicateProject(): void { this.customDialogService .open(DuplicateDialogComponent, { header: 'project.overview.dialog.duplicate.header' }) - .onClose.subscribe({ + .onClose.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ next: () => { const duplicatedProject = this.duplicatedProject(); if (duplicatedProject) { @@ -210,8 +216,4 @@ export class ProjectOverviewToolbarComponent { complete: () => this.actions.clearDuplicatedProject(), }); } - - navigateToDuplicatesView(): void { - this.router.navigate(['../analytics/duplicates'], { relativeTo: this.route }); - } } diff --git a/src/app/features/project/overview/services/index.ts b/src/app/features/project/overview/services/index.ts deleted file mode 100644 index 0b2facadf..000000000 --- a/src/app/features/project/overview/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './project-overview.service'; diff --git a/src/app/features/project/overview/services/project-overview.service.spec.ts b/src/app/features/project/overview/services/project-overview.service.spec.ts new file mode 100644 index 000000000..314248cf7 --- /dev/null +++ b/src/app/features/project/overview/services/project-overview.service.spec.ts @@ -0,0 +1,334 @@ +import { MockProvider } from 'ng-mocks'; + +import { firstValueFrom, of } from 'rxjs'; + +import { HttpContext } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; + +import { BYPASS_ERROR_INTERCEPTOR } from '@core/interceptors/error-interceptor.tokens'; +import { JSON_API_BULK_HEADERS } from '@osf/shared/constants/json-api-headers.const'; +import { IdentifiersMapper } from '@osf/shared/mappers/identifiers.mapper'; +import { InstitutionsMapper } from '@osf/shared/mappers/institutions'; +import { LicensesMapper } from '@osf/shared/mappers/licenses.mapper'; +import { BaseNodeMapper } from '@osf/shared/mappers/nodes'; +import { NodePreprintMapper } from '@osf/shared/mappers/nodes/node-preprint.mapper'; +import { NodeStorageMapper } from '@osf/shared/mappers/nodes/node-storage.mapper'; +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 { BaseNodeDataJsonApi } from '@osf/shared/models/nodes/base-node-data-json-api.model'; +import { NodePreprintsResponseJsonApi } from '@osf/shared/models/nodes/node-preprint-json-api.model'; +import { NodeStorageModel } from '@osf/shared/models/nodes/node-storage.model'; +import { NodeResponseJsonApi, NodesResponseJsonApi } from '@osf/shared/models/nodes/nodes-json-api.model'; +import { PaginatedData } from '@osf/shared/models/paginated-data.model'; +import { JsonApiService } from '@osf/shared/services/json-api.service'; +import { LicenseModel } from '@shared/models/license/license.model'; + +import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; +import { MOCK_NODE_PREPRINTS } from '@testing/mocks/node-preprint.mock'; +import { + MOCK_PROJECT_AFFILIATED_INSTITUTIONS, + MOCK_PROJECT_IDENTIFIERS, + MOCK_PROJECT_OVERVIEW, +} from '@testing/mocks/project-overview.mock'; +import { MOCK_API_URL, provideMockEnvironment } from '@testing/providers/environment.provider.mock'; +import { JsonApiServiceMock, JsonApiServiceMockType } from '@testing/providers/json-api.service.mock'; + +import { ProjectOverviewMapper } from '../mappers'; +import { PrivacyStatusModel } from '../models'; + +import { ProjectOverviewService } from './project-overview.service'; + +describe('ProjectOverviewService', () => { + let service: ProjectOverviewService; + let jsonApiService: JsonApiServiceMockType; + + const projectId = 'project-1'; + const nodeData = { id: projectId, type: 'nodes' } as BaseNodeDataJsonApi; + const nodeResponse = { data: nodeData, meta: { anonymous: false } } as NodeResponseJsonApi; + + function setup() { + jsonApiService = JsonApiServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [ProjectOverviewService, MockProvider(JsonApiService, jsonApiService), provideMockEnvironment()], + }); + + service = TestBed.inject(ProjectOverviewService); + } + + it('should expose apiUrl from environment', () => { + setup(); + expect(service.apiUrl).toBe(MOCK_API_URL); + }); + + it('getProjectById should request node with related counts and map response', async () => { + setup(); + const mapperSpy = vi.spyOn(ProjectOverviewMapper, 'getProjectOverview').mockReturnValue(MOCK_PROJECT_OVERVIEW); + jsonApiService.get.mockReturnValue(of(nodeResponse)); + + const response = await firstValueFrom(service.getProjectById(projectId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/${projectId}/`, { + related_counts: 'forks,view_only_links', + }); + expect(mapperSpy).toHaveBeenCalledWith(nodeData); + expect(response).toEqual({ + data: MOCK_PROJECT_OVERVIEW, + totalCount: 1, + pageSize: 1, + isAnonymous: false, + }); + }); + + it('getProjectById should default isAnonymous to false when meta is missing', async () => { + setup(); + vi.spyOn(ProjectOverviewMapper, 'getProjectOverview').mockReturnValue(MOCK_PROJECT_OVERVIEW); + jsonApiService.get.mockReturnValue(of({ data: nodeData } as NodeResponseJsonApi)); + + const response = await firstValueFrom(service.getProjectById(projectId)); + + expect(response.isAnonymous).toBe(false); + }); + + it('getProjectInstitutions should request institutions and map response', async () => { + setup(); + const apiResponse = { data: [] } as unknown as InstitutionsJsonApiResponse; + const mappedInstitutions = MOCK_PROJECT_AFFILIATED_INSTITUTIONS as unknown as Institution[]; + const mapperSpy = vi.spyOn(InstitutionsMapper, 'fromInstitutionsResponse').mockReturnValue(mappedInstitutions); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getProjectInstitutions(projectId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/${projectId}/institutions/`, { + 'page[size]': 100, + }); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual(mappedInstitutions); + }); + + it('getProjectIdentifiers should request identifiers and map response', async () => { + setup(); + const apiResponse = { data: [] } as unknown as IdentifiersResponseJsonApi; + const mapperSpy = vi.spyOn(IdentifiersMapper, 'fromJsonApi').mockReturnValue([MOCK_PROJECT_IDENTIFIERS]); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getProjectIdentifiers(projectId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/${projectId}/identifiers/`); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual([MOCK_PROJECT_IDENTIFIERS]); + }); + + it('getProjectLicense should request license and map response', async () => { + setup(); + const licenseId = 'license-1'; + const mappedLicense = { id: licenseId, name: 'MIT' } as LicenseModel; + const apiResponse = { data: { id: licenseId } } as LicenseResponseJsonApi; + const mapperSpy = vi.spyOn(LicensesMapper, 'fromLicenseDataJsonApi').mockReturnValue(mappedLicense); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getProjectLicense(licenseId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/licenses/${licenseId}/`); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse.data); + expect(response).toEqual(mappedLicense); + }); + + it('getProjectStorage should request storage and map response', async () => { + setup(); + const mappedStorage = { storageLimitStatus: 'DEFAULT', storageUsage: '1' } as NodeStorageModel; + const apiResponse = { data: { id: projectId } }; + const mapperSpy = vi.spyOn(NodeStorageMapper, 'getNodeStorage').mockReturnValue(mappedStorage); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getProjectStorage(projectId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/${projectId}/storage/`); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse.data); + expect(response).toEqual(mappedStorage); + }); + + it('getProjectPreprints should request preprints and map response', async () => { + setup(); + const apiResponse = { data: [] } as unknown as NodePreprintsResponseJsonApi; + const mapperSpy = vi.spyOn(NodePreprintMapper, 'getNodePreprints').mockReturnValue(MOCK_NODE_PREPRINTS); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getProjectPreprints(projectId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/${projectId}/preprints/`); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse.data); + expect(response).toEqual(MOCK_NODE_PREPRINTS); + }); + + it('updateProjectPublicStatus should bulk patch nodes and map response', async () => { + setup(); + const payload: PrivacyStatusModel[] = [ + { id: 'node-1', public: true }, + { id: 'node-2', public: false }, + ]; + const mapped = [MOCK_NODE_WITH_ADMIN]; + const mapperSpy = vi.spyOn(BaseNodeMapper, 'getNodesData').mockReturnValue(mapped); + jsonApiService.patch.mockReturnValue(of([nodeData])); + + const response = await firstValueFrom(service.updateProjectPublicStatus(payload)); + + expect(jsonApiService.patch).toHaveBeenCalledWith( + `${MOCK_API_URL}/nodes/`, + { + data: [ + { id: 'node-1', type: 'nodes', attributes: { public: true } }, + { id: 'node-2', type: 'nodes', attributes: { public: false } }, + ], + }, + undefined, + JSON_API_BULK_HEADERS + ); + expect(mapperSpy).toHaveBeenCalledWith([nodeData]); + expect(response).toEqual(mapped); + }); + + it('duplicateProject should post templated node and map response', async () => { + setup(); + const mapperSpy = vi.spyOn(BaseNodeMapper, 'getNodeData').mockReturnValue(MOCK_NODE_WITH_ADMIN); + jsonApiService.post.mockReturnValue(of(nodeResponse)); + + const response = await firstValueFrom(service.duplicateProject(projectId, 'My Project')); + + expect(jsonApiService.post).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/`, { + data: { + type: 'nodes', + attributes: { + template_from: projectId, + category: 'project', + title: 'Templated from My Project', + }, + }, + }); + expect(mapperSpy).toHaveBeenCalledWith(nodeData); + expect(response).toEqual(MOCK_NODE_WITH_ADMIN); + }); + + it('createComponent should create child without affiliations when none provided', async () => { + setup(); + jsonApiService.post.mockReturnValue(of({ data: { id: 'child-1' } } as NodeResponseJsonApi)); + + const response = await firstValueFrom(service.createComponent(projectId, 'Child', null, ['tag'], null, [], true)); + + expect(jsonApiService.post).toHaveBeenCalledWith( + `${MOCK_API_URL}/nodes/${projectId}/children/`, + { + data: { + type: 'nodes', + attributes: { + title: 'Child', + category: 'project', + description: '', + tags: ['tag'], + }, + }, + }, + { inherit_contributors: true } + ); + expect(jsonApiService.patch).not.toHaveBeenCalled(); + expect(response).toBeUndefined(); + }); + + it('createComponent should include region and patch affiliations when provided', async () => { + setup(); + jsonApiService.post.mockReturnValue(of({ data: { id: 'child-1' } } as NodeResponseJsonApi)); + jsonApiService.patch.mockReturnValue(of(undefined)); + + await firstValueFrom( + service.createComponent(projectId, 'Child', 'Desc', [], 'us-east', ['inst-1', 'inst-2'], false) + ); + + expect(jsonApiService.post).toHaveBeenCalledWith( + `${MOCK_API_URL}/nodes/${projectId}/children/`, + { + data: { + type: 'nodes', + attributes: { + title: 'Child', + category: 'project', + description: 'Desc', + tags: [], + }, + }, + }, + { inherit_contributors: false, region: 'us-east' } + ); + expect(jsonApiService.patch).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/child-1/relationships/institutions/`, { + data: [ + { type: 'institutions', id: 'inst-1' }, + { type: 'institutions', id: 'inst-2' }, + ], + }); + }); + + it('getComponents should request children with embeds and map response', async () => { + setup(); + const mapped: PaginatedData<(typeof MOCK_NODE_WITH_ADMIN)[]> = { + data: [MOCK_NODE_WITH_ADMIN], + totalCount: 1, + pageSize: 10, + }; + const apiResponse = { + data: [], + meta: { total: 1, per_page: 10, version: '2.0' }, + } as NodesResponseJsonApi; + const mapperSpy = vi.spyOn(BaseNodeMapper, 'getNodesWithEmbedsAndTotalData').mockReturnValue(mapped); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getComponents(projectId, 2, 25)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/${projectId}/children/`, { + embed: 'bibliographic_contributors', + 'fields[users]': 'family_name,full_name,given_name,middle_name', + page: 2, + 'page[size]': 25, + sort: '_order', + }); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual(mapped); + }); + + it('getParentProject should request node with bypass context and map response', async () => { + setup(); + const mapperSpy = vi.spyOn(BaseNodeMapper, 'getNodeWithEmbedContributors').mockReturnValue(MOCK_NODE_WITH_ADMIN); + jsonApiService.get.mockReturnValue(of(nodeResponse)); + + const response = await firstValueFrom(service.getParentProject(projectId)); + + expect(jsonApiService.get).toHaveBeenCalledWith( + `${MOCK_API_URL}/nodes/${projectId}/`, + { 'embed[]': ['bibliographic_contributors'] }, + expect.any(HttpContext) + ); + const context = jsonApiService.get.mock.calls[0][2] as HttpContext; + expect(context.get(BYPASS_ERROR_INTERCEPTOR)).toBe(true); + expect(mapperSpy).toHaveBeenCalledWith(nodeData); + expect(response).toEqual(MOCK_NODE_WITH_ADMIN); + }); + + it('reorderComponents should bulk patch component order', async () => { + setup(); + jsonApiService.patch.mockReturnValue(of(undefined)); + + await firstValueFrom(service.reorderComponents(projectId, ['c-2', 'c-1'])); + + expect(jsonApiService.patch).toHaveBeenCalledWith( + `${MOCK_API_URL}/nodes/${projectId}/reorder_components/`, + { + data: [ + { type: 'nodes', id: 'c-2', attributes: { _order: 0 } }, + { type: 'nodes', id: 'c-1', attributes: { _order: 1 } }, + ], + }, + undefined, + JSON_API_BULK_HEADERS + ); + }); +}); 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 5a0caba1e..9b93a910c 100644 --- a/src/app/features/project/overview/services/project-overview.service.ts +++ b/src/app/features/project/overview/services/project-overview.service.ts @@ -1,11 +1,10 @@ -import { Observable, of } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; +import { map, Observable, of, switchMap } from 'rxjs'; -import { HttpContext } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { BYPASS_ERROR_INTERCEPTOR } from '@core/interceptors/error-interceptor.tokens'; +import { bypassErrorInterceptorContext } from '@core/interceptors/error-interceptor.tokens'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { JSON_API_BULK_HEADERS } from '@osf/shared/constants/json-api-headers.const'; import { IdentifiersMapper } from '@osf/shared/mappers/identifiers.mapper'; import { InstitutionsMapper } from '@osf/shared/mappers/institutions'; import { LicensesMapper } from '@osf/shared/mappers/licenses.mapper'; @@ -92,25 +91,11 @@ export class ProjectOverviewService { data: data.map((item) => ({ id: item.id, type: 'nodes', attributes: { public: item.public } })), }; - const headers = { - 'Content-Type': 'application/vnd.api+json; ext=bulk', - }; - return this.jsonApiService - .patch(`${this.apiUrl}/nodes/`, payload, undefined, headers) + .patch(`${this.apiUrl}/nodes/`, payload, undefined, JSON_API_BULK_HEADERS) .pipe(map((res) => BaseNodeMapper.getNodesData(res))); } - forkResource(projectId: string, resourceType: string): Observable { - const payload = { - data: { - type: 'nodes', - }, - }; - - return this.jsonApiService.post(`${this.apiUrl}/${resourceType}/${projectId}/forks/`, payload); - } - duplicateProject(projectId: string, title: string): Observable { const payload = { data: { @@ -182,10 +167,6 @@ export class ProjectOverviewService { ); } - deleteComponent(componentId: string): Observable { - return this.jsonApiService.delete(`${this.apiUrl}/nodes/${componentId}/`); - } - getComponents(projectId: string, page = 1, pageSize = 10): Observable> { const params: Record = { embed: 'bibliographic_contributors', @@ -203,11 +184,8 @@ export class ProjectOverviewService { getParentProject(projectId: string): Observable { const params: Record = { 'embed[]': ['bibliographic_contributors'] }; - const context = new HttpContext(); - context.set(BYPASS_ERROR_INTERCEPTOR, true); - return this.jsonApiService - .get(`${this.apiUrl}/nodes/${projectId}/`, params, context) + .get(`${this.apiUrl}/nodes/${projectId}/`, params, bypassErrorInterceptorContext()) .pipe(map((response) => BaseNodeMapper.getNodeWithEmbedContributors(response.data))); } @@ -222,15 +200,11 @@ export class ProjectOverviewService { })), }; - const headers = { - 'Content-Type': 'application/vnd.api+json; ext=bulk', - }; - return this.jsonApiService.patch( `${this.apiUrl}/nodes/${projectId}/reorder_components/`, payload, undefined, - headers + JSON_API_BULK_HEADERS ); } } diff --git a/src/app/features/project/overview/store/project-overview.actions.ts b/src/app/features/project/overview/store/project-overview.actions.ts index 19c78cbef..16c79931a 100644 --- a/src/app/features/project/overview/store/project-overview.actions.ts +++ b/src/app/features/project/overview/store/project-overview.actions.ts @@ -1,5 +1,4 @@ import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { PrivacyStatusModel } from '../models'; @@ -51,13 +50,10 @@ export class SetProjectCustomCitation { constructor(public citation: string) {} } -export class ForkResource { - static readonly type = '[Project Overview] Fork Resource'; +export class ForkProject { + static readonly type = '[Project Overview] Fork Project'; - constructor( - public resourceId: string, - public resourceType: ResourceType - ) {} + constructor(public resourceId: string) {} } export class DuplicateProject { @@ -91,12 +87,6 @@ export class CreateComponent { ) {} } -export class DeleteComponent { - static readonly type = '[Project Overview] Delete Component'; - - constructor(public componentId: string) {} -} - export class GetComponents { static readonly type = '[Project Overview] Get Components'; diff --git a/src/app/features/project/overview/store/project-overview.selectors.ts b/src/app/features/project/overview/store/project-overview.selectors.ts index 01ce1bade..b45a6c7f8 100644 --- a/src/app/features/project/overview/store/project-overview.selectors.ts +++ b/src/app/features/project/overview/store/project-overview.selectors.ts @@ -31,11 +31,6 @@ export class ProjectOverviewSelectors { return state.components.isSubmitting; } - @Selector([ProjectOverviewState]) - static getForkProjectSubmitting(state: ProjectOverviewStateModel) { - return state.project.isSubmitting; - } - @Selector([ProjectOverviewState]) static getDuplicateProjectSubmitting(state: ProjectOverviewStateModel) { return state.project.isSubmitting; diff --git a/src/app/features/project/overview/store/project-overview.state.ts b/src/app/features/project/overview/store/project-overview.state.ts index e8799ccd1..8cd50508f 100644 --- a/src/app/features/project/overview/store/project-overview.state.ts +++ b/src/app/features/project/overview/store/project-overview.state.ts @@ -4,18 +4,18 @@ import { catchError, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; -import { ResourceType } from '@shared/enums/resource-type.enum'; +import { DuplicatesService } from '@osf/shared/services/duplicates.service'; -import { ProjectOverviewService } from '../services'; +import { ProjectOverviewService } from '../services/project-overview.service'; import { ClearDuplicatedProject, ClearProjectOverview, CreateComponent, - DeleteComponent, DuplicateProject, - ForkResource, + ForkProject, GetComponents, GetParentProject, GetProjectById, @@ -37,7 +37,8 @@ import { PROJECT_OVERVIEW_DEFAULTS, ProjectOverviewStateModel } from './project- }) @Injectable() export class ProjectOverviewState { - projectOverviewService = inject(ProjectOverviewService); + private readonly projectOverviewService = inject(ProjectOverviewService); + private readonly duplicatesService = inject(DuplicatesService); @Action(GetProjectById) getProjectById(ctx: StateContext, action: GetProjectById) { @@ -269,36 +270,20 @@ export class ProjectOverviewState { }); } - @Action(ForkResource) - forkResource(ctx: StateContext, action: ForkResource) { - const state = ctx.getState(); - ctx.patchState({ - project: { - ...state.project, - isSubmitting: true, - }, - }); - - let resourceType = ''; - switch (action.resourceType) { - case ResourceType.Project: - resourceType = 'nodes'; - break; - case ResourceType.Registration: - resourceType = 'registrations'; - break; - } - - return this.projectOverviewService.forkResource(action.resourceId, resourceType).pipe( + @Action(ForkProject) + forkProject(ctx: StateContext, action: ForkProject) { + return this.duplicatesService.forkResource(action.resourceId, CurrentResourceType.Projects).pipe( tap(() => { + const state = ctx.getState(); + if (!state.project.data) return; + ctx.patchState({ project: { ...state.project, data: { - ...state.project.data!, - forksCount: state.project.data!.forksCount + 1, + ...state.project.data, + forksCount: state.project.data.forksCount + 1, }, - isSubmitting: false, }, }); }), @@ -370,30 +355,6 @@ export class ProjectOverviewState { ); } - @Action(DeleteComponent) - deleteComponent(ctx: StateContext, action: DeleteComponent) { - const state = ctx.getState(); - ctx.patchState({ - components: { - ...state.components, - isSubmitting: true, - }, - }); - - return this.projectOverviewService.deleteComponent(action.componentId).pipe( - tap(() => { - ctx.patchState({ - components: { - ...state.components, - isSubmitting: false, - error: null, - }, - }); - }), - catchError((error) => handleSectionError(ctx, 'components', error)) - ); - } - @Action(GetComponents) getProjectComponents(ctx: StateContext, action: GetComponents) { const state = ctx.getState(); diff --git a/src/app/features/project/project.routes.ts b/src/app/features/project/project.routes.ts index 91a0f33c3..2cdd5edaa 100644 --- a/src/app/features/project/project.routes.ts +++ b/src/app/features/project/project.routes.ts @@ -105,7 +105,7 @@ export const projectRoutes: Routes = [ import('../analytics/components/view-duplicates/view-duplicates.component').then( (mod) => mod.ViewDuplicatesComponent ), - providers: [provideStates([DuplicatesState])], + providers: [provideStates([DuplicatesState, SettingsState])], }, { path: 'wiki/:wikiName', diff --git a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.html b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.html deleted file mode 100644 index 499c065ef..000000000 --- a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.html +++ /dev/null @@ -1,42 +0,0 @@ -@if (hasAdminAccessForAllComponents()) { -

{{ 'project.deleteProject.dialog.listMessage' | translate }}

- @if (isLoading()) { - - } @else { -
    - @for (project of projects(); track project.id) { -
  • {{ project.title }}
  • - } -
- } - -

{{ 'project.deleteProject.dialog.warningMessage' | translate }}

- -

- {{ 'project.deleteProject.dialog.confirmation' | translate }} - {{ selectedScientist() }} -

- - -} @else { -

-} -
- - -
diff --git a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.scss b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.spec.ts b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.spec.ts deleted file mode 100644 index ea0aeefaa..000000000 --- a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { MockProvider } from 'ng-mocks'; - -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ToastService } from '@osf/shared/services/toast.service'; -import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; - -import { provideOSFCore } from '@testing/osf.testing.provider'; -import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; - -import { SettingsSelectors } from '../../store'; - -import { DeleteProjectDialogComponent } from './delete-project-dialog.component'; - -describe('DeleteProjectDialogComponent', () => { - let component: DeleteProjectDialogComponent; - let fixture: ComponentFixture; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [DeleteProjectDialogComponent], - providers: [ - provideOSFCore(), - MockProvider(ToastService), - provideDynamicDialogRefMock(), - provideMockStore({ - signals: [ - { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, - { selector: CurrentResourceSelectors.getResourceWithChildren, value: [] }, - { selector: SettingsSelectors.isSettingsSubmitting, value: false }, - ], - }), - ], - }); - - fixture = TestBed.createComponent(DeleteProjectDialogComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.ts b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.ts deleted file mode 100644 index ec54ab464..000000000 --- a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { createDispatchMap, select } from '@ngxs/store'; - -import { TranslatePipe } from '@ngx-translate/core'; - -import { Button } from 'primeng/button'; -import { DynamicDialogRef } from 'primeng/dynamicdialog'; -import { InputText } from 'primeng/inputtext'; -import { Skeleton } from 'primeng/skeleton'; - -import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; - -import { ScientistsNames } from '@osf/shared/constants/scientists.const'; -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 { DeleteProject, SettingsSelectors } from '../../store'; - -@Component({ - selector: 'osf-delete-project-dialog', - imports: [TranslatePipe, Button, InputText, FormsModule, Skeleton], - templateUrl: './delete-project-dialog.component.html', - styleUrl: './delete-project-dialog.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class DeleteProjectDialogComponent { - private toastService = inject(ToastService); - private router = inject(Router); - - dialogRef = inject(DynamicDialogRef); - destroyRef = inject(DestroyRef); - - scientistNames = ScientistsNames; - userInput = signal(''); - - isLoading = select(CurrentResourceSelectors.isResourceWithChildrenLoading); - isSubmitting = select(SettingsSelectors.isSettingsSubmitting); - projects = select(CurrentResourceSelectors.getResourceWithChildren); - - hasAdminAccessForAllComponents = computed(() => { - const projects = this.projects(); - if (!projects || !projects.length) return false; - - return projects.every((project) => project.permissions?.includes(UserPermissions.Admin)); - }); - - selectedScientist = computed(() => { - const names = Object.values(this.scientistNames); - return names[Math.floor(Math.random() * names.length)]; - }); - - actions = createDispatchMap({ deleteProject: DeleteProject }); - - isInputValid = computed(() => this.userInput() === this.selectedScientist()); - - handleDeleteProject(): void { - const projects = this.projects(); - - if (!projects?.length) return; - - this.actions - .deleteProject(projects) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: () => { - this.dialogRef.close({ success: true }); - this.toastService.showSuccess('project.deleteProject.success'); - this.router.navigate(['/']); - }, - }); - } -} diff --git a/src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.html b/src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.html new file mode 100644 index 000000000..5874d5332 --- /dev/null +++ b/src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.html @@ -0,0 +1,40 @@ +@if (hasAdminAccessForAllComponents()) { + @if (showList()) { +

{{ dialogData.listMessageKey | translate }}

+ +
    + @for (resource of resources(); track resource.id) { +
  • {{ resource.title }}
  • + } +
+ +

{{ dialogData.warningMessageKey | translate }}

+ } @else { +

{{ dialogData.singleMessageKey | translate }}

+ } + +

+ {{ dialogData.confirmationKey | translate }} + {{ selectedScientist }} +

+ + +} @else { +

+} + +
+ + +
diff --git a/src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.spec.ts b/src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.spec.ts new file mode 100644 index 000000000..03a70851b --- /dev/null +++ b/src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.spec.ts @@ -0,0 +1,137 @@ +import { MockProvider } from 'ng-mocks'; + +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; +import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; + +import { DeleteResourceDialogData } from '../../models/delete-resource-options.model'; + +import { DeleteResourceDialogComponent } from './delete-resource-dialog.component'; + +interface SetupOverrides extends BaseSetupOverrides { + dialogData?: DeleteResourceDialogData; +} + +describe('DeleteResourceDialogComponent', () => { + let component: DeleteResourceDialogComponent; + let fixture: ComponentFixture; + let dialogRef: DynamicDialogRef; + + const resources: NodeShortInfoModel[] = [ + { + id: 'c1', + title: 'Component 1', + isPublic: true, + permissions: [UserPermissions.Admin], + }, + { + id: 'c2', + title: 'Component 2', + isPublic: true, + permissions: [UserPermissions.Admin, UserPermissions.Write], + }, + ]; + + const dialogData: DeleteResourceDialogData = { + listMessageKey: 'list', + warningMessageKey: 'warning', + confirmationKey: 'confirmation', + noPermissionsMessageKey: 'no-permissions', + singleMessageKey: 'single', + }; + + const defaultSignals: SignalOverride[] = [ + { selector: CurrentResourceSelectors.getResourceWithChildren, value: resources }, + { selector: CurrentResourceSelectors.allResourceChildrenHaveAdminAccess, value: true }, + ]; + + function setup(overrides: SetupOverrides = {}) { + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); + + TestBed.configureTestingModule({ + imports: [DeleteResourceDialogComponent], + providers: [ + provideOSFCore(), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, { data: overrides.dialogData ?? dialogData }), + provideMockStore({ signals }), + ], + }); + + dialogRef = TestBed.inject(DynamicDialogRef); + fixture = TestBed.createComponent(DeleteResourceDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + it('should create', () => { + setup(); + + expect(component).toBeTruthy(); + }); + + it('should return false for hasAdminAccessForAllComponents when access is denied', () => { + setup({ + selectorOverrides: [{ selector: CurrentResourceSelectors.allResourceChildrenHaveAdminAccess, value: false }], + }); + + expect(component.hasAdminAccessForAllComponents()).toBe(false); + }); + + it('should show the list when there are multiple resources', () => { + setup(); + + expect(component.showList()).toBe(true); + }); + + it('should show the single message when one resource has a single message key', () => { + setup({ + selectorOverrides: [{ selector: CurrentResourceSelectors.getResourceWithChildren, value: [resources[0]] }], + }); + + expect(component.showList()).toBe(false); + }); + + it('should show the list for one resource when no single message key is set', () => { + setup({ + dialogData: { + listMessageKey: 'list', + warningMessageKey: 'warning', + confirmationKey: 'confirmation', + noPermissionsMessageKey: 'no-permissions', + }, + selectorOverrides: [{ selector: CurrentResourceSelectors.getResourceWithChildren, value: [resources[0]] }], + }); + + expect(component.showList()).toBe(true); + }); + + it('should return true for isInputValid when confirmation matches selected scientist', () => { + setup(); + + component.userInput.set(component.selectedScientist); + + expect(component.isInputValid()).toBe(true); + }); + + it('should close dialog with true on delete', () => { + setup(); + + component.handleDelete(); + + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.ts b/src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.ts new file mode 100644 index 000000000..067bc6462 --- /dev/null +++ b/src/app/features/project/settings/components/delete-resource-dialog/delete-resource-dialog.component.ts @@ -0,0 +1,41 @@ +import { select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { InputText } from 'primeng/inputtext'; + +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { ScientistsNames } from '@osf/shared/constants/scientists.const'; +import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; + +import { DeleteResourceDialogData } from '../../models/delete-resource-options.model'; + +@Component({ + selector: 'osf-delete-resource-dialog', + imports: [TranslatePipe, Button, InputText, FormsModule], + templateUrl: './delete-resource-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeleteResourceDialogComponent { + readonly dialogRef = inject(DynamicDialogRef); + readonly dialogData: DeleteResourceDialogData = inject(DynamicDialogConfig).data; + + readonly resources = select(CurrentResourceSelectors.getResourceWithChildren); + readonly hasAdminAccessForAllComponents = select(CurrentResourceSelectors.allResourceChildrenHaveAdminAccess); + + readonly userInput = signal(''); + + readonly selectedScientist = ScientistsNames[Math.floor(Math.random() * ScientistsNames.length)]; + + readonly isInputValid = computed(() => this.userInput() === this.selectedScientist); + readonly hasMultipleResources = computed(() => (this.resources()?.length ?? 0) > 1); + readonly showList = computed(() => !this.dialogData.singleMessageKey || this.hasMultipleResources()); + + handleDelete(): void { + this.dialogRef.close(true); + } +} diff --git a/src/app/features/project/settings/components/index.ts b/src/app/features/project/settings/components/index.ts index 37ac121d2..48f7a5372 100644 --- a/src/app/features/project/settings/components/index.ts +++ b/src/app/features/project/settings/components/index.ts @@ -1,4 +1,3 @@ -export { DeleteProjectDialogComponent } from './delete-project-dialog/delete-project-dialog.component'; export { ProjectDetailSettingAccordionComponent } from './project-detail-setting-accordion/project-detail-setting-accordion.component'; export { ProjectSettingNotificationsComponent } from './project-setting-notifications/project-setting-notifications.component'; export { SettingsAccessRequestsCardComponent } from './settings-access-requests-card/settings-access-requests-card.component'; diff --git a/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.spec.ts b/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.spec.ts index dcfbbcb63..048b17084 100644 --- a/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.spec.ts +++ b/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.spec.ts @@ -3,7 +3,9 @@ import { MockComponent, MockDirective } from 'ng-mocks'; import { Textarea } from 'primeng/textarea'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { UserSelectors } from '@osf/core/store/user'; import { TextInputComponent } from '@osf/shared/components/text-input/text-input.component'; import { ProjectFormControls } from '@osf/shared/enums/create-project-form-controls.enum'; @@ -24,7 +26,12 @@ describe('SettingsProjectFormCardComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [SettingsProjectFormCardComponent, MockComponent(TextInputComponent), MockDirective(Textarea)], - providers: [provideOSFCore(), provideMockStore()], + providers: [ + provideOSFCore(), + provideMockStore({ + signals: [{ selector: UserSelectors.isProjectReadOnly, value: false }], + }), + ], }); fixture = TestBed.createComponent(SettingsProjectFormCardComponent); @@ -79,13 +86,18 @@ describe('SettingsProjectFormCardComponent', () => { }); it('should emit deleteProject when delete button is clicked', () => { - vi.spyOn(component.deleteProject, 'emit'); + const emitSpy = vi.spyOn(component.deleteProject, 'emit'); fixture.componentRef.setInput('projectDetails', mockNodeDetails); + fixture.componentRef.setInput('canDelete', true); fixture.detectChanges(); - component.deleteProject.emit(); + const deleteButton = fixture.debugElement + .queryAll(By.css('p-button')) + .find((element) => element.nativeElement.textContent?.includes('myProjects.settings.deleteProject')); - expect(component.deleteProject.emit).toHaveBeenCalled(); + deleteButton?.triggerEventHandler('onClick'); + + expect(emitSpy).toHaveBeenCalled(); }); it('should reset form to original values', () => { diff --git a/src/app/features/project/settings/models/delete-resource-options.model.ts b/src/app/features/project/settings/models/delete-resource-options.model.ts new file mode 100644 index 000000000..673d8123d --- /dev/null +++ b/src/app/features/project/settings/models/delete-resource-options.model.ts @@ -0,0 +1,22 @@ +import { DestroyRef } from '@angular/core'; + +export interface DeleteResourceDialogData { + listMessageKey: string; + warningMessageKey: string; + confirmationKey: string; + noPermissionsMessageKey: string; + singleMessageKey?: string; +} + +export interface DeleteResourceBaseOptions { + rootParentId: string; + resourceId: string; + destroyRef: DestroyRef; + onSuccess: () => void; +} + +export interface DeleteResourceRunConfig extends DeleteResourceBaseOptions { + dialogHeader: string; + toastKey: string; + dialogData: DeleteResourceDialogData; +} diff --git a/src/app/features/project/settings/services/delete-resource.service.spec.ts b/src/app/features/project/settings/services/delete-resource.service.spec.ts new file mode 100644 index 000000000..fb55e51c5 --- /dev/null +++ b/src/app/features/project/settings/services/delete-resource.service.spec.ts @@ -0,0 +1,183 @@ +import { Store } from '@ngxs/store'; + +import { MockProvider } from 'ng-mocks'; + +import { of, Subject, throwError } from 'rxjs'; + +import { Mock } from 'vitest'; + +import { DestroyRef } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { CurrentResourceSelectors, GetResourceWithChildren } from '@osf/shared/stores/current-resource'; + +import { CustomDialogServiceMock, CustomDialogServiceMockType } from '@testing/providers/custom-dialog-provider.mock'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; + +import { DeleteResourceDialogComponent } from '../components/delete-resource-dialog/delete-resource-dialog.component'; +import { DeleteProject } from '../store/settings.actions'; + +import { DeleteResourceService } from './delete-resource.service'; + +describe('DeleteResourceService', () => { + let service: DeleteResourceService; + let store: Store; + let customDialogService: CustomDialogServiceMockType; + let loaderService: LoaderServiceMock; + let toastService: ToastServiceMockType; + let destroyRef: DestroyRef; + let onClose$: Subject; + let onSuccess: Mock<() => void>; + + const resources: NodeShortInfoModel[] = [ + { + id: 'resource-1', + title: 'Resource 1', + isPublic: true, + permissions: [UserPermissions.Admin], + }, + ]; + + const baseOptions = { + rootParentId: 'root-1', + resourceId: 'resource-1', + }; + + function setup() { + customDialogService = CustomDialogServiceMock.simple(); + loaderService = new LoaderServiceMock(); + toastService = ToastServiceMock.simple(); + destroyRef = { onDestroy: vi.fn() } as unknown as DestroyRef; + onClose$ = new Subject(); + onSuccess = vi.fn<() => void>(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + + TestBed.configureTestingModule({ + providers: [ + DeleteResourceService, + MockProvider(CustomDialogService, customDialogService), + MockProvider(LoaderService, loaderService), + MockProvider(ToastService, toastService), + provideMockStore({ + signals: [{ selector: CurrentResourceSelectors.getResourceWithChildren, value: resources }], + }), + ], + }); + + service = TestBed.inject(DeleteResourceService); + store = TestBed.inject(Store); + } + + it('should create', () => { + setup(); + expect(service).toBeTruthy(); + }); + + it('deleteComponent should load children, open component dialog, delete, toast, and call onSuccess', () => { + setup(); + + service.deleteComponent({ ...baseOptions, destroyRef, onSuccess }); + + expect(store.dispatch).toHaveBeenCalledWith( + new GetResourceWithChildren('root-1', 'resource-1', ResourceType.Project) + ); + expect(loaderService.show).toHaveBeenCalled(); + expect(loaderService.hide).toHaveBeenCalled(); + expect(customDialogService.open).toHaveBeenCalledWith(DeleteResourceDialogComponent, { + header: 'project.overview.dialog.deleteComponent.header', + width: '500px', + data: { + listMessageKey: 'project.overview.dialog.deleteComponent.listMessage', + warningMessageKey: 'project.overview.dialog.deleteComponent.warningMessage', + confirmationKey: 'project.overview.dialog.deleteComponent.confirmation', + noPermissionsMessageKey: 'project.overview.dialog.deleteComponent.noPermissionsMessage', + singleMessageKey: 'project.overview.dialog.deleteComponent.message', + }, + }); + + (store.dispatch as Mock).mockClear(); + loaderService.show.mockClear(); + loaderService.hide.mockClear(); + + onClose$.next(true); + + expect(loaderService.show).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteProject(resources)); + expect(loaderService.hide).toHaveBeenCalled(); + expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.deleteComponent.success'); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('deleteProject should open project dialog and show project success toast', () => { + setup(); + + service.deleteProject({ ...baseOptions, destroyRef, onSuccess }); + + expect(customDialogService.open).toHaveBeenCalledWith(DeleteResourceDialogComponent, { + header: 'project.deleteProject.dialog.deleteProject', + width: '500px', + data: { + listMessageKey: 'project.deleteProject.dialog.listMessage', + warningMessageKey: 'project.deleteProject.dialog.warningMessage', + confirmationKey: 'project.deleteProject.dialog.confirmation', + noPermissionsMessageKey: 'project.deleteProject.dialog.noPermissionsMessage', + }, + }); + + onClose$.next(true); + + expect(toastService.showSuccess).toHaveBeenCalledWith('project.deleteProject.success'); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('should not delete when dialog is dismissed', () => { + setup(); + + service.deleteProject({ ...baseOptions, destroyRef, onSuccess }); + (store.dispatch as Mock).mockClear(); + + onClose$.next(false); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('should hide loader and skip success handlers when delete fails', () => { + setup(); + (store.dispatch as Mock).mockImplementation((action: unknown) => { + if (action instanceof DeleteProject) { + return throwError(() => new Error('delete failed')); + } + return of(true); + }); + + service.deleteProject({ ...baseOptions, destroyRef, onSuccess }); + loaderService.hide.mockClear(); + + onClose$.next(true); + + expect(loaderService.hide).toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('should skip dialog when loading children fails', () => { + setup(); + (store.dispatch as Mock).mockReturnValue(throwError(() => new Error('load failed'))); + + service.deleteProject({ ...baseOptions, destroyRef, onSuccess }); + + expect(customDialogService.open).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/project/settings/services/delete-resource.service.ts b/src/app/features/project/settings/services/delete-resource.service.ts new file mode 100644 index 000000000..746f75bf7 --- /dev/null +++ b/src/app/features/project/settings/services/delete-resource.service.ts @@ -0,0 +1,88 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { catchError, EMPTY, filter, finalize, switchMap, take, tap } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { CurrentResourceSelectors, GetResourceWithChildren } from '@osf/shared/stores/current-resource'; + +import { DeleteResourceDialogComponent } from '../components/delete-resource-dialog/delete-resource-dialog.component'; +import { DeleteResourceBaseOptions, DeleteResourceRunConfig } from '../models/delete-resource-options.model'; +import { DeleteProject } from '../store/settings.actions'; + +@Injectable({ providedIn: 'root' }) +export class DeleteResourceService { + private readonly customDialogService = inject(CustomDialogService); + private readonly loaderService = inject(LoaderService); + private readonly toastService = inject(ToastService); + + private readonly resourcesToDelete = select(CurrentResourceSelectors.getResourceWithChildren); + + private readonly actions = createDispatchMap({ + getResourceWithChildren: GetResourceWithChildren, + deleteProject: DeleteProject, + }); + + deleteComponent(options: DeleteResourceBaseOptions): void { + this.run({ + ...options, + dialogHeader: 'project.overview.dialog.deleteComponent.header', + toastKey: 'project.overview.dialog.toast.deleteComponent.success', + dialogData: { + listMessageKey: 'project.overview.dialog.deleteComponent.listMessage', + warningMessageKey: 'project.overview.dialog.deleteComponent.warningMessage', + confirmationKey: 'project.overview.dialog.deleteComponent.confirmation', + noPermissionsMessageKey: 'project.overview.dialog.deleteComponent.noPermissionsMessage', + singleMessageKey: 'project.overview.dialog.deleteComponent.message', + }, + }); + } + + deleteProject(options: DeleteResourceBaseOptions): void { + this.run({ + ...options, + dialogHeader: 'project.deleteProject.dialog.deleteProject', + toastKey: 'project.deleteProject.success', + dialogData: { + listMessageKey: 'project.deleteProject.dialog.listMessage', + warningMessageKey: 'project.deleteProject.dialog.warningMessage', + confirmationKey: 'project.deleteProject.dialog.confirmation', + noPermissionsMessageKey: 'project.deleteProject.dialog.noPermissionsMessage', + }, + }); + } + + private run(config: DeleteResourceRunConfig): void { + const { rootParentId, resourceId, destroyRef, onSuccess, dialogHeader, toastKey, dialogData } = config; + + this.actions + .getResourceWithChildren(rootParentId, resourceId, ResourceType.Project) + .pipe( + tap({ subscribe: () => this.loaderService.show() }), + finalize(() => this.loaderService.hide()), + switchMap(() => + this.customDialogService + .open(DeleteResourceDialogComponent, { header: dialogHeader, width: '500px', data: dialogData }) + .onClose.pipe( + take(1), + filter((confirmed: boolean) => !!confirmed) + ) + ), + tap(() => this.loaderService.show()), + switchMap(() => + this.actions.deleteProject(this.resourcesToDelete()).pipe(finalize(() => this.loaderService.hide())) + ), + catchError(() => EMPTY), + takeUntilDestroyed(destroyRef) + ) + .subscribe(() => { + this.toastService.showSuccess(toastKey); + onSuccess(); + }); + } +} diff --git a/src/app/features/project/settings/services/index.ts b/src/app/features/project/settings/services/index.ts deleted file mode 100644 index 31cb466fb..000000000 --- a/src/app/features/project/settings/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SettingsService } from './settings.service'; diff --git a/src/app/features/project/settings/services/settings.service.spec.ts b/src/app/features/project/settings/services/settings.service.spec.ts new file mode 100644 index 000000000..6abc8230a --- /dev/null +++ b/src/app/features/project/settings/services/settings.service.spec.ts @@ -0,0 +1,240 @@ +import { MockProvider } from 'ng-mocks'; + +import { firstValueFrom, of } from 'rxjs'; + +import { TestBed } from '@angular/core/testing'; + +import { JSON_API_BULK_HEADERS } from '@osf/shared/constants/json-api-headers.const'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { SubscriptionFrequency } from '@osf/shared/enums/subscriptions/subscription-frequency.enum'; +import { NotificationSubscriptionMapper } from '@osf/shared/mappers/notification-subscription.mapper'; +import { BaseNodeDataJsonApi } from '@osf/shared/models/nodes/base-node-data-json-api.model'; +import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; +import { NodeResponseJsonApi, UpdateNodeRequestModel } from '@osf/shared/models/nodes/nodes-json-api.model'; +import { NotificationSubscription } from '@osf/shared/models/notifications/notification-subscription.model'; +import { + NotificationSubscriptionDataJsonApi, + NotificationSubscriptionsListResponseJsonApi, +} from '@osf/shared/models/notifications/notification-subscription-json-api.model'; +import { JsonApiService } from '@osf/shared/services/json-api.service'; + +import { MOCK_NODE_DETAILS } from '@testing/mocks/node-details.mock'; +import { MOCK_NOTIFICATION_SUBSCRIPTIONS } from '@testing/mocks/notification-subscription.mock'; +import { MOCK_API_URL, provideMockEnvironment } from '@testing/providers/environment.provider.mock'; +import { JsonApiServiceMock, JsonApiServiceMockType } from '@testing/providers/json-api.service.mock'; + +import { SettingsMapper } from '../mappers'; +import { + NodeDetailsModel, + ProjectSettingsDataJsonApi, + ProjectSettingsModel, + ProjectSettingsResponseJsonApi, +} from '../models'; + +import { SettingsService } from './settings.service'; + +describe('SettingsService', () => { + let service: SettingsService; + let jsonApiService: JsonApiServiceMockType; + + const nodeId = 'node-1'; + const mappedSettings = { + id: nodeId, + attributes: { + accessRequestsEnabled: true, + anyoneCanEditWiki: false, + wikiEnabled: true, + }, + } as ProjectSettingsModel; + + function setup() { + jsonApiService = JsonApiServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [SettingsService, MockProvider(JsonApiService, jsonApiService), provideMockEnvironment()], + }); + + service = TestBed.inject(SettingsService); + } + + it('should expose apiUrl from environment', () => { + setup(); + + expect(service.apiUrl).toBe(MOCK_API_URL); + }); + + it('getProjectSettings should request node settings and map response', async () => { + setup(); + const apiResponse = { + data: { + id: nodeId, + type: 'node-settings', + attributes: { + access_requests_enabled: true, + anyone_can_edit_wiki: false, + wiki_enabled: true, + }, + }, + } as ProjectSettingsResponseJsonApi; + const mapperSpy = vi.spyOn(SettingsMapper, 'fromResponse').mockReturnValue(mappedSettings); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getProjectSettings(nodeId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/${nodeId}/settings/`); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse, nodeId); + expect(response).toEqual(mappedSettings); + }); + + it('updateProjectSettings should patch node settings and map response', async () => { + setup(); + const model = { + id: nodeId, + type: 'node-settings', + attributes: { + access_requests_enabled: false, + anyone_can_edit_wiki: true, + wiki_enabled: false, + }, + } as ProjectSettingsDataJsonApi; + const mapperSpy = vi.spyOn(SettingsMapper, 'fromResponse').mockReturnValue(mappedSettings); + jsonApiService.patch.mockReturnValue(of(model)); + + const response = await firstValueFrom(service.updateProjectSettings(model)); + + expect(jsonApiService.patch).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/${nodeId}/settings/`, { data: model }); + expect(mapperSpy).toHaveBeenCalledWith(model, nodeId); + expect(response).toEqual(mappedSettings); + }); + + it('getNotificationSubscriptions should request filtered subscriptions and map response', async () => { + setup(); + const apiItem = { + id: `${nodeId}_file_updated`, + type: 'subscription', + attributes: { + event_name: 'file_updated', + frequency: SubscriptionFrequency.Daily, + }, + } as NotificationSubscriptionDataJsonApi; + const apiResponse = { data: [apiItem] } as NotificationSubscriptionsListResponseJsonApi; + const mapped = [MOCK_NOTIFICATION_SUBSCRIPTIONS[0]]; + const mapperSpy = vi.spyOn(NotificationSubscriptionMapper, 'fromGetResponse').mockReturnValue(mapped[0]); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getNotificationSubscriptions(nodeId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/subscriptions/`, { + 'filter[id]': `${nodeId}_file_updated`, + }); + expect(mapperSpy).toHaveBeenCalledWith(apiItem); + expect(response).toEqual(mapped); + }); + + it('updateSubscription should patch subscription and map response', async () => { + setup(); + const subscriptionId = `${nodeId}_file_updated`; + const request = NotificationSubscriptionMapper.toUpdateRequest( + subscriptionId, + SubscriptionFrequency.Instant, + false + ); + const apiResponse = { + id: subscriptionId, + type: 'subscription', + attributes: { + event_name: 'file_updated', + frequency: SubscriptionFrequency.Instant, + }, + } as NotificationSubscriptionDataJsonApi; + const mapped = { + id: subscriptionId, + event: MOCK_NOTIFICATION_SUBSCRIPTIONS[0].event, + frequency: SubscriptionFrequency.Instant, + } as NotificationSubscription; + const toRequestSpy = vi.spyOn(NotificationSubscriptionMapper, 'toUpdateRequest').mockReturnValue(request); + const fromResponseSpy = vi.spyOn(NotificationSubscriptionMapper, 'fromGetResponse').mockReturnValue(mapped); + jsonApiService.patch.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.updateSubscription(subscriptionId, SubscriptionFrequency.Instant)); + + expect(toRequestSpy).toHaveBeenCalledWith(subscriptionId, SubscriptionFrequency.Instant, false); + expect(jsonApiService.patch).toHaveBeenCalledWith(`${MOCK_API_URL}/subscriptions/${subscriptionId}/`, request); + expect(fromResponseSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual(mapped); + }); + + it('getProjectById should request node with embeds and map response', async () => { + setup(); + const nodeData = { id: nodeId } as BaseNodeDataJsonApi; + const apiResponse = { data: nodeData } as NodeResponseJsonApi; + const mapperSpy = vi.spyOn(SettingsMapper, 'fromNodeResponse').mockReturnValue(MOCK_NODE_DETAILS); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getProjectById(nodeId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/${nodeId}/`, { + 'embed[]': ['affiliated_institutions', 'region'], + }); + expect(mapperSpy).toHaveBeenCalledWith(nodeData); + expect(response).toEqual(MOCK_NODE_DETAILS); + }); + + it('updateProjectById should patch node and map response', async () => { + setup(); + const model = { + data: { + type: 'nodes', + id: nodeId, + attributes: { title: 'New Title', description: 'New Description' }, + }, + } as UpdateNodeRequestModel; + const apiResponse = { id: nodeId } as BaseNodeDataJsonApi; + const mapped = { ...MOCK_NODE_DETAILS, title: 'New Title' } as NodeDetailsModel; + const mapperSpy = vi.spyOn(SettingsMapper, 'fromNodeResponse').mockReturnValue(mapped); + jsonApiService.patch.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.updateProjectById(model)); + + expect(jsonApiService.patch).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/${nodeId}/`, model); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual(mapped); + }); + + it('deleteProject should bulk delete nodes', async () => { + setup(); + const projects = [ + { id: 'node-1', title: 'One' }, + { id: 'node-2', title: 'Two' }, + ] as NodeShortInfoModel[]; + jsonApiService.delete.mockReturnValue(of(void 0)); + + await firstValueFrom(service.deleteProject(projects)); + + expect(jsonApiService.delete).toHaveBeenCalledWith( + `${MOCK_API_URL}/nodes/`, + { + data: [ + { id: 'node-1', type: CurrentResourceType.Projects }, + { id: 'node-2', type: CurrentResourceType.Projects }, + ], + }, + JSON_API_BULK_HEADERS + ); + }); + + it('deleteInstitution should delete institution node relationship', async () => { + setup(); + const institutionId = 'inst-1'; + jsonApiService.delete.mockReturnValue(of(void 0)); + + await firstValueFrom(service.deleteInstitution(institutionId, nodeId)); + + expect(jsonApiService.delete).toHaveBeenCalledWith( + `${MOCK_API_URL}/institutions/${institutionId}/relationships/nodes/`, + { + data: [{ type: 'nodes', id: nodeId }], + } + ); + }); +}); diff --git a/src/app/features/project/settings/services/settings.service.ts b/src/app/features/project/settings/services/settings.service.ts index fcdb82acb..3c965041d 100644 --- a/src/app/features/project/settings/services/settings.service.ts +++ b/src/app/features/project/settings/services/settings.service.ts @@ -3,6 +3,8 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { JSON_API_BULK_HEADERS } from '@osf/shared/constants/json-api-headers.const'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; import { SubscriptionFrequency } from '@osf/shared/enums/subscriptions/subscription-frequency.enum'; import { NotificationSubscriptionMapper } from '@osf/shared/mappers/notification-subscription.mapper'; import { BaseNodeDataJsonApi } from '@osf/shared/models/nodes/base-node-data-json-api.model'; @@ -83,18 +85,9 @@ export class SettingsService { } deleteProject(projects: NodeShortInfoModel[]): Observable { - const payload = { - data: projects.map((project) => ({ - type: 'nodes', - id: project.id, - })), - }; - - const headers = { - 'Content-Type': 'application/vnd.api+json; ext=bulk', - }; + const payload = { data: projects.map((project) => ({ id: project.id, type: CurrentResourceType.Projects })) }; - return this.jsonApiService.delete(`${this.apiUrl}/nodes/`, payload, headers); + return this.jsonApiService.delete(`${this.apiUrl}/nodes/`, payload, JSON_API_BULK_HEADERS); } deleteInstitution(institutionId: string, projectId: string): Observable { diff --git a/src/app/features/project/settings/settings.component.spec.ts b/src/app/features/project/settings/settings.component.spec.ts index 96afbff7f..39a66ba4b 100644 --- a/src/app/features/project/settings/settings.component.spec.ts +++ b/src/app/features/project/settings/settings.component.spec.ts @@ -1,23 +1,47 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; +import { Mock } from 'vitest'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +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.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { LoaderService } from '@osf/shared/services/loader.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { ViewOnlyLinkSelectors } from '@osf/shared/stores/view-only-links'; +import { GetResource } from '@osf/shared/stores/current-resource'; +import { DeleteViewOnlyLink, FetchViewOnlyLinks, ViewOnlyLinkSelectors } from '@osf/shared/stores/view-only-links'; +import { MOCK_NODE_DETAILS } from '@testing/mocks/node-details.mock'; import { MOCK_VIEW_ONLY_LINK } from '@testing/mocks/view-only-link.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { + DeleteResourceServiceMock, + DeleteResourceServiceMockType, +} from '@testing/providers/delete-resource-provider.mock'; +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 { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-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'; +import { DeleteResourceService } from './services/delete-resource.service'; import { ProjectSettingNotificationsComponent, SettingsAccessRequestsCardComponent, @@ -27,44 +51,65 @@ import { SettingsViewOnlyLinksCardComponent, SettingsWikiCardComponent, } from './components'; +import { NodeDetailsModel, ProjectSettingsDataJsonApi } from './models'; import { SettingsComponent } from './settings.component'; -import { SettingsSelectors } from './store'; +import { + DeleteInstitution, + GetProjectDetails, + GetProjectNotificationSubscriptions, + GetProjectSettings, + SettingsSelectors, + UpdateProjectDetails, + UpdateProjectNotificationSubscription, + UpdateProjectSettings, +} from './store'; -describe.skip('SettingsComponent', () => { +describe('SettingsComponent', () => { let component: SettingsComponent; let fixture: ComponentFixture; - let routerMock: ReturnType; - let activatedRouteMock: ReturnType; - let toastServiceMock: ReturnType; - let customConfirmationServiceMock: ReturnType; - let mockLoaderService: LoaderService; - - const mockProjectId = 'test-project-123'; - const mockSettings = { + let store: Store; + let routerMock: RouterMockType; + let toastService: ToastServiceMockType; + let customConfirmationService: CustomConfirmationServiceMockType; + let deleteResourceService: DeleteResourceServiceMockType; + + const projectId = 'test-project-123'; + const projectDetails: NodeDetailsModel = { + ...MOCK_NODE_DETAILS, + id: projectId, + rootId: 'root-1', + }; + const settings = { attributes: { accessRequestsEnabled: true, wikiEnabled: false, anyoneCanEditWiki: false, - anyoneCanComment: true, }, }; - const mockProjectDetails = { - id: mockProjectId, - title: 'Test Project', - description: 'Test Description', - isPublic: true, - region: { name: 'US East' }, - affiliatedInstitutions: [], - }; - beforeEach(() => { - activatedRouteMock = ActivatedRouteMockBuilder.create().withParams({ id: mockProjectId }).build(); + const defaultSignals: SignalOverride[] = [ + { selector: SettingsSelectors.getSettings, value: settings }, + { selector: SettingsSelectors.getNotificationSubscriptions, value: [] }, + { selector: SettingsSelectors.areNotificationsLoading, value: false }, + { selector: SettingsSelectors.getProjectDetails, value: projectDetails }, + { selector: SettingsSelectors.areProjectDetailsLoading, value: false }, + { selector: SettingsSelectors.hasAdminAccess, value: true }, + { selector: SettingsSelectors.hasWriteAccess, value: true }, + { selector: ViewOnlyLinkSelectors.getViewOnlyLinks, value: [] }, + { selector: ViewOnlyLinkSelectors.isViewOnlyLinksLoading, value: false }, + ]; - routerMock = RouterMockBuilder.create().build(); + function setup(overrides: BaseSetupOverrides = {}) { + const routeBuilder = ActivatedRouteMockBuilder.create().withParams(overrides.routeParams ?? { id: projectId }); - toastServiceMock = ToastServiceMockBuilder.create().build(); + if (overrides.hasParent === false) { + routeBuilder.withNoParent(); + } - customConfirmationServiceMock = CustomConfirmationServiceMockBuilder.create().build(); + routerMock = RouterMockBuilder.create().build(); + toastService = ToastServiceMock.simple(); + customConfirmationService = CustomConfirmationServiceMock.simple(); + deleteResourceService = DeleteResourceServiceMock.simple(); TestBed.configureTestingModule({ imports: [ @@ -83,98 +128,183 @@ describe.skip('SettingsComponent', () => { ], providers: [ provideOSFCore(), - MockProvider(ActivatedRoute, activatedRouteMock), + MockProvider(ActivatedRoute, routeBuilder.build()), MockProvider(Router, routerMock), - MockProvider(CustomConfirmationService, customConfirmationServiceMock), - MockProvider(LoaderService, mockLoaderService), - MockProvider(ToastService, toastServiceMock), - provideMockStore({ - signals: [ - { selector: SettingsSelectors.getSettings, value: mockSettings }, - { selector: SettingsSelectors.getNotificationSubscriptions, value: [] }, - { selector: SettingsSelectors.areNotificationsLoading, value: false }, - { selector: SettingsSelectors.getProjectDetails, value: mockProjectDetails }, - { selector: SettingsSelectors.areProjectDetailsLoading, value: false }, - { selector: ViewOnlyLinkSelectors.getViewOnlyLinks, value: [] }, - { selector: ViewOnlyLinkSelectors.isViewOnlyLinksLoading, value: false }, - ], - }), + MockProvider(CustomConfirmationService, customConfirmationService), + MockProvider(DeleteResourceService, deleteResourceService), + MockProvider(LoaderService, new LoaderServiceMock()), + MockProvider(ToastService, toastService), + provideMockStore({ signals: mergeSignalOverrides(defaultSignals, overrides.selectorOverrides) }), ], }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(SettingsComponent); component = fixture.componentInstance; - - mockLoaderService = TestBed.inject(LoaderService); - }); + fixture.detectChanges(); + } it('should create', () => { - fixture.detectChanges(); + setup(); + expect(component).toBeTruthy(); }); - it('should initialize with project ID from route', () => { - fixture.detectChanges(); - expect(component.projectId()).toBe(mockProjectId); + it('should load project settings from the parent route', () => { + setup(); + + expect(component.projectId()).toBe(projectId); + expect(store.dispatch).toHaveBeenCalledWith(new GetProjectSettings(projectId)); + expect(store.dispatch).toHaveBeenCalledWith(new GetProjectNotificationSubscriptions(projectId)); + expect(store.dispatch).toHaveBeenCalledWith(new GetProjectDetails(projectId)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchViewOnlyLinks(projectId, ResourceType.Project)); }); - it('should not call actions on ngOnInit when project ID is undefined', () => { - expect(typeof component.ngOnInit).toBe('function'); + it('should not load project settings when the route has no parent', () => { + setup({ hasParent: false }); + + expect(component.projectId()).toBeUndefined(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetProjectSettings)); }); - it('should handle access request change', () => { - component.onAccessRequestChange(true); + it('should apply saved settings', () => { + setup(); + expect(component.accessRequest()).toBe(true); + expect(component.wikiEnabled()).toBe(false); + expect(component.anyoneCanEditWiki()).toBe(false); }); - it('should handle wiki request change', () => { + it('should update access requests', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.onAccessRequestChange(false); + + expect(component.accessRequest()).toBe(false); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateProjectSettings({ + id: projectId, + type: 'node-settings', + attributes: { access_requests_enabled: false }, + } as ProjectSettingsDataJsonApi) + ); + expect(toastService.showSuccess).toHaveBeenCalledWith('myProjects.settings.updateProjectSettingsMessage'); + }); + + it('should refresh the current resource when wiki access changes', () => { + setup(); + (store.dispatch as Mock).mockClear(); + component.onWikiRequestChange(true); - expect(component.wikiEnabled()).toBe(true); + + expect(store.dispatch).toHaveBeenCalledWith(new GetResource(projectId, true)); }); - it('should handle anyone can edit wiki request change', () => { - component.onAnyoneCanEditWikiRequestChange(true); - expect(component.anyoneCanEditWiki()).toBe(true); + it('should not update project details when they are unchanged', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.submitForm({ title: projectDetails.title, description: projectDetails.description }); + + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should handle delete link item', () => { - const mockLink = MOCK_VIEW_ONLY_LINK; + it('should update project details when they change', () => { + setup(); + (store.dispatch as Mock).mockClear(); - component.deleteLinkItem(mockLink); + component.submitForm({ title: 'New Title', description: 'New Description' }); - expect(customConfirmationServiceMock.confirmDelete).toHaveBeenCalledWith({ - headerKey: 'myProjects.settings.delete.title', - headerParams: { name: mockLink.name }, - messageKey: 'myProjects.settings.delete.message', - onConfirm: expect.any(Function), - }); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateProjectDetails({ + data: { + type: 'nodes', + id: projectId, + attributes: { title: 'New Title', description: 'New Description' }, + }, + } as UpdateProjectDetails['payload']) + ); + expect(toastService.showSuccess).toHaveBeenCalledWith('myProjects.settings.updateProjectDetailsMessage'); + }); + + it('should delete a view-only link after confirmation', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.deleteLinkItem(MOCK_VIEW_ONLY_LINK); + customConfirmationService.confirmDelete.mock.calls[0][0].onConfirm(); + + expect(customConfirmationService.confirmDelete).toHaveBeenCalledWith( + expect.objectContaining({ + headerKey: 'myProjects.settings.delete.title', + headerParams: { name: MOCK_VIEW_ONLY_LINK.name }, + messageKey: 'myProjects.settings.delete.message', + }) + ); + expect(store.dispatch).toHaveBeenCalledWith( + new DeleteViewOnlyLink(projectId, ResourceType.Project, MOCK_VIEW_ONLY_LINK.id) + ); + expect(toastService.showSuccess).toHaveBeenCalledWith('myProjects.settings.viewOnlyLinkDeleted'); }); - it('should handle delete project', () => { - expect(() => component.deleteProject()).not.toThrow(); - expect(customConfirmationServiceMock.confirmDelete).toHaveBeenCalled(); + it('should delete the project and navigate home on success', () => { + setup(); + + component.deleteProject(); + deleteResourceService.deleteProject.mock.calls[0][0].onSuccess(); + + expect(deleteResourceService.deleteProject).toHaveBeenCalledWith( + expect.objectContaining({ + rootParentId: 'root-1', + resourceId: projectId, + }) + ); + expect(routerMock.navigate).toHaveBeenCalledWith(['/']); }); - it('should submit form when project details change', () => { - const mockFormData = { title: 'New Title', description: 'New Description' }; - expect(() => component.submitForm(mockFormData)).not.toThrow(); + it('should not delete the project when the route has no parent', () => { + setup({ hasParent: false }); + + component.deleteProject(); + + expect(deleteResourceService.deleteProject).not.toHaveBeenCalled(); }); - it('should not submit form when project details are unchanged', () => { - const mockFormData = { title: 'Same Title', description: 'Same Description' }; + it('should update a notification subscription', () => { + setup(); + (store.dispatch as Mock).mockClear(); - Object.defineProperty(component, 'projectDetails', { - value: () => ({ title: 'Same Title', description: 'Same Description' }), - writable: true, + component.onNotificationRequestChange({ + event: SubscriptionEvent.FileUpdated, + frequency: SubscriptionFrequency.Daily, }); - expect(() => component.submitForm(mockFormData)).not.toThrow(); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateProjectNotificationSubscription({ + id: `${projectId}_${SubscriptionEvent.FileUpdated}`, + frequency: SubscriptionFrequency.Daily, + }) + ); + expect(toastService.showSuccess).toHaveBeenCalledWith('myProjects.settings.updateProjectSettingsMessage'); }); - it('should initialize signals with default values', () => { - expect(component.accessRequest()).toBe(false); - expect(component.wikiEnabled()).toBe(false); - expect(component.anyoneCanEditWiki()).toBe(false); - expect(component.anyoneCanComment()).toBe(false); + it('should remove an affiliation after confirmation', () => { + setup(); + (store.dispatch as Mock).mockClear(); + const affiliation = { id: 'inst-1', name: 'Center' } as Institution; + + component.removeAffiliation(affiliation); + customConfirmationService.confirmDelete.mock.calls[0][0].onConfirm(); + + expect(customConfirmationService.confirmDelete).toHaveBeenCalledWith( + expect.objectContaining({ + headerKey: 'project.deleteInstitution.title', + messageKey: 'project.deleteInstitution.message', + messageParams: { name: 'Center' }, + }) + ); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteInstitution('inst-1', projectId)); + expect(toastService.showSuccess).toHaveBeenCalledWith('project.deleteInstitution.success'); }); }); diff --git a/src/app/features/project/settings/settings.component.ts b/src/app/features/project/settings/settings.component.ts index d697fe844..350f08fa4 100644 --- a/src/app/features/project/settings/settings.component.ts +++ b/src/app/features/project/settings/settings.component.ts @@ -4,12 +4,10 @@ import { TranslatePipe } from '@ngx-translate/core'; import { map, of } from 'rxjs'; -import { ChangeDetectionStrategy, Component, effect, inject, OnInit, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; +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 { ResourceType } from '@osf/shared/enums/resource-type.enum'; @@ -19,14 +17,13 @@ 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'; -import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { LoaderService } from '@osf/shared/services/loader.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { GetResource, GetResourceWithChildren } from '@osf/shared/stores/current-resource'; +import { GetResource } from '@osf/shared/stores/current-resource'; import { DeleteViewOnlyLink, FetchViewOnlyLinks, ViewOnlyLinkSelectors } from '@osf/shared/stores/view-only-links'; +import { DeleteResourceService } from './services/delete-resource.service'; import { - DeleteProjectDialogComponent, ProjectSettingNotificationsComponent, SettingsAccessRequestsCardComponent, SettingsProjectAffiliationComponent, @@ -38,7 +35,6 @@ import { import { ProjectDetailsModel, ProjectSettingsAttributesJsonApi, ProjectSettingsDataJsonApi } from './models'; import { DeleteInstitution, - DeleteProject, GetProjectDetails, GetProjectNotificationSubscriptions, GetProjectSettings, @@ -53,8 +49,6 @@ import { imports: [ TranslatePipe, SubHeaderComponent, - FormsModule, - ReactiveFormsModule, SettingsProjectFormCardComponent, SettingsStorageLocationCardComponent, SettingsViewOnlyLinksCardComponent, @@ -70,14 +64,15 @@ import { }) export class SettingsComponent implements OnInit { private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); private readonly customConfirmationService = inject(CustomConfirmationService); - private readonly customDialogService = inject(CustomDialogService); + private readonly deleteResourceService = inject(DeleteResourceService); private readonly toastService = inject(ToastService); private readonly loaderService = inject(LoaderService); readonly projectId = toSignal(this.route.parent?.params.pipe(map((params) => params['id'])) ?? of(undefined)); - currentUser = select(UserSelectors.getCurrentUser); settings = select(SettingsSelectors.getSettings); notifications = select(SettingsSelectors.getNotificationSubscriptions); areNotificationsLoading = select(SettingsSelectors.areNotificationsLoading); @@ -97,16 +92,13 @@ export class SettingsComponent implements OnInit { updateProjectSettings: UpdateProjectSettings, updateNotificationSubscription: UpdateProjectNotificationSubscription, deleteViewOnlyLink: DeleteViewOnlyLink, - deleteProject: DeleteProject, deleteInstitution: DeleteInstitution, refreshCurrentResource: GetResource, - getComponentsTree: GetResourceWithChildren, }); accessRequest = signal(false); wikiEnabled = signal(false); anyoneCanEditWiki = signal(false); - anyoneCanComment = signal(false); constructor() { this.setupEffects(); @@ -184,19 +176,17 @@ export class SettingsComponent implements OnInit { } deleteProject(): void { - this.loaderService.show(); + const projectId = this.projectId(); + const projectDetails = this.projectDetails(); - this.actions - .getComponentsTree(this.projectDetails()?.rootId || this.projectId(), this.projectId(), ResourceType.Project) - .subscribe({ - next: () => { - this.loaderService.hide(); - this.customDialogService.open(DeleteProjectDialogComponent, { - header: 'project.deleteProject.dialog.deleteProject', - width: '500px', - }); - }, - }); + if (!projectId) return; + + this.deleteResourceService.deleteProject({ + rootParentId: projectDetails?.rootId || projectId, + resourceId: projectId, + destroyRef: this.destroyRef, + onSuccess: () => this.router.navigate(['/']), + }); } removeAffiliation(affiliation: Institution): void { @@ -248,7 +238,6 @@ export class SettingsComponent implements OnInit { this.accessRequest.set(settings.attributes.accessRequestsEnabled); this.wikiEnabled.set(settings.attributes.wikiEnabled); this.anyoneCanEditWiki.set(settings.attributes.anyoneCanEditWiki); - this.anyoneCanComment.set(settings.attributes.anyoneCanComment); } }); diff --git a/src/app/features/project/settings/store/settings.state.ts b/src/app/features/project/settings/store/settings.state.ts index 21b038480..10b6128c4 100644 --- a/src/app/features/project/settings/store/settings.state.ts +++ b/src/app/features/project/settings/store/settings.state.ts @@ -9,7 +9,7 @@ import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { NotificationSubscription } from '@osf/shared/models/notifications/notification-subscription.model'; -import { SettingsService } from '../services'; +import { SettingsService } from '../services/settings.service'; import { DeleteInstitution, diff --git a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.html b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.html index 0b5609eea..804305b96 100644 --- a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.html +++ b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.html @@ -1,5 +1,14 @@ @if (resourceId()) {
+ @if (isAuthenticated() && !viewOnly()) { + + } + @if (isAuthenticated()) { { + let component: RegistrationOverviewToolbarComponent; + let fixture: ComponentFixture; + let store: Store; + let routerMock: RouterMockType; + let toastService: ToastServiceMockType; + let forkDialogService: ForkDialogServiceMockType; + + const bookmarkedItem = { id: 'reg-1' } as MyResourcesItem; + + function setup(overrides: SetupOverrides = {}) { + routerMock = RouterMockBuilder.create().build(); + toastService = ToastServiceMock.simple(); + forkDialogService = ForkDialogServiceMock.simple(); + + const defaultSignals = [ + { selector: BookmarksSelectors.getBookmarksCollectionId, value: 'bookmarks-1' }, + { selector: BookmarksSelectors.getBookmarks, value: [] }, + { selector: BookmarksSelectors.areBookmarksLoading, value: false }, + { selector: BookmarksSelectors.getBookmarksCollectionIdSubmitting, value: false }, + { selector: UserSelectors.isAuthenticated, value: true }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, + ]; + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); + + TestBed.configureTestingModule({ + imports: [ + RegistrationOverviewToolbarComponent, + ...MockComponents(ForkMenuButtonComponent, SocialsShareButtonComponent), + ], + providers: [ + provideOSFCore(), + provideMockStore({ signals }), + MockProvider(Router, routerMock), + MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().build()), + MockProvider(ToastService, toastService), + MockProvider(ForkDialogService, forkDialogService), + ], + }); + + store = TestBed.inject(Store); + fixture = TestBed.createComponent(RegistrationOverviewToolbarComponent); + component = fixture.componentInstance; + + fixture.componentRef.setInput('resourceId', overrides.resourceId ?? 'reg-1'); + fixture.componentRef.setInput('resourceTitle', 'Test Registration'); + fixture.componentRef.setInput('isPublic', overrides.isPublic ?? true); + fixture.componentRef.setInput('viewOnly', overrides.viewOnly ?? false); + fixture.detectChanges(); + } - return { fixture, component: fixture.componentInstance, store, mockToastService }; -} + function query(selector: string): HTMLElement | null { + return fixture.nativeElement.querySelector(selector); + } -describe('RegistrationOverviewToolbarComponent', () => { it('should dispatch GetResourceBookmark on init', () => { - const { store } = setup(); + setup(); expect(store.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - bookmarkCollectionId: MOCK_BOOKMARKS_COLLECTION_ID, - resourceId: MOCK_RESOURCE_ID, - resourceType: ResourceType.Registration, - }) + new GetResourceBookmark('bookmarks-1', 'reg-1', ResourceType.Registration) ); }); - it('should not dispatch GetResourceBookmark when bookmarksCollectionId is null', () => { - const { store } = setup({ bookmarksCollectionId: null }); + it('should not dispatch GetResourceBookmark without bookmarks collection id', () => { + setup({ selectorOverrides: [{ selector: BookmarksSelectors.getBookmarksCollectionId, value: null }] }); - expect(store.dispatch).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetResourceBookmark)); }); - it('should compute isBookmarked from bookmarks', () => { - const { component } = setup({ bookmarks: [{ id: MOCK_RESOURCE_ID }] }); + it('should render fork menu, bookmark and share buttons for authenticated user', () => { + setup(); - expect(component.isBookmarked()).toBe(true); + expect(query('osf-fork-menu-button')).toBeTruthy(); + expect(query('p-button')).toBeTruthy(); + expect(query('osf-socials-share-button')).toBeTruthy(); }); - it('should compute isBookmarked as false when not in bookmarks', () => { - const { component } = setup({ bookmarks: [{ id: 'other-id' }] }); + it('should render nothing when resourceId is empty', () => { + setup({ resourceId: '' }); - expect(component.isBookmarked()).toBe(false); + expect(query('div')).toBeNull(); }); - it('should dispatch add bookmark when not bookmarked', () => { - const { component, store, mockToastService } = setup(); - vi.spyOn(store, 'dispatch').mockReturnValue(of(undefined)); + it('should hide fork menu in view only mode', () => { + setup({ viewOnly: true }); + + expect(query('osf-fork-menu-button')).toBeNull(); + expect(query('p-button')).toBeTruthy(); + }); + + it('should hide fork menu and bookmark button for unauthenticated user', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isAuthenticated, value: false }] }); + + expect(query('osf-fork-menu-button')).toBeNull(); + expect(query('p-button')).toBeNull(); + }); + + it('should hide share button for private registration', () => { + setup({ isPublic: false }); + + expect(query('osf-socials-share-button')).toBeNull(); + }); + + it('should compute isBookmarked from bookmarks', () => { + setup({ selectorOverrides: [{ selector: BookmarksSelectors.getBookmarks, value: [bookmarkedItem] }] }); + + expect(component.isBookmarked()).toBe(true); + }); + + it('should add bookmark and show toast when not bookmarked', () => { + setup(); + (store.dispatch as Mock).mockClear(); component.toggleBookmark(); expect(store.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - bookmarkCollectionId: MOCK_BOOKMARKS_COLLECTION_ID, - resourceId: MOCK_RESOURCE_ID, - resourceType: ResourceType.Registration, - }) + new AddResourceToBookmarks('bookmarks-1', 'reg-1', ResourceType.Registration) ); - expect(mockToastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.bookmark.add'); + expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.bookmark.add'); }); - it('should dispatch remove bookmark when already bookmarked', () => { - const { component, store, mockToastService } = setup({ bookmarks: [{ id: MOCK_RESOURCE_ID }] }); - vi.spyOn(store, 'dispatch').mockReturnValue(of(undefined)); + it('should remove bookmark and show toast when bookmarked', () => { + setup({ selectorOverrides: [{ selector: BookmarksSelectors.getBookmarks, value: [bookmarkedItem] }] }); + (store.dispatch as Mock).mockClear(); component.toggleBookmark(); expect(store.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - bookmarkCollectionId: MOCK_BOOKMARKS_COLLECTION_ID, - resourceId: MOCK_RESOURCE_ID, - resourceType: ResourceType.Registration, - }) + new RemoveResourceFromBookmarks('bookmarks-1', 'reg-1', ResourceType.Registration) ); - expect(mockToastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.bookmark.remove'); + expect(toastService.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(); - vi.spyOn(store, 'dispatch').mockClear().mockReturnValue(of(undefined)); + it('should not toggle bookmark without bookmarks collection id', () => { + setup({ selectorOverrides: [{ selector: BookmarksSelectors.getBookmarksCollectionId, value: null }] }); + (store.dispatch as Mock).mockClear(); - fixture.componentInstance.toggleBookmark(); + component.toggleBookmark(); expect(store.dispatch).not.toHaveBeenCalled(); - expect(mockToastService.showSuccess).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + }); + + it('should navigate to duplicates view', () => { + setup(); + + component.forkActionItems[1].command?.({}); + + expect(routerMock.navigate).toHaveBeenCalledWith(['../analytics/duplicates'], { + relativeTo: TestBed.inject(ActivatedRoute), + }); + }); + + it('should open fork confirmation for registration', () => { + setup(); + + component.forkActionItems[0].command?.({}); + + expect(forkDialogService.confirmFork).toHaveBeenCalledWith( + expect.objectContaining({ resourceType: CurrentResourceType.Registrations }) + ); + }); + + it('should dispatch ForkRegistry when fork is confirmed', () => { + setup(); + component.forkActionItems[0].command?.({}); + const [options] = forkDialogService.confirmFork.mock.calls[0]; + (store.dispatch as Mock).mockClear(); + + options.fork(); + + expect(store.dispatch).toHaveBeenCalledWith(new ForkRegistry('reg-1')); }); }); 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 00640ee98..29fba9988 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 @@ -2,15 +2,20 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; +import { MenuItem } from 'primeng/api'; import { Button } from 'primeng/button'; import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, input } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; import { UserSelectors } from '@core/store/user'; +import { ForkRegistry } from '@osf/features/registry/store/registry'; +import { ForkMenuButtonComponent } from '@osf/shared/components/fork-menu-button/fork-menu-button.component'; import { SocialsShareButtonComponent } from '@osf/shared/components/socials-share-button/socials-share-button.component'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { ForkDialogService } from '@osf/shared/services/fork-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { AddResourceToBookmarks, @@ -21,18 +26,23 @@ import { @Component({ selector: 'osf-registration-overview-toolbar', - imports: [Button, Tooltip, SocialsShareButtonComponent, TranslatePipe], + imports: [Button, Tooltip, SocialsShareButtonComponent, ForkMenuButtonComponent, TranslatePipe], templateUrl: './registration-overview-toolbar.component.html', styleUrl: './registration-overview-toolbar.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistrationOverviewToolbarComponent { private readonly toastService = inject(ToastService); + private readonly forkDialogService = inject(ForkDialogService); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); readonly resourceId = input.required(); readonly resourceTitle = input.required(); readonly isPublic = input(false); + readonly forksCount = input(0); + readonly viewOnly = input(false); readonly resourceType = ResourceType.Registration; @@ -41,6 +51,7 @@ export class RegistrationOverviewToolbarComponent { readonly isBookmarksLoading = select(BookmarksSelectors.areBookmarksLoading); readonly isBookmarksSubmitting = select(BookmarksSelectors.getBookmarksCollectionIdSubmitting); readonly isAuthenticated = select(UserSelectors.isAuthenticated); + readonly preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled); readonly isBookmarked = computed( () => this.bookmarks()?.some((bookmark) => bookmark.id === this.resourceId()) ?? false @@ -50,8 +61,20 @@ export class RegistrationOverviewToolbarComponent { getResourceBookmark: GetResourceBookmark, addResourceToBookmarks: AddResourceToBookmarks, removeResourceFromBookmarks: RemoveResourceFromBookmarks, + forkRegistry: ForkRegistry, }); + readonly forkActionItems: MenuItem[] = [ + { + label: 'project.overview.actions.duplicateRegistration', + command: () => this.handleForkResource(), + }, + { + label: 'project.overview.actions.viewDuplication', + command: () => this.navigateToDuplicatesView(), + }, + ]; + constructor() { effect(() => { const bookmarksCollectionId = this.bookmarksCollectionId(); @@ -77,4 +100,18 @@ export class RegistrationOverviewToolbarComponent { action.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.toastService.showSuccess(toastKey)); } + + navigateToDuplicatesView(): void { + this.router.navigate(['../analytics/duplicates'], { relativeTo: this.route }); + } + + private handleForkResource(): void { + if (!this.resourceId()) return; + + this.forkDialogService.confirmFork({ + resourceType: CurrentResourceType.Registrations, + destroyRef: this.destroyRef, + fork: () => this.actions.forkRegistry(this.resourceId()), + }); + } } 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 480e1d155..2da584ea9 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 @@ -1,5 +1,5 @@
- @for (action of reviewActions(); track $index) { + @for (action of decisionActions(); track $index) {

@switch (action.toState) { @case (SubmissionReviewStatus.Pending) { @@ -63,6 +63,17 @@ }

+ +
+ + +
@@ -92,6 +103,17 @@

{{ 'moderation.makeDecision.forceWithdrawMessage' | translate }}

+ +
+ + +
diff --git a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.spec.ts b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.spec.ts index 8f4d69620..0cd9737e0 100644 --- a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.spec.ts +++ b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.spec.ts @@ -13,11 +13,13 @@ import { RegistrationReviewStates } from '@osf/shared/enums/registration-review- import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; import { ReviewActionTrigger, SchemaResponseActionTrigger } from '@osf/shared/enums/trigger-action.enum'; import { DateAgoPipe } from '@osf/shared/pipes/date-ago.pipe'; +import { ToastService } from '@osf/shared/services/toast.service'; import { MOCK_REGISTRATION_OVERVIEW_MODEL } from '@testing/mocks/registration-overview-model.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; import { RegistrySelectors } from '../../store/registry'; @@ -43,6 +45,7 @@ function setup(overrides: SetupOverrides = {}) { providers: [ provideOSFCore(), provideDynamicDialogRefMock(), + MockProvider(ToastService, ToastServiceMock.simple()), MockProvider(DynamicDialogConfig, { data: { registry, revisionId } }), provideMockStore({ signals: [ diff --git a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.ts b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.ts index b823b9a47..7f7b712cf 100644 --- a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.ts +++ b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.ts @@ -9,7 +9,7 @@ import { RadioButton } from 'primeng/radiobutton'; import { Textarea } from 'primeng/textarea'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; @@ -45,6 +45,9 @@ export class RegistryMakeDecisionComponent { readonly decisionCommentLimit = InputLimits.decisionComment.maxLength; readonly reviewActions = select(RegistrySelectors.getReviewActions); + readonly decisionActions = computed(() => + this.reviewActions().filter((action) => action.trigger !== ReviewActionTrigger.ReportSpam) + ); readonly isSubmitting = select(RegistrySelectors.isReviewActionSubmitting); private readonly actions = createDispatchMap({ submitDecision: SubmitDecision }); @@ -94,12 +97,14 @@ export class RegistryMakeDecisionComponent { } handleSubmission(): void { - const revisionId = this.config.data.revisionId; + const action = this.requestForm.value[ModerationDecisionFormControls.Action] ?? ''; + // a spam report is related to the registration and its submitter, so a revision is never its target + const revisionId = action === ReviewActionTrigger.ReportSpam ? null : this.config.data.revisionId; this.actions .submitDecision( { targetId: revisionId ? revisionId : this.registry.id, - action: this.requestForm.value[ModerationDecisionFormControls.Action] ?? '', + action, comment: this.requestForm.value[ModerationDecisionFormControls.Comment] ?? '', }, !!revisionId diff --git a/src/app/features/registry/mappers/registry-overview.mapper.ts b/src/app/features/registry/mappers/registry-overview.mapper.ts index aa051677c..e8307162b 100644 --- a/src/app/features/registry/mappers/registry-overview.mapper.ts +++ b/src/app/features/registry/mappers/registry-overview.mapper.ts @@ -13,6 +13,6 @@ export function MapRegistrationOverview(data: RegistrationOverviewDataJsonApi): providerId: data.relationships?.provider?.data?.id, rootParentId: data.relationships.root?.data?.id, status: MapRegistryStatus(data.attributes), - forksCount: 0, + forksCount: data.relationships.forks?.links.related.meta.count ?? 0, }; } diff --git a/src/app/features/registry/models/registration-overview-json-api.model.ts b/src/app/features/registry/models/registration-overview-json-api.model.ts index 4ea2531dc..4a4919ed5 100644 --- a/src/app/features/registry/models/registration-overview-json-api.model.ts +++ b/src/app/features/registry/models/registration-overview-json-api.model.ts @@ -1,4 +1,8 @@ -import { RelationshipLinks, ToOneRelData } from '@osf/shared/models/common/json-api/relationships.model'; +import { + RelatedCountRel, + RelationshipLinks, + ToOneRelData, +} from '@osf/shared/models/common/json-api/relationships.model'; import { JsonApiResource } from '@osf/shared/models/common/json-api/resource.model'; import { ItemResponse } from '@shared/models/common/json-api/responses.model'; import { RegistrationNodeAttributesJsonApi } from '@shared/models/registration/registration-node-json-api.model'; @@ -13,6 +17,7 @@ export interface RegistrationOverviewDataJsonApi extends JsonApiResource< } interface RegistryOverviewRelationshipsJsonApi { + forks?: RelatedCountRel; license: ToOneRelData<'licenses'>; provider: ToOneRelData<'registration-providers'>; registered_from: ToOneRelData<'nodes'>; diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.html b/src/app/features/registry/pages/registry-overview/registry-overview.component.html index 4712f0918..7e9be2e36 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.html +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.html @@ -1,9 +1,11 @@ -@if (!isRegistryLoading()) { +@let currentRegistry = registry(); + +@if (!isRegistryLoading() && currentRegistry) {
}
- @if (registry()?.archiving) { + @if (currentRegistry.archiving) {
- +
- } @else if (registry()?.withdrawn) { + } @else if (currentRegistry.withdrawn) {
- +
} @else {
- @if (schemaResponse() && !schemaResponse()?.isOriginalResponse && !isInitialState()) { + @let currentSchemaResponse = schemaResponse(); + + @if (currentSchemaResponse && !currentSchemaResponse.isOriginalResponse && !isInitialState()) {

{{ 'registry.overview.updatedRevisionDescription' | translate }} - {{ schemaResponse()?.dateCreated | date }}. + {{ currentSchemaResponse.dateCreated | date }}.

{{ 'registry.overview.updateReason' | translate }}

- {{ schemaResponse()?.revisionJustification }} + {{ currentSchemaResponse.revisionJustification }}

} - @if (hasViewOnly()) { + @if (hasViewOnly) { }
@@ -70,13 +76,13 @@

@if (!isAnonymous()) {

diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts index d8833dd28..408ee567b 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts @@ -15,11 +15,15 @@ import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/ import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-link-message/view-only-link-message.component'; import { RegistrationReviewStates } from '@osf/shared/enums/registration-review-states.enum'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { SignpostingService } from '@osf/shared/services/signposting.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; +import { GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks'; +import { GetBibliographicContributors } from '@osf/shared/stores/contributors'; import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; import { MOCK_REGISTRATION_OVERVIEW_MODEL } from '@testing/mocks/registration-overview-model.mock'; @@ -29,6 +33,7 @@ import { CustomDialogServiceMock } from '@testing/providers/custom-dialog-provid import { LoaderServiceMock, provideLoaderServiceMock } from '@testing/providers/loader-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { SignpostingServiceMock } from '@testing/providers/signposting-provider.mock'; import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; import { ViewOnlyLinkHelperMock } from '@testing/providers/view-only-link-helper.mock'; @@ -41,7 +46,7 @@ import { RegistryOverviewMetadataComponent } from '../../components/registry-ove import { RegistryRevisionsComponent } from '../../components/registry-revisions/registry-revisions.component'; import { RegistryStatusesComponent } from '../../components/registry-statuses/registry-statuses.component'; import { WithdrawnMessageComponent } from '../../components/withdrawn-message/withdrawn-message.component'; -import { RegistrySelectors } from '../../store/registry'; +import { GetRegistryById, GetRegistrySchemaResponses, GetSchemaBlocks, RegistrySelectors } from '../../store/registry'; import { RegistryOverviewComponent } from './registry-overview.component'; @@ -59,15 +64,21 @@ function setup(overrides: SetupOverrides = {}) { const schemaResponses = overrides.schemaResponses ?? []; const mockRouter = RouterMockBuilder.create().withUrl('/registries/registry-1').build(); - const mockActivatedRoute = ActivatedRouteMockBuilder.create() + const routeBuilder = ActivatedRouteMockBuilder.create() .withParams({ id: registryId }) - .withQueryParams(overrides.queryParams ?? {}) - .build(); + .withQueryParams(overrides.queryParams ?? {}); + + if (overrides.hasParent === false) { + routeBuilder.withNoParent(); + } + + const mockActivatedRoute = routeBuilder.build(); const mockDialogService = CustomDialogServiceMock.simple(); const mockLoaderService = new LoaderServiceMock(); const mockToastService = ToastServiceMock.simple(); const mockViewOnlyHelper = ViewOnlyLinkHelperMock.simple(overrides.hasViewOnly); + const signpostingService = SignpostingServiceMock.simple(); const signalDefaults = [ { selector: RegistrySelectors.getRegistry, value: registry }, { selector: RegistrySelectors.isRegistryLoading, value: false }, @@ -107,6 +118,7 @@ function setup(overrides: SetupOverrides = {}) { provideLoaderServiceMock(mockLoaderService), MockProvider(ToastService, mockToastService), MockProvider(ViewOnlyLinkHelperService, mockViewOnlyHelper), + MockProvider(SignpostingService, signpostingService), provideMockStore({ signals: mergeSignalOverrides(signalDefaults, overrides.selectorOverrides), }), @@ -125,6 +137,7 @@ function setup(overrides: SetupOverrides = {}) { mockDialogService, mockLoaderService, mockToastService, + signpostingService, }; } @@ -232,10 +245,16 @@ describe('RegistryOverviewComponent', () => { expect(component.isRootRegistration()).toBe(true); }); + it('should compute isRootRegistration as false for a child registration', () => { + const { component } = setup({ registry: MOCK_REGISTRATION_OVERVIEW_MODEL }); + + expect(component.isRootRegistration()).toBe(false); + }); + it('should compute hasViewOnly from ViewOnlyLinkHelperService', () => { const { component } = setup({ registry: MOCK_REGISTRATION_OVERVIEW_MODEL, hasViewOnly: true }); - expect(component.hasViewOnly()).toBe(true); + expect(component.hasViewOnly).toBe(true); }); it('should compute revisionInProgress from schemaResponses', () => { @@ -262,28 +281,56 @@ describe('RegistryOverviewComponent', () => { expect(component.schemaResponse()?.id).toBe('rev-2'); }); - it('should dispatch init actions when registryId is available', () => { + it('should return null schemaResponse when the selected index is missing', () => { + const { component } = setup({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + schemaResponses: [createMockSchemaResponse('rev-1', RevisionReviewStates.Approved)], + }); + + component.openRevision(3); + + expect(component.schemaResponse()).toBeNull(); + }); + + it('should load the registry, contributors, and bookmarks for the route id', () => { const { store } = setup({ registryId: 'reg-1', registry: MOCK_REGISTRATION_OVERVIEW_MODEL }); - expect(store.dispatch).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new GetRegistryById('reg-1')); + expect(store.dispatch).toHaveBeenCalledWith(new GetBibliographicContributors('reg-1', ResourceType.Registration)); + expect(store.dispatch).toHaveBeenCalledWith(new GetBookmarksCollectionId()); + }); + + it('should not load the registry when the parent route is missing', () => { + const { store } = setup({ hasParent: false, registry: MOCK_REGISTRATION_OVERVIEW_MODEL }); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetRegistryById)); + expect(store.dispatch).toHaveBeenCalledWith(new GetBookmarksCollectionId()); + }); + + it('should add signposting for the registry id and remove it on destroy', () => { + const { fixture, signpostingService } = setup({ registryId: 'reg-1', registry: MOCK_REGISTRATION_OVERVIEW_MODEL }); + + expect(signpostingService.addSignposting).toHaveBeenCalledWith('reg-1'); + + fixture.destroy(); + + expect(signpostingService.removeSignpostingLinkTags).toHaveBeenCalled(); }); it('should dispatch schema actions when registry exists and is not withdrawn', () => { const { store } = setup({ registry: MOCK_REGISTRATION_OVERVIEW_MODEL }); - const calls = (store.dispatch as Mock).mock.calls.map((c) => c[0]); - const hasSchemaResponses = calls.some((a) => a.constructor?.name === 'GetRegistrySchemaResponses'); - - expect(hasSchemaResponses).toBe(true); + expect(store.dispatch).toHaveBeenCalledWith( + new GetSchemaBlocks(MOCK_REGISTRATION_OVERVIEW_MODEL.registrationSchemaLink) + ); + expect(store.dispatch).toHaveBeenCalledWith(new GetRegistrySchemaResponses(MOCK_REGISTRATION_OVERVIEW_MODEL.id)); }); it('should not dispatch schema actions when registry is withdrawn', () => { const { store } = setup({ registry: { ...MOCK_REGISTRATION_OVERVIEW_MODEL, withdrawn: true } }); - const calls = (store.dispatch as Mock).mock.calls.map((c) => c[0]); - const hasSchemaResponses = calls.some((a) => a.constructor?.name === 'GetRegistrySchemaResponses'); - - expect(hasSchemaResponses).toBe(false); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetRegistrySchemaResponses)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetSchemaBlocks)); }); it('should call loaderService.show and navigate on onUpdateRegistration', () => { @@ -297,10 +344,11 @@ describe('RegistryOverviewComponent', () => { component.onUpdateRegistration('registry-1'); expect(mockLoaderService.show).toHaveBeenCalled(); + expect(mockLoaderService.hide).toHaveBeenCalled(); expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/revisions/new-rev/justification']); }); - it('should navigate to justification on onContinueUpdateRegistration for approved revision', () => { + it('should navigate to justification on onContinueUpdateRegistration for an in-progress revision', () => { const schemaResponses = [createMockSchemaResponse('rev-1', RevisionReviewStates.RevisionInProgress)]; const { component, mockRouter } = setup({ registry: MOCK_REGISTRATION_OVERVIEW_MODEL, @@ -334,13 +382,15 @@ describe('RegistryOverviewComponent', () => { }); it('should open make decision dialog and handle success response', () => { - const onCloseSubject = new Subject(); + const onCloseSubject = new Subject<{ action: string }>(); const { component, store, mockDialogService, mockToastService, mockRouter } = setup({ registry: { ...MOCK_REGISTRATION_OVERVIEW_MODEL, id: 'reg-1' }, queryParams: { revisionId: 'rev-1' }, }); vi.spyOn(store, 'dispatch').mockReturnValue(of(undefined)); - mockDialogService.open.mockReturnValue({ onClose: onCloseSubject.asObservable() } as any); + mockDialogService.open.mockReturnValue({ + onClose: onCloseSubject.asObservable(), + } as ReturnType); component.handleOpenMakeDecisionDialog(); diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts index fcf3bbac3..4d48fba26 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts @@ -4,7 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Message } from 'primeng/message'; -import { map, of, switchMap } from 'rxjs'; +import { finalize, map, of, switchMap } from 'rxjs'; import { DatePipe } from '@angular/common'; import { @@ -29,6 +29,7 @@ import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-l import { RegistrationReviewStates } from '@osf/shared/enums/registration-review-states.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; +import { ReviewActionTrigger } from '@osf/shared/enums/trigger-action.enum'; import { toCamelCase } from '@osf/shared/helpers/camel-case'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { LoaderService } from '@osf/shared/services/loader.service'; @@ -107,7 +108,7 @@ export class RegistryOverviewComponent implements OnInit, OnDestroy { readonly revisionId = computed(() => (this.queryParams()?.['revisionId'] as string) ?? null); readonly isModeration = computed(() => this.queryParams()?.['mode'] === 'moderator'); - readonly hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); + readonly hasViewOnly = this.viewOnlyService.hasViewOnlyParam(this.router); readonly showToolbar = computed(() => !this.registry()?.archiving && !this.registry()?.withdrawn); readonly isInitialState = computed(() => this.registry()?.reviewsState === RegistrationReviewStates.Initial); readonly canMakeDecision = computed( @@ -116,7 +117,7 @@ export class RegistryOverviewComponent implements OnInit, OnDestroy { readonly canUpdate = computed(() => this.hasAdminAccess() && this.allowUpdates()); - isRootRegistration = computed(() => { + readonly isRootRegistration = computed(() => { const rootId = this.registry()?.rootParentId; return !rootId || rootId === this.registry()?.id; }); @@ -127,9 +128,8 @@ export class RegistryOverviewComponent implements OnInit, OnDestroy { }); readonly schemaResponse = computed(() => { - const index = this.selectedRevisionIndex(); const schemaResponses = this.schemaResponses() || []; - return index !== null ? schemaResponses[index] : null; + return schemaResponses[this.selectedRevisionIndex()] ?? null; }); private readonly registryId = toSignal( @@ -182,7 +182,10 @@ export class RegistryOverviewComponent implements OnInit, OnDestroy { this.loaderService.show(); this.actions .createSchemaResponse(id) - .pipe(takeUntilDestroyed(this.destroyRef)) + .pipe( + finalize(() => this.loaderService.hide()), + takeUntilDestroyed(this.destroyRef) + ) .subscribe({ next: () => { const revisionId = this.currentRevision()?.id; @@ -230,6 +233,12 @@ export class RegistryOverviewComponent implements OnInit, OnDestroy { this.toastService.showSuccess(`moderation.makeDecision.${action}Success`); } + // a spam report leaves the registration untouched, and the reload below drops the + // moderator query param, which would hide the 'Make decision' button + if (data.action === ReviewActionTrigger.ReportSpam) { + return; + } + const currentUrl = this.router.url.split('?')[0]; this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => { this.router.navigateByUrl(currentUrl); diff --git a/src/app/features/registry/pages/registry-resources/registry-resources.component.spec.ts b/src/app/features/registry/pages/registry-resources/registry-resources.component.spec.ts index a251a0542..42a4706cb 100644 --- a/src/app/features/registry/pages/registry-resources/registry-resources.component.spec.ts +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.spec.ts @@ -2,10 +2,7 @@ import { Store } from '@ngxs/store'; import { MockComponents, MockProvider } from 'ng-mocks'; -import { Button } from 'primeng/button'; -import { DynamicDialogRef } from 'primeng/dynamicdialog'; - -import { Subject, throwError } from 'rxjs'; +import { Subject } from 'rxjs'; import { Mock } from 'vitest'; @@ -66,8 +63,6 @@ function setup(overrides: BaseSetupOverrides = {}) { { selector: RegistryResourcesSelectors.getResources, value: [] }, { selector: RegistryResourcesSelectors.getResourcesTotalCount, value: 0 }, { selector: RegistryResourcesSelectors.isResourcesLoading, value: false }, - { selector: RegistryResourcesSelectors.getCurrentResource, value: null }, - { selector: RegistrySelectors.getRegistry, value: null }, { selector: RegistrySelectors.getIdentifiers, value: [MOCK_PROJECT_IDENTIFIERS] }, { selector: RegistrySelectors.hasWriteAccess, value: true }, ]; @@ -77,7 +72,7 @@ function setup(overrides: BaseSetupOverrides = {}) { TestBed.configureTestingModule({ imports: [ RegistryResourcesComponent, - ...MockComponents(Button, LoadingSpinnerComponent, SubHeaderComponent, IconComponent, CustomPaginatorComponent), + ...MockComponents(LoadingSpinnerComponent, SubHeaderComponent, IconComponent, CustomPaginatorComponent), ], providers: [ provideOSFCore(), @@ -170,15 +165,6 @@ describe('RegistryResourcesComponent', () => { expect(component.isAddingResource()).toBe(false); }); - it('should show an error toast when addResource fails', () => { - const { component, store, mockToastService } = setup(); - - vi.spyOn(store, 'dispatch').mockReturnValue(throwError(() => new Error('fail'))); - component.addResource(); - - expect(mockToastService.showError).toHaveBeenCalledWith('resources.toastMessages.addResourceError'); - }); - it('should update a resource and show a success toast', () => { const { component, dialogClose$, mockDialogService, mockToastService } = setup(); @@ -196,20 +182,6 @@ describe('RegistryResourcesComponent', () => { expect(mockToastService.showSuccess).toHaveBeenCalledWith('resources.toastMessages.updatedResourceSuccess'); }); - it('should show an error toast when updateResource fails', () => { - const errorSubject = new Subject(); - const { component, mockDialogService, mockToastService } = setup(); - - mockDialogService.open.mockReturnValue({ - onClose: errorSubject.pipe(), - close: vi.fn(), - } as unknown as DynamicDialogRef); - component.updateResource(MOCK_RESOURCE); - errorSubject.error(new Error('fail')); - - expect(mockToastService.showError).toHaveBeenCalledWith('resources.toastMessages.updateResourceError'); - }); - it('should delete a resource, reset pagination, and show a success toast', () => { const { component, mockConfirmationService, mockToastService, store } = setup(); diff --git a/src/app/features/registry/pages/registry-resources/registry-resources.component.ts b/src/app/features/registry/pages/registry-resources/registry-resources.component.ts index 00f559dbd..ffa227a29 100644 --- a/src/app/features/registry/pages/registry-resources/registry-resources.component.ts +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.ts @@ -68,8 +68,6 @@ export class RegistryResourcesComponent { readonly resources = select(RegistryResourcesSelectors.getResources); readonly resourcesTotalCount = select(RegistryResourcesSelectors.getResourcesTotalCount); readonly isResourcesLoading = select(RegistryResourcesSelectors.isResourcesLoading); - readonly currentResource = select(RegistryResourcesSelectors.getCurrentResource); - readonly registry = select(RegistrySelectors.getRegistry); readonly identifiers = select(RegistrySelectors.getIdentifiers); readonly canEdit = select(RegistrySelectors.hasWriteAccess); @@ -126,7 +124,6 @@ export class RegistryResourcesComponent { this.resetPagination(); this.toastService.showSuccess('resources.toastMessages.addResourceSuccess'); }, - error: () => this.toastService.showError('resources.toastMessages.addResourceError'), }); } @@ -146,7 +143,6 @@ export class RegistryResourcesComponent { ) .subscribe({ next: () => this.toastService.showSuccess('resources.toastMessages.updatedResourceSuccess'), - error: () => this.toastService.showError('resources.toastMessages.updateResourceError'), }); } diff --git a/src/app/features/registry/registry.routes.ts b/src/app/features/registry/registry.routes.ts index 06109e3b9..0182a4efa 100644 --- a/src/app/features/registry/registry.routes.ts +++ b/src/app/features/registry/registry.routes.ts @@ -13,6 +13,7 @@ import { ViewOnlyLinkState } from '@osf/shared/stores/view-only-links'; import { ActivityLogsState } from '@shared/stores/activity-logs'; import { AnalyticsState } from '../analytics/store'; +import { SettingsState } from '../project/settings/store'; import { RegistryState } from './store/registry'; import { RegistryComponentsState } from './store/registry-components'; @@ -73,7 +74,7 @@ export const registryRoutes: Routes = [ import('../analytics/components/view-duplicates/view-duplicates.component').then( (mod) => mod.ViewDuplicatesComponent ), - providers: [provideStates([DuplicatesState])], + providers: [provideStates([DuplicatesState, SettingsState])], }, { path: 'files', diff --git a/src/app/features/registry/services/registry-overview.service.ts b/src/app/features/registry/services/registry-overview.service.ts index 2d69607af..ee8bfeb18 100644 --- a/src/app/features/registry/services/registry-overview.service.ts +++ b/src/app/features/registry/services/registry-overview.service.ts @@ -46,7 +46,9 @@ export class RegistryOverviewService { } getRegistrationById(id: string): Observable { - return this.jsonApiService.get(`${this.apiUrl}/registrations/${id}/`).pipe( + const params = { related_counts: 'forks' }; + + return this.jsonApiService.get(`${this.apiUrl}/registrations/${id}/`, params).pipe( map((response) => ({ registry: MapRegistrationOverview(response.data), isAnonymous: response.meta?.anonymous ?? false, diff --git a/src/app/features/registry/store/registry/registry.actions.ts b/src/app/features/registry/store/registry/registry.actions.ts index 70b702ffb..54723554a 100644 --- a/src/app/features/registry/store/registry/registry.actions.ts +++ b/src/app/features/registry/store/registry/registry.actions.ts @@ -84,6 +84,12 @@ export class SubmitDecision { ) {} } +export class ForkRegistry { + static readonly type = '[Registry] Fork Registry'; + + constructor(public resourceId: string) {} +} + export class ClearRegistry { static readonly type = '[Registry] Clear Registry'; } diff --git a/src/app/features/registry/store/registry/registry.state.ts b/src/app/features/registry/store/registry/registry.state.ts index 778fcb967..3674f4989 100644 --- a/src/app/features/registry/store/registry/registry.state.ts +++ b/src/app/features/registry/store/registry/registry.state.ts @@ -5,7 +5,9 @@ import { catchError } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; +import { DuplicatesService } from '@osf/shared/services/duplicates.service'; import { GetRegistryProvider } from '@osf/shared/stores/registration-provider'; import { RegistryOverviewService } from '../../services'; @@ -13,6 +15,7 @@ import { RegistryOverviewService } from '../../services'; import { ClearRegistry, CreateSchemaResponse, + ForkRegistry, GetRegistryById, GetRegistryIdentifiers, GetRegistryInstitutions, @@ -35,6 +38,7 @@ import { REGISTRY_DEFAULTS, RegistryStateModel } from './registry.model'; }) export class RegistryState { private readonly registryOverviewService = inject(RegistryOverviewService); + private readonly duplicatesService = inject(DuplicatesService); @Action(GetRegistryById) getRegistryById(ctx: StateContext, action: GetRegistryById) { @@ -361,6 +365,27 @@ export class RegistryState { ); } + @Action(ForkRegistry) + forkRegistry(ctx: StateContext, action: ForkRegistry) { + return this.duplicatesService.forkResource(action.resourceId, CurrentResourceType.Registrations).pipe( + tap(() => { + const state = ctx.getState(); + if (!state.registry.data) return; + + ctx.patchState({ + registry: { + ...state.registry, + data: { + ...state.registry.data, + forksCount: state.registry.data.forksCount + 1, + }, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'registry', error)) + ); + } + @Action(ClearRegistry) clearRegistry(ctx: StateContext) { ctx.patchState(REGISTRY_DEFAULTS); diff --git a/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.spec.ts b/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.spec.ts index e69a564bc..51deca2f4 100644 --- a/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.spec.ts +++ b/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.spec.ts @@ -1,37 +1,26 @@ -import { Store } from '@ngxs/store'; - import { MockComponent, MockProvider } from 'ng-mocks'; -import { Mock } from 'vitest'; - import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ReadonlyInputComponent } from '@osf/shared/components/readonly-input/readonly-input.component'; -import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; -import { LoaderService } from '@osf/shared/services/loader.service'; -import { ToastService } from '@osf/shared/services/toast.service'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { - CustomConfirmationServiceMock, - CustomConfirmationServiceMockType, -} from '@testing/providers/custom-confirmation-provider.mock'; -import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; + ExternalIdentityDeletionServiceMock, + ExternalIdentityDeletionServiceMockType, +} from '@testing/providers/external-identity-deletion-service.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; import { ExternalIdentity } from '../../models'; -import { AccountSettingsSelectors, DeleteExternalIdentity } from '../../store'; +import { ExternalIdentityDeletionService } from '../../services/external-identity-deletion.service'; +import { AccountSettingsSelectors } from '../../store'; import { ConnectedIdentitiesComponent } from './connected-identities.component'; describe('ConnectedIdentitiesComponent', () => { let component: ConnectedIdentitiesComponent; let fixture: ComponentFixture; - let store: Store; - let loaderService: LoaderServiceMock; - let confirmationService: CustomConfirmationServiceMockType; - let toastService: ToastServiceMockType; + let externalIdentityDeletion: ExternalIdentityDeletionServiceMockType; const mockIdentity: ExternalIdentity = { id: 'orcid', @@ -40,24 +29,19 @@ describe('ConnectedIdentitiesComponent', () => { }; beforeEach(() => { - loaderService = new LoaderServiceMock(); - confirmationService = CustomConfirmationServiceMock.simple(); - toastService = ToastServiceMock.simple(); + externalIdentityDeletion = ExternalIdentityDeletionServiceMock.simple(); TestBed.configureTestingModule({ imports: [ConnectedIdentitiesComponent, MockComponent(ReadonlyInputComponent)], providers: [ provideOSFCore(), - MockProvider(LoaderService, loaderService), - MockProvider(CustomConfirmationService, confirmationService), - MockProvider(ToastService, toastService), + MockProvider(ExternalIdentityDeletionService, externalIdentityDeletion), provideMockStore({ signals: [{ selector: AccountSettingsSelectors.getExternalIdentities, value: [mockIdentity] }], }), ], }); - store = TestBed.inject(Store); fixture = TestBed.createComponent(ConnectedIdentitiesComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -71,36 +55,9 @@ describe('ConnectedIdentitiesComponent', () => { expect(component.externalIdentities()).toEqual([mockIdentity]); }); - it('should open delete confirmation with expected payload', () => { - component.deleteExternalIdentity(mockIdentity); - - expect(confirmationService.confirmDelete).toHaveBeenCalledWith({ - headerKey: 'settings.accountSettings.connectedIdentities.deleteDialog.header', - messageParams: { name: mockIdentity.id }, - messageKey: 'settings.accountSettings.connectedIdentities.deleteDialog.message', - onConfirm: expect.any(Function), - }); - }); - - it('should dispatch delete and show success when confirmation is accepted', () => { - (store.dispatch as Mock).mockClear(); - component.deleteExternalIdentity(mockIdentity); - - const { onConfirm } = confirmationService.confirmDelete.mock.calls[0][0]; - onConfirm(); - - expect(loaderService.show).toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith(new DeleteExternalIdentity(mockIdentity.id)); - expect(toastService.showSuccess).toHaveBeenCalledWith('settings.accountSettings.connectedIdentities.successDelete'); - expect(loaderService.hide).toHaveBeenCalled(); - }); - - it('should not dispatch delete when confirmation is not accepted', () => { - (store.dispatch as Mock).mockClear(); + it('should delegate deletion of the identity to the deletion service', () => { component.deleteExternalIdentity(mockIdentity); - expect(loaderService.show).not.toHaveBeenCalled(); - expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(DeleteExternalIdentity)); - expect(toastService.showSuccess).not.toHaveBeenCalled(); + expect(externalIdentityDeletion.confirmAndDelete).toHaveBeenCalledWith(mockIdentity.id); }); }); diff --git a/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.ts b/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.ts index fa57af0ae..09bbc9723 100644 --- a/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.ts +++ b/src/app/features/settings/account-settings/components/connected-identities/connected-identities.component.ts @@ -1,20 +1,16 @@ -import { createDispatchMap, select } from '@ngxs/store'; +import { select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; -import { finalize } from 'rxjs'; - import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ReadonlyInputComponent } from '@osf/shared/components/readonly-input/readonly-input.component'; -import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; -import { LoaderService } from '@osf/shared/services/loader.service'; -import { ToastService } from '@osf/shared/services/toast.service'; import { ExternalIdentity } from '../../models'; -import { AccountSettingsSelectors, DeleteExternalIdentity } from '../../store'; +import { ExternalIdentityDeletionService } from '../../services/external-identity-deletion.service'; +import { AccountSettingsSelectors } from '../../store'; @Component({ selector: 'osf-connected-identities', @@ -24,25 +20,11 @@ import { AccountSettingsSelectors, DeleteExternalIdentity } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ConnectedIdentitiesComponent { - private readonly customConfirmationService = inject(CustomConfirmationService); - private readonly loaderService = inject(LoaderService); - private readonly toastService = inject(ToastService); + private readonly externalIdentityDeletion = inject(ExternalIdentityDeletionService); - readonly actions = createDispatchMap({ deleteExternalIdentity: DeleteExternalIdentity }); readonly externalIdentities = select(AccountSettingsSelectors.getExternalIdentities); deleteExternalIdentity(identity: ExternalIdentity): void { - this.customConfirmationService.confirmDelete({ - headerKey: 'settings.accountSettings.connectedIdentities.deleteDialog.header', - messageParams: { name: identity.id }, - messageKey: 'settings.accountSettings.connectedIdentities.deleteDialog.message', - onConfirm: () => { - this.loaderService.show(); - this.actions - .deleteExternalIdentity(identity.id) - .pipe(finalize(() => this.loaderService.hide())) - .subscribe(() => this.toastService.showSuccess('settings.accountSettings.connectedIdentities.successDelete')); - }, - }); + this.externalIdentityDeletion.confirmAndDelete(identity.id); } } diff --git a/src/app/features/settings/account-settings/services/account-settings.service.spec.ts b/src/app/features/settings/account-settings/services/account-settings.service.spec.ts new file mode 100644 index 000000000..4c711e41e --- /dev/null +++ b/src/app/features/settings/account-settings/services/account-settings.service.spec.ts @@ -0,0 +1,193 @@ +import { firstValueFrom } from 'rxjs'; + +import { HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { BYPASS_ERROR_INTERCEPTOR } from '@core/interceptors/error-interceptor.tokens'; +import { UserMapper } from '@osf/shared/mappers/user'; + +import { getUserDataJsonApi } from '@testing/data/user/user.data'; +import { provideOSFCore, provideOSFHttp } from '@testing/osf.testing.provider'; + +import { AccountSettingsMapper, MapExternalIdentities } from '../mappers'; +import { AccountSettingsDataJsonApi, AccountSettingsResponseJsonApi, ListIdentitiesResponseJsonApi } from '../models'; + +import { AccountSettingsService } from './account-settings.service'; + +describe('AccountSettingsService', () => { + let service: AccountSettingsService; + let httpMock: HttpTestingController; + + const apiUrl = 'http://localhost:8000/v2'; + const userId = 'user-1'; + + const accountSettingsData: AccountSettingsDataJsonApi = { + id: userId, + type: 'user-settings', + attributes: { + two_factor_enabled: true, + two_factor_confirmed: false, + subscribe_osf_general_email: true, + subscribe_osf_help_email: false, + deactivation_requested: false, + contacted_deactivation: false, + secret: 'secret-value', + }, + }; + + const identitiesResponse: ListIdentitiesResponseJsonApi = { + data: [ + { + id: 'orcid', + type: 'external-identities', + attributes: { + external_id: '0000-0001-2345-6789', + status: 'verified', + }, + }, + ], + meta: { + total: 1, + per_page: 10, + version: '2.0', + }, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideOSFCore(), provideOSFHttp(), AccountSettingsService], + }); + + service = TestBed.inject(AccountSettingsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should expose the api url from the environment', () => { + expect(service.apiUrl).toBe(apiUrl); + }); + + it('should get account settings', async () => { + const response: AccountSettingsResponseJsonApi = { data: accountSettingsData }; + const resultPromise = firstValueFrom(service.getSettings()); + + const req = httpMock.expectOne(`${apiUrl}/users/me/settings/`); + expect(req.request.method).toBe('GET'); + req.flush(response); + + await expect(resultPromise).resolves.toEqual(AccountSettingsMapper.getAccountSettings(accountSettingsData)); + }); + + it('should update account settings', async () => { + const settings = { subscribe_osf_general_email: 'true' }; + const resultPromise = firstValueFrom(service.updateSettings(userId, settings)); + + const req = httpMock.expectOne(`${apiUrl}/users/${userId}/settings/`); + expect(req.request.method).toBe('PATCH'); + expect(req.request.body).toEqual({ + data: { + id: userId, + attributes: settings, + type: 'user_settings', + }, + }); + req.flush({ data: accountSettingsData }); + + await expect(resultPromise).resolves.toEqual(AccountSettingsMapper.getAccountSettings(accountSettingsData)); + }); + + it('should update location', async () => { + const userData = getUserDataJsonApi(); + const resultPromise = firstValueFrom(service.updateLocation(userId, 'us')); + + const req = httpMock.expectOne(`${apiUrl}/users/${userId}/`); + expect(req.request.method).toBe('PATCH'); + expect(req.request.body).toEqual({ + data: { + id: userId, + attributes: {}, + relationships: { + default_region: { + data: { + type: 'regions', + id: 'us', + }, + }, + }, + type: 'users', + }, + }); + req.flush({ data: userData }); + + await expect(resultPromise).resolves.toEqual(UserMapper.fromUserGetResponse(userData)); + }); + + it('should update indexing', async () => { + const userData = getUserDataJsonApi(); + const resultPromise = firstValueFrom(service.updateIndexing(userId, false)); + + const req = httpMock.expectOne(`${apiUrl}/users/${userId}/`); + expect(req.request.method).toBe('PATCH'); + expect(req.request.body).toEqual({ + data: { + id: userId, + attributes: { + allow_indexing: false, + }, + relationships: {}, + type: 'users', + }, + }); + req.flush({ data: userData }); + + await expect(resultPromise).resolves.toEqual(UserMapper.fromUserGetResponse(userData)); + }); + + it('should update password', async () => { + const resultPromise = firstValueFrom(service.updatePassword('old-pass', 'new-pass')); + + const req = httpMock.expectOne(`${apiUrl}/users/me/settings/password/`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ + data: { + type: 'user_passwords', + attributes: { + existing_password: 'old-pass', + new_password: 'new-pass', + }, + }, + }); + req.flush(null); + + await expect(resultPromise).resolves.toBeNull(); + }); + + it('should get external identities', async () => { + const resultPromise = firstValueFrom(service.getExternalIdentities()); + + const req = httpMock.expectOne( + (request) => + request.url === `${apiUrl}/users/me/settings/identities/` && + request.params.get('page') === '1' && + request.params.get('page[size]') === '10' + ); + expect(req.request.method).toBe('GET'); + req.flush(identitiesResponse); + + await expect(resultPromise).resolves.toEqual(MapExternalIdentities(identitiesResponse.data)); + }); + + it('should delete an external identity with the error interceptor bypassed', async () => { + const resultPromise = firstValueFrom(service.deleteExternalIdentity('orcid')); + + const req = httpMock.expectOne(`${apiUrl}/users/me/settings/identities/orcid/`); + expect(req.request.method).toBe('DELETE'); + expect(req.request.context.get(BYPASS_ERROR_INTERCEPTOR)).toBe(true); + req.flush(null); + + await expect(resultPromise).resolves.toBeNull(); + }); +}); diff --git a/src/app/features/settings/account-settings/services/account-settings.service.ts b/src/app/features/settings/account-settings/services/account-settings.service.ts index f46142573..1ec363a9c 100644 --- a/src/app/features/settings/account-settings/services/account-settings.service.ts +++ b/src/app/features/settings/account-settings/services/account-settings.service.ts @@ -2,6 +2,7 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { bypassErrorInterceptorContext } from '@core/interceptors/error-interceptor.tokens'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { UserMapper } from '@osf/shared/mappers/user'; import { UserModel } from '@osf/shared/models/user/user.model'; @@ -113,6 +114,11 @@ export class AccountSettingsService { } deleteExternalIdentity(id: string): Observable { - return this.jsonApiService.delete(`${this.apiUrl}/users/me/settings/identities/${id}/`); + return this.jsonApiService.delete( + `${this.apiUrl}/users/me/settings/identities/${id}/`, + undefined, + undefined, + bypassErrorInterceptorContext() + ); } } diff --git a/src/app/features/settings/account-settings/services/external-identity-deletion.service.spec.ts b/src/app/features/settings/account-settings/services/external-identity-deletion.service.spec.ts new file mode 100644 index 000000000..d48243ff6 --- /dev/null +++ b/src/app/features/settings/account-settings/services/external-identity-deletion.service.spec.ts @@ -0,0 +1,108 @@ +import { Store } from '@ngxs/store'; + +import { MockProvider } from 'ng-mocks'; + +import { throwError } from 'rxjs'; + +import { Mock } from 'vitest'; + +import { HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; + +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; + +import { DeleteExternalIdentity } from '../store'; + +import { ExternalIdentityDeletionService } from './external-identity-deletion.service'; + +describe('ExternalIdentityDeletionService', () => { + let service: ExternalIdentityDeletionService; + let store: Store; + let loaderService: LoaderServiceMock; + let confirmationService: CustomConfirmationServiceMockType; + let toastService: ToastServiceMockType; + + const identityId = 'ORCID'; + + const confirm = () => { + const { onConfirm } = confirmationService.confirmDelete.mock.calls[0][0]; + onConfirm(); + }; + + beforeEach(() => { + loaderService = new LoaderServiceMock(); + confirmationService = CustomConfirmationServiceMock.simple(); + toastService = ToastServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [ + provideOSFCore(), + MockProvider(LoaderService, loaderService), + MockProvider(CustomConfirmationService, confirmationService), + MockProvider(ToastService, toastService), + provideMockStore(), + ], + }); + + store = TestBed.inject(Store); + service = TestBed.inject(ExternalIdentityDeletionService); + }); + + it('should open delete confirmation with expected payload', () => { + service.confirmAndDelete(identityId); + + expect(confirmationService.confirmDelete).toHaveBeenCalledWith({ + headerKey: 'settings.accountSettings.connectedIdentities.deleteDialog.header', + messageParams: { name: identityId }, + messageKey: 'settings.accountSettings.connectedIdentities.deleteDialog.message', + onConfirm: expect.any(Function), + }); + }); + + it('should not dispatch delete until confirmation is accepted', () => { + (store.dispatch as Mock).mockClear(); + service.confirmAndDelete(identityId); + + expect(loaderService.show).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + }); + + it('should dispatch delete and show success when confirmation is accepted', () => { + (store.dispatch as Mock).mockClear(); + service.confirmAndDelete(identityId); + confirm(); + + expect(loaderService.show).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteExternalIdentity(identityId)); + expect(toastService.showSuccess).toHaveBeenCalledWith('settings.accountSettings.connectedIdentities.successDelete'); + expect(toastService.showError).not.toHaveBeenCalled(); + expect(loaderService.hide).toHaveBeenCalled(); + }); + + it.each([ + [503, 'settings.accountSettings.connectedIdentities.errors.retryLater'], + [409, 'settings.accountSettings.connectedIdentities.errors.signInViaOrcid'], + [500, 'settings.accountSettings.connectedIdentities.errors.contactSupport'], + [404, 'settings.accountSettings.connectedIdentities.errors.contactSupport'], + ])('should show the matching error toast for status %i', (status, messageKey) => { + (store.dispatch as Mock).mockReturnValue(throwError(() => new HttpErrorResponse({ status }))); + service.confirmAndDelete(identityId); + confirm(); + + expect(toastService.showError).toHaveBeenCalledWith(messageKey); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + expect(loaderService.hide).toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/settings/account-settings/services/external-identity-deletion.service.ts b/src/app/features/settings/account-settings/services/external-identity-deletion.service.ts new file mode 100644 index 000000000..84690e445 --- /dev/null +++ b/src/app/features/settings/account-settings/services/external-identity-deletion.service.ts @@ -0,0 +1,54 @@ +import { createDispatchMap } from '@ngxs/store'; + +import { finalize } from 'rxjs'; + +import { HttpErrorResponse } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; + +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { DeleteExternalIdentity } from '../store'; + +@Injectable({ + providedIn: 'root', +}) +export class ExternalIdentityDeletionService { + private readonly customConfirmationService = inject(CustomConfirmationService); + private readonly loaderService = inject(LoaderService); + private readonly toastService = inject(ToastService); + + private readonly actions = createDispatchMap({ deleteExternalIdentity: DeleteExternalIdentity }); + + confirmAndDelete(identityId: string): void { + this.customConfirmationService.confirmDelete({ + headerKey: 'settings.accountSettings.connectedIdentities.deleteDialog.header', + messageParams: { name: identityId }, + messageKey: 'settings.accountSettings.connectedIdentities.deleteDialog.message', + onConfirm: () => { + this.loaderService.show(); + this.actions + .deleteExternalIdentity(identityId) + .pipe(finalize(() => this.loaderService.hide())) + .subscribe({ + next: () => this.toastService.showSuccess('settings.accountSettings.connectedIdentities.successDelete'), + error: (error: HttpErrorResponse) => this.toastService.showError(this.getDeleteErrorKey(error)), + }); + }, + }); + } + + private getDeleteErrorKey(error: HttpErrorResponse): string { + const prefix = 'settings.accountSettings.connectedIdentities.errors'; + + switch (error.status) { + case 503: + return `${prefix}.retryLater`; + case 409: + return `${prefix}.signInViaOrcid`; + default: + return `${prefix}.contactSupport`; + } + } +} diff --git a/src/app/features/settings/account-settings/services/index.ts b/src/app/features/settings/account-settings/services/index.ts deleted file mode 100644 index 60c89cf30..000000000 --- a/src/app/features/settings/account-settings/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AccountSettingsService } from './account-settings.service'; diff --git a/src/app/features/settings/account-settings/store/account-settings.state.ts b/src/app/features/settings/account-settings/store/account-settings.state.ts index 9382c7ee0..296a0959a 100644 --- a/src/app/features/settings/account-settings/store/account-settings.state.ts +++ b/src/app/features/settings/account-settings/store/account-settings.state.ts @@ -9,7 +9,7 @@ import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { InstitutionsService } from '@osf/shared/services/institutions.service'; import { AccountSettingsMapper } from '../mappers'; -import { AccountSettingsService } from '../services'; +import { AccountSettingsService } from '../services/account-settings.service'; import { CancelDeactivationRequest, diff --git a/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.spec.ts b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.spec.ts index 9b49b9506..8d173a0d7 100644 --- a/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.spec.ts +++ b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.spec.ts @@ -2,8 +2,6 @@ import { Store } from '@ngxs/store'; import { MockProvider } from 'ng-mocks'; -import { Mock } from 'vitest'; - import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -11,21 +9,17 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { AuthService } from '@core/services/auth.service'; import { AccountSettingsSelectors } from '@osf/features/settings/account-settings/store/account-settings.selectors'; import { ExternalIdentityStatus } from '@osf/shared/enums/external-identity-status.enum'; -import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; -import { LoaderService } from '@osf/shared/services/loader.service'; -import { ToastService } from '@osf/shared/services/toast.service'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { AuthServiceMock, AuthServiceMockType } from '@testing/providers/auth-service.mock'; import { - CustomConfirmationServiceMock, - CustomConfirmationServiceMockType, -} from '@testing/providers/custom-confirmation-provider.mock'; -import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; + ExternalIdentityDeletionServiceMock, + ExternalIdentityDeletionServiceMockType, +} from '@testing/providers/external-identity-deletion-service.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; -import { DeleteExternalIdentity, GetExternalIdentities } from '../../../account-settings/store'; +import { ExternalIdentityDeletionService } from '../../../account-settings/services/external-identity-deletion.service'; +import { GetExternalIdentities } from '../../../account-settings/store'; import { AuthenticatedIdentityComponent } from './authenticated-identity.component'; @@ -33,9 +27,7 @@ describe('AuthenticatedIdentityComponent', () => { let component: AuthenticatedIdentityComponent; let fixture: ComponentFixture; let store: Store; - let customConfirmationServiceMock: CustomConfirmationServiceMockType; - let loaderServiceMock: LoaderServiceMock; - let toastServiceMock: ToastServiceMockType; + let externalIdentityDeletion: ExternalIdentityDeletionServiceMockType; let authServiceMock: AuthServiceMockType; const mockExternalIdentities = signal([ @@ -47,9 +39,7 @@ describe('AuthenticatedIdentityComponent', () => { ]); beforeEach(() => { - customConfirmationServiceMock = CustomConfirmationServiceMock.simple(); - loaderServiceMock = new LoaderServiceMock(); - toastServiceMock = ToastServiceMock.simple(); + externalIdentityDeletion = ExternalIdentityDeletionServiceMock.simple(); authServiceMock = AuthServiceMock.simple(); TestBed.configureTestingModule({ @@ -58,9 +48,7 @@ describe('AuthenticatedIdentityComponent', () => { provideOSFCore(), MockProvider(ENVIRONMENT, { webUrl: 'http://localhost:4200', casUrl: 'http://localhost:8080' }), MockProvider(AuthService, authServiceMock), - MockProvider(LoaderService, loaderServiceMock), - MockProvider(CustomConfirmationService, customConfirmationServiceMock), - MockProvider(ToastService, toastServiceMock), + MockProvider(ExternalIdentityDeletionService, externalIdentityDeletion), provideMockStore({ signals: [ { @@ -85,8 +73,6 @@ describe('AuthenticatedIdentityComponent', () => { it('should show existing user ORCID when present in external identities', () => { expect(component.existingOrcid()).toEqual('0001-0002-0003-0004'); expect(component.orcidUrl()).toEqual('https://orcid.org/0001-0002-0003-0004'); - component.disconnectOrcid(); - expect(customConfirmationServiceMock.confirmDelete).toHaveBeenCalled(); }); it('should show connect button when no existing ORCID is present in external identities', () => { @@ -101,29 +87,10 @@ describe('AuthenticatedIdentityComponent', () => { expect(store.dispatch).toHaveBeenCalledWith(new GetExternalIdentities()); }); - it('should delete ORCID and show success when confirmation is accepted', () => { - (store.dispatch as Mock).mockClear(); - component.disconnectOrcid(); - - const { onConfirm } = customConfirmationServiceMock.confirmDelete.mock.calls[0][0]; - onConfirm(); - - expect(loaderServiceMock.show).toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith(new DeleteExternalIdentity('ORCID')); - expect(toastServiceMock.showSuccess).toHaveBeenCalledWith( - 'settings.accountSettings.connectedIdentities.successDelete' - ); - expect(loaderServiceMock.hide).toHaveBeenCalled(); - }); - - it('should not delete ORCID when confirmation is not accepted', () => { - (store.dispatch as Mock).mockClear(); + it('should delegate ORCID disconnect to the deletion service', () => { component.disconnectOrcid(); - expect(loaderServiceMock.show).not.toHaveBeenCalled(); - expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(DeleteExternalIdentity)); - expect(toastServiceMock.showSuccess).not.toHaveBeenCalled(); - expect(loaderServiceMock.hide).not.toHaveBeenCalled(); + expect(externalIdentityDeletion.confirmAndDelete).toHaveBeenCalledWith('ORCID'); }); it('should redirect to CAS login with ORCID redirect and social tab destination', () => { diff --git a/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.ts b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.ts index fb9bdf6fe..c31f7f7df 100644 --- a/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.ts +++ b/src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.ts @@ -5,23 +5,15 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Tooltip } from 'primeng/tooltip'; -import { finalize } from 'rxjs'; - import { NgOptimizedImage } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, OnInit } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { AuthService } from '@core/services/auth.service'; import { ExternalIdentityStatus } from '@osf/shared/enums/external-identity-status.enum'; -import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; -import { LoaderService } from '@osf/shared/services/loader.service'; -import { ToastService } from '@osf/shared/services/toast.service'; -import { - AccountSettingsSelectors, - DeleteExternalIdentity, - GetExternalIdentities, -} from '../../../account-settings/store'; +import { ExternalIdentityDeletionService } from '../../../account-settings/services/external-identity-deletion.service'; +import { AccountSettingsSelectors, GetExternalIdentities } from '../../../account-settings/store'; import { ProfileSettingsTabOption } from '../../enums'; @Component({ @@ -34,9 +26,7 @@ import { ProfileSettingsTabOption } from '../../enums'; export class AuthenticatedIdentityComponent implements OnInit { private readonly authService = inject(AuthService); private readonly environment = inject(ENVIRONMENT); - private readonly customConfirmationService = inject(CustomConfirmationService); - private readonly toastService = inject(ToastService); - private readonly loaderService = inject(LoaderService); + private readonly externalIdentityDeletion = inject(ExternalIdentityDeletionService); private readonly ORCID_PROVIDER = 'ORCID'; @@ -45,7 +35,6 @@ export class AuthenticatedIdentityComponent implements OnInit { } readonly actions = createDispatchMap({ - deleteExternalIdentity: DeleteExternalIdentity, getExternalIdentities: GetExternalIdentities, }); @@ -60,18 +49,7 @@ export class AuthenticatedIdentityComponent implements OnInit { ); disconnectOrcid(): void { - this.customConfirmationService.confirmDelete({ - headerKey: 'settings.accountSettings.connectedIdentities.deleteDialog.header', - messageParams: { name: this.ORCID_PROVIDER }, - messageKey: 'settings.accountSettings.connectedIdentities.deleteDialog.message', - onConfirm: () => { - this.loaderService.show(); - this.actions - .deleteExternalIdentity(this.ORCID_PROVIDER) - .pipe(finalize(() => this.loaderService.hide())) - .subscribe(() => this.toastService.showSuccess('settings.accountSettings.connectedIdentities.successDelete')); - }, - }); + this.externalIdentityDeletion.confirmAndDelete(this.ORCID_PROVIDER); } connectOrcid(): void { diff --git a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.html b/src/app/shared/components/fork-dialog/fork-dialog.component.html similarity index 67% rename from src/app/features/project/overview/components/fork-dialog/fork-dialog.component.html rename to src/app/shared/components/fork-dialog/fork-dialog.component.html index 4811c0b13..82ba337fb 100644 --- a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.html +++ b/src/app/shared/components/fork-dialog/fork-dialog.component.html @@ -1,19 +1,17 @@

- {{ 'project.overview.dialog.fork.messageProject' | translate }} + {{ messageKey | translate }}

diff --git a/src/app/shared/components/fork-dialog/fork-dialog.component.spec.ts b/src/app/shared/components/fork-dialog/fork-dialog.component.spec.ts new file mode 100644 index 000000000..e043815b2 --- /dev/null +++ b/src/app/shared/components/fork-dialog/fork-dialog.component.spec.ts @@ -0,0 +1,72 @@ +import { MockProvider } from 'ng-mocks'; + +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; + +import { ForkDialogComponent } from './fork-dialog.component'; + +interface SetupOverrides { + resourceType?: CurrentResourceType; +} + +describe('ForkDialogComponent', () => { + let component: ForkDialogComponent; + let fixture: ComponentFixture; + let dialogRef: DynamicDialogRef; + + function setup(overrides: SetupOverrides = {}) { + TestBed.configureTestingModule({ + imports: [ForkDialogComponent], + providers: [ + provideOSFCore(), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, { + data: { resourceType: overrides.resourceType ?? CurrentResourceType.Projects }, + }), + ], + }); + + dialogRef = TestBed.inject(DynamicDialogRef); + fixture = TestBed.createComponent(ForkDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + it('should show the project message', () => { + setup(); + + expect(component.messageKey).toBe('project.overview.dialog.fork.messageProject'); + expect(fixture.nativeElement.textContent).toContain('project.overview.dialog.fork.messageProject'); + }); + + it('should show the registration message', () => { + setup({ resourceType: CurrentResourceType.Registrations }); + + expect(component.messageKey).toBe('project.overview.dialog.fork.messageRegistration'); + expect(fixture.nativeElement.textContent).toContain('project.overview.dialog.fork.messageRegistration'); + }); + + it('should close with false when cancel is clicked', () => { + setup(); + + const [cancelButton] = fixture.nativeElement.querySelectorAll('button'); + cancelButton.click(); + + expect(dialogRef.close).toHaveBeenCalledWith(false); + }); + + it('should close with true when confirm is clicked', () => { + setup(); + + const buttons = fixture.nativeElement.querySelectorAll('button'); + buttons[1].click(); + + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/app/shared/components/fork-dialog/fork-dialog.component.ts b/src/app/shared/components/fork-dialog/fork-dialog.component.ts new file mode 100644 index 000000000..3f5776510 --- /dev/null +++ b/src/app/shared/components/fork-dialog/fork-dialog.component.ts @@ -0,0 +1,24 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; + +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; + +@Component({ + selector: 'osf-fork-dialog', + imports: [TranslatePipe, Button], + templateUrl: './fork-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ForkDialogComponent { + readonly dialogRef = inject(DynamicDialogRef); + private readonly config = inject(DynamicDialogConfig); + + readonly messageKey = + this.config.data?.resourceType === CurrentResourceType.Registrations + ? 'project.overview.dialog.fork.messageRegistration' + : 'project.overview.dialog.fork.messageProject'; +} diff --git a/src/app/shared/components/fork-menu-button/fork-menu-button.component.html b/src/app/shared/components/fork-menu-button/fork-menu-button.component.html new file mode 100644 index 000000000..b0a88ebdd --- /dev/null +++ b/src/app/shared/components/fork-menu-button/fork-menu-button.component.html @@ -0,0 +1,30 @@ +@if (preventDuplicateCreation()) { + + {{ forksCount() }} + + +} @else { + + {{ forksCount() }} + + + + + {{ item.label | translate }} + + + + +} diff --git a/src/app/shared/components/fork-menu-button/fork-menu-button.component.spec.ts b/src/app/shared/components/fork-menu-button/fork-menu-button.component.spec.ts new file mode 100644 index 000000000..af29a270b --- /dev/null +++ b/src/app/shared/components/fork-menu-button/fork-menu-button.component.spec.ts @@ -0,0 +1,72 @@ +import { MenuItem } from 'primeng/api'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; + +import { ForkMenuButtonComponent } from './fork-menu-button.component'; + +interface SetupOverrides { + forksCount?: number; + menuItems?: MenuItem[]; + preventDuplicateCreation?: boolean; +} + +describe('ForkMenuButtonComponent', () => { + let component: ForkMenuButtonComponent; + let fixture: ComponentFixture; + + const defaultMenuItems: MenuItem[] = [ + { label: 'project.overview.actions.forkProject' }, + { label: 'project.overview.actions.viewDuplication' }, + ]; + + function setup(overrides: SetupOverrides = {}) { + TestBed.configureTestingModule({ + imports: [ForkMenuButtonComponent], + providers: [provideOSFCore()], + }); + + fixture = TestBed.createComponent(ForkMenuButtonComponent); + component = fixture.componentInstance; + + fixture.componentRef.setInput('forksCount', overrides.forksCount ?? 3); + fixture.componentRef.setInput('menuItems', overrides.menuItems ?? defaultMenuItems); + fixture.componentRef.setInput('preventDuplicateCreation', overrides.preventDuplicateCreation ?? false); + fixture.detectChanges(); + } + + it('should show the forks count', () => { + setup({ forksCount: 7 }); + + expect(fixture.nativeElement.textContent).toContain('7'); + }); + + it('should show the menu when duplicate creation is allowed', () => { + setup(); + + expect(fixture.nativeElement.querySelector('p-menu')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('button')?.getAttribute('aria-label')).toBe( + 'project.overview.tooltips.duplicate' + ); + }); + + it('should show the view duplicates button when duplicate creation is prevented', () => { + setup({ preventDuplicateCreation: true }); + + expect(fixture.nativeElement.querySelector('p-menu')).toBeNull(); + expect(fixture.nativeElement.querySelector('button')?.getAttribute('aria-label')).toBe( + 'project.overview.actions.viewDuplication' + ); + }); + + it('should emit viewDuplicates when the view duplicates button is clicked', () => { + setup({ preventDuplicateCreation: true }); + const viewDuplicatesSpy = vi.fn(); + component.viewDuplicates.subscribe(viewDuplicatesSpy); + + fixture.nativeElement.querySelector('button').click(); + + expect(viewDuplicatesSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/shared/components/fork-menu-button/fork-menu-button.component.ts b/src/app/shared/components/fork-menu-button/fork-menu-button.component.ts new file mode 100644 index 000000000..2317a7bc9 --- /dev/null +++ b/src/app/shared/components/fork-menu-button/fork-menu-button.component.ts @@ -0,0 +1,22 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { MenuItem } from 'primeng/api'; +import { Button } from 'primeng/button'; +import { Menu } from 'primeng/menu'; +import { Tooltip } from 'primeng/tooltip'; + +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; + +@Component({ + selector: 'osf-fork-menu-button', + imports: [Button, Menu, Tooltip, TranslatePipe], + templateUrl: './fork-menu-button.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ForkMenuButtonComponent { + readonly forksCount = input.required(); + readonly menuItems = input.required(); + readonly preventDuplicateCreation = input(false); + + readonly viewDuplicates = output(); +} diff --git a/src/app/shared/components/toast/toast.component.html b/src/app/shared/components/toast/toast.component.html index ca02a6bba..cc9007bfc 100644 --- a/src/app/shared/components/toast/toast.component.html +++ b/src/app/shared/components/toast/toast.component.html @@ -1,6 +1,6 @@ = { + 'Content-Type': 'application/vnd.api+json; ext=bulk', +}; diff --git a/src/app/shared/enums/node-menu-action.enum.ts b/src/app/shared/enums/node-menu-action.enum.ts new file mode 100644 index 000000000..3b66369ba --- /dev/null +++ b/src/app/shared/enums/node-menu-action.enum.ts @@ -0,0 +1,5 @@ +export enum NodeMenuAction { + ManageContributors = 'manageContributors', + Settings = 'settings', + Delete = 'delete', +} diff --git a/src/app/shared/enums/trigger-action.enum.ts b/src/app/shared/enums/trigger-action.enum.ts index 2740af5ca..29c3d7c1a 100644 --- a/src/app/shared/enums/trigger-action.enum.ts +++ b/src/app/shared/enums/trigger-action.enum.ts @@ -7,6 +7,7 @@ export enum ReviewActionTrigger { AcceptWithdrawal = 'accept_withdrawal', RejectWithdrawal = 'reject_withdrawal', RequestEmbargoTermination = 'request_embargo_termination', + ReportSpam = 'report_spam', } export enum SchemaResponseActionTrigger { diff --git a/src/app/shared/mappers/wiki/wiki.mapper.ts b/src/app/shared/mappers/wiki/wiki.mapper.ts index a522efcec..915dec5e9 100644 --- a/src/app/shared/mappers/wiki/wiki.mapper.ts +++ b/src/app/shared/mappers/wiki/wiki.mapper.ts @@ -35,7 +35,7 @@ export class WikiMapper { return { id: response.id, createdAt: response.attributes.date_created, - createdBy: UserMapper.getUserInfo(response.embeds.user)?.fullName, + createdBy: UserMapper.getUserInfo(response.embeds?.user)?.fullName, }; } diff --git a/src/app/shared/models/confirm-fork-options.model.ts b/src/app/shared/models/confirm-fork-options.model.ts new file mode 100644 index 000000000..a398a503e --- /dev/null +++ b/src/app/shared/models/confirm-fork-options.model.ts @@ -0,0 +1,12 @@ +import { Observable } from 'rxjs'; + +import { DestroyRef } from '@angular/core'; + +import { CurrentResourceType } from '../enums/resource-type.enum'; + +export interface ConfirmForkOptions { + resourceType: CurrentResourceType; + destroyRef: DestroyRef; + fork: () => Observable; + onSuccess?: () => void; +} diff --git a/src/app/shared/models/environment.model.ts b/src/app/shared/models/environment.model.ts index fcc33126d..361902fe5 100644 --- a/src/app/shared/models/environment.model.ts +++ b/src/app/shared/models/environment.model.ts @@ -29,9 +29,6 @@ export interface EnvironmentModel { newRelicLoaderConfigAgentID: string; newRelicLoaderConfigLicenseKey: string; newRelicLoaderConfigApplicationID: string; - activityLogs?: { - pageSize?: number; - }; /** * The DSN (Data Source Name) used to configure Sentry for error tracking. @@ -41,6 +38,14 @@ export interface EnvironmentModel { */ sentryDsn: string; + /** + * Sentry environment name sent with captured events. + * Names are case-sensitive and cannot contain spaces, newlines, or forward slashes. + * + * @example "staging" + */ + sentryEnvironment: string; + /** * The Google Tag Manager ID used to embed GTM scripts for analytics tracking. * This ID typically starts with "GTM-". diff --git a/src/app/shared/models/wiki/wiki-json-api.model.ts b/src/app/shared/models/wiki/wiki-json-api.model.ts index 19950715b..c89ff401c 100644 --- a/src/app/shared/models/wiki/wiki-json-api.model.ts +++ b/src/app/shared/models/wiki/wiki-json-api.model.ts @@ -6,7 +6,6 @@ import { ItemResponse, ListResponse } from '../common/json-api/responses.model'; export type WikiResponseJsonApi = ListResponse; export type WikiItemResponseJsonApi = ItemResponse; export type WikiVersionResponseJsonApi = ListResponse; -export type WikiVersionItemResponseJsonApi = ItemResponse; export interface WikiDataJsonApi extends JsonApiResource<'wikis', WikiAttributesJsonApi> { links: WikiLinksJsonApi; diff --git a/src/app/shared/services/addons/addons.service.spec.ts b/src/app/shared/services/addons/addons.service.spec.ts index 729e5ccfe..b789650fe 100644 --- a/src/app/shared/services/addons/addons.service.spec.ts +++ b/src/app/shared/services/addons/addons.service.spec.ts @@ -1,9 +1,17 @@ +import { firstValueFrom } from 'rxjs'; + import { HttpTestingController } from '@angular/common/http/testing'; -import { inject, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; + +import { BYPASS_ERROR_INTERCEPTOR } from '@core/interceptors/error-interceptor.tokens'; +import { UserSelectors } from '@core/store/user'; +import { AuthorizedAddonRequestJsonApi } from '@osf/shared/models/addons/authorized-addon-json-api.model'; +import { UserModel } from '@osf/shared/models/user/user.model'; import { getAddonsAuthorizedStorageData } from '@testing/data/addons/addons.authorized-storage.data'; import { getConfiguredAddonsData } from '@testing/data/addons/addons.configured.data'; import { getAddonsExternalStorageData } from '@testing/data/addons/addons.external-storage.data'; +import { MOCK_USER } from '@testing/mocks/data.mock'; import { provideOSFCore, provideOSFHttp } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; @@ -11,25 +19,37 @@ import { AddonsService } from './addons.service'; describe('Service: Addons', () => { let service: AddonsService; + let httpMock: HttpTestingController; - beforeEach(() => { + function setup(currentUser: UserModel | null = null) { TestBed.configureTestingModule({ - providers: [provideOSFCore(), provideOSFHttp(), provideMockStore(), AddonsService], + providers: [ + provideOSFCore(), + provideOSFHttp(), + provideMockStore({ + signals: [{ selector: UserSelectors.getCurrentUser, value: currentUser }], + }), + AddonsService, + ], }); service = TestBed.inject(AddonsService); + httpMock = TestBed.inject(HttpTestingController); + } + + afterEach(() => { + httpMock.verify(); }); - it('should test getAddons', inject([HttpTestingController], (httpMock: HttpTestingController) => { - let results: any[] = []; - service.getAddons('storage').subscribe((result) => { - results = result; - }); + it('should test getAddons', async () => { + setup(); + const resultsPromise = firstValueFrom(service.getAddons('storage')); const request = httpMock.expectOne('http://addons.localhost:8000/external-storage-services'); expect(request.request.method).toBe('GET'); request.flush(getAddonsExternalStorageData()); + const results = await resultsPromise; expect(results[0]).toEqual( Object({ authUrl: 'https://figshare.com/account/applications/authorize', @@ -45,22 +65,19 @@ describe('Service: Addons', () => { configurableApiRoot: false, }) ); + }); - expect(httpMock.verify).toBeTruthy(); - })); - - it('should test getConfigureAddons', inject([HttpTestingController], (httpMock: HttpTestingController) => { - let results: any[] = []; - service.getConfiguredAddons('storage', 'reference-id').subscribe((result) => { - results = result; - }); + it('should test getConfigureAddons', async () => { + setup(); + const resultsPromise = firstValueFrom(service.getConfiguredAddons('storage', 'reference-id')); const request = httpMock.expectOne( 'http://addons.localhost:8000/resource-references/reference-id/configured_storage_addons/?include=external-storage-service&fields%5Bexternal-storage-services%5D=external_service_name,credentials_format,icon_url' ); expect(request.request.method).toBe('GET'); request.flush(getConfiguredAddonsData()); + const results = await resultsPromise; expect(results[0]).toEqual( Object({ baseAccountId: '62ed6dd7-f7b7-4003-b7b4-855789c1f991', @@ -80,22 +97,19 @@ describe('Service: Addons', () => { iconUrl: 'https://osf.io/assets/images/logo.svg', }) ); + }); - expect(httpMock.verify).toBeTruthy(); - })); - - it('should test getAuthorizedAddons', inject([HttpTestingController], (httpMock: HttpTestingController) => { - let results: any[] = []; - service.getAuthorizedAddons('storage', 'reference-id').subscribe((result) => { - results = result; - }); + it('should test getAuthorizedAddons', async () => { + setup(); + const resultsPromise = firstValueFrom(service.getAuthorizedAddons('storage', 'reference-id')); const request = httpMock.expectOne( 'http://addons.localhost:8000/user-references/reference-id/authorized_storage_accounts/?include=external-storage-service&fields%5Bexternal-storage-services%5D=external_service_name,credentials_format,icon_url' ); expect(request.request.method).toBe('GET'); request.flush(getAddonsAuthorizedStorageData()); + const results = await resultsPromise; expect(results[0]).toEqual( Object({ accountOwnerId: '0b441148-83e5-4f7f-b302-b07b528b160b', @@ -117,56 +131,114 @@ describe('Service: Addons', () => { type: 'authorized-storage-accounts', }) ); + }); + + it('should error when getAddonsUserReference has no current user', async () => { + setup(null); + + await expect(firstValueFrom(service.getAddonsUserReference())).rejects.toThrow('Current user not found'); + }); + + it('should fetch getAddonsUserReference for the current user', async () => { + setup(MOCK_USER); + + const resultsPromise = firstValueFrom(service.getAddonsUserReference()); + const request = httpMock.expectOne( + (req) => + req.url === 'http://addons.localhost:8000/user-references/' && + req.params.get('filter[user_uri]') === `http://localhost:4200/${MOCK_USER.id}` + ); + expect(request.request.method).toBe('GET'); + request.flush({ + data: [ + { + id: 'user-ref-1', + type: 'user-references', + attributes: { user_uri: `http://localhost:4200/${MOCK_USER.id}` }, + }, + ], + }); - expect(httpMock.verify).toBeTruthy(); - })); - - it('should test getAuthorizedStorageOauthToken', inject( - [HttpTestingController], - (httpMock: HttpTestingController) => { - let results; - service.getAuthorizedStorageOauthToken('account-id', 'storage').subscribe((result) => { - results = result; - }); - - const request = httpMock.expectOne('http://addons.localhost:8000/authorized-storage-accounts/account-id'); - expect(request.request.method).toBe('PATCH'); - expect(request.request.body).toEqual( - Object({ - data: Object({ - attributes: Object({ - serialize_oauth_token: 'true', - }), - id: 'account-id', - type: 'authorized-storage-accounts', + await expect(resultsPromise).resolves.toEqual([ + { + id: 'user-ref-1', + userUri: `http://localhost:4200/${MOCK_USER.id}`, + }, + ]); + }); + + it('should test getAuthorizedStorageOauthToken', async () => { + setup(); + + const resultsPromise = firstValueFrom(service.getAuthorizedStorageOauthToken('account-id', 'storage')); + const request = httpMock.expectOne('http://addons.localhost:8000/authorized-storage-accounts/account-id'); + expect(request.request.method).toBe('PATCH'); + expect(request.request.context.get(BYPASS_ERROR_INTERCEPTOR)).toBe(true); + expect(request.request.body).toEqual( + Object({ + data: Object({ + attributes: Object({ + serialize_oauth_token: 'true', }), - }) - ); - request.flush(getAddonsAuthorizedStorageData(0)); - - expect(results).toEqual( - Object({ - accountOwnerId: '0b441148-83e5-4f7f-b302-b07b528b160b', - apiBaseUrl: 'https://www.googleapis.com', - authUrl: null, - authorizedCapabilities: ['ACCESS', 'UPDATE'], - authorizedOperationNames: ['list_root_items', 'get_item_info', 'list_child_items'], - credentialsAvailable: true, - credentialsFormat: '', - iconUrl: '', - defaultRootFolder: '', - displayName: 'Google Drive', - externalServiceName: '', - oauthToken: 'ya29.A0AS3H6NzDCKgrUx', - externalStorageServiceId: '8aeb85e9-3a73-426f-a89b-5624b4b9d418', - id: '331b7333-a13a-4d3b-add0-5af0fd1d4ac4', - providerName: '', - supportedFeatures: [], + id: 'account-id', type: 'authorized-storage-accounts', - }) - ); + }), + }) + ); + request.flush(getAddonsAuthorizedStorageData(0)); - expect(httpMock.verify).toBeTruthy(); - } - )); + await expect(resultsPromise).resolves.toEqual( + Object({ + accountOwnerId: '0b441148-83e5-4f7f-b302-b07b528b160b', + apiBaseUrl: 'https://www.googleapis.com', + authUrl: null, + authorizedCapabilities: ['ACCESS', 'UPDATE'], + authorizedOperationNames: ['list_root_items', 'get_item_info', 'list_child_items'], + credentialsAvailable: true, + credentialsFormat: '', + iconUrl: '', + defaultRootFolder: '', + displayName: 'Google Drive', + externalServiceName: '', + oauthToken: 'ya29.A0AS3H6NzDCKgrUx', + externalStorageServiceId: '8aeb85e9-3a73-426f-a89b-5624b4b9d418', + id: '331b7333-a13a-4d3b-add0-5af0fd1d4ac4', + providerName: '', + supportedFeatures: [], + type: 'authorized-storage-accounts', + }) + ); + }); + + it('should update authorized addon with the full response document', async () => { + setup(); + + const payload = { + data: { + type: 'authorized-storage-accounts', + attributes: { + api_base_url: 'https://www.googleapis.com', + auth_url: null, + authorized_capabilities: ['ACCESS'], + credentials: {}, + credentials_available: true, + display_name: 'Google Drive', + initiate_oauth: false, + }, + relationships: {}, + }, + } as AuthorizedAddonRequestJsonApi; + + const resultsPromise = firstValueFrom(service.updateAuthorizedAddon(payload, 'storage', 'account-id')); + const request = httpMock.expectOne( + 'http://addons.localhost:8000/authorized-storage-accounts/account-id/?include=external-storage-service' + ); + expect(request.request.method).toBe('PATCH'); + expect(request.request.body).toEqual(payload); + request.flush(getAddonsAuthorizedStorageData(0)); + + const result = await resultsPromise; + expect(result.id).toBe('331b7333-a13a-4d3b-add0-5af0fd1d4ac4'); + expect(result.displayName).toBe('Google Drive'); + }); }); diff --git a/src/app/shared/services/addons/addons.service.ts b/src/app/shared/services/addons/addons.service.ts index cf12e1983..91701cf5d 100644 --- a/src/app/shared/services/addons/addons.service.ts +++ b/src/app/shared/services/addons/addons.service.ts @@ -1,11 +1,10 @@ import { select } from '@ngxs/store'; -import { map, Observable } from 'rxjs'; +import { map, Observable, throwError } from 'rxjs'; -import { HttpContext } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { BYPASS_ERROR_INTERCEPTOR } from '@core/interceptors/error-interceptor.tokens'; +import { bypassErrorInterceptorContext } from '@core/interceptors/error-interceptor.tokens'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { UserSelectors } from '@core/store/user'; import { AddonMapper } from '@osf/shared/mappers/addon.mapper'; @@ -64,7 +63,9 @@ export class AddonsService { getAddonsUserReference(): Observable { const currentUser = this.currentUser(); - if (!currentUser) throw new Error('Current user not found'); + if (!currentUser) { + return throwError(() => new Error('Current user not found')); + } const userUri = `${this.webUrl}/${currentUser.id}`; const params = { 'filter[user_uri]': userUri }; @@ -98,24 +99,23 @@ export class AddonsService { } getAuthorizedStorageOauthToken(accountId: string, addonType: string): Observable { - const context = new HttpContext(); - context.set(BYPASS_ERROR_INTERCEPTOR, true); + const payload = { + data: { + id: accountId, + type: `authorized-${addonType}-accounts`, + attributes: { serialize_oauth_token: 'true' }, + }, + }; return this.jsonApiService .patch( `${this.apiUrl}/authorized-${addonType}-accounts/${accountId}`, - { - data: { - id: accountId, - type: `authorized-${addonType}-accounts`, - attributes: { serialize_oauth_token: 'true' }, - }, - }, - {}, - {}, - context + payload, + undefined, + undefined, + bypassErrorInterceptorContext() ) - .pipe(map((response) => AddonMapper.fromAuthorizedAddonResponse(response as AuthorizedAddonDataJsonApi))); + .pipe(map((response) => AddonMapper.fromAuthorizedAddonResponse(response))); } getConfiguredAddons(addonType: string, referenceId: string): Observable { @@ -149,8 +149,8 @@ export class AddonsService { addonType: string, addonId: string ): Observable { - return this.jsonApiService.http - .patch( + return this.jsonApiService + .patchResponse( `${this.apiUrl}/authorized-${addonType}-accounts/${addonId}/?include=external-${addonType}-service`, addonRequestPayload ) diff --git a/src/app/shared/services/contributors.service.ts b/src/app/shared/services/contributors.service.ts index 094c3beee..4360da2b7 100644 --- a/src/app/shared/services/contributors.service.ts +++ b/src/app/shared/services/contributors.service.ts @@ -4,6 +4,7 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; +import { JSON_API_BULK_HEADERS } from '@osf/shared/constants/json-api-headers.const'; import { AddContributorType } from '../enums/contributors/add-contributor-type.enum'; import { ContributorPermission } from '../enums/contributors/contributor-permission.enum'; @@ -201,12 +202,8 @@ export class ContributorsService { data: contributors.map((contributor) => ContributorsMapper.toContributorUpdateRequest(contributor)), }; - const headers = { - 'Content-Type': 'application/vnd.api+json; ext=bulk', - }; - return this.jsonApiService - .patch(baseUrl, contributorData, undefined, headers) + .patch(baseUrl, contributorData, undefined, JSON_API_BULK_HEADERS) .pipe(map((response) => ContributorsMapper.getContributors(response.data))); } @@ -229,12 +226,8 @@ export class ContributorsService { }), }; - const headers = { - 'Content-Type': 'application/vnd.api+json; ext=bulk', - }; - return this.jsonApiService - .post(baseUrl, contributorData, undefined, headers) + .post(baseUrl, contributorData, undefined, JSON_API_BULK_HEADERS) .pipe(map((response) => ContributorsMapper.getContributors(response.data))); } diff --git a/src/app/shared/services/datacite/datacite.service.ts b/src/app/shared/services/datacite/datacite.service.ts index 38ed9f53d..3efff79b6 100644 --- a/src/app/shared/services/datacite/datacite.service.ts +++ b/src/app/shared/services/datacite/datacite.service.ts @@ -1,10 +1,10 @@ import { EMPTY, filter, map, Observable, of, switchMap, take } from 'rxjs'; import { isPlatformBrowser } from '@angular/common'; -import { HttpClient, HttpContext } from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; import { inject, Injectable, PLATFORM_ID } from '@angular/core'; -import { BYPASS_ERROR_INTERCEPTOR } from '@core/interceptors/error-interceptor.tokens'; +import { bypassErrorInterceptorContext } from '@core/interceptors/error-interceptor.tokens'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { DataciteEvent } from '@osf/shared/enums/datacite/datacite-event.enum'; import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; @@ -61,7 +61,7 @@ export class DataciteService { private logFile(targetId: string, targetType: string, event: DataciteEvent): Observable { const url = `${this.apiDomainUrl}/v2/${targetType}/${targetId}/identifiers`; - return this.http.get(url).pipe( + return this.http.get(url, { context: bypassErrorInterceptorContext() }).pipe( map((item) => ({ identifiers: item.data.map((identifierData) => ({ id: identifierData.id, @@ -74,21 +74,8 @@ export class DataciteService { ); } - /** - * Internal helper to log a specific Datacite event for a given DOI. - * Only tracks in browser environment using sendBeacon with HTTP POST fallback. - * - * @param event - The Datacite event type (VIEW or DOWNLOAD). - * @param doi - The DOI (Digital Object Identifier) of the resource. - * @returns An Observable that completes when the tracking request is sent, - * or EMPTY if DOI, repo ID is missing, or not in browser. - */ private logActivity(event: DataciteEvent, doi: string): Observable { - if (!doi || !this.dataciteTrackerRepoId) { - return EMPTY; - } - - if (!isPlatformBrowser(this.platformId)) { + if (!this.dataciteTrackerRepoId || !isPlatformBrowser(this.platformId)) { return EMPTY; } @@ -105,13 +92,11 @@ export class DataciteService { return of(void 0); } else { const headers = { 'Content-Type': 'application/json' }; - const context = new HttpContext(); - context.set(BYPASS_ERROR_INTERCEPTOR, true); return this.http .post(this.dataciteTrackerAddress, payload, { headers, - context, + context: bypassErrorInterceptorContext(), }) .pipe(map(() => undefined)); } diff --git a/src/app/shared/services/duplicates.service.spec.ts b/src/app/shared/services/duplicates.service.spec.ts new file mode 100644 index 000000000..827a9e4ea --- /dev/null +++ b/src/app/shared/services/duplicates.service.spec.ts @@ -0,0 +1,108 @@ +import { MockProvider } from 'ng-mocks'; + +import { firstValueFrom, of } from 'rxjs'; + +import { TestBed } from '@angular/core/testing'; + +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { BaseNodeMapper } from '@osf/shared/mappers/nodes'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; +import { NodesResponseJsonApi } from '@osf/shared/models/nodes/nodes-json-api.model'; +import { PaginatedData } from '@osf/shared/models/paginated-data.model'; + +import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; +import { MOCK_API_URL, provideMockEnvironment } from '@testing/providers/environment.provider.mock'; +import { JsonApiServiceMock, JsonApiServiceMockType } from '@testing/providers/json-api.service.mock'; + +import { DuplicatesService } from './duplicates.service'; +import { JsonApiService } from './json-api.service'; + +const BASE_GET_PARAMS: Record = { + embed: 'bibliographic_contributors', + 'fields[users]': 'family_name,full_name,given_name,middle_name', +}; + +function getDuplicatesApiResponse(): NodesResponseJsonApi { + return { + data: [], + links: {}, + meta: { + total: 1, + per_page: 10, + version: '2.0', + }, + }; +} + +describe('DuplicatesService', () => { + let service: DuplicatesService; + let jsonApiService: JsonApiServiceMockType; + + function setup() { + jsonApiService = JsonApiServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [DuplicatesService, MockProvider(JsonApiService, jsonApiService), provideMockEnvironment()], + }); + + service = TestBed.inject(DuplicatesService); + } + + it('should expose apiUrl from environment', () => { + setup(); + expect(service.apiUrl).toBe(MOCK_API_URL); + }); + + it('fetchAllDuplicates should request forks and map response', async () => { + setup(); + const mapped: PaginatedData = { + data: [MOCK_NODE_WITH_ADMIN], + totalCount: 1, + pageSize: 10, + }; + const mapperSpy = vi.spyOn(BaseNodeMapper, 'getNodesWithEmbedsAndTotalData').mockReturnValue(mapped); + const apiResponse = getDuplicatesApiResponse(); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.fetchAllDuplicates('node-1', CurrentResourceType.Projects)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/node-1/forks/`, BASE_GET_PARAMS); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual(mapped); + }); + + it('fetchAllDuplicates should include page and page size when provided', async () => { + setup(); + jsonApiService.get.mockReturnValue(of(getDuplicatesApiResponse())); + + await firstValueFrom(service.fetchAllDuplicates('reg-1', CurrentResourceType.Registrations, 2, 25)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/registrations/reg-1/forks/`, { + ...BASE_GET_PARAMS, + page: 2, + 'page[size]': 25, + }); + }); + + it('forkResource should POST forks payload with projects type', async () => { + setup(); + jsonApiService.post.mockReturnValue(of(undefined)); + + await firstValueFrom(service.forkResource('node-1', CurrentResourceType.Projects)); + + expect(jsonApiService.post).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/node-1/forks/`, { + data: { type: CurrentResourceType.Projects }, + }); + }); + + it('forkResource should POST to registrations forks with projects type payload', async () => { + setup(); + jsonApiService.post.mockReturnValue(of(undefined)); + + await firstValueFrom(service.forkResource('reg-1', CurrentResourceType.Registrations)); + + expect(jsonApiService.post).toHaveBeenCalledWith(`${MOCK_API_URL}/registrations/reg-1/forks/`, { + data: { type: CurrentResourceType.Projects }, + }); + }); +}); diff --git a/src/app/shared/services/duplicates.service.ts b/src/app/shared/services/duplicates.service.ts index 5d25f3166..5ade49285 100644 --- a/src/app/shared/services/duplicates.service.ts +++ b/src/app/shared/services/duplicates.service.ts @@ -4,6 +4,7 @@ import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; import { BaseNodeMapper } from '../mappers/nodes'; import { NodeModel } from '../models/nodes/base-node.model'; @@ -46,4 +47,10 @@ export class DuplicatesService { .get(`${this.apiUrl}/${resourceType}/${resourceId}/forks/`, params) .pipe(map((res) => BaseNodeMapper.getNodesWithEmbedsAndTotalData(res))); } + + forkResource(resourceId: string, resourceType: CurrentResourceType): Observable { + const payload = { data: { type: CurrentResourceType.Projects } }; + + return this.jsonApiService.post(`${this.apiUrl}/${resourceType}/${resourceId}/forks/`, payload); + } } diff --git a/src/app/shared/services/fork-dialog.service.spec.ts b/src/app/shared/services/fork-dialog.service.spec.ts new file mode 100644 index 000000000..e8037bee5 --- /dev/null +++ b/src/app/shared/services/fork-dialog.service.spec.ts @@ -0,0 +1,162 @@ +import { MockProvider } from 'ng-mocks'; + +import { of, Subject, throwError } from 'rxjs'; + +import { DestroyRef } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { ForkDialogComponent } from '@osf/shared/components/fork-dialog/fork-dialog.component'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { CustomDialogServiceMock, CustomDialogServiceMockType } from '@testing/providers/custom-dialog-provider.mock'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; + +import { ForkDialogService } from './fork-dialog.service'; + +describe('ForkDialogService', () => { + let service: ForkDialogService; + let customDialogService: CustomDialogServiceMockType; + let loaderService: LoaderServiceMock; + let toastService: ToastServiceMockType; + let destroyRef: DestroyRef; + + function setup() { + customDialogService = CustomDialogServiceMock.simple(); + loaderService = new LoaderServiceMock(); + toastService = ToastServiceMock.simple(); + destroyRef = { onDestroy: vi.fn() } as unknown as DestroyRef; + + TestBed.configureTestingModule({ + providers: [ + ForkDialogService, + MockProvider(CustomDialogService, customDialogService), + MockProvider(LoaderService, loaderService), + MockProvider(ToastService, toastService), + ], + }); + + service = TestBed.inject(ForkDialogService); + } + + it('confirmFork should open dialog with project header', () => { + setup(); + const onClose$ = new Subject(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + + service.confirmFork({ + resourceType: CurrentResourceType.Projects, + destroyRef, + fork: vi.fn().mockReturnValue(of(undefined)), + }); + + expect(customDialogService.open).toHaveBeenCalledWith(ForkDialogComponent, { + header: 'project.overview.dialog.fork.headerProject', + width: '450px', + data: { resourceType: CurrentResourceType.Projects }, + }); + }); + + it('confirmFork should open dialog with registration header', () => { + setup(); + const onClose$ = new Subject(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + + service.confirmFork({ + resourceType: CurrentResourceType.Registrations, + destroyRef, + fork: vi.fn().mockReturnValue(of(undefined)), + }); + + expect(customDialogService.open).toHaveBeenCalledWith(ForkDialogComponent, { + header: 'project.overview.dialog.fork.headerRegistration', + width: '450px', + data: { resourceType: CurrentResourceType.Registrations }, + }); + }); + + it('confirmFork should not fork when dialog is dismissed', () => { + setup(); + const onClose$ = new Subject(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + const fork = vi.fn().mockReturnValue(of(undefined)); + const onSuccess = vi.fn(); + + service.confirmFork({ + resourceType: CurrentResourceType.Projects, + destroyRef, + fork, + onSuccess, + }); + + onClose$.next(false); + + expect(fork).not.toHaveBeenCalled(); + expect(loaderService.show).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('confirmFork should fork project, show toast, and call onSuccess', () => { + setup(); + const onClose$ = new Subject(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + const fork = vi.fn().mockReturnValue(of(undefined)); + const onSuccess = vi.fn(); + + service.confirmFork({ + resourceType: CurrentResourceType.Projects, + destroyRef, + fork, + onSuccess, + }); + + onClose$.next(true); + + expect(loaderService.show).toHaveBeenCalled(); + expect(fork).toHaveBeenCalled(); + expect(loaderService.hide).toHaveBeenCalled(); + expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.fork.success'); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('confirmFork should show registration success toast', () => { + setup(); + const onClose$ = new Subject(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + + service.confirmFork({ + resourceType: CurrentResourceType.Registrations, + destroyRef, + fork: vi.fn().mockReturnValue(of(undefined)), + }); + + onClose$.next(true); + + expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.fork.successRegistration'); + }); + + it('confirmFork should hide loader and skip success handlers when fork fails', () => { + setup(); + const onClose$ = new Subject(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + const onSuccess = vi.fn(); + + service.confirmFork({ + resourceType: CurrentResourceType.Projects, + destroyRef, + fork: vi.fn().mockReturnValue(throwError(() => new Error('fork failed'))), + onSuccess, + }); + + onClose$.next(true); + + expect(loaderService.show).toHaveBeenCalled(); + expect(loaderService.hide).toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/shared/services/fork-dialog.service.ts b/src/app/shared/services/fork-dialog.service.ts new file mode 100644 index 000000000..bbdcc9960 --- /dev/null +++ b/src/app/shared/services/fork-dialog.service.ts @@ -0,0 +1,54 @@ +import { EMPTY } from 'rxjs'; +import { catchError, filter, finalize, switchMap } from 'rxjs/operators'; + +import { inject, Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { ForkDialogComponent } from '@osf/shared/components/fork-dialog/fork-dialog.component'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { ConfirmForkOptions } from '../models/confirm-fork-options.model'; + +@Injectable({ + providedIn: 'root', +}) +export class ForkDialogService { + private readonly customDialogService = inject(CustomDialogService); + private readonly loaderService = inject(LoaderService); + private readonly toastService = inject(ToastService); + + confirmFork(options: ConfirmForkOptions): void { + const isRegistration = options.resourceType === CurrentResourceType.Registrations; + + this.customDialogService + .open(ForkDialogComponent, { + header: isRegistration + ? 'project.overview.dialog.fork.headerRegistration' + : 'project.overview.dialog.fork.headerProject', + width: '450px', + data: { resourceType: options.resourceType }, + }) + .onClose.pipe( + filter((confirmed: boolean) => !!confirmed), + switchMap(() => { + this.loaderService.show(); + return options.fork().pipe( + catchError(() => EMPTY), + finalize(() => this.loaderService.hide()) + ); + }), + takeUntilDestroyed(options.destroyRef) + ) + .subscribe(() => { + this.toastService.showSuccess( + isRegistration + ? 'project.overview.dialog.toast.fork.successRegistration' + : 'project.overview.dialog.toast.fork.success' + ); + options.onSuccess?.(); + }); + } +} diff --git a/src/app/shared/services/json-api.service.spec.ts b/src/app/shared/services/json-api.service.spec.ts new file mode 100644 index 000000000..ad9de9d2b --- /dev/null +++ b/src/app/shared/services/json-api.service.spec.ts @@ -0,0 +1,133 @@ +import { firstValueFrom } from 'rxjs'; + +import { HttpContext } from '@angular/common/http'; +import { HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { BYPASS_ERROR_INTERCEPTOR, bypassErrorInterceptorContext } from '@core/interceptors/error-interceptor.tokens'; + +import { provideOSFCore, provideOSFHttp } from '@testing/osf.testing.provider'; + +import { JsonApiService } from './json-api.service'; + +describe('JsonApiService', () => { + let service: JsonApiService; + let httpMock: HttpTestingController; + + const resource = { + id: '1', + type: 'nodes', + attributes: { title: 'Test' }, + }; + + const document = { + data: resource, + included: [{ id: '2', type: 'users' }], + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideOSFCore(), provideOSFHttp(), JsonApiService], + }); + + service = TestBed.inject(JsonApiService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should get the full response document', async () => { + const resultPromise = firstValueFrom(service.get('/v2/nodes/1/')); + + const req = httpMock.expectOne('/v2/nodes/1/'); + expect(req.request.method).toBe('GET'); + req.flush(document); + + await expect(resultPromise).resolves.toEqual(document); + }); + + it('should append array query params', async () => { + const resultPromise = firstValueFrom( + service.get('/v2/nodes/', { 'embed[]': ['bibliographic_contributors', 'parent'] }) + ); + + const req = httpMock.expectOne( + (request) => + request.url === '/v2/nodes/' && + request.params.getAll('embed[]')?.join(',') === 'bibliographic_contributors,parent' + ); + expect(req.request.method).toBe('GET'); + req.flush(document); + + await expect(resultPromise).resolves.toEqual(document); + }); + + it('should post the full response document', async () => { + const body = { data: { type: 'nodes', attributes: { title: 'New' } } }; + const resultPromise = firstValueFrom(service.post('/v2/nodes/', body)); + + const req = httpMock.expectOne('/v2/nodes/'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(body); + req.flush(document); + + await expect(resultPromise).resolves.toEqual(document); + }); + + it('should unwrap data from patch responses', async () => { + const body = { data: resource }; + const resultPromise = firstValueFrom(service.patch('/v2/nodes/1/', body)); + + const req = httpMock.expectOne('/v2/nodes/1/'); + expect(req.request.method).toBe('PATCH'); + expect(req.request.body).toEqual(body); + req.flush(document); + + await expect(resultPromise).resolves.toEqual(resource); + }); + + it('should return the full document from patchResponse', async () => { + const body = { data: resource }; + const context = bypassErrorInterceptorContext(); + const resultPromise = firstValueFrom( + service.patchResponse('/v2/nodes/1/?include=users', body, undefined, undefined, context) + ); + + const req = httpMock.expectOne('/v2/nodes/1/?include=users'); + expect(req.request.method).toBe('PATCH'); + expect(req.request.body).toEqual(body); + expect(req.request.context.get(BYPASS_ERROR_INTERCEPTOR)).toBe(true); + req.flush(document); + + await expect(resultPromise).resolves.toEqual(document); + }); + + it('should unwrap data from put responses', async () => { + const body = { data: resource }; + const resultPromise = firstValueFrom(service.put('/v2/nodes/1/', body)); + + const req = httpMock.expectOne('/v2/nodes/1/'); + expect(req.request.method).toBe('PUT'); + req.flush(document); + + await expect(resultPromise).resolves.toEqual(resource); + }); + + it('should delete with optional body, headers, and context', async () => { + const body = { data: [{ id: '1', type: 'nodes' }] }; + const headers = { 'Content-Type': 'application/vnd.api+json; ext=bulk' }; + const context = new HttpContext(); + const resultPromise = firstValueFrom(service.delete('/v2/nodes/', body, headers, context)); + + const req = httpMock.expectOne('/v2/nodes/'); + expect(req.request.method).toBe('DELETE'); + expect(req.request.body).toEqual(body); + expect(req.request.headers.get('Content-Type')).toBe(headers['Content-Type']); + expect(req.request.context).toBe(context); + req.flush(null); + + await expect(resultPromise).resolves.toBeNull(); + }); +}); diff --git a/src/app/shared/services/json-api.service.ts b/src/app/shared/services/json-api.service.ts index 0b5cc1520..52ebfcf39 100644 --- a/src/app/shared/services/json-api.service.ts +++ b/src/app/shared/services/json-api.service.ts @@ -9,7 +9,7 @@ import { JsonApiResponse } from '../models/common/json-api/responses.model'; providedIn: 'root', }) export class JsonApiService { - http: HttpClient = inject(HttpClient); + private readonly http = inject(HttpClient); get( url: string, @@ -61,6 +61,16 @@ export class JsonApiService { .pipe(map((response) => response?.data)); } + patchResponse( + url: string, + body: unknown, + params?: Record, + headers?: Record, + context?: HttpContext + ): Observable { + return this.http.patch(url, body, { params: this.buildHttpParams(params), headers, context }); + } + put(url: string, body: unknown, params?: Record): Observable { return this.http .put>(url, body, { params: this.buildHttpParams(params) }) @@ -80,7 +90,7 @@ export class JsonApiService { }); } - delete(url: string, body?: unknown, headers?: Record): Observable { - return this.http.delete(url, { body, headers }); + delete(url: string, body?: unknown, headers?: Record, context?: HttpContext): Observable { + return this.http.delete(url, { body, headers, context }); } } diff --git a/src/app/shared/services/wiki.service.ts b/src/app/shared/services/wiki.service.ts index 4bd4997b1..dfb1ef4c5 100644 --- a/src/app/shared/services/wiki.service.ts +++ b/src/app/shared/services/wiki.service.ts @@ -15,7 +15,6 @@ import { WikiDataJsonApi, WikiItemResponseJsonApi, WikiResponseJsonApi, - WikiVersionItemResponseJsonApi, WikiVersionResponseJsonApi, } from '../models/wiki/wiki-json-api.model'; import { ComponentWiki } from '../stores/wiki'; @@ -133,8 +132,8 @@ export class WikiService { const body = { data: { type: 'wiki-versions', attributes: { content } } }; return this.jsonApiService - .post(`${this.apiUrl}/wikis/${wikiId}/versions/`, body) - .pipe(map((response) => WikiMapper.fromGetWikiVersionResponse(response.data))); + .post(`${this.apiUrl}/wikis/${wikiId}/versions/`, body) + .pipe(map((response) => WikiMapper.fromCreateWikiVersionResponse(response.data))); } getWikiVersionContent(wikiId: string, versionId: string): Observable { diff --git a/src/app/shared/stores/current-resource/current-resource.selectors.ts b/src/app/shared/stores/current-resource/current-resource.selectors.ts index c4d7d3b17..c428ef8c5 100644 --- a/src/app/shared/stores/current-resource/current-resource.selectors.ts +++ b/src/app/shared/stores/current-resource/current-resource.selectors.ts @@ -24,6 +24,13 @@ export class CurrentResourceSelectors { return state.resourceChildren.data; } + @Selector([CurrentResourceSelectors.getResourceWithChildren]) + static allResourceChildrenHaveAdminAccess(children: NodeShortInfoModel[]): boolean { + if (!children?.length) return false; + + return children.every((child) => child.permissions?.includes(UserPermissions.Admin)); + } + @Selector([CurrentResourceState]) static hasWriteAccess(state: CurrentResourceStateModel): boolean { return state.currentResource.data?.permissions?.includes(UserPermissions.Write) || false; diff --git a/src/assets/config/template.json b/src/assets/config/template.json index f74a71179..620aa70e5 100644 --- a/src/assets/config/template.json +++ b/src/assets/config/template.json @@ -10,6 +10,7 @@ "dataciteTrackerRepoId": null, "dataciteTrackerAddress": "", "sentryDsn": "", + "sentryEnvironment": "", "googleTagManagerId": "", "googleFilePickerApiKey": "", "googleFilePickerAppId": 0, diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 26437b779..7304da4ff 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -480,7 +480,12 @@ "message": "Are you sure you want to discard your unsaved changes?" }, "errorMessages": { + "badRequest": "Invalid request. Please check your input.", + "unauthorized": "You are not authorized to perform this action.", + "forbidden": "You don't have permission to access this resource.", + "notFound": "The requested resource was not found.", "serverError": "An unexpected error occurred. Please try again later.", + "unknown": "An unknown error occurred. Please try again.", "actionUnavailable": "This action is no longer available. Contact support if you have any questions." }, "hint": { @@ -977,6 +982,8 @@ "rejectWithdrawalMessage": "Submission will remain fully public", "remarksPlaceholder": "Add remarks to registration admins", "removeSuccess": "Submission has been withdrawn successfully", + "reportSpam": "Report spam", + "reportSpamSuccess": "Submission has been reported as spam", "submitDecision": "Submit decision", "withdrawRequest": "Withdraw Request", "withdrawRequestMessage": "Submission will be withdrawn from the collection and will no longer appear in search results." @@ -1279,8 +1286,13 @@ "software": "Software", "other": "Other" }, + "contentDeleted": { + "message": "This page has been removed and is no longer available. Contact support if you have any questions.", + "title": "This content has been deleted" + }, "pageNotFound": { - "message": "The page you were looking for is either doesn't exist, was deleted, or the URL may be incorrect. If this should not have occurred and the issue persists, please report it to", + "contactSupport": "Contact Support", + "message": "The page you're looking for doesn't exist or may have been moved. Contact support if you have any questions.", "title": "Page not found" }, "preprints": { @@ -1332,6 +1344,10 @@ "post": "Submission will be removed from search results and made private.", "pre": "Submission will not appear in search results and will remain private." }, + "reportSpam": { + "label": "Report spam", + "success": "Submission has been reported as spam" + }, "settings": { "comments": { "private": "Comments are not visible to contributors", @@ -1895,6 +1911,7 @@ "overview": { "actions": { "duplicateProject": "Duplicate project (structure only)", + "duplicateRegistration": "Duplicate registration", "forkProject": "Duplicate project (with files)", "forkProjectLabel": "Duplicate project", "manageContributors": "Manage Contributors", @@ -1952,9 +1969,11 @@ }, "fork": { "confirmButton": "Duplicate", - "forksMessage": "Duplicates you have permission to view are shown here.", + "forksMessage": "Duplicates that you have permission to view are shown here.", "headerProject": "Duplicate This Project", + "headerRegistration": "Duplicate This Registration", "messageProject": "Are you sure you want to duplicate this project (with files)?", + "messageRegistration": "Are you sure you want to duplicate this registration?", "noForksMessage": "No duplicates found" }, "linkProject": { @@ -2006,7 +2025,8 @@ "success": "Project has been duplicated successfully" }, "fork": { - "success": "Project has been duplicated successfully" + "success": "Project has been duplicated successfully", + "successRegistration": "Registration has been duplicated successfully" }, "reorderComponents": { "success": "Components have been reordered successfully." @@ -2542,6 +2562,11 @@ "message": "Are you sure you want to delete {{name}} identity?" }, "description": "Connected identities allow you to log in to the OSF via a third-party service. You can revoke these identifies.", + "errors": { + "contactSupport": "Unable to disconnect this identity. Please contact support if the problem persists.", + "retryLater": "Unable to revoke ORCiD access at this time. Please try again later.", + "signInViaOrcid": "We couldn't find a stored ORCiD authorization for this identity. Please sign in via ORCiD and try again." + }, "noIdentities": "You have not authorized any external services to log in to the OSF.", "successDelete": "Successfully deleted connected identities.", "title": "Connected identities" @@ -2903,7 +2928,7 @@ "maintenance": { "details": "For more details, please visit", "message": "Please come back later.", - "title": "The OSF is currently down for scheduled maintenance." + "title": "The OSF is currently unavailable for scheduled maintenance." }, "shared": { "affiliatedInstitutions": { diff --git a/src/testing/providers/cookie-consent.service.mock.ts b/src/testing/providers/cookie-consent.service.mock.ts new file mode 100644 index 000000000..67ef9d9e3 --- /dev/null +++ b/src/testing/providers/cookie-consent.service.mock.ts @@ -0,0 +1,17 @@ +import { Mock } from 'vitest'; + +import { CookieConsentService } from '@core/services/cookie-consent.service'; + +export type CookieConsentServiceMockType = Pick & { + hasConsent: Mock; + accept: Mock; +}; + +export const CookieConsentServiceMock = { + simple(): CookieConsentServiceMockType { + return { + hasConsent: vi.fn().mockReturnValue(false), + accept: vi.fn(), + }; + }, +}; diff --git a/src/testing/providers/delete-resource-provider.mock.ts b/src/testing/providers/delete-resource-provider.mock.ts new file mode 100644 index 000000000..8e1f1eecc --- /dev/null +++ b/src/testing/providers/delete-resource-provider.mock.ts @@ -0,0 +1,20 @@ +import { Mock } from 'vitest'; + +import { DeleteResourceBaseOptions } from '@osf/features/project/settings/models/delete-resource-options.model'; +import { DeleteResourceService } from '@osf/features/project/settings/services/delete-resource.service'; + +type DeleteResourceFn = (options: DeleteResourceBaseOptions) => void; + +export type DeleteResourceServiceMockType = Partial & { + deleteComponent: Mock; + deleteProject: Mock; +}; + +export const DeleteResourceServiceMock = { + simple(): DeleteResourceServiceMockType { + return { + deleteComponent: vi.fn(), + deleteProject: vi.fn(), + }; + }, +}; diff --git a/src/testing/providers/environment.provider.mock.ts b/src/testing/providers/environment.provider.mock.ts new file mode 100644 index 000000000..61df2eb80 --- /dev/null +++ b/src/testing/providers/environment.provider.mock.ts @@ -0,0 +1,14 @@ +import { MockProvider } from 'ng-mocks'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { EnvironmentModel } from '@osf/shared/models/environment.model'; + +export const MOCK_API_DOMAIN_URL = 'https://api.test'; +export const MOCK_API_URL = `${MOCK_API_DOMAIN_URL}/v2`; + +export function provideMockEnvironment(overrides: Partial = {}) { + return MockProvider(ENVIRONMENT, { + apiDomainUrl: MOCK_API_DOMAIN_URL, + ...overrides, + }); +} diff --git a/src/testing/providers/external-identity-deletion-service.mock.ts b/src/testing/providers/external-identity-deletion-service.mock.ts new file mode 100644 index 000000000..9d7b6f413 --- /dev/null +++ b/src/testing/providers/external-identity-deletion-service.mock.ts @@ -0,0 +1,37 @@ +import { Mock, vi } from 'vitest'; + +import { ExternalIdentityDeletionService } from '@osf/features/settings/account-settings/services/external-identity-deletion.service'; + +type ConfirmAndDeleteFn = (identityId: string) => void; + +export type ExternalIdentityDeletionServiceMockType = Pick & { + confirmAndDelete: Mock; +}; + +export class ExternalIdentityDeletionServiceMockBuilder { + private confirmAndDeleteMock: Mock = vi.fn(); + + static create(): ExternalIdentityDeletionServiceMockBuilder { + return new ExternalIdentityDeletionServiceMockBuilder(); + } + + withConfirmAndDelete(mockImpl: Mock): ExternalIdentityDeletionServiceMockBuilder { + this.confirmAndDeleteMock = mockImpl; + return this; + } + + build(): ExternalIdentityDeletionServiceMockType { + return { + confirmAndDelete: this.confirmAndDeleteMock, + } as ExternalIdentityDeletionServiceMockType; + } +} + +export const ExternalIdentityDeletionServiceMock = { + create() { + return ExternalIdentityDeletionServiceMockBuilder.create(); + }, + simple() { + return ExternalIdentityDeletionServiceMockBuilder.create().build(); + }, +}; diff --git a/src/testing/providers/fork-dialog.service.mock.ts b/src/testing/providers/fork-dialog.service.mock.ts new file mode 100644 index 000000000..d80dc40ab --- /dev/null +++ b/src/testing/providers/fork-dialog.service.mock.ts @@ -0,0 +1,14 @@ +import { Mock } from 'vitest'; + +import { ConfirmForkOptions } from '@osf/shared/models/confirm-fork-options.model'; +import { ForkDialogService } from '@osf/shared/services/fork-dialog.service'; + +export type ForkDialogServiceMockType = Partial & { + confirmFork: Mock<(options: ConfirmForkOptions) => void>; +}; + +export const ForkDialogServiceMock = { + simple(): ForkDialogServiceMockType { + return { confirmFork: vi.fn() }; + }, +}; diff --git a/src/testing/providers/json-api.service.mock.ts b/src/testing/providers/json-api.service.mock.ts index 828c7b44e..7134d401f 100644 --- a/src/testing/providers/json-api.service.mock.ts +++ b/src/testing/providers/json-api.service.mock.ts @@ -27,12 +27,21 @@ type PatchFn = ( ) => Observable; type PutFn = (url: string, body: unknown, params?: Record) => Observable; type PutFileFn = (url: string, file: File, params?: Record) => Observable>; -type DeleteFn = (url: string, body?: unknown, headers?: Record) => Observable; +type DeleteFn = ( + url: string, + body?: unknown, + headers?: Record, + context?: HttpContext +) => Observable; -export type JsonApiServiceMockType = Pick & { +export type JsonApiServiceMockType = Pick< + JsonApiService, + 'get' | 'post' | 'patch' | 'patchResponse' | 'put' | 'putFile' | 'delete' +> & { get: Mock; post: Mock; patch: Mock; + patchResponse: Mock; put: Mock; putFile: Mock; delete: Mock; @@ -42,6 +51,7 @@ export class JsonApiServiceMockBuilder { private getMock: Mock = vi.fn().mockReturnValue(of({})); private postMock: Mock = vi.fn().mockReturnValue(of({})); private patchMock: Mock = vi.fn().mockReturnValue(of({})); + private patchResponseMock: Mock = vi.fn().mockReturnValue(of({})); private putMock: Mock = vi.fn().mockReturnValue(of({})); private putFileMock: Mock = vi.fn().mockReturnValue(of({} as HttpEvent)); private deleteMock: Mock = vi.fn().mockReturnValue(of(void 0)); @@ -65,6 +75,11 @@ export class JsonApiServiceMockBuilder { return this; } + withPatchResponse(mockImpl: Mock): JsonApiServiceMockBuilder { + this.patchResponseMock = mockImpl; + return this; + } + withPut(mockImpl: Mock): JsonApiServiceMockBuilder { this.putMock = mockImpl; return this; @@ -85,6 +100,7 @@ export class JsonApiServiceMockBuilder { get: this.getMock, post: this.postMock, patch: this.patchMock, + patchResponse: this.patchResponseMock, put: this.putMock, putFile: this.putFileMock, delete: this.deleteMock, diff --git a/src/testing/providers/maintenance-mode.service.mock.ts b/src/testing/providers/maintenance-mode.service.mock.ts index fcea0a289..95e03ad5c 100644 --- a/src/testing/providers/maintenance-mode.service.mock.ts +++ b/src/testing/providers/maintenance-mode.service.mock.ts @@ -1,5 +1,6 @@ import { Mock } from 'vitest'; +import { HttpErrorResponse } from '@angular/common/http'; import { Signal, signal } from '@angular/core'; import { MaintenanceModeService } from '@core/services/maintenance-mode.service'; @@ -8,6 +9,7 @@ export type MaintenanceModeServiceMockType = Partial & { activate: Mock<() => void>; deactivate: Mock<() => void>; checkOnce: Mock<() => void>; + isMaintenanceError: Mock<(error: HttpErrorResponse) => boolean>; isActive: Signal; }; @@ -17,6 +19,9 @@ export const MaintenanceModeServiceMock = { activate: vi.fn(), deactivate: vi.fn(), checkOnce: vi.fn(), + isMaintenanceError: vi.fn( + (error: HttpErrorResponse) => error.status === 503 && error.error?.meta?.maintenance_mode === true + ), isActive: signal(isActive).asReadonly(), } as MaintenanceModeServiceMockType; },