diff --git a/.github/scripts/check-coverage-thresholds.js b/.github/scripts/check-coverage-thresholds.js index 6eb7a39e7..02c3995fd 100644 --- a/.github/scripts/check-coverage-thresholds.js +++ b/.github/scripts/check-coverage-thresholds.js @@ -1,5 +1,4 @@ const fs = require('fs'); -const { execSync } = require('child_process'); const coverage = require('../../coverage/coverage-summary.json'); const jestConfig = require('../../jest.config.js'); @@ -41,7 +40,6 @@ for (const key of ['branches', 'functions', 'lines', 'statements']) { if (failed) { const stars = '*'.repeat(warnMessage.length + 8); - execSync('clear', { stdio: 'inherit' }); console.log('\n\nCongratulations! You have successfully run the coverage check and added tests.'); console.log('\n\nThe jest.config.js file is not insync with your new test additions.'); console.log('Please update the coverage thresholds in jest.config.js.'); diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 3c12bf4fe..b06877914 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -3,9 +3,7 @@ name: Require at least one approval on: pull_request: - types: [opened, reopened, ready_for_review, synchronize] - pull_request_review: - types: [submitted, edited, dismissed] + types: [opened, reopened, ready_for_review, synchronize, edited] jobs: check-approval: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7e41d3893..75fc25cd7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,11 +5,29 @@ on: branches: ['*'] # or change to match your default branch push: branches: ['*'] + workflow_dispatch: jobs: test: runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.13.1 + + - name: Install dependencies + run: npm ci + + - name: Run Jest tests + run: npm run ci:test + test-coverage: + runs-on: ubuntu-latest + steps: - name: Checkout code uses: actions/checkout@v3 @@ -23,7 +41,27 @@ jobs: run: npm ci - name: Run Jest tests with coverage - run: npm run ci + run: npm run ci:test:coverage - name: Check coverage thresholds run: npm run test:check-coverage-thresholds + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.13.1 + + - name: Install dependencies + run: npm ci + + - name: Run linting + run: npm run lint + + - name: Check formatting with prettier + run: npm run format:check diff --git a/.gitignore b/.gitignore index 2f85265bd..fca5b5041 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /tmp /out-tsc /bazel-out +/src/assets/config/config.json # Node /node_modules diff --git a/README.md b/README.md index 14508c7ab..8b4fbb3be 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ take up to 60 seconds once the docker build finishes. - Install git commit template: [Commit Template](docs/commit.template.md). - [Volta](#volta) +- 3rd-party tokens [Configuration](#configuration) ### Recommended @@ -25,6 +26,7 @@ take up to 60 seconds once the docker build finishes. - [Docker Commands](docs/docker.md). - [ESLint Strategy](docs/eslint.md). - [Git Conventions](docs/git-convention.md). +- [i18n](docs/i18n.md). - [NGXS Conventions](docs/ngxs.md). - [Testing Strategy](docs/testing.md). @@ -58,3 +60,9 @@ npm run test:check-coverage-thresholds OSF uses volta to manage node and npm versions inside of the repository Install Volta from [volta](https://volta.sh/) and it will automatically pin Node/npm per the repo toolchain. + +## Configuration + +OSF uses an `assets/config/config.json` file for any 3rd-party tokens. This file is not committed to the repo. + +There is a `assets/config/template.json` file that can be copied to `assets/config/config.json` to store any 3rd-party tokens locally. diff --git a/angular.json b/angular.json index 51ff0bd44..a799f5f67 100644 --- a/angular.json +++ b/angular.json @@ -29,9 +29,11 @@ "cedar-embeddable-editor", "cedar-artifact-viewer", "markdown-it-video", - "ace-builds/src-noconflict/ext-language_tools" + "ace-builds/src-noconflict/ext-language_tools", + "@traptitech/markdown-it-katex" ], "assets": [ + "src/favicon.ico", "src/assets", { "glob": "**/*", @@ -63,7 +65,7 @@ "budgets": [ { "type": "initial", - "maximumWarning": "7MB", + "maximumWarning": "9MB", "maximumError": "10MB" }, { @@ -79,25 +81,58 @@ "outputHashing": "none", "namedChunks": true }, - "local": { + "development": { "optimization": false, "extractLicenses": false, "sourceMap": true, "fileReplacements": [ { "replace": "src/environments/environment.ts", - "with": "src/environments/environment.local.ts" + "with": "src/environments/environment.development.ts" } ] }, - "development": { + "docker": { "optimization": false, "extractLicenses": false, "sourceMap": true, "fileReplacements": [ { "replace": "src/environments/environment.ts", - "with": "src/environments/environment.development.ts" + "with": "src/environments/environment.docker.ts" + } + ] + }, + "staging": { + "optimization": true, + "extractLicenses": false, + "sourceMap": false, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.staging.ts" + } + ] + }, + "test": { + "optimization": true, + "extractLicenses": false, + "sourceMap": false, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.test.ts" + } + ] + }, + "test-osf": { + "optimization": true, + "extractLicenses": false, + "sourceMap": false, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.test-osf.ts" } ] } @@ -114,8 +149,20 @@ "buildTarget": "osf:build:development", "hmr": false }, - "local": { - "buildTarget": "osf:build:local", + "docker": { + "buildTarget": "osf:build:docker", + "hmr": false + }, + "staging": { + "buildTarget": "osf:build:staging", + "hmr": false + }, + "test": { + "buildTarget": "osf:build:test", + "hmr": false + }, + "test-osf": { + "buildTarget": "osf:build:test-osf", "hmr": false } }, diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 33a11c3e9..2e552773e 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -5,15 +5,16 @@ module.exports = { 2, 'always', [ + 'chore', // Build process, CI/CD, dependencies + 'docs', // Documentation update 'feat', // New feature 'fix', // Bug fix - 'docs', // Documentation update - 'style', // Code style (formatting, missing semicolons, etc.) - 'refactor', // Code refactoring (no feature changes) + 'lang', // All updates related to i18n changes 'perf', // Performance improvements - 'test', // Adding tests - 'chore', // Build process, CI/CD, dependencies + 'refactor', // Code refactoring (no feature changes) 'revert', // Reverting changes + 'style', // Code style (formatting, missing semicolons, etc.) + 'test', // Adding tests ], ], 'scope-empty': [2, 'never'], // Scope must always be present diff --git a/docker-compose.yml b/docker-compose.yml index d5e025875..a8148f3b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: - ./angular.json:/app/angular.json - ./tsconfig.json:/app/tsconfig.json - ./tsconfig.app.json:/app/tsconfig.app.json + - ./docker/scripts:/app/docker # (CMD comes from Dockerfile, but you could override here if you wanted) command: ['npm', 'run', 'start:docker'] diff --git a/docker/scripts/check-config.js b/docker/scripts/check-config.js new file mode 100644 index 000000000..4dc32d9aa --- /dev/null +++ b/docker/scripts/check-config.js @@ -0,0 +1,12 @@ +const fs = require('fs'); +const path = require('path'); + +const configPath = path.join(__dirname, '../src/assets/config/config.json'); +const templatePath = path.join(__dirname, '../src/assets/config/template.json'); + +if (!fs.existsSync(configPath)) { + console.log('[INFO] config.json not found. Copying from template.json...'); + fs.copyFileSync(templatePath, configPath); +} else { + console.log('[INFO] config.json already exists.'); +} diff --git a/docs/admin.knowledge-base.md b/docs/admin.knowledge-base.md index 15d8adf5a..d33082099 100644 --- a/docs/admin.knowledge-base.md +++ b/docs/admin.knowledge-base.md @@ -1,7 +1,18 @@ # Admin Knowledge Base +## Index + +- [Overview](#overview) +- [All things GitHub](#all-things-github) + +--- + +## Overview + Information on updates that require admin permissions +--- + ## All things GitHub ### GitHub pipeline @@ -16,6 +27,8 @@ The `.github` folder contains the following: .github/workflows 4. The GitHub PR templates .github/pull_request_template.md +5. The backup json for the settings/rules + .github/rules ### Local pipeline diff --git a/docs/assets/osf-ngxs-diagram.png b/docs/assets/osf-ngxs-diagram.png index e19e06910..95460f3ae 100644 Binary files a/docs/assets/osf-ngxs-diagram.png and b/docs/assets/osf-ngxs-diagram.png differ diff --git a/docs/commit.template.md b/docs/commit.template.md index f5a591b92..5bcfdf575 100644 --- a/docs/commit.template.md +++ b/docs/commit.template.md @@ -1,5 +1,7 @@ ## 📝 Enabling the Shared Commit Template +## Overview + This project includes a Git commit message template stored at: ``` diff --git a/docs/compodoc.md b/docs/compodoc.md index 30ff0b86b..0f61cc364 100644 --- a/docs/compodoc.md +++ b/docs/compodoc.md @@ -1,5 +1,20 @@ # Angular Documentation with Compodoc +## Index + +- [Overview](#overview) +- [How to Generate Documentation](#how-to-generate-documentation) +- [Documentation Coverage Requirements](#documentation-coverage-requirements) +- [Pre-commit Enforcement via Husky](#pre-commit-enforcement-via-husky) +- [CI/CD Enforcement](#cicd-enforcement) +- [Tips for Passing Coverage](#tips-for-passing-coverage) +- [Output Directory](#output-directory) +- [Need Help?](#need-help) + +--- + +## Overview + This project uses [Compodoc](https://compodoc.app/) to generate and enforce documentation for all Angular code. Documentation is mandatory and must meet a **100% coverage threshold** to ensure consistent API clarity across the codebase. --- diff --git a/docs/docker.md b/docs/docker.md index 6cb529323..0404bdaa4 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -1,5 +1,15 @@ # Docker +## Index + +- [Overview](#overview) +- [Docker Commands](#docker-commands) +- [Troubleshooting](#troubleshooting) + +--- + +## Overview + The OSF angular project uses a docker image to simplify the developer process. ### Volumes @@ -33,6 +43,8 @@ If you don’t see the site, ensure the start script includes: "start": "ng serve --host 0.0.0.0 --port 4200 --poll 2000" ``` +--- + ## Docker Commands ### build + run in background (build is only required for the initial install or npm updates) @@ -105,6 +117,8 @@ docker rmi : docker rmi -f ``` +--- + ## Troubleshooting If the application does not open in your browser at [http://localhost:4200](http://localhost:4200), follow these steps in order: diff --git a/docs/eslint.md b/docs/eslint.md index 5a4933835..255d0cc3c 100644 --- a/docs/eslint.md +++ b/docs/eslint.md @@ -1,5 +1,13 @@ # Linting Strategy – OSF Angular +## Index + +- [Overview](#overview) +- [Linting Commands](#linting-commands) +- [ESLint Config Structure](#eslint-config-structure) +- [Pre-Commit Hook](#pre-commit-hook) +- [Summary](#summary) + --- ## Overview @@ -12,6 +20,9 @@ This project uses a **unified, modern ESLint flat config** approach to enforce c It also integrates into the **Git workflow** via `pre-commit` hooks to ensure clean, compliant code before every commit. +**IMPORTANT** +The OSF application must meet full accessibility (a11y) compliance to ensure equitable access for users of all abilities, in alignment with our commitment to inclusivity and funding obligations. + --- ## Linting Commands diff --git a/docs/git-convention.md b/docs/git-convention.md index d0c99b2b4..f231f792f 100644 --- a/docs/git-convention.md +++ b/docs/git-convention.md @@ -1,11 +1,25 @@ # CommitLint and Git Branch Naming Convention (Aligned with Angular Guideline) +## Index + +- [Overview](#overview) +- [Local pipeline](#local-pipeline) +- [Contributing Workflow](#contributing-workflow) +- [Commitlint](#commitlint) +- [Branch Naming Format](#branch-naming-format) + +--- + +## Overview + To maintain a clean, structured commit history and optimize team collaboration, we adhere to the Angular Conventional Commits standard for both commit messages and Git branch naming. This ensures every change type is immediately recognizable and supports automation for changelog generation, semantic versioning, and streamlined release processes. In addition, we enforce these standards using CommitLint, ensuring that all commit messages conform to the defined rules before they are accepted into the repository. This project employs both GitHub Actions and a local pre-commit pipeline to validate commit messages, enforce branch naming conventions, and maintain repository integrity throughout the development workflow. +--- + ## Local pipeline The local pipeline is managed via husky @@ -16,6 +30,8 @@ The local pipeline is managed via husky - All tests pass - Test coverage is met +--- + ## Contributing Workflow To contribute to this repository, follow these steps: @@ -69,6 +85,8 @@ This workflow ensures that: For a step-by-step guide on forking and creating pull requests, see [GitHub’s documentation on forks](https://docs.github.com/en/get-started/quickstart/fork-a-repo) and [about pull requests](https://docs.github.com/en/pull-requests). +--- + ## Commitlint OSF uses [Commitlint](https://www.npmjs.com/package/commitlint) to **enforce a consistent commit message format**. @@ -103,35 +121,37 @@ Commit messages must be structured as: | Type | Description | | ------------ | ------------------------------------------------------------------------------------- | +| **chore** | Changes to the build process, CI/CD pipeline, or dependencies. | +| **docs** | Documentation-only changes (e.g., README, comments). | | **feat** | New feature added to the codebase. | | **fix** | Bug fix for an existing issue. | -| **docs** | Documentation-only changes (e.g., README, comments). | -| **style** | Changes that do not affect code meaning (formatting, whitespace, missing semicolons). | -| **refactor** | Code restructuring without changing external behavior. | +| **lang** | Any updates to the i18n files in src/asssets/i18n/en.json. | | **perf** | Code changes that improve performance. | -| **test** | Adding or updating tests. | -| **chore** | Changes to the build process, CI/CD pipeline, or dependencies. | +| **refactor** | Code restructuring without changing external behavior. | | **revert** | Reverts a previous commit. | +| **style** | Changes that do not affect code meaning (formatting, whitespace, missing semicolons). | +| **test** | Adding or updating tests. | --- ### **Examples** -✅ **Good Examples** +**Good Examples** ``` +chore(deps): update Angular to v19 +docs(readme): add setup instructions for Windows feat(auth): add OAuth2 login support fix(user-profile): resolve avatar upload failure on Safari -docs(readme): add setup instructions for Windows -style(header): reformat nav menu CSS -refactor(api): simplify data fetching logic +lang(eng-4898): added new strings for preprint page perf(search): reduce API response time by caching results -test(auth): add tests for password reset flow -chore(deps): update Angular to v19 +refactor(api): simplify data fetching logic revert: revert “feat(auth): add OAuth2 login support” +style(header): reformat nav menu CSS +test(auth): add tests for password reset flow ``` -❌ **Bad Examples** +**Bad Examples** ``` fixed bug in login @@ -156,10 +176,10 @@ update stuff Refs #456 ``` ---- - Commitlint will run automatically and reject non-compliant messages. +--- + ## Branch Naming Format ### The branch name should follow the format: @@ -175,10 +195,14 @@ short-description – a brief description of the change. ``` +--- + ## Available Types (type) See the [Allowed Commit Types](#allowed-commit-types) section for details. +--- + ## Branch Naming Examples ### Here are some examples of branch names: @@ -192,7 +216,7 @@ See the [Allowed Commit Types](#allowed-commit-types) section for details. ``` -### 🛠 Example of Creating a Branch: +### Example of Creating a Branch: To create a new branch, use the following command: @@ -201,15 +225,15 @@ git checkout -b feat/1234-add-user-authentication ``` -### 🏆 Best Practices +### Best Practices -- ✅ Use short and clear descriptions in branch names. -- ✅ Follow a consistent style across all branches for better project structure. -- ✅ Avoid redundant words, e.g., fix/1234-fix-bug (the word "fix" is redundant). -- ✅ Use kebab-case (- instead of \_ or CamelCase). -- ✅ If there is no issue ID, omit it, e.g., docs/update-contributing-guide. +- Use short and clear descriptions in branch names. +- Follow a consistent style across all branches for better project structure. +- Avoid redundant words, e.g., fix/1234-fix-bug (the word "fix" is redundant). +- Use kebab-case (- instead of \_ or CamelCase). +- If there is no issue ID, omit it, e.g., docs/update-contributing-guide. -### 🔗 Additional Resources +### Additional Resources **Conventional Commits**: https://www.conventionalcommits.org @@ -219,7 +243,7 @@ git checkout -b feat/1234-add-user-authentication ### This branch naming strategy ensures better traceability and improves commit history readability. -### 🔗 Additional Resources +### Additional Resources Conventional Commits: https://www.conventionalcommits.org @@ -227,4 +251,4 @@ Angular Commit Guidelines: https://github.com/angular/angular/blob/main/CONTRIBU Git Flow: https://nvie.com/posts/a-successful-git-branching-model/ -This branch naming and commit message strategy ensures better traceability and improves commit history readability. 🚀 +This branch naming and commit message strategy ensures better traceability and improves commit history readability. diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 000000000..45e7e2a39 --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,147 @@ +# OSF Angular – Internationalization (i18n) Strategy + +## Index + +- [Overview](#overview) +- [Integration: `@ngx-translate/core`](#integration-ngx-translatecore) +- [Usage Guidelines](#usage-guidelines) +- [Format: `en.json`](#format-enjson) +- [Source of Truth: `en.json` Only](#source-of-truth-enjson-only) +- [Language Branch Workflow](#language-branch-workflow) +- [Summary](#summary) + +--- + +## Overview + +The OSF Angular project uses [`@ngx-translate/core`](https://github.com/ngx-translate/core) to manage internationalization (i18n) across the entire application. This allows for consistent, dynamic translation of all user-visible text, making it easier to support multiple languages. + +All strings rendered to users—whether in HTML templates or dynamically via Angular components—must be sourced from the centralized i18n JSON files located in: + +``` +src/app/i18n/ +``` + +**IMPORTANT** +The OSF application must maintain 100% translation coverage, as it is a requirement of grant funding that supports a globally distributed user base. + +## Integration: `@ngx-translate/core` + +To support multilingual content, the following module is included globally: + +```ts +import { TranslatePipe } from '@ngx-translate/core'; +``` + +The translation service (`TranslateService`) is injected where necessary and used to load and access translations at runtime. + +## Usage Guidelines + +### 1. HTML Templates + +In templates, use the `translate` pipe: + +```html +

{{ 'home.title' | translate }}

+``` + +### 2. Dynamic Component Strings + +For component logic, use the `TranslateService`: + +```ts +private readonly translateService = inject(TranslateService); +public title!: string; + +ngOnInit(): void { + this.title = this.translate.instant('home.title'); +} +``` + +Avoid hardcoded strings in both the template and logic. All user-facing text must be represented by a translation key from the JSON files in `src/app/i18n`. + +## Format: `en.json` + +The primary English translation file is located at: + +``` +src/app/i18n/en.json +``` + +The structure should be **namespaced by feature or component** for maintainability: + +```json +{ + "home": { + "title": "Welcome to OSF", + "subTitle": "Your research. Your control." + }, + "login": { + "email": "Email Address", + "password": "Password" + } +} +``` + +Avoid deeply nested keys, and always use consistent camel-casing and key naming for reuse and clarity. + +**Note** + +The `common section` in the en.json file stores frequently used phrases to prevent one-off duplicate translations. + +## Source of Truth: `en.json` Only + +All translation key additions and updates must be made **only** in the `src/app/i18n/en.json` file. + +> Other language files (e.g., `fr.json`, `es.json`) must **not** be modified by non-OSF engineers. These translations will be handle internally by the OSF Angular team. + +This ensures consistency across translations and prevents desynchronization between supported languages. + +### Summary + +| Language File | Editable? | Notes | +| -------------------- | --------- | -------------------------------------------------- | +| `en.json` | Yes | Canonical source of all keys and values | +| `fr.json`, `es.json` | No | Never manually modified during feature development | + +Always validate that new translation keys appear in `en.json` only. + +## Language Branch Workflow + +All updates to the i18n translation files (e.g., `en.json`) must follow a strict workflow: + +### Branch Naming + +Create a branch prefixed with: + +``` +language/ +``` + +Example: + +``` +language/ENG-145-update-login-copy +``` + +### Commit Message Format + +The commit header must include the `lang()` label: + +``` +lang(ENG-145): update login page strings +``` + +This ensures that all translation updates are tracked, reviewed, and associated with the appropriate task or ticket. + +## Summary + +| Requirement | Description | +| ---------------------- | -------------------------------------------------------------- | +| Translation Library | [`@ngx-translate/core`](https://github.com/ngx-translate/core) | +| Source of All Strings | `src/app/i18n/*.json` | +| Required in HTML | Must use the `translate` pipe | +| Required in Components | Must use `TranslateService` | +| English File Format | Namespaced keys in `en.json` | +| Branch Naming | `language/` | +| Commit Convention | `lang(): message` | diff --git a/docs/ngxs.md b/docs/ngxs.md index 114db45fa..3f93391c0 100644 --- a/docs/ngxs.md +++ b/docs/ngxs.md @@ -1,4 +1,18 @@ -# NGXS State Management Overview +# NGXS State Management + +## Index + +- [Purpose](#purpose) +- [Core Concepts](#core-concepts) +- [Directory Structure](#directory-structure) +- [State Models](#state-models) +- [Tooling and Extensions](#tooling-and-extensions) +- [Testing](#testing) +- [Documentation](#documentation) + +--- + +## Overview The OSF Angular project uses [NGXS](https://www.ngxs.io/) as the state management library for Angular applications. NGXS provides a simple, powerful, and TypeScript-friendly framework for managing state across components and services. @@ -45,6 +59,33 @@ src/app/shared/services/ --- +## State Models + +The OSF Angular project follows a consistent NGXS state model structure to ensure clarity, predictability, and alignment across all features. The recommended shape for each domain-specific state is as follows: + +1. Domain state pattern: + +```ts +domain: { + data: [], // Array of typed model data (e.g., Project[], User[]) + isLoading: false, // Indicates if data retrieval (GET) is in progress + isSubmitting: false, // Indicates if data submission (POST/PUT/DELETE) is in progress + error: null, // Captures error messages from failed HTTP requests +} +``` + +2. `data` holds the strongly typed collection of entities defined by the feature's interface or model class. + +3. `isLoading` is a signal used to inform the component and template layer that a read or fetch operation is currently pending. + +4. `isSubmitting` signals that a write operation (form submission, update, delete, etc.) is currently in progress. + +5. `error` stores error state information (commonly strings or structured error objects) that result from failed service interactions. This can be displayed in UI or logged for debugging. + +Each domain state should be minimal, normalized, and scoped to its specific feature, mirroring the structure and shape of the corresponding OSF backend API response. + +--- + ## Tooling and Extensions - [Redux DevTools](https://github.com/zalmoxisus/redux-devtools-extension) is supported. Enable it in development via `NgxsReduxDevtoolsPluginModule`. @@ -55,7 +96,8 @@ src/app/shared/services/ ## Testing -- Mock `Store` using `jest.fn()` or test-specific modules for unit testing components and services. +- [Testing Strategy](docs/testing.md) +- [NGXS State Testing Strategy](docs/testing.md#ngxs-state-testing-strategy) --- diff --git a/docs/testing.md b/docs/testing.md index 17448bdc9..3de20e198 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,13 +1,9 @@ # OSF Angular Testing Strategy -## Overview - -The OSF Angular project uses a modular and mock-driven testing strategy. A shared `testing/` folder provides reusable mocks, mock data, and testing module configuration to support consistent and maintainable unit tests across the codebase. - ---- - ## Index +- [Overview](#overview) + - [Pro-tips](#pro-tips) - [Best Practices](#best-practices) - [Summary Table](#summary-table) - [Test Coverage Enforcement (100%)](#test-coverage-enforcement-100) @@ -18,7 +14,36 @@ The OSF Angular project uses a modular and mock-driven testing strategy. A share - [Testing Angular Directives](#testing-angular-directives) - [Testing Angular NGXS](#testing-ngxs) --- +--- + +## Overview + +The OSF Angular project uses a modular and mock-driven testing strategy. A shared `testing/` folder provides reusable mocks, mock data, and testing module configuration to support consistent and maintainable unit tests across the codebase. + +--- + +### Pro-tips + +**What to test** + +The OSF Angular testing strategy enforces 100% coverage while also serving as a guardrail for future engineers. Each test should highlight the most critical aspect of your code — what you’d want the next developer to understand before making changes. If a test fails during a refactor, it should clearly signal that a core feature was impacted, prompting them to investigate why and preserve the intended behavior. + +--- + +**Test Data** + +The OSF Angular Test Data module provides a centralized and consistent source of data across all unit tests. It is intended solely for use within unit tests. By standardizing test data, any changes to underlying data models will produce cascading failures, which help expose the full scope of a refactor. This is preferable to isolated or hardcoded test values, which can lead to false positives and missed regressions. + +The strategy for structuring test data follows two principles: + +1. Include enough data to cover all relevant permutations required by the test suite. +2. Ensure the data reflects all possible states (stati) of the model. + +**Test Scope** + +The OSF Angular project defines a `@testing` scope that can be used for importing all testing-related modules. + +--- ## Best Practices @@ -34,8 +59,8 @@ The OSF Angular project uses a modular and mock-driven testing strategy. A share | Location | Purpose | | ----------------------- | -------------------------------------- | | `osf.testing.module.ts` | Unified test module for shared imports | -| `mocks/*.mock.ts` | Mock services and tokens | -| `data/*.data.ts` | Static mock data for test cases | +| `src/mocks/*.mock.ts` | Mock services and tokens | +| `src/data/*.data.ts` | Static mock data for test cases | --- @@ -91,9 +116,11 @@ This guarantees **test integrity in CI** and **prevents regressions**. - **Push blocked** without passing 100% tests. - GitHub CI double-checks every PR. +--- + ## Key Structure -### `testing/osf.testing.module.ts` +### `src/testing/osf.testing.module.ts` This module centralizes commonly used providers, declarations, and test utilities. It's intended to be imported into any `*.spec.ts` test file to avoid repetitive boilerplate. @@ -101,7 +128,7 @@ Example usage: ```ts import { TestBed } from '@angular/core/testing'; -import { OsfTestingModule } from 'testing/osf.testing.module'; +import { OsfTestingModule } from '@testing/osf.testing.module'; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -141,9 +168,9 @@ beforeEach(async () => { - `StoreMock` – mocks NgRx Store for selector and dispatch testing. - `ToastServiceMock` – injects a mock version of the UI toast service. -### `testing/mocks/` +### Testing Mocks -Provides common service and token mocks to isolate unit tests from real implementations. +The `src/testing/mocks/` directory provides common service and token mocks to isolate unit tests from real implementations. **examples** @@ -154,11 +181,16 @@ Provides common service and token mocks to isolate unit tests from real implemen --- -### `testing/data/` +### Test Data -Includes fake/mock data used by tests to simulate external API responses or internal state. +The `src/testing/data/` directory includes fake/mock data used by tests to simulate external API responses or internal state. -Only use data from the `testing/data` data mocks to ensure that all data is the centralized. +The OSF Angular Test Data module provides a centralized and consistent source of data across all unit tests. It is intended solely for use within unit tests. By standardizing test data, any changes to underlying data models will produce cascading failures, which help expose the full scope of a refactor. This is preferable to isolated or hardcoded test values, which can lead to false positives and missed regressions. + +The strategy for structuring test data follows two principles: + +1. Include enough data to cover all relevant permutations required by the test suite. +2. Ensure the data reflects all possible states (stati) of the model. **examples** @@ -169,11 +201,13 @@ Only use data from the `testing/data` data mocks to ensure that all data is the --- ---- - ## Testing Angular Services (with HTTP) -All OSF Angular services that make HTTP requests must be tested using `HttpClientTestingModule` and `HttpTestingController`. +All OSF Angular services that make HTTP requests must be tested using `HttpClientTestingModule` and `HttpTestingController`. This testing style verifies both the API call itself and the logic that maps the response into application data. + +When using HttpTestingController to flush HTTP requests in tests, only use data from the @testing/data mocks to ensure consistency and full test coverage. + +Any error handling will also need to be tested. ### Setup @@ -240,8 +274,86 @@ it('should call correct endpoint and return expected data', inject( --- -## Testing NGXS +## NGXS State Testing Strategy -- coming soon +The OSF Angular strategy for NGXS state testing is to create **small integration test scenarios**. This is a deliberate departure from traditional **black box isolated** testing. The rationale is: + +1. **NGXS actions** tested in isolation are difficult to mock and result in garbage-in/garbage-out tests. +2. **NGXS selectors** tested in isolation are easy to mock but also lead to garbage-in/garbage-out outcomes. +3. **NGXS states** tested in isolation are easy to invoke but provide no meaningful validation. +4. **Mocking service calls** during state testing introduces false positives, since the mocked service responses may not reflect actual backend behavior. + +This approach favors realism and accuracy over artificial test isolation. + +### Test Outline Strategy + +1. **Dispatch the primary action** – Kick off the state logic under test. +2. **Dispatch any dependent actions** – Include any secondary actions that rely on the primary action's outcome. +3. **Verify the loading selector is `true`** – Ensure the loading state is activated during the async flow. +4. **Verify the service call using `HttpTestingController` and `@testing/data` mocks** – Confirm that the correct HTTP request is made and flushed with known mock data. +5. **Verify the loading selector is `false`** – Ensure the loading state deactivates after the response is handled. +6. **Verify the primary data selector** – Check that the core selector related to the dispatched action returns the expected state. +7. **Verify any additional selectors** – Assert the output of other derived selectors relevant to the action. +8. **Validate the test with `httpMock.verify()`** – Confirm that all HTTP requests were flushed and none remain unhandled: + +```ts +expect(httpMock.verify).toBeTruthy(); +``` + +### Example + +This is an example of an NGXS action test that involves both a **primary action** and a **dependent action**. The dependency must be dispatched first to ensure the test environment mimics the actual runtime behavior. This pattern helps validate not only the action effects but also the full selector state after updates. All HTTP requests are flushed using the centralized `@testing/data` mocks. + +```ts +it('should test action, state and selectors', inject([HttpTestingController], (httpMock: HttpTestingController) => { + let result: any[] = []; + // Dependency Action + store.dispatch(new GetAuthorizedStorageAddons('reference-id')).subscribe(); + + // Primary Action + store.dispatch(new GetAuthorizedStorageOauthToken('account-id')).subscribe(() => { + result = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddons); + }); + + // Loading selector is true + const loading = store.selectSignal(AddonsSelectors.getAuthorizedStorageAddonsLoading); + expect(loading()).toBeTruthy(); + + // Http request for service for dependency action + let request = httpMock.expectOne('api/path/dependency/action'); + expect(request.request.method).toBe('GET'); + // @testing/data response mock + request.flush(getAddonsAuthorizedStorageData()); + + // Http request for service for primary action + let request = httpMock.expectOne('api/path/primary/action'); + expect(request.request.method).toBe('PATCH'); + // @testing/data response mock with updates + const addonWithToken = getAddonsAuthorizedStorageData(1); + addonWithToken.data.attributes.oauth_token = 'ya2.34234324534'; + request.flush(addonWithToken); + + // Full testing of the dependency selector + expect(result[1]).toEqual( + Object({ + accountOwnerId: '0b441148-83e5-4f7f-b302-b07b528b160b', + }) + ); + + // Full testing of the primary selector + let oauthToken = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(result[0].id)); + expect(oauthToken).toBe('ya29.A0AS3H6NzDCKgrUx'); + + // Verify only the requested `account-id` was updated + oauthToken = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(result[1].id)); + expect(oauthToken).toBe(result[1].oauthToken); + + // Loading selector is false + expect(loading()).toBeFalsy(); + + // httpMock.verify to ensure no other api calls are called. + expect(httpMock.verify).toBeTruthy(); +})); +``` --- diff --git a/eslint.config.js b/eslint.config.js index 1b4ca309c..17c8f35d2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -87,9 +87,6 @@ module.exports = tseslint.config( }, { files: ['**/*.html'], - plugins: { - '@angular-eslint/template': angularEslintTemplate, - }, languageOptions: { parser: angularTemplateParser, }, @@ -112,7 +109,7 @@ module.exports = tseslint.config( }, }, { - files: ['**/*.spec.ts'], + files: ['**/*.spec.ts', 'src/testing/**/*.ts'], rules: { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-empty-function': 'off', diff --git a/jest.config.js b/jest.config.js index 6a119eb3c..791170ae3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,7 +8,7 @@ module.exports = { '^@osf/(.*)$': '/src/app/$1', '^@core/(.*)$': '/src/app/core/$1', '^@shared/(.*)$': '/src/app/shared/$1', - '^@styles/(.*)$': '/src/styles/$1', + '^@styles/(.*)$': '/assets/styles/$1', '^@testing/(.*)$': '/src/testing/$1', '^src/environments/environment$': '/src/environments/environment.ts', }, @@ -35,8 +35,6 @@ module.exports = { '!src/app/**/*.route.{ts,js}', '!src/app/**/*.enum.{ts,js}', '!src/app/**/*.type.{ts,js}', - '!src/app/**/*.enum.{ts,js}', - '!src/app/**/*.type.{ts,js}', '!src/app/**/*.spec.{ts,js}', '!src/app/**/*.module.ts', '!src/app/**/index.ts', @@ -45,10 +43,10 @@ module.exports = { extensionsToTreatAsEsm: ['.ts'], coverageThreshold: { global: { - branches: 14.27, - functions: 15.55, - lines: 42.6, - statements: 43.2, + branches: 15, + functions: 18.22, + lines: 43.77, + statements: 44.42, }, }, watchPathIgnorePatterns: [ @@ -59,47 +57,23 @@ module.exports = { '/src/environments/', '/src/@types/', ], - watchPathIgnorePatterns: [ - '/node_modules/', - '/dist/', - '/coverage/', - '/src/assets/', - '/src/environments/', - '/src/@types/', - ], testPathIgnorePatterns: [ + '/src/environments', '/src/app/app.config.ts', '/src/app/app.routes.ts', - '/src/app/features/registry/', - '/src/app/features/project/addons/components/configure-configure-addon/', - '/src/app/features/project/addons/components/connect-configured-addon/', - '/src/app/features/project/addons/components/disconnect-addon-modal/', - '/src/app/features/project/addons/components/confirm-account-connection-modal/', - '/src/app/features/files/', - '/src/app/features/my-projects/', - '/src/app/features/preprints/', - '/src/app/features/project/contributors/', + '/src/app/features/files/components', + '/src/app/features/files/pages/file-detail', + '/src/app/features/project/addons/', '/src/app/features/project/overview/', '/src/app/features/project/registrations', - '/src/app/features/project/settings', '/src/app/features/project/wiki', - '/src/app/features/project/project.component.ts', - '/src/app/features/registries/', + '/src/app/features/registry/', '/src/app/features/settings/addons/', - '/src/app/features/settings/settings-container.component.ts', - '/src/app/features/settings/tokens/components/', - '/src/app/features/settings/tokens/mappers/', '/src/app/features/settings/tokens/store/', - '/src/app/features/settings/tokens/pages/tokens-list/', '/src/app/shared/components/file-menu/', - '/src/app/shared/components/files-tree/', '/src/app/shared/components/line-chart/', - '/src/app/shared/components/make-decision-dialog/', '/src/app/shared/components/pie-chart/', - '/src/app/shared/components/resource-citations/', '/src/app/shared/components/reusable-filter/', - '/src/app/shared/components/subjects/', '/src/app/shared/components/wiki/edit-section/', - '/src/app/shared/components/wiki/wiki-list/', ], }; diff --git a/package-lock.docker.json b/package-lock.docker.json index d98c25804..3e765740e 100644 --- a/package-lock.docker.json +++ b/package-lock.docker.json @@ -3107,15 +3107,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", - "cpu": [ - "ppc64" - ], + "cpu": ["ppc64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "aix" - ], + "os": ["aix"], "engines": { "node": ">=18" } @@ -3124,15 +3120,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", - "cpu": [ - "arm" - ], + "cpu": ["arm"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ], + "os": ["android"], "engines": { "node": ">=18" } @@ -3141,15 +3133,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ], + "os": ["android"], "engines": { "node": ">=18" } @@ -3158,15 +3146,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ], + "os": ["android"], "engines": { "node": ">=18" } @@ -3175,15 +3159,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ], + "os": ["darwin"], "engines": { "node": ">=18" } @@ -3192,15 +3172,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ], + "os": ["darwin"], "engines": { "node": ">=18" } @@ -3209,15 +3185,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "freebsd" - ], + "os": ["freebsd"], "engines": { "node": ">=18" } @@ -3226,15 +3198,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "freebsd" - ], + "os": ["freebsd"], "engines": { "node": ">=18" } @@ -3243,15 +3211,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", - "cpu": [ - "arm" - ], + "cpu": ["arm"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">=18" } @@ -3260,15 +3224,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">=18" } @@ -3277,15 +3237,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", - "cpu": [ - "ia32" - ], + "cpu": ["ia32"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">=18" } @@ -3294,15 +3250,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", - "cpu": [ - "loong64" - ], + "cpu": ["loong64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">=18" } @@ -3311,15 +3263,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", - "cpu": [ - "mips64el" - ], + "cpu": ["mips64el"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">=18" } @@ -3328,15 +3276,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", - "cpu": [ - "ppc64" - ], + "cpu": ["ppc64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">=18" } @@ -3345,15 +3289,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", - "cpu": [ - "riscv64" - ], + "cpu": ["riscv64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">=18" } @@ -3362,15 +3302,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", - "cpu": [ - "s390x" - ], + "cpu": ["s390x"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">=18" } @@ -3379,15 +3315,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">=18" } @@ -3396,15 +3328,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "netbsd" - ], + "os": ["netbsd"], "engines": { "node": ">=18" } @@ -3413,15 +3341,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "netbsd" - ], + "os": ["netbsd"], "engines": { "node": ">=18" } @@ -3430,15 +3354,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "openbsd" - ], + "os": ["openbsd"], "engines": { "node": ">=18" } @@ -3447,15 +3367,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "openbsd" - ], + "os": ["openbsd"], "engines": { "node": ">=18" } @@ -3464,15 +3380,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "sunos" - ], + "os": ["sunos"], "engines": { "node": ">=18" } @@ -3481,15 +3393,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], + "os": ["win32"], "engines": { "node": ">=18" } @@ -3498,15 +3406,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", - "cpu": [ - "ia32" - ], + "cpu": ["ia32"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], + "os": ["win32"], "engines": { "node": ">=18" } @@ -3515,15 +3419,11 @@ "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], + "os": ["win32"], "engines": { "node": ">=18" } @@ -5164,169 +5064,121 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.2.6.tgz", "integrity": "sha512-yF/ih9EJJZc72psFQbwnn8mExIWfTnzWJg+N02hnpXtDPETYLmQswIMBn7+V88lfCaFrMozJsUvcEQIkEPU0Gg==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ] + "os": ["darwin"] }, "node_modules/@lmdb/lmdb-darwin-x64": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.2.6.tgz", "integrity": "sha512-5BbCumsFLbCi586Bb1lTWQFkekdQUw8/t8cy++Uq251cl3hbDIGEwD9HAwh8H6IS2F6QA9KdKmO136LmipRNkg==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ] + "os": ["darwin"] }, "node_modules/@lmdb/lmdb-linux-arm": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.2.6.tgz", "integrity": "sha512-+6XgLpMb7HBoWxXj+bLbiiB4s0mRRcDPElnRS3LpWRzdYSe+gFk5MT/4RrVNqd2MESUDmb53NUXw1+BP69bjiQ==", - "cpu": [ - "arm" - ], + "cpu": ["arm"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@lmdb/lmdb-linux-arm64": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.2.6.tgz", "integrity": "sha512-l5VmJamJ3nyMmeD1ANBQCQqy7do1ESaJQfKPSm2IG9/ADZryptTyCj8N6QaYgIWewqNUrcbdMkJajRQAt5Qjfg==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@lmdb/lmdb-linux-x64": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.2.6.tgz", "integrity": "sha512-nDYT8qN9si5+onHYYaI4DiauDMx24OAiuZAUsEqrDy+ja/3EbpXPX/VAkMV8AEaQhy3xc4dRC+KcYIvOFefJ4Q==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@lmdb/lmdb-win32-x64": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.2.6.tgz", "integrity": "sha512-XlqVtILonQnG+9fH2N3Aytria7P/1fwDgDhl29rde96uH2sLB8CHORIf2PfuLVzFQJ7Uqp8py9AYwr3ZUCFfWg==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "os": ["win32"] }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ] + "os": ["darwin"] }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ] + "os": ["darwin"] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", - "cpu": [ - "arm" - ], + "cpu": ["arm"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "os": ["win32"] }, "node_modules/@napi-rs/nice": { "version": "1.0.4", @@ -5365,15 +5217,11 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.4.tgz", "integrity": "sha512-OZFMYUkih4g6HCKTjqJHhMUlgvPiDuSLZPbPBWHLjKmFTv74COzRlq/gwHtmEVaR39mJQ6ZyttDl2HNMUbLVoA==", - "cpu": [ - "arm" - ], + "cpu": ["arm"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ], + "os": ["android"], "engines": { "node": ">= 10" } @@ -5382,15 +5230,11 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.0.4.tgz", "integrity": "sha512-k8u7cjeA64vQWXZcRrPbmwjH8K09CBnNaPnI9L1D5N6iMPL3XYQzLcN6WwQonfcqCDv5OCY3IqX89goPTV4KMw==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ], + "os": ["android"], "engines": { "node": ">= 10" } @@ -5399,15 +5243,11 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.0.4.tgz", "integrity": "sha512-GsLdQvUcuVzoyzmtjsThnpaVEizAqH5yPHgnsBmq3JdVoVZHELFo7PuJEdfOH1DOHi2mPwB9sCJEstAYf3XCJA==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ], + "os": ["darwin"], "engines": { "node": ">= 10" } @@ -5416,15 +5256,11 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.0.4.tgz", "integrity": "sha512-1y3gyT3e5zUY5SxRl3QDtJiWVsbkmhtUHIYwdWWIQ3Ia+byd/IHIEpqAxOGW1nhhnIKfTCuxBadHQb+yZASVoA==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ], + "os": ["darwin"], "engines": { "node": ">= 10" } @@ -5433,15 +5269,11 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.0.4.tgz", "integrity": "sha512-06oXzESPRdXUuzS8n2hGwhM2HACnDfl3bfUaSqLGImM8TA33pzDXgGL0e3If8CcFWT98aHows5Lk7xnqYNGFeA==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "freebsd" - ], + "os": ["freebsd"], "engines": { "node": ">= 10" } @@ -5450,15 +5282,11 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.0.4.tgz", "integrity": "sha512-CgklZ6g8WL4+EgVVkxkEvvsi2DSLf9QIloxWO0fvQyQBp6VguUSX3eHLeRpqwW8cRm2Hv/Q1+PduNk7VK37VZw==", - "cpu": [ - "arm" - ], + "cpu": ["arm"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 10" } @@ -5467,15 +5295,11 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.0.4.tgz", "integrity": "sha512-wdAJ7lgjhAlsANUCv0zi6msRwq+D4KDgU+GCCHssSxWmAERZa2KZXO0H2xdmoJ/0i03i6YfK/sWaZgUAyuW2oQ==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 10" } @@ -5484,15 +5308,11 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.0.4.tgz", "integrity": "sha512-4b1KYG+sriufhFrpUS9uNOEYYJqSfcbnwGx6uGX7JjrH8tELG90cOpCawz5THNIwlS3DhLgnCOcn0+4p6z26QA==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 10" } @@ -5501,15 +5321,11 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.0.4.tgz", "integrity": "sha512-iaf3vMRgr23oe1PUaKpxaH3DS0IMN0+N9iEiWVwYPm/U15vZFYdqVegGfN2PzrZLUl5lc8ZxbmEKDfuqslhAMA==", - "cpu": [ - "ppc64" - ], + "cpu": ["ppc64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 10" } @@ -5518,15 +5334,11 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.0.4.tgz", "integrity": "sha512-UXoREY6Yw6rHrGuTwQgBxpfjK34t6mTjibE9/cXbefL9AuUCJ9gEgwNKZiONuR5QGswChqo9cnthjdKkYyAdDg==", - "cpu": [ - "riscv64" - ], + "cpu": ["riscv64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 10" } @@ -5535,15 +5347,11 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.0.4.tgz", "integrity": "sha512-eFbgYCRPmsqbYPAlLYU5hYTNbogmIDUvknilehHsFhCH1+0/kN87lP+XaLT0Yeq4V/rpwChSd9vlz4muzFArtw==", - "cpu": [ - "s390x" - ], + "cpu": ["s390x"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 10" } @@ -5552,15 +5360,11 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.0.4.tgz", "integrity": "sha512-4T3E6uTCwWT6IPnwuPcWVz3oHxvEp/qbrCxZhsgzwTUBEwu78EGNXGdHfKJQt3soth89MLqZJw+Zzvnhrsg1mQ==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 10" } @@ -5569,15 +5373,11 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.0.4.tgz", "integrity": "sha512-NtbBkAeyBPLvCBkWtwkKXkNSn677eaT0cX3tygq+2qVv71TmHgX4gkX6o9BXjlPzdgPGwrUudavCYPT9tzkEqQ==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 10" } @@ -5586,15 +5386,11 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.0.4.tgz", "integrity": "sha512-vubOe3i+YtSJGEk/++73y+TIxbuVHi+W8ZzrRm2eETCjCRwNlgbfToQZ85dSA+4iBB/NJRGNp+O4hfdbbttZWA==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], + "os": ["win32"], "engines": { "node": ">= 10" } @@ -5603,15 +5399,11 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.0.4.tgz", "integrity": "sha512-BMOVrUDZeg1RNRKVlh4eyLv5djAAVLiSddfpuuQ47EFjBcklg0NUeKMFKNrKQR4UnSn4HAiACLD7YK7koskwmg==", - "cpu": [ - "ia32" - ], + "cpu": ["ia32"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], + "os": ["win32"], "engines": { "node": ">= 10" } @@ -5620,15 +5412,11 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.0.4.tgz", "integrity": "sha512-kCNk6HcRZquhw/whwh4rHsdPyOSCQCgnVDVik+Y9cuSVTDy3frpiCJTScJqPPS872h4JgZKkr/+CwcwttNEo9Q==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], + "os": ["win32"], "engines": { "node": ">= 10" } @@ -6129,15 +5917,11 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ], + "os": ["android"], "engines": { "node": ">= 10.0.0" }, @@ -6150,15 +5934,11 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ], + "os": ["darwin"], "engines": { "node": ">= 10.0.0" }, @@ -6171,15 +5951,11 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ], + "os": ["darwin"], "engines": { "node": ">= 10.0.0" }, @@ -6192,15 +5968,11 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "freebsd" - ], + "os": ["freebsd"], "engines": { "node": ">= 10.0.0" }, @@ -6213,15 +5985,11 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", - "cpu": [ - "arm" - ], + "cpu": ["arm"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 10.0.0" }, @@ -6234,15 +6002,11 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", - "cpu": [ - "arm" - ], + "cpu": ["arm"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 10.0.0" }, @@ -6255,15 +6019,11 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 10.0.0" }, @@ -6276,15 +6036,11 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 10.0.0" }, @@ -6297,15 +6053,11 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 10.0.0" }, @@ -6318,15 +6070,11 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "engines": { "node": ">= 10.0.0" }, @@ -6339,15 +6087,11 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], + "os": ["win32"], "engines": { "node": ">= 10.0.0" }, @@ -6360,15 +6104,11 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", - "cpu": [ - "ia32" - ], + "cpu": ["ia32"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], + "os": ["win32"], "engines": { "node": ">= 10.0.0" }, @@ -6381,15 +6121,11 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], + "os": ["win32"], "engines": { "node": ">= 10.0.0" }, @@ -6489,297 +6225,213 @@ "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz", "integrity": "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw==", - "cpu": [ - "arm" - ], + "cpu": ["arm"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ] + "os": ["android"] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz", "integrity": "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ] + "os": ["android"] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz", "integrity": "sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ] + "os": ["darwin"] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz", "integrity": "sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ] + "os": ["darwin"] }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz", "integrity": "sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "freebsd" - ] + "os": ["freebsd"] }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz", "integrity": "sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "freebsd" - ] + "os": ["freebsd"] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz", "integrity": "sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g==", - "cpu": [ - "arm" - ], + "cpu": ["arm"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz", "integrity": "sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA==", - "cpu": [ - "arm" - ], + "cpu": ["arm"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz", "integrity": "sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz", "integrity": "sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz", "integrity": "sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ==", - "cpu": [ - "loong64" - ], + "cpu": ["loong64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz", "integrity": "sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw==", - "cpu": [ - "ppc64" - ], + "cpu": ["ppc64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz", "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==", - "cpu": [ - "ppc64" - ], + "cpu": ["ppc64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz", "integrity": "sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw==", - "cpu": [ - "riscv64" - ], + "cpu": ["riscv64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz", "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==", - "cpu": [ - "riscv64" - ], + "cpu": ["riscv64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz", "integrity": "sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA==", - "cpu": [ - "s390x" - ], + "cpu": ["s390x"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz", "integrity": "sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.8.tgz", "integrity": "sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "os": ["linux"] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz", "integrity": "sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "os": ["win32"] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz", "integrity": "sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w==", - "cpu": [ - "ia32" - ], + "cpu": ["ia32"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "os": ["win32"] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz", "integrity": "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "os": ["win32"] }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -8404,9 +8056,7 @@ "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", "dev": true, - "engines": [ - "node >= 0.8.0" - ], + "engines": ["node >= 0.8.0"], "license": "Apache-2.0", "bin": { "ansi-html": "bin/ansi-html" @@ -12589,9 +12239,7 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ], + "os": ["darwin"], "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -15971,9 +15619,7 @@ "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", "dev": true, - "engines": [ - "node >= 0.2.0" - ], + "engines": ["node >= 0.2.0"], "license": "MIT" }, "node_modules/JSONStream": { @@ -23193,270 +22839,198 @@ "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz", "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==", - "cpu": [ - "arm" - ], + "cpu": ["arm"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ], + "os": ["android"], "peer": true }, "node_modules/vite/node_modules/@rollup/rollup-android-arm64": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz", "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ], + "os": ["android"], "peer": true }, "node_modules/vite/node_modules/@rollup/rollup-darwin-arm64": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz", "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ], + "os": ["darwin"], "peer": true }, "node_modules/vite/node_modules/@rollup/rollup-darwin-x64": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz", "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ], + "os": ["darwin"], "peer": true }, "node_modules/vite/node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz", "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "freebsd" - ], + "os": ["freebsd"], "peer": true }, "node_modules/vite/node_modules/@rollup/rollup-freebsd-x64": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz", "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "freebsd" - ], + "os": ["freebsd"], "peer": true }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz", "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==", - "cpu": [ - "arm" - ], + "cpu": ["arm"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "peer": true }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz", "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==", - "cpu": [ - "arm" - ], + "cpu": ["arm"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "peer": true }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz", "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "peer": true }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz", "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "peer": true }, "node_modules/vite/node_modules/@rollup/rollup-linux-loongarch64-gnu": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz", "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==", - "cpu": [ - "loong64" - ], + "cpu": ["loong64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "peer": true }, "node_modules/vite/node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz", "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==", - "cpu": [ - "riscv64" - ], + "cpu": ["riscv64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "peer": true }, "node_modules/vite/node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz", "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==", - "cpu": [ - "s390x" - ], + "cpu": ["s390x"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "peer": true }, "node_modules/vite/node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz", "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "peer": true }, "node_modules/vite/node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz", "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "os": ["linux"], "peer": true }, "node_modules/vite/node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz", "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==", - "cpu": [ - "arm64" - ], + "cpu": ["arm64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], + "os": ["win32"], "peer": true }, "node_modules/vite/node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz", "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==", - "cpu": [ - "ia32" - ], + "cpu": ["ia32"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], + "os": ["win32"], "peer": true }, "node_modules/vite/node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz", "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==", - "cpu": [ - "x64" - ], + "cpu": ["x64"], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], + "os": ["win32"], "peer": true }, "node_modules/vite/node_modules/postcss": { diff --git a/package-lock.json b/package-lock.json index 912834b45..d4639d509 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,6 @@ "@angular/platform-browser": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0", "@angular/router": "^19.2.0", - "@angular/service-worker": "^19.2.0", "@fortawesome/fontawesome-free": "^6.7.2", "@ngx-translate/core": "^16.0.4", "@ngx-translate/http-loader": "^16.0.1", @@ -25,9 +24,12 @@ "@ngxs/logger-plugin": "^19.0.0", "@ngxs/store": "^19.0.0", "@primeng/themes": "^19.0.9", + "@sentry/angular": "^10.10.0", + "@traptitech/markdown-it-katex": "^3.6.0", "ace-builds": "^1.42.0", + "angular-google-tag-manager": "^1.11.0", "cedar-artifact-viewer": "^0.9.5", - "cedar-embeddable-editor": "^1.5.0", + "cedar-embeddable-editor": "1.2.2", "chart.js": "^4.4.9", "diff": "^8.0.2", "markdown-it": "^14.1.0", @@ -99,13 +101,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1902.15", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.15.tgz", - "integrity": "sha512-RbqhStc6ZoRv57ZqLB36VOkBkAdU3nNezCvIs0AJV5V4+vLPMrb0hpIB0sF+9yMlMjWsolnRsj0/Fil+zQG3bw==", + "version": "0.1902.17", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.17.tgz", + "integrity": "sha512-/LV8lXi6/SqevyI9ZAk2uAqlnN/pUwNwD6SyjotCqU55FBhBW8vM3/GucFXawJqTOzNmBXuMx1YVvQN5H0v5LQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.15", + "@angular-devkit/core": "19.2.17", "rxjs": "7.8.1" }, "engines": { @@ -125,17 +127,17 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-19.2.15.tgz", - "integrity": "sha512-mqudAcyrSp/E7ZQdQoHfys0/nvQuwyJDaAzj3qL3HUStuUzb5ULNOj2f6sFBo+xYo+/WT8IzmzDN9DCqDgvFaA==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-19.2.17.tgz", + "integrity": "sha512-lbvzNoSjHlhP6bcHtFMlEQHG/Zxc1tTdwoelm4+AWPuQH4rGfoty4SXH4rr50SXVBUg9Zb4xZuChOYZmYKpGLQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1902.15", - "@angular-devkit/build-webpack": "0.1902.15", - "@angular-devkit/core": "19.2.15", - "@angular/build": "19.2.15", + "@angular-devkit/architect": "0.1902.17", + "@angular-devkit/build-webpack": "0.1902.17", + "@angular-devkit/core": "19.2.17", + "@angular/build": "19.2.17", "@babel/core": "7.26.10", "@babel/generator": "7.26.10", "@babel/helper-annotate-as-pure": "7.25.9", @@ -146,7 +148,7 @@ "@babel/preset-env": "7.26.9", "@babel/runtime": "7.26.10", "@discoveryjs/json-ext": "0.6.3", - "@ngtools/webpack": "19.2.15", + "@ngtools/webpack": "19.2.17", "@vitejs/plugin-basic-ssl": "1.2.0", "ansi-colors": "4.1.3", "autoprefixer": "10.4.20", @@ -200,7 +202,7 @@ "@angular/localize": "^19.0.0 || ^19.2.0-next.0", "@angular/platform-server": "^19.0.0 || ^19.2.0-next.0", "@angular/service-worker": "^19.0.0 || ^19.2.0-next.0", - "@angular/ssr": "^19.2.15", + "@angular/ssr": "^19.2.17", "@web/test-runner": "^0.20.0", "browser-sync": "^3.0.2", "jest": "^29.5.0", @@ -261,13 +263,13 @@ } }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.1902.15", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1902.15.tgz", - "integrity": "sha512-pIfZeizWsViXx8bsMoBLZw7Tl7uFf7bM7hAfmNwk0bb0QGzx5k1BiW6IKWyaG+Dg6U4UCrlNpIiut2b78HwQZw==", + "version": "0.1902.17", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1902.17.tgz", + "integrity": "sha512-8NVJL7ujeTYKR1LgErkc5UN3EEoGYasqtu5AACXraFf9NLOw2p9N0+QY4cfjIwip1nyBp0RRzlBS4omGEymJCw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1902.15", + "@angular-devkit/architect": "0.1902.17", "rxjs": "7.8.1" }, "engines": { @@ -291,9 +293,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.15.tgz", - "integrity": "sha512-pU2RZYX6vhd7uLSdLwPnuBcr0mXJSjp3EgOXKsrlQFQZevc+Qs+2JdXgIElnOT/aDqtRtriDmLlSbtdE8n3ZbA==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.17.tgz", + "integrity": "sha512-Ah008x2RJkd0F+NLKqIpA34/vUGwjlprRCkvddjDopAWRzYn6xCkz1Tqwuhn0nR1Dy47wTLKYD999TYl5ONOAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -329,13 +331,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.15.tgz", - "integrity": "sha512-kNOJ+3vekJJCQKWihNmxBkarJzNW09kP5a9E1SRNiQVNOUEeSwcRR0qYotM65nx821gNzjjhJXnAZ8OazWldrg==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.17.tgz", + "integrity": "sha512-ADfbaBsrG8mBF6Mfs+crKA/2ykB8AJI50Cv9tKmZfwcUcyAdmTr+vVvhsBCfvUAEokigSsgqgpYxfkJVxhJYeg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.15", + "@angular-devkit/core": "19.2.17", "jsonc-parser": "3.3.1", "magic-string": "0.30.17", "ora": "5.4.1", @@ -373,21 +375,21 @@ } }, "node_modules/@angular-eslint/bundled-angular-compiler": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-19.1.0.tgz", - "integrity": "sha512-HUJyukRvnh8Z9lIdxdblBRuBaPYEVv4iAYZMw3d+dn4rrM27Nt5oh3/zkwYrrPkt36tZdeXdDWrOuz9jgjVN5w==", + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-19.8.1.tgz", + "integrity": "sha512-WXi1YbSs7SIQo48u+fCcc5Nt14/T4QzYQPLZUnjtsUXPgQG7ZoahhcGf7PPQ+n0V3pSopHOlSHwqK+tSsYK87A==", "dev": true, "license": "MIT" }, "node_modules/@angular-eslint/eslint-plugin": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-19.1.0.tgz", - "integrity": "sha512-TDO0+Ry+oNkxnaLHogKp1k2aey6IkJef5d7hathE4UFT6owjRizltWaRoX6bGw7Qu1yagVLL8L2Se8SddxSPAQ==", + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-19.8.1.tgz", + "integrity": "sha512-wZEBMPwD2TRhifG751hcj137EMIEaFmsxRB2EI+vfINCgPnFGSGGOHXqi8aInn9fXqHs7VbXkAzXYdBsvy1m4Q==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "19.1.0", - "@angular-eslint/utils": "19.1.0" + "@angular-eslint/bundled-angular-compiler": "19.8.1", + "@angular-eslint/utils": "19.8.1" }, "peerDependencies": { "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", @@ -396,18 +398,19 @@ } }, "node_modules/@angular-eslint/eslint-plugin-template": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-19.1.0.tgz", - "integrity": "sha512-bIUizkCY40mnU8oAO1tLV7uN2H/cHf1evLlhpqlb9JYwc5dT2moiEhNDo61OtOgkJmDGNuThAeO9Xk9hGQc7nA==", + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-19.8.1.tgz", + "integrity": "sha512-0ZVQldndLrDfB0tzFe/uIwvkUcakw8qGxvkEU0l7kSbv/ngNQ/qrkRi7P64otB15inIDUNZI2jtmVat52dqSfQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-eslint/bundled-angular-compiler": "19.1.0", - "@angular-eslint/utils": "19.1.0", + "@angular-eslint/bundled-angular-compiler": "19.8.1", + "@angular-eslint/utils": "19.8.1", "aria-query": "5.3.2", "axobject-query": "4.1.0" }, "peerDependencies": { + "@angular-eslint/template-parser": "19.8.1", "@typescript-eslint/types": "^7.11.0 || ^8.0.0", "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", "eslint": "^8.57.0 || ^9.0.0", @@ -430,22 +433,49 @@ "strip-json-comments": "3.1.1" } }, - "node_modules/@angular-eslint/template-parser": { + "node_modules/@angular-eslint/schematics/node_modules/@angular-eslint/bundled-angular-compiler": { "version": "19.1.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-19.1.0.tgz", - "integrity": "sha512-wbMi7adlC+uYqZo7NHNBShpNhFJRZsXLqihqvFpAUt1Ei6uDX8HR6MyMEDZ9tUnlqtPVW5nmbedPyLVG7HkjAA==", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-19.1.0.tgz", + "integrity": "sha512-HUJyukRvnh8Z9lIdxdblBRuBaPYEVv4iAYZMw3d+dn4rrM27Nt5oh3/zkwYrrPkt36tZdeXdDWrOuz9jgjVN5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-eslint/schematics/node_modules/@angular-eslint/eslint-plugin": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-19.1.0.tgz", + "integrity": "sha512-TDO0+Ry+oNkxnaLHogKp1k2aey6IkJef5d7hathE4UFT6owjRizltWaRoX6bGw7Qu1yagVLL8L2Se8SddxSPAQ==", "dev": true, "license": "MIT", "dependencies": { "@angular-eslint/bundled-angular-compiler": "19.1.0", - "eslint-scope": "^8.0.2" + "@angular-eslint/utils": "19.1.0" }, "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": "*" } }, - "node_modules/@angular-eslint/utils": { + "node_modules/@angular-eslint/schematics/node_modules/@angular-eslint/eslint-plugin-template": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-19.1.0.tgz", + "integrity": "sha512-bIUizkCY40mnU8oAO1tLV7uN2H/cHf1evLlhpqlb9JYwc5dT2moiEhNDo61OtOgkJmDGNuThAeO9Xk9hGQc7nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "19.1.0", + "@angular-eslint/utils": "19.1.0", + "aria-query": "5.3.2", + "axobject-query": "4.1.0" + }, + "peerDependencies": { + "@typescript-eslint/types": "^7.11.0 || ^8.0.0", + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/schematics/node_modules/@angular-eslint/utils": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-19.1.0.tgz", "integrity": "sha512-mcb7hPMH/u6wwUwvsewrmgb9y9NWN6ZacvpUvKlTOxF/jOtTdsu0XfV4YB43sp2A8NWzYzX0Str4c8K1xSmuBQ==", @@ -460,10 +490,40 @@ "typescript": "*" } }, + "node_modules/@angular-eslint/template-parser": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-19.8.1.tgz", + "integrity": "sha512-pQiOg+se1AU/ncMlnJ9V6xYnMQ84qI1BGWuJpbU6A99VTXJg90scg0+T7DWmKssR1YjP5qmmBtrZfKsHEcLW/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "19.8.1", + "eslint-scope": "^8.0.2" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/utils": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-19.8.1.tgz", + "integrity": "sha512-gVDKYWmAjeTPtaYmddT/HS03fCebXJtrk8G1MouQIviZbHqLjap6TbVlzlkBigRzaF0WnFnrDduQslkJzEdceA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "19.8.1" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, "node_modules/@angular/animations": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-19.2.14.tgz", - "integrity": "sha512-xhl8fLto5HHJdVj8Nb6EoBEiTAcXuWDYn1q5uHcGxyVH3kiwENWy/2OQXgCr2CuWo2e6hNUGzSLf/cjbsMNqEA==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-19.2.15.tgz", + "integrity": "sha512-eq9vokLU8bjs7g/Znz8zJUQEOhT0MAJ/heBCHbB35S+CtZXJmItrsEqkI1tsRiR58NKXB6cbhBhULVo6qJbhXQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -472,19 +532,19 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "19.2.14", - "@angular/core": "19.2.14" + "@angular/common": "19.2.15", + "@angular/core": "19.2.15" } }, "node_modules/@angular/build": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.2.15.tgz", - "integrity": "sha512-iE4fp4d5ALu702uoL6/YkjM2JlGEXZ5G+RVzq3W2jg/Ft6ISAQnRKB6mymtetDD6oD7i87e8uSu9kFVNBauX2w==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.2.17.tgz", + "integrity": "sha512-JrF9dSrsMip2xJzSz3zNoozBXu/OYg0bHuKfuPA/usPhz5AomJ2SQ2unvl6sDF00pTlgJohJMQ6SUHjylybn2g==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1902.15", + "@angular-devkit/architect": "0.1902.17", "@babel/core": "7.26.10", "@babel/helper-annotate-as-pure": "7.25.9", "@babel/helper-split-export-declaration": "7.24.7", @@ -507,7 +567,7 @@ "sass": "1.85.0", "semver": "7.7.1", "source-map-support": "0.5.21", - "vite": "6.2.7", + "vite": "6.3.6", "watchpack": "2.4.2" }, "engines": { @@ -524,7 +584,7 @@ "@angular/localize": "^19.0.0 || ^19.2.0-next.0", "@angular/platform-server": "^19.0.0 || ^19.2.0-next.0", "@angular/service-worker": "^19.0.0 || ^19.2.0-next.0", - "@angular/ssr": "^19.2.15", + "@angular/ssr": "^19.2.17", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^19.0.0 || ^19.2.0-next.0", @@ -562,107 +622,6 @@ } } }, - "node_modules/@angular/build/node_modules/vite": { - "version": "6.2.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.7.tgz", - "integrity": "sha512-qg3LkeuinTrZoJHHF94coSaTfIPyBYoywp+ys4qu20oSJFbKMYoIJo0FWJT9q6Vp49l6z9IsJRbHdcGtiKbGoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "postcss": "^8.5.3", - "rollup": "^4.30.1" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/@angular/build/node_modules/vite/node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/@angular/cdk": { "version": "19.2.19", "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.19.tgz", @@ -679,18 +638,18 @@ } }, "node_modules/@angular/cli": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.15.tgz", - "integrity": "sha512-YRIpARHWSOnWkHusUWTQgeUrPWMjWvtQrOkjWc6stF36z2KUzKMEng6EzUvH6sZolNSwVwOFpODEP0ut4aBkvQ==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.17.tgz", + "integrity": "sha512-BIdFAPbDOASSVUvtJ2sFRlsmkSyX9VgdAs631RoVMlw/BskIW1q/MUJtwzEsOsybVpSH7vGHYJa6JFqDDriiRg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1902.15", - "@angular-devkit/core": "19.2.15", - "@angular-devkit/schematics": "19.2.15", + "@angular-devkit/architect": "0.1902.17", + "@angular-devkit/core": "19.2.17", + "@angular-devkit/schematics": "19.2.17", "@inquirer/prompts": "7.3.2", "@listr2/prompt-adapter-inquirer": "2.0.18", - "@schematics/angular": "19.2.15", + "@schematics/angular": "19.2.17", "@yarnpkg/lockfile": "1.1.0", "ini": "5.0.0", "jsonc-parser": "3.3.1", @@ -713,9 +672,9 @@ } }, "node_modules/@angular/common": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.14.tgz", - "integrity": "sha512-NcNklcuyqaTjOVGf7aru8APX9mjsnZ01gFZrn47BxHozhaR0EMRrotYQTdi8YdVjPkeYFYanVntSLfhyobq/jg==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.15.tgz", + "integrity": "sha512-aVa/ctBYH/4qgA7r4sS7TV+/DzRYmcS+3d6l89pNKUXkI8gpmsd+r3FjccaemX4Wqru1QOrMvC+i+e7IBIVv0g==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -724,14 +683,14 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "19.2.14", + "@angular/core": "19.2.15", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.14.tgz", - "integrity": "sha512-ZqJDYOdhgKpVGNq3+n/Gbxma8DVYElDsoRe0tvNtjkWBVdaOxdZZUqmJ3kdCBsqD/aqTRvRBu0KGo9s2fCChkA==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.15.tgz", + "integrity": "sha512-hMHZU6/03xG0tbPDIm1hbVSTFLnRkGYfh+xdBwUMnIFYYTS0QJ2hdPfEZKCJIXm+fz9IAI5MPdDTfeyp0sgaHQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -741,9 +700,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.14.tgz", - "integrity": "sha512-e9/h86ETjoIK2yTLE9aUeMCKujdg/du2pq7run/aINjop4RtnNOw+ZlSTUa6R65lP5CVwDup1kPytpAoifw8cA==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.15.tgz", + "integrity": "sha512-4r5tvGA2Ok3o8wROZBkF9qNKS7L0AEpdBIkAVJbLw2rBY2SlyycFIRYyV2+D1lJ1jq/f9U7uN6oon0MjTvNYkA==", "dev": true, "license": "MIT", "dependencies": { @@ -765,7 +724,7 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "19.2.14", + "@angular/compiler": "19.2.15", "typescript": ">=5.5 <5.9" } }, @@ -818,9 +777,9 @@ } }, "node_modules/@angular/core": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.14.tgz", - "integrity": "sha512-EVErpW9tGqJ/wNcAN3G/ErH8pHCJ8mM1E6bsJ8UJIpDTZkpqqYjBMtZS9YWH5n3KwUd1tAkAB2w8FK125AjDUQ==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.15.tgz", + "integrity": "sha512-PxhzCwwm23N4Mq6oV7UPoYiJF4r6FzGhRSxOBBlEp322k7zEQbIxd/XO6F3eoG73qC1UsOXMYYv6GnQpx42y3A==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -834,9 +793,9 @@ } }, "node_modules/@angular/forms": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.14.tgz", - "integrity": "sha512-hWtDOj2B0AuRTf+nkMJeodnFpDpmEK9OIhIv1YxcRe73ooaxrIdjgugkElO8I9Tj0E4/7m117ezhWDUkbqm1zA==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.15.tgz", + "integrity": "sha512-pZDElcYPmNzPxvWJpZQCIizsNApDIfk9xLJE4I8hzLISfWGbQvfjuuarDAuQZEXudeLXoDOstDXkDja40muLGg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -845,16 +804,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "19.2.14", - "@angular/core": "19.2.14", - "@angular/platform-browser": "19.2.14", + "@angular/common": "19.2.15", + "@angular/core": "19.2.15", + "@angular/platform-browser": "19.2.15", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/platform-browser": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.14.tgz", - "integrity": "sha512-hzkT5nmA64oVBQl6PRjdL4dIFT1n7lfM9rm5cAoS+6LUUKRgiE2d421Kpn/Hz3jaCJfo+calMIdtSMIfUJBmww==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.15.tgz", + "integrity": "sha512-OelQ6weCjon8kZD8kcqNzwugvZJurjS3uMJCwsA2vXmP/3zJ31SWtNqE2zLT1R2csVuwnp0h+nRMgq+pINU7Rg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -863,9 +822,9 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/animations": "19.2.14", - "@angular/common": "19.2.14", - "@angular/core": "19.2.14" + "@angular/animations": "19.2.15", + "@angular/common": "19.2.15", + "@angular/core": "19.2.15" }, "peerDependenciesMeta": { "@angular/animations": { @@ -874,9 +833,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-19.2.14.tgz", - "integrity": "sha512-Hfz0z1KDQmIdnFXVFCwCPykuIsHPkr1uW2aY396eARwZ6PK8i0Aadcm1ZOnpd3MR1bMyDrJo30VRS5kx89QWvA==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-19.2.15.tgz", + "integrity": "sha512-dKy0SS395FCh8cW9AQ8nf4Wn3XlONaH7z50T1bGxm3eOoRqjxJYyIeIlEbDdJakMz4QPR3dGr81HleZd8TJumQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -885,16 +844,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "19.2.14", - "@angular/compiler": "19.2.14", - "@angular/core": "19.2.14", - "@angular/platform-browser": "19.2.14" + "@angular/common": "19.2.15", + "@angular/compiler": "19.2.15", + "@angular/core": "19.2.15", + "@angular/platform-browser": "19.2.15" } }, "node_modules/@angular/router": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.14.tgz", - "integrity": "sha512-cBTWY9Jx7YhbmDYDb7Hqz4Q7UNIMlKTkdKToJd2pbhIXyoS+kHVQrySmyca+jgvYMjWnIjsAEa3dpje12D4mFw==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.15.tgz", + "integrity": "sha512-0TM1D8S7RQ00drKy7hA/ZLBY14dUBqFBgm06djcNcOjNzVAtgkeV0i+0Smq9tCC7UsGKdpZu4RgfYjHATBNlTQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -903,29 +862,20 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "19.2.14", - "@angular/core": "19.2.14", - "@angular/platform-browser": "19.2.14", + "@angular/common": "19.2.15", + "@angular/core": "19.2.15", + "@angular/platform-browser": "19.2.15", "rxjs": "^6.5.3 || ^7.4.0" } }, - "node_modules/@angular/service-worker": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-19.2.14.tgz", - "integrity": "sha512-ajH4kjsuzDvJNxnG18y8N47R0avXFKwOeLszoiirlr5160C+k4HmQvIbzcCjD5liW0OkmxJN1cMW6KdilP8/2w==", + "node_modules/@arr/every": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@arr/every/-/every-1.0.1.tgz", + "integrity": "sha512-UQFQ6SgyJ6LX42W8rHCs8KVc0JS0tzVL9ct4XYedJukskYVWTo49tNiMEK9C2HTyarbNiT/RVIRSY82vH+6sTg==", + "dev": true, "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "bin": { - "ngsw-config": "ngsw-config.js" - }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/core": "19.2.14", - "rxjs": "^6.5.3 || ^7.4.0" + "node": ">=4" } }, "node_modules/@babel/code-frame": { @@ -944,9 +894,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", "dev": true, "license": "MIT", "engines": { @@ -1059,18 +1009,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", - "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz", + "integrity": "sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.27.1", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.27.1", + "@babel/traverse": "^7.28.3", "semver": "^6.3.1" }, "engines": { @@ -1200,15 +1150,15 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" + "@babel/traverse": "^7.28.3" }, "engines": { "node": ">=6.9.0" @@ -1347,42 +1297,42 @@ } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.27.1.tgz", - "integrity": "sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz", + "integrity": "sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.1", - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz", - "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2" + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.0" + "@babel/types": "^7.28.4" }, "bin": { "parser": "bin/babel-parser.js" @@ -1459,14 +1409,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.27.1.tgz", - "integrity": "sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz", + "integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/traverse": "^7.28.3" }, "engines": { "node": ">=6.9.0" @@ -1829,9 +1779,9 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.0.tgz", - "integrity": "sha512-gKKnwjpdx5sER/wl0WN0efUBFzF/56YZO0RJrSYP4CljXnP31ByY7fol89AzomdlLNzI36AvOTmYHsnZTCkq8Q==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.4.tgz", + "integrity": "sha512-1yxmvN0MJHOhPVmAsmoW5liWwoILobu/d/ShymZmj867bAdxGbehIrew1DuLpw2Ukv+qDSSPQdYW1dLNE7t11A==", "dev": true, "license": "MIT", "dependencies": { @@ -1862,13 +1812,13 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.27.1.tgz", - "integrity": "sha512-s734HmYU78MVzZ++joYM+NkJusItbdRcbm+AGRgJCt3iA+yux0QpD9cBVdz3tKyrjVYWRl7j0mHSmv4lhV0aoA==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz", + "integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.28.3", "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { @@ -1879,9 +1829,9 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.0.tgz", - "integrity": "sha512-IjM1IoJNw72AZFlj33Cu8X0q2XK/6AaVC3jQu+cgQ5lThWD5ajnuUAml80dqRmOhmPkTH8uAwnpMu9Rvj0LTRA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", + "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", "dev": true, "license": "MIT", "dependencies": { @@ -1890,7 +1840,7 @@ "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", - "@babel/traverse": "^7.28.0" + "@babel/traverse": "^7.28.4" }, "engines": { "node": ">=6.9.0" @@ -2012,6 +1962,23 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", + "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-exponentiation-operator": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz", @@ -2279,9 +2246,9 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.0.tgz", - "integrity": "sha512-9VNGikXxzu5eCiQjdE4IZn8sb9q7Xsk5EXLDBKUYg1e/Tve8/05+KJEtcxGxAgCY5t/BpKQM+JEL/yT4tvgiUA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.4.tgz", + "integrity": "sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==", "dev": true, "license": "MIT", "dependencies": { @@ -2289,7 +2256,7 @@ "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-transform-destructuring": "^7.28.0", "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.0" + "@babel/traverse": "^7.28.4" }, "engines": { "node": ">=6.9.0" @@ -2429,9 +2396,9 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.28.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.1.tgz", - "integrity": "sha512-P0QiV/taaa3kXpLY+sXla5zec4E+4t4Aqc9ggHlfZ7a2cp8/x/Gv08jfwEtn9gnnYIMvHx6aoOZ8XJL8eU71Dg==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.4.tgz", + "integrity": "sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==", "dev": true, "license": "MIT", "dependencies": { @@ -2794,18 +2761,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", - "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", + "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.0", + "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", - "@babel/types": "^7.28.0", + "@babel/types": "^7.28.4", "debug": "^4.3.1" }, "engines": { @@ -2813,14 +2780,14 @@ } }, "node_modules/@babel/traverse/node_modules/@babel/generator": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", - "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.0", - "@babel/types": "^7.28.0", + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -2830,9 +2797,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3108,34 +3075,36 @@ } }, "node_modules/@compodoc/compodoc": { - "version": "1.1.26", - "resolved": "https://registry.npmjs.org/@compodoc/compodoc/-/compodoc-1.1.26.tgz", - "integrity": "sha512-CJkqTtdotxMA4SDyUx8J6Mrm3MMmcgFtfEViUnG9Of2CXhYiXIqNeD881+pxn0opmMC+VCTL0/SCD03tDYhWYA==", + "version": "1.1.30", + "resolved": "https://registry.npmjs.org/@compodoc/compodoc/-/compodoc-1.1.30.tgz", + "integrity": "sha512-pdFe1tnskXmvj8lfXZgq7LwgAxAPYfxflcGZiexMu1fOyRVsZYrBcHHEWp0sX9FU+or61IOicJv8Vdcri08FEQ==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "@angular-devkit/schematics": "18.2.8", - "@babel/core": "7.25.8", - "@babel/plugin-transform-private-methods": "7.25.7", - "@babel/preset-env": "7.25.8", + "@angular-devkit/schematics": "20.2.2", + "@babel/core": "7.28.4", + "@babel/plugin-transform-private-methods": "7.27.1", + "@babel/preset-env": "7.28.3", "@compodoc/live-server": "^1.2.3", "@compodoc/ngd-transformer": "^2.1.3", - "bootstrap.native": "^5.0.13", - "cheerio": "1.0.0-rc.12", - "chokidar": "^4.0.1", + "@polka/send-type": "^0.5.2", + "body-parser": "^2.2.0", + "bootstrap.native": "^5.1.5", + "cheerio": "1.1.2", + "chokidar": "^4.0.3", "colors": "1.4.0", - "commander": "^12.1.0", + "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "decache": "^4.6.2", "es6-shim": "^0.35.8", "fancy-log": "^2.0.0", - "fast-glob": "^3.3.2", - "fs-extra": "^11.2.0", - "glob": "^11.0.0", + "fast-glob": "^3.3.3", + "fs-extra": "^11.3.1", + "glob": "^11.0.3", "handlebars": "^4.7.8", - "html-entities": "^2.5.2", - "i18next": "^23.16.0", + "html-entities": "^2.6.0", + "i18next": "25.5.2", "json5": "^2.2.3", "lodash": "^4.17.21", "loglevel": "^1.9.2", @@ -3146,62 +3115,45 @@ "neotraverse": "^0.6.18", "opencollective-postinstall": "^2.0.3", "os-name": "4.0.1", - "picocolors": "^1.1.0", - "prismjs": "^1.29.0", - "semver": "^7.6.3", - "svg-pan-zoom": "^3.6.1", - "tablesort": "^5.3.0", - "ts-morph": "^24.0.0", - "uuid": "^10.0.0", + "picocolors": "^1.1.1", + "polka": "^0.5.2", + "prismjs": "^1.30.0", + "semver": "^7.7.2", + "sirv": "^3.0.2", + "svg-pan-zoom": "^3.6.2", + "tablesort": "^5.6.0", + "ts-morph": "^26.0.0", + "uuid": "^11.1.0", "vis": "^4.21.0-EOL" }, "bin": { "compodoc": "bin/index-cli.js" }, "engines": { - "node": ">= 16.0.0" - } - }, - "node_modules/@compodoc/compodoc/node_modules/@angular-devkit/schematics": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-18.2.8.tgz", - "integrity": "sha512-i/h2Oji5FhJMC7wDSnIl5XUe/qym+C1ZwScaATJwDyRLCUIynZkj5rLgdG/uK6l+H0PgvxigkF+akWpokkwW6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "18.2.8", - "jsonc-parser": "3.3.1", - "magic-string": "0.30.11", - "ora": "5.4.1", - "rxjs": "7.8.1" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/@compodoc/compodoc/node_modules/@angular-devkit/schematics/node_modules/@angular-devkit/core": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.8.tgz", - "integrity": "sha512-4o2T6wsmXGE/v53+F8L7kGoN2+qzt03C9rtjLVQpOljzpJVttQ8bhvfWxyYLWwcl04RWqRa+82fpIZtBkOlZJw==", + "node_modules/@compodoc/compodoc/node_modules/@angular-devkit/core": { + "version": "20.2.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-20.2.2.tgz", + "integrity": "sha512-SC+f5isSWJBpEgR+R7jP++2Z14WExNWLAdKpIickLWjuL8FlGkj+kaF3dWXhh0KcXo+r6kKb4pWUptSaqer5gA==", "dev": true, "license": "MIT", "dependencies": { "ajv": "8.17.1", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", - "picomatch": "4.0.2", - "rxjs": "7.8.1", - "source-map": "0.7.4" + "picomatch": "4.0.3", + "rxjs": "7.8.2", + "source-map": "0.7.6" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" }, "peerDependencies": { - "chokidar": "^3.5.2" + "chokidar": "^4.0.0" }, "peerDependenciesMeta": { "chokidar": { @@ -3209,50 +3161,42 @@ } } }, - "node_modules/@compodoc/compodoc/node_modules/@angular-devkit/schematics/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "node_modules/@compodoc/compodoc/node_modules/@angular-devkit/schematics": { + "version": "20.2.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-20.2.2.tgz", + "integrity": "sha512-rtL7slZjzdChQoiADKZv/Ra8D3C3tIw/WcVxd2stiLHdK/Oaf9ejx5m/X9o0QMEbNsy2Fy/RKodNqmz1CjzpCg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "@angular-devkit/core": "20.2.2", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "8.2.0", + "rxjs": "7.8.2" }, "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" } }, "node_modules/@compodoc/compodoc/node_modules/@babel/core": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.8.tgz", - "integrity": "sha512-Oixnb+DzmRT30qu9d3tJSQkxuygWm32DFykT4bRoORPa9hZ/L4KhVB/XiRm6KG+roIEM7DBQlmg27kw2HZkdZg==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.25.7", - "@babel/generator": "^7.25.7", - "@babel/helper-compilation-targets": "^7.25.7", - "@babel/helper-module-transforms": "^7.25.7", - "@babel/helpers": "^7.25.7", - "@babel/parser": "^7.25.8", - "@babel/template": "^7.25.7", - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.8", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -3277,15 +3221,67 @@ "semver": "bin/semver.js" } }, - "node_modules/@compodoc/compodoc/node_modules/@babel/plugin-transform-private-methods": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.7.tgz", - "integrity": "sha512-KY0hh2FluNxMLwOCHbxVOKfdB5sjWG4M183885FmaqWWiGMhRZq4DQRKH6mHdEucbJnyDyYiZNwNG424RymJjA==", + "node_modules/@compodoc/compodoc/node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@compodoc/compodoc/node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@compodoc/compodoc/node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", + "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@compodoc/compodoc/node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", + "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -3295,79 +3291,81 @@ } }, "node_modules/@compodoc/compodoc/node_modules/@babel/preset-env": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.25.8.tgz", - "integrity": "sha512-58T2yulDHMN8YMUxiLq5YmWUnlDCyY1FsHM+v12VMx+1/FlrUj5tY50iDCpofFQEM8fMYOaY9YRvym2jcjn1Dg==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.3.tgz", + "integrity": "sha512-ROiDcM+GbYVPYBOeCR6uBXKkQpBExLl8k9HO1ygXEyds39j+vCCsjmj7S8GOniZQlEs81QlkdJZe76IpLSiqpg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.25.8", - "@babel/helper-compilation-targets": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-validator-option": "^7.25.7", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.7", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.7", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.7", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.7", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.7", + "@babel/compat-data": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.27.1", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.3", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.25.7", - "@babel/plugin-syntax-import-attributes": "^7.25.7", + "@babel/plugin-syntax-import-assertions": "^7.27.1", + "@babel/plugin-syntax-import-attributes": "^7.27.1", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.25.7", - "@babel/plugin-transform-async-generator-functions": "^7.25.8", - "@babel/plugin-transform-async-to-generator": "^7.25.7", - "@babel/plugin-transform-block-scoped-functions": "^7.25.7", - "@babel/plugin-transform-block-scoping": "^7.25.7", - "@babel/plugin-transform-class-properties": "^7.25.7", - "@babel/plugin-transform-class-static-block": "^7.25.8", - "@babel/plugin-transform-classes": "^7.25.7", - "@babel/plugin-transform-computed-properties": "^7.25.7", - "@babel/plugin-transform-destructuring": "^7.25.7", - "@babel/plugin-transform-dotall-regex": "^7.25.7", - "@babel/plugin-transform-duplicate-keys": "^7.25.7", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.7", - "@babel/plugin-transform-dynamic-import": "^7.25.8", - "@babel/plugin-transform-exponentiation-operator": "^7.25.7", - "@babel/plugin-transform-export-namespace-from": "^7.25.8", - "@babel/plugin-transform-for-of": "^7.25.7", - "@babel/plugin-transform-function-name": "^7.25.7", - "@babel/plugin-transform-json-strings": "^7.25.8", - "@babel/plugin-transform-literals": "^7.25.7", - "@babel/plugin-transform-logical-assignment-operators": "^7.25.8", - "@babel/plugin-transform-member-expression-literals": "^7.25.7", - "@babel/plugin-transform-modules-amd": "^7.25.7", - "@babel/plugin-transform-modules-commonjs": "^7.25.7", - "@babel/plugin-transform-modules-systemjs": "^7.25.7", - "@babel/plugin-transform-modules-umd": "^7.25.7", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.7", - "@babel/plugin-transform-new-target": "^7.25.7", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.25.8", - "@babel/plugin-transform-numeric-separator": "^7.25.8", - "@babel/plugin-transform-object-rest-spread": "^7.25.8", - "@babel/plugin-transform-object-super": "^7.25.7", - "@babel/plugin-transform-optional-catch-binding": "^7.25.8", - "@babel/plugin-transform-optional-chaining": "^7.25.8", - "@babel/plugin-transform-parameters": "^7.25.7", - "@babel/plugin-transform-private-methods": "^7.25.7", - "@babel/plugin-transform-private-property-in-object": "^7.25.8", - "@babel/plugin-transform-property-literals": "^7.25.7", - "@babel/plugin-transform-regenerator": "^7.25.7", - "@babel/plugin-transform-reserved-words": "^7.25.7", - "@babel/plugin-transform-shorthand-properties": "^7.25.7", - "@babel/plugin-transform-spread": "^7.25.7", - "@babel/plugin-transform-sticky-regex": "^7.25.7", - "@babel/plugin-transform-template-literals": "^7.25.7", - "@babel/plugin-transform-typeof-symbol": "^7.25.7", - "@babel/plugin-transform-unicode-escapes": "^7.25.7", - "@babel/plugin-transform-unicode-property-regex": "^7.25.7", - "@babel/plugin-transform-unicode-regex": "^7.25.7", - "@babel/plugin-transform-unicode-sets-regex": "^7.25.7", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.28.0", + "@babel/plugin-transform-async-to-generator": "^7.27.1", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.0", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-class-static-block": "^7.28.3", + "@babel/plugin-transform-classes": "^7.28.3", + "@babel/plugin-transform-computed-properties": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-dotall-regex": "^7.27.1", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.0", + "@babel/plugin-transform-exponentiation-operator": "^7.27.1", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.27.1", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.27.1", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-modules-systemjs": "^7.27.1", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", + "@babel/plugin-transform-numeric-separator": "^7.27.1", + "@babel/plugin-transform-object-rest-spread": "^7.28.0", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.27.1", + "@babel/plugin-transform-private-property-in-object": "^7.27.1", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.28.3", + "@babel/plugin-transform-regexp-modifiers": "^7.27.1", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.27.1", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.10.6", - "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.38.1", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "core-js-compat": "^3.43.0", "semver": "^6.3.1" }, "engines": { @@ -3388,14 +3386,14 @@ } }, "node_modules/@compodoc/compodoc/node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.10.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", - "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.2", - "core-js-compat": "^3.38.0" + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -3408,166 +3406,145 @@ "dev": true, "license": "MIT" }, - "node_modules/@compodoc/compodoc/node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "node_modules/@compodoc/compodoc/node_modules/emoji-regex": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, + "license": "MIT" + }, + "node_modules/@compodoc/compodoc/node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", "engines": { - "node": "20 || >=22" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@compodoc/compodoc/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/@compodoc/compodoc/node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "is-glob": "^4.0.1" - }, + "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@compodoc/compodoc/node_modules/jackspeak": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "node_modules/@compodoc/compodoc/node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "@isaacs/cliui": "^8.0.2" + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" }, "engines": { - "node": "20 || >=22" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@compodoc/compodoc/node_modules/lru-cache": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", - "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", + "node_modules/@compodoc/compodoc/node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", "dev": true, - "license": "ISC", + "license": "MIT", "engines": { - "node": "20 || >=22" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@compodoc/compodoc/node_modules/magic-string": { - "version": "0.30.11", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", - "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", + "node_modules/@compodoc/compodoc/node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } - }, - "node_modules/@compodoc/compodoc/node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": "20 || >=22" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@compodoc/compodoc/node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "node_modules/@compodoc/compodoc/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, + "license": "MIT", "engines": { - "node": "20 || >=22" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@compodoc/compodoc/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "node_modules/@compodoc/compodoc/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "picomatch": "^2.2.1" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=8.10.0" + "node": ">=10" } }, - "node_modules/@compodoc/compodoc/node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/@compodoc/compodoc/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true, + "license": "BSD-3-Clause", "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@compodoc/compodoc/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" + "node": ">= 12" } }, - "node_modules/@compodoc/compodoc/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "node_modules/@compodoc/compodoc/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@compodoc/live-server": { @@ -4187,9 +4164,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, "license": "MIT", "dependencies": { @@ -4360,9 +4337,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.33.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", - "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", + "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", "dev": true, "license": "MIT", "engines": { @@ -4416,33 +4393,19 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -4471,17 +4434,27 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.0.tgz", + "integrity": "sha512-JWaTfCxI1eTmJ1BIv86vUfjVatOdxwD0DAVKYevY8SazeUUZtW+tNbsdejVO1GYE0GXJW1N1ahmiC3TFd+7wZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@inquirer/checkbox": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.2.1.tgz", - "integrity": "sha512-bevKGO6kX1eM/N+pdh9leS5L7TBF4ICrzi9a+cbWkrxeAeIcwlo/7OfWGCDERdRCI2/Q6tjltX4bt07ALHDwFw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.2.4.tgz", + "integrity": "sha512-2n9Vgf4HSciFq8ttKXk+qy+GsyTXPV1An6QAwe/8bkbbqvG4VW1I/ZY1pNu2rf+h9bdzMLPbRSfcNxkHBy/Ydw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.15", + "@inquirer/ansi": "^1.0.0", + "@inquirer/core": "^10.2.2", "@inquirer/figures": "^1.0.13", "@inquirer/type": "^3.0.8", - "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, "engines": { @@ -4519,15 +4492,15 @@ } }, "node_modules/@inquirer/core": { - "version": "10.1.15", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.15.tgz", - "integrity": "sha512-8xrp836RZvKkpNbVvgWUlxjT4CraKk2q+I3Ksy+seI2zkcE+y6wNs1BVhgcv8VyImFecUhdQrYLdW32pAjwBdA==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.2.2.tgz", + "integrity": "sha512-yXq/4QUnk4sHMtmbd7irwiepjB8jXU0kkFRL4nr/aDBA2mDz13cMakEWdDwX3eSCTkk03kwcndD1zfRAIlELxA==", "dev": true, "license": "MIT", "dependencies": { + "@inquirer/ansi": "^1.0.0", "@inquirer/figures": "^1.0.13", "@inquirer/type": "^3.0.8", - "ansi-escapes": "^4.3.2", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", @@ -4547,14 +4520,14 @@ } }, "node_modules/@inquirer/editor": { - "version": "4.2.17", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.17.tgz", - "integrity": "sha512-r6bQLsyPSzbWrZZ9ufoWL+CztkSatnJ6uSxqd6N+o41EZC51sQeWOzI6s5jLb+xxTWxl7PlUppqm8/sow241gg==", + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.20.tgz", + "integrity": "sha512-7omh5y5bK672Q+Brk4HBbnHNowOZwrb/78IFXdrEB9PfdxL3GudQyDk8O9vQ188wj3xrEebS2M9n18BjJoI83g==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.15", - "@inquirer/external-editor": "^1.0.1", + "@inquirer/core": "^10.2.2", + "@inquirer/external-editor": "^1.0.2", "@inquirer/type": "^3.0.8" }, "engines": { @@ -4570,13 +4543,13 @@ } }, "node_modules/@inquirer/expand": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.17.tgz", - "integrity": "sha512-PSqy9VmJx/VbE3CT453yOfNa+PykpKg/0SYP7odez1/NWBGuDXgPhp4AeGYYKjhLn5lUUavVS/JbeYMPdH50Mw==", + "version": "4.0.20", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.20.tgz", + "integrity": "sha512-Dt9S+6qUg94fEvgn54F2Syf0Z3U8xmnBI9ATq2f5h9xt09fs2IJXSCIXyyVHwvggKWFXEY/7jATRo2K6Dkn6Ow==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.15", + "@inquirer/core": "^10.2.2", "@inquirer/type": "^3.0.8", "yoctocolors-cjs": "^2.1.2" }, @@ -4593,14 +4566,14 @@ } }, "node_modules/@inquirer/external-editor": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.1.tgz", - "integrity": "sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.2.tgz", + "integrity": "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==", "dev": true, "license": "MIT", "dependencies": { "chardet": "^2.1.0", - "iconv-lite": "^0.6.3" + "iconv-lite": "^0.7.0" }, "engines": { "node": ">=18" @@ -4625,13 +4598,13 @@ } }, "node_modules/@inquirer/input": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.2.1.tgz", - "integrity": "sha512-tVC+O1rBl0lJpoUZv4xY+WGWY8V5b0zxU1XDsMsIHYregdh7bN5X5QnIONNBAl0K765FYlAfNHS2Bhn7SSOVow==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.2.4.tgz", + "integrity": "sha512-cwSGpLBMwpwcZZsc6s1gThm0J+it/KIJ+1qFL2euLmSKUMGumJ5TcbMgxEjMjNHRGadouIYbiIgruKoDZk7klw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.15", + "@inquirer/core": "^10.2.2", "@inquirer/type": "^3.0.8" }, "engines": { @@ -4647,13 +4620,13 @@ } }, "node_modules/@inquirer/number": { - "version": "3.0.17", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.17.tgz", - "integrity": "sha512-GcvGHkyIgfZgVnnimURdOueMk0CztycfC8NZTiIY9arIAkeOgt6zG57G+7vC59Jns3UX27LMkPKnKWAOF5xEYg==", + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.20.tgz", + "integrity": "sha512-bbooay64VD1Z6uMfNehED2A2YOPHSJnQLs9/4WNiV/EK+vXczf/R988itL2XLDGTgmhMF2KkiWZo+iEZmc4jqg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.15", + "@inquirer/core": "^10.2.2", "@inquirer/type": "^3.0.8" }, "engines": { @@ -4669,15 +4642,15 @@ } }, "node_modules/@inquirer/password": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.17.tgz", - "integrity": "sha512-DJolTnNeZ00E1+1TW+8614F7rOJJCM4y4BAGQ3Gq6kQIG+OJ4zr3GLjIjVVJCbKsk2jmkmv6v2kQuN/vriHdZA==", + "version": "4.0.20", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.20.tgz", + "integrity": "sha512-nxSaPV2cPvvoOmRygQR+h0B+Av73B01cqYLcr7NXcGXhbmsYfUb8fDdw2Us1bI2YsX+VvY7I7upgFYsyf8+Nug==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.15", - "@inquirer/type": "^3.0.8", - "ansi-escapes": "^4.3.2" + "@inquirer/ansi": "^1.0.0", + "@inquirer/core": "^10.2.2", + "@inquirer/type": "^3.0.8" }, "engines": { "node": ">=18" @@ -4722,13 +4695,13 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.5.tgz", - "integrity": "sha512-R5qMyGJqtDdi4Ht521iAkNqyB6p2UPuZUbMifakg1sWtu24gc2Z8CJuw8rP081OckNDMgtDCuLe42Q2Kr3BolA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.8.tgz", + "integrity": "sha512-CQ2VkIASbgI2PxdzlkeeieLRmniaUU1Aoi5ggEdm6BIyqopE9GuDXdDOj9XiwOqK5qm72oI2i6J+Gnjaa26ejg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.15", + "@inquirer/core": "^10.2.2", "@inquirer/type": "^3.0.8", "yoctocolors-cjs": "^2.1.2" }, @@ -4745,13 +4718,13 @@ } }, "node_modules/@inquirer/search": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.1.0.tgz", - "integrity": "sha512-PMk1+O/WBcYJDq2H7foV0aAZSmDdkzZB9Mw2v/DmONRJopwA/128cS9M/TXWLKKdEQKZnKwBzqu2G4x/2Nqx8Q==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.1.3.tgz", + "integrity": "sha512-D5T6ioybJJH0IiSUK/JXcoRrrm8sXwzrVMjibuPs+AgxmogKslaafy1oxFiorNI4s3ElSkeQZbhYQgLqiL8h6Q==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.15", + "@inquirer/core": "^10.2.2", "@inquirer/figures": "^1.0.13", "@inquirer/type": "^3.0.8", "yoctocolors-cjs": "^2.1.2" @@ -4769,16 +4742,16 @@ } }, "node_modules/@inquirer/select": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.3.1.tgz", - "integrity": "sha512-Gfl/5sqOF5vS/LIrSndFgOh7jgoe0UXEizDqahFRkq5aJBLegZ6WjuMh/hVEJwlFQjyLq1z9fRtvUMkb7jM1LA==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.3.4.tgz", + "integrity": "sha512-Qp20nySRmfbuJBBsgPU7E/cL62Hf250vMZRzYDcBHty2zdD1kKCnoDFWRr0WO2ZzaXp3R7a4esaVGJUx0E6zvA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.15", + "@inquirer/ansi": "^1.0.0", + "@inquirer/core": "^10.2.2", "@inquirer/figures": "^1.0.13", "@inquirer/type": "^3.0.8", - "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, "engines": { @@ -4853,9 +4826,9 @@ } }, "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { @@ -5630,9 +5603,20 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", @@ -5659,9 +5643,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.30", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", - "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -5721,9 +5705,9 @@ } }, "node_modules/@jsonjoy.com/json-pack": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.10.0.tgz", - "integrity": "sha512-PMOU9Sh0baiLZEDewwR/YAHJBV2D8pPIzcFQSU7HQl/k/HNCDyVfO1OvkyDwBGp4dPtvZc7Hl9FFYWwTP1CbZw==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.14.0.tgz", + "integrity": "sha512-LpWbYgVnKzphN5S6uss4M25jJ/9+m6q6UJoeN6zTkK4xAGhKsiBRPVeF7OYMWonn5repMQbE5vieRXcMUrKDKw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5747,13 +5731,14 @@ } }, "node_modules/@jsonjoy.com/json-pointer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.1.tgz", - "integrity": "sha512-tJpwQfuBuxqZlyoJOSZcqf7OUmiYQ6MiPNmOv4KbZdXE/DdvBSSAwhos0zIlJU/AXxC8XpuO8p08bh2fIl+RKA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/util": "^1.3.0" + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" }, "engines": { "node": ">=10.0" @@ -6008,9 +5993,9 @@ ] }, "node_modules/@napi-rs/nice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.4.tgz", - "integrity": "sha512-Sqih1YARrmMoHlXGgI9JrrgkzxcaaEso0AH+Y7j8NHonUs+xe4iDsgC3IBIDNdzEewbNpccNN6hip+b5vmyRLw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", + "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", "dev": true, "license": "MIT", "optional": true, @@ -6022,28 +6007,29 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "optionalDependencies": { - "@napi-rs/nice-android-arm-eabi": "1.0.4", - "@napi-rs/nice-android-arm64": "1.0.4", - "@napi-rs/nice-darwin-arm64": "1.0.4", - "@napi-rs/nice-darwin-x64": "1.0.4", - "@napi-rs/nice-freebsd-x64": "1.0.4", - "@napi-rs/nice-linux-arm-gnueabihf": "1.0.4", - "@napi-rs/nice-linux-arm64-gnu": "1.0.4", - "@napi-rs/nice-linux-arm64-musl": "1.0.4", - "@napi-rs/nice-linux-ppc64-gnu": "1.0.4", - "@napi-rs/nice-linux-riscv64-gnu": "1.0.4", - "@napi-rs/nice-linux-s390x-gnu": "1.0.4", - "@napi-rs/nice-linux-x64-gnu": "1.0.4", - "@napi-rs/nice-linux-x64-musl": "1.0.4", - "@napi-rs/nice-win32-arm64-msvc": "1.0.4", - "@napi-rs/nice-win32-ia32-msvc": "1.0.4", - "@napi-rs/nice-win32-x64-msvc": "1.0.4" + "@napi-rs/nice-android-arm-eabi": "1.1.1", + "@napi-rs/nice-android-arm64": "1.1.1", + "@napi-rs/nice-darwin-arm64": "1.1.1", + "@napi-rs/nice-darwin-x64": "1.1.1", + "@napi-rs/nice-freebsd-x64": "1.1.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/nice-linux-arm64-gnu": "1.1.1", + "@napi-rs/nice-linux-arm64-musl": "1.1.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", + "@napi-rs/nice-linux-s390x-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-musl": "1.1.1", + "@napi-rs/nice-openharmony-arm64": "1.1.1", + "@napi-rs/nice-win32-arm64-msvc": "1.1.1", + "@napi-rs/nice-win32-ia32-msvc": "1.1.1", + "@napi-rs/nice-win32-x64-msvc": "1.1.1" } }, "node_modules/@napi-rs/nice-android-arm-eabi": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.4.tgz", - "integrity": "sha512-OZFMYUkih4g6HCKTjqJHhMUlgvPiDuSLZPbPBWHLjKmFTv74COzRlq/gwHtmEVaR39mJQ6ZyttDl2HNMUbLVoA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz", + "integrity": "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==", "cpu": [ "arm" ], @@ -6058,9 +6044,9 @@ } }, "node_modules/@napi-rs/nice-android-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.0.4.tgz", - "integrity": "sha512-k8u7cjeA64vQWXZcRrPbmwjH8K09CBnNaPnI9L1D5N6iMPL3XYQzLcN6WwQonfcqCDv5OCY3IqX89goPTV4KMw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz", + "integrity": "sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==", "cpu": [ "arm64" ], @@ -6075,9 +6061,9 @@ } }, "node_modules/@napi-rs/nice-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-GsLdQvUcuVzoyzmtjsThnpaVEizAqH5yPHgnsBmq3JdVoVZHELFo7PuJEdfOH1DOHi2mPwB9sCJEstAYf3XCJA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", "cpu": [ "arm64" ], @@ -6092,9 +6078,9 @@ } }, "node_modules/@napi-rs/nice-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.0.4.tgz", - "integrity": "sha512-1y3gyT3e5zUY5SxRl3QDtJiWVsbkmhtUHIYwdWWIQ3Ia+byd/IHIEpqAxOGW1nhhnIKfTCuxBadHQb+yZASVoA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz", + "integrity": "sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==", "cpu": [ "x64" ], @@ -6109,9 +6095,9 @@ } }, "node_modules/@napi-rs/nice-freebsd-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.0.4.tgz", - "integrity": "sha512-06oXzESPRdXUuzS8n2hGwhM2HACnDfl3bfUaSqLGImM8TA33pzDXgGL0e3If8CcFWT98aHows5Lk7xnqYNGFeA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz", + "integrity": "sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==", "cpu": [ "x64" ], @@ -6126,9 +6112,9 @@ } }, "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.0.4.tgz", - "integrity": "sha512-CgklZ6g8WL4+EgVVkxkEvvsi2DSLf9QIloxWO0fvQyQBp6VguUSX3eHLeRpqwW8cRm2Hv/Q1+PduNk7VK37VZw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz", + "integrity": "sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==", "cpu": [ "arm" ], @@ -6143,9 +6129,9 @@ } }, "node_modules/@napi-rs/nice-linux-arm64-gnu": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.0.4.tgz", - "integrity": "sha512-wdAJ7lgjhAlsANUCv0zi6msRwq+D4KDgU+GCCHssSxWmAERZa2KZXO0H2xdmoJ/0i03i6YfK/sWaZgUAyuW2oQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz", + "integrity": "sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==", "cpu": [ "arm64" ], @@ -6160,9 +6146,9 @@ } }, "node_modules/@napi-rs/nice-linux-arm64-musl": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.0.4.tgz", - "integrity": "sha512-4b1KYG+sriufhFrpUS9uNOEYYJqSfcbnwGx6uGX7JjrH8tELG90cOpCawz5THNIwlS3DhLgnCOcn0+4p6z26QA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==", "cpu": [ "arm64" ], @@ -6177,9 +6163,9 @@ } }, "node_modules/@napi-rs/nice-linux-ppc64-gnu": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.0.4.tgz", - "integrity": "sha512-iaf3vMRgr23oe1PUaKpxaH3DS0IMN0+N9iEiWVwYPm/U15vZFYdqVegGfN2PzrZLUl5lc8ZxbmEKDfuqslhAMA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz", + "integrity": "sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==", "cpu": [ "ppc64" ], @@ -6194,9 +6180,9 @@ } }, "node_modules/@napi-rs/nice-linux-riscv64-gnu": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.0.4.tgz", - "integrity": "sha512-UXoREY6Yw6rHrGuTwQgBxpfjK34t6mTjibE9/cXbefL9AuUCJ9gEgwNKZiONuR5QGswChqo9cnthjdKkYyAdDg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz", + "integrity": "sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==", "cpu": [ "riscv64" ], @@ -6211,9 +6197,9 @@ } }, "node_modules/@napi-rs/nice-linux-s390x-gnu": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.0.4.tgz", - "integrity": "sha512-eFbgYCRPmsqbYPAlLYU5hYTNbogmIDUvknilehHsFhCH1+0/kN87lP+XaLT0Yeq4V/rpwChSd9vlz4muzFArtw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz", + "integrity": "sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==", "cpu": [ "s390x" ], @@ -6228,9 +6214,9 @@ } }, "node_modules/@napi-rs/nice-linux-x64-gnu": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.0.4.tgz", - "integrity": "sha512-4T3E6uTCwWT6IPnwuPcWVz3oHxvEp/qbrCxZhsgzwTUBEwu78EGNXGdHfKJQt3soth89MLqZJw+Zzvnhrsg1mQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz", + "integrity": "sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==", "cpu": [ "x64" ], @@ -6245,9 +6231,9 @@ } }, "node_modules/@napi-rs/nice-linux-x64-musl": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.0.4.tgz", - "integrity": "sha512-NtbBkAeyBPLvCBkWtwkKXkNSn677eaT0cX3tygq+2qVv71TmHgX4gkX6o9BXjlPzdgPGwrUudavCYPT9tzkEqQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==", "cpu": [ "x64" ], @@ -6261,10 +6247,27 @@ "node": ">= 10" } }, + "node_modules/@napi-rs/nice-openharmony-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz", + "integrity": "sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/nice-win32-arm64-msvc": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.0.4.tgz", - "integrity": "sha512-vubOe3i+YtSJGEk/++73y+TIxbuVHi+W8ZzrRm2eETCjCRwNlgbfToQZ85dSA+4iBB/NJRGNp+O4hfdbbttZWA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz", + "integrity": "sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==", "cpu": [ "arm64" ], @@ -6279,9 +6282,9 @@ } }, "node_modules/@napi-rs/nice-win32-ia32-msvc": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.0.4.tgz", - "integrity": "sha512-BMOVrUDZeg1RNRKVlh4eyLv5djAAVLiSddfpuuQ47EFjBcklg0NUeKMFKNrKQR4UnSn4HAiACLD7YK7koskwmg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz", + "integrity": "sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==", "cpu": [ "ia32" ], @@ -6296,9 +6299,9 @@ } }, "node_modules/@napi-rs/nice-win32-x64-msvc": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.0.4.tgz", - "integrity": "sha512-kCNk6HcRZquhw/whwh4rHsdPyOSCQCgnVDVik+Y9cuSVTDy3frpiCJTScJqPPS872h4JgZKkr/+CwcwttNEo9Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz", + "integrity": "sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==", "cpu": [ "x64" ], @@ -6313,9 +6316,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.15.tgz", - "integrity": "sha512-H37nop/wWMkSgoU2VvrMzanHePdLRRrX52nC5tT2ZhH3qP25+PrnMyw11PoLDLv3iWXC68uB1AiKNIT+jiQbuQ==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.17.tgz", + "integrity": "sha512-HpbOLwS8tIW041UXcMqwfySqpZ9ztObH8U4NWKwjPBe0S5UDnF6doW2rS3GQm71hkiuB8sqbxOWz5I/NNvZFNQ==", "dev": true, "license": "MIT", "engines": { @@ -6596,10 +6599,71 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/@npmcli/package-json/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@npmcli/package-json/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/@npmcli/package-json/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/package-json/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@npmcli/promise-spawn": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-8.0.2.tgz", - "integrity": "sha512-/bNJhjc+o6qL+Dwz/bqfTQClkEO5nTQ1ZEcdCkAQjhkZMHIh22LPG7fNh1enJP1NKWDqYiiABnjFCY7E0zHYtQ==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-8.0.3.tgz", + "integrity": "sha512-Yb00SWaL4F8w+K8YGhQ55+xE4RUNdMHV43WZGsiTM92gS+lC0mGsn7I4hLug7pbao035S6bj3Y3w0cUNGLfmkg==", "dev": true, "license": "ISC", "dependencies": { @@ -7045,6 +7109,20 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@polka/send-type": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@polka/send-type/-/send-type-0.5.2.tgz", + "integrity": "sha512-jGXalKihnhGQmMQ+xxfxrRfI2cWs38TIZuwgYpnbQDD4r9TkOiU3ocjAS+6CqqMNQNAu9Ul2iHU5YFRDODak2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@polka/url": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-0.5.0.tgz", + "integrity": "sha512-oZLYFEAzUKyi3SKnXvj32ZCEGH6RDnao7COuCVhDydMS9NrCSVXhM79VaKyP5+Zc33m0QXEd2DN3UkU7OsHcfw==", + "dev": true, + "license": "MIT" + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -7226,6 +7304,20 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz", + "integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz", @@ -7255,9 +7347,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz", - "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz", + "integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==", "cpu": [ "ppc64" ], @@ -7266,8 +7358,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.34.8", @@ -7284,9 +7375,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz", - "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz", + "integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==", "cpu": [ "riscv64" ], @@ -7295,8 +7386,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.34.8", @@ -7340,6 +7430,20 @@ "linux" ] }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz", + "integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz", @@ -7368,6 +7472,20 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz", + "integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz", @@ -7390,14 +7508,14 @@ "license": "MIT" }, "node_modules/@schematics/angular": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.2.15.tgz", - "integrity": "sha512-dz/eoFQKG09POSygpEDdlCehFIMo35HUM2rVV8lx9PfQEibpbGwl1NNQYEbqwVjTyCyD/ILyIXCWPE+EfTnG4g==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.2.17.tgz", + "integrity": "sha512-FGNDJXjeCoYz3JMze3JReNOGkPsxLGs5LmLf5Zj3+BYYMsFoDUJD9BMRRf+tmc/epBkiZZtv80c8gmfhqGv4dA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.15", - "@angular-devkit/schematics": "19.2.15", + "@angular-devkit/core": "19.2.17", + "@angular-devkit/schematics": "19.2.17", "jsonc-parser": "3.3.1" }, "engines": { @@ -7406,6 +7524,101 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.15.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.15.0.tgz", + "integrity": "sha512-hJxo6rj3cMqiYlZd6PC8o/i2FG6hRnZdHcJkfm1HXgWCRgdCPilKghL6WU+B2H5dLyRKJ17nWjDAVQPRdCxO9w==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.15.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.15.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.15.0.tgz", + "integrity": "sha512-EP+NvdU9yfmepGzQwz0jnqhd0DBxHzrP16TsJIVXJe93QJ+gumdN3XQ0lvYtEC9zHuU08DghRLjfI1kLRfGzdQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.15.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.15.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.15.0.tgz", + "integrity": "sha512-vHBAFVdDfa51oqPWyRCK4fOIFhFeE2mVlqBWrBb+S3vCNcmtpvqJUq6o4sjSYcQzdZQpMSp5/Lj8Y3a8x/ed7w==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.15.0", + "@sentry/core": "10.15.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.15.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.15.0.tgz", + "integrity": "sha512-SXgUWArk+haUJ24W6pIm9IiwmIk3WxeQyFUxFfMUetSRb06CVAoNjPb0YuzKIeuFYJb6hDPGQ9UWhShnQpTmkw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.15.0", + "@sentry/core": "10.15.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/angular": { + "version": "10.15.0", + "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-10.15.0.tgz", + "integrity": "sha512-HXnqABIzAbGO5YFNwAyt/5RvdFw7pLU6RufXDIkBpUK/daQqSzgHfybjOTGpK2Jx2JJg7JJtv/dWUMn2t4IS6Q==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.15.0", + "@sentry/core": "10.15.0", + "tslib": "^2.4.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@angular/common": ">= 14.x <= 20.x", + "@angular/core": ">= 14.x <= 20.x", + "@angular/router": ">= 14.x <= 20.x", + "rxjs": "^6.5.5 || ^7.x" + } + }, + "node_modules/@sentry/browser": { + "version": "10.15.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.15.0.tgz", + "integrity": "sha512-YV42VgW7xdmY23u7+nQLNJXDVilNTP0d5WWkHDxeI/uD6AAvn3GyKjx1YMG/KCulxva3dPDPEUunzDm3al26Sw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.15.0", + "@sentry-internal/feedback": "10.15.0", + "@sentry-internal/replay": "10.15.0", + "@sentry-internal/replay-canvas": "10.15.0", + "@sentry/core": "10.15.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/core": { + "version": "10.15.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.15.0.tgz", + "integrity": "sha512-J7WsQvb9G6nsVgWkTHwyX7wR2djtEACYCx19hAnRbSGIg+ysVG+7Ti3RL4bz9/VXfcxsz346cleKc7ljhynYlQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@sigstore/bundle": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-3.1.0.tgz", @@ -7572,16 +7785,41 @@ "node": ">= 10" } }, + "node_modules/@traptitech/markdown-it-katex": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@traptitech/markdown-it-katex/-/markdown-it-katex-3.6.0.tgz", + "integrity": "sha512-CnJzTWxsgLGXFdSrWRaGz7GZ1kUUi8g3E9HzJmeveX1YwVJavrKYqysktfHZQsujdnRqV5O7g8FPKEA/aeTkOQ==", + "license": "MIT", + "dependencies": { + "katex": "^0.16.0" + } + }, "node_modules/@ts-morph/common": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.25.0.tgz", - "integrity": "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", + "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==", "dev": true, "license": "MIT", "dependencies": { - "minimatch": "^9.0.4", - "path-browserify": "^1.0.1", - "tinyglobby": "^0.2.9" + "fast-glob": "^3.3.3", + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@tufjs/canonical-json": { @@ -7901,19 +8139,19 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.2.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz", - "integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==", + "version": "24.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", + "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.10.0" + "undici-types": "~7.12.0" } }, "node_modules/@types/node-forge": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.13.tgz", - "integrity": "sha512-zePQJSW5QkwSHKRApqWCVKeKoSOt4xvEnLENZPjyvm9Ezdf/EyDeJM7jqLzOwjVICQQzvLZ63T55MKdJB5H6ww==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", + "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", "dev": true, "license": "MIT", "dependencies": { @@ -8295,14 +8533,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.1.tgz", - "integrity": "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.44.1.tgz", + "integrity": "sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.39.1", - "@typescript-eslint/types": "^8.39.1", + "@typescript-eslint/tsconfig-utils": "^8.44.1", + "@typescript-eslint/types": "^8.44.1", "debug": "^4.3.4" }, "engines": { @@ -8317,14 +8555,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.1.tgz", - "integrity": "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.44.1.tgz", + "integrity": "sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/visitor-keys": "8.39.1" + "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/visitor-keys": "8.44.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8335,9 +8573,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz", - "integrity": "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.44.1.tgz", + "integrity": "sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ==", "dev": true, "license": "MIT", "engines": { @@ -8490,9 +8728,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz", - "integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.44.1.tgz", + "integrity": "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ==", "dev": true, "license": "MIT", "engines": { @@ -8504,16 +8742,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz", - "integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.44.1.tgz", + "integrity": "sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.39.1", - "@typescript-eslint/tsconfig-utils": "8.39.1", - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/visitor-keys": "8.39.1", + "@typescript-eslint/project-service": "8.44.1", + "@typescript-eslint/tsconfig-utils": "8.44.1", + "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/visitor-keys": "8.44.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -8533,16 +8771,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.1.tgz", - "integrity": "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.44.1.tgz", + "integrity": "sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.39.1", - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/typescript-estree": "8.39.1" + "@typescript-eslint/scope-manager": "8.44.1", + "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/typescript-estree": "8.44.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8557,13 +8795,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz", - "integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==", + "version": "8.44.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.44.1.tgz", + "integrity": "sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/types": "8.44.1", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -8825,9 +9063,9 @@ } }, "node_modules/ace-builds": { - "version": "1.43.2", - "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.2.tgz", - "integrity": "sha512-3wzJUJX0RpMc03jo0V8Q3bSb/cKPnS7Nqqw8fVHsCCHweKMiTIxT3fP46EhjmVy6MCuxwP801ere+RW245phGw==", + "version": "1.43.3", + "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.3.tgz", + "integrity": "sha512-MCl9rALmXwIty/4Qboijo/yNysx1r6hBTzG+6n/TiOm5LFhZpEvEIcIITPFiEOEFDfgBOEmxu+a4f54LEFM6Sg==", "license": "BSD-3-Clause" }, "node_modules/acorn": { @@ -8987,23 +9225,108 @@ "typescript-eslint": "^8.0.0" } }, - "node_modules/angularx-qrcode": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/angularx-qrcode/-/angularx-qrcode-19.0.0.tgz", - "integrity": "sha512-uH1gO/X1hgSojZwgO3EmaXP+MvWCgZm5WGh3y1ZL2+VMstEGEMtJGZTyR645fB7ABF2ZIBUMB9h/SKvGJQX/zQ==", + "node_modules/angular-eslint/node_modules/@angular-eslint/bundled-angular-compiler": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-19.1.0.tgz", + "integrity": "sha512-HUJyukRvnh8Z9lIdxdblBRuBaPYEVv4iAYZMw3d+dn4rrM27Nt5oh3/zkwYrrPkt36tZdeXdDWrOuz9jgjVN5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/angular-eslint/node_modules/@angular-eslint/eslint-plugin": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-19.1.0.tgz", + "integrity": "sha512-TDO0+Ry+oNkxnaLHogKp1k2aey6IkJef5d7hathE4UFT6owjRizltWaRoX6bGw7Qu1yagVLL8L2Se8SddxSPAQ==", "dev": true, "license": "MIT", "dependencies": { - "qrcode": "1.5.4", - "tslib": "^2.3.0" + "@angular-eslint/bundled-angular-compiler": "19.1.0", + "@angular-eslint/utils": "19.1.0" }, "peerDependencies": { - "@angular/core": "^19.0.0" + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" } }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "node_modules/angular-eslint/node_modules/@angular-eslint/eslint-plugin-template": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-19.1.0.tgz", + "integrity": "sha512-bIUizkCY40mnU8oAO1tLV7uN2H/cHf1evLlhpqlb9JYwc5dT2moiEhNDo61OtOgkJmDGNuThAeO9Xk9hGQc7nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "19.1.0", + "@angular-eslint/utils": "19.1.0", + "aria-query": "5.3.2", + "axobject-query": "4.1.0" + }, + "peerDependencies": { + "@typescript-eslint/types": "^7.11.0 || ^8.0.0", + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/angular-eslint/node_modules/@angular-eslint/template-parser": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-19.1.0.tgz", + "integrity": "sha512-wbMi7adlC+uYqZo7NHNBShpNhFJRZsXLqihqvFpAUt1Ei6uDX8HR6MyMEDZ9tUnlqtPVW5nmbedPyLVG7HkjAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "19.1.0", + "eslint-scope": "^8.0.2" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/angular-eslint/node_modules/@angular-eslint/utils": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-19.1.0.tgz", + "integrity": "sha512-mcb7hPMH/u6wwUwvsewrmgb9y9NWN6ZacvpUvKlTOxF/jOtTdsu0XfV4YB43sp2A8NWzYzX0Str4c8K1xSmuBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "19.1.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/angular-google-tag-manager": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/angular-google-tag-manager/-/angular-google-tag-manager-1.11.0.tgz", + "integrity": "sha512-r9sHS+LO9LUoQsiqPo05yTfGRpA3oODc/0AmL0QA1SbeboHKBkCRZIUHkv5w6+GGmWR/G+ZR52eHNLWcgTwIAA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.5.0" + }, + "peerDependencies": { + "@angular/common": "^19.0.0", + "@angular/compiler": "^19.0.0" + } + }, + "node_modules/angularx-qrcode": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/angularx-qrcode/-/angularx-qrcode-19.0.0.tgz", + "integrity": "sha512-uH1gO/X1hgSojZwgO3EmaXP+MvWCgZm5WGh3y1ZL2+VMstEGEMtJGZTyR645fB7ABF2ZIBUMB9h/SKvGJQX/zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "qrcode": "1.5.4", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": "^19.0.0" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", "dev": true, "license": "MIT", @@ -9041,9 +9364,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -9623,6 +9946,16 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.9.tgz", + "integrity": "sha512-hY/u2lxLrbecMEWSB0IpGzGyDyeoMFQhCvZd2jGFSE5I17Fh01sYUBPCJtkWERw7zrac9+cIghxm/ytJa2X8iA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -9636,13 +9969,6 @@ "node": ">= 0.8" } }, - "node_modules/basic-auth/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, "node_modules/batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", @@ -9713,60 +10039,39 @@ } }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", "dev": true, "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" + "node": ">=18" } }, "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" } }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, "node_modules/bonjour-service": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", @@ -9786,9 +10091,9 @@ "license": "ISC" }, "node_modules/bootstrap": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz", - "integrity": "sha512-7KgiD8UHjfcPBHEpDNg+zGz8L3LqR3GVwqZiBRFX04a1BCArZOz1r2kjly2HQ0WokqTO0v1nF+QAt8dsW4lKlw==", + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", "funding": [ { "type": "github", @@ -9806,9 +10111,9 @@ } }, "node_modules/bootstrap.native": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/bootstrap.native/-/bootstrap.native-5.1.5.tgz", - "integrity": "sha512-sQdFng2Szpseyo1TlpG5pV+se4nbGeQWFXBemsPSnrVzd82ps9F6hti+lHFwcGgS80oIc54dY5ycOYJwUpQn3A==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/bootstrap.native/-/bootstrap.native-5.1.6.tgz", + "integrity": "sha512-bLveDBWhNLoFLsPctVo6yxSRQ1ysmKHBa+1FFMTQuruzTb3y7/InGSoe5lZdOiqZ4L0UOzpdbXMsI+bA5DoRew==", "dev": true, "license": "MIT", "dependencies": { @@ -9845,9 +10150,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz", - "integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==", + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", + "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", "dev": true, "funding": [ { @@ -9865,9 +10170,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001733", - "electron-to-chromium": "^1.5.199", - "node-releases": "^2.0.19", + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001741", + "electron-to-chromium": "^1.5.218", + "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { @@ -10005,6 +10311,43 @@ "node": ">=18" } }, + "node_modules/cacache/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/cacache/node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -10012,34 +10355,34 @@ "dev": true, "license": "ISC" }, - "node_modules/cacache/node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "node_modules/cacache/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/cacache/node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", + "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", "dev": true, "license": "ISC", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", + "minizlib": "^3.1.0", "yallist": "^5.0.0" }, "engines": { @@ -10136,9 +10479,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001734", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001734.tgz", - "integrity": "sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==", + "version": "1.0.30001745", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", + "integrity": "sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==", "dev": true, "funding": [ { @@ -10163,15 +10506,15 @@ "license": "ISC" }, "node_modules/cedar-embeddable-editor": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/cedar-embeddable-editor/-/cedar-embeddable-editor-1.5.0.tgz", - "integrity": "sha512-yZb/lmk+qUFmms/Ku6L+SPEDEE98d0+957JSsHqeR+EsvSu9D5IXYap4GEKWTHMUIsokJ3/Oj5UCxzFHlDJGzA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cedar-embeddable-editor/-/cedar-embeddable-editor-1.2.2.tgz", + "integrity": "sha512-GuwFKwz52JRl0ZwJMWl/cHn2gj5jtP9b8QMsYv8ulQClTUNn6FCDPWsQmBfPAhG+3NMtT3+jkm169C36itJmTA==", "license": "ISC" }, "node_modules/chalk": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz", - "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, "license": "MIT", "engines": { @@ -10211,22 +10554,26 @@ } }, "node_modules/cheerio": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", - "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz", + "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==", "dev": true, "license": "MIT", "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "htmlparser2": "^8.0.1", - "parse5": "^7.0.0", - "parse5-htmlparser2-tree-adapter": "^7.0.0" + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.0.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.12.0", + "whatwg-mimetype": "^4.0.0" }, "engines": { - "node": ">= 6" + "node": ">=20.18.1" }, "funding": { "url": "https://github.com/cheeriojs/cheerio?sponsor=1" @@ -10250,26 +10597,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/cheerio/node_modules/htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", - "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -10376,9 +10703,9 @@ } }, "node_modules/cli-truncate/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", "dev": true, "license": "MIT" }, @@ -10638,13 +10965,13 @@ } }, "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz", + "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/common-path-prefix": { @@ -10724,6 +11051,27 @@ "node": ">= 0.6" } }, + "node_modules/compression/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -10767,35 +11115,6 @@ "ms": "2.0.0" } }, - "node_modules/connect/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/connect/node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "statuses": "~1.5.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/connect/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -10803,29 +11122,6 @@ "dev": true, "license": "MIT" }, - "node_modules/connect/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/connect/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -10839,6 +11135,27 @@ "node": ">= 0.6" } }, + "node_modules/content-disposition/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", @@ -10957,13 +11274,13 @@ } }, "node_modules/core-js-compat": { - "version": "3.45.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.0.tgz", - "integrity": "sha512-gRoVMBawZg0OnxaVv3zpqLLxaHmsubEGyTnqdpI/CEBvX4JadI1dMSHxagThprYRtSVbuQxvi6iUatdPxohHpA==", + "version": "3.45.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.1.tgz", + "integrity": "sha512-tqTt5T4PzsMIZ430XGviK4vzYSoeNJ6CXODi6c/voxOT6IZqBht5/EKaSNnYiEjjRYxjVz7DQIsOsY0XNi8PIA==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.25.1" + "browserslist": "^4.25.3" }, "funding": { "type": "opencollective", @@ -11240,6 +11557,16 @@ "node": ">=12" } }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -11295,9 +11622,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -11340,9 +11667,9 @@ "license": "MIT" }, "node_modules/dedent": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", - "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -11495,9 +11822,9 @@ } }, "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", + "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -11720,9 +12047,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.200", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz", - "integrity": "sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==", + "version": "1.5.227", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.227.tgz", + "integrity": "sha512-ITxuoPfJu3lsNWUi2lBM2PaBPYgH3uqmxut5vmBxgYvyI4AlJ6P3Cai1O76mOrkJCBzq0IxWg/NtqOrpu/0gKA==", "dev": true, "license": "ISC" }, @@ -11766,9 +12093,9 @@ } }, "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", "dev": true, "license": "MIT", "engines": { @@ -11786,6 +12113,47 @@ "iconv-lite": "^0.6.2" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -11867,9 +12235,9 @@ } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12157,19 +12525,19 @@ } }, "node_modules/eslint": { - "version": "9.33.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz", - "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", + "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.33.0", + "@eslint/js": "9.36.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -12403,9 +12771,9 @@ } }, "node_modules/eslint-plugin-unused-imports": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.1.4.tgz", - "integrity": "sha512-YptD6IzQjDardkl0POxnnRBhU1OEePMV0nd6siHaRBbd+lyh6NAhFEobiznKU7kTsSsDeSD62Pe7kAM1b7dAZQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.2.0.tgz", + "integrity": "sha512-hLbJ2/wnjKq4kGA9AUaExVFIbNzyxYdVo49QZmKCnhk5pc9wcYRbfgLHvWJ8tnsdcseGhoUAddm9gn/lt+d74w==", "dev": true, "license": "MIT", "peerDependencies": { @@ -12856,6 +13224,31 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express/node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -12866,13 +13259,187 @@ "ms": "2.0.0" } }, - "node_modules/express/node_modules/ms": { + "node_modules/express/node_modules/debug/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true, "license": "MIT" }, + "node_modules/express/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/express/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/express/node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/express/node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express/node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fancy-log": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-2.0.0.tgz", @@ -12945,9 +13512,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "dev": true, "funding": [ { @@ -12995,11 +13562,14 @@ } }, "node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -13059,18 +13629,18 @@ } }, "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", "dev": true, "license": "MIT", "dependencies": { "debug": "2.6.9", - "encodeurl": "~2.0.0", + "encodeurl": "~1.0.2", "escape-html": "~1.0.3", - "on-finished": "2.4.1", + "on-finished": "~2.3.0", "parseurl": "~1.3.3", - "statuses": "2.0.1", + "statuses": "~1.5.0", "unpipe": "~1.0.0" }, "engines": { @@ -13094,6 +13664,19 @@ "dev": true, "license": "MIT" }, + "node_modules/finalhandler/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/find-cache-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", @@ -13266,13 +13849,13 @@ } }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/from": { @@ -13283,9 +13866,9 @@ "license": "MIT" }, "node_modules/fs-extra": { - "version": "11.3.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", - "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", "dev": true, "license": "MIT", "dependencies": { @@ -13297,16 +13880,6 @@ "node": ">=14.14" } }, - "node_modules/fs-extra/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/fs-minipass": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", @@ -13404,9 +13977,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", - "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", "dev": true, "license": "MIT", "engines": { @@ -13515,22 +14088,25 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", "dev": true, "license": "ISC", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" }, + "engines": { + "node": "20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -13548,6 +14124,23 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regex.js": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.0.1.tgz", + "integrity": "sha512-CG/iEvgQqfzoVsMUbxSJcwbG2JwyZ3naEqPkeltwl0BSS8Bp83k3xlGms+0QdWFUAwV+uvo80wNswKF6FWEkKg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", @@ -13555,6 +14148,22 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/global-directory": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", @@ -13887,13 +14496,6 @@ "util-deprecate": "~1.0.1" } }, - "node_modules/hpack.js/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, "node_modules/hpack.js/node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -13917,6 +14519,32 @@ "node": ">=12" } }, + "node_modules/html-encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/html-encoding-sniffer/node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", @@ -14000,6 +14628,16 @@ "node": ">=8" } }, + "node_modules/http-auth/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -14031,6 +14669,16 @@ "node": ">= 0.8" } }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/http-parser-js": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", @@ -14150,9 +14798,9 @@ } }, "node_modules/i18next": { - "version": "23.16.8", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", - "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==", + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.5.2.tgz", + "integrity": "sha512-lW8Zeh37i/o0zVr+NoCHfNnfvVw+M6FQbRp36ZZ/NyHDJ3NJVpp2HhAUyU9WafL5AssymNoOjMRB48mmx2P6Hw==", "dev": true, "funding": [ { @@ -14170,13 +14818,31 @@ ], "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.2" + "@babel/runtime": "^7.27.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next/node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "dev": true, "license": "MIT", "dependencies": { @@ -14184,6 +14850,10 @@ }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/icss-utils": { @@ -14391,9 +15061,9 @@ } }, "node_modules/import-meta-resolve": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", - "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", "dev": true, "license": "MIT", "funding": { @@ -14783,9 +15453,9 @@ } }, "node_modules/is-network-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.1.0.tgz", - "integrity": "sha512-tUdRRAnhT+OtCZR/LxZelH/C7QtjtFrTu5tXCA8pl55eTUElUHT+GPYV8MBMBvea/j+NxQqVt3LbWMRir7Gx9g==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", + "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", "dev": true, "license": "MIT", "engines": { @@ -15161,9 +15831,9 @@ } }, "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -15175,19 +15845,19 @@ } }, "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, + "engines": { + "node": "20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/jake": { @@ -16492,9 +17162,9 @@ } }, "node_modules/jiti": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", - "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", + "integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==", "dev": true, "license": "MIT", "bin": { @@ -16594,6 +17264,42 @@ "node": ">= 6" } }, + "node_modules/jsdom/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jsdom/node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -16671,16 +17377,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/jsonfile/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", @@ -16718,6 +17414,31 @@ "source-map-support": "^0.5.5" } }, + "node_modules/katex": { + "version": "0.16.22", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", + "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", + "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/keycharm": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/keycharm/-/keycharm-0.2.0.tgz", @@ -17115,9 +17836,9 @@ } }, "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { @@ -17128,9 +17849,9 @@ } }, "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", "dev": true, "license": "MIT" }, @@ -17160,9 +17881,9 @@ } }, "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { @@ -17395,9 +18116,9 @@ } }, "node_modules/log-update/node_modules/ansi-escapes": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", - "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.1.1.tgz", + "integrity": "sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q==", "dev": true, "license": "MIT", "dependencies": { @@ -17411,9 +18132,9 @@ } }, "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { @@ -17424,20 +18145,20 @@ } }, "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", "dev": true, "license": "MIT" }, "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", - "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.0.0" + "get-east-asian-width": "^1.3.1" }, "engines": { "node": ">=18" @@ -17447,9 +18168,9 @@ } }, "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", - "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "dev": true, "license": "MIT", "dependencies": { @@ -17482,9 +18203,9 @@ } }, "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { @@ -17659,6 +18380,19 @@ "node": ">= 16" } }, + "node_modules/matchit": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/matchit/-/matchit-1.1.0.tgz", + "integrity": "sha512-+nGYoOlfHmxe5BW5tE0EMJppXEwdSf8uBA1GTZC7Q77kbT35+VKLYJMzVNWCHSsga1ps1tPYFtFyvxvKzWVmMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@arr/every": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -17676,30 +18410,29 @@ "license": "MIT" }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/memfs": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.36.0.tgz", - "integrity": "sha512-mfBfzGUdoEw5AZwG8E965ej3BbvW2F9LxEWj4uLxF6BEh1dO2N9eS3AGu9S6vfenuQYrVjsbUOOZK7y3vz4vyQ==", + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.47.0.tgz", + "integrity": "sha512-Xey8IZA57tfotV/TN4d6BmccQuhFP+CqRiI7TTNdipZdZBzF2WnzUcH//Cudw6X4zJiUbo/LTuU/HPA/iC/pNg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/json-pack": "^1.0.3", - "@jsonjoy.com/util": "^1.3.0", - "tree-dump": "^1.0.1", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", "tslib": "^2.0.0" }, - "engines": { - "node": ">= 4.0.0" - }, "funding": { "type": "github", "url": "https://github.com/sponsors/streamich" @@ -18036,9 +18769,9 @@ "license": "ISC" }, "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dev": true, "license": "MIT", "dependencies": { @@ -18237,6 +18970,20 @@ "node": ">= 4.4.x" } }, + "node_modules/needle/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -18339,9 +19086,9 @@ } }, "node_modules/node-gyp": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.3.0.tgz", - "integrity": "sha512-9J0+C+2nt3WFuui/mC46z2XCZ21/cKlFDuywULmseD/LlmnOrSeEAE4c/1jw6aybXLmpZnQY3/LmOJfgyHIcng==", + "version": "11.4.2", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.4.2.tgz", + "integrity": "sha512-3gD+6zsrLQH7DyYOUIutaauuXrcyxeTPyQuZQCQoNPZMHMMS5m4y0xclNpvYzoK3VNzuyxT6eF4mkIL4WSZ1eQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18399,34 +19146,17 @@ "node": ">=16" } }, - "node_modules/node-gyp/node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/node-gyp/node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", + "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", "dev": true, "license": "ISC", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", + "minizlib": "^3.1.0", "yallist": "^5.0.0" }, "engines": { @@ -18467,9 +19197,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", "dev": true, "license": "MIT" }, @@ -18523,9 +19253,9 @@ } }, "node_modules/npm-install-checks": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-7.1.1.tgz", - "integrity": "sha512-u6DCwbow5ynAX5BdiHQ9qvexme4U3qHW3MWe5NqH+NeBm0LbiH6zvGjNNew1fY+AZZUtVHbOPF3j7mJxbUzpXg==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-7.1.2.tgz", + "integrity": "sha512-z9HJBCYw9Zr8BqXcllKIs5nI+QggAImbBdHphOzVYrz2CB4iQ6FzWyKmlqDZua+51nAu7FcemlbTc9VgQN5XDQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -18637,9 +19367,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.21", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.21.tgz", - "integrity": "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==", + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", + "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", "dev": true, "license": "MIT" }, @@ -19251,6 +19981,19 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parse5-sax-parser": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-7.0.0.tgz", @@ -19331,28 +20074,31 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", "dev": true, - "license": "ISC" + "license": "ISC", + "engines": { + "node": "20 || >=22" + } }, "node_modules/path-to-regexp": { "version": "0.1.12", @@ -19494,6 +20240,17 @@ "node": ">=10.13.0" } }, + "node_modules/polka": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/polka/-/polka-0.5.2.tgz", + "integrity": "sha512-FVg3vDmCqP80tOrs+OeNlgXYmFppTXdjD5E7I4ET1NjvtNmQrb1/mJibybKkb/d4NA7YWAr1ojxuhpL3FHqdlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^0.5.0", + "trouter": "^2.0.1" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -20116,13 +20873,13 @@ } }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -20180,32 +20937,19 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", "dev": true, "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", - "iconv-lite": "0.4.24", + "iconv-lite": "0.7.0", "unpipe": "1.0.0" }, "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" + "node": ">= 0.10" } }, "node_modules/react-is": { @@ -20282,9 +21026,9 @@ "license": "MIT" }, "node_modules/regenerate-unicode-properties": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", - "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", "dev": true, "license": "MIT", "dependencies": { @@ -20330,18 +21074,18 @@ } }, "node_modules/regexpu-core": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", - "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", "dev": true, "license": "MIT", "dependencies": { "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.0", + "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", - "regjsparser": "^0.12.0", + "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.1.0" + "unicode-match-property-value-ecmascript": "^2.2.1" }, "engines": { "node": ">=4" @@ -20355,31 +21099,18 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", - "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "jsesc": "~3.0.2" + "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, - "node_modules/regjsparser/node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -20678,9 +21409,9 @@ "license": "MIT" }, "node_modules/run-applescript": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", - "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", "dev": true, "license": "MIT", "engines": { @@ -20744,24 +21475,10 @@ } }, "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], "license": "MIT" }, "node_modules/safe-push-apply": { @@ -20962,51 +21679,65 @@ } }, "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", "dev": true, "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" } }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "dev": true, "license": "MIT", - "dependencies": { - "ms": "2.0.0" + "engines": { + "node": ">= 0.8" } }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">= 0.6" + } }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "node_modules/send/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "dev": true, "license": "MIT", "engines": { @@ -21099,30 +21830,102 @@ "dev": true, "license": "ISC" }, - "node_modules/serve-index/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-static/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static/node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, - "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "node_modules/serve-static/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "dev": true, "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" - }, "engines": { - "node": ">= 0.8.0" + "node": ">= 0.8" } }, "node_modules/set-blocking": { @@ -21344,6 +22147,28 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/sirv/node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -21382,9 +22207,9 @@ } }, "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { @@ -21417,6 +22242,16 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", @@ -21755,6 +22590,19 @@ "webpack": "^5.72.1" } }, + "node_modules/source-map-loader/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -21911,13 +22759,26 @@ } }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 0.6" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/stop-iteration-iterator": { @@ -21955,6 +22816,27 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -22136,9 +23018,9 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "license": "MIT", "dependencies": { @@ -22293,13 +23175,17 @@ } }, "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tar": { @@ -22622,14 +23508,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -22638,6 +23524,19 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -22668,6 +23567,16 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", @@ -22684,6 +23593,16 @@ "node": ">=6" } }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/tr46": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", @@ -22698,9 +23617,9 @@ } }, "node_modules/tree-dump": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.3.tgz", - "integrity": "sha512-il+Cv80yVHFBwokQSfd4bldvr1Md951DpgAGfmhydt04L+YzHgubm2tQ7zueWDcGENKHq0ZvGFR/hjvNXilHEg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -22724,6 +23643,19 @@ "tree-kill": "cli.js" } }, + "node_modules/trouter": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/trouter/-/trouter-2.0.1.tgz", + "integrity": "sha512-kr8SKKw94OI+xTGOkfsvwZQ8mWoikZDd2n8XZHjJVZUARZT+4/VV6cacRS6CLsH9bNm+HFIPU1Zx4CnNnb4qlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "matchit": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -22738,9 +23670,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.1", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.1.tgz", - "integrity": "sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==", + "version": "29.4.4", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.4.tgz", + "integrity": "sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw==", "dev": true, "license": "MIT", "dependencies": { @@ -22817,13 +23749,13 @@ } }, "node_modules/ts-morph": { - "version": "24.0.0", - "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-24.0.0.tgz", - "integrity": "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==", + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", + "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==", "dev": true, "license": "MIT", "dependencies": { - "@ts-morph/common": "~0.25.0", + "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, @@ -22921,14 +23853,38 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "dev": true, "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" }, "engines": { "node": ">= 0.6" @@ -23209,10 +24165,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", + "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", "dev": true, "license": "MIT" }, @@ -23241,9 +24207,9 @@ } }, "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", - "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", "dev": true, "license": "MIT", "engines": { @@ -23251,9 +24217,9 @@ } }, "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", - "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", "dev": true, "license": "MIT", "engines": { @@ -23300,13 +24266,13 @@ } }, "node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 4.0.0" + "node": ">= 10.0.0" } }, "node_modules/unix-crypt-td-js": { @@ -23396,13 +24362,17 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-to-istanbul": { @@ -23474,12 +24444,11 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", + "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -23550,9 +24519,9 @@ } }, "node_modules/vite/node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz", - "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz", + "integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==", "cpu": [ "arm" ], @@ -23561,13 +24530,12 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/vite/node_modules/@rollup/rollup-android-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz", - "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz", + "integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==", "cpu": [ "arm64" ], @@ -23576,13 +24544,12 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/vite/node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz", - "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz", + "integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==", "cpu": [ "arm64" ], @@ -23591,13 +24558,12 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/vite/node_modules/@rollup/rollup-darwin-x64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz", - "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz", + "integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==", "cpu": [ "x64" ], @@ -23606,13 +24572,12 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/vite/node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz", - "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz", + "integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==", "cpu": [ "arm64" ], @@ -23621,13 +24586,12 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/vite/node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz", - "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz", + "integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==", "cpu": [ "x64" ], @@ -23636,13 +24600,12 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz", - "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz", + "integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==", "cpu": [ "arm" ], @@ -23651,13 +24614,12 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz", - "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz", + "integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==", "cpu": [ "arm" ], @@ -23666,13 +24628,12 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz", - "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz", + "integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==", "cpu": [ "arm64" ], @@ -23681,13 +24642,12 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz", - "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz", + "integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==", "cpu": [ "arm64" ], @@ -23696,28 +24656,12 @@ "optional": true, "os": [ "linux" - ], - "peer": true - }, - "node_modules/vite/node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz", - "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true + ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz", - "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz", + "integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==", "cpu": [ "riscv64" ], @@ -23726,13 +24670,12 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz", - "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz", + "integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==", "cpu": [ "s390x" ], @@ -23741,13 +24684,12 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz", - "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz", + "integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==", "cpu": [ "x64" ], @@ -23756,13 +24698,12 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz", - "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz", + "integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==", "cpu": [ "x64" ], @@ -23771,13 +24712,12 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/vite/node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz", - "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz", + "integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==", "cpu": [ "arm64" ], @@ -23786,13 +24726,12 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/vite/node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz", - "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz", + "integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==", "cpu": [ "ia32" ], @@ -23801,13 +24740,12 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/vite/node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz", - "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz", + "integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==", "cpu": [ "x64" ], @@ -23816,8 +24754,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/vite/node_modules/postcss": { "version": "8.5.6", @@ -23839,7 +24776,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -23850,12 +24786,11 @@ } }, "node_modules/vite/node_modules/rollup": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", - "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", + "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -23867,26 +24802,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.46.2", - "@rollup/rollup-android-arm64": "4.46.2", - "@rollup/rollup-darwin-arm64": "4.46.2", - "@rollup/rollup-darwin-x64": "4.46.2", - "@rollup/rollup-freebsd-arm64": "4.46.2", - "@rollup/rollup-freebsd-x64": "4.46.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", - "@rollup/rollup-linux-arm-musleabihf": "4.46.2", - "@rollup/rollup-linux-arm64-gnu": "4.46.2", - "@rollup/rollup-linux-arm64-musl": "4.46.2", - "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", - "@rollup/rollup-linux-ppc64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-musl": "4.46.2", - "@rollup/rollup-linux-s390x-gnu": "4.46.2", - "@rollup/rollup-linux-x64-gnu": "4.46.2", - "@rollup/rollup-linux-x64-musl": "4.46.2", - "@rollup/rollup-win32-arm64-msvc": "4.46.2", - "@rollup/rollup-win32-ia32-msvc": "4.46.2", - "@rollup/rollup-win32-x64-msvc": "4.46.2", + "@rollup/rollup-android-arm-eabi": "4.52.3", + "@rollup/rollup-android-arm64": "4.52.3", + "@rollup/rollup-darwin-arm64": "4.52.3", + "@rollup/rollup-darwin-x64": "4.52.3", + "@rollup/rollup-freebsd-arm64": "4.52.3", + "@rollup/rollup-freebsd-x64": "4.52.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.3", + "@rollup/rollup-linux-arm-musleabihf": "4.52.3", + "@rollup/rollup-linux-arm64-gnu": "4.52.3", + "@rollup/rollup-linux-arm64-musl": "4.52.3", + "@rollup/rollup-linux-loong64-gnu": "4.52.3", + "@rollup/rollup-linux-ppc64-gnu": "4.52.3", + "@rollup/rollup-linux-riscv64-gnu": "4.52.3", + "@rollup/rollup-linux-riscv64-musl": "4.52.3", + "@rollup/rollup-linux-s390x-gnu": "4.52.3", + "@rollup/rollup-linux-x64-gnu": "4.52.3", + "@rollup/rollup-linux-x64-musl": "4.52.3", + "@rollup/rollup-openharmony-arm64": "4.52.3", + "@rollup/rollup-win32-arm64-msvc": "4.52.3", + "@rollup/rollup-win32-ia32-msvc": "4.52.3", + "@rollup/rollup-win32-x64-gnu": "4.52.3", + "@rollup/rollup-win32-x64-msvc": "4.52.3", "fsevents": "~2.3.2" } }, @@ -24293,26 +25230,39 @@ } }, "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "dev": true, "license": "MIT", "dependencies": { "iconv-lite": "0.6.3" }, "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" } }, "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-url": { @@ -24909,9 +25859,9 @@ } }, "node_modules/yoctocolors-cjs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", - "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 533339fcc..e86d48ab3 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "ng": "ng", "analyze-bundle": "ng build --configuration=analyze-bundle && source-map-explorer dist/**/*.js --no-border-checks", "build": "ng build", - "ci": "npm run lint && jest --coverage", + "check:config": "node ./docker/check-config.js", + "ci:test": "jest", + "ci:test:coverage": "jest --coverage", "docs": "./node_modules/.bin/compodoc -p tsconfig.docs.json --name 'OSF Angular Documentation' --theme 'laravel' -s", "docs:coverage": "./node_modules/.bin/compodoc -p tsconfig.docs.json --coverageTest 0 --coverageMinimumPerFile 0", "lint": "ng lint", @@ -17,9 +19,11 @@ "ngxs:store": "ng generate @ngxs/store:store --name --path", "prepare": "husky", "start": "ng serve", - "start:docker": "ng serve --host 0.0.0.0 --port 4200 --poll 2000", - "start:docker:local": "ng serve --host 0.0.0.0 --port 4200 --poll 2000 --configuration local", - "test": "jest && npm run test:display", + "start:docker": "npm run check:config && ng serve --host 0.0.0.0 --port 4200 --poll 2000 --configuration development", + "start:docker:local": "npm run check:config && ng serve --host 0.0.0.0 --port 4200 --poll 2000 --configuration docker", + "start:test": "ng serve --configuration test-osf", + "start:test:future": "ng serve --configuration test", + "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage && npm run test:display", "test:check-coverage-thresholds": "node .github/scripts/check-coverage-thresholds.js", @@ -37,7 +41,6 @@ "@angular/platform-browser": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0", "@angular/router": "^19.2.0", - "@angular/service-worker": "^19.2.0", "@fortawesome/fontawesome-free": "^6.7.2", "@ngx-translate/core": "^16.0.4", "@ngx-translate/http-loader": "^16.0.1", @@ -45,9 +48,12 @@ "@ngxs/logger-plugin": "^19.0.0", "@ngxs/store": "^19.0.0", "@primeng/themes": "^19.0.9", + "@sentry/angular": "^10.10.0", + "@traptitech/markdown-it-katex": "^3.6.0", "ace-builds": "^1.42.0", + "angular-google-tag-manager": "^1.11.0", "cedar-artifact-viewer": "^0.9.5", - "cedar-embeddable-editor": "^1.5.0", + "cedar-embeddable-editor": "1.2.2", "chart.js": "^4.4.9", "diff": "^8.0.2", "markdown-it": "^14.1.0", diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 582a45a49..1d6eba8ad 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -1,10 +1,51 @@ import type gapi from 'gapi-script'; // or just `import gapi from 'gapi-script';` +/** + * Extends the global `Window` interface to include additional properties used by the application, + * such as Google APIs (`gapi`, `google.picker`) and the `dataLayer` for analytics or GTM integration. + */ declare global { interface Window { + /** + * Represents the Google API client library (`gapi`) attached to the global window. + * Used for OAuth, Picker API, Drive API, etc. + * + * @see https://developers.google.com/api-client-library/javascript/ + */ gapi: typeof gapi; + + /** + * Contains Google-specific UI services attached to the global window, + * such as the `google.picker` API. + * + * @see https://developers.google.com/picker/docs/ + */ google: { + /** + * Reference to the Google Picker API used for file selection and Drive integration. + */ picker: typeof google.picker; }; + + /** + * Global analytics `dataLayer` object used by Google Tag Manager (GTM). + * Can store custom application metadata for tracking and event push. + * + * @property resourceType - The type of resource currently being viewed (e.g., 'project', 'file', etc.) + * @property loggedIn - Indicates whether the user is currently authenticated. + */ + dataLayer: { + /** + * The type of content or context being viewed (e.g., "project", "node", etc.). + * Optional — may be undefined depending on when or where GTM initializes. + */ + resourceType: string | undefined; + + /** + * Indicates if the current user is authenticated. + * Used for segmenting analytics based on login state. + */ + loggedIn: boolean; + }; } } diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 3edc6c1e5..6b1e47073 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,52 +1,104 @@ import { provideStore, Store } from '@ngxs/store'; -import { MockComponents } from 'ng-mocks'; +import { MockComponents, MockProvider } from 'ng-mocks'; + +import { Subject } from 'rxjs'; -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { NavigationEnd, Router } from '@angular/router'; +import { CookieConsentBannerComponent } from '@core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; import { GetCurrentUser, UserState } from '@core/store/user'; import { UserEmailsState } from '@core/store/user-emails'; +import { CustomDialogService } from '@shared/services'; import { FullScreenLoaderComponent, ToastComponent } from './shared/components'; import { TranslateServiceMock } from './shared/mocks'; import { AppComponent } from './app.component'; -describe('AppComponent', () => { - let component: AppComponent; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { GoogleTagManagerService } from 'angular-google-tag-manager'; + +describe('Component: App', () => { + let routerEvents$: Subject; + let gtmServiceMock: jest.Mocked; let fixture: ComponentFixture; + let mockCustomDialogService: ReturnType; beforeEach(async () => { + mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); + routerEvents$ = new Subject(); + + gtmServiceMock = { + pushTag: jest.fn(), + } as any; + await TestBed.configureTestingModule({ - imports: [AppComponent, ...MockComponents(ToastComponent, FullScreenLoaderComponent)], + imports: [ + OSFTestingModule, + AppComponent, + ...MockComponents(ToastComponent, FullScreenLoaderComponent, CookieConsentBannerComponent), + ], providers: [ provideStore([UserState, UserEmailsState]), - provideHttpClient(), - provideHttpClientTesting(), + MockProvider(CustomDialogService, mockCustomDialogService), TranslateServiceMock, + { provide: GoogleTagManagerService, useValue: gtmServiceMock }, + { + provide: Router, + useValue: { + events: routerEvents$.asObservable(), + }, + }, ], }).compileComponents(); fixture = TestBed.createComponent(AppComponent); - component = fixture.componentInstance; - fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); + describe('detect changes', () => { + beforeEach(() => { + fixture.detectChanges(); + }); - it('should dispatch GetCurrentUser action on initialization', () => { - const store = TestBed.inject(Store); - const dispatchSpy = jest.spyOn(store, 'dispatch'); - store.dispatch(GetCurrentUser); - expect(dispatchSpy).toHaveBeenCalledWith(GetCurrentUser); + it('should dispatch GetCurrentUser action on initialization', () => { + const store = TestBed.inject(Store); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + store.dispatch(GetCurrentUser); + expect(dispatchSpy).toHaveBeenCalledWith(GetCurrentUser); + }); + + it('should render router outlet', () => { + const routerOutlet = fixture.debugElement.query(By.css('router-outlet')); + expect(routerOutlet).toBeTruthy(); + }); }); - it('should render router outlet', () => { - const routerOutlet = fixture.debugElement.query(By.css('router-outlet')); - expect(routerOutlet).toBeTruthy(); + describe('Google Tag Manager', () => { + it('should push GTM tag on NavigationEnd with google tag id', () => { + fixture.detectChanges(); + const event = new NavigationEnd(1, '/previous', '/current'); + + routerEvents$.next(event); + + expect(gtmServiceMock.pushTag).toHaveBeenCalledWith({ + event: 'page', + pageName: '/current', + }); + }); + + it('should not push GTM tag on NavigationEnd without google tag id', () => { + const environment = TestBed.inject(ENVIRONMENT); + environment.googleTagManagerId = ''; + fixture.detectChanges(); + const event = new NavigationEnd(1, '/previous', '/current'); + + routerEvents$.next(event); + + expect(gtmServiceMock.pushTag).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index ccf75a496..e1eb1045b 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,21 +1,20 @@ -import { createDispatchMap, select } from '@ngxs/store'; +import { Actions, createDispatchMap, ofActionSuccessful, select } from '@ngxs/store'; -import { TranslateService } from '@ngx-translate/core'; - -import { DialogService } from 'primeng/dynamicdialog'; - -import { filter } from 'rxjs/operators'; +import { filter, take } from 'rxjs'; import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; import { GetCurrentUser } from '@core/store/user'; import { GetEmails, UserEmailsSelectors } from '@core/store/user-emails'; import { ConfirmEmailComponent } from '@shared/components'; +import { CustomDialogService } from '@shared/services'; import { FullScreenLoaderComponent, ToastComponent } from './shared/components'; -import { MetaTagsService } from './shared/services/meta-tags.service'; + +import { GoogleTagManagerService } from 'angular-google-tag-manager'; @Component({ selector: 'osf-root', @@ -23,21 +22,19 @@ import { MetaTagsService } from './shared/services/meta-tags.service'; templateUrl: './app.component.html', styleUrl: './app.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [DialogService], }) export class AppComponent implements OnInit { + private readonly googleTagManagerService = inject(GoogleTagManagerService); private readonly destroyRef = inject(DestroyRef); - private readonly dialogService = inject(DialogService); + private readonly customDialogService = inject(CustomDialogService); private readonly router = inject(Router); - private readonly translateService = inject(TranslateService); - private readonly metaTagsService = inject(MetaTagsService); - + private readonly environment = inject(ENVIRONMENT); + private readonly actions$ = inject(Actions); private readonly actions = createDispatchMap({ getCurrentUser: GetCurrentUser, getEmails: GetEmails }); unverifiedEmails = select(UserEmailsSelectors.getUnverifiedEmails); constructor() { - this.setupMetaTagsCleanup(); effect(() => { if (this.unverifiedEmails().length) { this.showEmailDialog(); @@ -47,26 +44,32 @@ export class AppComponent implements OnInit { ngOnInit(): void { this.actions.getCurrentUser(); - this.actions.getEmails(); - } - private setupMetaTagsCleanup(): void { - this.router.events - .pipe( - filter((event) => event instanceof NavigationEnd), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe((event: NavigationEnd) => this.metaTagsService.clearMetaTagsIfNeeded(event.url)); + this.actions$.pipe(ofActionSuccessful(GetCurrentUser), take(1)).subscribe(() => { + this.actions.getEmails(); + }); + + if (this.environment.googleTagManagerId) { + this.router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((event: NavigationEnd) => { + this.googleTagManagerService.pushTag({ + event: 'page', + pageName: event.urlAfterRedirects, + }); + }); + } } private showEmailDialog() { - this.dialogService.open(ConfirmEmailComponent, { + const unverifiedEmailsData = this.unverifiedEmails(); + this.customDialogService.open(ConfirmEmailComponent, { + header: unverifiedEmailsData[0].isMerge ? 'home.confirmEmail.merge.title' : 'home.confirmEmail.add.title', width: '448px', - focusOnShow: false, - header: this.translateService.instant('home.confirmEmail.title'), - modal: true, - closable: false, - data: this.unverifiedEmails(), + data: unverifiedEmailsData, }); } } diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 4f619d1f6..0f53a5184 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -5,25 +5,36 @@ import { TranslateModule } from '@ngx-translate/core'; import { ConfirmationService, MessageService } from 'primeng/api'; import { providePrimeNG } from 'primeng/config'; +import { DialogService } from 'primeng/dynamicdialog'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { ApplicationConfig, ErrorHandler, importProvidersFrom, provideZoneChangeDetection } from '@angular/core'; import { provideAnimations } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; +import { provideRouter, withInMemoryScrolling } from '@angular/router'; import { STATES } from '@core/constants'; import { provideTranslation } from '@core/helpers'; +import { APPLICATION_INITIALIZATION_PROVIDER } from '@core/provider/application.initialization.provider'; +import { SENTRY_PROVIDER } from '@core/provider/sentry.provider'; -import { GlobalErrorHandler } from './core/handlers'; import { authInterceptor, errorInterceptor, viewOnlyInterceptor } from './core/interceptors'; import CustomPreset from './core/theme/custom-preset'; import { routes } from './app.routes'; +import * as Sentry from '@sentry/angular'; + export const appConfig: ApplicationConfig = { providers: [ - provideZoneChangeDetection({ eventCoalescing: true }), - provideRouter(routes), - provideStore(STATES, withNgxsReduxDevtoolsPlugin({ disabled: false })), + APPLICATION_INITIALIZATION_PROVIDER, + ConfirmationService, + DialogService, + MessageService, + { + provide: ErrorHandler, + useFactory: () => Sentry.createErrorHandler({ showDialog: false }), + }, + importProvidersFrom(TranslateModule.forRoot(provideTranslation())), + provideAnimations(), providePrimeNG({ theme: { preset: CustomPreset, @@ -36,11 +47,10 @@ export const appConfig: ApplicationConfig = { }, }, }), - provideAnimations(), provideHttpClient(withInterceptors([authInterceptor, viewOnlyInterceptor, errorInterceptor])), - importProvidersFrom(TranslateModule.forRoot(provideTranslation())), - ConfirmationService, - MessageService, - { provide: ErrorHandler, useClass: GlobalErrorHandler }, + provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'top', anchorScrolling: 'enabled' })), + provideStore(STATES, withNgxsReduxDevtoolsPlugin({ disabled: true })), + provideZoneChangeDetection({ eventCoalescing: true }), + SENTRY_PROVIDER, ], }; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index c4812db23..fefa6332e 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -8,22 +8,17 @@ import { BookmarksState, ProjectsState } from '@shared/stores'; import { authGuard, redirectIfLoggedInGuard } from './core/guards'; import { isProjectGuard } from './core/guards/is-project.guard'; import { isRegistryGuard } from './core/guards/is-registry.guard'; -import { MyProfileResourceFiltersOptionsState } from './features/my-profile/components/filters/store'; -import { MyProfileResourceFiltersState } from './features/my-profile/components/my-profile-resource-filters/store'; -import { MyProfileState } from './features/my-profile/store'; import { PreprintState } from './features/preprints/store/preprint'; +import { ProfileState } from './features/profile/store'; import { RegistriesState } from './features/registries/store'; import { LicensesHandlers, ProjectsHandlers, ProvidersHandlers } from './features/registries/store/handlers'; import { FilesHandlers } from './features/registries/store/handlers/files.handlers'; -import { ResourceFiltersOptionsState } from './features/search/components/filters/store'; -import { ResourceFiltersState } from './features/search/components/resource-filters/store'; -import { SearchState } from './features/search/store'; import { LicensesService } from './shared/services'; export const routes: Routes = [ { path: '', - loadComponent: () => import('./core/components/root/root.component').then((mod) => mod.RootComponent), + loadComponent: () => import('./core/components/layout/layout.component').then((mod) => mod.LayoutComponent), children: [ { path: '', @@ -40,11 +35,6 @@ export const routes: Routes = [ canActivate: [authGuard], providers: [provideStates([ProjectsState])], }, - { - path: 'confirm/:userId/:token', - loadComponent: () => import('./features/home/home.component').then((mod) => mod.HomeComponent), - data: { skipBreadcrumbs: true }, - }, { path: 'register', canActivate: [redirectIfLoggedInGuard], @@ -71,7 +61,6 @@ export const routes: Routes = [ { path: 'search', loadComponent: () => import('./features/search/search.component').then((mod) => mod.SearchComponent), - providers: [provideStates([ResourceFiltersState, ResourceFiltersOptionsState, SearchState])], }, { path: 'my-projects', @@ -118,13 +107,17 @@ export const routes: Routes = [ loadChildren: () => import('./features/registries/registries.routes').then((mod) => mod.registriesRoutes), }, { - path: 'my-profile', - loadComponent: () => import('./features/my-profile/my-profile.component').then((mod) => mod.MyProfileComponent), - providers: [ - provideStates([MyProfileResourceFiltersState, MyProfileResourceFiltersOptionsState, MyProfileState]), - ], + path: 'profile', + loadComponent: () => import('./features/profile/profile.component').then((mod) => mod.ProfileComponent), + providers: [provideStates([ProfileState])], + data: { scrollToTop: false }, canActivate: [authGuard], }, + { + path: 'user/:id', + loadComponent: () => import('./features/profile/profile.component').then((mod) => mod.ProfileComponent), + providers: [provideStates([ProfileState])], + }, { path: 'institutions', loadChildren: () => import('./features/institutions/institutions.routes').then((r) => r.routes), @@ -164,6 +157,27 @@ export const routes: Routes = [ import('./core/components/request-access/request-access.component').then((mod) => mod.RequestAccessComponent), data: { skipBreadcrumbs: true }, }, + { + path: 'not-found', + loadComponent: () => + import('./core/components/page-not-found/page-not-found.component').then((mod) => mod.PageNotFoundComponent), + data: { skipBreadcrumbs: true }, + }, + { + path: ':id/files/:provider/:fileId', + loadComponent: () => + import('./features/files/pages/file-redirect/file-redirect.component').then((m) => m.FileRedirectComponent), + }, + { + path: 'project/:id/files/:provider/:fileId', + loadComponent: () => + import('./features/files/pages/file-redirect/file-redirect.component').then((m) => m.FileRedirectComponent), + }, + { + path: 'project/:id/node/:nodeId/files/:provider/:fileId', + loadComponent: () => + import('./features/files/pages/file-redirect/file-redirect.component').then((m) => m.FileRedirectComponent), + }, { path: ':id', canMatch: [isFileGuard], diff --git a/src/app/core/animations/fade.in-out.animation.ts b/src/app/core/animations/fade.in-out.animation.ts new file mode 100644 index 000000000..7befb072b --- /dev/null +++ b/src/app/core/animations/fade.in-out.animation.ts @@ -0,0 +1,39 @@ +import { animate, style, transition, trigger } from '@angular/animations'; + +/** + * Angular animation trigger for fading elements in and out. + * + * This trigger can be used with Angular structural directives like `*ngIf` or `@if` + * to smoothly animate the appearance and disappearance of components or elements. + * + * ## Usage: + * + * In the component decorator: + * ```ts + * @Component({ + * selector: 'my-component', + * templateUrl: './my.component.html', + * animations: [fadeInOut] + * }) + * export class MyComponent {} + * ``` + * + * In the template: + * ```html + * @if (show) { + *
+ * Fades in and out! + *
+ * } + * ``` + * + * ## Transitions: + * - **:enter** — Fades in from opacity `0` to `1` over `200ms`. + * - **:leave** — Fades out from opacity `1` to `0` over `200ms`. + * + * @returns An Angular `AnimationTriggerMetadata` object used for component animations. + */ +export const fadeInOutAnimation = trigger('fadeInOut', [ + transition(':enter', [style({ opacity: 0 }), animate('200ms', style({ opacity: 1 }))]), + transition(':leave', [animate('200ms', style({ opacity: 0 }))]), +]); diff --git a/src/app/core/components/breadcrumb/breadcrumb.component.html b/src/app/core/components/breadcrumb/breadcrumb.component.html index dd294e46b..0f6c4b35f 100644 --- a/src/app/core/components/breadcrumb/breadcrumb.component.html +++ b/src/app/core/components/breadcrumb/breadcrumb.component.html @@ -2,8 +2,8 @@ diff --git a/src/app/features/registries/components/files-control/files-control.component.spec.ts b/src/app/features/registries/components/files-control/files-control.component.spec.ts index 004f5e814..202cf396b 100644 --- a/src/app/features/registries/components/files-control/files-control.component.spec.ts +++ b/src/app/features/registries/components/files-control/files-control.component.spec.ts @@ -1,22 +1,117 @@ +import { MockProvider } from 'ng-mocks'; + +import { of, Subject } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HelpScoutService } from '@core/services/help-scout.service'; +import { RegistriesSelectors } from '@osf/features/registries/store'; +import { CustomConfirmationService, CustomDialogService, FilesService, ToastService } from '@osf/shared/services'; + import { FilesControlComponent } from './files-control.component'; -describe('FilesControlComponent', () => { +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; +import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; + +describe('Component: File Control', () => { let component: FilesControlComponent; let fixture: ComponentFixture; + let helpScoutService: HelpScoutService; + let mockFilesService: jest.Mocked; + let mockDialogService: ReturnType; + let mockToastService: ReturnType; + let mockCustomConfirmationService: ReturnType; + const currentFolder = { + links: { newFolder: '/new-folder', upload: '/upload' }, + relationships: { filesLink: '/files-link' }, + } as any; beforeEach(async () => { + mockFilesService = { uploadFile: jest.fn(), getFileGuid: jest.fn() } as any; + mockDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); + mockToastService = ToastServiceMockBuilder.create().build(); + mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); + await TestBed.configureTestingModule({ - imports: [FilesControlComponent], + imports: [FilesControlComponent, OSFTestingModule], + providers: [ + MockProvider(FilesService, mockFilesService), + MockProvider(CustomDialogService, mockDialogService), + MockProvider(ToastService, mockToastService), + MockProvider(CustomConfirmationService, mockCustomConfirmationService), + { + provide: HelpScoutService, + useValue: { + setResourceType: jest.fn(), + unsetResourceType: jest.fn(), + }, + }, + provideMockStore({ + signals: [ + { selector: RegistriesSelectors.getFiles, value: [] }, + { selector: RegistriesSelectors.getFilesTotalCount, value: 0 }, + { selector: RegistriesSelectors.isFilesLoading, value: false }, + { selector: RegistriesSelectors.getCurrentFolder, value: currentFolder }, + ], + }), + ], }).compileComponents(); + helpScoutService = TestBed.inject(HelpScoutService); fixture = TestBed.createComponent(FilesControlComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('attachedFiles', []); + fixture.componentRef.setInput('filesLink', '/files-link'); + fixture.componentRef.setInput('projectId', 'project-1'); + fixture.componentRef.setInput('provider', 'provider-1'); + fixture.componentRef.setInput('filesViewOnly', false); fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should have a default value', () => { + expect(component.fileIsUploading()).toBeFalsy(); + }); + + it('should called the helpScoutService', () => { + expect(helpScoutService.setResourceType).toHaveBeenCalledWith('files'); + }); + + it('should open create folder dialog and trigger files update', () => { + const onClose$ = new Subject(); + (mockDialogService.open as any).mockReturnValue({ onClose: onClose$ }); + const updateSpy = jest.spyOn(component, 'updateFilesList').mockReturnValue(of(void 0)); + + component.createFolder(); + + expect(mockDialogService.open).toHaveBeenCalled(); + onClose$.next('New Folder'); + expect(updateSpy).toHaveBeenCalled(); + }); + + it('should upload files, update progress and select uploaded file', () => { + const file = new File(['data'], 'test.txt', { type: 'text/plain' }); + const progress = { type: 1, loaded: 50, total: 100 } as any; + const response = { type: 4, body: { data: { id: 'files/abc' } } } as any; + + (mockFilesService.uploadFile as any).mockReturnValue(of(progress, response)); + (mockFilesService.getFileGuid as any).mockReturnValue(of({ id: 'abc' })); + + const selectSpy = jest.spyOn(component, 'selectFile'); + + component.uploadFiles(file); + expect(mockFilesService.uploadFile).toHaveBeenCalledWith(file, '/upload'); + expect(selectSpy).toHaveBeenCalledWith({ id: 'abc' } as any); + }); + + it('should emit attachFile when selectFile and not view-only', (done) => { + const file = { id: 'file-1' } as any; + component.attachFile.subscribe((f) => { + expect(f).toEqual(file); + done(); + }); + component.selectFile(file); }); }); diff --git a/src/app/features/registries/components/files-control/files-control.component.ts b/src/app/features/registries/components/files-control/files-control.component.ts index 4b10951ab..bc767b0ff 100644 --- a/src/app/features/registries/components/files-control/files-control.component.ts +++ b/src/app/features/registries/components/files-control/files-control.component.ts @@ -1,23 +1,34 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { TreeDragDropService } from 'primeng/api'; import { Button } from 'primeng/button'; -import { Dialog } from 'primeng/dialog'; -import { DialogService } from 'primeng/dynamicdialog'; import { EMPTY, filter, finalize, Observable, shareReplay, take } from 'rxjs'; import { HttpEventType } from '@angular/common/http'; -import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, output, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + effect, + inject, + input, + OnDestroy, + output, + signal, +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { HelpScoutService } from '@core/services/help-scout.service'; import { CreateFolderDialogComponent } from '@osf/features/files/components'; -import { FilesTreeComponent, LoadingSpinnerComponent } from '@osf/shared/components'; -import { FilesTreeActions, OsfFile } from '@osf/shared/models'; -import { FilesService } from '@osf/shared/services'; +import { FilesTreeComponent, FileUploadDialogComponent, LoadingSpinnerComponent } from '@osf/shared/components'; +import { FILE_SIZE_LIMIT } from '@osf/shared/constants'; +import { ClearFileDirective } from '@osf/shared/directives'; +import { FileFolderModel, FileModel } from '@osf/shared/models'; +import { CustomDialogService, FilesService, ToastService } from '@osf/shared/services'; import { CreateFolder, @@ -26,7 +37,6 @@ import { RegistriesSelectors, SetCurrentFolder, SetFilesIsLoading, - SetMoveFileCurrentFolder, } from '../../store'; @Component({ @@ -35,39 +45,41 @@ import { FilesTreeComponent, Button, LoadingSpinnerComponent, - Dialog, + FileUploadDialogComponent, FormsModule, ReactiveFormsModule, TranslatePipe, + ClearFileDirective, ], templateUrl: './files-control.component.html', styleUrl: './files-control.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [DialogService, TreeDragDropService], + providers: [TreeDragDropService], }) -export class FilesControlComponent { - attachedFiles = input.required[]>(); - attachFile = output(); +export class FilesControlComponent implements OnDestroy { + attachedFiles = input.required[]>(); + attachFile = output(); filesLink = input.required(); projectId = input.required(); provider = input.required(); filesViewOnly = input(false); private readonly filesService = inject(FilesService); - private readonly dialogService = inject(DialogService); - private readonly translateService = inject(TranslateService); + private readonly customDialogService = inject(CustomDialogService); private readonly destroyRef = inject(DestroyRef); + private readonly toastService = inject(ToastService); + private readonly helpScoutService = inject(HelpScoutService); - protected readonly files = select(RegistriesSelectors.getFiles); - protected readonly isFilesLoading = select(RegistriesSelectors.isFilesLoading); - protected readonly currentFolder = select(RegistriesSelectors.getCurrentFolder); + readonly files = select(RegistriesSelectors.getFiles); + readonly filesTotalCount = select(RegistriesSelectors.getFilesTotalCount); + readonly isFilesLoading = select(RegistriesSelectors.isFilesLoading); + readonly currentFolder = select(RegistriesSelectors.getCurrentFolder); - protected readonly progress = signal(0); - protected readonly fileName = signal(''); - protected readonly dataLoaded = signal(false); + readonly progress = signal(0); + readonly fileName = signal(''); + readonly dataLoaded = signal(false); fileIsUploading = signal(false); - isFolderOpening = signal(false); private readonly actions = createDispatchMap({ createFolder: CreateFolder, @@ -75,17 +87,10 @@ export class FilesControlComponent { setFilesIsLoading: SetFilesIsLoading, setCurrentFolder: SetCurrentFolder, getRootFolders: GetRootFolders, - setMoveFileCurrentFolder: SetMoveFileCurrentFolder, }); - protected readonly filesTreeActions: FilesTreeActions = { - setCurrentFolder: (folder) => this.actions.setCurrentFolder(folder), - setFilesIsLoading: (isLoading) => this.actions.setFilesIsLoading(isLoading), - getFiles: (filesLink) => this.actions.getFiles(filesLink), - setMoveFileCurrentFolder: (folder) => this.actions.setMoveFileCurrentFolder(folder), - }; - constructor() { + this.helpScoutService.setResourceType('files'); effect(() => { const filesLink = this.filesLink(); if (filesLink) { @@ -97,14 +102,26 @@ export class FilesControlComponent { }); } }); + + effect(() => { + const currentFolder = this.currentFolder(); + if (currentFolder) { + this.updateFilesList().subscribe(); + } + }); } onFileSelected(event: Event): void { const input = event.target as HTMLInputElement; const file = input.files?.[0]; + + if (file && file.size >= FILE_SIZE_LIMIT) { + this.toastService.showWarn('shared.files.limitText'); + return; + } if (!file) return; - this.uploadFile(file); + this.uploadFiles(file); } createFolder(): void { @@ -113,14 +130,10 @@ export class FilesControlComponent { if (!newFolderLink) return; - this.dialogService + this.customDialogService .open(CreateFolderDialogComponent, { + header: 'files.dialogs.createFolder.title', width: '448px', - focusOnShow: false, - header: this.translateService.instant('files.dialogs.createFolder.title'), - closeOnEscape: true, - modal: true, - closable: true, }) .onClose.pipe(filter((folderName: string) => !!folderName)) .subscribe((folderName) => { @@ -138,15 +151,17 @@ export class FilesControlComponent { updateFilesList(): Observable { const currentFolder = this.currentFolder(); - if (currentFolder?.relationships.filesLink) { - this.filesTreeActions.setFilesIsLoading?.(true); - return this.actions.getFiles(currentFolder?.relationships.filesLink).pipe(take(1)); + if (currentFolder?.links.filesLink) { + this.actions.setFilesIsLoading(true); + return this.actions.getFiles(currentFolder?.links.filesLink, 1).pipe(take(1)); } return EMPTY; } - uploadFile(file: File): void { + uploadFiles(files: File | File[]): void { + const fileArray = Array.isArray(files) ? files : [files]; + const file = fileArray[0]; const currentFolder = this.currentFolder(); const uploadLink = currentFolder?.links.upload; if (!uploadLink) return; @@ -185,12 +200,20 @@ export class FilesControlComponent { }); } - selectFile(file: OsfFile): void { + selectFile(file: FileModel): void { if (this.filesViewOnly()) return; this.attachFile.emit(file); } - folderIsOpening(value: boolean): void { - this.isFolderOpening.set(value); + onLoadFiles(event: { link: string; page: number }) { + this.actions.getFiles(event.link, event.page); + } + + setCurrentFolder(folder: FileFolderModel) { + this.actions.setCurrentFolder(folder); + } + + ngOnDestroy(): void { + this.helpScoutService.unsetResourceType(); } } diff --git a/src/app/features/registries/components/justification-review/justification-review.component.spec.ts b/src/app/features/registries/components/justification-review/justification-review.component.spec.ts index e3c2c7c4c..3055059f1 100644 --- a/src/app/features/registries/components/justification-review/justification-review.component.spec.ts +++ b/src/app/features/registries/components/justification-review/justification-review.component.spec.ts @@ -1,14 +1,67 @@ +import { MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { SchemaActionTrigger } from '@osf/features/registries/enums'; +import { RegistriesSelectors } from '@osf/features/registries/store'; +import { RevisionReviewStates } from '@osf/shared/enums'; +import { CustomConfirmationService, CustomDialogService, ToastService } from '@osf/shared/services'; import { JustificationReviewComponent } from './justification-review.component'; +import { MOCK_PAGES_SCHEMA } from '@testing/mocks/registries.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; +import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { 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'; + describe('JustificationReviewComponent', () => { let component: JustificationReviewComponent; let fixture: ComponentFixture; + let mockActivatedRoute: ReturnType; + let mockRouter: ReturnType; + let mockCustomDialogService: ReturnType; + let mockCustomConfirmationService: ReturnType; + let mockToastService: ReturnType; + + const MOCK_SCHEMA_RESPONSE = { + id: 'rev-1', + registrationId: 'reg-1', + reviewsState: RevisionReviewStates.RevisionInProgress, + updatedResponseKeys: ['field1'], + } as any; beforeEach(async () => { + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'rev-1' }).build(); + mockRouter = RouterMockBuilder.create().withUrl('/x').build(); + mockCustomDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); + mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); + mockToastService = ToastServiceMockBuilder.create().build(); + await TestBed.configureTestingModule({ - imports: [JustificationReviewComponent], + imports: [JustificationReviewComponent, OSFTestingModule], + providers: [ + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + MockProvider(CustomDialogService, mockCustomDialogService), + MockProvider(CustomConfirmationService, mockCustomConfirmationService), + MockProvider(ToastService, mockToastService), + provideMockStore({ + signals: [ + { selector: RegistriesSelectors.getPagesSchema, value: MOCK_PAGES_SCHEMA }, + { selector: RegistriesSelectors.getSchemaResponse, value: MOCK_SCHEMA_RESPONSE }, + { selector: RegistriesSelectors.getSchemaResponseRevisionData, value: {} }, + { selector: RegistriesSelectors.getUpdatedFields, value: {} }, + { selector: RegistriesSelectors.getSchemaResponseLoading, value: false }, + ], + }), + ], }).compileComponents(); fixture = TestBed.createComponent(JustificationReviewComponent); @@ -19,4 +72,65 @@ describe('JustificationReviewComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should compute changes from pages and updated fields', () => { + expect(component.changes().length).toBeGreaterThan(0); + }); + + it('should navigate back to previous step', () => { + component.goBack(); + expect(mockRouter.navigate).toHaveBeenCalled(); + }); + + it('should submit revision for review', () => { + const mockActions = { + handleSchemaResponse: jest.fn().mockReturnValue(of({})), + } as any; + Object.defineProperty(component, 'actions', { value: mockActions }); + + component.submit(); + + expect(mockActions.handleSchemaResponse).toHaveBeenCalledWith('rev-1', SchemaActionTrigger.Submit); + expect(mockToastService.showSuccess).toHaveBeenCalledWith('registries.justification.successSubmit'); + }); + + it('should accept changes', () => { + const mockActions = { + handleSchemaResponse: jest.fn().mockReturnValue(of({})), + } as any; + Object.defineProperty(component, 'actions', { value: mockActions }); + + component.acceptChanges(); + + expect(mockActions.handleSchemaResponse).toHaveBeenCalledWith('rev-1', SchemaActionTrigger.Approve); + expect(mockToastService.showSuccess).toHaveBeenCalledWith('registries.justification.successAccept'); + expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/reg-1/overview'); + }); + + it('should continue editing and show decision recorded toast when confirmed', () => { + jest.spyOn(mockCustomDialogService, 'open').mockReturnValue({ onClose: of(true) } as any); + + component.continueEditing(); + + expect(mockCustomDialogService.open).toHaveBeenCalled(); + expect(mockToastService.showSuccess).toHaveBeenCalledWith('registries.justification.decisionRecorded'); + }); + + it('should delete draft update after confirmation', () => { + const mockActions = { + deleteSchemaResponse: jest.fn().mockReturnValue(of({})), + clearState: jest.fn(), + } as any; + Object.defineProperty(component, 'actions', { value: mockActions }); + + component.deleteDraftUpdate(); + expect(mockCustomConfirmationService.confirmDelete).toHaveBeenCalled(); + const call = (mockCustomConfirmationService.confirmDelete as any).mock.calls[0][0]; + call.onConfirm(); + + expect(mockActions.deleteSchemaResponse).toHaveBeenCalledWith('rev-1'); + expect(mockToastService.showSuccess).toHaveBeenCalledWith('registries.justification.successDeleteDraft'); + expect(mockActions.clearState).toHaveBeenCalled(); + expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/reg-1/overview'); + }); }); diff --git a/src/app/features/registries/components/justification-review/justification-review.component.ts b/src/app/features/registries/components/justification-review/justification-review.component.ts index 902859c73..015c67152 100644 --- a/src/app/features/registries/components/justification-review/justification-review.component.ts +++ b/src/app/features/registries/components/justification-review/justification-review.component.ts @@ -1,10 +1,9 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; -import { DialogService } from 'primeng/dynamicdialog'; import { Message } from 'primeng/message'; import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; @@ -13,7 +12,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { RegistrationBlocksDataComponent } from '@osf/shared/components'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants'; import { FieldType, RevisionReviewStates } from '@osf/shared/enums'; -import { CustomConfirmationService, ToastService } from '@osf/shared/services'; +import { CustomConfirmationService, CustomDialogService, ToastService } from '@osf/shared/services'; import { SchemaActionTrigger } from '../../enums'; import { ClearState, DeleteSchemaResponse, HandleSchemaResponse, RegistriesSelectors } from '../../store'; @@ -25,27 +24,25 @@ import { ConfirmContinueEditingDialogComponent } from '../confirm-continue-editi templateUrl: './justification-review.component.html', styleUrl: './justification-review.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [DialogService], }) export class JustificationReviewComponent { private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); private readonly customConfirmationService = inject(CustomConfirmationService); - private readonly dialogService = inject(DialogService); - private readonly translateService = inject(TranslateService); + private readonly customDialogService = inject(CustomDialogService); private readonly toastService = inject(ToastService); - protected readonly pages = select(RegistriesSelectors.getPagesSchema); - protected readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); - protected readonly schemaResponseRevisionData = select(RegistriesSelectors.getSchemaResponseRevisionData); - protected readonly updatedFields = select(RegistriesSelectors.getUpdatedFields); - protected readonly isSchemaResponseLoading = select(RegistriesSelectors.getSchemaResponseLoading); + readonly pages = select(RegistriesSelectors.getPagesSchema); + readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); + readonly schemaResponseRevisionData = select(RegistriesSelectors.getSchemaResponseRevisionData); + readonly updatedFields = select(RegistriesSelectors.getUpdatedFields); + readonly isSchemaResponseLoading = select(RegistriesSelectors.getSchemaResponseLoading); - protected readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - protected readonly FieldType = FieldType; - protected readonly RevisionReviewStates = RevisionReviewStates; + readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; + readonly FieldType = FieldType; + readonly RevisionReviewStates = RevisionReviewStates; - protected actions = createDispatchMap({ + actions = createDispatchMap({ deleteSchemaResponse: DeleteSchemaResponse, handleSchemaResponse: HandleSchemaResponse, clearState: ClearState, @@ -109,7 +106,7 @@ export class JustificationReviewComponent { next: () => { this.toastService.showSuccess('registries.justification.successDeleteDraft'); this.actions.clearState(); - this.router.navigateByUrl(`/registries/${registrationId}/overview`); + this.router.navigateByUrl(`/${registrationId}/overview`); }, }); }, @@ -120,19 +117,16 @@ export class JustificationReviewComponent { this.actions.handleSchemaResponse(this.revisionId, SchemaActionTrigger.Approve).subscribe({ next: () => { this.toastService.showSuccess('registries.justification.successAccept'); - this.router.navigateByUrl(`/registries/${this.schemaResponse()?.registrationId}/overview`); + this.router.navigateByUrl(`/${this.schemaResponse()?.registrationId}/overview`); }, }); } continueEditing() { - this.dialogService + this.customDialogService .open(ConfirmContinueEditingDialogComponent, { + header: 'registries.justification.confirmContinueEditing.header', width: '552px', - header: this.translateService.instant('registries.justification.confirmContinueEditing.header'), - focusOnShow: false, - closeOnEscape: true, - modal: true, data: { revisionId: this.revisionId, }, diff --git a/src/app/features/registries/components/justification-step/justification-step.component.html b/src/app/features/registries/components/justification-step/justification-step.component.html index a833891e3..e1265453e 100644 --- a/src/app/features/registries/components/justification-step/justification-step.component.html +++ b/src/app/features/registries/components/justification-step/justification-step.component.html @@ -1,4 +1,4 @@ -
+

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

diff --git a/src/app/features/registries/components/justification-step/justification-step.component.spec.ts b/src/app/features/registries/components/justification-step/justification-step.component.spec.ts index 051d5d069..66b167279 100644 --- a/src/app/features/registries/components/justification-step/justification-step.component.spec.ts +++ b/src/app/features/registries/components/justification-step/justification-step.component.spec.ts @@ -1,14 +1,54 @@ +import { MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { RegistriesSelectors } from '@osf/features/registries/store'; +import { CustomConfirmationService, ToastService } from '@osf/shared/services'; import { JustificationStepComponent } from './justification-step.component'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; + describe('JustificationStepComponent', () => { let component: JustificationStepComponent; let fixture: ComponentFixture; + let mockActivatedRoute: ReturnType; + let mockRouter: jest.Mocked; + let mockCustomConfirmationService: ReturnType; + let mockToastService: ReturnType; + + const MOCK_SCHEMA_RESPONSE = { + registrationId: 'reg-1', + revisionJustification: 'reason', + } as any; beforeEach(async () => { + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'rev-1' }).build(); + mockRouter = { navigate: jest.fn(), navigateByUrl: jest.fn(), url: '/x' } as any; + mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); + mockToastService = ToastServiceMockBuilder.create().build(); + await TestBed.configureTestingModule({ - imports: [JustificationStepComponent], + imports: [JustificationStepComponent, OSFTestingModule], + providers: [ + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + MockProvider(CustomConfirmationService, mockCustomConfirmationService as any), + MockProvider(ToastService, mockToastService), + provideMockStore({ + signals: [ + { selector: RegistriesSelectors.getSchemaResponse, value: MOCK_SCHEMA_RESPONSE }, + { selector: RegistriesSelectors.getStepsState, value: {} }, + ], + }), + ], }).compileComponents(); fixture = TestBed.createComponent(JustificationStepComponent); @@ -19,4 +59,44 @@ describe('JustificationStepComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should patch justification when schema has justification', () => { + expect(component.justificationForm.value.justification).toBe('reason'); + }); + + it('should submit justification and navigate to first step', () => { + const mockActions = { + updateRevision: jest.fn().mockReturnValue(of({})), + updateStepState: jest.fn(), + } as any; + Object.defineProperty(component, 'actions', { value: mockActions }); + + component.justificationForm.patchValue({ justification: 'new reason' }); + component.submit(); + + expect(mockActions.updateRevision).toHaveBeenCalledWith('rev-1', 'new reason'); + expect(mockRouter.navigate).toHaveBeenCalledWith(['../1'], { + relativeTo: expect.any(Object), + onSameUrlNavigation: 'reload', + }); + }); + + it('should delete draft update after confirmation', () => { + const mockActions = { + deleteSchemaResponse: jest.fn().mockReturnValue(of({})), + clearState: jest.fn(), + } as any; + Object.defineProperty(component, 'actions', { value: mockActions }); + + component.deleteDraftUpdate(); + + expect(mockCustomConfirmationService.confirmDelete).toHaveBeenCalled(); + const call = (mockCustomConfirmationService.confirmDelete as any).mock.calls[0][0]; + call.onConfirm(); + + expect(mockActions.deleteSchemaResponse).toHaveBeenCalledWith('rev-1'); + expect(mockToastService.showSuccess).toHaveBeenCalledWith('registries.justification.successDeleteDraft'); + expect(mockActions.clearState).toHaveBeenCalled(); + expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/reg-1/overview'); + }); }); diff --git a/src/app/features/registries/components/justification-step/justification-step.component.ts b/src/app/features/registries/components/justification-step/justification-step.component.ts index 9122fa622..19f780e26 100644 --- a/src/app/features/registries/components/justification-step/justification-step.component.ts +++ b/src/app/features/registries/components/justification-step/justification-step.component.ts @@ -21,7 +21,7 @@ import { DeleteSchemaResponse, RegistriesSelectors, UpdateSchemaResponse, - UpdateStepValidation, + UpdateStepState, } from '../../store'; @Component({ @@ -38,12 +38,13 @@ export class JustificationStepComponent implements OnDestroy { private readonly customConfirmationService = inject(CustomConfirmationService); private readonly toastService = inject(ToastService); - protected readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); + readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); + readonly stepsState = select(RegistriesSelectors.getStepsState); readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - protected actions = createDispatchMap({ - updateStepValidation: UpdateStepValidation, + actions = createDispatchMap({ + updateStepState: UpdateStepState, updateRevision: UpdateSchemaResponse, deleteSchemaResponse: DeleteSchemaResponse, clearState: ClearState, @@ -97,7 +98,7 @@ export class JustificationStepComponent implements OnDestroy { this.isDraftDeleted = true; this.actions.clearState(); this.toastService.showSuccess('registries.justification.successDeleteDraft'); - this.router.navigateByUrl(`/registries/${registrationId}/overview`); + this.router.navigateByUrl(`/${registrationId}/overview`); }, }); }, @@ -106,7 +107,7 @@ export class JustificationStepComponent implements OnDestroy { ngOnDestroy(): void { if (!this.isDraftDeleted) { - this.actions.updateStepValidation('0', this.justificationForm.invalid); + this.actions.updateStepState('0', this.justificationForm.invalid, true); const changes = findChangedFields( { justification: this.justificationForm.value.justification! }, { justification: this.schemaResponse()?.revisionJustification } diff --git a/src/app/features/registries/components/metadata/contributors/contributors.component.html b/src/app/features/registries/components/metadata/contributors/contributors.component.html deleted file mode 100644 index ddd24e9f4..000000000 --- a/src/app/features/registries/components/metadata/contributors/contributors.component.html +++ /dev/null @@ -1,20 +0,0 @@ - -

{{ 'project.overview.metadata.contributors' | translate }}

- - - -
- @if (hasChanges) { -
- - -
- } - -
-
diff --git a/src/app/features/registries/components/metadata/contributors/contributors.component.spec.ts b/src/app/features/registries/components/metadata/contributors/contributors.component.spec.ts deleted file mode 100644 index fb45f2cde..000000000 --- a/src/app/features/registries/components/metadata/contributors/contributors.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ContributorsComponent } from './contributors.component'; - -describe('ContributorsComponent', () => { - let component: ContributorsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ContributorsComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ContributorsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/registries/components/metadata/contributors/contributors.component.ts b/src/app/features/registries/components/metadata/contributors/contributors.component.ts deleted file mode 100644 index 267636654..000000000 --- a/src/app/features/registries/components/metadata/contributors/contributors.component.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { createDispatchMap, select } from '@ngxs/store'; - -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; - -import { Button } from 'primeng/button'; -import { Card } from 'primeng/card'; -import { DialogService } from 'primeng/dynamicdialog'; -import { TableModule } from 'primeng/table'; - -import { filter, forkJoin, map, of } from 'rxjs'; - -import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, OnInit, signal } from '@angular/core'; -import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; -import { FormControl, FormsModule } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; - -import { - AddContributorDialogComponent, - AddUnregisteredContributorDialogComponent, - ContributorsListComponent, -} from '@osf/shared/components/contributors'; -import { BIBLIOGRAPHY_OPTIONS, PERMISSION_OPTIONS } from '@osf/shared/constants'; -import { AddContributorType, ContributorPermission, ResourceType } from '@osf/shared/enums'; -import { findChangedItems } from '@osf/shared/helpers'; -import { ContributorDialogAddModel, ContributorModel, SelectOption } from '@osf/shared/models'; -import { CustomConfirmationService, ToastService } from '@osf/shared/services'; -import { - AddContributor, - ContributorsSelectors, - DeleteContributor, - GetAllContributors, - UpdateContributor, -} from '@osf/shared/stores'; - -@Component({ - selector: 'osf-contributors', - imports: [FormsModule, TableModule, ContributorsListComponent, TranslatePipe, Card, Button], - templateUrl: './contributors.component.html', - styleUrl: './contributors.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, - providers: [DialogService], -}) -export class ContributorsComponent implements OnInit { - control = input.required(); - - readonly destroyRef = inject(DestroyRef); - readonly translateService = inject(TranslateService); - readonly dialogService = inject(DialogService); - readonly toastService = inject(ToastService); - readonly customConfirmationService = inject(CustomConfirmationService); - - private readonly route = inject(ActivatedRoute); - private readonly draftId = toSignal(this.route.params.pipe(map((params) => params['id'])) ?? of(undefined)); - - protected readonly selectedPermission = signal(null); - protected readonly selectedBibliography = signal(null); - protected readonly permissionsOptions: SelectOption[] = PERMISSION_OPTIONS; - protected readonly bibliographyOptions: SelectOption[] = BIBLIOGRAPHY_OPTIONS; - - protected initialContributors = select(ContributorsSelectors.getContributors); - protected contributors = signal([]); - - protected readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); - - protected actions = createDispatchMap({ - getContributors: GetAllContributors, - deleteContributor: DeleteContributor, - updateContributor: UpdateContributor, - addContributor: AddContributor, - }); - - get hasChanges(): boolean { - return JSON.stringify(this.initialContributors()) !== JSON.stringify(this.contributors()); - } - - constructor() { - effect(() => { - this.contributors.set(JSON.parse(JSON.stringify(this.initialContributors()))); - }); - } - - ngOnInit(): void { - this.actions.getContributors(this.draftId(), ResourceType.DraftRegistration); - } - - onFocusOut() { - // [NM] TODO: make request to update contributor if changed - console.log('Focus out event:', 'Changed:', this.hasChanges); - if (this.control()) { - this.control().markAsTouched(); - this.control().markAsDirty(); - this.control().updateValueAndValidity(); - } - } - - cancel() { - this.contributors.set(JSON.parse(JSON.stringify(this.initialContributors()))); - } - - save() { - const updatedContributors = findChangedItems(this.initialContributors(), this.contributors(), 'id'); - - const updateRequests = updatedContributors.map((payload) => - this.actions.updateContributor(this.draftId(), ResourceType.DraftRegistration, payload) - ); - - forkJoin(updateRequests).subscribe(() => { - this.toastService.showSuccess('project.contributors.toastMessages.multipleUpdateSuccessMessage'); - }); - } - - openAddContributorDialog() { - const addedContributorIds = this.initialContributors().map((x) => x.userId); - - this.dialogService - .open(AddContributorDialogComponent, { - width: '448px', - data: addedContributorIds, - focusOnShow: false, - header: this.translateService.instant('project.contributors.addDialog.addRegisteredContributor'), - closeOnEscape: true, - modal: true, - closable: true, - }) - .onClose.pipe( - filter((res: ContributorDialogAddModel) => !!res), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe((res: ContributorDialogAddModel) => { - if (res.type === AddContributorType.Unregistered) { - this.openAddUnregisteredContributorDialog(); - } else { - const addRequests = res.data.map((payload) => - this.actions.addContributor(this.draftId(), ResourceType.DraftRegistration, payload) - ); - - forkJoin(addRequests).subscribe(() => { - this.toastService.showSuccess('project.contributors.toastMessages.multipleAddSuccessMessage'); - }); - } - }); - } - - openAddUnregisteredContributorDialog() { - this.dialogService - .open(AddUnregisteredContributorDialogComponent, { - width: '448px', - focusOnShow: false, - header: this.translateService.instant('project.contributors.addDialog.addUnregisteredContributor'), - closeOnEscape: true, - modal: true, - closable: true, - }) - .onClose.pipe( - filter((res: ContributorDialogAddModel) => !!res), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe((res: ContributorDialogAddModel) => { - if (res.type === AddContributorType.Registered) { - this.openAddContributorDialog(); - } else { - const params = { name: res.data[0].fullName }; - - this.actions.addContributor(this.draftId(), ResourceType.DraftRegistration, res.data[0]).subscribe({ - next: () => this.toastService.showSuccess('project.contributors.toastMessages.addSuccessMessage', params), - }); - } - }); - } - - removeContributor(contributor: ContributorModel) { - this.customConfirmationService.confirmDelete({ - headerKey: 'project.contributors.removeDialog.title', - messageKey: 'project.contributors.removeDialog.message', - messageParams: { name: contributor.fullName }, - acceptLabelKey: 'common.buttons.remove', - onConfirm: () => { - this.actions - .deleteContributor(this.draftId(), ResourceType.DraftRegistration, contributor.userId) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: () => - this.toastService.showSuccess('project.contributors.removeDialog.successMessage', { - name: contributor.fullName, - }), - }); - }, - }); - } -} diff --git a/src/app/features/registries/components/metadata/metadata.component.html b/src/app/features/registries/components/metadata/metadata.component.html deleted file mode 100644 index 4adf7ea04..000000000 --- a/src/app/features/registries/components/metadata/metadata.component.html +++ /dev/null @@ -1,45 +0,0 @@ -
- -
-

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

-

{{ 'registries.metadata.description' | translate }}

- - - -
- - - @if ( - metadataForm.controls['description'].errors?.['required'] && - (metadataForm.controls['description'].touched || metadataForm.controls['description'].dirty) - ) { - - {{ INPUT_VALIDATION_MESSAGES.required | translate }} - - } -
-
-
-
- - - - -
- - - -
- -
diff --git a/src/app/features/registries/components/metadata/metadata.component.spec.ts b/src/app/features/registries/components/metadata/metadata.component.spec.ts deleted file mode 100644 index 311ac4e9f..000000000 --- a/src/app/features/registries/components/metadata/metadata.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { MetadataComponent } from './metadata.component'; - -describe('MetadataComponent', () => { - let component: MetadataComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MetadataComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(MetadataComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/registries/components/metadata/metadata.component.ts b/src/app/features/registries/components/metadata/metadata.component.ts deleted file mode 100644 index 3e3031fd8..000000000 --- a/src/app/features/registries/components/metadata/metadata.component.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { createDispatchMap, select } from '@ngxs/store'; - -import { TranslatePipe } from '@ngx-translate/core'; - -import { Button } from 'primeng/button'; -import { Card } from 'primeng/card'; -import { Message } from 'primeng/message'; -import { TextareaModule } from 'primeng/textarea'; - -import { tap } from 'rxjs'; - -import { ChangeDetectionStrategy, Component, effect, inject, OnDestroy } from '@angular/core'; -import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; - -import { TextInputComponent } from '@osf/shared/components'; -import { INPUT_VALIDATION_MESSAGES, InputLimits } from '@osf/shared/constants'; -import { CustomValidators, findChangedFields } from '@osf/shared/helpers'; -import { ContributorModel, DraftRegistrationModel, SubjectModel } from '@osf/shared/models'; -import { CustomConfirmationService } from '@osf/shared/services'; -import { ContributorsSelectors, SubjectsSelectors } from '@osf/shared/stores'; - -import { ClearState, DeleteDraft, RegistriesSelectors, UpdateDraft, UpdateStepValidation } from '../../store'; - -import { ContributorsComponent } from './contributors/contributors.component'; -import { RegistriesLicenseComponent } from './registries-license/registries-license.component'; -import { RegistriesSubjectsComponent } from './registries-subjects/registries-subjects.component'; -import { RegistriesTagsComponent } from './registries-tags/registries-tags.component'; - -@Component({ - selector: 'osf-metadata', - imports: [ - Card, - TextInputComponent, - ReactiveFormsModule, - Button, - TranslatePipe, - TextareaModule, - ContributorsComponent, - RegistriesSubjectsComponent, - RegistriesTagsComponent, - RegistriesLicenseComponent, - Message, - ], - templateUrl: './metadata.component.html', - styleUrl: './metadata.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class MetadataComponent implements OnDestroy { - private readonly fb = inject(FormBuilder); - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly customConfirmationService = inject(CustomConfirmationService); - - private readonly draftId = this.route.snapshot.params['id']; - protected readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); - protected selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); - protected initialContributors = select(ContributorsSelectors.getContributors); - protected stepsValidation = select(RegistriesSelectors.getStepsValidation); - - protected actions = createDispatchMap({ - deleteDraft: DeleteDraft, - updateDraft: UpdateDraft, - updateStepValidation: UpdateStepValidation, - clearState: ClearState, - }); - protected inputLimits = InputLimits; - readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - - metadataForm = this.fb.group({ - title: ['', CustomValidators.requiredTrimmed()], - description: ['', CustomValidators.requiredTrimmed()], - contributors: [[] as ContributorModel[], Validators.required], - subjects: [[] as SubjectModel[], Validators.required], - tags: [[]], - license: this.fb.group({ - id: ['', Validators.required], - }), - }); - - isDraftDeleted = false; - isFormUpdated = false; - - constructor() { - effect(() => { - const draft = this.draftRegistration(); - if (draft && !this.isFormUpdated) { - this.updateFormValue(draft); - this.isFormUpdated = true; - } - }); - } - - private updateFormValue(data: DraftRegistrationModel): void { - this.metadataForm.patchValue({ - title: data.title, - description: data.description, - license: data.license, - contributors: this.initialContributors(), - subjects: this.selectedSubjects(), - }); - if (this.stepsValidation()?.[0]?.invalid) { - this.metadataForm.markAllAsTouched(); - } - } - - submitMetadata(): void { - this.actions - .updateDraft(this.draftId, { - title: this.metadataForm.value.title?.trim(), - description: this.metadataForm.value.description?.trim(), - }) - .pipe( - tap(() => { - this.metadataForm.markAllAsTouched(); - this.router.navigate(['../1'], { - relativeTo: this.route, - onSameUrlNavigation: 'reload', - }); - }) - ) - .subscribe(); - } - - deleteDraft(): void { - this.customConfirmationService.confirmDelete({ - headerKey: 'registries.deleteDraft', - messageKey: 'registries.confirmDeleteDraft', - onConfirm: () => { - const providerId = this.draftRegistration()?.providerId; - this.actions.deleteDraft(this.draftId).subscribe({ - next: () => { - this.isDraftDeleted = true; - this.actions.clearState(); - this.router.navigateByUrl(`/registries/${providerId}/new`); - }, - }); - }, - }); - } - - ngOnDestroy(): void { - if (!this.isDraftDeleted) { - this.actions.updateStepValidation('0', this.metadataForm.invalid); - const changedFields = findChangedFields( - { title: this.metadataForm.value.title!, description: this.metadataForm.value.description! }, - { title: this.draftRegistration()?.title, description: this.draftRegistration()?.description } - ); - if (Object.keys(changedFields).length > 0) { - this.actions.updateDraft(this.draftId, changedFields); - this.metadataForm.markAllAsTouched(); - } - } - } -} diff --git a/src/app/features/registries/components/metadata/registries-license/registries-license.component.html b/src/app/features/registries/components/metadata/registries-license/registries-license.component.html deleted file mode 100644 index f765fbd7f..000000000 --- a/src/app/features/registries/components/metadata/registries-license/registries-license.component.html +++ /dev/null @@ -1,22 +0,0 @@ - -

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

- -

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

-

- {{ 'shared.license.helpText' | translate }} - {{ 'common.links.helpGuide' | translate }}. -

- - @if (control().invalid && (control().touched || control().dirty)) { - - {{ INPUT_VALIDATION_MESSAGES.required | translate }} - - } -
diff --git a/src/app/features/registries/components/metadata/registries-license/registries-license.component.spec.ts b/src/app/features/registries/components/metadata/registries-license/registries-license.component.spec.ts deleted file mode 100644 index b4f491478..000000000 --- a/src/app/features/registries/components/metadata/registries-license/registries-license.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { RegistriesLicenseComponent } from './registries-license.component'; - -describe('LicenseComponent', () => { - let component: RegistriesLicenseComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RegistriesLicenseComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(RegistriesLicenseComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/registries/components/metadata/registries-license/registries-license.component.ts b/src/app/features/registries/components/metadata/registries-license/registries-license.component.ts deleted file mode 100644 index 83be58e7e..000000000 --- a/src/app/features/registries/components/metadata/registries-license/registries-license.component.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { createDispatchMap, select } from '@ngxs/store'; - -import { TranslatePipe } from '@ngx-translate/core'; - -import { Card } from 'primeng/card'; -import { Message } from 'primeng/message'; - -import { ChangeDetectionStrategy, Component, effect, inject, input } from '@angular/core'; -import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; - -import { FetchLicenses, RegistriesSelectors, SaveLicense } from '@osf/features/registries/store'; -import { LicenseComponent } from '@osf/shared/components'; -import { INPUT_VALIDATION_MESSAGES, InputLimits } from '@osf/shared/constants'; -import { CustomValidators } from '@osf/shared/helpers'; -import { License, LicenseOptions } from '@osf/shared/models'; - -import { environment } from 'src/environments/environment'; - -@Component({ - selector: 'osf-registries-license', - imports: [FormsModule, ReactiveFormsModule, LicenseComponent, Card, TranslatePipe, Message], - templateUrl: './registries-license.component.html', - styleUrl: './registries-license.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class RegistriesLicenseComponent { - control = input.required(); - - private readonly route = inject(ActivatedRoute); - private readonly draftId = this.route.snapshot.params['id']; - private readonly fb = inject(FormBuilder); - - protected actions = createDispatchMap({ fetchLicenses: FetchLicenses, saveLicense: SaveLicense }); - protected licenses = select(RegistriesSelectors.getLicenses); - protected inputLimits = InputLimits; - - protected selectedLicense = select(RegistriesSelectors.getSelectedLicense); - protected draftRegistration = select(RegistriesSelectors.getDraftRegistration); - - currentYear = new Date(); - licenseYear = this.currentYear; - licenseForm = this.fb.group({ - year: [this.currentYear.getFullYear().toString(), CustomValidators.requiredTrimmed()], - copyrightHolders: ['', CustomValidators.requiredTrimmed()], - }); - - readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - - private isLoaded = false; - - constructor() { - effect(() => { - if (this.draftRegistration() && !this.isLoaded) { - this.actions.fetchLicenses(this.draftRegistration()?.providerId ?? environment.defaultProvider); - this.isLoaded = true; - } - }); - - effect(() => { - const selectedLicense = this.selectedLicense(); - if (selectedLicense) { - this.control().patchValue({ - id: selectedLicense.id, - // [NM] TODO: Add validation for license options - }); - } - }); - } - - createLicense(licenseDetails: { id: string; licenseOptions: LicenseOptions }) { - this.actions.saveLicense(this.draftId, licenseDetails.id, licenseDetails.licenseOptions); - } - - selectLicense(license: License) { - if (license.requiredFields.length) { - return; - } - this.control().patchValue({ - id: license.id, - }); - this.control().markAsTouched(); - this.control().updateValueAndValidity(); - this.actions.saveLicense(this.draftId, license.id); - } - - onFocusOut() { - if (this.control()) { - this.control().markAsTouched(); - this.control().markAsDirty(); - this.control().updateValueAndValidity(); - } - } -} diff --git a/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.spec.ts b/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.spec.ts deleted file mode 100644 index c6b0b212d..000000000 --- a/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { RegistriesSubjectsComponent } from './registries-subjects.component'; - -describe('RegistriesSubjectsComponent', () => { - let component: RegistriesSubjectsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RegistriesSubjectsComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(RegistriesSubjectsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.html b/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.html deleted file mode 100644 index 5dde35e0a..000000000 --- a/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.html +++ /dev/null @@ -1,7 +0,0 @@ - -
-

{{ 'project.overview.metadata.tags' | translate }} ({{ 'common.labels.optional' | translate }})

- - -
-
diff --git a/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.spec.ts b/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.spec.ts deleted file mode 100644 index 3ec731631..000000000 --- a/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { RegistriesTagsComponent } from './registries-tags.component'; - -describe('TagsComponent', () => { - let component: RegistriesTagsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RegistriesTagsComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(RegistriesTagsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/registries/components/new-registration/new-registration.component.html b/src/app/features/registries/components/new-registration/new-registration.component.html index 07d10ead5..8ce2cb21f 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.html +++ b/src/app/features/registries/components/new-registration/new-registration.component.html @@ -1,58 +1,68 @@ -
+
+

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

+
-

{{ ('registries.new.steps.title' | translate) + '1' }}

+

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

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

+
@if (fromProject) { -

{{ ('registries.new.steps.title' | translate) + '2' }}

+

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

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

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

} + -

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

+

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

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

{{ ('registries.new.steps.title' | translate) + (fromProject ? />
+
{ let component: NewRegistrationComponent; let fixture: ComponentFixture; + let mockActivatedRoute: ReturnType; + let mockRouter: ReturnType; + const PROJECTS = [{ id: 'p1', title: 'P1' }]; + const PROVIDER_SCHEMAS = MOCK_PROVIDER_SCHEMAS; beforeEach(async () => { + mockActivatedRoute = ActivatedRouteMockBuilder.create() + .withParams({ providerId: 'prov-1' }) + .withQueryParams({ projectId: 'proj-1' }) + .build(); + mockRouter = RouterMockBuilder.create().withUrl('/x').build(); + await TestBed.configureTestingModule({ - imports: [NewRegistrationComponent], + imports: [NewRegistrationComponent, OSFTestingModule, MockComponent(SubHeaderComponent)], + providers: [ + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + provideMockStore({ + signals: [ + { selector: RegistriesSelectors.getProjects, value: PROJECTS }, + { selector: RegistriesSelectors.getProviderSchemas, value: PROVIDER_SCHEMAS }, + { selector: RegistriesSelectors.isDraftSubmitting, value: false }, + { selector: RegistriesSelectors.getDraftRegistration, value: { id: 'draft-1' } }, + { selector: RegistriesSelectors.isProvidersLoading, value: false }, + { selector: RegistriesSelectors.isProjectsLoading, value: false }, + { selector: UserSelectors.getCurrentUser, value: { id: 'user-1' } }, + ], + }), + ], }).compileComponents(); fixture = TestBed.createComponent(NewRegistrationComponent); @@ -19,4 +59,45 @@ describe('NewRegistrationComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should init with provider and project ids from route', () => { + expect(component.providerId).toBe('prov-1'); + expect(component.projectId).toBe('proj-1'); + expect(component.fromProject).toBe(true); + }); + + it('should default providerSchema when empty', () => { + expect(component['draftForm'].get('providerSchema')?.value).toBe('schema-1'); + }); + + it('should update project on selection', () => { + component.onSelectProject('p1'); + expect(component['draftForm'].get('project')?.value).toBe('p1'); + }); + + it('should toggle fromProject and add/remove validator', () => { + component.fromProject = false; + component.toggleFromProject(); + expect(component.fromProject).toBe(true); + component.toggleFromProject(); + expect(component.fromProject).toBe(false); + }); + + it('should create draft when form valid', () => { + const mockActions = { + createDraft: jest.fn().mockReturnValue(of({})), + } as any; + Object.defineProperty(component, 'actions', { value: mockActions }); + + component.draftForm.patchValue({ providerSchema: 'schema-1', project: 'proj-1' }); + component.fromProject = true; + component.createDraft(); + + expect(mockActions.createDraft).toHaveBeenCalledWith({ + registrationSchemaId: 'schema-1', + provider: 'prov-1', + projectId: 'proj-1', + }); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/drafts/', 'draft-1', 'metadata']); + }); }); diff --git a/src/app/features/registries/components/new-registration/new-registration.component.ts b/src/app/features/registries/components/new-registration/new-registration.component.ts index c36413b32..3fbccff19 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.ts +++ b/src/app/features/registries/components/new-registration/new-registration.component.ts @@ -6,12 +6,17 @@ import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; import { Select } from 'primeng/select'; -import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; +import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; +import { UserSelectors } from '@core/store/user'; import { SubHeaderComponent } from '@osf/shared/components'; import { ToastService } from '@osf/shared/services'; +import { GetRegistryProvider } from '@shared/stores/registration-provider'; import { CreateDraft, GetProjects, GetProviderSchemas, RegistriesSelectors } from '../../store'; @@ -27,20 +32,24 @@ export class NewRegistrationComponent { private readonly toastService = inject(ToastService); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); - protected readonly projects = select(RegistriesSelectors.getProjects); - protected readonly providerSchemas = select(RegistriesSelectors.getProviderSchemas); - protected readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting); - protected readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); - protected readonly isProvidersLoading = select(RegistriesSelectors.isProvidersLoading); - protected readonly isProjectsLoading = select(RegistriesSelectors.isProjectsLoading); - protected actions = createDispatchMap({ + private destroyRef = inject(DestroyRef); + + readonly projects = select(RegistriesSelectors.getProjects); + readonly providerSchemas = select(RegistriesSelectors.getProviderSchemas); + readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting); + readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); + readonly isProvidersLoading = select(RegistriesSelectors.isProvidersLoading); + readonly isProjectsLoading = select(RegistriesSelectors.isProjectsLoading); + readonly user = select(UserSelectors.getCurrentUser); + actions = createDispatchMap({ + getProvider: GetRegistryProvider, getProjects: GetProjects, getProviderSchemas: GetProviderSchemas, createDraft: CreateDraft, }); - protected readonly providerId = this.route.snapshot.params['providerId']; - protected readonly projectId = this.route.snapshot.queryParams['projectId']; + readonly providerId = this.route.snapshot.params['providerId']; + readonly projectId = this.route.snapshot.queryParams['projectId']; fromProject = this.projectId !== undefined; @@ -49,8 +58,14 @@ export class NewRegistrationComponent { project: [this.projectId || ''], }); + private filter$ = new Subject(); + constructor() { - this.actions.getProjects(); + const userId = this.user()?.id; + if (userId) { + this.actions.getProjects(userId, ''); + } + this.actions.getProvider(this.providerId); this.actions.getProviderSchemas(this.providerId); effect(() => { const providerSchema = this.draftForm.get('providerSchema')?.value; @@ -58,6 +73,14 @@ export class NewRegistrationComponent { this.draftForm.get('providerSchema')?.setValue(this.providerSchemas()[0]?.id); } }); + + this.filter$ + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((value: string) => { + if (userId) { + this.actions.getProjects(userId, value); + } + }); } onSelectProject(projectId: string) { @@ -66,6 +89,10 @@ export class NewRegistrationComponent { }); } + onProjectFilter(value: string) { + this.filter$.next(value); + } + onSelectProviderSchema(providerSchemaId: string) { this.draftForm.patchValue({ providerSchema: providerSchemaId, @@ -89,7 +116,7 @@ export class NewRegistrationComponent { projectId: this.fromProject ? (project ?? undefined) : undefined, }) .subscribe(() => { - this.toastService.showSuccess('Draft created successfully'); + this.toastService.showSuccess('registries.new.createdSuccessfully'); this.router.navigate(['/registries/drafts/', this.draftRegistration()?.id, 'metadata']); }); } diff --git a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.html b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.html new file mode 100644 index 000000000..4e5effc82 --- /dev/null +++ b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.html @@ -0,0 +1,22 @@ + +
+

{{ 'project.overview.metadata.affiliatedInstitutions' | translate }}

+

+ {{ 'project.overview.metadata.affiliatedInstitutionsDescription' | translate }} +

+ @if (userInstitutions().length) { +
+ +
+ } @else { +

{{ 'project.overview.metadata.noAffiliatedInstitutions' | translate }}

+ } +
+
diff --git a/src/app/features/preprints/components/stepper/metadata-step/contributors/contributors.component.scss b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.scss similarity index 100% rename from src/app/features/preprints/components/stepper/metadata-step/contributors/contributors.component.scss rename to src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.scss diff --git a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.spec.ts new file mode 100644 index 000000000..ef6845138 --- /dev/null +++ b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.spec.ts @@ -0,0 +1,68 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { InstitutionsSelectors } from '@osf/shared/stores'; + +import { RegistriesAffiliatedInstitutionComponent } from './registries-affiliated-institution.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('RegistriesAffiliatedInstitutionComponent', () => { + let component: RegistriesAffiliatedInstitutionComponent; + let fixture: ComponentFixture; + let mockActivatedRoute: ReturnType; + + beforeEach(async () => { + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + + await TestBed.configureTestingModule({ + imports: [RegistriesAffiliatedInstitutionComponent, OSFTestingModule], + providers: [ + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + provideMockStore({ + signals: [ + { selector: InstitutionsSelectors.getUserInstitutions, value: [] }, + { selector: InstitutionsSelectors.areUserInstitutionsLoading, value: false }, + { selector: InstitutionsSelectors.getResourceInstitutions, value: [] }, + { selector: InstitutionsSelectors.areResourceInstitutionsLoading, value: false }, + { selector: InstitutionsSelectors.areResourceInstitutionsSubmitting, value: false }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistriesAffiliatedInstitutionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should dispatch updateResourceInstitutions on selection', () => { + const actionsMock = { + updateResourceInstitutions: jest.fn(), + fetchUserInstitutions: jest.fn(), + fetchResourceInstitutions: jest.fn(), + } as any; + Object.defineProperty(component, 'actions', { value: actionsMock }); + const selected = [{ id: 'i2' }] as any; + component.institutionsSelected(selected); + expect(actionsMock.updateResourceInstitutions).toHaveBeenCalledWith('draft-1', 8, selected); + }); + + it('should fetch user and resource institutions on init', () => { + const actionsMock = { + updateResourceInstitutions: jest.fn(), + fetchUserInstitutions: jest.fn(), + fetchResourceInstitutions: jest.fn(), + } as any; + Object.defineProperty(component, 'actions', { value: actionsMock }); + component.ngOnInit(); + expect(actionsMock.fetchUserInstitutions).toHaveBeenCalled(); + expect(actionsMock.fetchResourceInstitutions).toHaveBeenCalledWith('draft-1', 8); + }); +}); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts new file mode 100644 index 000000000..1a6ffdad8 --- /dev/null +++ b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts @@ -0,0 +1,62 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Card } from 'primeng/card'; + +import { ChangeDetectionStrategy, Component, effect, inject, OnInit, signal } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components'; +import { ResourceType } from '@osf/shared/enums'; +import { Institution } from '@osf/shared/models'; +import { + FetchResourceInstitutions, + FetchUserInstitutions, + InstitutionsSelectors, + UpdateResourceInstitutions, +} from '@osf/shared/stores'; + +@Component({ + selector: 'osf-registries-affiliated-institution', + imports: [Card, AffiliatedInstitutionSelectComponent, TranslatePipe], + templateUrl: './registries-affiliated-institution.component.html', + styleUrl: './registries-affiliated-institution.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistriesAffiliatedInstitutionComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly draftId = this.route.snapshot.params['id']; + + selectedInstitutions = signal([]); + + userInstitutions = select(InstitutionsSelectors.getUserInstitutions); + areUserInstitutionsLoading = select(InstitutionsSelectors.areUserInstitutionsLoading); + resourceInstitutions = select(InstitutionsSelectors.getResourceInstitutions); + areResourceInstitutionsLoading = select(InstitutionsSelectors.areResourceInstitutionsLoading); + areResourceInstitutionsSubmitting = select(InstitutionsSelectors.areResourceInstitutionsSubmitting); + + private actions = createDispatchMap({ + fetchUserInstitutions: FetchUserInstitutions, + fetchResourceInstitutions: FetchResourceInstitutions, + updateResourceInstitutions: UpdateResourceInstitutions, + }); + + constructor() { + effect(() => { + const resourceInstitutions = this.resourceInstitutions(); + if (resourceInstitutions.length > 0) { + this.selectedInstitutions.set([...resourceInstitutions]); + } + }); + } + + ngOnInit() { + this.actions.fetchUserInstitutions(); + this.actions.fetchResourceInstitutions(this.draftId, ResourceType.DraftRegistration); + } + + institutionsSelected(institutions: Institution[]) { + this.actions.updateResourceInstitutions(this.draftId, ResourceType.DraftRegistration, institutions); + } +} diff --git a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.html b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.html new file mode 100644 index 000000000..b920f2b04 --- /dev/null +++ b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.html @@ -0,0 +1,23 @@ + +

{{ 'project.overview.metadata.contributors' | translate }}

+ + + +
+ @if (hasChanges) { +
+ + +
+ } + +
+
diff --git a/src/app/features/registries/components/metadata/contributors/contributors.component.scss b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.scss similarity index 100% rename from src/app/features/registries/components/metadata/contributors/contributors.component.scss rename to src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.scss diff --git a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.spec.ts new file mode 100644 index 000000000..35d007b66 --- /dev/null +++ b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.spec.ts @@ -0,0 +1,134 @@ +import { MockComponent, MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; + +import { UserSelectors } from '@core/store/user'; +import { ResourceType } from '@osf/shared/enums'; +import { CustomConfirmationService, CustomDialogService, ToastService } from '@osf/shared/services'; +import { ContributorsSelectors } from '@osf/shared/stores'; +import { ContributorsTableComponent } from '@shared/components/contributors'; + +import { RegistriesContributorsComponent } from './registries-contributors.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; +import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; + +describe('RegistriesContributorsComponent', () => { + let component: RegistriesContributorsComponent; + let fixture: ComponentFixture; + let mockActivatedRoute: ReturnType; + let mockCustomDialogService: ReturnType; + let mockCustomConfirmationService: ReturnType; + let mockToast: ReturnType; + + const initialContributors = [ + { id: '1', userId: 'u1', fullName: 'A', permission: 2 }, + { id: '2', userId: 'u2', fullName: 'B', permission: 1 }, + ] as any[]; + + beforeAll(() => { + if (typeof (globalThis as any).structuredClone !== 'function') { + Object.defineProperty(globalThis as any, 'structuredClone', { + configurable: true, + writable: true, + value: (o: unknown) => JSON.parse(JSON.stringify(o)), + }); + } + }); + + beforeEach(async () => { + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + mockCustomDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); + mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); + mockToast = ToastServiceMockBuilder.create().build(); + + await TestBed.configureTestingModule({ + imports: [RegistriesContributorsComponent, OSFTestingModule, MockComponent(ContributorsTableComponent)], + providers: [ + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(CustomDialogService, mockCustomDialogService), + MockProvider(CustomConfirmationService, mockCustomConfirmationService), + MockProvider(ToastService, mockToast), + provideMockStore({ + signals: [ + { selector: UserSelectors.getCurrentUser, value: { id: 'u1' } }, + { selector: ContributorsSelectors.getContributors, value: initialContributors }, + { selector: ContributorsSelectors.isContributorsLoading, value: false }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistriesContributorsComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('control', new FormControl([])); + const mockActions = { + getContributors: jest.fn().mockReturnValue(of({})), + updateContributor: jest.fn().mockReturnValue(of({})), + addContributor: jest.fn().mockReturnValue(of({})), + deleteContributor: jest.fn().mockReturnValue(of({})), + bulkUpdateContributors: jest.fn().mockReturnValue(of({})), + bulkAddContributors: jest.fn().mockReturnValue(of({})), + } as any; + Object.defineProperty(component, 'actions', { value: mockActions }); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should request contributors on init', () => { + const actions = (component as any).actions; + expect(actions.getContributors).toHaveBeenCalledWith('draft-1', ResourceType.DraftRegistration); + }); + + it('should cancel changes and reset local contributors', () => { + (component as any).contributors.set([{ id: '3' }]); + component.cancel(); + expect(component.contributors()).toEqual(JSON.parse(JSON.stringify(initialContributors))); + }); + + it('should save changed contributors and show success toast', () => { + (component as any).contributors.set([{ ...initialContributors[0] }, { ...initialContributors[1], permission: 2 }]); + component.save(); + expect(mockToast.showSuccess).toHaveBeenCalled(); + }); + + it('should open add contributor dialog', () => { + component.openAddContributorDialog(); + expect(mockCustomDialogService.open).toHaveBeenCalled(); + }); + + it('should open add unregistered contributor dialog', () => { + component.openAddUnregisteredContributorDialog(); + expect(mockCustomDialogService.open).toHaveBeenCalled(); + }); + + it('should remove contributor after confirmation and show success toast', () => { + const contributor = { id: '2', userId: 'u2', fullName: 'B' } as any; + component.removeContributor(contributor); + expect(mockCustomConfirmationService.confirmDelete).toHaveBeenCalled(); + const call = (mockCustomConfirmationService.confirmDelete as any).mock.calls[0][0]; + call.onConfirm(); + expect(mockToast.showSuccess).toHaveBeenCalled(); + }); + + it('should mark control touched and dirty on focus out', () => { + const control = new FormControl([]); + const spy = jest.spyOn(control, 'updateValueAndValidity'); + fixture.componentRef.setInput('control', control); + component.onFocusOut(); + expect(control.touched).toBe(true); + expect(control.dirty).toBe(true); + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts new file mode 100644 index 000000000..5974ed64e --- /dev/null +++ b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts @@ -0,0 +1,211 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Card } from 'primeng/card'; +import { TableModule } from 'primeng/table'; + +import { filter, map, of } from 'rxjs'; + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + input, + OnInit, + signal, +} from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { FormControl, FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { UserSelectors } from '@core/store/user'; +import { + AddContributorDialogComponent, + AddUnregisteredContributorDialogComponent, + ContributorsTableComponent, +} from '@osf/shared/components/contributors'; +import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants'; +import { AddContributorType, ContributorPermission, ResourceType } from '@osf/shared/enums'; +import { findChangedItems } from '@osf/shared/helpers'; +import { ContributorDialogAddModel, ContributorModel, TableParameters } from '@osf/shared/models'; +import { CustomConfirmationService, CustomDialogService, ToastService } from '@osf/shared/services'; +import { + AddContributor, + BulkAddContributors, + BulkUpdateContributors, + ContributorsSelectors, + DeleteContributor, + GetAllContributors, +} from '@osf/shared/stores'; + +@Component({ + selector: 'osf-registries-contributors', + imports: [FormsModule, TableModule, ContributorsTableComponent, TranslatePipe, Card, Button], + templateUrl: './registries-contributors.component.html', + styleUrl: './registries-contributors.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistriesContributorsComponent implements OnInit { + control = input.required(); + + readonly destroyRef = inject(DestroyRef); + readonly customDialogService = inject(CustomDialogService); + readonly toastService = inject(ToastService); + readonly customConfirmationService = inject(CustomConfirmationService); + + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly draftId = toSignal(this.route.params.pipe(map((params) => params['id'])) ?? of(undefined)); + + currentUser = select(UserSelectors.getCurrentUser); + + isCurrentUserAdminContributor = computed(() => { + const currentUserId = this.currentUser()?.id; + const initialContributors = this.initialContributors(); + if (!currentUserId) return false; + + return initialContributors.some( + (contributor: ContributorModel) => + contributor.userId === currentUserId && contributor.permission === ContributorPermission.Admin + ); + }); + + initialContributors = select(ContributorsSelectors.getContributors); + contributors = signal([]); + + readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); + contributorsTotalCount = select(ContributorsSelectors.getContributorsTotalCount); + + readonly tableParams = computed(() => ({ + ...DEFAULT_TABLE_PARAMS, + totalRecords: this.contributorsTotalCount(), + paginator: this.contributorsTotalCount() > DEFAULT_TABLE_PARAMS.rows, + })); + + actions = createDispatchMap({ + getContributors: GetAllContributors, + deleteContributor: DeleteContributor, + bulkUpdateContributors: BulkUpdateContributors, + bulkAddContributors: BulkAddContributors, + addContributor: AddContributor, + }); + + get hasChanges(): boolean { + return JSON.stringify(this.initialContributors()) !== JSON.stringify(this.contributors()); + } + + constructor() { + effect(() => { + this.contributors.set(structuredClone(this.initialContributors())); + }); + } + + ngOnInit(): void { + this.actions.getContributors(this.draftId(), ResourceType.DraftRegistration); + } + + onFocusOut() { + if (this.control()) { + this.control().markAsTouched(); + this.control().markAsDirty(); + this.control().updateValueAndValidity(); + } + } + + cancel() { + this.contributors.set(structuredClone(this.initialContributors())); + } + + save() { + const updatedContributors = findChangedItems(this.initialContributors(), this.contributors(), 'id'); + + this.actions + .bulkUpdateContributors(this.draftId(), ResourceType.DraftRegistration, updatedContributors) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => + this.toastService.showSuccess('project.contributors.toastMessages.multipleUpdateSuccessMessage') + ); + } + + openAddContributorDialog() { + const addedContributorIds = this.initialContributors().map((x) => x.userId); + + this.customDialogService + .open(AddContributorDialogComponent, { + header: 'project.contributors.addDialog.addRegisteredContributor', + width: '448px', + data: addedContributorIds, + }) + .onClose.pipe( + filter((res: ContributorDialogAddModel) => !!res), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((res: ContributorDialogAddModel) => { + if (res.type === AddContributorType.Unregistered) { + this.openAddUnregisteredContributorDialog(); + } else { + this.actions + .bulkAddContributors(this.draftId(), ResourceType.DraftRegistration, res.data) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => + this.toastService.showSuccess('project.contributors.toastMessages.multipleAddSuccessMessage') + ); + } + }); + } + + openAddUnregisteredContributorDialog() { + this.customDialogService + .open(AddUnregisteredContributorDialogComponent, { + header: 'project.contributors.addDialog.addUnregisteredContributor', + width: '448px', + }) + .onClose.pipe( + filter((res: ContributorDialogAddModel) => !!res), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((res: ContributorDialogAddModel) => { + if (res.type === AddContributorType.Registered) { + this.openAddContributorDialog(); + } else { + const params = { name: res.data[0].fullName }; + + this.actions.addContributor(this.draftId(), ResourceType.DraftRegistration, res.data[0]).subscribe({ + next: () => this.toastService.showSuccess('project.contributors.toastMessages.addSuccessMessage', params), + }); + } + }); + } + + removeContributor(contributor: ContributorModel) { + const isDeletingSelf = contributor.userId === this.currentUser()?.id; + + this.customConfirmationService.confirmDelete({ + headerKey: 'project.contributors.removeDialog.title', + messageKey: 'project.contributors.removeDialog.message', + messageParams: { name: contributor.fullName }, + acceptLabelKey: 'common.buttons.remove', + onConfirm: () => { + this.actions + .deleteContributor(this.draftId(), ResourceType.DraftRegistration, contributor.userId, isDeletingSelf) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.toastService.showSuccess('project.contributors.removeDialog.successMessage', { + name: contributor.fullName, + }); + + if (isDeletingSelf) { + this.router.navigate(['/']); + } + }, + }); + }, + }); + } +} diff --git a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.html b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.html new file mode 100644 index 000000000..da782b1e3 --- /dev/null +++ b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.html @@ -0,0 +1,24 @@ + +

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

+ +

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

+

+ {{ 'shared.license.helpText' | translate }} + + {{ 'common.links.helpGuide' | translate }}. +

+ + @if (control().invalid && (control().touched || control().dirty)) { + + {{ INPUT_VALIDATION_MESSAGES.required | translate }} + + } +
diff --git a/src/app/features/registries/components/metadata/registries-license/registries-license.component.scss b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.scss similarity index 100% rename from src/app/features/registries/components/metadata/registries-license/registries-license.component.scss rename to src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.scss diff --git a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.spec.ts new file mode 100644 index 000000000..d9ed36813 --- /dev/null +++ b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.spec.ts @@ -0,0 +1,82 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl, FormGroup } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; + +import { RegistriesSelectors } from '@osf/features/registries/store'; + +import { RegistriesLicenseComponent } from './registries-license.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('RegistriesLicenseComponent', () => { + let component: RegistriesLicenseComponent; + let fixture: ComponentFixture; + let mockActivatedRoute: ReturnType; + + beforeEach(async () => { + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + + await TestBed.configureTestingModule({ + imports: [RegistriesLicenseComponent, OSFTestingModule], + providers: [ + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + provideMockStore({ + signals: [ + { selector: RegistriesSelectors.getLicenses, value: [] }, + { selector: RegistriesSelectors.getSelectedLicense, value: null }, + { selector: RegistriesSelectors.getDraftRegistration, value: { providerId: 'osf' } }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistriesLicenseComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('control', new FormGroup({ id: new FormControl('') })); + const mockActions = { + fetchLicenses: jest.fn().mockReturnValue({}), + saveLicense: jest.fn().mockReturnValue({}), + } as any; + Object.defineProperty(component, 'actions', { value: mockActions }); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should fetch licenses on init when draft present', () => { + expect((component as any).actions.fetchLicenses).toHaveBeenCalledWith('osf'); + }); + + it('should set control id and save license when selecting simple license', () => { + const saveSpy = jest.spyOn((component as any).actions, 'saveLicense'); + component.selectLicense({ id: 'lic-1', requiredFields: [] } as any); + expect((component.control() as FormGroup).get('id')?.value).toBe('lic-1'); + expect(saveSpy).toHaveBeenCalledWith('draft-1', 'lic-1'); + }); + + it('should not save when license has required fields', () => { + const saveSpy = jest.spyOn((component as any).actions, 'saveLicense'); + component.selectLicense({ id: 'lic-2', requiredFields: ['year'] } as any); + expect(saveSpy).not.toHaveBeenCalled(); + }); + + it('should create license with options', () => { + const saveSpy = jest.spyOn((component as any).actions, 'saveLicense'); + component.createLicense({ id: 'lic-3', licenseOptions: { year: '2024', copyrightHolders: 'Me' } as any }); + expect(saveSpy).toHaveBeenCalledWith('draft-1', 'lic-3', { year: '2024', copyrightHolders: 'Me' }); + }); + + it('should mark control on focus out', () => { + const control = new FormGroup({ id: new FormControl('') }); + fixture.componentRef.setInput('control', control); + const spy = jest.spyOn(control, 'updateValueAndValidity'); + component.onFocusOut(); + expect(control.touched).toBe(true); + expect(control.dirty).toBe(true); + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.ts new file mode 100644 index 000000000..bbe927a7b --- /dev/null +++ b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.ts @@ -0,0 +1,105 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Card } from 'primeng/card'; +import { Message } from 'primeng/message'; + +import { ChangeDetectionStrategy, Component, effect, inject, input, untracked } from '@angular/core'; +import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { FetchLicenses, RegistriesSelectors, SaveLicense } from '@osf/features/registries/store'; +import { LicenseComponent } from '@osf/shared/components'; +import { INPUT_VALIDATION_MESSAGES, InputLimits } from '@osf/shared/constants'; +import { LicenseModel, LicenseOptions } from '@osf/shared/models'; + +@Component({ + selector: 'osf-registries-license', + imports: [FormsModule, ReactiveFormsModule, LicenseComponent, Card, TranslatePipe, Message], + templateUrl: './registries-license.component.html', + styleUrl: './registries-license.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistriesLicenseComponent { + control = input.required(); + + private readonly route = inject(ActivatedRoute); + private readonly environment = inject(ENVIRONMENT); + private readonly draftId = this.route.snapshot.params['id']; + + actions = createDispatchMap({ fetchLicenses: FetchLicenses, saveLicense: SaveLicense }); + licenses = select(RegistriesSelectors.getLicenses); + inputLimits = InputLimits; + + selectedLicense = select(RegistriesSelectors.getSelectedLicense); + draftRegistration = select(RegistriesSelectors.getDraftRegistration); + + currentYear = new Date(); + + readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; + + private isLoaded = false; + + constructor() { + effect(() => { + if (this.draftRegistration() && !this.isLoaded) { + this.actions.fetchLicenses(this.draftRegistration()?.providerId ?? this.environment.defaultProvider); + this.isLoaded = true; + } + }); + + effect(() => { + const selectedLicense = this.selectedLicense(); + if (!selectedLicense) { + return; + } + + this.control().patchValue({ + id: selectedLicense.id, + }); + }); + + effect(() => { + const licenses = this.licenses(); + const selectedLicense = untracked(() => this.selectedLicense()); + + if (!licenses.length || !selectedLicense) { + return; + } + + if (!licenses.find((license) => license.id === selectedLicense.id)) { + this.control().patchValue({ + id: null, + }); + this.control().markAsTouched(); + this.control().updateValueAndValidity(); + } + }); + } + + createLicense(licenseDetails: { id: string; licenseOptions: LicenseOptions }) { + this.actions.saveLicense(this.draftId, licenseDetails.id, licenseDetails.licenseOptions); + } + + selectLicense(license: LicenseModel) { + if (license.requiredFields.length) { + return; + } + this.control().patchValue({ + id: license.id, + }); + this.control().markAsTouched(); + this.control().updateValueAndValidity(); + this.actions.saveLicense(this.draftId, license.id); + } + + onFocusOut() { + if (this.control()) { + this.control().markAsTouched(); + this.control().markAsDirty(); + this.control().updateValueAndValidity(); + } + } +} diff --git a/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.html b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.html new file mode 100644 index 000000000..fc02cf924 --- /dev/null +++ b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.html @@ -0,0 +1,76 @@ +
+
+

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

+

{{ 'registries.metadata.description' | translate }}

+
+ + +
+ + + +

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

+ + + +
+
+ +
+ +
+ + +

{{ 'shared.description.message' | translate }}

+ + + @if ( + metadataForm.controls['description'].errors?.['required'] && + (metadataForm.controls['description'].touched || metadataForm.controls['description'].dirty) + ) { + + {{ INPUT_VALIDATION_MESSAGES.required | translate }} + + } +
+
+
+
+ + + + + + + +
+ + + + +
+ +
diff --git a/src/app/features/registries/components/metadata/metadata.component.scss b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.scss similarity index 100% rename from src/app/features/registries/components/metadata/metadata.component.scss rename to src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.scss diff --git a/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.spec.ts new file mode 100644 index 000000000..02fc8dfe6 --- /dev/null +++ b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.spec.ts @@ -0,0 +1,130 @@ +import { MockComponents, MockProvider } from 'ng-mocks'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { RegistriesContributorsComponent } from '@osf/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component'; +import { RegistriesLicenseComponent } from '@osf/features/registries/components/registries-metadata-step/registries-license/registries-license.component'; +import { RegistriesSubjectsComponent } from '@osf/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component'; +import { RegistriesTagsComponent } from '@osf/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component'; +import { RegistriesSelectors } from '@osf/features/registries/store'; +import { CustomConfirmationService } from '@osf/shared/services'; +import { ContributorsSelectors, InstitutionsSelectors, SubjectsSelectors } from '@osf/shared/stores'; +import { TextInputComponent } from '@shared/components'; + +import { RegistriesMetadataStepComponent } from './registries-metadata-step.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe.skip('RegistriesMetadataStepComponent', () => { + let component: RegistriesMetadataStepComponent; + let fixture: ComponentFixture; + let mockActivatedRoute: ReturnType; + let mockRouter: ReturnType; + + beforeEach(async () => { + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + mockRouter = RouterMockBuilder.create().withUrl('/registries/osf/draft/draft-1/metadata').build(); + + await TestBed.configureTestingModule({ + imports: [ + RegistriesMetadataStepComponent, + OSFTestingModule, + ...MockComponents( + RegistriesContributorsComponent, + RegistriesLicenseComponent, + RegistriesSubjectsComponent, + RegistriesTagsComponent, + TextInputComponent + ), + ], + providers: [ + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + MockProvider(CustomConfirmationService, { confirmDelete: jest.fn() }), + provideMockStore({ + signals: [ + { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false } } }, + { selector: ContributorsSelectors.getContributors, value: [] }, + { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, + { selector: InstitutionsSelectors.getResourceInstitutions, value: [] }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistriesMetadataStepComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize form with draft data', () => { + expect(component.metadataForm.value.title).toBe(' My Title '); + expect(component.metadataForm.value.description).toBe(' Description '); + expect(component.metadataForm.value.license).toEqual({ id: 'mit' }); + }); + + it('should compute hasAdminAccess', () => { + expect(component.hasAdminAccess()).toBe(true); + }); + + it('should submit metadata, trim values and navigate to first step', () => { + const actionsMock = { + updateDraft: jest.fn().mockReturnValue({ pipe: () => ({ subscribe: jest.fn() }) }), + deleteDraft: jest.fn(), + clearState: jest.fn(), + updateStepState: jest.fn(), + } as any; + Object.defineProperty(component, 'actions', { value: actionsMock }); + const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + + component.submitMetadata(); + + expect(actionsMock.updateDraft).toHaveBeenCalledWith('draft-1', { + title: 'My Title', + description: 'Description', + }); + expect(navSpy).toHaveBeenCalledWith(['../1'], { + relativeTo: TestBed.inject(ActivatedRoute), + onSameUrlNavigation: 'reload', + }); + }); + + it('should delete draft on confirm and navigate to new registration', () => { + const confirmService = TestBed.inject(CustomConfirmationService) as jest.Mocked as any; + const actionsMock = { + deleteDraft: jest.fn().mockReturnValue({ subscribe: ({ next }: any) => next() }), + clearState: jest.fn(), + } as any; + Object.defineProperty(component, 'actions', { value: actionsMock }); + const navSpy = jest.spyOn(TestBed.inject(Router), 'navigateByUrl'); + + (confirmService.confirmDelete as jest.Mock).mockImplementation(({ onConfirm }) => onConfirm()); + + component.deleteDraft(); + + expect(actionsMock.clearState).toHaveBeenCalled(); + expect(navSpy).toHaveBeenCalledWith('/registries/osf/new'); + }); + + it('should update step state and draft on destroy if changed', () => { + const actionsMock = { + updateStepState: jest.fn(), + updateDraft: jest.fn(), + } as any; + Object.defineProperty(component, 'actions', { value: actionsMock }); + + component.metadataForm.patchValue({ title: 'Changed', description: 'Changed desc' }); + fixture.destroy(); + + expect(actionsMock.updateStepState).toHaveBeenCalledWith('0', true, true); + expect(actionsMock.updateDraft).toHaveBeenCalledWith('draft-1', { title: 'Changed', description: 'Changed desc' }); + }); +}); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.ts new file mode 100644 index 000000000..977b252c9 --- /dev/null +++ b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.ts @@ -0,0 +1,167 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Card } from 'primeng/card'; +import { Message } from 'primeng/message'; +import { TextareaModule } from 'primeng/textarea'; + +import { tap } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, computed, effect, inject, OnDestroy } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { TextInputComponent } from '@osf/shared/components'; +import { INPUT_VALIDATION_MESSAGES, InputLimits } from '@osf/shared/constants'; +import { CustomValidators, findChangedFields } from '@osf/shared/helpers'; +import { ContributorModel, DraftRegistrationModel, SubjectModel } from '@osf/shared/models'; +import { CustomConfirmationService } from '@osf/shared/services'; +import { ContributorsSelectors, SubjectsSelectors } from '@osf/shared/stores'; +import { UserPermissions } from '@shared/enums'; + +import { ClearState, DeleteDraft, RegistriesSelectors, UpdateDraft, UpdateStepState } from '../../store'; + +import { RegistriesAffiliatedInstitutionComponent } from './registries-affiliated-institution/registries-affiliated-institution.component'; +import { RegistriesContributorsComponent } from './registries-contributors/registries-contributors.component'; +import { RegistriesLicenseComponent } from './registries-license/registries-license.component'; +import { RegistriesSubjectsComponent } from './registries-subjects/registries-subjects.component'; +import { RegistriesTagsComponent } from './registries-tags/registries-tags.component'; + +@Component({ + selector: 'osf-registries-metadata-step', + imports: [ + Card, + TextInputComponent, + ReactiveFormsModule, + Button, + TranslatePipe, + TextareaModule, + RegistriesContributorsComponent, + RegistriesSubjectsComponent, + RegistriesTagsComponent, + RegistriesLicenseComponent, + RegistriesAffiliatedInstitutionComponent, + Message, + ], + templateUrl: './registries-metadata-step.component.html', + styleUrl: './registries-metadata-step.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistriesMetadataStepComponent implements OnDestroy { + private readonly fb = inject(FormBuilder); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly customConfirmationService = inject(CustomConfirmationService); + + readonly titleLimit = InputLimits.title.maxLength; + + private readonly draftId = this.route.snapshot.params['id']; + readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); + selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); + initialContributors = select(ContributorsSelectors.getContributors); + stepsState = select(RegistriesSelectors.getStepsState); + + hasAdminAccess = computed(() => { + const registry = this.draftRegistration(); + if (!registry) return false; + return registry.currentUserPermissions.includes(UserPermissions.Admin); + }); + + actions = createDispatchMap({ + deleteDraft: DeleteDraft, + updateDraft: UpdateDraft, + updateStepState: UpdateStepState, + clearState: ClearState, + }); + + readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; + + metadataForm = this.fb.group({ + title: ['', CustomValidators.requiredTrimmed()], + description: ['', CustomValidators.requiredTrimmed()], + contributors: [[] as ContributorModel[], Validators.required], + subjects: [[] as SubjectModel[], Validators.required], + tags: [[]], + license: this.fb.group({ + id: ['', Validators.required], + }), + }); + + isDraftDeleted = false; + isFormUpdated = false; + + constructor() { + effect(() => { + const draft = this.draftRegistration(); + // TODO: This shouldn't be an effect() + if (draft && !this.isFormUpdated) { + this.updateFormValue(draft); + this.isFormUpdated = true; + } + }); + } + + private updateFormValue(data: DraftRegistrationModel): void { + this.metadataForm.patchValue({ + title: data.title, + description: data.description, + license: data.license, + contributors: this.initialContributors(), + subjects: this.selectedSubjects(), + }); + if (this.stepsState()?.[0]?.invalid) { + this.metadataForm.markAllAsTouched(); + } + } + + submitMetadata(): void { + this.actions + .updateDraft(this.draftId, { + title: this.metadataForm.value.title?.trim(), + description: this.metadataForm.value.description?.trim(), + }) + .pipe( + tap(() => { + this.metadataForm.markAllAsTouched(); + this.router.navigate(['../1'], { + relativeTo: this.route, + onSameUrlNavigation: 'reload', + }); + }) + ) + .subscribe(); + } + + deleteDraft(): void { + this.customConfirmationService.confirmDelete({ + headerKey: 'registries.deleteDraft', + messageKey: 'registries.confirmDeleteDraft', + onConfirm: () => { + const providerId = this.draftRegistration()?.providerId; + this.actions.deleteDraft(this.draftId).subscribe({ + next: () => { + this.isDraftDeleted = true; + this.actions.clearState(); + this.router.navigateByUrl(`/registries/${providerId}/new`); + }, + }); + }, + }); + } + + ngOnDestroy(): void { + if (!this.isDraftDeleted) { + this.actions.updateStepState('0', this.metadataForm.invalid, true); + const changedFields = findChangedFields( + { title: this.metadataForm.value.title!, description: this.metadataForm.value.description! }, + { title: this.draftRegistration()?.title, description: this.draftRegistration()?.description } + ); + if (Object.keys(changedFields).length > 0) { + this.actions.updateDraft(this.draftId, changedFields); + this.metadataForm.markAllAsTouched(); + } + } + } +} diff --git a/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.html b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.html similarity index 100% rename from src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.html rename to src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.html diff --git a/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.scss b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.scss similarity index 100% rename from src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.scss rename to src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.scss diff --git a/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.spec.ts new file mode 100644 index 000000000..4102b431a --- /dev/null +++ b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.spec.ts @@ -0,0 +1,90 @@ +import { MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; + +import { RegistriesSelectors } from '@osf/features/registries/store'; +import { ResourceType } from '@osf/shared/enums'; +import { SubjectsSelectors } from '@osf/shared/stores'; + +import { RegistriesSubjectsComponent } from './registries-subjects.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('RegistriesSubjectsComponent', () => { + let component: RegistriesSubjectsComponent; + let fixture: ComponentFixture; + let mockActivatedRoute: ReturnType; + + beforeEach(async () => { + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + await TestBed.configureTestingModule({ + imports: [RegistriesSubjectsComponent, OSFTestingModule], + providers: [ + MockProvider(ActivatedRoute, mockActivatedRoute), + provideMockStore({ + signals: [ + { selector: RegistriesSelectors.getDraftRegistration, value: { providerId: 'prov-1' } }, + { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, + { selector: SubjectsSelectors.getSubjects, value: [] }, + { selector: SubjectsSelectors.getSearchedSubjects, value: [] }, + { selector: SubjectsSelectors.getSubjectsLoading, value: false }, + { selector: SubjectsSelectors.getSearchedSubjectsLoading, value: false }, + { selector: SubjectsSelectors.areSelectedSubjectsLoading, value: false }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistriesSubjectsComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('control', new FormControl([])); + const mockActions = { + fetchSubjects: jest.fn().mockReturnValue(of({})), + fetchSelectedSubjects: jest.fn().mockReturnValue(of({})), + fetchChildrenSubjects: jest.fn().mockReturnValue(of({})), + updateResourceSubjects: jest.fn().mockReturnValue(of({})), + } as any; + Object.defineProperty(component, 'actions', { value: mockActions }); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should fetch subjects and selected subjects on init', () => { + const actions = (component as any).actions; + expect(actions.fetchSubjects).toHaveBeenCalledWith(ResourceType.Registration, 'prov-1'); + expect(actions.fetchSelectedSubjects).toHaveBeenCalledWith('draft-1', ResourceType.DraftRegistration); + }); + + it('should fetch children on demand', () => { + const actions = (component as any).actions; + component.getSubjectChildren('parent-1'); + expect(actions.fetchChildrenSubjects).toHaveBeenCalledWith('parent-1'); + }); + + it('should search subjects', () => { + const actions = (component as any).actions; + component.searchSubjects('term'); + expect(actions.fetchSubjects).toHaveBeenCalledWith(ResourceType.Registration, 'prov-1', 'term'); + }); + + it('should update selected subjects and control state', () => { + const actions = (component as any).actions; + const nextSubjects = [{ id: 's1' } as any]; + component.updateSelectedSubjects(nextSubjects); + expect(actions.updateResourceSubjects).toHaveBeenCalledWith( + 'draft-1', + ResourceType.DraftRegistration, + nextSubjects + ); + expect(component.control().value).toEqual(nextSubjects); + }); +}); diff --git a/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.ts similarity index 90% rename from src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.ts rename to src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.ts index 9844053e9..ea6db6907 100644 --- a/src/app/features/registries/components/metadata/registries-subjects/registries-subjects.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.ts @@ -34,11 +34,11 @@ export class RegistriesSubjectsComponent { private readonly route = inject(ActivatedRoute); private readonly draftId = this.route.snapshot.params['id']; - protected selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); - protected isSubjectsUpdating = select(SubjectsSelectors.areSelectedSubjectsLoading); - protected draftRegistration = select(RegistriesSelectors.getDraftRegistration); + selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); + isSubjectsUpdating = select(SubjectsSelectors.areSelectedSubjectsLoading); + draftRegistration = select(RegistriesSelectors.getDraftRegistration); - protected actions = createDispatchMap({ + actions = createDispatchMap({ fetchSubjects: FetchSubjects, fetchSelectedSubjects: FetchSelectedSubjects, fetchChildrenSubjects: FetchChildrenSubjects, diff --git a/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.html b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.html new file mode 100644 index 000000000..f6c3f9981 --- /dev/null +++ b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.html @@ -0,0 +1,13 @@ + +
+ +

{{ 'shared.tags.description' | translate }}

+ +
+
diff --git a/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.scss b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.scss similarity index 100% rename from src/app/features/registries/components/metadata/registries-tags/registries-tags.component.scss rename to src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.scss diff --git a/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.spec.ts new file mode 100644 index 000000000..4072f1ed6 --- /dev/null +++ b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.spec.ts @@ -0,0 +1,54 @@ +import { of } from 'rxjs'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { RegistriesSelectors } from '@osf/features/registries/store'; + +import { RegistriesTagsComponent } from './registries-tags.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('RegistriesTagsComponent', () => { + let component: RegistriesTagsComponent; + let fixture: ComponentFixture; + let mockActivatedRoute: ReturnType; + + beforeEach(async () => { + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'someId' }).build(); + + await TestBed.configureTestingModule({ + imports: [RegistriesTagsComponent, OSFTestingModule], + providers: [ + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + provideMockStore({ + signals: [{ selector: RegistriesSelectors.getSelectedTags, value: [] }], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistriesTagsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render with label', () => { + const labelElement = fixture.nativeElement.querySelector('label'); + expect(labelElement.textContent).toEqual('project.overview.metadata.tags (common.labels.optional)'); + }); + + it('should update tags on change', () => { + const mockActions = { + updateDraft: jest.fn().mockReturnValue(of({})), + } as any; + Object.defineProperty(component, 'actions', { value: mockActions }); + component.onTagsChanged(['a', 'b']); + expect(mockActions.updateDraft).toHaveBeenCalledWith('someId', { tags: ['a', 'b'] }); + }); +}); diff --git a/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.ts similarity index 89% rename from src/app/features/registries/components/metadata/registries-tags/registries-tags.component.ts rename to src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.ts index 1d9fbd869..977316404 100644 --- a/src/app/features/registries/components/metadata/registries-tags/registries-tags.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.ts @@ -20,9 +20,9 @@ import { TagsInputComponent } from '@osf/shared/components'; export class RegistriesTagsComponent { private readonly route = inject(ActivatedRoute); private readonly draftId = this.route.snapshot.params['id']; - protected selectedTags = select(RegistriesSelectors.getSelectedTags); + selectedTags = select(RegistriesSelectors.getSelectedTags); - protected actions = createDispatchMap({ + actions = createDispatchMap({ updateDraft: UpdateDraft, }); diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html index 303d9c564..4efab8ac6 100644 --- a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html @@ -5,12 +5,7 @@ } @else { - + Provider Logo }
@@ -33,15 +28,13 @@ @if (isProviderLoading()) { } @else { -
- -
+ }
diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.scss b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.scss index e69de29bb..e84a2dfaf 100644 --- a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.scss +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.scss @@ -0,0 +1,8 @@ +.registries-hero-container { + background-image: var(--branding-hero-background-image-url); + color: var(--white); + + .provider-description { + line-height: 1.5rem; + } +} diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts index d53950ae4..ec4f81f98 100644 --- a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts @@ -1,22 +1,74 @@ +import { MockComponent, MockPipe, MockProvider } from 'ng-mocks'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +import { CustomDialogService } from '@osf/shared/services'; +import { SearchInputComponent } from '@shared/components'; +import { DecodeHtmlPipe } from '@shared/pipes'; import { RegistryProviderHeroComponent } from './registry-provider-hero.component'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; + describe('RegistryProviderHeroComponent', () => { let component: RegistryProviderHeroComponent; let fixture: ComponentFixture; + let mockCustomDialogService: ReturnType; beforeEach(async () => { + const mockRouter = RouterMockBuilder.create().withUrl('/x').build(); + mockCustomDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); await TestBed.configureTestingModule({ - imports: [RegistryProviderHeroComponent], + imports: [ + RegistryProviderHeroComponent, + OSFTestingModule, + MockComponent(SearchInputComponent), + MockPipe(DecodeHtmlPipe), + ], + providers: [MockProvider(Router, mockRouter), MockProvider(CustomDialogService, mockCustomDialogService)], }).compileComponents(); fixture = TestBed.createComponent(RegistryProviderHeroComponent); component = fixture.componentInstance; + + fixture.componentRef.setInput('provider', { id: 'prov-1', title: 'Provider', brand: undefined } as any); + fixture.componentRef.setInput('isProviderLoading', false); fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should emit triggerSearch on onTriggerSearch', () => { + jest.spyOn(component.triggerSearch, 'emit'); + component.onTriggerSearch('abc'); + expect(component.triggerSearch.emit).toHaveBeenCalledWith('abc'); + }); + + it('should open help dialog', () => { + component.openHelpDialog(); + expect(mockCustomDialogService.open).toHaveBeenCalledWith(expect.any(Function), { + header: 'preprints.helpDialog.header', + }); + }); + + it('should navigate to create page when provider id present', () => { + const router = TestBed.inject(Router); + const navSpy = jest.spyOn(router, 'navigate'); + fixture.componentRef.setInput('provider', { id: 'prov-1', title: 'Provider', brand: undefined } as any); + component.navigateToCreatePage(); + expect(navSpy).toHaveBeenCalledWith(['/registries/prov-1/new']); + }); + + it('should not navigate when provider id missing', () => { + const router = TestBed.inject(Router); + const navSpy = jest.spyOn(router, 'navigate'); + fixture.componentRef.setInput('provider', { id: undefined, title: 'Provider', brand: undefined } as any); + component.navigateToCreatePage(); + expect(navSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.ts b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.ts index c270736a8..cfe83aa9a 100644 --- a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.ts +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.ts @@ -1,20 +1,19 @@ -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; -import { DialogService } from 'primeng/dynamicdialog'; import { Skeleton } from 'primeng/skeleton'; import { TitleCasePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, effect, inject, input, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, inject, input, OnDestroy, output } from '@angular/core'; import { FormControl } from '@angular/forms'; import { Router } from '@angular/router'; import { PreprintsHelpDialogComponent } from '@osf/features/preprints/components'; -import { RegistryProviderDetails } from '@osf/features/registries/models/registry-provider.model'; import { HeaderStyleHelper } from '@osf/shared/helpers'; +import { RegistryProviderDetails } from '@osf/shared/models'; import { SearchInputComponent } from '@shared/components'; import { DecodeHtmlPipe } from '@shared/pipes'; -import { BrandService } from '@shared/services'; +import { BrandService, CustomDialogService } from '@shared/services'; @Component({ selector: 'osf-registry-provider-hero', @@ -23,11 +22,11 @@ import { BrandService } from '@shared/services'; styleUrl: './registry-provider-hero.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class RegistryProviderHeroComponent { +export class RegistryProviderHeroComponent implements OnDestroy { private readonly router = inject(Router); - private readonly translateService = inject(TranslateService); - private readonly dialogService = inject(DialogService); + private readonly customDialogService = inject(CustomDialogService); + private readonly WHITE = '#ffffff'; searchControl = input(new FormControl()); provider = input.required(); isProviderLoading = input.required(); @@ -41,25 +40,24 @@ export class RegistryProviderHeroComponent { effect(() => { const provider = this.provider(); - if (provider) { + if (provider?.brand) { BrandService.applyBranding(provider.brand); HeaderStyleHelper.applyHeaderStyles( + this.WHITE, provider.brand.primaryColor, - undefined, provider.brand.heroBackgroundImageUrl ); } }); } + ngOnDestroy() { + HeaderStyleHelper.resetToDefaults(); + BrandService.resetBranding(); + } + openHelpDialog() { - this.dialogService.open(PreprintsHelpDialogComponent, { - focusOnShow: false, - header: this.translateService.instant('preprints.helpDialog.header'), - closeOnEscape: true, - modal: true, - closable: true, - }); + this.customDialogService.open(PreprintsHelpDialogComponent, { header: 'preprints.helpDialog.header' }); } navigateToCreatePage() { diff --git a/src/app/features/registries/components/registry-services/registry-services.component.html b/src/app/features/registries/components/registry-services/registry-services.component.html index 832caae5d..5c94dde52 100644 --- a/src/app/features/registries/components/registry-services/registry-services.component.html +++ b/src/app/features/registries/components/registry-services/registry-services.component.html @@ -7,7 +7,7 @@

{{ 'registries.services.title' | translate }} @for (registryService of registryServices; track $index) { {{ 'registries.services.title' | translate }} diff --git a/src/app/features/registries/components/registry-services/registry-services.component.spec.ts b/src/app/features/registries/components/registry-services/registry-services.component.spec.ts index eaec061b9..bf13f3b1d 100644 --- a/src/app/features/registries/components/registry-services/registry-services.component.spec.ts +++ b/src/app/features/registries/components/registry-services/registry-services.component.spec.ts @@ -2,13 +2,15 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RegistryServicesComponent } from './registry-services.component'; +import { OSFTestingModule } from '@testing/osf.testing.module'; + describe('RegistryServicesComponent', () => { let component: RegistryServicesComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [RegistryServicesComponent], + imports: [RegistryServicesComponent, OSFTestingModule], }).compileComponents(); fixture = TestBed.createComponent(RegistryServicesComponent); @@ -19,4 +21,28 @@ describe('RegistryServicesComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should expose registryServices', () => { + expect(Array.isArray(component.registryServices)).toBe(true); + expect(component.registryServices.length).toBeGreaterThan(0); + }); + + it('should render service items', () => { + const compiled = fixture.nativeElement as HTMLElement; + const buttons = compiled.querySelectorAll('button, a'); + expect(buttons.length).toBeGreaterThan(0); + }); + + it('should open email via mailto when openEmail is called', () => { + const originalHref = window.location.href; + Object.defineProperty(window, 'location', { + value: { href: originalHref }, + writable: true, + configurable: true, + }); + + component.openEmail(); + + expect(window.location.href).toBe('mailto:contact@osf.io'); + }); }); diff --git a/src/app/features/registries/components/registry-services/registry-services.component.ts b/src/app/features/registries/components/registry-services/registry-services.component.ts index 85dba0605..c7c66a518 100644 --- a/src/app/features/registries/components/registry-services/registry-services.component.ts +++ b/src/app/features/registries/components/registry-services/registry-services.component.ts @@ -15,7 +15,7 @@ import { RegistryServiceIcons } from '@shared/constants'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistryServicesComponent { - protected registryServices = RegistryServiceIcons; + registryServices = RegistryServiceIcons; openEmail() { window.location.href = 'mailto:contact@osf.io'; diff --git a/src/app/features/registries/components/review/review.component.html b/src/app/features/registries/components/review/review.component.html index 31c3bd704..f984826f5 100644 --- a/src/app/features/registries/components/review/review.component.html +++ b/src/app/features/registries/components/review/review.component.html @@ -4,7 +4,7 @@

{{ 'navigation.metadata' | translate }}

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

-

{{ draftRegistration()?.title }}

+

{{ draftRegistration()?.title }}

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

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

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

@@ -15,7 +15,7 @@

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

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

-

{{ draftRegistration()?.description }}

+

{{ draftRegistration()?.description }}

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

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

@@ -25,17 +25,9 @@

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

@@ -44,7 +36,7 @@

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

-
{{ license()?.name }}
+
{{ license()?.name }}
{{ license()!.text | interpolate: licenseOptionsRecord() }}
@@ -62,7 +54,7 @@

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

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

@for (subject of subjects(); track subject.id) { - + }
@if (!subjects().length) { @@ -75,7 +67,7 @@

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

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

-
+
@for (tag of draftRegistration()?.tags; track tag) { } @@ -102,6 +94,7 @@

{{ section.title }}

} @if (section.questions?.length) { @@ -111,6 +104,7 @@

{{ section.title }}

} @else { @if (page.questions?.length) { @@ -127,12 +121,19 @@

{{ section.title }}

class="mr-2" (click)="goBack()" > - +
diff --git a/src/app/features/registries/components/review/review.component.spec.ts b/src/app/features/registries/components/review/review.component.spec.ts index f555378f7..705ff9be9 100644 --- a/src/app/features/registries/components/review/review.component.spec.ts +++ b/src/app/features/registries/components/review/review.component.spec.ts @@ -1,14 +1,86 @@ +import { MockComponents, MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { RegistriesSelectors } from '@osf/features/registries/store'; +import { + ContributorsListComponent, + LoadingSpinnerComponent, + RegistrationBlocksDataComponent, + SubHeaderComponent, +} from '@osf/shared/components'; +import { FieldType } from '@osf/shared/enums'; +import { CustomConfirmationService, CustomDialogService, ToastService } from '@osf/shared/services'; +import { ContributorsSelectors, SubjectsSelectors } from '@osf/shared/stores'; import { ReviewComponent } from './review.component'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; +import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { 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'; + describe('ReviewComponent', () => { let component: ReviewComponent; let fixture: ComponentFixture; + let mockRouter: ReturnType; + let mockActivatedRoute: ReturnType; + let mockDialog: ReturnType; + let mockConfirm: ReturnType; + let mockToast: ReturnType; beforeEach(async () => { + mockRouter = RouterMockBuilder.create().withUrl('/registries/123/review').build(); + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + + mockDialog = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); + mockConfirm = CustomConfirmationServiceMockBuilder.create() + .withConfirmDelete(jest.fn((opts) => opts.onConfirm && opts.onConfirm())) + .build(); + mockToast = ToastServiceMockBuilder.create().build(); + await TestBed.configureTestingModule({ - imports: [ReviewComponent], + imports: [ + ReviewComponent, + OSFTestingModule, + ...MockComponents( + RegistrationBlocksDataComponent, + ContributorsListComponent, + SubHeaderComponent, + LoadingSpinnerComponent + ), + ], + providers: [ + MockProvider(Router, mockRouter), + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(CustomDialogService, mockDialog), + MockProvider(CustomConfirmationService, mockConfirm), + MockProvider(ToastService, mockToast), + provideMockStore({ + signals: [ + { selector: RegistriesSelectors.getPagesSchema, value: [] }, + { + selector: RegistriesSelectors.getDraftRegistration, + value: { id: 'draft-1', providerId: 'prov-1', currentUserPermissions: [], hasProject: false }, + }, + { selector: RegistriesSelectors.isDraftSubmitting, value: false }, + { selector: RegistriesSelectors.isDraftLoading, value: false }, + { selector: RegistriesSelectors.getStepsData, value: {} }, + { selector: RegistriesSelectors.getRegistrationComponents, value: [] }, + { selector: RegistriesSelectors.getRegistrationLicense, value: null }, + { selector: RegistriesSelectors.getRegistration, value: { id: 'new-reg-1' } }, + { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false } } }, + { selector: ContributorsSelectors.getContributors, value: [] }, + { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, + ], + }), + ], }).compileComponents(); fixture = TestBed.createComponent(ReviewComponent); @@ -18,5 +90,35 @@ describe('ReviewComponent', () => { it('should create', () => { expect(component).toBeTruthy(); + expect(component.FieldType).toBe(FieldType); + }); + + it('should navigate back to previous step', () => { + const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + component.goBack(); + expect(navSpy).toHaveBeenCalledWith(['../', 0], { relativeTo: TestBed.inject(ActivatedRoute) }); + }); + + it('should open confirmation dialog when deleting draft and navigate on confirm', () => { + const navSpy = jest.spyOn(TestBed.inject(Router), 'navigateByUrl'); + (component as any).actions = { + ...component.actions, + deleteDraft: jest.fn().mockReturnValue(of({})), + clearState: jest.fn(), + }; + + component.deleteDraft(); + + expect(mockConfirm.confirmDelete).toHaveBeenCalled(); + expect(navSpy).toHaveBeenCalledWith('/registries/prov-1/new'); + }); + + it('should open select components dialog when components exist and chain to confirm', () => { + (component as any).components = () => ['c1', 'c2']; + (mockDialog.open as jest.Mock).mockReturnValueOnce({ onClose: of(['c1']) } as any); + component.confirmRegistration(); + + expect(mockDialog.open).toHaveBeenCalled(); + expect((mockDialog.open as jest.Mock).mock.calls.length).toBeGreaterThan(1); }); }); diff --git a/src/app/features/registries/components/review/review.component.ts b/src/app/features/registries/components/review/review.component.ts index 33d5387af..cc8b2e29a 100644 --- a/src/app/features/registries/components/review/review.component.ts +++ b/src/app/features/registries/components/review/review.component.ts @@ -1,11 +1,10 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { Accordion, AccordionContent, AccordionHeader, AccordionPanel } from 'primeng/accordion'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; -import { DialogService } from 'primeng/dynamicdialog'; import { Message } from 'primeng/message'; import { Tag } from 'primeng/tag'; @@ -13,13 +12,14 @@ import { map, of } from 'rxjs'; import { ChangeDetectionStrategy, Component, computed, effect, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; -import { RegistrationBlocksDataComponent } from '@osf/shared/components'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { ContributorsListComponent, RegistrationBlocksDataComponent } from '@osf/shared/components'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants'; -import { FieldType, ResourceType } from '@osf/shared/enums'; +import { FieldType, ResourceType, UserPermissions } from '@osf/shared/enums'; import { InterpolatePipe } from '@osf/shared/pipes'; -import { CustomConfirmationService, ToastService } from '@osf/shared/services'; +import { CustomConfirmationService, CustomDialogService, ToastService } from '@osf/shared/services'; import { ContributorsSelectors, FetchSelectedSubjects, @@ -27,19 +27,23 @@ import { SubjectsSelectors, } from '@osf/shared/stores'; -import { ClearState, DeleteDraft, FetchLicenses, FetchProjectChildren, RegistriesSelectors } from '../../store'; +import { + ClearState, + DeleteDraft, + FetchLicenses, + FetchProjectChildren, + RegistriesSelectors, + UpdateStepState, +} from '../../store'; import { ConfirmRegistrationDialogComponent } from '../confirm-registration-dialog/confirm-registration-dialog.component'; import { SelectComponentsDialogComponent } from '../select-components-dialog/select-components-dialog.component'; -import { environment } from 'src/environments/environment'; - @Component({ selector: 'osf-review', imports: [ TranslatePipe, Card, Message, - RouterLink, Tag, Button, Accordion, @@ -48,55 +52,60 @@ import { environment } from 'src/environments/environment'; AccordionPanel, InterpolatePipe, RegistrationBlocksDataComponent, + ContributorsListComponent, ], templateUrl: './review.component.html', styleUrl: './review.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [DialogService], }) export class ReviewComponent { private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); private readonly customConfirmationService = inject(CustomConfirmationService); - private readonly dialogService = inject(DialogService); - private readonly translateService = inject(TranslateService); + private readonly customDialogService = inject(CustomDialogService); private readonly toastService = inject(ToastService); - - protected readonly pages = select(RegistriesSelectors.getPagesSchema); - protected readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); - protected readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting); - protected readonly isDraftLoading = select(RegistriesSelectors.isDraftLoading); - protected readonly stepsData = select(RegistriesSelectors.getStepsData); - protected readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - protected readonly contributors = select(ContributorsSelectors.getContributors); - protected readonly subjects = select(SubjectsSelectors.getSelectedSubjects); - protected readonly components = select(RegistriesSelectors.getRegistrationComponents); - protected readonly license = select(RegistriesSelectors.getRegistrationLicense); - protected readonly newRegistration = select(RegistriesSelectors.getRegistration); - - protected readonly FieldType = FieldType; - - protected actions = createDispatchMap({ + private readonly environment = inject(ENVIRONMENT); + + readonly pages = select(RegistriesSelectors.getPagesSchema); + readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); + readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting); + readonly isDraftLoading = select(RegistriesSelectors.isDraftLoading); + readonly stepsData = select(RegistriesSelectors.getStepsData); + readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; + readonly contributors = select(ContributorsSelectors.getContributors); + readonly subjects = select(SubjectsSelectors.getSelectedSubjects); + readonly components = select(RegistriesSelectors.getRegistrationComponents); + readonly license = select(RegistriesSelectors.getRegistrationLicense); + readonly newRegistration = select(RegistriesSelectors.getRegistration); + + readonly FieldType = FieldType; + + actions = createDispatchMap({ getContributors: GetAllContributors, getSubjects: FetchSelectedSubjects, deleteDraft: DeleteDraft, clearState: ClearState, getProjectsComponents: FetchProjectChildren, fetchLicenses: FetchLicenses, + updateStepState: UpdateStepState, }); private readonly draftId = toSignal(this.route.params.pipe(map((params) => params['id'])) ?? of(undefined)); - protected stepsValidation = select(RegistriesSelectors.getStepsValidation); + stepsState = select(RegistriesSelectors.getStepsState); - isDraftInvalid = computed(() => { - return Object.values(this.stepsValidation()).some((step) => step.invalid); - }); + isDraftInvalid = computed(() => Object.values(this.stepsState()).some((step) => step.invalid)); - licenseOptionsRecord = computed(() => { - return (this.draftRegistration()?.license.options ?? {}) as Record; + licenseOptionsRecord = computed(() => (this.draftRegistration()?.license.options ?? {}) as Record); + + hasAdminAccess = computed(() => { + const registry = this.draftRegistration(); + if (!registry) return false; + return registry.currentUserPermissions.includes(UserPermissions.Admin); }); + registerButtonDisabled = computed(() => this.isDraftLoading() || this.isDraftInvalid() || !this.hasAdminAccess()); + constructor() { if (!this.contributors()?.length) { this.actions.getContributors(this.draftId(), ResourceType.DraftRegistration); @@ -107,7 +116,7 @@ export class ReviewComponent { effect(() => { if (this.draftRegistration()) { - this.actions.fetchLicenses(this.draftRegistration()?.providerId ?? environment.defaultProvider); + this.actions.fetchLicenses(this.draftRegistration()?.providerId ?? this.environment.defaultProvider); } }); @@ -155,31 +164,27 @@ export class ReviewComponent { } openSelectComponentsForRegistrationDialog(): void { - this.dialogService + this.customDialogService .open(SelectComponentsDialogComponent, { + header: 'registries.review.selectComponents.title', width: '552px', - focusOnShow: false, - header: this.translateService.instant('registries.review.selectComponents.title'), - closeOnEscape: true, - modal: true, data: { parent: this.draftRegistration()?.branchedFrom, components: this.components(), }, }) .onClose.subscribe((selectedComponents) => { - this.openConfirmRegistrationDialog(selectedComponents); + if (selectedComponents) { + this.openConfirmRegistrationDialog(selectedComponents); + } }); } openConfirmRegistrationDialog(components?: string[]): void { - this.dialogService + this.customDialogService .open(ConfirmRegistrationDialogComponent, { + header: 'registries.review.confirmation.title', width: '552px', - focusOnShow: false, - header: this.translateService.instant('registries.review.confirmation.title'), - closeOnEscape: true, - modal: true, data: { draftId: this.draftId(), projectId: @@ -193,7 +198,7 @@ export class ReviewComponent { .onClose.subscribe((res) => { if (res) { this.toastService.showSuccess('registries.review.confirmation.successMessage'); - this.router.navigate([`registries/${this.newRegistration()?.id}/overview`]); + this.router.navigate([`/${this.newRegistration()?.id}/overview`]); } else { if (this.components()?.length) { this.openSelectComponentsForRegistrationDialog(); diff --git a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html index 4b55e05b6..334e43284 100644 --- a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html +++ b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html @@ -1,6 +1,12 @@

{{ 'registries.review.selectComponents.description' | translate }}

- +
{ let component: SelectComponentsDialogComponent; let fixture: ComponentFixture; + let dialogRefMock: { close: jest.Mock }; + let dialogConfigMock: DynamicDialogConfig; + + const parent = { id: 'p1', title: 'Parent Project' } as any; + const components = [ + { id: 'c1', title: 'Child 1', children: [{ id: 'c1a', title: 'Child 1A' }] }, + { id: 'c2', title: 'Child 2' }, + ] as any; beforeEach(async () => { + dialogRefMock = { close: jest.fn() } as any; + dialogConfigMock = { data: { parent, components } } as any; + await TestBed.configureTestingModule({ - imports: [SelectComponentsDialogComponent], + imports: [SelectComponentsDialogComponent, OSFTestingModule], + providers: [ + MockProvider(DynamicDialogRef, dialogRefMock as any), + MockProvider(DynamicDialogConfig, dialogConfigMock as any), + ], }).compileComponents(); fixture = TestBed.createComponent(SelectComponentsDialogComponent); @@ -16,7 +37,23 @@ describe('SelectComponentsDialogComponent', () => { fixture.detectChanges(); }); - it('should create', () => { + it('should create and initialize tree with parent and children', () => { expect(component).toBeTruthy(); + expect(component.components.length).toBe(1); + const root = component.components[0]; + expect(root.label).toBe('Parent Project'); + expect(root.children?.length).toBe(2); + const selectedKeys = new Set(component.selectedComponents.map((n) => n.key)); + expect(selectedKeys.has('p1')).toBe(true); + expect(selectedKeys.has('c1')).toBe(true); + expect(selectedKeys.has('c1a')).toBe(true); + expect(selectedKeys.has('c2')).toBe(true); + }); + + it('should close with unique selected component ids including parent on continue', () => { + component.continue(); + expect(dialogRefMock.close).toHaveBeenCalledWith(expect.arrayContaining(['p1', 'c1', 'c1a', 'c2'])); + const passed = (dialogRefMock.close as jest.Mock).mock.calls[0][0] as string[]; + expect(new Set(passed).size).toBe(passed.length); }); }); diff --git a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts index 4daae3712..25350b20b 100644 --- a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts +++ b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts @@ -7,7 +7,7 @@ import { Tree } from 'primeng/tree'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { Project } from '../../models'; +import { ProjectShortInfoModel } from '../../models'; @Component({ selector: 'osf-select-components-dialog', @@ -17,10 +17,10 @@ import { Project } from '../../models'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SelectComponentsDialogComponent { - protected readonly dialogRef = inject(DynamicDialogRef); + readonly dialogRef = inject(DynamicDialogRef); readonly config = inject(DynamicDialogConfig); selectedComponents: TreeNode[] = []; - parent: Project = this.config.data.parent; + parent: ProjectShortInfoModel = this.config.data.parent; components: TreeNode[] = []; constructor() { @@ -37,7 +37,7 @@ export class SelectComponentsDialogComponent { this.selectedComponents.push({ key: this.parent.id }); } - private mapProjectToTreeNode = (project: Project): TreeNode => { + private mapProjectToTreeNode = (project: ProjectShortInfoModel): TreeNode => { this.selectedComponents.push({ key: project.id, }); diff --git a/src/app/features/registries/mappers/files.mapper.ts b/src/app/features/registries/mappers/files.mapper.ts index bde6bd742..40ac9a1fe 100644 --- a/src/app/features/registries/mappers/files.mapper.ts +++ b/src/app/features/registries/mappers/files.mapper.ts @@ -1,7 +1,7 @@ -import { FilePayloadJsonApi, OsfFile } from '@osf/shared/models'; +import { FileModel, FilePayloadJsonApi } from '@osf/shared/models'; export class FilesMapper { - static toFilePayload(file: OsfFile): FilePayloadJsonApi { + static toFilePayload(file: FileModel): FilePayloadJsonApi { return { file_id: file.id, file_name: file.name, diff --git a/src/app/features/registries/mappers/index.ts b/src/app/features/registries/mappers/index.ts index 91c916d86..8c8a64cf3 100644 --- a/src/app/features/registries/mappers/index.ts +++ b/src/app/features/registries/mappers/index.ts @@ -1,3 +1 @@ export * from './licenses.mapper'; -export * from './projects.mapper'; -export * from './providers.mapper'; diff --git a/src/app/features/registries/mappers/licenses.mapper.ts b/src/app/features/registries/mappers/licenses.mapper.ts index 27e12fd9a..8072bc716 100644 --- a/src/app/features/registries/mappers/licenses.mapper.ts +++ b/src/app/features/registries/mappers/licenses.mapper.ts @@ -1,7 +1,7 @@ -import { License, LicensesResponseJsonApi } from '@osf/shared/models'; +import { LicenseModel, LicensesResponseJsonApi } from '@osf/shared/models'; export class LicensesMapper { - static fromLicensesResponse(response: LicensesResponseJsonApi): License[] { + static fromLicensesResponse(response: LicensesResponseJsonApi): LicenseModel[] { return response.data.map((item) => ({ id: item.id, name: item.attributes.name, diff --git a/src/app/features/registries/mappers/projects.mapper.ts b/src/app/features/registries/mappers/projects.mapper.ts deleted file mode 100644 index df0729851..000000000 --- a/src/app/features/registries/mappers/projects.mapper.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Project, ProjectsResponseJsonApi } from '../models'; - -export class ProjectsMapper { - static fromProjectsResponse(response: ProjectsResponseJsonApi): Project[] { - return response.data.map((item) => ({ - id: item.id, - title: item.attributes.title, - })); - } -} diff --git a/src/app/features/registries/mappers/providers.mapper.ts b/src/app/features/registries/mappers/providers.mapper.ts deleted file mode 100644 index 46139e7b6..000000000 --- a/src/app/features/registries/mappers/providers.mapper.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { RegistryProviderDetails } from '@osf/features/registries/models/registry-provider.model'; -import { RegistryProviderDetailsJsonApi } from '@osf/features/registries/models/registry-provider-json-api.model'; -import { ProvidersResponseJsonApi } from '@osf/shared/models'; - -import { ProviderSchema } from '../models'; - -export class ProvidersMapper { - static fromProvidersResponse(response: ProvidersResponseJsonApi): ProviderSchema[] { - return response.data.map((item) => ({ - id: item.id, - name: item.attributes.name, - })); - } - - static fromRegistryProvider(response: RegistryProviderDetailsJsonApi): RegistryProviderDetails { - const brandRaw = response.embeds!.brand.data; - return { - id: response.id, - name: response.attributes.name, - descriptionHtml: response.attributes.description, - brand: { - id: brandRaw.id, - name: brandRaw.attributes.name, - heroLogoImageUrl: brandRaw.attributes.hero_logo_image, - heroBackgroundImageUrl: brandRaw.attributes.hero_background_image, - topNavLogoImageUrl: brandRaw.attributes.topnav_logo_image, - primaryColor: brandRaw.attributes.primary_color, - secondaryColor: brandRaw.attributes.secondary_color, - backgroundColor: brandRaw.attributes.background_color, - }, - iri: response.links.iri, - }; - } -} diff --git a/src/app/features/registries/models/index.ts b/src/app/features/registries/models/index.ts index abedd2a68..101e1aeac 100644 --- a/src/app/features/registries/models/index.ts +++ b/src/app/features/registries/models/index.ts @@ -1,5 +1 @@ -export * from './project'; -export * from './projects-json-api.model'; -export * from './provider-schema.model'; -export * from './registry-provider.model'; -export * from './registry-provider-json-api.model'; +export * from './project-short-info.model'; diff --git a/src/app/features/registries/models/project-short-info.model.ts b/src/app/features/registries/models/project-short-info.model.ts new file mode 100644 index 000000000..86e569c39 --- /dev/null +++ b/src/app/features/registries/models/project-short-info.model.ts @@ -0,0 +1,5 @@ +export interface ProjectShortInfoModel { + id: string; + title: string; + children?: ProjectShortInfoModel[]; +} diff --git a/src/app/features/registries/models/project.ts b/src/app/features/registries/models/project.ts deleted file mode 100644 index dc1ae9d02..000000000 --- a/src/app/features/registries/models/project.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface Project { - id: string; - title: string; - children?: Project[]; -} diff --git a/src/app/features/registries/models/projects-json-api.model.ts b/src/app/features/registries/models/projects-json-api.model.ts deleted file mode 100644 index 468e34c0c..000000000 --- a/src/app/features/registries/models/projects-json-api.model.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ApiData, MetaJsonApi, PaginationLinksJsonApi } from '@osf/shared/models'; - -export interface ProjectsResponseJsonApi { - data: ProjectsDataJsonApi[]; - meta: MetaJsonApi; - links: PaginationLinksJsonApi; -} - -export type ProjectsDataJsonApi = ApiData; - -interface ProjectsAttributesJsonApi { - title: string; -} diff --git a/src/app/features/registries/models/registry-provider-json-api.model.ts b/src/app/features/registries/models/registry-provider-json-api.model.ts deleted file mode 100644 index d74327e65..000000000 --- a/src/app/features/registries/models/registry-provider-json-api.model.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { BrandDataJsonApi } from '@shared/models'; - -export interface RegistryProviderDetailsJsonApi { - id: string; - type: 'registration-providers'; - attributes: { - name: string; - description: string; - }; - embeds?: { - brand: { - data: BrandDataJsonApi; - }; - }; - links: { - iri: string; - }; -} diff --git a/src/app/features/registries/models/registry-provider.model.ts b/src/app/features/registries/models/registry-provider.model.ts deleted file mode 100644 index 6d6673440..000000000 --- a/src/app/features/registries/models/registry-provider.model.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Brand } from '@shared/models'; - -export interface RegistryProviderDetails { - id: string; - name: string; - descriptionHtml: string; - brand: Brand; - iri: string; -} diff --git a/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts b/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts index 80806fd94..5b2151e10 100644 --- a/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts +++ b/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts @@ -1,14 +1,43 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { RegistriesSelectors } from '@osf/features/registries/store'; import { DraftRegistrationCustomStepComponent } from './draft-registration-custom-step.component'; -describe('DraftRegistrationCustomStepComponent', () => { +import { MOCK_REGISTRIES_PAGE } from '@testing/mocks/registries.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe.skip('DraftRegistrationCustomStepComponent', () => { let component: DraftRegistrationCustomStepComponent; let fixture: ComponentFixture; + let mockActivatedRoute: ReturnType; + let mockRouter: ReturnType; beforeEach(async () => { + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1', step: '1' }).build(); + mockRouter = RouterMockBuilder.create().withUrl('/registries/prov-1/draft/draft-1/custom').build(); + await TestBed.configureTestingModule({ - imports: [DraftRegistrationCustomStepComponent], + imports: [DraftRegistrationCustomStepComponent, OSFTestingModule], + providers: [ + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: Router, useValue: mockRouter }, + provideMockStore({ + signals: [ + { selector: RegistriesSelectors.getStepsData, value: {} }, + { + selector: RegistriesSelectors.getDraftRegistration, + value: { id: 'draft-1', providerId: 'prov-1', branchedFrom: { id: 'node-1', filesLink: '/files' } }, + }, + { selector: RegistriesSelectors.getPagesSchema, value: [MOCK_REGISTRIES_PAGE] }, + { selector: RegistriesSelectors.getStepsState, value: { 1: { invalid: false } } }, + ], + }), + ], }).compileComponents(); fixture = TestBed.createComponent(DraftRegistrationCustomStepComponent); @@ -19,4 +48,30 @@ describe('DraftRegistrationCustomStepComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should compute inputs from draft registration', () => { + expect(component.filesLink()).toBe('/files'); + expect(component.provider()).toBe('prov-1'); + expect(component.projectId()).toBe('node-1'); + }); + + it('should dispatch updateDraft on onUpdateAction', () => { + const actionsMock = { updateDraft: jest.fn() } as any; + Object.defineProperty(component, 'actions', { value: actionsMock }); + + component.onUpdateAction({ a: 1 } as any); + expect(actionsMock.updateDraft).toHaveBeenCalledWith('draft-1', { registration_responses: { a: 1 } }); + }); + + it('should navigate back to metadata on onBack', () => { + const navigateSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + component.onBack(); + expect(navigateSpy).toHaveBeenCalledWith(['../', 'metadata'], { relativeTo: TestBed.inject(ActivatedRoute) }); + }); + + it('should navigate to review on onNext', () => { + const navigateSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + component.onNext(); + expect(navigateSpy).toHaveBeenCalledWith(['../', 'review'], { relativeTo: TestBed.inject(ActivatedRoute) }); + }); }); diff --git a/src/app/features/registries/pages/justification/justification.component.spec.ts b/src/app/features/registries/pages/justification/justification.component.spec.ts index 8a16191a7..d6f239047 100644 --- a/src/app/features/registries/pages/justification/justification.component.spec.ts +++ b/src/app/features/registries/pages/justification/justification.component.spec.ts @@ -1,14 +1,54 @@ +import { MockProvider } from 'ng-mocks'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { RegistriesSelectors } from '@osf/features/registries/store'; +import { LoaderService } from '@osf/shared/services'; import { JustificationComponent } from './justification.component'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + describe('JustificationComponent', () => { let component: JustificationComponent; let fixture: ComponentFixture; + let mockActivatedRoute: Partial; + let mockRouter: ReturnType; beforeEach(async () => { + mockActivatedRoute = { + snapshot: { + firstChild: { params: { id: 'rev-1', step: '0' } } as any, + } as any, + firstChild: { snapshot: { params: { id: 'rev-1', step: '0' } } } as any, + } as Partial; + mockRouter = RouterMockBuilder.create().withUrl('/registries/revisions/rev-1/justification').build(); + await TestBed.configureTestingModule({ - imports: [JustificationComponent], + imports: [JustificationComponent, OSFTestingModule], + providers: [ + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + MockProvider(LoaderService, { show: jest.fn(), hide: jest.fn() }), + provideMockStore({ + signals: [ + { selector: RegistriesSelectors.getPagesSchema, value: [] }, + { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false, touched: false } } }, + { + selector: RegistriesSelectors.getSchemaResponse, + value: { + registrationSchemaId: 'schema-1', + revisionJustification: 'Reason', + reviewsState: 'revision_in_progress', + }, + }, + { selector: RegistriesSelectors.getSchemaResponseRevisionData, value: {} }, + ], + }), + ], }).compileComponents(); fixture = TestBed.createComponent(JustificationComponent); @@ -19,4 +59,27 @@ describe('JustificationComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should compute steps with justification and review', () => { + const steps = component.steps(); + expect(steps.length).toBe(2); + expect(steps[0].value).toBe('justification'); + expect(steps[1].value).toBe('review'); + }); + + it('should navigate on stepChange', () => { + const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + component.stepChange({ index: 1, routeLink: '1', value: 'p1', label: 'Page 1' } as any); + expect(navSpy).toHaveBeenCalledWith(['/registries/revisions/rev-1/', 'review']); + }); + + it('should clear state on destroy', () => { + const actionsMock = { + clearState: jest.fn(), + getSchemaBlocks: jest.fn().mockReturnValue({ pipe: () => ({ subscribe: () => {} }) }), + } as any; + Object.defineProperty(component as any, 'actions', { value: actionsMock }); + fixture.destroy(); + expect(actionsMock.clearState).toHaveBeenCalled(); + }); }); diff --git a/src/app/features/registries/pages/justification/justification.component.ts b/src/app/features/registries/pages/justification/justification.component.ts index 9b6d4eb58..d73a2aa32 100644 --- a/src/app/features/registries/pages/justification/justification.component.ts +++ b/src/app/features/registries/pages/justification/justification.component.ts @@ -4,7 +4,17 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { filter, tap } from 'rxjs'; -import { ChangeDetectionStrategy, Component, computed, effect, inject, OnDestroy, Signal, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + OnDestroy, + Signal, + signal, + untracked, +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router'; @@ -13,13 +23,7 @@ import { RevisionReviewStates } from '@osf/shared/enums'; import { StepOption } from '@osf/shared/models'; import { LoaderService } from '@osf/shared/services'; -import { - ClearState, - FetchSchemaBlocks, - FetchSchemaResponse, - RegistriesSelectors, - UpdateStepValidation, -} from '../../store'; +import { ClearState, FetchSchemaBlocks, FetchSchemaResponse, RegistriesSelectors, UpdateStepState } from '../../store'; @Component({ selector: 'osf-justification', @@ -35,16 +39,16 @@ export class JustificationComponent implements OnDestroy { private readonly loaderService = inject(LoaderService); private readonly translateService = inject(TranslateService); - protected readonly pages = select(RegistriesSelectors.getPagesSchema); - protected readonly stepsValidation = select(RegistriesSelectors.getStepsValidation); - protected readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); - protected readonly schemaResponseRevisionData = select(RegistriesSelectors.getSchemaResponseRevisionData); + readonly pages = select(RegistriesSelectors.getPagesSchema); + readonly stepsState = select(RegistriesSelectors.getStepsState); + readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); + readonly schemaResponseRevisionData = select(RegistriesSelectors.getSchemaResponseRevisionData); private readonly actions = createDispatchMap({ getSchemaBlocks: FetchSchemaBlocks, clearState: ClearState, getSchemaResponse: FetchSchemaResponse, - updateStepValidation: UpdateStepValidation, + updateStepState: UpdateStepState, }); get isReviewPage(): boolean { @@ -56,11 +60,13 @@ export class JustificationComponent implements OnDestroy { revisionId = this.route.snapshot.firstChild?.params['id'] || ''; steps: Signal = computed(() => { + const isJustificationValid = !!this.schemaResponse()?.revisionJustification; this.justificationStep = { index: 0, value: 'justification', label: this.translateService.instant('registries.justification.step'), - invalid: false, + invalid: !isJustificationValid, + touched: isJustificationValid, routeLink: 'justification', disabled: this.schemaResponse()?.reviewsState !== RevisionReviewStates.RevisionInProgress, }; @@ -72,14 +78,15 @@ export class JustificationComponent implements OnDestroy { invalid: false, routeLink: 'review', }; - + const stepState = this.stepsState(); const customSteps = this.pages().map((page, index) => { return { index: index + 1, label: page.title, value: page.id, routeLink: `${index + 1}`, - invalid: this.stepsValidation()?.[index + 1]?.invalid || false, + invalid: stepState?.[index + 1]?.invalid || false, + touched: stepState?.[index + 1]?.touched || false, disabled: this.schemaResponse()?.reviewsState !== RevisionReviewStates.RevisionInProgress, }; }); @@ -139,8 +146,10 @@ export class JustificationComponent implements OnDestroy { }); effect(() => { + const stepState = untracked(() => this.stepsState()); + if (this.currentStepIndex() > 0) { - this.actions.updateStepValidation('0', true); + this.actions.updateStepState('0', true, stepState?.[0]?.touched || false); } if (this.pages().length && this.currentStepIndex() > 0 && this.schemaResponseRevisionData()) { for (let i = 1; i < this.currentStepIndex(); i++) { @@ -150,7 +159,7 @@ export class JustificationComponent implements OnDestroy { const questionData = this.schemaResponseRevisionData()[question.responseKey!]; return question.required && (Array.isArray(questionData) ? !questionData.length : !questionData); }) || false; - this.actions.updateStepValidation(i.toString(), isStepInvalid); + this.actions.updateStepState(i.toString(), isStepInvalid, stepState?.[i]?.touched || false); } } }); diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.html b/src/app/features/registries/pages/my-registrations/my-registrations.component.html index fc1b1733f..2795b3018 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.html +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.html @@ -65,11 +65,7 @@ } @for (registration of submittedRegistrations(); track $index) { - + } @if (submittedRegistrationsTotalCount() > itemsPerPage) { { let component: MyRegistrationsComponent; let fixture: ComponentFixture; + let mockRouter: ReturnType; + let mockActivatedRoute: Partial; beforeEach(async () => { + mockRouter = RouterMockBuilder.create().withUrl('/registries/me').build(); + mockActivatedRoute = { snapshot: { queryParams: {} } } as any; + await TestBed.configureTestingModule({ - imports: [MyRegistrationsComponent], + imports: [MyRegistrationsComponent, OSFTestingModule], + providers: [ + { provide: Router, useValue: mockRouter }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + MockProvider(CustomConfirmationService, { confirmDelete: jest.fn() }), + MockProvider(ToastService, { showSuccess: jest.fn(), showWarn: jest.fn(), showError: jest.fn() }), + provideMockStore({ + signals: [ + { selector: RegistriesSelectors.getDraftRegistrations, value: [] }, + { selector: RegistriesSelectors.getDraftRegistrationsTotalCount, value: 0 }, + { selector: RegistriesSelectors.isDraftRegistrationsLoading, value: false }, + { selector: RegistriesSelectors.getSubmittedRegistrations, value: [] }, + { selector: RegistriesSelectors.getSubmittedRegistrationsTotalCount, value: 0 }, + { selector: RegistriesSelectors.isSubmittedRegistrationsLoading, value: false }, + { selector: UserSelectors.getCurrentUser, value: { id: 'user-1' } }, + ], + }), + ], }).compileComponents(); fixture = TestBed.createComponent(MyRegistrationsComponent); @@ -19,4 +52,57 @@ describe('MyRegistrationsComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should default to submitted tab and fetch submitted registrations', () => { + const actionsMock = { + getDraftRegistrations: jest.fn(), + getSubmittedRegistrations: jest.fn(), + deleteDraft: jest.fn(), + } as any; + Object.defineProperty(component, 'actions', { value: actionsMock }); + + component.selectedTab.set(component.RegistrationTab.Drafts); + fixture.detectChanges(); + component.selectedTab.set(component.RegistrationTab.Submitted); + fixture.detectChanges(); + expect(actionsMock.getSubmittedRegistrations).toHaveBeenCalledWith('user-1'); + }); + + it('should navigate to create registration page', () => { + const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + component.goToCreateRegistration(); + expect(navSpy).toHaveBeenCalledWith(['/registries/osf/new']); + }); + + it('should handle drafts pagination', () => { + const actionsMock = { getDraftRegistrations: jest.fn() } as any; + Object.defineProperty(component, 'actions', { value: actionsMock }); + component.onDraftsPageChange({ page: 2, first: 20 } as any); + expect(actionsMock.getDraftRegistrations).toHaveBeenCalledWith(3); + expect(component.draftFirst).toBe(20); + }); + + it('should handle submitted pagination', () => { + const actionsMock = { getSubmittedRegistrations: jest.fn() } as any; + Object.defineProperty(component, 'actions', { value: actionsMock }); + component.onSubmittedPageChange({ page: 1, first: 10 } as any); + expect(actionsMock.getSubmittedRegistrations).toHaveBeenCalledWith('user-1', 2); + expect(component.submittedFirst).toBe(10); + }); + + it('should switch to drafts tab based on query param and fetch drafts', async () => { + (mockActivatedRoute.snapshot as any).queryParams = { tab: 'drafts' }; + const actionsMock = { getDraftRegistrations: jest.fn(), getSubmittedRegistrations: jest.fn() } as any; + fixture = TestBed.createComponent(MyRegistrationsComponent); + component = fixture.componentInstance; + Object.defineProperty(component, 'actions', { value: actionsMock }); + fixture.detectChanges(); + + expect(component.selectedTab()).toBe(0); + component.selectedTab.set(component.RegistrationTab.Submitted); + fixture.detectChanges(); + component.selectedTab.set(component.RegistrationTab.Drafts); + fixture.detectChanges(); + expect(actionsMock.getDraftRegistrations).toHaveBeenCalled(); + }); }); diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.ts b/src/app/features/registries/pages/my-registrations/my-registrations.component.ts index 8b0dda23d..bcbb17c1e 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.ts +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.ts @@ -7,14 +7,13 @@ import { PaginatorState } from 'primeng/paginator'; import { Skeleton } from 'primeng/skeleton'; import { TabsModule } from 'primeng/tabs'; -import { tap } from 'rxjs'; - import { NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, Component, effect, inject, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; import { UserSelectors } from '@core/store/user'; import { CustomPaginatorComponent, @@ -27,16 +26,7 @@ import { CustomConfirmationService, ToastService } from '@osf/shared/services'; import { REGISTRATIONS_TABS } from '../../constants'; import { RegistrationTab } from '../../enums'; -import { - CreateSchemaResponse, - DeleteDraft, - FetchAllSchemaResponses, - FetchDraftRegistrations, - FetchSubmittedRegistrations, - RegistriesSelectors, -} from '../../store'; - -import { environment } from 'src/environments/environment'; +import { DeleteDraft, FetchDraftRegistrations, FetchSubmittedRegistrations, RegistriesSelectors } from '../../store'; @Component({ selector: 'osf-my-registrations', @@ -58,11 +48,12 @@ import { environment } from 'src/environments/environment'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class MyRegistrationsComponent { - private router = inject(Router); + private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); private readonly customConfirmationService = inject(CustomConfirmationService); private readonly toastService = inject(ToastService); + private readonly environment = inject(ENVIRONMENT); readonly isMobile = toSignal(inject(IS_XSMALL)); readonly tabOptions = REGISTRATIONS_TABS; @@ -74,19 +65,16 @@ export class MyRegistrationsComponent { submittedRegistrations = select(RegistriesSelectors.getSubmittedRegistrations); submittedRegistrationsTotalCount = select(RegistriesSelectors.getSubmittedRegistrationsTotalCount); isSubmittedRegistrationsLoading = select(RegistriesSelectors.isSubmittedRegistrationsLoading); - schemaResponse = select(RegistriesSelectors.getSchemaResponse); actions = createDispatchMap({ getDraftRegistrations: FetchDraftRegistrations, getSubmittedRegistrations: FetchSubmittedRegistrations, deleteDraft: DeleteDraft, - getSchemaResponse: FetchAllSchemaResponses, - createSchemaResponse: CreateSchemaResponse, }); readonly RegistrationTab = RegistrationTab; - readonly provider = environment.defaultProvider; + readonly provider = this.environment.defaultProvider; selectedTab = signal(RegistrationTab.Submitted); itemsPerPage = 10; @@ -149,36 +137,4 @@ export class MyRegistrationsComponent { this.actions.getSubmittedRegistrations(this.currentUser()?.id, event.page! + 1); this.submittedFirst = event.first!; } - - onUpdateRegistration(id: string): void { - this.actions - .createSchemaResponse(id) - .pipe(tap(() => this.navigateToJustificationPage())) - .subscribe(); - } - - onContinueUpdateRegistration({ id, unapproved }: { id: string; unapproved: boolean }): void { - this.actions - .getSchemaResponse(id) - .pipe( - tap(() => { - if (unapproved) { - this.navigateToJustificationReview(); - } else { - this.navigateToJustificationPage(); - } - }) - ) - .subscribe(); - } - - private navigateToJustificationPage(): void { - const revisionId = this.schemaResponse()?.id; - this.router.navigate([`/registries/revisions/${revisionId}/justification`]); - } - - private navigateToJustificationReview(): void { - const revisionId = this.schemaResponse()?.id; - this.router.navigate([`/registries/revisions/${revisionId}/review`]); - } } diff --git a/src/app/features/registries/pages/registries-landing/registries-landing.component.html b/src/app/features/registries/pages/registries-landing/registries-landing.component.html index f7733e40d..378a6080d 100644 --- a/src/app/features/registries/pages/registries-landing/registries-landing.component.html +++ b/src/app/features/registries/pages/registries-landing/registries-landing.component.html @@ -8,7 +8,7 @@ [buttonLabel]="'registries.addRegistration' | translate" (buttonClick)="goToCreateRegistration()" /> - + {{ 'registries.browse' | translate }}

styleClass="hidden sm:block sm:w-auto" [label]="'registries.seeMore' | translate" severity="secondary" - (click)="redirectToSearchPageRegistrations()" + (onClick)="redirectToSearchPageRegistrations()" /> @if (!isRegistriesLoading()) { - @for (item of registries(); track item.id) { - + @for (item of registries(); track $index) { + } } @else { @@ -44,7 +44,7 @@

{{ 'registries.browse' | translate }}

styleClass="block w-full sm:hidden" [label]="'registries.seeMore' | translate" severity="secondary" - (click)="redirectToSearchPageRegistrations()" + (onClick)="redirectToSearchPageRegistrations()" />
diff --git a/src/app/features/registries/pages/registries-landing/registries-landing.component.scss b/src/app/features/registries/pages/registries-landing/registries-landing.component.scss index 4f4c2e9be..201936eb4 100644 --- a/src/app/features/registries/pages/registries-landing/registries-landing.component.scss +++ b/src/app/features/registries/pages/registries-landing/registries-landing.component.scss @@ -1,9 +1,3 @@ -@use "styles/variables" as var; - -.subheader { - color: var.$dark-blue-1; -} - .registries { - background: var.$white; + background: var(--white); } diff --git a/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts b/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts index 64450d266..7a4f91e7a 100644 --- a/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts +++ b/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts @@ -1,14 +1,58 @@ +import { MockComponents } from 'ng-mocks'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +import { ScheduledBannerComponent } from '@core/components/osf-banners/scheduled-banner/scheduled-banner.component'; +import { RegistryServicesComponent } from '@osf/features/registries/components'; +import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; +import { + LoadingSpinnerComponent, + ResourceCardComponent, + SearchInputComponent, + SubHeaderComponent, +} from '@shared/components'; + +import { RegistriesSelectors } from '../../store'; import { RegistriesLandingComponent } from './registries-landing.component'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + describe('RegistriesLandingComponent', () => { let component: RegistriesLandingComponent; let fixture: ComponentFixture; + let mockRouter: ReturnType; beforeEach(async () => { + mockRouter = RouterMockBuilder.create().withUrl('/registries').build(); + await TestBed.configureTestingModule({ - imports: [RegistriesLandingComponent], + imports: [ + RegistriesLandingComponent, + OSFTestingModule, + ...MockComponents( + SearchInputComponent, + RegistryServicesComponent, + ResourceCardComponent, + LoadingSpinnerComponent, + SubHeaderComponent, + ScheduledBannerComponent + ), + ], + providers: [ + { provide: Router, useValue: mockRouter }, + provideMockStore({ + signals: [ + { selector: RegistrationProviderSelectors.getBrandedProvider, value: null }, + { selector: RegistrationProviderSelectors.isBrandedProviderLoading, value: false }, + { selector: RegistriesSelectors.getRegistries, value: [] }, + { selector: RegistriesSelectors.isRegistriesLoading, value: false }, + ], + }), + ], }).compileComponents(); fixture = TestBed.createComponent(RegistriesLandingComponent); @@ -19,4 +63,52 @@ describe('RegistriesLandingComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should dispatch get registries and provider on init', () => { + const actionsMock = { + getRegistries: jest.fn(), + getProvider: jest.fn(), + clearCurrentProvider: jest.fn(), + clearRegistryProvider: jest.fn(), + } as any; + Object.defineProperty(component, 'actions', { value: actionsMock }); + + component.ngOnInit(); + + expect(actionsMock.getRegistries).toHaveBeenCalled(); + expect(actionsMock.getProvider).toHaveBeenCalledWith(component.defaultProvider); + }); + + it('should clear providers on destroy', () => { + const actionsMock = { + getRegistries: jest.fn(), + getProvider: jest.fn(), + clearCurrentProvider: jest.fn(), + clearRegistryProvider: jest.fn(), + } as any; + Object.defineProperty(component, 'actions', { value: actionsMock }); + + fixture.destroy(); + expect(actionsMock.clearCurrentProvider).toHaveBeenCalled(); + expect(actionsMock.clearRegistryProvider).toHaveBeenCalled(); + }); + + it('should navigate to search with value', () => { + const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + component.searchControl.setValue('abc'); + component.redirectToSearchPageWithValue(); + expect(navSpy).toHaveBeenCalledWith(['/search'], { queryParams: { search: 'abc', tab: 3 } }); + }); + + it('should navigate to search registrations tab', () => { + const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + component.redirectToSearchPageRegistrations(); + expect(navSpy).toHaveBeenCalledWith(['/search'], { queryParams: { tab: 3 } }); + }); + + it('should navigate to create page', () => { + const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + component.goToCreateRegistration(); + expect(navSpy).toHaveBeenCalledWith(['/registries/osf/new']); + }); }); diff --git a/src/app/features/registries/pages/registries-landing/registries-landing.component.ts b/src/app/features/registries/pages/registries-landing/registries-landing.component.ts index ec9091b64..12b0bc62f 100644 --- a/src/app/features/registries/pages/registries-landing/registries-landing.component.ts +++ b/src/app/features/registries/pages/registries-landing/registries-landing.component.ts @@ -4,21 +4,28 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; -import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit } from '@angular/core'; import { FormControl } from '@angular/forms'; import { Router } from '@angular/router'; -import { RegistryServicesComponent } from '@osf/features/registries/components'; -import { GetRegistries, RegistriesSelectors } from '@osf/features/registries/store'; +import { ScheduledBannerComponent } from '@core/components/osf-banners/scheduled-banner/scheduled-banner.component'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { ClearCurrentProvider } from '@core/store/provider'; import { LoadingSpinnerComponent, ResourceCardComponent, SearchInputComponent, SubHeaderComponent, -} from '@shared/components'; -import { ResourceTab } from '@shared/enums'; +} from '@osf/shared/components'; +import { ResourceType } from '@osf/shared/enums'; +import { + ClearRegistryProvider, + GetRegistryProvider, + RegistrationProviderSelectors, +} from '@osf/shared/stores/registration-provider'; -import { environment } from 'src/environments/environment'; +import { RegistryServicesComponent } from '../../components'; +import { GetRegistries, RegistriesSelectors } from '../../store'; @Component({ selector: 'osf-registries-landing', @@ -30,42 +37,52 @@ import { environment } from 'src/environments/environment'; ResourceCardComponent, LoadingSpinnerComponent, SubHeaderComponent, + ScheduledBannerComponent, ], templateUrl: './registries-landing.component.html', styleUrl: './registries-landing.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class RegistriesLandingComponent implements OnInit { +export class RegistriesLandingComponent implements OnInit, OnDestroy { private router = inject(Router); + private readonly environment = inject(ENVIRONMENT); - protected searchControl = new FormControl(''); - - private readonly actions = createDispatchMap({ + private actions = createDispatchMap({ getRegistries: GetRegistries, + getProvider: GetRegistryProvider, + clearCurrentProvider: ClearCurrentProvider, + clearRegistryProvider: ClearRegistryProvider, }); - protected registries = select(RegistriesSelectors.getRegistries); - protected isRegistriesLoading = select(RegistriesSelectors.isRegistriesLoading); + provider = select(RegistrationProviderSelectors.getBrandedProvider); + isProviderLoading = select(RegistrationProviderSelectors.isBrandedProviderLoading); + registries = select(RegistriesSelectors.getRegistries); + isRegistriesLoading = select(RegistriesSelectors.isRegistriesLoading); + + searchControl = new FormControl(''); + defaultProvider = this.environment.defaultProvider; ngOnInit(): void { this.actions.getRegistries(); + this.actions.getProvider(this.defaultProvider); + } + + ngOnDestroy(): void { + this.actions.clearCurrentProvider(); + this.actions.clearRegistryProvider(); } redirectToSearchPageWithValue(): void { const searchValue = this.searchControl.value; - this.router.navigate(['/search'], { - queryParams: { search: searchValue, resourceTab: ResourceTab.Registrations }, - }); + this.router.navigate(['/search'], { queryParams: { search: searchValue, tab: ResourceType.Registration } }); } redirectToSearchPageRegistrations(): void { - this.router.navigate(['/search'], { - queryParams: { resourceTab: ResourceTab.Registrations }, - }); + this.router.navigate(['/search'], { queryParams: { tab: ResourceType.Registration } }); } goToCreateRegistration(): void { - this.router.navigate([`/registries/${environment.defaultProvider}/new`]); + this.router.navigate([`/registries/${this.defaultProvider}/new`]); } } diff --git a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.html b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.html index 2af87a712..197b3db6f 100644 --- a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.html +++ b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.html @@ -2,60 +2,8 @@ [searchControl]="searchControl" [provider]="provider()" [isProviderLoading]="isProviderLoading()" -> +/> -
-
- -
- -
- -
- -
- -
- -
-
- - -
-
+@if (provider()) { + +} diff --git a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts index 81cbddd79..b8d8fd76d 100644 --- a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts +++ b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts @@ -1,22 +1,67 @@ +import { MockComponents, MockProvider } from 'ng-mocks'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { RegistryProviderHeroComponent } from '@osf/features/registries/components/registry-provider-hero/registry-provider-hero.component'; +import { CustomDialogService } from '@osf/shared/services'; +import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; +import { GlobalSearchComponent } from '@shared/components'; import { RegistriesProviderSearchComponent } from './registries-provider-search.component'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + describe('RegistriesProviderSearchComponent', () => { let component: RegistriesProviderSearchComponent; let fixture: ComponentFixture; beforeEach(async () => { + const routeMock = ActivatedRouteMockBuilder.create().withParams({ name: 'osf' }).build(); + await TestBed.configureTestingModule({ - imports: [RegistriesProviderSearchComponent], + imports: [ + RegistriesProviderSearchComponent, + OSFTestingModule, + ...MockComponents(GlobalSearchComponent, RegistryProviderHeroComponent), + ], + providers: [ + { provide: ActivatedRoute, useValue: routeMock }, + MockProvider(CustomDialogService, { open: jest.fn() }), + provideMockStore({ + signals: [ + { selector: RegistrationProviderSelectors.getBrandedProvider, value: { iri: 'http://iri/provider' } }, + { selector: RegistrationProviderSelectors.isBrandedProviderLoading, value: false }, + ], + }), + ], }).compileComponents(); fixture = TestBed.createComponent(RegistriesProviderSearchComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { + fixture.detectChanges(); expect(component).toBeTruthy(); }); + + it('should clear providers on destroy', () => { + fixture.detectChanges(); + + const actionsMock = { + getProvider: jest.fn(), + setDefaultFilterValue: jest.fn(), + setResourceType: jest.fn(), + clearCurrentProvider: jest.fn(), + clearRegistryProvider: jest.fn(), + } as any; + Object.defineProperty(component as any, 'actions', { value: actionsMock }); + + fixture.destroy(); + expect(actionsMock.clearCurrentProvider).toHaveBeenCalled(); + expect(actionsMock.clearRegistryProvider).toHaveBeenCalled(); + }); }); diff --git a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.ts b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.ts index dd2c0b779..f89270f47 100644 --- a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.ts +++ b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.ts @@ -1,293 +1,58 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { DialogService } from 'primeng/dynamicdialog'; - -import { debounceTime, distinctUntilChanged } from 'rxjs'; - -import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; -import { RegistryProviderHeroComponent } from '@osf/features/registries/components/registry-provider-hero/registry-provider-hero.component'; +import { ClearCurrentProvider } from '@core/store/provider'; +import { GlobalSearchComponent } from '@osf/shared/components'; +import { ResourceType } from '@osf/shared/enums'; +import { SetDefaultFilterValue, SetResourceType } from '@osf/shared/stores/global-search'; import { - FetchResources, - FetchResourcesByLink, - GetRegistryProviderBrand, - LoadFilterOptions, - LoadFilterOptionsAndSetValues, - RegistriesProviderSearchSelectors, - SetFilterValues, - UpdateFilterValue, - UpdateResourceType, - UpdateSortBy, -} from '@osf/features/registries/store/registries-provider-search'; -import { - FilterChipsComponent, - ReusableFilterComponent, - SearchHelpTutorialComponent, - SearchResultsContainerComponent, -} from '@shared/components'; -import { SEARCH_TAB_OPTIONS } from '@shared/constants'; -import { ResourceTab } from '@shared/enums'; -import { DiscoverableFilter } from '@shared/models'; + ClearRegistryProvider, + GetRegistryProvider, + RegistrationProviderSelectors, +} from '@osf/shared/stores/registration-provider'; + +import { RegistryProviderHeroComponent } from '../../components/registry-provider-hero/registry-provider-hero.component'; @Component({ selector: 'osf-registries-provider-search', - imports: [ - RegistryProviderHeroComponent, - FilterChipsComponent, - ReusableFilterComponent, - SearchHelpTutorialComponent, - SearchResultsContainerComponent, - ], + imports: [RegistryProviderHeroComponent, GlobalSearchComponent], templateUrl: './registries-provider-search.component.html', styleUrl: './registries-provider-search.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [DialogService], }) -export class RegistriesProviderSearchComponent { - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly destroyRef = inject(DestroyRef); - - protected readonly provider = select(RegistriesProviderSearchSelectors.getBrandedProvider); - protected readonly isProviderLoading = select(RegistriesProviderSearchSelectors.isBrandedProviderLoading); - protected readonly resources = select(RegistriesProviderSearchSelectors.getResources); - protected readonly isResourcesLoading = select(RegistriesProviderSearchSelectors.getResourcesLoading); - protected readonly resourcesCount = select(RegistriesProviderSearchSelectors.getResourcesCount); - protected readonly resourceType = select(RegistriesProviderSearchSelectors.getResourceType); - protected readonly filters = select(RegistriesProviderSearchSelectors.getFilters); - protected readonly selectedValues = select(RegistriesProviderSearchSelectors.getFilterValues); - protected readonly selectedSort = select(RegistriesProviderSearchSelectors.getSortBy); - protected readonly first = select(RegistriesProviderSearchSelectors.getFirst); - protected readonly next = select(RegistriesProviderSearchSelectors.getNext); - protected readonly previous = select(RegistriesProviderSearchSelectors.getPrevious); - - searchControl = new FormControl(''); - - private readonly actions = createDispatchMap({ - getProvider: GetRegistryProviderBrand, - updateResourceType: UpdateResourceType, - updateSortBy: UpdateSortBy, - loadFilterOptions: LoadFilterOptions, - loadFilterOptionsAndSetValues: LoadFilterOptionsAndSetValues, - setFilterValues: SetFilterValues, - updateFilterValue: UpdateFilterValue, - fetchResourcesByLink: FetchResourcesByLink, - fetchResources: FetchResources, +export class RegistriesProviderSearchComponent implements OnInit, OnDestroy { + private route = inject(ActivatedRoute); + + private actions = createDispatchMap({ + getProvider: GetRegistryProvider, + setDefaultFilterValue: SetDefaultFilterValue, + setResourceType: SetResourceType, + clearCurrentProvider: ClearCurrentProvider, + clearRegistryProvider: ClearRegistryProvider, }); - protected currentStep = signal(0); - protected isFiltersOpen = signal(false); - protected isSortingOpen = signal(false); - - private readonly tabUrlMap = new Map( - SEARCH_TAB_OPTIONS.map((option) => [option.value, option.label.split('.').pop()?.toLowerCase() || 'all']) - ); - - private readonly urlTabMap = new Map( - SEARCH_TAB_OPTIONS.map((option) => [option.label.split('.').pop()?.toLowerCase() || 'all', option.value]) - ); - - readonly filterLabels = computed(() => { - const filtersData = this.filters(); - const labels: Record = {}; - filtersData.forEach((filter) => { - if (filter.key && filter.label) { - labels[filter.key] = filter.label; - } - }); - return labels; - }); - - readonly filterOptions = computed(() => { - const filtersData = this.filters(); - const options: Record = {}; - filtersData.forEach((filter) => { - if (filter.key && filter.options) { - options[filter.key] = filter.options.map((opt) => ({ - id: String(opt.value || ''), - value: String(opt.value || ''), - label: opt.label, - })); - } - }); - return options; - }); - - constructor() { - this.restoreFiltersFromUrl(); - this.restoreSearchFromUrl(); - this.handleSearch(); - - this.route.params.subscribe((params) => { - const name = params['name']; - if (name) { - this.actions.getProvider(name); - } - }); - } - - onSortChanged(sort: string): void { - this.actions.updateSortBy(sort); - this.actions.fetchResources(); - } - - onFilterChipRemoved(filterKey: string): void { - this.actions.updateFilterValue(filterKey, null); - - const currentFilters = this.selectedValues(); - const updatedFilters = { ...currentFilters }; - delete updatedFilters[filterKey]; - this.updateUrlWithFilters(updatedFilters); - - this.actions.fetchResources(); - } - - onAllFiltersCleared(): void { - this.actions.setFilterValues({}); - - this.searchControl.setValue('', { emitEvent: false }); - this.actions.updateFilterValue('search', ''); - - const queryParams: Record = { ...this.route.snapshot.queryParams }; - - Object.keys(queryParams).forEach((key) => { - if (key.startsWith('filter_')) { - delete queryParams[key]; - } - }); - - delete queryParams['search']; - - this.router.navigate([], { - relativeTo: this.route, - queryParams, - queryParamsHandling: 'replace', - replaceUrl: true, - }); - } - - onLoadFilterOptions(event: { filterType: string; filter: DiscoverableFilter }): void { - this.actions.loadFilterOptions(event.filterType); - } + provider = select(RegistrationProviderSelectors.getBrandedProvider); + isProviderLoading = select(RegistrationProviderSelectors.isBrandedProviderLoading); - onFilterChanged(event: { filterType: string; value: string | null }): void { - this.actions.updateFilterValue(event.filterType, event.value); - - const currentFilters = this.selectedValues(); - const updatedFilters = { - ...currentFilters, - [event.filterType]: event.value, - }; - - Object.keys(updatedFilters).forEach((key) => { - if (!updatedFilters[key]) { - delete updatedFilters[key]; - } - }); - - this.updateUrlWithFilters(updatedFilters); - } - - onPageChanged(link: string): void { - this.actions.fetchResourcesByLink(link); - } - - onFiltersToggled(): void { - this.isFiltersOpen.update((open) => !open); - this.isSortingOpen.set(false); - } - - onSortingToggled(): void { - this.isSortingOpen.update((open) => !open); - this.isFiltersOpen.set(false); - } - - showTutorial() { - this.currentStep.set(1); - } - - private updateUrlWithFilters(filterValues: Record): void { - const queryParams: Record = { ...this.route.snapshot.queryParams }; - - Object.keys(queryParams).forEach((key) => { - if (key.startsWith('filter_')) { - delete queryParams[key]; - } - }); - - Object.entries(filterValues).forEach(([key, value]) => { - if (value && value.trim() !== '') { - queryParams[`filter_${key}`] = value; - } - }); - - this.router.navigate([], { - relativeTo: this.route, - queryParams, - queryParamsHandling: 'replace', - replaceUrl: true, - }); - } - - private updateUrlWithTab(tab: ResourceTab): void { - const queryParams: Record = { ...this.route.snapshot.queryParams }; - - if (tab !== ResourceTab.All) { - queryParams['tab'] = this.tabUrlMap.get(tab) || 'all'; - } else { - delete queryParams['tab']; - } - - this.router.navigate([], { - relativeTo: this.route, - queryParams, - queryParamsHandling: 'replace', - replaceUrl: true, - }); - } - - private restoreFiltersFromUrl(): void { - const queryParams = this.route.snapshot.queryParams; - const filterValues: Record = {}; - - Object.keys(queryParams).forEach((key) => { - if (key.startsWith('filter_')) { - const filterKey = key.replace('filter_', ''); - const filterValue = queryParams[key]; - if (filterValue) { - filterValues[filterKey] = filterValue; - } - } - }); + searchControl = new FormControl(''); - if (Object.keys(filterValues).length > 0) { - this.actions.loadFilterOptionsAndSetValues(filterValues); - } - } - private restoreSearchFromUrl(): void { - const queryParams = this.route.snapshot.queryParams; - const searchTerm = queryParams['search']; - if (searchTerm) { - this.searchControl.setValue(searchTerm, { emitEvent: false }); - this.actions.updateFilterValue('search', searchTerm); + ngOnInit(): void { + const providerId = this.route.snapshot.params['providerId']; + if (providerId) { + this.actions.getProvider(providerId).subscribe({ + next: () => { + this.actions.setDefaultFilterValue('publisher', this.provider()!.iri!); + this.actions.setResourceType(ResourceType.Registration); + }, + }); } } - private handleSearch(): void { - this.searchControl.valueChanges - .pipe(debounceTime(1000), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: (newValue) => { - this.actions.updateFilterValue('search', newValue); - this.router.navigate([], { - relativeTo: this.route, - queryParams: { search: newValue }, - queryParamsHandling: 'merge', - }); - }, - }); + ngOnDestroy(): void { + this.actions.clearCurrentProvider(); + this.actions.clearRegistryProvider(); } } diff --git a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts index cc923b654..dc883e7b5 100644 --- a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts +++ b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts @@ -1,14 +1,48 @@ +import { MockComponents, MockProvider } from 'ng-mocks'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { CustomStepComponent } from '@osf/features/registries/components/custom-step/custom-step.component'; +import { RegistriesSelectors } from '@osf/features/registries/store'; import { RevisionsCustomStepComponent } from './revisions-custom-step.component'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + describe('RevisionsCustomStepComponent', () => { let component: RevisionsCustomStepComponent; let fixture: ComponentFixture; + let mockActivatedRoute: ReturnType; + let mockRouter: ReturnType; beforeEach(async () => { + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'rev-1', step: '1' }).build(); + mockRouter = RouterMockBuilder.create().withUrl('/registries/revisions/rev-1/1').build(); + await TestBed.configureTestingModule({ - imports: [RevisionsCustomStepComponent], + imports: [RevisionsCustomStepComponent, OSFTestingModule, MockComponents(CustomStepComponent)], + providers: [ + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + provideMockStore({ + signals: [ + { + selector: RegistriesSelectors.getSchemaResponse, + value: { + registrationId: 'reg-1', + filesLink: '/files', + revisionJustification: 'because', + revisionResponses: { a: 1 }, + }, + }, + { selector: RegistriesSelectors.getSchemaResponseRevisionData, value: { a: 1 } }, + ], + }), + ], }).compileComponents(); fixture = TestBed.createComponent(RevisionsCustomStepComponent); @@ -19,4 +53,30 @@ describe('RevisionsCustomStepComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should compute inputs from schema response', () => { + expect(component.filesLink()).toBe('/files'); + expect(component.provider()).toBe('reg-1'); + expect(component.projectId()).toBe('reg-1'); + expect(component.stepsData()).toEqual({ a: 1 }); + }); + + it('should dispatch updateRevision on onUpdateAction', () => { + const actionsMock = { updateRevision: jest.fn() } as any; + Object.defineProperty(component, 'actions', { value: actionsMock }); + component.onUpdateAction({ x: 2 }); + expect(actionsMock.updateRevision).toHaveBeenCalledWith('rev-1', 'because', { x: 2 }); + }); + + it('should navigate back to justification on onBack', () => { + const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + component.onBack(); + expect(navSpy).toHaveBeenCalledWith(['../', 'justification'], { relativeTo: TestBed.inject(ActivatedRoute) }); + }); + + it('should navigate to review on onNext', () => { + const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + component.onNext(); + expect(navSpy).toHaveBeenCalledWith(['../', 'review'], { relativeTo: TestBed.inject(ActivatedRoute) }); + }); }); diff --git a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.ts b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.ts index b2b7012a9..e59a55ef7 100644 --- a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.ts +++ b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.ts @@ -14,12 +14,12 @@ import { RegistriesSelectors, UpdateSchemaResponse } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class RevisionsCustomStepComponent { - protected readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); - protected readonly schemaResponseRevisionData = select(RegistriesSelectors.getSchemaResponseRevisionData); + readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); + readonly schemaResponseRevisionData = select(RegistriesSelectors.getSchemaResponseRevisionData); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - protected actions = createDispatchMap({ + actions = createDispatchMap({ updateRevision: UpdateSchemaResponse, }); @@ -35,7 +35,7 @@ export class RevisionsCustomStepComponent { return this.schemaResponse()?.registrationId || ''; }); - protected stepsData = computed(() => { + stepsData = computed(() => { const schemaResponse = this.schemaResponse(); return schemaResponse?.revisionResponses || {}; }); diff --git a/src/app/features/registries/registries.component.spec.ts b/src/app/features/registries/registries.component.spec.ts index 01003ff5e..e0c522f9d 100644 --- a/src/app/features/registries/registries.component.spec.ts +++ b/src/app/features/registries/registries.component.spec.ts @@ -1,22 +1,35 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HelpScoutService } from '@core/services/help-scout.service'; + import { RegistriesComponent } from './registries.component'; -describe('RegistriesComponent', () => { - let component: RegistriesComponent; +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('Component: Registries', () => { let fixture: ComponentFixture; + let helpScoutService: HelpScoutService; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [RegistriesComponent], + imports: [RegistriesComponent, OSFTestingModule], + providers: [ + { + provide: HelpScoutService, + useValue: { + setResourceType: jest.fn(), + unsetResourceType: jest.fn(), + }, + }, + ], }).compileComponents(); + helpScoutService = TestBed.inject(HelpScoutService); fixture = TestBed.createComponent(RegistriesComponent); - component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should called the helpScoutService', () => { + expect(helpScoutService.setResourceType).toHaveBeenCalledWith('registration'); }); }); diff --git a/src/app/features/registries/registries.component.ts b/src/app/features/registries/registries.component.ts index 78716bee1..841cff891 100644 --- a/src/app/features/registries/registries.component.ts +++ b/src/app/features/registries/registries.component.ts @@ -1,6 +1,8 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { HelpScoutService } from '@core/services/help-scout.service'; + @Component({ selector: 'osf-registries', imports: [RouterOutlet], @@ -8,4 +10,13 @@ import { RouterOutlet } from '@angular/router'; styleUrl: './registries.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class RegistriesComponent {} +export class RegistriesComponent implements OnDestroy { + private readonly helpScoutService = inject(HelpScoutService); + constructor() { + this.helpScoutService.setResourceType('registration'); + } + + ngOnDestroy(): void { + this.helpScoutService.unsetResourceType(); + } +} diff --git a/src/app/features/registries/registries.routes.ts b/src/app/features/registries/registries.routes.ts index c2ce29a6f..9e5e74892 100644 --- a/src/app/features/registries/registries.routes.ts +++ b/src/app/features/registries/registries.routes.ts @@ -2,11 +2,12 @@ import { provideStates } from '@ngxs/store'; import { Routes } from '@angular/router'; +import { registrationModerationGuard } from '@core/guards/registration-moderation.guard'; import { authGuard } from '@osf/core/guards'; import { RegistriesComponent } from '@osf/features/registries/registries.component'; import { RegistriesState } from '@osf/features/registries/store'; -import { RegistriesProviderSearchState } from '@osf/features/registries/store/registries-provider-search'; import { CitationsState, ContributorsState, SubjectsState } from '@osf/shared/stores'; +import { RegistrationProviderState } from '@osf/shared/stores/registration-provider'; import { LicensesHandlers, ProjectsHandlers, ProvidersHandlers } from './store/handlers'; import { FilesHandlers } from './store/handlers/files.handlers'; @@ -17,7 +18,7 @@ export const registriesRoutes: Routes = [ path: '', component: RegistriesComponent, providers: [ - provideStates([RegistriesState, CitationsState, ContributorsState, SubjectsState, RegistriesProviderSearchState]), + provideStates([RegistriesState, CitationsState, ContributorsState, SubjectsState, RegistrationProviderState]), ProvidersHandlers, ProjectsHandlers, LicensesHandlers, @@ -28,14 +29,14 @@ export const registriesRoutes: Routes = [ { path: '', pathMatch: 'full', - redirectTo: 'overview', + redirectTo: 'discover', }, { path: 'discover', loadComponent: () => import('@osf/features/registries/pages').then((c) => c.RegistriesLandingComponent), }, { - path: ':name', + path: ':providerId', loadComponent: () => import('@osf/features/registries/pages/registries-provider-search/registries-provider-search.component').then( (c) => c.RegistriesProviderSearchComponent @@ -43,7 +44,7 @@ export const registriesRoutes: Routes = [ }, { path: ':providerId/moderation', - canActivate: [authGuard], + canActivate: [authGuard, registrationModerationGuard], loadChildren: () => import('@osf/features/moderation/registry-moderation.routes').then((c) => c.registryModerationRoutes), }, @@ -62,7 +63,9 @@ export const registriesRoutes: Routes = [ { path: ':id/metadata', loadComponent: () => - import('./components/metadata/metadata.component').then((mod) => mod.MetadataComponent), + import('./components/registries-metadata-step/registries-metadata-step.component').then( + (mod) => mod.RegistriesMetadataStepComponent + ), }, { path: ':id/review', diff --git a/src/app/features/registries/services/index.ts b/src/app/features/registries/services/index.ts index c4eb84e78..0dca62501 100644 --- a/src/app/features/registries/services/index.ts +++ b/src/app/features/registries/services/index.ts @@ -1,5 +1,3 @@ export * from './licenses.service'; -export * from './projects.service'; -export * from './providers.service'; -export * from './registration-files.service'; export * from './registries.service'; +export * from '@osf/shared/services/registration-provider.service'; diff --git a/src/app/features/registries/services/licenses.service.ts b/src/app/features/registries/services/licenses.service.ts index 2b35cb689..e20ac47ee 100644 --- a/src/app/features/registries/services/licenses.service.ts +++ b/src/app/features/registries/services/licenses.service.ts @@ -2,12 +2,13 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; import { RegistrationMapper } from '@osf/shared/mappers/registration'; import { CreateRegistrationPayloadJsonApi, DraftRegistrationDataJsonApi, DraftRegistrationModel, - License, + LicenseModel, LicenseOptions, LicensesResponseJsonApi, } from '@osf/shared/models'; @@ -15,27 +16,23 @@ import { JsonApiService } from '@osf/shared/services'; import { LicensesMapper } from '../mappers'; -import { environment } from 'src/environments/environment'; - @Injectable({ providedIn: 'root', }) export class LicensesService { - private apiUrl = environment.apiUrl; private readonly jsonApiService = inject(JsonApiService); + private readonly environment = inject(ENVIRONMENT); + + get apiUrl() { + return `${this.environment.apiDomainUrl}/v2`; + } - getLicenses(providerId: string): Observable { + getLicenses(providerId: string): Observable { return this.jsonApiService .get(`${this.apiUrl}/providers/registrations/${providerId}/licenses/`, { - params: { - 'page[size]': 100, - }, + 'page[size]': 100, }) - .pipe( - map((licenses) => { - return LicensesMapper.fromLicensesResponse(licenses); - }) - ); + .pipe(map((licenses) => LicensesMapper.fromLicensesResponse(licenses))); } updateLicense( @@ -56,12 +53,15 @@ export class LicensesService { }, }, attributes: { - ...(licenseOptions && { - node_license: { - copyright_holders: [licenseOptions.copyrightHolders], - year: licenseOptions.year, - }, - }), + node_license: licenseOptions + ? { + copyright_holders: [licenseOptions.copyrightHolders], + year: licenseOptions.year, + } + : { + copyright_holders: [], + year: '', + }, }, }, }; diff --git a/src/app/features/registries/services/projects.service.ts b/src/app/features/registries/services/projects.service.ts deleted file mode 100644 index 95b8b3b55..000000000 --- a/src/app/features/registries/services/projects.service.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { forkJoin, map, Observable, of, switchMap } from 'rxjs'; - -import { inject, Injectable } from '@angular/core'; - -import { JsonApiService } from '@osf/shared/services'; - -import { ProjectsMapper } from '../mappers/projects.mapper'; -import { Project } from '../models'; -import { ProjectsResponseJsonApi } from '../models/projects-json-api.model'; - -import { environment } from 'src/environments/environment'; - -@Injectable({ - providedIn: 'root', -}) -export class ProjectsService { - private apiUrl = environment.apiUrl; - private readonly jsonApiService = inject(JsonApiService); - - getProjects(): Observable { - const params: Record = { - 'filter[current_user_permissions]': 'admin', - }; - return this.jsonApiService - .get(`${this.apiUrl}/users/me/nodes/`, params) - .pipe(map((response) => ProjectsMapper.fromProjectsResponse(response))); - } - - getProjectChildren(id: string): Observable { - return this.jsonApiService - .get(`${this.apiUrl}/nodes/${id}/children`) - .pipe(map((response) => ProjectsMapper.fromProjectsResponse(response))); - } - - getComponentsTree(id: string): Observable { - return this.getProjectChildren(id).pipe( - switchMap((children) => { - if (!children.length) { - return of([]); - } - const childrenWithSubtrees$ = children.map((child) => - this.getComponentsTree(child.id).pipe( - map((subChildren) => ({ - ...child, - children: subChildren, - })) - ) - ); - - return childrenWithSubtrees$.length ? forkJoin(childrenWithSubtrees$) : of([]); - }) - ); - } -} diff --git a/src/app/features/registries/services/providers.service.ts b/src/app/features/registries/services/providers.service.ts deleted file mode 100644 index 88c02b1e5..000000000 --- a/src/app/features/registries/services/providers.service.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { map, Observable } from 'rxjs'; - -import { inject, Injectable } from '@angular/core'; - -import { RegistryProviderDetails } from '@osf/features/registries/models/registry-provider.model'; -import { RegistryProviderDetailsJsonApi } from '@osf/features/registries/models/registry-provider-json-api.model'; -import { ProvidersResponseJsonApi } from '@osf/shared/models'; -import { JsonApiService } from '@osf/shared/services'; -import { JsonApiResponse } from '@shared/models'; - -import { ProvidersMapper } from '../mappers/providers.mapper'; -import { ProviderSchema } from '../models'; - -import { environment } from 'src/environments/environment'; - -@Injectable({ - providedIn: 'root', -}) -export class ProvidersService { - private apiUrl = environment.apiUrl; - private readonly jsonApiService = inject(JsonApiService); - - getProviderSchemas(providerId: string): Observable { - return this.jsonApiService - .get(`${this.apiUrl}/providers/registrations/${providerId}/schemas/`) - .pipe(map((response) => ProvidersMapper.fromProvidersResponse(response))); - } - - getProviderBrand(providerName: string): Observable { - return this.jsonApiService - .get< - JsonApiResponse - >(`${this.apiUrl}/providers/registrations/${providerName}/?embed=brand`) - .pipe(map((response) => ProvidersMapper.fromRegistryProvider(response.data))); - } -} diff --git a/src/app/features/registries/services/registration-files.service.ts b/src/app/features/registries/services/registration-files.service.ts deleted file mode 100644 index 85866e099..000000000 --- a/src/app/features/registries/services/registration-files.service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { inject, Injectable } from '@angular/core'; - -import { FilesService } from '@osf/shared/services'; -import { JsonApiService } from '@shared/services'; - -@Injectable({ - providedIn: 'root', -}) -export class RegistrationFilesService { - private filesService = inject(FilesService); - private jsonApiService = inject(JsonApiService); -} diff --git a/src/app/features/registries/services/registries.service.ts b/src/app/features/registries/services/registries.service.ts index 89db221f8..cd43f80ca 100644 --- a/src/app/features/registries/services/registries.service.ts +++ b/src/app/features/registries/services/registries.service.ts @@ -2,6 +2,7 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; import { PageSchemaMapper, RegistrationMapper } from '@osf/shared/mappers/registration'; import { DraftRegistrationDataJsonApi, @@ -9,6 +10,7 @@ import { DraftRegistrationRelationshipsJsonApi, DraftRegistrationResponseJsonApi, PageSchema, + PaginatedData, RegistrationAttributesJsonApi, RegistrationCard, RegistrationDataJsonApi, @@ -25,14 +27,16 @@ import { JsonApiService } from '@osf/shared/services'; import { SchemaActionTrigger } from '../enums'; -import { environment } from 'src/environments/environment'; - @Injectable({ providedIn: 'root', }) export class RegistriesService { - private apiUrl = environment.apiUrl; private readonly jsonApiService = inject(JsonApiService); + private readonly environment = inject(ENVIRONMENT); + + get apiUrl() { + return `${this.environment.apiDomainUrl}/v2`; + } createDraft( registrationSchemaId: string, @@ -90,7 +94,7 @@ export class RegistriesService { id, attributes, relationships, - type: 'draft_registrations', // force the correct type + type: 'draft_registrations', }, }; const params = { @@ -125,7 +129,7 @@ export class RegistriesService { .pipe(map((response) => PageSchemaMapper.fromSchemaBlocksResponse(response))); } - getDraftRegistrations(page: number, pageSize: number): Observable<{ data: RegistrationCard[]; totalCount: number }> { + getDraftRegistrations(page: number, pageSize: number): Observable> { const params = { page, 'page[size]': pageSize, @@ -141,6 +145,7 @@ export class RegistriesService { return { data, totalCount: response.meta?.total, + pageSize: response.meta.per_page, }; }) ); @@ -150,12 +155,14 @@ export class RegistriesService { userId: string, page: number, pageSize: number - ): Observable<{ data: RegistrationCard[]; totalCount: number }> { + ): Observable> { const params = { page, 'page[size]': pageSize, + 'filter[parent]': null, embed: ['bibliographic_contributors', 'registration_schema', 'provider'], }; + return this.jsonApiService .get>(`${this.apiUrl}/users/${userId}/registrations/`, params) .pipe( @@ -166,6 +173,7 @@ export class RegistriesService { return { data, totalCount: response.meta?.total, + pageSize: response.meta.per_page, }; }) ); diff --git a/src/app/features/registries/store/default.state.ts b/src/app/features/registries/store/default.state.ts deleted file mode 100644 index 73eb1596b..000000000 --- a/src/app/features/registries/store/default.state.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { RegistriesStateModel } from './registries.model'; - -export const DefaultState: RegistriesStateModel = { - providerSchemas: { - data: [], - isLoading: false, - error: null, - }, - projects: { - data: [], - isLoading: false, - error: null, - }, - draftRegistration: { - isLoading: false, - data: null, - isSubmitting: false, - error: null, - }, - registries: { - data: [], - isLoading: false, - error: null, - }, - licenses: { - data: [], - isLoading: false, - error: null, - }, - pagesSchema: { - data: [], - isLoading: false, - error: null, - }, - stepsValidation: {}, - registration: { - data: null, - isLoading: false, - isSubmitting: false, - error: null, - }, - draftRegistrations: { - data: [], - isLoading: false, - error: null, - totalCount: 0, - }, - submittedRegistrations: { - data: [], - isLoading: false, - error: null, - totalCount: 0, - }, - files: { - data: [], - isLoading: false, - error: null, - }, - currentFolder: null, - moveFileCurrentFolder: null, - rootFolders: { - data: null, - isLoading: false, - error: null, - }, - schemaResponse: { - data: null, - isLoading: false, - error: null, - }, - updatedFields: {}, -}; diff --git a/src/app/features/registries/store/handlers/files.handlers.ts b/src/app/features/registries/store/handlers/files.handlers.ts index 825a4b98f..aa2e1a7a6 100644 --- a/src/app/features/registries/store/handlers/files.handlers.ts +++ b/src/app/features/registries/store/handlers/files.handlers.ts @@ -50,6 +50,7 @@ export class FilesHandlers { data: response, isLoading: false, error: null, + totalCount: response.length, }, }); }), diff --git a/src/app/features/registries/store/handlers/projects.handlers.ts b/src/app/features/registries/store/handlers/projects.handlers.ts index 75fce07c9..000f4898b 100644 --- a/src/app/features/registries/store/handlers/projects.handlers.ts +++ b/src/app/features/registries/store/handlers/projects.handlers.ts @@ -2,25 +2,35 @@ import { StateContext } from '@ngxs/store'; import { inject, Injectable } from '@angular/core'; -import { Project } from '../../models'; -import { ProjectsService } from '../../services'; -import { DefaultState } from '../default.state'; -import { RegistriesStateModel } from '../registries.model'; +import { handleSectionError } from '@osf/shared/helpers'; +import { ProjectsService } from '@osf/shared/services/projects.service'; + +import { ProjectShortInfoModel } from '../../models'; +import { REGISTRIES_STATE_DEFAULTS, RegistriesStateModel } from '../registries.model'; @Injectable() export class ProjectsHandlers { projectsService = inject(ProjectsService); - getProjects({ patchState }: StateContext) { - patchState({ + getProjects(ctx: StateContext, userId: string, search: string) { + const params: Record = { + 'filter[current_user_permissions]': 'admin', + }; + + if (search) { + params['filter[title]'] = search; + } + const state = ctx.getState(); + ctx.patchState({ projects: { - ...DefaultState.projects, + data: state.projects.data, + error: null, isLoading: true, }, }); - return this.projectsService.getProjects().subscribe({ - next: (projects: Project[]) => { - patchState({ + return this.projectsService.fetchProjects(userId, params).subscribe({ + next: (projects: ProjectShortInfoModel[]) => { + ctx.patchState({ projects: { data: projects, isLoading: false, @@ -29,8 +39,8 @@ export class ProjectsHandlers { }); }, error: (error) => { - patchState({ - projects: { ...DefaultState.projects, isLoading: false, error }, + ctx.patchState({ + projects: { ...REGISTRIES_STATE_DEFAULTS.projects, isLoading: false, error }, }); }, }); @@ -47,7 +57,7 @@ export class ProjectsHandlers { }); return this.projectsService.getComponentsTree(projectId).subscribe({ - next: (children: Project[]) => { + next: (children: ProjectShortInfoModel[]) => { ctx.patchState({ draftRegistration: { data: { @@ -59,11 +69,7 @@ export class ProjectsHandlers { }, }); }, - error: (error) => { - ctx.patchState({ - projects: { ...state.projects, isLoading: false, error }, - }); - }, + error: (error) => handleSectionError(ctx, 'draftRegistration', error), }); } } diff --git a/src/app/features/registries/store/handlers/providers.handlers.ts b/src/app/features/registries/store/handlers/providers.handlers.ts index 8b68e2a05..424d2286b 100644 --- a/src/app/features/registries/store/handlers/providers.handlers.ts +++ b/src/app/features/registries/store/handlers/providers.handlers.ts @@ -2,18 +2,17 @@ import { StateContext } from '@ngxs/store'; import { inject, Injectable } from '@angular/core'; -import { ProvidersService } from '../../services'; -import { DefaultState } from '../default.state'; -import { RegistriesStateModel } from '../registries.model'; +import { RegistrationProviderService } from '../../services'; +import { REGISTRIES_STATE_DEFAULTS, RegistriesStateModel } from '../registries.model'; @Injectable() export class ProvidersHandlers { - providersService = inject(ProvidersService); + providersService = inject(RegistrationProviderService); getProviderSchemas({ patchState }: StateContext, providerId: string) { patchState({ providerSchemas: { - ...DefaultState.providerSchemas, + ...REGISTRIES_STATE_DEFAULTS.providerSchemas, isLoading: true, }, }); @@ -30,7 +29,7 @@ export class ProvidersHandlers { error: (error) => { patchState({ providerSchemas: { - ...DefaultState.providerSchemas, + ...REGISTRIES_STATE_DEFAULTS.providerSchemas, isLoading: false, error, }, diff --git a/src/app/features/registries/store/registries-provider-search/index.ts b/src/app/features/registries/store/registries-provider-search/index.ts deleted file mode 100644 index f0cef0a5b..000000000 --- a/src/app/features/registries/store/registries-provider-search/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './registries-provider-search.actions'; -export * from './registries-provider-search.model'; -export * from './registries-provider-search.selectors'; -export * from './registries-provider-search.state'; diff --git a/src/app/features/registries/store/registries-provider-search/registries-provider-search.actions.ts b/src/app/features/registries/store/registries-provider-search/registries-provider-search.actions.ts deleted file mode 100644 index 3352239e6..000000000 --- a/src/app/features/registries/store/registries-provider-search/registries-provider-search.actions.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ResourceTab } from '@shared/enums'; - -const stateName = '[Registry Provider Search]'; - -export class GetRegistryProviderBrand { - static readonly type = `${stateName} Get Registry Provider Brand`; - - constructor(public providerName: string) {} -} - -export class UpdateResourceType { - static readonly type = `${stateName} Update Resource Type`; - - constructor(public type: ResourceTab) {} -} - -export class FetchResources { - static readonly type = `${stateName} Fetch Resources`; -} - -export class FetchResourcesByLink { - static readonly type = `${stateName} Fetch Resources By Link`; - - constructor(public link: string) {} -} - -export class LoadFilterOptionsAndSetValues { - static readonly type = `${stateName} Load Filter Options And Set Values`; - constructor(public filterValues: Record) {} -} - -export class LoadFilterOptions { - static readonly type = `${stateName} Load Filter Options`; - constructor(public filterKey: string) {} -} - -export class UpdateFilterValue { - static readonly type = `${stateName} Update Filter Value`; - constructor( - public filterKey: string, - public value: string | null - ) {} -} - -export class SetFilterValues { - static readonly type = `${stateName} Set Filter Values`; - constructor(public filterValues: Record) {} -} - -export class UpdateSortBy { - static readonly type = `${stateName} Update Sort By`; - - constructor(public sortBy: string) {} -} diff --git a/src/app/features/registries/store/registries-provider-search/registries-provider-search.model.ts b/src/app/features/registries/store/registries-provider-search/registries-provider-search.model.ts deleted file mode 100644 index e879feb6a..000000000 --- a/src/app/features/registries/store/registries-provider-search/registries-provider-search.model.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { RegistryProviderDetails } from '@osf/features/registries/models/registry-provider.model'; -import { ResourceTab } from '@shared/enums'; -import { AsyncStateModel, DiscoverableFilter, Resource, SelectOption } from '@shared/models'; - -export interface RegistriesProviderSearchStateModel { - currentBrandedProvider: AsyncStateModel; - resourceType: ResourceTab; - resources: AsyncStateModel; - filters: DiscoverableFilter[]; - filterValues: Record; - filterOptionsCache: Record; - providerIri: string; - resourcesCount: number; - searchText: string; - sortBy: string; - first: string; - next: string; - previous: string; -} diff --git a/src/app/features/registries/store/registries-provider-search/registries-provider-search.selectors.ts b/src/app/features/registries/store/registries-provider-search/registries-provider-search.selectors.ts deleted file mode 100644 index 59ed1ccd2..000000000 --- a/src/app/features/registries/store/registries-provider-search/registries-provider-search.selectors.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Selector } from '@ngxs/store'; - -import { RegistriesProviderSearchStateModel } from '@osf/features/registries/store/registries-provider-search/registries-provider-search.model'; -import { RegistriesProviderSearchState } from '@osf/features/registries/store/registries-provider-search/registries-provider-search.state'; -import { DiscoverableFilter, Resource, SelectOption } from '@shared/models'; - -import { RegistryProviderDetails } from '../../models/registry-provider.model'; - -export class RegistriesProviderSearchSelectors { - @Selector([RegistriesProviderSearchState]) - static getBrandedProvider(state: RegistriesProviderSearchStateModel): RegistryProviderDetails | null { - return state.currentBrandedProvider.data; - } - - @Selector([RegistriesProviderSearchState]) - static isBrandedProviderLoading(state: RegistriesProviderSearchStateModel): boolean { - return state.currentBrandedProvider.isLoading; - } - - @Selector([RegistriesProviderSearchState]) - static getResources(state: RegistriesProviderSearchStateModel): Resource[] { - return state.resources.data; - } - - @Selector([RegistriesProviderSearchState]) - static getResourcesLoading(state: RegistriesProviderSearchStateModel): boolean { - return state.resources.isLoading; - } - - @Selector([RegistriesProviderSearchState]) - static getFilters(state: RegistriesProviderSearchStateModel): DiscoverableFilter[] { - return state.filters; - } - - @Selector([RegistriesProviderSearchState]) - static getResourcesCount(state: RegistriesProviderSearchStateModel): number { - return state.resourcesCount; - } - - @Selector([RegistriesProviderSearchState]) - static getSearchText(state: RegistriesProviderSearchStateModel): string { - return state.searchText; - } - - @Selector([RegistriesProviderSearchState]) - static getSortBy(state: RegistriesProviderSearchStateModel): string { - return state.sortBy; - } - - @Selector([RegistriesProviderSearchState]) - static getIris(state: RegistriesProviderSearchStateModel): string { - return state.providerIri; - } - - @Selector([RegistriesProviderSearchState]) - static getFirst(state: RegistriesProviderSearchStateModel): string { - return state.first; - } - - @Selector([RegistriesProviderSearchState]) - static getNext(state: RegistriesProviderSearchStateModel): string { - return state.next; - } - - @Selector([RegistriesProviderSearchState]) - static getPrevious(state: RegistriesProviderSearchStateModel): string { - return state.previous; - } - - @Selector([RegistriesProviderSearchState]) - static getResourceType(state: RegistriesProviderSearchStateModel) { - return state.resourceType; - } - - @Selector([RegistriesProviderSearchState]) - static getFilterValues(state: RegistriesProviderSearchStateModel): Record { - return state.filterValues; - } - - @Selector([RegistriesProviderSearchState]) - static getFilterOptionsCache(state: RegistriesProviderSearchStateModel): Record { - return state.filterOptionsCache; - } -} diff --git a/src/app/features/registries/store/registries-provider-search/registries-provider-search.state.ts b/src/app/features/registries/store/registries-provider-search/registries-provider-search.state.ts deleted file mode 100644 index 3150532fa..000000000 --- a/src/app/features/registries/store/registries-provider-search/registries-provider-search.state.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { Action, NgxsOnInit, State, StateContext } from '@ngxs/store'; -import { patch } from '@ngxs/store/operators'; - -import { BehaviorSubject, catchError, EMPTY, forkJoin, of, switchMap, tap } from 'rxjs'; - -import { inject, Injectable } from '@angular/core'; - -import { ProvidersService } from '@osf/features/registries/services'; -import { - FetchResources, - FetchResourcesByLink, - GetRegistryProviderBrand, - LoadFilterOptions, - LoadFilterOptionsAndSetValues, - SetFilterValues, - UpdateFilterValue, - UpdateResourceType, - UpdateSortBy, -} from '@osf/features/registries/store/registries-provider-search/registries-provider-search.actions'; -import { RegistriesProviderSearchStateModel } from '@osf/features/registries/store/registries-provider-search/registries-provider-search.model'; -import { ResourcesData } from '@osf/features/search/models'; -import { getResourceTypes } from '@osf/shared/helpers'; -import { GetResourcesRequestTypeEnum, ResourceTab } from '@shared/enums'; -import { handleSectionError } from '@shared/helpers'; -import { SearchService } from '@shared/services'; - -@State({ - name: 'registryProviderSearch', - defaults: { - currentBrandedProvider: { - data: null, - isLoading: false, - error: null, - }, - resources: { data: [], isLoading: false, error: null }, - filters: [], - filterValues: {}, - filterOptionsCache: {}, - providerIri: '', - resourcesCount: 0, - searchText: '', - sortBy: '-relevance', - first: '', - next: '', - previous: '', - resourceType: ResourceTab.All, - }, -}) -@Injectable() -export class RegistriesProviderSearchState implements NgxsOnInit { - private readonly searchService = inject(SearchService); - providersService = inject(ProvidersService); - - private loadRequests = new BehaviorSubject<{ type: GetResourcesRequestTypeEnum; link?: string } | null>(null); - private filterOptionsRequests = new BehaviorSubject(null); - - ngxsOnInit(ctx: StateContext): void { - this.setupLoadRequests(ctx); - this.setupFilterOptionsRequests(ctx); - } - - private setupLoadRequests(ctx: StateContext) { - this.loadRequests - .pipe( - switchMap((query) => { - if (!query) return EMPTY; - return query.type === GetResourcesRequestTypeEnum.GetResources - ? this.loadResources(ctx) - : this.loadResourcesByLink(ctx, query.link); - }) - ) - .subscribe(); - } - - private loadResources(ctx: StateContext) { - const state = ctx.getState(); - ctx.patchState({ resources: { ...state.resources, isLoading: true } }); - const filtersParams: Record = {}; - const searchText = state.searchText; - const sortBy = state.sortBy; - const resourceTypes = getResourceTypes(ResourceTab.Registrations); - - filtersParams['cardSearchFilter[publisher][]'] = state.providerIri; - - Object.entries(state.filterValues).forEach(([key, value]) => { - if (value) filtersParams[`cardSearchFilter[${key}][]`] = value; - }); - - return this.searchService - .getResources(filtersParams, searchText, sortBy, resourceTypes) - .pipe(tap((response) => this.updateResourcesState(ctx, response))); - } - - private loadResourcesByLink(ctx: StateContext, link?: string) { - if (!link) return EMPTY; - return this.searchService - .getResourcesByLink(link) - .pipe(tap((response) => this.updateResourcesState(ctx, response))); - } - - private updateResourcesState(ctx: StateContext, response: ResourcesData) { - const state = ctx.getState(); - const filtersWithCachedOptions = (response.filters || []).map((filter) => { - const cachedOptions = state.filterOptionsCache[filter.key]; - return cachedOptions?.length ? { ...filter, options: cachedOptions, isLoaded: true } : filter; - }); - - ctx.patchState({ - resources: { data: response.resources, isLoading: false, error: null }, - filters: filtersWithCachedOptions, - resourcesCount: response.count, - first: response.first, - next: response.next, - previous: response.previous, - }); - } - - private setupFilterOptionsRequests(ctx: StateContext) { - this.filterOptionsRequests - .pipe( - switchMap((filterKey) => { - if (!filterKey) return EMPTY; - return this.handleFilterOptionLoad(ctx, filterKey); - }) - ) - .subscribe(); - } - - private handleFilterOptionLoad(ctx: StateContext, filterKey: string) { - const state = ctx.getState(); - const cachedOptions = state.filterOptionsCache[filterKey]; - if (cachedOptions?.length) { - const updatedFilters = state.filters.map((f) => - f.key === filterKey ? { ...f, options: cachedOptions, isLoaded: true, isLoading: false } : f - ); - ctx.patchState({ filters: updatedFilters }); - return EMPTY; - } - - const loadingFilters = state.filters.map((f) => (f.key === filterKey ? { ...f, isLoading: true } : f)); - ctx.patchState({ filters: loadingFilters }); - - return this.searchService.getFilterOptions(filterKey).pipe( - tap((options) => { - const updatedCache = { ...ctx.getState().filterOptionsCache, [filterKey]: options }; - const updatedFilters = ctx - .getState() - .filters.map((f) => (f.key === filterKey ? { ...f, options, isLoaded: true, isLoading: false } : f)); - ctx.patchState({ filters: updatedFilters, filterOptionsCache: updatedCache }); - }) - ); - } - - @Action(FetchResources) - getResources(ctx: StateContext) { - if (!ctx.getState().providerIri) return; - this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResources }); - } - - @Action(FetchResourcesByLink) - getResourcesByLink(_: StateContext, action: FetchResourcesByLink) { - this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResourcesByLink, link: action.link }); - } - - @Action(LoadFilterOptions) - loadFilterOptions(_: StateContext, action: LoadFilterOptions) { - this.filterOptionsRequests.next(action.filterKey); - } - - @Action(UpdateResourceType) - updateResourceType(ctx: StateContext, action: UpdateResourceType) { - ctx.patchState({ resourceType: action.type }); - } - - @Action(LoadFilterOptionsAndSetValues) - loadFilterOptionsAndSetValues( - ctx: StateContext, - action: LoadFilterOptionsAndSetValues - ) { - const filterKeys = Object.keys(action.filterValues).filter((key) => action.filterValues[key]); - if (!filterKeys.length) return; - - const loadingFilters = ctx - .getState() - .filters.map((f) => - filterKeys.includes(f.key) && !ctx.getState().filterOptionsCache[f.key]?.length ? { ...f, isLoading: true } : f - ); - ctx.patchState({ filters: loadingFilters }); - - const observables = filterKeys.map((key) => - this.searchService.getFilterOptions(key).pipe( - tap((options) => { - const updatedCache = { ...ctx.getState().filterOptionsCache, [key]: options }; - const updatedFilters = ctx - .getState() - .filters.map((f) => (f.key === key ? { ...f, options, isLoaded: true, isLoading: false } : f)); - ctx.patchState({ filters: updatedFilters, filterOptionsCache: updatedCache }); - }), - catchError(() => of({ filterKey: key, options: [] })) - ) - ); - - return forkJoin(observables).pipe(tap(() => ctx.patchState({ filterValues: action.filterValues }))); - } - - @Action(SetFilterValues) - setFilterValues(ctx: StateContext, action: SetFilterValues) { - ctx.patchState({ filterValues: action.filterValues }); - } - - @Action(UpdateFilterValue) - updateFilterValue(ctx: StateContext, action: UpdateFilterValue) { - if (action.filterKey === 'search') { - ctx.patchState({ searchText: action.value || '' }); - this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResources }); - return; - } - - const updatedFilterValues = { ...ctx.getState().filterValues, [action.filterKey]: action.value }; - ctx.patchState({ filterValues: updatedFilterValues }); - this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResources }); - } - - @Action(GetRegistryProviderBrand) - getProviderBrand(ctx: StateContext, action: GetRegistryProviderBrand) { - const state = ctx.getState(); - ctx.patchState({ - currentBrandedProvider: { - ...state.currentBrandedProvider, - isLoading: true, - }, - }); - - return this.providersService.getProviderBrand(action.providerName).pipe( - tap((brand) => { - ctx.setState( - patch({ - currentBrandedProvider: patch({ - data: brand, - isLoading: false, - error: null, - }), - providerIri: brand.iri, - }) - ); - this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResources }); - }), - catchError((error) => handleSectionError(ctx, 'currentBrandedProvider', error)) - ); - } - - @Action(UpdateSortBy) - updateSortBy(ctx: StateContext, action: UpdateSortBy) { - ctx.patchState({ sortBy: action.sortBy }); - } -} diff --git a/src/app/features/registries/store/registries.actions.ts b/src/app/features/registries/store/registries.actions.ts index 019a79765..0b7d8c431 100644 --- a/src/app/features/registries/store/registries.actions.ts +++ b/src/app/features/registries/store/registries.actions.ts @@ -1,8 +1,8 @@ import { DraftRegistrationAttributesJsonApi, DraftRegistrationRelationshipsJsonApi, + FileFolderModel, LicenseOptions, - OsfFile, } from '@osf/shared/models'; import { SchemaActionTrigger } from '../enums'; @@ -13,25 +13,34 @@ export class GetRegistries { export class GetProviderSchemas { static readonly type = '[Registries] Get Provider Schemas'; + constructor(public providerId: string) {} } export class GetProjects { static readonly type = '[Registries] Get Projects'; + + constructor( + public userId: string, + public search: string + ) {} } export class CreateDraft { static readonly type = '[Registries] Create Draft'; + constructor(public payload: { registrationSchemaId: string; provider: string; projectId?: string }) {} } export class FetchDraft { static readonly type = '[Registries] Fetch Draft'; + constructor(public draftId: string) {} } export class UpdateDraft { static readonly type = '[Registries] Update Registration Tags'; + constructor( public draftId: string, public attributes: Partial, @@ -41,11 +50,13 @@ export class UpdateDraft { export class DeleteDraft { static readonly type = '[Registries] Delete Draft'; + constructor(public draftId: string) {} } export class RegisterDraft { static readonly type = '[Registries] Register Draft Registration'; + constructor( public draftId: string, public embargoDate: string, @@ -57,16 +68,19 @@ export class RegisterDraft { export class FetchSchemaBlocks { static readonly type = '[Registries] Fetch Schema Blocks'; + constructor(public registrationSchemaId: string) {} } export class FetchLicenses { static readonly type = '[Registries] Fetch Licenses'; + constructor(public providerId: string) {} } export class SaveLicense { static readonly type = '[Registries] Save License'; + constructor( public registrationId: string, public licenseId: string, @@ -74,16 +88,19 @@ export class SaveLicense { ) {} } -export class UpdateStepValidation { +export class UpdateStepState { static readonly type = '[Registries] Update Step Validation'; + constructor( public step: string, - public invalid: boolean + public invalid: boolean, + public touched: boolean ) {} } export class FetchDraftRegistrations { static readonly type = '[Registries] Fetch Draft Registrations'; + constructor( public page = 1, public pageSize = 10 @@ -92,6 +109,7 @@ export class FetchDraftRegistrations { export class FetchSubmittedRegistrations { static readonly type = '[Registries] Fetch Submitted Registrations'; + constructor( public userId: string | undefined, public page = 1, @@ -101,6 +119,7 @@ export class FetchSubmittedRegistrations { export class FetchProjectChildren { static readonly type = '[Registries] Fetch Project Children'; + constructor(public projectId: string) {} } @@ -111,7 +130,10 @@ export class ClearState { export class GetFiles { static readonly type = '[Registries] Get Files'; - constructor(public filesLink: string) {} + constructor( + public filesLink: string, + public page: number + ) {} } export class SetFilesIsLoading { @@ -138,32 +160,30 @@ export class CreateFolder { export class SetCurrentFolder { static readonly type = '[Registries] Set Current Folder'; - constructor(public folder: OsfFile | null) {} -} - -export class SetMoveFileCurrentFolder { - static readonly type = '[Registries] Set Move File Current Folder'; - - constructor(public folder: OsfFile | null) {} + constructor(public folder: FileFolderModel | null) {} } export class FetchAllSchemaResponses { static readonly type = '[Registries] Fetch All Schema Responses'; + constructor(public registrationId: string) {} } export class FetchSchemaResponse { static readonly type = '[Registries] Fetch Schema Response'; + constructor(public schemaResponseId: string) {} } export class CreateSchemaResponse { static readonly type = '[Registries] Create Schema Response'; + constructor(public registrationId: string) {} } export class UpdateSchemaResponse { static readonly type = '[Registries] Update Schema Response'; + constructor( public schemaResponseId: string, public revisionJustification: string, @@ -174,6 +194,7 @@ export class UpdateSchemaResponse { export class HandleSchemaResponse { static readonly type = '[Registries] Handle Schema Response'; + constructor( public schemaResponseId: string, public trigger: SchemaActionTrigger, @@ -183,10 +204,12 @@ export class HandleSchemaResponse { export class DeleteSchemaResponse { static readonly type = '[Registries] Delete Schema Response'; + constructor(public schemaResponseId: string) {} } export class SetUpdatedFields { static readonly type = '[Registries] Set Updated Fields'; + constructor(public updatedFields: Record) {} } diff --git a/src/app/features/registries/store/registries.model.ts b/src/app/features/registries/store/registries.model.ts index 582df3dc7..1f457fdb5 100644 --- a/src/app/features/registries/store/registries.model.ts +++ b/src/app/features/registries/store/registries.model.ts @@ -2,32 +2,104 @@ import { AsyncStateModel, AsyncStateWithTotalCount, DraftRegistrationModel, - License, - OsfFile, + FileFolderModel, + FileModel, + LicenseModel, PageSchema, + ProviderSchema, RegistrationCard, RegistrationModel, - Resource, + ResourceModel, SchemaResponse, } from '@shared/models'; -import { Project, ProviderSchema } from '../models'; +import { ProjectShortInfoModel } from '../models'; export interface RegistriesStateModel { providerSchemas: AsyncStateModel; - projects: AsyncStateModel; + projects: AsyncStateModel; draftRegistration: AsyncStateModel; registration: AsyncStateModel; - registries: AsyncStateModel; - licenses: AsyncStateModel; + registries: AsyncStateModel; + licenses: AsyncStateModel; pagesSchema: AsyncStateModel; - stepsValidation: Record; + stepsState: Record; draftRegistrations: AsyncStateWithTotalCount; submittedRegistrations: AsyncStateWithTotalCount; - files: AsyncStateModel; - currentFolder: OsfFile | null; - moveFileCurrentFolder: OsfFile | null; - rootFolders: AsyncStateModel; + files: AsyncStateWithTotalCount; + currentFolder: FileFolderModel | null; + rootFolders: AsyncStateModel; schemaResponse: AsyncStateModel; updatedFields: Record; } + +export const REGISTRIES_STATE_DEFAULTS: RegistriesStateModel = { + providerSchemas: { + data: [], + isLoading: false, + error: null, + }, + projects: { + data: [], + isLoading: false, + error: null, + }, + draftRegistration: { + isLoading: false, + data: null, + isSubmitting: false, + error: null, + }, + registries: { + data: [], + isLoading: false, + error: null, + }, + licenses: { + data: [], + isLoading: false, + error: null, + }, + pagesSchema: { + data: [], + isLoading: false, + error: null, + }, + stepsState: {}, + registration: { + data: null, + isLoading: false, + isSubmitting: false, + error: null, + }, + draftRegistrations: { + data: [], + isLoading: false, + error: null, + totalCount: 0, + }, + submittedRegistrations: { + data: [], + isLoading: false, + error: null, + totalCount: 0, + }, + files: { + data: [], + isLoading: false, + error: null, + totalCount: 0, + }, + currentFolder: null, + rootFolders: { + data: null, + isLoading: false, + error: null, + }, + schemaResponse: { + data: null, + isLoading: false, + error: null, + }, + updatedFields: {}, +}; diff --git a/src/app/features/registries/store/registries.selectors.ts b/src/app/features/registries/store/registries.selectors.ts index 346d8cea4..82b37c498 100644 --- a/src/app/features/registries/store/registries.selectors.ts +++ b/src/app/features/registries/store/registries.selectors.ts @@ -2,16 +2,18 @@ import { Selector } from '@ngxs/store'; import { DraftRegistrationModel, - License, - OsfFile, + FileFolderModel, + FileModel, + LicenseModel, PageSchema, + ProviderSchema, RegistrationCard, RegistrationModel, - Resource, + ResourceModel, SchemaResponse, } from '@shared/models'; -import { Project, ProviderSchema } from '../models'; +import { ProjectShortInfoModel } from '../models'; import { RegistriesStateModel } from './registries.model'; import { RegistriesState } from './registries.state'; @@ -28,7 +30,7 @@ export class RegistriesSelectors { } @Selector([RegistriesState]) - static getProjects(state: RegistriesStateModel): Project[] { + static getProjects(state: RegistriesStateModel): ProjectShortInfoModel[] { return state.projects.data; } @@ -58,7 +60,7 @@ export class RegistriesSelectors { } @Selector([RegistriesState]) - static getRegistries(state: RegistriesStateModel): Resource[] { + static getRegistries(state: RegistriesStateModel): ResourceModel[] { return state.registries.data; } @@ -68,7 +70,7 @@ export class RegistriesSelectors { } @Selector([RegistriesState]) - static getLicenses(state: RegistriesStateModel): License[] { + static getLicenses(state: RegistriesStateModel): LicenseModel[] { return state.licenses.data; } @@ -78,7 +80,7 @@ export class RegistriesSelectors { } @Selector([RegistriesState]) - static getRegistrationLicense(state: RegistriesStateModel): License | null { + static getRegistrationLicense(state: RegistriesStateModel): LicenseModel | null { return state.licenses.data.find((l) => l.id === state.draftRegistration.data?.license.id) || null; } @@ -93,8 +95,8 @@ export class RegistriesSelectors { } @Selector([RegistriesState]) - static getStepsValidation(state: RegistriesStateModel): Record { - return state.stepsValidation; + static getStepsState(state: RegistriesStateModel): Record { + return state.stepsState; } @Selector([RegistriesState]) @@ -143,10 +145,15 @@ export class RegistriesSelectors { } @Selector([RegistriesState]) - static getFiles(state: RegistriesStateModel): OsfFile[] { + static getFiles(state: RegistriesStateModel): FileModel[] { return state.files.data; } + @Selector([RegistriesState]) + static getFilesTotalCount(state: RegistriesStateModel): number { + return state.files.totalCount; + } + @Selector([RegistriesState]) static isFilesLoading(state: RegistriesStateModel): boolean { return state.files.isLoading; @@ -158,7 +165,7 @@ export class RegistriesSelectors { } @Selector([RegistriesState]) - static getCurrentFolder(state: RegistriesStateModel): OsfFile | null { + static getCurrentFolder(state: RegistriesStateModel): FileFolderModel | null { return state.currentFolder; } diff --git a/src/app/features/registries/store/registries.state.ts b/src/app/features/registries/store/registries.state.ts index 1e3c88028..21308884b 100644 --- a/src/app/features/registries/store/registries.state.ts +++ b/src/app/features/registries/store/registries.state.ts @@ -4,9 +4,10 @@ import { catchError, tap } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; -import { ResourceTab } from '@osf/shared/enums'; -import { getResourceTypes, handleSectionError } from '@osf/shared/helpers'; -import { FilesService, SearchService } from '@osf/shared/services'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { ResourceType } from '@osf/shared/enums'; +import { getResourceTypeStringFromEnum, handleSectionError } from '@osf/shared/helpers'; +import { GlobalSearchService } from '@osf/shared/services'; import { RegistriesService } from '../services'; @@ -14,7 +15,6 @@ import { FilesHandlers } from './handlers/files.handlers'; import { LicensesHandlers } from './handlers/licenses.handlers'; import { ProjectsHandlers } from './handlers/projects.handlers'; import { ProvidersHandlers } from './handlers/providers.handlers'; -import { DefaultState } from './default.state'; import { ClearState, CreateDraft, @@ -39,23 +39,22 @@ import { RegisterDraft, SaveLicense, SetCurrentFolder, - SetMoveFileCurrentFolder, SetUpdatedFields, UpdateDraft, UpdateSchemaResponse, - UpdateStepValidation, + UpdateStepState, } from './registries.actions'; -import { RegistriesStateModel } from './registries.model'; +import { REGISTRIES_STATE_DEFAULTS, RegistriesStateModel } from './registries.model'; @State({ name: 'registries', - defaults: { ...DefaultState }, + defaults: REGISTRIES_STATE_DEFAULTS, }) @Injectable() export class RegistriesState { - searchService = inject(SearchService); + searchService = inject(GlobalSearchService); registriesService = inject(RegistriesService); - fileService = inject(FilesService); + private readonly environment = inject(ENVIRONMENT); providersHandler = inject(ProvidersHandlers); projectsHandler = inject(ProjectsHandlers); @@ -72,9 +71,13 @@ export class RegistriesState { }, }); - const resourceType = getResourceTypes(ResourceTab.Registrations); + const params: Record = { + 'cardSearchFilter[resourceType]': getResourceTypeStringFromEnum(ResourceType.Registration), + 'cardSearchFilter[accessService]': `${this.environment.webUrl}/`, + 'page[size]': '10', + }; - return this.searchService.getResources({}, '', '', resourceType).pipe( + return this.searchService.getResources(params).pipe( tap((registries) => { ctx.patchState({ registries: { @@ -89,8 +92,8 @@ export class RegistriesState { } @Action(GetProjects) - getProjects(ctx: StateContext) { - return this.projectsHandler.getProjects(ctx); + getProjects(ctx: StateContext, { userId, search }: GetProjects) { + return this.projectsHandler.getProjects(ctx, userId, search); } @Action(FetchProjectChildren) @@ -256,13 +259,13 @@ export class RegistriesState { ); } - @Action(UpdateStepValidation) - updateStepValidation(ctx: StateContext, { step, invalid }: UpdateStepValidation) { + @Action(UpdateStepState) + updateStepState(ctx: StateContext, { step, invalid, touched }: UpdateStepState) { const state = ctx.getState(); ctx.patchState({ - stepsValidation: { - ...state.stepsValidation, - [step]: { invalid }, + stepsState: { + ...state.stepsState, + [step]: { invalid, touched }, }, }); } @@ -334,12 +337,12 @@ export class RegistriesState { @Action(ClearState) clearState(ctx: StateContext) { - ctx.setState({ ...DefaultState }); + ctx.setState(REGISTRIES_STATE_DEFAULTS); } @Action(GetFiles) - getFiles(ctx: StateContext, { filesLink }: GetFiles) { - return this.filesHandlers.getProjectFiles(ctx, { filesLink }); + getFiles(ctx: StateContext, { filesLink, page }: GetFiles) { + return this.filesHandlers.getProjectFiles(ctx, { filesLink, page }); } @Action(GetRootFolders) @@ -352,11 +355,6 @@ export class RegistriesState { return this.filesHandlers.createFolder(ctx, action); } - @Action(SetMoveFileCurrentFolder) - setMoveFileCurrentFolder(ctx: StateContext, action: SetMoveFileCurrentFolder) { - ctx.patchState({ moveFileCurrentFolder: action.folder }); - } - @Action(SetCurrentFolder) setSelectedFolder(ctx: StateContext, action: SetCurrentFolder) { ctx.patchState({ currentFolder: action.folder }); diff --git a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html index 4983d95fb..9f8a88dc4 100644 --- a/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html +++ b/src/app/features/registry/components/add-resource-dialog/add-resource-dialog.component.html @@ -14,7 +14,9 @@

{{ currentResource()?.description }}

@@ -25,7 +27,7 @@

{{ resourceName }}

class="btn-full-width" [label]="'common.buttons.edit' | translate" severity="info" - (click)="backToEdit()" + (onClick)="backToEdit()" /> ({ + doiLink = computed(() => `${this.doiDomain}${this.currentResource()?.pid}`); + + form = new FormGroup({ pid: new FormControl('', [CustomValidators.requiredTrimmed(), CustomValidators.doiValidator]), resourceType: new FormControl('', [Validators.required]), description: new FormControl(''), @@ -60,7 +63,7 @@ export class AddResourceDialogComponent { public resourceOptions = signal(resourceTypeOptions); public isPreviewMode = signal(false); - protected readonly RegistryResourceType = RegistryResourceType; + readonly RegistryResourceType = RegistryResourceType; previewResource(): void { if (this.form.invalid) { @@ -78,9 +81,7 @@ export class AddResourceDialogComponent { throw new Error(this.translateService.instant('resources.errors.noCurrentResource')); } - this.actions.previewResource(currentResource.id, addResource).subscribe(() => { - this.isPreviewMode.set(true); - }); + this.actions.previewResource(currentResource.id, addResource).subscribe(() => this.isPreviewMode.set(true)); } backToEdit() { @@ -88,9 +89,7 @@ export class AddResourceDialogComponent { } onAddResource() { - const addResource: ConfirmAddResource = { - finalized: true, - }; + const addResource: ConfirmAddResource = { finalized: true }; const currentResource = this.currentResource(); if (!currentResource) { @@ -113,9 +112,9 @@ export class AddResourceDialogComponent { closeDialog(): void { this.dialogRef.close(); const currentResource = this.currentResource(); - if (!currentResource) { - throw new Error(this.translateService.instant('resources.errors.noRegistryId')); + + if (currentResource) { + this.actions.deleteResource(currentResource.id); } - this.actions.deleteResource(currentResource.id); } } diff --git a/src/app/features/registry/components/archiving-message/archiving-message.component.ts b/src/app/features/registry/components/archiving-message/archiving-message.component.ts index 28be0ad0e..c34f9931e 100644 --- a/src/app/features/registry/components/archiving-message/archiving-message.component.ts +++ b/src/app/features/registry/components/archiving-message/archiving-message.component.ts @@ -3,15 +3,14 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; import { Divider } from 'primeng/divider'; -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; import { IconComponent } from '@osf/shared/components'; import { RegistryOverview } from '../../models'; import { ShortRegistrationInfoComponent } from '../short-registration-info/short-registration-info.component'; -import { environment } from 'src/environments/environment'; - @Component({ selector: 'osf-archiving-message', imports: [TranslatePipe, Card, IconComponent, Divider, ShortRegistrationInfoComponent], @@ -20,7 +19,9 @@ import { environment } from 'src/environments/environment'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ArchivingMessageComponent { + private readonly environment = inject(ENVIRONMENT); + registration = input.required(); - readonly supportEmail = environment.supportEmail; + readonly supportEmail = this.environment.supportEmail; } diff --git a/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts index eae30ae18..39915fcdf 100644 --- a/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts +++ b/src/app/features/registry/components/edit-resource-dialog/edit-resource-dialog.component.ts @@ -1,7 +1,5 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { TranslateService } from '@ngx-translate/core'; - import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { finalize, take } from 'rxjs'; @@ -24,23 +22,20 @@ import { ResourceFormComponent } from '../resource-form/resource-form.component' changeDetection: ChangeDetectionStrategy.OnPush, }) export class EditResourceDialogComponent { - protected readonly dialogRef = inject(DynamicDialogRef); - protected readonly isCurrentResourceLoading = select(RegistryResourcesSelectors.isCurrentResourceLoading); - private translateService = inject(TranslateService); + readonly dialogRef = inject(DynamicDialogRef); + readonly isCurrentResourceLoading = select(RegistryResourcesSelectors.isCurrentResourceLoading); private dialogConfig = inject(DynamicDialogConfig); private registryId: string = this.dialogConfig.data.id; private resource: RegistryResource = this.dialogConfig.data.resource as RegistryResource; - protected form = new FormGroup({ + form = new FormGroup({ pid: new FormControl('', [CustomValidators.requiredTrimmed(), CustomValidators.doiValidator]), resourceType: new FormControl('', [Validators.required]), description: new FormControl(''), }); - private readonly actions = createDispatchMap({ - updateResource: UpdateResource, - }); + private readonly actions = createDispatchMap({ updateResource: UpdateResource }); constructor() { this.form.patchValue({ @@ -61,10 +56,6 @@ export class EditResourceDialogComponent { description: this.form.controls['description'].value ?? '', }; - if (!this.resource.id) { - throw new Error(this.translateService.instant('resources.errors.noRegistryId')); - } - this.actions .updateResource(this.registryId, this.resource.id, addResource) .pipe( diff --git a/src/app/features/registry/components/registration-links-card/registration-links-card.component.html b/src/app/features/registry/components/registration-links-card/registration-links-card.component.html index 8afb19122..612052d22 100644 --- a/src/app/features/registry/components/registration-links-card/registration-links-card.component.html +++ b/src/app/features/registry/components/registration-links-card/registration-links-card.component.html @@ -1,12 +1,16 @@ - - @if (registrationData()) { +@if (registrationData()) { +
-

- {{ registrationData().title || 'project.registrations.card.noTitle' | translate }} -

+
@@ -35,15 +39,11 @@

- {{ 'project.overview.metadata.contributors' | translate }}: - - @for (contributor of registrationData().contributors; track contributor.id) { - {{ contributor.fullName }} - @if (!$last) { - , - } - } - + {{ 'common.labels.contributors' | translate }}: +
@@ -81,7 +81,7 @@

@@ -103,15 +103,15 @@

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

- } - + +} diff --git a/src/app/features/registry/components/registration-links-card/registration-links-card.component.ts b/src/app/features/registry/components/registration-links-card/registration-links-card.component.ts index 02831f81a..a5b50c72e 100644 --- a/src/app/features/registry/components/registration-links-card/registration-links-card.component.ts +++ b/src/app/features/registry/components/registration-links-card/registration-links-card.component.ts @@ -6,14 +6,28 @@ import { Card } from 'primeng/card'; import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; -import { DataResourcesComponent, IconComponent, TruncatedTextComponent } from '@osf/shared/components'; +import { + ContributorsListComponent, + DataResourcesComponent, + IconComponent, + TruncatedTextComponent, +} from '@osf/shared/components'; import { RevisionReviewStates } from '@osf/shared/enums'; import { LinkedNode, LinkedRegistration, RegistryComponentModel } from '../../models'; @Component({ selector: 'osf-registration-links-card', - imports: [Card, Button, TranslatePipe, DatePipe, DataResourcesComponent, TruncatedTextComponent, IconComponent], + imports: [ + Card, + Button, + TranslatePipe, + DatePipe, + DataResourcesComponent, + TruncatedTextComponent, + IconComponent, + ContributorsListComponent, + ], templateUrl: './registration-links-card.component.html', styleUrl: './registration-links-card.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -24,24 +38,24 @@ export class RegistrationLinksCardComponent { readonly updateEmitRegistrationData = output(); readonly reviewEmitRegistrationData = output(); - protected readonly RevisionReviewStates = RevisionReviewStates; + readonly RevisionReviewStates = RevisionReviewStates; - protected readonly isRegistrationData = computed(() => { + readonly isRegistrationData = computed(() => { const data = this.registrationData(); return 'reviewsState' in data; }); - protected readonly isComponentData = computed(() => { + readonly isComponentData = computed(() => { const data = this.registrationData(); return 'registrationSupplement' in data; }); - protected readonly registrationDataTyped = computed(() => { + readonly registrationDataTyped = computed(() => { const data = this.registrationData(); return this.isRegistrationData() ? (data as LinkedRegistration) : null; }); - protected readonly componentsDataTyped = computed(() => { + readonly componentsDataTyped = computed(() => { const data = this.registrationData(); return this.isComponentData() ? (data as RegistryComponentModel) : null; }); 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 1f5c602a1..67a1aa5c9 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 @@ -8,10 +8,13 @@ @case (SubmissionReviewStatus.Accepted) { {{ 'moderation.submissionReview.accepted' | translate }} } + @case (SubmissionReviewStatus.PendingWithdrawal) { + {{ 'moderation.submissionReview.withdrawalRequested' | translate }} + } } {{ action.dateModified | dateAgo }} {{ 'moderation.submissionReview.by' | translate }} - {{ action.creator.name }} + {{ action.creator?.name }} @if (embargoEndDate) { {{ 'moderation.submissionReview.embargoEnding' | translate }} {{ embargoEndDate | date: 'shortDate' }} @@ -20,20 +23,21 @@ }
- @if (isPendingModeration || isPendingReview) { + @if (isPendingModeration || isPendingReview || isPendingWithdrawal) {
-
+
+
@@ -29,7 +36,7 @@ class="btn-full-width" [label]="cancelButtonLabel() | translate" severity="info" - (click)="handleCancel()" + (onClick)="handleCancel()" /> } (); submitClicked = output(); - protected inputLimits = InputLimits; - public resourceOptions = signal(resourceTypeOptions); + inputLimits = InputLimits; + resourceOptions = signal(resourceTypeOptions); - protected getControl(controlName: keyof RegistryResourceFormModel): FormControl { + getControl(controlName: keyof RegistryResourceFormModel): FormControl { return this.formGroup().get(controlName) as FormControl; } diff --git a/src/app/features/registry/components/short-registration-info/short-registration-info.component.html b/src/app/features/registry/components/short-registration-info/short-registration-info.component.html index ecf9c9da0..4487d7184 100644 --- a/src/app/features/registry/components/short-registration-info/short-registration-info.component.html +++ b/src/app/features/registry/components/short-registration-info/short-registration-info.component.html @@ -1,35 +1,34 @@
-

{{ 'navigation.contributors' | translate }}

-

- @for (c of registration().contributors; track c.id) { - {{ c.fullName }} - {{ !$last ? ', ' : '' }} - - } -

+

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

+ +
+

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

{{ registration().description }}

+

{{ 'registry.overview.metadata.type' | translate }}

{{ registration().registrationType }}

+

{{ 'registry.overview.metadata.registeredDate' | translate }}

{{ registration().dateRegistered | date }}

+

{{ 'registry.archiving.createdDate' | translate }}

{{ registration().dateCreated | date }}

+

{{ 'registry.overview.metadata.associatedProject' | translate }}

- {{ associatedProjectUrl }} + {{ associatedProjectUrl }}
diff --git a/src/app/features/registry/components/short-registration-info/short-registration-info.component.ts b/src/app/features/registry/components/short-registration-info/short-registration-info.component.ts index d20a7ef4e..b859aec99 100644 --- a/src/app/features/registry/components/short-registration-info/short-registration-info.component.ts +++ b/src/app/features/registry/components/short-registration-info/short-registration-info.component.ts @@ -1,22 +1,25 @@ import { TranslatePipe } from '@ngx-translate/core'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; +import { RouterLink } from '@angular/router'; -import { RegistryOverview } from '../../models'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { ContributorsListComponent } from '@osf/shared/components'; -import { environment } from 'src/environments/environment'; +import { RegistryOverview } from '../../models'; @Component({ selector: 'osf-short-registration-info', - imports: [TranslatePipe, DatePipe], + imports: [TranslatePipe, DatePipe, RouterLink, ContributorsListComponent], templateUrl: './short-registration-info.component.html', styleUrl: './short-registration-info.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ShortRegistrationInfoComponent { + private readonly environment = inject(ENVIRONMENT); + registration = input.required(); - protected readonly environment = environment; get associatedProjectUrl(): string { return `${this.environment.webUrl}/${this.registration().associatedProjectId}`; diff --git a/src/app/features/registry/components/withdraw-dialog/withdraw-dialog.component.html b/src/app/features/registry/components/withdraw-dialog/withdraw-dialog.component.html index 83a7fbf94..a615d5e11 100644 --- a/src/app/features/registry/components/withdraw-dialog/withdraw-dialog.component.html +++ b/src/app/features/registry/components/withdraw-dialog/withdraw-dialog.component.html @@ -4,17 +4,18 @@
- +
diff --git a/src/app/features/registry/components/withdraw-dialog/withdraw-dialog.component.ts b/src/app/features/registry/components/withdraw-dialog/withdraw-dialog.component.ts index 37f7e7299..8d0339eee 100644 --- a/src/app/features/registry/components/withdraw-dialog/withdraw-dialog.component.ts +++ b/src/app/features/registry/components/withdraw-dialog/withdraw-dialog.component.ts @@ -12,6 +12,7 @@ import { FormControl, FormGroup } from '@angular/forms'; import { WithdrawRegistration } from '@osf/features/registry/store/registry-overview'; import { InputLimits } from '@osf/shared/constants'; +import { CustomValidators } from '@osf/shared/helpers'; import { TextInputComponent } from '@shared/components'; @Component({ @@ -22,25 +23,29 @@ import { TextInputComponent } from '@shared/components'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class WithdrawDialogComponent { - protected readonly dialogRef = inject(DynamicDialogRef); + readonly dialogRef = inject(DynamicDialogRef); private readonly config = inject(DynamicDialogConfig); - private readonly actions = createDispatchMap({ - withdrawRegistration: WithdrawRegistration, - }); + private readonly actions = createDispatchMap({ withdrawRegistration: WithdrawRegistration }); - protected readonly form = new FormGroup({ - text: new FormControl(''), + readonly form = new FormGroup({ + text: new FormControl('', { nonNullable: true, validators: [CustomValidators.requiredTrimmed()] }), }); - protected readonly inputLimits = InputLimits; + readonly inputLimits = InputLimits; + + submitting = false; withdrawRegistration(): void { const registryId = this.config.data.registryId; if (registryId) { + this.submitting = true; this.actions .withdrawRegistration(registryId, this.form.controls.text.value ?? '') .pipe( take(1), - finalize(() => this.dialogRef.close()) + finalize(() => { + this.submitting = false; + this.dialogRef.close(); + }) ) .subscribe(); } diff --git a/src/app/features/registry/mappers/add-resource-request.mapper.ts b/src/app/features/registry/mappers/add-resource-request.mapper.ts index 82fa14a66..fcac04488 100644 --- a/src/app/features/registry/mappers/add-resource-request.mapper.ts +++ b/src/app/features/registry/mappers/add-resource-request.mapper.ts @@ -1,4 +1,4 @@ -import { AddResourceRequest } from '@osf/features/registry/models/resources/add-resource-request.model'; +import { AddResourceRequest } from '../models'; export interface AddResourcePayload { data: AddResourceRequest; diff --git a/src/app/features/registry/mappers/bibliographic-contributors.mapper.ts b/src/app/features/registry/mappers/bibliographic-contributors.mapper.ts deleted file mode 100644 index 09de68467..000000000 --- a/src/app/features/registry/mappers/bibliographic-contributors.mapper.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { BibliographicContributorJsonApi, NodeBibliographicContributor } from '../models'; - -export class BibliographicContributorsMapper { - static fromApiResponse(contributor: BibliographicContributorJsonApi): NodeBibliographicContributor { - const userData = contributor.embeds.users.data; - - return { - id: contributor.id, - userId: userData.id, - fullName: userData.attributes.full_name, - givenName: userData.attributes.given_name, - middleNames: userData.attributes.middle_names, - familyName: userData.attributes.family_name, - suffix: userData.attributes.suffix, - dateRegistered: userData.attributes.date_registered, - isActive: userData.attributes.active, - timezone: userData.attributes.timezone, - locale: userData.attributes.locale, - profileImage: userData.links.profile_image, - profileUrl: userData.links.html, - permission: contributor.attributes.permission, - isBibliographic: contributor.attributes.bibliographic, - isCurator: contributor.attributes.is_curator, - }; - } - - static fromApiResponseArray(contributors: BibliographicContributorJsonApi[]): NodeBibliographicContributor[] { - return contributors.map(this.fromApiResponse); - } -} diff --git a/src/app/features/registry/mappers/index.ts b/src/app/features/registry/mappers/index.ts index 71652246f..dc9524393 100644 --- a/src/app/features/registry/mappers/index.ts +++ b/src/app/features/registry/mappers/index.ts @@ -1,8 +1,6 @@ export * from './add-resource-request.mapper'; -export * from './bibliographic-contributors.mapper'; export * from './linked-nodes.mapper'; export * from './linked-registrations.mapper'; export * from './registry-components.mapper'; -export * from './registry-metadata.mapper'; export * from './registry-overview.mapper'; export * from './registry-resource.mapper'; diff --git a/src/app/features/registry/mappers/linked-nodes.mapper.ts b/src/app/features/registry/mappers/linked-nodes.mapper.ts index 6f6e20fb5..cfc31289e 100644 --- a/src/app/features/registry/mappers/linked-nodes.mapper.ts +++ b/src/app/features/registry/mappers/linked-nodes.mapper.ts @@ -1,3 +1,5 @@ +import { ContributorsMapper } from '@osf/shared/mappers'; + import { LinkedNode, LinkedNodeJsonApi } from '../models'; export class LinkedNodesMapper { @@ -13,6 +15,7 @@ export class LinkedNodesMapper { isPublic: apiNode.attributes.public, htmlUrl: apiNode.links.html, apiUrl: apiNode.links.self, + contributors: ContributorsMapper.getContributors(apiNode.embeds.bibliographic_contributors.data) || [], }; } } diff --git a/src/app/features/registry/mappers/linked-registrations.mapper.ts b/src/app/features/registry/mappers/linked-registrations.mapper.ts index 1de985491..a7d1f527e 100644 --- a/src/app/features/registry/mappers/linked-registrations.mapper.ts +++ b/src/app/features/registry/mappers/linked-registrations.mapper.ts @@ -1,3 +1,5 @@ +import { ContributorsMapper } from '@osf/shared/mappers'; + import { LinkedRegistration, LinkedRegistrationJsonApi } from '../models'; export class LinkedRegistrationsMapper { @@ -23,8 +25,8 @@ export class LinkedRegistrationsMapper { pendingWithdrawal: apiRegistration.attributes.pending_withdrawal, pendingRegistrationApproval: apiRegistration.attributes.pending_registration_approval, registrationSupplement: apiRegistration.attributes.registration_supplement, - subjects: apiRegistration.attributes.subjects, currentUserPermissions: apiRegistration.attributes.current_user_permissions, + contributors: ContributorsMapper.getContributors(apiRegistration.embeds.bibliographic_contributors.data) || [], }; } } diff --git a/src/app/features/registry/mappers/registry-components.mapper.ts b/src/app/features/registry/mappers/registry-components.mapper.ts index 3de6bb6e4..6d61f983d 100644 --- a/src/app/features/registry/mappers/registry-components.mapper.ts +++ b/src/app/features/registry/mappers/registry-components.mapper.ts @@ -1,5 +1,6 @@ -import { RegistryComponentModel } from '../models/registry-components.models'; -import { RegistryComponentJsonApi } from '../models/registry-components-json-api.model'; +import { ContributorsMapper } from '@osf/shared/mappers'; + +import { RegistryComponentJsonApi, RegistryComponentModel } from '../models'; export class RegistryComponentsMapper { static fromApiResponse(apiComponent: RegistryComponentJsonApi): RegistryComponentModel { @@ -14,10 +15,7 @@ export class RegistryComponentsMapper { registrationSupplement: apiComponent.attributes.registration_supplement, tags: apiComponent.attributes.tags, isPublic: apiComponent.attributes.public, + contributors: ContributorsMapper.getContributors(apiComponent.embeds?.bibliographic_contributors?.data || []), }; } - - static fromApiResponseArray(apiComponents: RegistryComponentJsonApi[]): RegistryComponentModel[] { - return apiComponents.map(this.fromApiResponse); - } } diff --git a/src/app/features/registry/mappers/registry-metadata.mapper.ts b/src/app/features/registry/mappers/registry-metadata.mapper.ts deleted file mode 100644 index 9a7250f15..000000000 --- a/src/app/features/registry/mappers/registry-metadata.mapper.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { ProjectOverviewContributor } from '@osf/features/project/overview/models'; -import { ReviewPermissionsMapper } from '@osf/shared/mappers'; -import { RegistrationReviewStates, RegistryStatus, RevisionReviewStates } from '@shared/enums'; -import { License, ProviderDataJsonApi } from '@shared/models'; - -import { - BibliographicContributor, - BibliographicContributorData, - BibliographicContributorsJsonApi, -} from '../models/registry-metadata.models'; -import { RegistryOverview } from '../models/registry-overview.models'; - -export class RegistryMetadataMapper { - static fromMetadataApiResponse(response: Record): RegistryOverview { - const attributes = response['attributes'] as Record; - const embeds = response['embeds'] as Record; - const relationships = response['relationships'] as Record; - - const contributors: ProjectOverviewContributor[] = []; - if (embeds && embeds['contributors']) { - const contributorsData = (embeds['contributors'] as Record)['data'] as Record[]; - contributorsData?.forEach((contributor) => { - const contributorEmbeds = contributor['embeds'] as Record; - if (contributorEmbeds && contributorEmbeds['users']) { - const userData = (contributorEmbeds['users'] as Record)['data'] as Record; - const userAttributes = userData['attributes'] as Record; - - contributors.push({ - id: userData['id'] as string, - type: userData['type'] as string, - fullName: userAttributes['full_name'] as string, - givenName: userAttributes['given_name'] as string, - familyName: userAttributes['family_name'] as string, - middleName: (userAttributes['middle_name'] as string) || '', - }); - } - }); - } - - let license: License | undefined; - let licenseUrl: string | undefined; - - if (embeds && embeds['license']) { - const licenseData = (embeds['license'] as Record)['data'] as Record; - if (licenseData) { - const licenseAttributes = licenseData['attributes'] as Record; - license = { - id: licenseData['id'] as string, - name: licenseAttributes['name'] as string, - text: licenseAttributes['text'] as string, - url: licenseAttributes['url'] as string, - requiredFields: (licenseAttributes['required_fields'] as string[]) || [], - }; - } - } else if (relationships && relationships['license']) { - const licenseRelationship = relationships['license'] as Record; - if (licenseRelationship['links']) { - const licenseLinks = licenseRelationship['links'] as Record; - if (licenseLinks['related'] && typeof licenseLinks['related'] === 'object') { - const relatedLinks = licenseLinks['related'] as Record; - licenseUrl = relatedLinks['href'] as string; - } - } - } - - let nodeLicense: { copyrightHolders: string[]; year: string } | undefined; - if (attributes['node_license']) { - const nodeLicenseData = attributes['node_license'] as Record; - nodeLicense = { - copyrightHolders: (nodeLicenseData['copyright_holders'] as string[]) || [], - year: (nodeLicenseData['year'] as string) || new Date().getFullYear().toString(), - }; - } - - return { - id: response['id'] as string, - type: (response['type'] as string) || 'registrations', - title: attributes['title'] as string, - description: attributes['description'] as string, - category: attributes['category'] as string, - tags: (attributes['tags'] as string[]) || [], - dateCreated: attributes['date_created'] as string, - dateModified: attributes['date_modified'] as string, - dateRegistered: attributes['date_registered'] as string, - registrationType: (attributes['registration_type'] as string) || '', - doi: (attributes['doi'] as string) || '', - isPublic: attributes['public'] as boolean, - isFork: attributes['fork'] as boolean, - customCitation: (attributes['custom_citation'] as string) || '', - accessRequestsEnabled: attributes['access_requests_enabled'] as boolean, - wikiEnabled: attributes['wiki_enabled'] as boolean, - currentUserCanComment: attributes['current_user_can_comment'] as boolean, - currentUserPermissions: (attributes['current_user_permissions'] as string[]) || [], - currentUserIsContributor: attributes['current_user_is_contributor'] as boolean, - currentUserIsContributorOrGroupMember: attributes['current_user_is_contributor_or_group_member'] as boolean, - analyticsKey: (attributes['analytics_key'] as string) || '', - contributors: contributors, - subjects: Array.isArray(attributes['subjects']) ? attributes['subjects'].flat() : attributes['subjects'], - license: license, - nodeLicense: nodeLicense, - licenseUrl: licenseUrl, - forksCount: 0, - citation: '', - hasData: false, - hasAnalyticCode: false, - hasMaterials: false, - hasPapers: false, - hasSupplements: false, - questions: {}, - registrationSchemaLink: '', - associatedProjectId: '', - schemaResponses: [], - status: attributes['status'] as RegistryStatus, - revisionStatus: attributes['revision_status'] as RevisionReviewStates, - reviewsState: attributes['reviews_state'] as RegistrationReviewStates, - links: { - files: '', - }, - archiving: attributes['archiving'] as boolean, - currentUserIsModerator: ReviewPermissionsMapper.fromProviderResponse( - (embeds['contributors'] as Record)['data'] as ProviderDataJsonApi - ), - embargoEndDate: attributes['embargo_end_date'] as string, - withdrawn: attributes['withdrawn'] as boolean, - withdrawalJustification: attributes['withdrawal_justification'] as string | undefined, - dateWithdrawn: attributes['date_withdrawn'] as string | null, - } as RegistryOverview; - } - - static mapBibliographicContributors(response: BibliographicContributorsJsonApi): BibliographicContributor[] { - return response.data.map((contributor: BibliographicContributorData) => ({ - id: contributor.id, - index: contributor.attributes.index, - user: { - id: contributor.embeds.users.data.id, - fullName: contributor.embeds.users.data.attributes.full_name, - profileImage: contributor.embeds.users.data.links.profile_image, - htmlUrl: contributor.embeds.users.data.links.html, - iri: contributor.embeds.users.data.links.iri, - }, - })); - } -} diff --git a/src/app/features/registry/mappers/registry-overview.mapper.ts b/src/app/features/registry/mappers/registry-overview.mapper.ts index 636a62cc1..34f611f00 100644 --- a/src/app/features/registry/mappers/registry-overview.mapper.ts +++ b/src/app/features/registry/mappers/registry-overview.mapper.ts @@ -1,7 +1,7 @@ -import { RegistryOverview, RegistryOverviewJsonApiData } from '@osf/features/registry/models'; -import { ReviewPermissionsMapper } from '@osf/shared/mappers'; -import { RegistrationMapper } from '@osf/shared/mappers/registration'; -import { MapRegistryStatus } from '@shared/mappers/registry/map-registry-status.mapper'; +import { ContributorsMapper, IdentifiersMapper, LicensesMapper } from '@osf/shared/mappers'; +import { MapRegistryStatus, RegistrationMapper, RegistrationNodeMapper } from '@osf/shared/mappers/registration'; + +import { RegistryOverview, RegistryOverviewJsonApiData } from '../models'; export function MapRegistryOverview(data: RegistryOverviewJsonApiData): RegistryOverview | null { return { @@ -17,63 +17,59 @@ export function MapRegistryOverview(data: RegistryOverviewJsonApiData): Registry category: data.attributes?.category, customCitation: data.attributes?.custom_citation, isFork: data.attributes?.fork, - accessRequestsEnabled: data.attributes?.accessRequestsEnabled, + accessRequestsEnabled: data.attributes?.access_requests_enabled, nodeLicense: data.attributes.node_license ? { copyrightHolders: data.attributes.node_license.copyright_holders, year: data.attributes.node_license.year, } : undefined, - license: data.embeds.license?.data?.attributes, registrationType: data.attributes?.registration_supplement, - doi: data.attributes?.doi, + doi: data.attributes?.article_doi, tags: data.attributes?.tags, - contributors: data.embeds?.bibliographic_contributors?.data.map((contributor) => ({ - id: contributor?.embeds?.users?.data?.id, - familyName: contributor?.embeds?.users?.data?.attributes?.family_name, - fullName: contributor?.embeds?.users?.data?.attributes?.full_name, - givenName: contributor?.embeds?.users?.data?.attributes?.given_name, - middleName: contributor?.embeds?.users?.data?.attributes?.middle_names, - type: contributor?.embeds?.users?.data?.type, - })), - identifiers: data.embeds.identifiers?.data.map((identifier) => ({ - id: identifier.id, - type: identifier.type, - value: identifier.attributes.value, - category: identifier.attributes.category, - })), - analyticsKey: data.attributes?.analyticsKey, + contributors: ContributorsMapper.getContributors(data?.embeds?.bibliographic_contributors?.data), + identifiers: IdentifiersMapper.fromJsonApi(data.embeds?.identifiers), + analyticsKey: data.attributes?.analytics_key, currentUserCanComment: data.attributes.current_user_can_comment, currentUserPermissions: data.attributes.current_user_permissions, currentUserIsContributor: data.attributes.current_user_is_contributor, currentUserIsContributorOrGroupMember: data.attributes.current_user_is_contributor_or_group_member, citation: data.relationships?.citation?.data?.id, - wikiEnabled: data.attributes.wikiEnabled, + wikiEnabled: data.attributes.wiki_enabled, region: data.relationships.region?.data, hasData: data.attributes.has_data, hasAnalyticCode: data.attributes.has_analytic_code, hasMaterials: data.attributes.has_materials, hasPapers: data.attributes.has_papers, hasSupplements: data.attributes.has_supplements, - questions: data.attributes.registration_responses, + iaUrl: data.attributes.ia_url, + license: LicensesMapper.fromLicenseDataJsonApi(data.embeds?.license?.data), registrationSchemaLink: data.relationships.registration_schema.links.related.href, associatedProjectId: data.relationships?.registered_from?.data?.id, schemaResponses: data.embeds?.schema_responses?.data?.map((item) => RegistrationMapper.fromSchemaResponse(item)), - provider: { - id: data.embeds.provider.data.id, - name: data.embeds.provider.data.attributes.name, - permissions: data.embeds.provider.data.attributes.permissions, - }, + provider: RegistrationNodeMapper.getRegistrationProviderShortInfo(data.embeds?.provider?.data), status: MapRegistryStatus(data.attributes), revisionStatus: data.attributes.revision_state, reviewsState: data.attributes.reviews_state, - links: { - files: data?.embeds?.files?.data?.[0]?.relationships?.files?.links?.related?.href, - }, archiving: data.attributes.archiving, - currentUserIsModerator: ReviewPermissionsMapper.fromProviderResponse(data.embeds?.provider.data), withdrawn: data.attributes.withdrawn || false, withdrawalJustification: data.attributes.withdrawal_justification, dateWithdrawn: data.attributes.date_withdrawn || null, + embargoEndDate: data.attributes.embargo_end_date || null, + rootParentId: data.relationships.root?.data?.id, } as RegistryOverview; } + +export function MapRegistrationOverview(data: RegistryOverviewJsonApiData) { + const registrationAttributes = RegistrationNodeMapper.getRegistrationNodeAttributes(data.id, data.attributes); + const providerInfo = RegistrationNodeMapper.getRegistrationProviderShortInfo(data.embeds?.provider?.data); + const identifiers = IdentifiersMapper.fromJsonApi(data.embeds?.identifiers); + const license = LicensesMapper.fromLicenseDataJsonApi(data.embeds?.license?.data); + + return { + ...registrationAttributes, + provider: providerInfo, + identifiers: identifiers, + license: license, + }; +} diff --git a/src/app/features/registry/models/bibliographic-contributors.models.ts b/src/app/features/registry/models/bibliographic-contributors.models.ts deleted file mode 100644 index dcfa990ab..000000000 --- a/src/app/features/registry/models/bibliographic-contributors.models.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { InstitutionUsersLinksJsonApi } from '@osf/features/admin-institutions/models'; -import { MetaJsonApi } from '@osf/shared/models'; - -export interface BibliographicContributorJsonApi { - id: string; - type: 'contributors'; - attributes: { - index: number; - bibliographic: boolean; - permission: string; - unregistered_contributor: string | null; - is_curator: boolean; - }; - relationships: { - users: { - links: { - related: { - href: string; - meta: Record; - }; - }; - data: { - id: string; - type: 'users'; - }; - }; - node: { - links: { - related: { - href: string; - meta: Record; - }; - }; - data: { - id: string; - type: 'nodes'; - }; - }; - }; - embeds: { - users: { - data: { - id: string; - type: 'users'; - attributes: { - full_name: string; - given_name: string; - middle_names: string; - family_name: string; - suffix: string; - date_registered: string; - active: boolean; - timezone: string; - locale: string; - social: Record; - employment: unknown[]; - education: unknown[]; - }; - relationships: Record; - links: { - html: string; - profile_image: string; - self: string; - iri: string; - }; - }; - }; - }; - links: { - self: string; - }; -} - -export interface BibliographicContributorsResponse { - data: BibliographicContributorJsonApi[]; - meta: MetaJsonApi; - links: InstitutionUsersLinksJsonApi; -} - -export interface NodeBibliographicContributor { - id: string; - userId: string; - fullName: string; - givenName: string; - middleNames: string; - familyName: string; - suffix: string; - dateRegistered: string; - isActive: boolean; - timezone: string; - locale: string; - profileImage: string; - profileUrl: string; - permission: string; - isBibliographic: boolean; - isCurator: boolean; -} diff --git a/src/app/features/registry/models/get-registry-overview-json-api.model.ts b/src/app/features/registry/models/get-registry-overview-json-api.model.ts index a9679683c..79bc04867 100644 --- a/src/app/features/registry/models/get-registry-overview-json-api.model.ts +++ b/src/app/features/registry/models/get-registry-overview-json-api.model.ts @@ -1,9 +1,14 @@ import { RegistrationReviewStates, RevisionReviewStates } from '@osf/shared/enums'; import { ApiData, + ContributorDataJsonApi, + IdentifiersJsonApiData, JsonApiResponseWithMeta, + LicenseDataJsonApi, MetaAnonymousJsonApi, - ProviderDataJsonApi, + RegistrationNodeAttributesJsonApi, + RegistryProviderDetailsJsonApi, + ResponseJsonApi, SchemaResponseDataJsonApi, } from '@osf/shared/models'; @@ -14,7 +19,7 @@ export type GetRegistryOverviewJsonApi = JsonApiResponseWithMeta< >; export type RegistryOverviewJsonApiData = ApiData< - RegistryOverviewJsonApiAttributes, + RegistrationNodeAttributesJsonApi, RegistryOverviewJsonApiEmbed, RegistryOverviewJsonApiRelationships, null @@ -69,44 +74,12 @@ export type RegistrationQuestions = Record; schema_responses: { data: SchemaResponseDataJsonApi[]; }; @@ -124,7 +97,7 @@ export interface RegistryOverviewJsonApiEmbed { }; }[]; }; - provider: { data: ProviderDataJsonApi }; + provider: { data: RegistryProviderDetailsJsonApi }; } export interface RegistryOverviewJsonApiRelationships { @@ -160,4 +133,10 @@ export interface RegistryOverviewJsonApiRelationships { }; }; }; + root: { + data: { + id: string; + type: string; + }; + }; } diff --git a/src/app/features/registry/models/get-resource-subjects-json-api.model.ts b/src/app/features/registry/models/get-resource-subjects-json-api.model.ts deleted file mode 100644 index 9cee066aa..000000000 --- a/src/app/features/registry/models/get-resource-subjects-json-api.model.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ApiData, JsonApiResponse } from '@shared/models'; - -export type GetResourceSubjectsJsonApi = JsonApiResponse[], null>; diff --git a/src/app/features/registry/models/index.ts b/src/app/features/registry/models/index.ts index fcf6c2b48..6e4738ab5 100644 --- a/src/app/features/registry/models/index.ts +++ b/src/app/features/registry/models/index.ts @@ -1,14 +1,9 @@ -export * from './bibliographic-contributors.models'; export * from './get-registry-overview-json-api.model'; -export * from './get-resource-subjects-json-api.model'; export * from './linked-nodes.models'; export * from './linked-nodes-json-api.model'; export * from './linked-registrations-json-api.model'; export * from './linked-response.models'; export * from './registry-components.models'; export * from './registry-components-json-api.model'; -export * from './registry-contributor-json-api.model'; -export * from './registry-metadata.models'; export * from './registry-overview.models'; -export * from './registry-subject.model'; export * from './resources'; diff --git a/src/app/features/registry/models/linked-nodes-json-api.model.ts b/src/app/features/registry/models/linked-nodes-json-api.model.ts index 87877e2af..d1b672cea 100644 --- a/src/app/features/registry/models/linked-nodes-json-api.model.ts +++ b/src/app/features/registry/models/linked-nodes-json-api.model.ts @@ -1,110 +1,12 @@ -import { MetaJsonApi } from '@osf/shared/models'; +import { BaseNodeAttributesJsonApi, ContributorDataJsonApi, MetaJsonApi } from '@osf/shared/models'; export interface LinkedNodeJsonApi { id: string; type: 'nodes'; - attributes: { - title: string; - description: string; - category: string; - custom_citation: string; - date_created: string; - date_modified: string; - registration: boolean; - preprint: boolean; - fork: boolean; - collection: boolean; - tags: string[]; - node_license?: { - copyright_holders: string[]; - year: string; - }; - analytics_key: string; - current_user_can_comment: boolean; - current_user_permissions: string[]; - current_user_is_contributor: boolean; - current_user_is_contributor_or_group_member: boolean; - wiki_enabled: boolean; - public: boolean; - }; - relationships: { - license: { - links: { - related: { - href: string; - meta: Record; - }; - }; - data: { - id: string; - type: 'licenses'; - }; - }; - children: { - links: { - related: { - href: string; - meta: Record; - }; - }; - }; - contributors: { - links: { - related: { - href: string; - meta: Record; - }; - }; - }; - files: { - links: { - related: { - href: string; - meta: Record; - }; - }; - }; - parent: { - data: null | { - id: string; - type: 'nodes'; - }; - }; - root: { - links: { - related: { - href: string; - meta: Record; - }; - }; - data: { - id: string; - type: 'nodes'; - }; - }; - linked_nodes: { - links: { - related: { - href: string; - meta: Record; - }; - self: { - href: string; - meta: Record; - }; - }; - }; - linked_registrations: { - links: { - related: { - href: string; - meta: Record; - }; - self: { - href: string; - meta: Record; - }; - }; + attributes: BaseNodeAttributesJsonApi; + embeds: { + bibliographic_contributors: { + data: ContributorDataJsonApi[]; }; }; links: { diff --git a/src/app/features/registry/models/linked-nodes.models.ts b/src/app/features/registry/models/linked-nodes.models.ts index cff6bb26f..105fe3e14 100644 --- a/src/app/features/registry/models/linked-nodes.models.ts +++ b/src/app/features/registry/models/linked-nodes.models.ts @@ -1,7 +1,6 @@ +import { ContributorModel } from '@osf/shared/models'; import { RegistrationReviewStates } from '@shared/enums'; -import { NodeBibliographicContributor } from './bibliographic-contributors.models'; - export interface LinkedNode { id: string; title: string; @@ -11,8 +10,7 @@ export interface LinkedNode { dateModified: string; tags: string[]; isPublic: boolean; - contributorsCount?: number; - contributors?: NodeBibliographicContributor[]; + contributors: ContributorModel[]; htmlUrl: string; apiUrl: string; } @@ -27,10 +25,9 @@ export interface LinkedRegistration { dateRegistered?: string; tags: string[]; isPublic: boolean; - contributorsCount?: number; reviewsState: RegistrationReviewStates; revisionState?: string; - contributors?: NodeBibliographicContributor[]; + contributors: ContributorModel[]; currentUserPermissions: string[]; hasData?: boolean; hasAnalyticCode?: boolean; diff --git a/src/app/features/registry/models/linked-registrations-json-api.model.ts b/src/app/features/registry/models/linked-registrations-json-api.model.ts index 09999de8e..6c34b8090 100644 --- a/src/app/features/registry/models/linked-registrations-json-api.model.ts +++ b/src/app/features/registry/models/linked-registrations-json-api.model.ts @@ -1,34 +1,13 @@ -import { MetaJsonApi } from '@osf/shared/models'; -import { RegistrationReviewStates } from '@shared/enums'; +import { ContributorDataJsonApi, MetaJsonApi, RegistrationNodeAttributesJsonApi } from '@osf/shared/models'; export interface LinkedRegistrationJsonApi { id: string; type: 'registrations'; - attributes: { - title: string; - description: string; - category: string; - date_created: string; - date_registered?: string; - date_modified: string; - reviews_state: RegistrationReviewStates; - tags: string[]; - public: boolean; - registration_supplement: string; - current_user_permissions: string[]; - has_data: boolean; - has_analytic_code: boolean; - has_materials: boolean; - has_papers: boolean; - has_supplements: boolean; - withdrawn: boolean; - embargoed: boolean; - pending_withdrawal: boolean; - pending_registration_approval: boolean; - subjects: { - id: string; - text: string; - }[][]; + attributes: RegistrationNodeAttributesJsonApi; + embeds: { + bibliographic_contributors: { + data: ContributorDataJsonApi[]; + }; }; } diff --git a/src/app/features/registry/models/registry-components-json-api.model.ts b/src/app/features/registry/models/registry-components-json-api.model.ts index 7602d5f95..fb298d250 100644 --- a/src/app/features/registry/models/registry-components-json-api.model.ts +++ b/src/app/features/registry/models/registry-components-json-api.model.ts @@ -1,20 +1,15 @@ -import { MetaJsonApi } from '@osf/shared/models'; +import { ContributorDataJsonApi, MetaJsonApi, RegistrationNodeAttributesJsonApi } from '@osf/shared/models'; import { RegistryComponentModel } from './registry-components.models'; export interface RegistryComponentJsonApi { id: string; type: string; - attributes: { - title: string; - description: string; - category: string; - date_created: string; - date_modified: string; - date_registered: string; - registration_supplement: string; - tags: string[]; - public: boolean; + attributes: RegistrationNodeAttributesJsonApi; + embeds: { + bibliographic_contributors: { + data: ContributorDataJsonApi[]; + }; }; } diff --git a/src/app/features/registry/models/registry-components.models.ts b/src/app/features/registry/models/registry-components.models.ts index 8fc63540f..b1f0158d3 100644 --- a/src/app/features/registry/models/registry-components.models.ts +++ b/src/app/features/registry/models/registry-components.models.ts @@ -1,4 +1,4 @@ -import { NodeBibliographicContributor } from './bibliographic-contributors.models'; +import { ContributorModel } from '@osf/shared/models'; export interface RegistryComponentModel { id: string; @@ -11,7 +11,6 @@ export interface RegistryComponentModel { registrationSupplement: string; tags: string[]; isPublic: boolean; - contributorsCount?: number; - contributors?: NodeBibliographicContributor[]; + contributors: ContributorModel[]; registry?: string; } diff --git a/src/app/features/registry/models/registry-contributor-json-api.model.ts b/src/app/features/registry/models/registry-contributor-json-api.model.ts deleted file mode 100644 index a75b4391c..000000000 --- a/src/app/features/registry/models/registry-contributor-json-api.model.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { MetaJsonApi } from '@osf/shared/models'; - -export interface RegistryContributorJsonApi { - id: string; - type: 'contributors'; - attributes: { - index: number; - bibliographic: boolean; - permission: string; - unregistered_contributor: string | null; - is_curator: boolean; - }; - relationships: { - users: { - links: { - related: { - href: string; - meta: Record; - }; - }; - data: { - id: string; - type: 'users'; - }; - }; - node: { - links: { - related: { - href: string; - meta: Record; - }; - }; - data: { - id: string; - type: 'nodes'; - }; - }; - }; - embeds?: { - users: { - data: { - id: string; - type: 'users'; - attributes: { - full_name: string; - given_name: string; - middle_names: string; - family_name: string; - suffix: string; - date_registered: string; - active: boolean; - timezone: string; - locale: string; - social: Record; - employment: unknown[]; - education: unknown[]; - }; - relationships: Record; - links: { - html: string; - profile_image: string; - self: string; - iri: string; - }; - }; - }; - }; - links: { - self: string; - }; -} - -export interface RegistryContributorJsonApiResponse { - data: RegistryContributorJsonApi; - links: { - self: string; - }; - meta: MetaJsonApi; -} - -export interface RegistryContributorUpdateRequest { - data: { - id: string; - type: 'contributors'; - attributes: Record; - relationships: Record; - }; -} - -export interface RegistryContributorAddRequest { - data: { - type: 'contributors'; - attributes: Record; - relationships: Record; - }; -} diff --git a/src/app/features/registry/models/registry-metadata.models.ts b/src/app/features/registry/models/registry-metadata.models.ts deleted file mode 100644 index 46e340b14..000000000 --- a/src/app/features/registry/models/registry-metadata.models.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { MetaJsonApi } from '@osf/shared/models'; - -export interface BibliographicContributorsJsonApi { - data: BibliographicContributorData[]; - meta: MetaJsonApi; - links: { - self: string; - first: string | null; - last: string | null; - prev: string | null; - next: string | null; - }; -} - -export interface BibliographicContributorData { - id: string; - type: string; - attributes: { - index: number; - }; - relationships: { - users: { - links: { - related: { - href: string; - meta: Record; - }; - }; - data: { - id: string; - type: string; - }; - }; - }; - embeds: { - users: { - data: { - id: string; - type: string; - attributes: { - full_name: string; - }; - links: { - html: string; - profile_image: string; - self: string; - iri: string; - }; - }; - }; - }; - links: { - self: string; - }; -} - -export interface BibliographicContributor { - id: string; - index: number; - user: { - id: string; - fullName: string; - profileImage: string; - htmlUrl: string; - iri: string; - }; -} - -export interface CustomItemMetadataRecord { - language?: string; - resource_type_general?: string; - funders?: { - funder_name: string; - funder_identifier?: string; - funder_identifier_type?: string; - award_number?: string; - award_uri?: string; - award_title?: string; - }[]; -} - -export interface CustomItemMetadataResponse { - data: { - id: string; - type: string; - attributes: CustomItemMetadataRecord; - }; -} - -export interface CrossRefFunder { - id: number; - location: string; - name: string; - alt_names: string[]; - uri: string; - replaces: number[]; - replaced_by: number | null; - tokens: string[]; -} - -export interface CrossRefFundersResponse { - status: string; - message_type: string; - message_version: string; - message: { - facets: Record; - total_results: number; - items: CrossRefFunder[]; - items_per_page: number; - }; -} - -export interface UserInstitution { - id: string; - type: string; - attributes: { - name: string; - description: string; - logo_path: string | null; - }; -} - -export interface UserInstitutionsResponse { - data: UserInstitution[]; - meta: MetaJsonApi; - links: { - self: string; - first: string | null; - last: string | null; - prev: string | null; - next: string | null; - }; -} - -export interface RegistryMetadata { - tags?: string[]; - description?: string; - category?: string; - doi?: boolean; - node_license?: { - id: string; - type: string; - }; - institutions?: { - id: string; - type: string; - }[]; -} - -export interface RegistrySubjectsJsonApi { - data: RegistrySubjectData[]; - links: { - first: string | null; - last: string | null; - prev: string | null; - next: string | null; - }; - meta: MetaJsonApi; -} - -export interface RegistrySubjectData { - id: string; - type: string; - attributes: { - text: string; - taxonomy_name: string; - }; - relationships: { - children: { - links: { - related: { - href: string; - meta: Record; - }; - }; - }; - }; - links: { - self: string; - iri: string; - }; -} diff --git a/src/app/features/registry/models/registry-overview.models.ts b/src/app/features/registry/models/registry-overview.models.ts index 1785d38c8..1031f5c57 100644 --- a/src/app/features/registry/models/registry-overview.models.ts +++ b/src/app/features/registry/models/registry-overview.models.ts @@ -1,14 +1,17 @@ -import { ProjectOverviewContributor } from '@osf/features/project/overview/models'; -import { RegistrationQuestions, RegistrySubject } from '@osf/features/registry/models'; import { + ContributorModel, + Identifier, IdTypeModel, - License, + LicenseModel, LicensesOption, MetaAnonymousJsonApi, - ProviderModel, + ProviderShortInfoModel, + RegistrationNodeModel, + RegistrationResponses, SchemaResponse, + SubjectModel, } from '@osf/shared/models'; -import { RegistrationReviewStates, RegistryStatus, RevisionReviewStates } from '@shared/enums'; +import { RegistrationReviewStates, RegistryStatus, RevisionReviewStates, UserPermissions } from '@shared/enums'; export interface RegistryOverview { id: string; @@ -23,51 +26,57 @@ export interface RegistryOverview { registrationType: string; doi: string; tags: string[]; - provider?: ProviderModel; - contributors: ProjectOverviewContributor[]; + provider?: ProviderShortInfoModel; + contributors: ContributorModel[]; citation: string; category: string; isFork: boolean; accessRequestsEnabled: boolean; nodeLicense?: LicensesOption; - license?: License; + license?: LicenseModel; licenseUrl?: string; - identifiers?: { - id: string; - type: string; - category: string; - value: string; - }[]; + identifiers?: Identifier[]; analyticsKey: string; currentUserCanComment: boolean; - currentUserPermissions: string[]; + currentUserPermissions: UserPermissions[]; currentUserIsContributor: boolean; currentUserIsContributorOrGroupMember: boolean; wikiEnabled: boolean; region?: IdTypeModel; - subjects?: RegistrySubject[]; + subjects?: SubjectModel[]; customCitation: string; hasData: boolean; hasAnalyticCode: boolean; hasMaterials: boolean; hasPapers: boolean; hasSupplements: boolean; - questions: RegistrationQuestions; + questions: RegistrationResponses; registrationSchemaLink: string; associatedProjectId: string; schemaResponses: SchemaResponse[]; status: RegistryStatus; revisionStatus: RevisionReviewStates; reviewsState?: RegistrationReviewStates; - links: { - files: string; - }; archiving: boolean; embargoEndDate: string; - currentUserIsModerator: boolean; withdrawn: boolean; withdrawalJustification?: string; dateWithdrawn: string | null; + rootParentId: string | null; + iaUrl: string | null; +} + +export interface RegistrationOverviewModel extends RegistrationNodeModel { + type: string; + registrationSchemaLink: string; + associatedProjectId: string; + citation: string; + provider?: ProviderShortInfoModel; + contributors: ContributorModel[]; + license?: LicenseModel; + identifiers?: Identifier[]; + schemaResponses: SchemaResponse[]; + status: RegistryStatus; } export interface RegistryOverviewWithMeta { diff --git a/src/app/features/registry/models/registry-subject.model.ts b/src/app/features/registry/models/registry-subject.model.ts deleted file mode 100644 index 19f1c6c07..000000000 --- a/src/app/features/registry/models/registry-subject.model.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface RegistrySubject { - id: string; - text: string; -} diff --git a/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.html b/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.html new file mode 100644 index 000000000..f46a02896 --- /dev/null +++ b/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.html @@ -0,0 +1,50 @@ +
+

+ {{ 'project.overview.recentActivity.title' | translate }} +

+ + @if (!isLoading()) { +
+ @for (activityLog of formattedActivityLogs(); track activityLog.id) { +
+
+ + +
+ } @empty { +
+ {{ 'project.overview.recentActivity.noActivity' | translate }} +
+ } +
+ + @if (totalCount() > pageSize) { + + } + } @else { +
+ + + + + +
+ } +
diff --git a/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.spec.ts b/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.spec.ts new file mode 100644 index 000000000..be42f705e --- /dev/null +++ b/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.spec.ts @@ -0,0 +1,197 @@ +import { provideStore, Store } from '@ngxs/store'; + +import { TranslateService } from '@ngx-translate/core'; + +import { of } from 'rxjs'; + +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { ActivityLogDisplayService } from '@shared/services'; +import { ClearActivityLogsStore, GetRegistrationActivityLogs } from '@shared/stores/activity-logs'; +import { ActivityLogsState } from '@shared/stores/activity-logs/activity-logs.state'; + +import { RegistrationRecentActivityComponent } from './registration-recent-activity.component'; + +describe('RegistrationRecentActivityComponent', () => { + let fixture: ComponentFixture; + let store: Store; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RegistrationRecentActivityComponent], + providers: [ + provideStore([ActivityLogsState]), + + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + + { + provide: TranslateService, + useValue: { + instant: (k: string) => k, + get: () => of(''), + stream: () => of(''), + onLangChange: of({}), + onDefaultLangChange: of({}), + onTranslationChange: of({}), + }, + }, + + { + provide: ActivityLogDisplayService, + useValue: { getActivityDisplay: jest.fn(() => 'formatted') }, + }, + + { provide: ActivatedRoute, useValue: { snapshot: { params: { id: 'reg123' } }, parent: null } }, + ], + }).compileComponents(); + + store = TestBed.inject(Store); + jest.spyOn(store, 'dispatch'); + + fixture = TestBed.createComponent(RegistrationRecentActivityComponent); + fixture.detectChanges(); + }); + + it('dispatches initial registration logs fetch', () => { + const dispatchSpy = store.dispatch as jest.Mock; + expect(dispatchSpy).toHaveBeenCalledWith(expect.any(GetRegistrationActivityLogs)); + const action = dispatchSpy.mock.calls.at(-1)?.[0] as GetRegistrationActivityLogs; + expect(action.registrationId).toBe('reg123'); + expect(action.page).toBe(1); + }); + + it('renders empty state when no logs and not loading', () => { + store.reset({ + activityLogs: { + activityLogs: { data: [], isLoading: false, error: null, totalCount: 0 }, + }, + } as any); + fixture.detectChanges(); + + const empty = fixture.nativeElement.querySelector('[data-test="recent-activity-empty"]'); + expect(empty).toBeTruthy(); + }); + + it('renders item & paginator when logs exist and totalCount > pageSize', () => { + store.reset({ + activityLogs: { + activityLogs: { + data: [ + { + id: 'log1', + date: '2024-01-01T12:34:00Z', + formattedActivity: 'formatted', + }, + ], + isLoading: false, + error: null, + totalCount: 25, + }, + }, + } as any); + fixture.detectChanges(); + + const item = fixture.nativeElement.querySelector('[data-test="recent-activity-item"]'); + const content = fixture.nativeElement.querySelector('[data-test="recent-activity-item-content"]'); + const paginator = fixture.nativeElement.querySelector('[data-test="recent-activity-paginator"]'); + const dateText = fixture.nativeElement.querySelector('[data-test="recent-activity-item-date"]')?.textContent ?? ''; + + expect(item).toBeTruthy(); + expect(content?.innerHTML).toContain('formatted'); + expect(paginator).toBeTruthy(); + expect(dateText).toMatch(/\w{3} \d{1,2}, \d{4} \d{1,2}:\d{2} [AP]M/); + }); + + it('does not render paginator when totalCount <= pageSize', () => { + store.reset({ + activityLogs: { + activityLogs: { + data: [{ id: 'log1', date: '2024-01-01T12:34:00Z', formattedActivity: 'formatted' }], + isLoading: false, + error: null, + totalCount: 10, + }, + }, + } as any); + fixture.detectChanges(); + + const paginator = fixture.nativeElement.querySelector('[data-test="recent-activity-paginator"]'); + expect(paginator).toBeFalsy(); + }); + + it('dispatches on page change', () => { + const dispatchSpy = store.dispatch as jest.Mock; + dispatchSpy.mockClear(); + + fixture.componentInstance.onPageChange({ page: 2 } as any); + expect(dispatchSpy).toHaveBeenCalledWith(expect.any(GetRegistrationActivityLogs)); + + const action = dispatchSpy.mock.calls.at(-1)?.[0] as GetRegistrationActivityLogs; + expect(action.page).toBe(3); + }); + + it('does not dispatch when page change event has undefined page', () => { + const dispatchSpy = store.dispatch as jest.Mock; + dispatchSpy.mockClear(); + + fixture.componentInstance.onPageChange({} as any); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it('computes firstIndex correctly after page change', () => { + fixture.componentInstance.onPageChange({ page: 1 } as any); + const firstIndex = (fixture.componentInstance as any)['firstIndex'](); + expect(firstIndex).toBe(10); + }); + + it('clears store on destroy', () => { + const dispatchSpy = store.dispatch as jest.Mock; + dispatchSpy.mockClear(); + + fixture.destroy(); + expect(dispatchSpy).toHaveBeenCalledWith(expect.any(ClearActivityLogsStore)); + }); + + it('shows skeleton while loading', () => { + store.reset({ + activityLogs: { + activityLogs: { data: [], isLoading: true, error: null, totalCount: 0 }, + }, + } as any); + + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('[data-test="recent-activity-skeleton"]')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('[data-test="recent-activity-list"]')).toBeFalsy(); + expect(fixture.nativeElement.querySelector('[data-test="recent-activity-paginator"]')).toBeFalsy(); + }); + + it('renders expected ARIA roles/labels', () => { + store.reset({ + activityLogs: { + activityLogs: { + data: [{ id: 'log1', date: '2024-01-01T12:34:00Z', formattedActivity: 'formatted' }], + isLoading: false, + error: null, + totalCount: 1, + }, + }, + } as any); + fixture.detectChanges(); + + const region = fixture.nativeElement.querySelector('[role="region"]'); + const heading = fixture.nativeElement.querySelector('#recent-activity-title'); + const list = fixture.nativeElement.querySelector('[role="list"]'); + const listitem = fixture.nativeElement.querySelector('[role="listitem"]'); + + expect(region).toBeTruthy(); + expect(region.getAttribute('aria-labelledby')).toBe('recent-activity-title'); + expect(heading).toBeTruthy(); + expect(list).toBeTruthy(); + expect(listitem).toBeTruthy(); + }); +}); diff --git a/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.ts b/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.ts new file mode 100644 index 000000000..47f4e0d78 --- /dev/null +++ b/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.ts @@ -0,0 +1,64 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { PaginatorState } from 'primeng/paginator'; +import { Skeleton } from 'primeng/skeleton'; + +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, signal } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { CustomPaginatorComponent } from '@shared/components'; +import { ACTIVITY_LOGS_DEFAULT_PAGE_SIZE } from '@shared/constants/activity-logs'; +import { + ActivityLogsSelectors, + ClearActivityLogsStore, + GetRegistrationActivityLogs, +} from '@shared/stores/activity-logs'; + +@Component({ + selector: 'osf-registration-recent-activity', + imports: [TranslatePipe, DatePipe, CustomPaginatorComponent, Skeleton], + templateUrl: './registration-recent-activity.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistrationRecentActivityComponent implements OnDestroy { + private readonly route = inject(ActivatedRoute); + readonly environment = inject(ENVIRONMENT); + + readonly pageSize = this.environment.activityLogs?.pageSize ?? ACTIVITY_LOGS_DEFAULT_PAGE_SIZE; + + private readonly registrationId: string = (this.route.snapshot.params['id'] ?? + this.route.parent?.snapshot.params['id']) as string; + + currentPage = signal(1); + + formattedActivityLogs = select(ActivityLogsSelectors.getFormattedActivityLogs); + totalCount = select(ActivityLogsSelectors.getActivityLogsTotalCount); + isLoading = select(ActivityLogsSelectors.getActivityLogsLoading); + + firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize); + + actions = createDispatchMap({ + getRegistrationActivityLogs: GetRegistrationActivityLogs, + clearActivityLogsStore: ClearActivityLogsStore, + }); + + constructor() { + this.actions.getRegistrationActivityLogs(this.registrationId, 1, this.pageSize); + } + + onPageChange(event: PaginatorState) { + if (event.page !== undefined) { + const pageNumber = event.page + 1; + this.currentPage.set(pageNumber); + this.actions.getRegistrationActivityLogs(this.registrationId, pageNumber, this.pageSize); + } + } + + ngOnDestroy(): void { + this.actions.clearActivityLogsStore(); + } +} diff --git a/src/app/features/registry/pages/registry-components/registry-components.component.html b/src/app/features/registry/pages/registry-components/registry-components.component.html index 381e4ce9f..fa9016802 100644 --- a/src/app/features/registry/pages/registry-components/registry-components.component.html +++ b/src/app/features/registry/pages/registry-components/registry-components.component.html @@ -10,13 +10,13 @@
- } @else if (components().length === 0) { + } @else if (registryComponents().length === 0) {

{{ 'registry.components.noComponentsFound' | translate }}

} @else { -
- @for (component of components(); track component.id) { +
+ @for (component of registryComponents(); track component.id) { { - return hasViewOnlyParam(this.router); - }); - - components = signal([]); + hasViewOnly = computed(() => hasViewOnlyParam(this.router)); registryComponents = select(RegistryComponentsSelectors.getRegistryComponents); registryComponentsLoading = select(RegistryComponentsSelectors.getRegistryComponentsLoading); - bibliographicContributorsForRegistration = select(RegistryLinksSelectors.getBibliographicContributorsForRegistration); - bibliographicContributorsForRegistrationId = select( - RegistryLinksSelectors.getBibliographicContributorsForRegistrationId - ); - - registry = select(RegistryOverviewSelectors.getRegistry); - - constructor() { - effect(() => { - const components = this.registryComponents(); - - if (components.length > 0) { - components.forEach((component) => { - this.fetchContributorsForComponent(component.id); - }); - - this.components.set(components); - } - }); - - effect(() => { - const bibliographicContributorsForRegistration = this.bibliographicContributorsForRegistration(); - const bibliographicContributorsForRegistrationId = this.bibliographicContributorsForRegistrationId(); - - if (bibliographicContributorsForRegistration && bibliographicContributorsForRegistrationId) { - this.components.set( - this.registryComponents().map((component) => { - if (component.id === bibliographicContributorsForRegistrationId) { - return { - ...component, - registry: this.registry()?.provider?.name, - contributors: bibliographicContributorsForRegistration, - }; - } - - return component; - }) - ); - } - }); - } - ngOnInit(): void { this.registryId.set(this.route.parent?.parent?.snapshot.params['id']); if (this.registryId()) { this.actions.getRegistryComponents(this.registryId()); - this.actions.getRegistryById(this.registryId(), true); } } - fetchContributorsForComponent(componentId: string): void { - this.actions.getBibliographicContributorsForRegistration(componentId); - } - reviewComponentDetails(id: string): void { this.router.navigate([id, 'overview']); } diff --git a/src/app/features/registry/pages/registry-links/registry-links.component.html b/src/app/features/registry/pages/registry-links/registry-links.component.html index a31859c56..ce7cc1ac9 100644 --- a/src/app/features/registry/pages/registry-links/registry-links.component.html +++ b/src/app/features/registry/pages/registry-links/registry-links.component.html @@ -16,8 +16,8 @@

{{ 'registry.links.noLinkedProjectsOrComponentsFound' | translate }}

} @else { -
- @for (node of nodes(); track node.id) { +
+ @for (node of linkedNodes(); track node.id) {
- } @else if (registrations().length === 0) { + } @else if (linkedRegistrations().length === 0) {

{{ 'registry.links.noLinkedRegistrationsFound' | translate }}

} @else { -
- @for (registration of registrations(); track registration.id) { +
+ @for (registration of linkedRegistrations(); track registration.id) { ([]); - registrations = signal([]); - - protected linkedNodes = select(RegistryLinksSelectors.getLinkedNodes); - protected linkedNodesLoading = select(RegistryLinksSelectors.getLinkedNodesLoading); - - protected linkedRegistrations = select(RegistryLinksSelectors.getLinkedRegistrations); - protected linkedRegistrationsLoading = select(RegistryLinksSelectors.getLinkedRegistrationsLoading); - - protected bibliographicContributors = select(RegistryLinksSelectors.getBibliographicContributors); - protected bibliographicContributorsNodeId = select(RegistryLinksSelectors.getBibliographicContributorsNodeId); - - protected bibliographicContributorsForRegistration = select( - RegistryLinksSelectors.getBibliographicContributorsForRegistration - ); - protected bibliographicContributorsForRegistrationId = select( - RegistryLinksSelectors.getBibliographicContributorsForRegistrationId - ); - - protected schemaResponse = select(RegistriesSelectors.getSchemaResponse); - - constructor() { - effect(() => { - const nodes = this.linkedNodes(); - - if (nodes) { - nodes.forEach((node) => { - this.fetchContributors(node.id); - }); - - this.nodes.set(nodes); - } - }); - - effect(() => { - const bibliographicContributors = this.bibliographicContributors(); - const bibliographicContributorsNodeId = this.bibliographicContributorsNodeId(); - - if (bibliographicContributors && bibliographicContributorsNodeId) { - this.nodes.set( - this.linkedNodes().map((node) => { - if (node.id === bibliographicContributorsNodeId) { - return { - ...node, - contributors: bibliographicContributors, - }; - } - - return node; - }) - ); - } - }); - - effect(() => { - const registrations = this.linkedRegistrations(); - - if (registrations) { - registrations.forEach((registration) => { - this.fetchContributorsForRegistration(registration.id); - }); - - this.registrations.set(registrations); - } - }); - - effect(() => { - const bibliographicContributorsForRegistration = this.bibliographicContributorsForRegistration(); - const bibliographicContributorsForRegistrationId = this.bibliographicContributorsForRegistrationId(); - - if (bibliographicContributorsForRegistration && bibliographicContributorsForRegistrationId) { - this.registrations.set( - this.linkedRegistrations().map((registration) => { - if (registration.id === bibliographicContributorsForRegistrationId) { - return { - ...registration, - contributors: bibliographicContributorsForRegistration, - }; - } - - return registration; - }) - ); - } - }); - } + linkedNodes = select(RegistryLinksSelectors.getLinkedNodes); + linkedNodesLoading = select(RegistryLinksSelectors.getLinkedNodesLoading); + + linkedRegistrations = select(RegistryLinksSelectors.getLinkedRegistrations); + linkedRegistrationsLoading = select(RegistryLinksSelectors.getLinkedRegistrationsLoading); + + schemaResponse = select(RegistriesSelectors.getSchemaResponse); ngOnInit(): void { this.registryId.set(this.route.parent?.parent?.snapshot.params['id']); @@ -160,14 +72,6 @@ export class RegistryLinksComponent implements OnInit { this.router.navigate([id, 'overview']); } - fetchContributors(nodeId: string): void { - this.actions.getBibliographicContributors(nodeId); - } - - fetchContributorsForRegistration(registrationId: string): void { - this.actions.getBibliographicContributorsForRegistration(registrationId); - } - private navigateToJustificationPage(): void { const revisionId = this.schemaResponse()?.id; this.router.navigate([`/registries/revisions/${revisionId}/justification`]); 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 74ba3f8b0..8280fd1cb 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,19 +1,24 @@ -@if (!isRegistryLoading() && !isSubjectsLoading() && !isInstitutionsLoading() && !isSchemaBlocksLoading()) { +@if (!isLoading()) {
- @if (!registry()?.archiving && !registry()?.withdrawn) { + @if (showToolbar()) {
- +
}
@@ -27,7 +32,7 @@
} @else {
- @if (!schemaResponse()?.isOriginalResponse && !isInitialState && !hasViewOnly()) { + @if (schemaResponse() && !schemaResponse()?.isOriginalResponse && !isInitialState) {
@@ -45,11 +50,13 @@

} + @if (hasViewOnly()) { } +
@if (!isAnonymous()) {
- + + (continueUpdate)="onContinueUpdateRegistration()" [isModeration]="isModeration" [isSubmitting]="isSchemaResponseLoading()" + [canEdit]="hasAdminAccess()" >
@@ -121,7 +134,8 @@

{{ section.title }}

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 ef6f6d95a..b35ef8cdc 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 @@ -1,15 +1,23 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; -import { DialogService } from 'primeng/dynamicdialog'; import { Message } from 'primeng/message'; -import { filter, map, switchMap, tap } from 'rxjs'; +import { map, of, switchMap, tap } from 'rxjs'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, DestroyRef, HostBinding, inject, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + HostBinding, + inject, + signal, +} from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { OverviewToolbarComponent } from '@osf/features/project/overview/components'; @@ -20,14 +28,14 @@ import { RegistrationBlocksDataComponent, ResourceMetadataComponent, SubHeaderComponent, + ViewOnlyLinkMessageComponent, } from '@osf/shared/components'; import { RegistrationReviewStates, ResourceType, RevisionReviewStates, UserPermissions } from '@osf/shared/enums'; import { hasViewOnlyParam, toCamelCase } from '@osf/shared/helpers'; import { MapRegistryOverview } from '@osf/shared/mappers'; import { SchemaResponse, ToolbarResource } from '@osf/shared/models'; -import { ToastService } from '@osf/shared/services'; -import { GetBookmarksCollectionId } from '@osf/shared/stores'; -import { ViewOnlyLinkMessageComponent } from '@shared/components/view-only-link-message/view-only-link-message.component'; +import { CustomDialogService, ToastService } from '@osf/shared/services'; +import { FetchSelectedSubjects, GetBookmarksCollectionId, SubjectsSelectors } from '@osf/shared/stores'; import { ArchivingMessageComponent, RegistryRevisionsComponent, RegistryStatusesComponent } from '../../components'; import { RegistryMakeDecisionComponent } from '../../components/registry-make-decision/registry-make-decision.component'; @@ -36,7 +44,6 @@ import { GetRegistryById, GetRegistryInstitutions, GetRegistryReviewActions, - GetRegistrySubjects, RegistryOverviewSelectors, SetRegistryCustomCitation, } from '../../store/registry-overview'; @@ -62,7 +69,6 @@ import { templateUrl: './registry-overview.component.html', styleUrl: './registry-overview.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [DialogService], }) export class RegistryOverviewComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full h-full'; @@ -70,14 +76,13 @@ export class RegistryOverviewComponent { private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); private readonly toastService = inject(ToastService); - private readonly dialogService = inject(DialogService); - private readonly translateService = inject(TranslateService); + private readonly customDialogService = inject(CustomDialogService); readonly registry = select(RegistryOverviewSelectors.getRegistry); readonly isRegistryLoading = select(RegistryOverviewSelectors.isRegistryLoading); readonly isAnonymous = select(RegistryOverviewSelectors.isRegistryAnonymous); - readonly subjects = select(RegistryOverviewSelectors.getSubjects); - readonly isSubjectsLoading = select(RegistryOverviewSelectors.isSubjectsLoading); + readonly subjects = select(SubjectsSelectors.getSelectedSubjects); + readonly areSubjectsLoading = select(SubjectsSelectors.areSelectedSubjectsLoading); readonly institutions = select(RegistryOverviewSelectors.getInstitutions); readonly isInstitutionsLoading = select(RegistryOverviewSelectors.isInstitutionsLoading); readonly schemaBlocks = select(RegistryOverviewSelectors.getSchemaBlocks); @@ -85,18 +90,45 @@ export class RegistryOverviewComponent { readonly areReviewActionsLoading = select(RegistryOverviewSelectors.areReviewActionsLoading); readonly currentRevision = select(RegistriesSelectors.getSchemaResponse); readonly isSchemaResponseLoading = select(RegistriesSelectors.getSchemaResponseLoading); + + readonly hasWriteAccess = select(RegistryOverviewSelectors.hasWriteAccess); + readonly hasAdminAccess = select(RegistryOverviewSelectors.hasAdminAccess); + readonly hasNoPermissions = select(RegistryOverviewSelectors.hasNoPermissions); + revisionInProgress: SchemaResponse | undefined; + isLoading = computed( + () => + this.isRegistryLoading() || + this.isInstitutionsLoading() || + this.isSchemaBlocksLoading() || + this.isSchemaResponseLoading() || + this.areSubjectsLoading() + ); + + canMakeDecision = computed(() => !this.registry()?.archiving && !this.registry()?.withdrawn && this.isModeration); + + isRootRegistration = computed(() => { + const rootId = this.registry()?.rootParentId; + return !rootId || rootId === this.registry()?.id; + }); + + private registryId = toSignal(this.route.parent?.params.pipe(map((params) => params['id'])) ?? of(undefined)); + readonly schemaResponse = computed(() => { const registry = this.registry(); const index = this.selectedRevisionIndex(); - this.revisionInProgress = registry?.schemaResponses.find( + this.revisionInProgress = registry?.schemaResponses?.find( (r) => r.reviewsState === RevisionReviewStates.RevisionInProgress ); + const schemaResponses = (this.isModeration ? registry?.schemaResponses - : registry?.schemaResponses.filter((r) => r.reviewsState === RevisionReviewStates.Approved)) || []; + : registry?.schemaResponses?.filter( + (r) => r.reviewsState === RevisionReviewStates.Approved || this.hasAdminAccess() + )) || []; + if (index !== null) { return schemaResponses[index]; } @@ -115,18 +147,23 @@ export class RegistryOverviewComponent { const registry = this.registry(); const subjects = this.subjects(); const institutions = this.institutions(); + if (registry && subjects && institutions) { return MapRegistryOverview(registry, subjects, institutions, this.isAnonymous()); } + return null; }); readonly selectedRevisionIndex = signal(0); + showToolbar = computed(() => !this.registry()?.archiving && !this.registry()?.withdrawn); + toolbarResource = computed(() => { if (this.registry()) { return { id: this.registry()!.id, + title: this.registry()?.title, isPublic: this.registry()!.isPublic, storage: undefined, viewOnlyLinksCount: 0, @@ -141,7 +178,7 @@ export class RegistryOverviewComponent { private readonly actions = createDispatchMap({ getRegistryById: GetRegistryById, getBookmarksId: GetBookmarksCollectionId, - getSubjects: GetRegistrySubjects, + getSubjects: FetchSelectedSubjects, getInstitutions: GetRegistryInstitutions, setCustomCitation: SetRegistryCustomCitation, getRegistryReviewActions: GetRegistryReviewActions, @@ -152,38 +189,34 @@ export class RegistryOverviewComponent { revisionId: string | null = null; isModeration = false; - userPermissions = computed(() => { - return this.registry()?.currentUserPermissions || []; - }); + hasViewOnly = computed(() => hasViewOnlyParam(this.router)); - hasViewOnly = computed(() => { - return hasViewOnlyParam(this.router); + canEdit = computed(() => { + const registry = this.registry(); + if (!registry) return false; + return ( + registry.currentUserPermissions.includes(UserPermissions.Admin) || + registry.currentUserPermissions.includes(UserPermissions.Write) + ); }); - get isAdmin(): boolean { - return this.userPermissions().includes(UserPermissions.Admin); - } - get isInitialState(): boolean { return this.registry()?.reviewsState === RegistrationReviewStates.Initial; } constructor() { - this.route.parent?.params.subscribe((params) => { - const id = params['id']; - if (id) { - this.actions - .getRegistryById(id) - .pipe( - filter(() => { - return !this.registry()?.withdrawn; - }), - tap(() => { - this.actions.getSubjects(id); - this.actions.getInstitutions(id); - }) - ) - .subscribe(); + effect(() => { + const registry = this.registry(); + + if (registry && !registry?.withdrawn) { + this.actions.getSubjects(registry?.id, ResourceType.Registration); + this.actions.getInstitutions(registry?.id); + } + }); + + effect(() => { + if (this.registryId()) { + this.actions.getRegistryById(this.registryId()); } }); @@ -201,7 +234,6 @@ export class RegistryOverviewComponent { } navigateToFile(fileId: string): void { - // [NM] TODO: add logic to handle fileId this.router.navigate(['/files', fileId]); } @@ -255,19 +287,14 @@ export class RegistryOverviewComponent { } handleOpenMakeDecisionDialog() { - const dialogWidth = '600px'; this.actions .getRegistryReviewActions(this.registry()?.id || '') .pipe( switchMap(() => - this.dialogService + this.customDialogService .open(RegistryMakeDecisionComponent, { - width: dialogWidth, - focusOnShow: false, - header: this.translateService.instant('moderation.makeDecision.header'), - closeOnEscape: true, - modal: true, - closable: true, + header: 'moderation.makeDecision.header', + width: '600px', data: { registry: this.registry(), revisionId: this.revisionId, @@ -286,6 +313,7 @@ export class RegistryOverviewComponent { this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => { this.router.navigateByUrl(currentUrl); }); + this.actions.getRegistryById(this.registry()?.id || ''); } }); diff --git a/src/app/features/registry/pages/registry-resources/registry-resources.component.html b/src/app/features/registry/pages/registry-resources/registry-resources.component.html index 1ca013a57..d03ab0cab 100644 --- a/src/app/features/registry/pages/registry-resources/registry-resources.component.html +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.html @@ -1,7 +1,7 @@ @@ -12,7 +12,7 @@

{{ 'resources.description' | translate }} - + {{ 'common.labels.learnMore' | translate }}

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

{{ resourceName }}

- https://doi.org/{{ resource.pid }} + + {{ doiDomain + resource.pid }} +

{{ resource.description }}

-
- + @if (canEdit()) { +
+ - -
+ +
+ }
}
diff --git a/src/app/features/registry/pages/registry-resources/registry-resources.component.scss b/src/app/features/registry/pages/registry-resources/registry-resources.component.scss index 34a3c6ca3..b48a0b49f 100644 --- a/src/app/features/registry/pages/registry-resources/registry-resources.component.scss +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.scss @@ -1,10 +1,8 @@ -@use "styles/mixins" as mix; - .resource-block { display: flex; align-items: center; width: 100%; height: max-content; border: 1px solid var(--grey-2); - border-radius: mix.rem(12px); + border-radius: 0.75rem; } 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 7ccd81da4..beaff82f8 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 @@ -1,17 +1,19 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; -import { DialogService } from 'primeng/dynamicdialog'; -import { finalize, take } from 'rxjs'; +import { filter, finalize, switchMap, take } from 'rxjs'; -import { ChangeDetectionStrategy, Component, HostBinding, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, HostBinding, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; +import { GetResourceMetadata, MetadataSelectors } from '@osf/features/metadata/store'; import { IconComponent, LoadingSpinnerComponent, SubHeaderComponent } from '@osf/shared/components'; -import { CustomConfirmationService, ToastService } from '@osf/shared/services'; +import { CustomConfirmationService, CustomDialogService, ToastService } from '@osf/shared/services'; +import { ResourceType, UserPermissions } from '@shared/enums'; import { AddResourceDialogComponent, EditResourceDialogComponent } from '../../components'; import { RegistryResource } from '../../models'; @@ -20,7 +22,6 @@ import { DeleteResource, GetRegistryResources, RegistryResourcesSelectors, - SilentDelete, } from '../../store/registry-resources'; @Component({ @@ -29,102 +30,90 @@ import { templateUrl: './registry-resources.component.html', styleUrl: './registry-resources.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [DialogService], }) export class RegistryResourcesComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full h-full'; private readonly route = inject(ActivatedRoute); - private dialogService = inject(DialogService); - private translateService = inject(TranslateService); - private toastService = inject(ToastService); - private customConfirmationService = inject(CustomConfirmationService); + private readonly customDialogService = inject(CustomDialogService); + private readonly toastService = inject(ToastService); + private readonly customConfirmationService = inject(CustomConfirmationService); + private readonly destroyRef = inject(DestroyRef); - protected readonly resources = select(RegistryResourcesSelectors.getResources); - protected readonly isResourcesLoading = select(RegistryResourcesSelectors.isResourcesLoading); - private registryId = ''; - protected addingResource = signal(false); - private readonly currentResource = select(RegistryResourcesSelectors.getCurrentResource); + readonly resources = select(RegistryResourcesSelectors.getResources); + readonly isResourcesLoading = select(RegistryResourcesSelectors.isResourcesLoading); + readonly currentResource = select(RegistryResourcesSelectors.getCurrentResource); + readonly registry = select(MetadataSelectors.getResourceMetadata); + + registryId = this.route.snapshot.parent?.params['id']; + isAddingResource = signal(false); + doiDomain = 'https://doi.org/'; private readonly actions = createDispatchMap({ + fetchRegistryData: GetResourceMetadata, getResources: GetRegistryResources, addResource: AddRegistryResource, deleteResource: DeleteResource, - silentDelete: SilentDelete, }); + canEdit = computed(() => { + const registry = this.registry(); + if (!registry) return false; + + return registry.currentUserPermissions.includes(UserPermissions.Write); + }); + + addButtonVisible = computed(() => !!this.registry()?.identifiers?.length && this.canEdit()); + constructor() { - this.route.parent?.params.subscribe((params) => { - this.registryId = params['id']; - if (this.registryId) { - this.actions.getResources(this.registryId); - } - }); + this.actions.fetchRegistryData(this.registryId, ResourceType.Registration); + this.actions.getResources(this.registryId); } addResource() { - if (!this.registryId) { - throw new Error(this.translateService.instant('resources.errors.noRegistryId')); - } + if (!this.registryId) return; - this.addingResource.set(true); + this.isAddingResource.set(true); this.actions .addResource(this.registryId) .pipe( take(1), - finalize(() => this.addingResource.set(false)) + switchMap(() => this.openAddResourceDialog()), + filter((res) => !!res), + finalize(() => this.isAddingResource.set(false)), + takeUntilDestroyed(this.destroyRef) ) - .subscribe(() => { - const dialogRef = this.dialogService.open(AddResourceDialogComponent, { - header: this.translateService.instant('resources.add'), - width: '500px', - focusOnShow: false, - closeOnEscape: true, - modal: true, - closable: true, - data: { id: this.registryId }, - }); - - dialogRef.onClose.subscribe({ - next: (res) => { - if (res) { - this.toastService.showSuccess('resources.toastMessages.addResourceSuccess'); - } else { - const currentResource = this.currentResource(); - if (!currentResource) { - throw new Error(this.translateService.instant('resources.errors.noCurrentResource')); - } - this.actions.silentDelete(currentResource.id); - } - }, - error: () => this.toastService.showError('resources.toastMessages.addResourceError'), - }); + .subscribe({ + next: () => this.toastService.showSuccess('resources.toastMessages.addResourceSuccess'), + error: () => this.toastService.showError('resources.toastMessages.addResourceError'), }); } - updateResource(resource: RegistryResource) { - if (!this.registryId) { - throw new Error(this.translateService.instant('resources.errors.noRegistryId')); - } - - const dialogRef = this.dialogService.open(EditResourceDialogComponent, { - header: this.translateService.instant('resources.edit'), + openAddResourceDialog() { + return this.customDialogService.open(AddResourceDialogComponent, { + header: 'resources.add', width: '500px', - focusOnShow: false, - closeOnEscape: true, - modal: true, - closable: true, - data: { id: this.registryId, resource: resource }, - }); + data: { id: this.registryId }, + }).onClose; + } - dialogRef.onClose.subscribe({ - next: (res) => { - if (res) { - this.toastService.showSuccess('resources.toastMessages.updatedResourceSuccess'); - } - }, - error: () => this.toastService.showError('resources.toastMessages.updateResourceError'), - }); + updateResource(resource: RegistryResource) { + if (!this.registryId) return; + + this.customDialogService + .open(EditResourceDialogComponent, { + header: 'resources.edit', + width: '500px', + data: { id: this.registryId, resource: resource }, + }) + .onClose.pipe( + takeUntilDestroyed(this.destroyRef), + filter((res) => !!res) + ) + .subscribe({ + next: () => this.toastService.showSuccess('resources.toastMessages.updatedResourceSuccess'), + error: () => this.toastService.showError('resources.toastMessages.updateResourceError'), + }); } deleteResource(id: string) { @@ -138,9 +127,7 @@ export class RegistryResourcesComponent { this.actions .deleteResource(id, this.registryId) .pipe(take(1)) - .subscribe(() => { - this.toastService.showSuccess('resources.toastMessages.deletedResourceSuccess'); - }); + .subscribe(() => this.toastService.showSuccess('resources.toastMessages.deletedResourceSuccess')); }, }); } diff --git a/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html b/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html index 01bedd68c..799fd09be 100644 --- a/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html +++ b/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html @@ -1,4 +1,4 @@ - + } -
+
+ @if (wikiModes().view) { } + @if (wikiModes().compare) { { - return hasViewOnlyParam(this.router); - }); + hasViewOnly = computed(() => hasViewOnlyParam(this.router)); readonly resourceId = this.route.parent?.snapshot.params['id']; @@ -73,6 +74,8 @@ export class RegistryWikiComponent { getWikiVersions: GetWikiVersions, getWikiVersionContent: GetWikiVersionContent, getCompareVersionContent: GetCompareVersionContent, + getComponentsWikiList: GetComponentsWikiList, + clearWiki: ClearWiki, }); wikiIdFromQueryParams = this.route.snapshot.queryParams['wiki']; @@ -90,6 +93,8 @@ export class RegistryWikiComponent { ) .subscribe(); + this.actions.getComponentsWikiList(ResourceType.Registration, this.resourceId); + this.route.queryParams .pipe( takeUntilDestroyed(), @@ -101,6 +106,10 @@ export class RegistryWikiComponent { mergeMap((wikiId) => this.actions.getWikiVersions(wikiId)) ) .subscribe(); + + this.destroyRef.onDestroy(() => { + this.actions.clearWiki(); + }); } toggleMode(mode: WikiModes) { @@ -108,7 +117,9 @@ export class RegistryWikiComponent { } onSelectVersion(versionId: string) { - this.actions.getWikiVersionContent(this.currentWikiId(), versionId); + if (versionId) { + this.actions.getWikiVersionContent(this.currentWikiId(), versionId); + } } onSelectCompareVersion(versionId: string) { diff --git a/src/app/features/registry/registry.component.spec.ts b/src/app/features/registry/registry.component.spec.ts index f3bf7dc7d..a3e06cde7 100644 --- a/src/app/features/registry/registry.component.spec.ts +++ b/src/app/features/registry/registry.component.spec.ts @@ -1,22 +1,56 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Store } from '@ngxs/store'; + +import { DatePipe } from '@angular/common'; +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { RegistryOverviewSelectors } from '@osf/features/registry/store/registry-overview'; +import { MetaTagsService } from '@osf/shared/services'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; import { RegistryComponent } from './registry.component'; +import { DataciteMockFactory } from '@testing/mocks/datacite.service.mock'; + describe('RegistryComponent', () => { + let fixture: any; let component: RegistryComponent; - let fixture: ComponentFixture; + let dataciteService: jest.Mocked; + + const registrySignal = signal(null); beforeEach(async () => { + dataciteService = DataciteMockFactory(); + + const mockStore = { + selectSignal: jest.fn((selector: any) => { + if (selector === RegistryOverviewSelectors.getRegistry) { + return registrySignal; // return a signal, not an observable + } + return signal(null); + }), + }; + await TestBed.configureTestingModule({ - imports: [RegistryComponent], + imports: [RegistryComponent], // standalone component + providers: [ + { provide: Store, useValue: mockStore }, + DatePipe, + { provide: DataciteService, useValue: dataciteService }, + { + provide: MetaTagsService, + useValue: { updateMetaTags: jest.fn() }, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(RegistryComponent); component = fixture.componentInstance; - fixture.detectChanges(); + TestBed.inject(MetaTagsService); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('reacts to sequence of state changes', () => { + fixture.detectChanges(); + expect(dataciteService.logIdentifiableView).toHaveBeenCalledWith(component.registry$); }); }); diff --git a/src/app/features/registry/registry.component.ts b/src/app/features/registry/registry.component.ts index d8005d983..03ff2b96f 100644 --- a/src/app/features/registry/registry.component.ts +++ b/src/app/features/registry/registry.component.ts @@ -1,15 +1,19 @@ -import { select } from '@ngxs/store'; +import { createDispatchMap, select } from '@ngxs/store'; + +import { map, of } from 'rxjs'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, effect, HostBinding, inject } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { ChangeDetectionStrategy, Component, DestroyRef, effect, HostBinding, inject, OnDestroy } from '@angular/core'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, RouterOutlet } from '@angular/router'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { ClearCurrentProvider } from '@core/store/provider'; import { pathJoin } from '@osf/shared/helpers'; -import { MetaTagsService } from '@osf/shared/services'; - -import { RegistryOverviewSelectors } from './store/registry-overview'; +import { AnalyticsService, MetaTagsService } from '@osf/shared/services'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; -import { environment } from 'src/environments/environment'; +import { GetRegistryById, RegistryOverviewSelectors } from './store/registry-overview'; @Component({ selector: 'osf-registry', @@ -19,33 +23,64 @@ import { environment } from 'src/environments/environment'; changeDetection: ChangeDetectionStrategy.OnPush, providers: [DatePipe], }) -export class RegistryComponent { +export class RegistryComponent implements OnDestroy { @HostBinding('class') classes = 'flex-1 flex flex-column'; private readonly metaTags = inject(MetaTagsService); private readonly datePipe = inject(DatePipe); + private readonly dataciteService = inject(DataciteService); + private readonly destroyRef = inject(DestroyRef); + private readonly route = inject(ActivatedRoute); + private readonly environment = inject(ENVIRONMENT); + + private readonly actions = createDispatchMap({ + getRegistryById: GetRegistryById, + clearCurrentProvider: ClearCurrentProvider, + }); + + private registryId = toSignal(this.route.params.pipe(map((params) => params['id'])) ?? of(undefined)); readonly registry = select(RegistryOverviewSelectors.getRegistry); + readonly isRegistryLoading = select(RegistryOverviewSelectors.isRegistryLoading); + readonly registry$ = toObservable(select(RegistryOverviewSelectors.getRegistry)); + readonly analyticsService = inject(AnalyticsService); constructor() { effect(() => { - if (this.registry()) { + if (this.registryId()) { + this.actions.getRegistryById(this.registryId()); + } + }); + + effect(() => { + if (!this.isRegistryLoading() && this.registry()) { this.setMetaTags(); } }); + + effect(() => { + const currentRegistry = this.registry(); + if (currentRegistry && currentRegistry.isPublic) { + this.analyticsService.sendCountedUsage(currentRegistry.id, 'registry.detail').subscribe(); + } + }); + + this.dataciteService.logIdentifiableView(this.registry$).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); } - private setMetaTags(): void { - const image = 'engines-dist/registries/assets/img/osf-sharing.png'; + ngOnDestroy(): void { + this.actions.clearCurrentProvider(); + } - this.metaTags.updateMetaTagsForRoute( + private setMetaTags(): void { + this.metaTags.updateMetaTags( { + osfGuid: this.registry()?.id, title: this.registry()?.title, description: this.registry()?.description, publishedDate: this.datePipe.transform(this.registry()?.dateRegistered, 'yyyy-MM-dd'), modifiedDate: this.datePipe.transform(this.registry()?.dateModified, 'yyyy-MM-dd'), - url: pathJoin(environment.webUrl, this.registry()?.id ?? ''), - image, + url: pathJoin(this.environment.webUrl, this.registry()?.id ?? ''), identifier: this.registry()?.id, doi: this.registry()?.doi, keywords: this.registry()?.tags, @@ -53,11 +88,12 @@ export class RegistryComponent { license: this.registry()?.license?.name, contributors: this.registry()?.contributors?.map((contributor) => ({ + fullName: contributor.fullName, givenName: contributor.givenName, familyName: contributor.familyName, })) ?? [], }, - 'registries' + this.destroyRef ); } } diff --git a/src/app/features/registry/registry.routes.ts b/src/app/features/registry/registry.routes.ts index 22da6cde0..f26006bbe 100644 --- a/src/app/features/registry/registry.routes.ts +++ b/src/app/features/registry/registry.routes.ts @@ -12,6 +12,7 @@ import { SubjectsState, ViewOnlyLinkState, } from '@osf/shared/stores'; +import { ActivityLogsState } from '@shared/stores/activity-logs'; import { AnalyticsState } from '../analytics/store'; import { RegistriesState } from '../registries/store'; @@ -28,7 +29,7 @@ export const registryRoutes: Routes = [ { path: '', component: RegistryComponent, - providers: [provideStates([RegistryOverviewState])], + providers: [provideStates([RegistryOverviewState, ActivityLogsState])], children: [ { path: '', @@ -40,7 +41,7 @@ export const registryRoutes: Routes = [ loadComponent: () => import('./pages/registry-overview/registry-overview.component').then((c) => c.RegistryOverviewComponent), providers: [ - provideStates([RegistriesState, CitationsState]), + provideStates([RegistriesState, SubjectsState, CitationsState]), ProvidersHandlers, ProjectsHandlers, LicensesHandlers, @@ -65,8 +66,7 @@ export const registryRoutes: Routes = [ { path: 'contributors', canActivate: [viewOnlyGuard], - loadComponent: () => - import('../project/contributors/contributors.component').then((mod) => mod.ContributorsComponent), + loadComponent: () => import('../contributors/contributors.component').then((mod) => mod.ContributorsComponent), data: { resourceType: ResourceType.Registration }, providers: [provideStates([ContributorsState, ViewOnlyLinkState])], }, @@ -113,6 +113,13 @@ export const registryRoutes: Routes = [ loadComponent: () => import('./pages/registry-wiki/registry-wiki.component').then((c) => c.RegistryWikiComponent), }, + { + path: 'recent-activity', + loadComponent: () => + import('./pages/registration-recent-activity/registration-recent-activity.component').then( + (c) => c.RegistrationRecentActivityComponent + ), + }, ], }, ]; diff --git a/src/app/features/registry/services/registry-components.service.ts b/src/app/features/registry/services/registry-components.service.ts index e81cc0d2b..5832f7ce5 100644 --- a/src/app/features/registry/services/registry-components.service.ts +++ b/src/app/features/registry/services/registry-components.service.ts @@ -3,22 +3,26 @@ import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; import { JsonApiService } from '@osf/shared/services'; import { RegistryComponentsMapper } from '../mappers'; import { RegistryComponentsJsonApiResponse, RegistryComponentsResponseJsonApi } from '../models'; -import { environment } from 'src/environments/environment'; - @Injectable({ providedIn: 'root', }) export class RegistryComponentsService { private readonly jsonApiService = inject(JsonApiService); - private readonly apiUrl = environment.apiUrl; + private readonly environment = inject(ENVIRONMENT); + + get apiUrl() { + return `${this.environment.apiDomainUrl}/v2`; + } getRegistryComponents(registryId: string, page = 1, pageSize = 10): Observable { const params: Record = { + 'embed[]': 'bibliographic_contributors', page: page, 'page[size]': pageSize, }; diff --git a/src/app/features/registry/services/registry-links.service.ts b/src/app/features/registry/services/registry-links.service.ts index 5659d5855..fd5fd0194 100644 --- a/src/app/features/registry/services/registry-links.service.ts +++ b/src/app/features/registry/services/registry-links.service.ts @@ -3,29 +3,31 @@ import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; import { JsonApiService } from '@osf/shared/services'; -import { BibliographicContributorsMapper, LinkedNodesMapper, LinkedRegistrationsMapper } from '../mappers'; +import { LinkedNodesMapper, LinkedRegistrationsMapper } from '../mappers'; import { - BibliographicContributorsResponse, LinkedNodesJsonApiResponse, LinkedNodesResponseJsonApi, LinkedRegistrationsJsonApiResponse, LinkedRegistrationsResponseJsonApi, - NodeBibliographicContributor, } from '../models'; -import { environment } from 'src/environments/environment'; - @Injectable({ providedIn: 'root', }) export class RegistryLinksService { private readonly jsonApiService = inject(JsonApiService); - private readonly apiUrl = environment.apiUrl; + private readonly environment = inject(ENVIRONMENT); + + get apiUrl() { + return `${this.environment.apiDomainUrl}/v2`; + } getLinkedNodes(registryId: string, page = 1, pageSize = 10): Observable { const params: Record = { + 'embed[]': 'bibliographic_contributors', page: page, 'page[size]': pageSize, }; @@ -43,6 +45,7 @@ export class RegistryLinksService { getLinkedRegistrations(registryId: string, page = 1, pageSize = 10): Observable { const params: Record = { + 'embed[]': 'bibliographic_contributors', page: page, 'page[size]': pageSize, }; @@ -60,27 +63,4 @@ export class RegistryLinksService { })) ); } - - getBibliographicContributors(nodeId: string): Observable { - const params: Record = { - embed: 'users', - }; - - return this.jsonApiService - .get(`${this.apiUrl}/nodes/${nodeId}/bibliographic_contributors/`, params) - .pipe(map((response) => BibliographicContributorsMapper.fromApiResponseArray(response.data))); - } - - getBibliographicContributorsForRegistration(registrationId: string): Observable { - const params: Record = { - embed: 'users', - }; - - return this.jsonApiService - .get( - `${this.apiUrl}/registrations/${registrationId}/bibliographic_contributors/`, - params - ) - .pipe(map((response) => BibliographicContributorsMapper.fromApiResponseArray(response.data))); - } } diff --git a/src/app/features/registry/services/registry-metadata.service.ts b/src/app/features/registry/services/registry-metadata.service.ts deleted file mode 100644 index c472629b7..000000000 --- a/src/app/features/registry/services/registry-metadata.service.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; - -import { inject, Injectable } from '@angular/core'; - -import { - CedarMetadataRecord, - CedarMetadataRecordJsonApi, - CedarMetadataTemplateJsonApi, -} from '@osf/features/project/metadata/models'; -import { JsonApiService } from '@osf/shared/services'; -import { InstitutionsMapper } from '@shared/mappers'; -import { Institution, InstitutionsJsonApiResponse, License } from '@shared/models'; - -import { RegistryMetadataMapper } from '../mappers'; -import { - BibliographicContributor, - BibliographicContributorsJsonApi, - CustomItemMetadataRecord, - CustomItemMetadataResponse, - RegistryContributorAddRequest, - RegistryContributorJsonApiResponse, - RegistryContributorUpdateRequest, - RegistryOverview, - RegistrySubjectsJsonApi, - UserInstitutionsResponse, -} from '../models'; - -import { environment } from 'src/environments/environment'; - -@Injectable({ - providedIn: 'root', -}) -export class RegistryMetadataService { - private readonly jsonApiService = inject(JsonApiService); - private readonly apiUrl = environment.apiUrl; - - getBibliographicContributors(registryId: string, page = 1, pageSize = 100): Observable { - const params: Record = { - 'fields[contributors]': 'index,users', - 'fields[users]': 'full_name', - page: page, - 'page[size]': pageSize, - }; - - return this.jsonApiService - .get( - `${this.apiUrl}/registrations/${registryId}/bibliographic_contributors/`, - params - ) - .pipe(map((response) => RegistryMetadataMapper.mapBibliographicContributors(response))); - } - - getCustomItemMetadata(guid: string): Observable { - return this.jsonApiService.get(`${this.apiUrl}/custom_item_metadata_records/${guid}/`); - } - - updateCustomItemMetadata(guid: string, metadata: CustomItemMetadataRecord): Observable { - return this.jsonApiService.patch( - `${this.apiUrl}/custom_item_metadata_records/${guid}/`, - { - data: { - id: guid, - type: 'custom-item-metadata-records', - attributes: metadata, - relationships: {}, - }, - } - ); - } - - getRegistryForMetadata(registryId: string): Observable { - const params: Record = { - 'embed[]': ['contributors', 'affiliated_institutions', 'identifiers', 'license', 'subjects_acceptable'], - 'fields[institutions]': 'assets,description,name', - 'fields[users]': 'family_name,full_name,given_name,middle_name', - 'fields[subjects]': 'text,taxonomy', - }; - - return this.jsonApiService - .get<{ data: Record }>(`${environment.apiUrl}/registrations/${registryId}/`, params) - .pipe(map((response) => RegistryMetadataMapper.fromMetadataApiResponse(response.data))); - } - - updateRegistryDetails(registryId: string, updates: Partial>): Observable { - const payload = { - data: { - id: registryId, - type: 'registrations', - attributes: updates, - }, - }; - - return this.jsonApiService - .patch>(`${this.apiUrl}/registrations/${registryId}`, payload) - .pipe(map((response) => RegistryMetadataMapper.fromMetadataApiResponse(response))); - } - - getUserInstitutions(userId: string, page = 1, pageSize = 10): Observable { - const params = { - page: page.toString(), - 'page[size]': pageSize.toString(), - }; - - return this.jsonApiService.get(`${this.apiUrl}/users/${userId}/institutions/`, { - params, - }); - } - - getRegistrySubjects(registryId: string, page = 1, pageSize = 100): Observable { - const params: Record = { - 'page[size]': pageSize, - page: page, - }; - - return this.jsonApiService.get( - `${this.apiUrl}/registrations/${registryId}/subjects/`, - params - ); - } - - getRegistryCedarMetadataRecords(registryId: string): Observable { - const params: Record = { - embed: 'template', - 'page[size]': 20, - }; - - return this.jsonApiService.get( - `${this.apiUrl}/registrations/${registryId}/cedar_metadata_records/`, - params - ); - } - - getCedarMetadataTemplates(url?: string): Observable { - return this.jsonApiService.get( - url || `${environment.apiDomainUrl}/_/cedar_metadata_templates/?adapterOptions[sort]=schema_name` - ); - } - - createCedarMetadataRecord(data: CedarMetadataRecord): Observable { - return this.jsonApiService.post(`${environment.apiDomainUrl}/_/cedar_metadata_records/`, data); - } - - updateCedarMetadataRecord(data: CedarMetadataRecord, recordId: string): Observable { - return this.jsonApiService.patch( - `${environment.apiDomainUrl}/_/cedar_metadata_records/${recordId}/`, - data - ); - } - - updateRegistrySubjects( - registryId: string, - subjects: { type: string; id: string }[] - ): Observable<{ data: { type: string; id: string }[] }> { - return this.jsonApiService.patch<{ data: { type: string; id: string }[] }>( - `${this.apiUrl}/registrations/${registryId}/relationships/subjects/`, - { - data: subjects, - } - ); - } - - updateRegistryInstitutions( - registryId: string, - institutions: { type: string; id: string }[] - ): Observable<{ data: { type: string; id: string }[] }> { - return this.jsonApiService.patch<{ data: { type: string; id: string }[] }>( - `${this.apiUrl}/registrations/${registryId}/relationships/institutions/`, - { - data: institutions, - } - ); - } - - getLicenseFromUrl(licenseUrl: string): Observable { - return this.jsonApiService.get<{ data: Record }>(licenseUrl).pipe( - map((response) => { - const licenseData = response.data; - const attributes = licenseData['attributes'] as Record; - - return { - id: licenseData['id'] as string, - name: attributes['name'] as string, - text: attributes['text'] as string, - url: attributes['url'] as string, - requiredFields: (attributes['required_fields'] as string[]) || [], - } as License; - }) - ); - } - - getRegistryInstitutions(registryId: string, page = 1, pageSize = 100): Observable { - const params: Record = { - page: page, - 'page[size]': pageSize, - }; - - return this.jsonApiService - .get(`${this.apiUrl}/registrations/${registryId}/institutions/`, params) - .pipe(map((response) => InstitutionsMapper.fromInstitutionsResponse(response))); - } - - updateRegistryContributor( - registryId: string, - contributorId: string, - updateData: RegistryContributorUpdateRequest - ): Observable { - return this.jsonApiService.patch( - `${this.apiUrl}/registrations/${registryId}/contributors/${contributorId}/`, - updateData - ); - } - - addRegistryContributor( - registryId: string, - contributorData: RegistryContributorAddRequest - ): Observable { - return this.jsonApiService.post( - `${this.apiUrl}/registrations/${registryId}/contributors/`, - contributorData - ); - } -} diff --git a/src/app/features/registry/services/registry-overview.service.ts b/src/app/features/registry/services/registry-overview.service.ts index 8465e7980..933352ed1 100644 --- a/src/app/features/registry/services/registry-overview.service.ts +++ b/src/app/features/registry/services/registry-overview.service.ts @@ -2,16 +2,15 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; import { RegistryModerationMapper } from '@osf/features/moderation/mappers'; import { ReviewAction, ReviewActionsResponseJsonApi } from '@osf/features/moderation/models'; import { MapRegistryOverview } from '@osf/features/registry/mappers'; import { GetRegistryOverviewJsonApi, - GetResourceSubjectsJsonApi, RegistryOverview, RegistryOverviewJsonApiData, RegistryOverviewWithMeta, - RegistrySubject, } from '@osf/features/registry/models'; import { InstitutionsMapper, ReviewActionsMapper } from '@osf/shared/mappers'; import { PageSchemaMapper } from '@osf/shared/mappers/registration'; @@ -19,17 +18,20 @@ import { Institution, InstitutionsJsonApiResponse, PageSchema, SchemaBlocksRespo import { ReviewActionPayload } from '@osf/shared/models/review-action'; import { JsonApiService } from '@shared/services'; -import { environment } from 'src/environments/environment'; - @Injectable({ providedIn: 'root', }) export class RegistryOverviewService { - private jsonApiService = inject(JsonApiService); + private readonly jsonApiService = inject(JsonApiService); + private readonly environment = inject(ENVIRONMENT); + + get apiUrl() { + return `${this.environment.apiDomainUrl}/v2`; + } getRegistrationById(id: string): Observable { const params = { - related_counts: 'forks,comments,linked_nodes,linked_registrations,children,wikis', + related_counts: 'forks,linked_nodes,linked_registrations,children,wikis', 'embed[]': [ 'bibliographic_contributors', 'provider', @@ -43,28 +45,17 @@ export class RegistryOverviewService { }; return this.jsonApiService - .get(`${environment.apiUrl}/registrations/${id}/`, params) + .get(`${this.apiUrl}/registrations/${id}/`, params) .pipe(map((response) => ({ registry: MapRegistryOverview(response.data), meta: response.meta }))); } - getSubjects(registryId: string): Observable { - const params = { - 'page[size]': 100, - page: 1, - }; - - return this.jsonApiService - .get(`${environment.apiUrl}/registrations/${registryId}/subjects/`, params) - .pipe(map((response) => response.data.map((subject) => ({ id: subject.id, text: subject.attributes.text })))); - } - getInstitutions(registryId: string): Observable { const params = { 'page[size]': 100, }; return this.jsonApiService - .get(`${environment.apiUrl}/registrations/${registryId}/institutions/`, params) + .get(`${this.apiUrl}/registrations/${registryId}/institutions/`, params) .pipe(map((response) => InstitutionsMapper.fromInstitutionsResponse(response))); } @@ -101,7 +92,7 @@ export class RegistryOverviewService { }; return this.jsonApiService - .patch(`${environment.apiUrl}/registrations/${registryId}`, payload) + .patch(`${this.apiUrl}/registrations/${registryId}/`, payload) .pipe(map((response) => MapRegistryOverview(response))); } @@ -118,12 +109,12 @@ export class RegistryOverviewService { }; return this.jsonApiService - .patch(`${environment.apiUrl}/registrations/${registryId}`, payload) + .patch(`${this.apiUrl}/registrations/${registryId}/`, payload) .pipe(map((response) => MapRegistryOverview(response))); } getRegistryReviewActions(id: string): Observable { - const baseUrl = `${environment.apiUrl}/registrations/${id}/actions/`; + const baseUrl = `${this.apiUrl}/registrations/${id}/actions/`; return this.jsonApiService .get(baseUrl) @@ -132,7 +123,7 @@ export class RegistryOverviewService { submitDecision(payload: ReviewActionPayload, isRevision: boolean): Observable { const path = isRevision ? 'schema_responses' : 'registrations'; - const baseUrl = `${environment.apiUrl}/${path}/${payload.targetId}/actions/`; + const baseUrl = `${this.apiUrl}/${path}/${payload.targetId}/actions/`; const actionType = isRevision ? 'schema_response_actions' : 'review_actions'; const targetType = isRevision ? 'schema-responses' : 'registrations'; diff --git a/src/app/features/registry/services/registry-resources.service.ts b/src/app/features/registry/services/registry-resources.service.ts index 112a73425..9d6c6012c 100644 --- a/src/app/features/registry/services/registry-resources.service.ts +++ b/src/app/features/registry/services/registry-resources.service.ts @@ -2,6 +2,7 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; import { MapAddResourceRequest, MapRegistryResource, toAddResourceRequestBody } from '@osf/features/registry/mappers'; import { GetRegistryResourcesJsonApi, RegistryResource } from '@osf/features/registry/models'; import { AddResource } from '@osf/features/registry/models/resources/add-resource.model'; @@ -12,13 +13,16 @@ import { import { ConfirmAddResource } from '@osf/features/registry/models/resources/confirm-add-resource.model'; import { JsonApiService } from '@shared/services'; -import { environment } from 'src/environments/environment'; - @Injectable({ providedIn: 'root', }) export class RegistryResourcesService { - private jsonApiService = inject(JsonApiService); + private readonly jsonApiService = inject(JsonApiService); + private readonly environment = inject(ENVIRONMENT); + + get apiUrl() { + return `${this.environment.apiDomainUrl}/v2`; + } getResources(registryId: string): Observable { const params = { @@ -26,14 +30,14 @@ export class RegistryResourcesService { }; return this.jsonApiService - .get(`${environment.apiUrl}/registrations/${registryId}/resources/?page=1`, params) + .get(`${this.apiUrl}/registrations/${registryId}/resources/?page=1`, params) .pipe(map((response) => response.data.map((resource) => MapRegistryResource(resource)))); } addRegistryResource(registryId: string): Observable { const body = toAddResourceRequestBody(registryId); - return this.jsonApiService.post(`${environment.apiUrl}/resources/`, body).pipe( + return this.jsonApiService.post(`${this.apiUrl}/resources/`, body).pipe( map((response) => { return MapRegistryResource(response.data); }) @@ -44,7 +48,7 @@ export class RegistryResourcesService { const payload = MapAddResourceRequest(resourceId, resource); return this.jsonApiService - .patch(`${environment.apiUrl}/resources/${resourceId}/`, payload) + .patch(`${this.apiUrl}/resources/${resourceId}/`, payload) .pipe( map((response) => { return MapRegistryResource(response); @@ -56,7 +60,7 @@ export class RegistryResourcesService { const payload = MapAddResourceRequest(resourceId, resource); return this.jsonApiService - .patch(`${environment.apiUrl}/resources/${resourceId}/`, payload) + .patch(`${this.apiUrl}/resources/${resourceId}/`, payload) .pipe( map((response) => { return MapRegistryResource(response); @@ -65,12 +69,12 @@ export class RegistryResourcesService { } deleteResource(resourceId: string): Observable { - return this.jsonApiService.delete(`${environment.apiUrl}/resources/${resourceId}/`); + return this.jsonApiService.delete(`${this.apiUrl}/resources/${resourceId}/`); } updateResource(resourceId: string, resource: AddResource) { const payload = MapAddResourceRequest(resourceId, resource); - return this.jsonApiService.patch(`${environment.apiUrl}/resources/${resourceId}/`, payload); + return this.jsonApiService.patch(`${this.apiUrl}/resources/${resourceId}/`, payload); } } diff --git a/src/app/features/registry/store/registry-components/registry-components.model.ts b/src/app/features/registry/store/registry-components/registry-components.model.ts index a285b7853..d07b37b34 100644 --- a/src/app/features/registry/store/registry-components/registry-components.model.ts +++ b/src/app/features/registry/store/registry-components/registry-components.model.ts @@ -5,3 +5,7 @@ import { RegistryComponentModel } from '../../models'; export interface RegistryComponentsStateModel { registryComponents: AsyncStateWithTotalCount; } + +export const REGISTRY_COMPONENTS_STATE_DEFAULTS: RegistryComponentsStateModel = { + registryComponents: { data: [], isLoading: false, error: null, totalCount: 0 }, +}; diff --git a/src/app/features/registry/store/registry-components/registry-components.state.ts b/src/app/features/registry/store/registry-components/registry-components.state.ts index fa46b1b32..d3914fcbd 100644 --- a/src/app/features/registry/store/registry-components/registry-components.state.ts +++ b/src/app/features/registry/store/registry-components/registry-components.state.ts @@ -9,15 +9,11 @@ import { handleSectionError } from '@shared/helpers'; import { RegistryComponentsService } from '../../services/registry-components.service'; import { GetRegistryComponents } from './registry-components.actions'; -import { RegistryComponentsStateModel } from './registry-components.model'; - -const initialState: RegistryComponentsStateModel = { - registryComponents: { data: [], isLoading: false, error: null, totalCount: 0 }, -}; +import { REGISTRY_COMPONENTS_STATE_DEFAULTS, RegistryComponentsStateModel } from './registry-components.model'; @State({ name: 'registryComponents', - defaults: initialState, + defaults: REGISTRY_COMPONENTS_STATE_DEFAULTS, }) @Injectable() export class RegistryComponentsState { diff --git a/src/app/features/registry/store/registry-files/index.ts b/src/app/features/registry/store/registry-files/index.ts deleted file mode 100644 index 6c68e2520..000000000 --- a/src/app/features/registry/store/registry-files/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './registry-files.actions'; -export * from './registry-files.model'; -export * from './registry-files.selectors'; -export * from './registry-files.state'; diff --git a/src/app/features/registry/store/registry-files/registry-files.actions.ts b/src/app/features/registry/store/registry-files/registry-files.actions.ts deleted file mode 100644 index 60015342f..000000000 --- a/src/app/features/registry/store/registry-files/registry-files.actions.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { OsfFile } from '@shared/models'; - -export class GetRegistryFiles { - static readonly type = '[Registry Files] Get Registry Files'; - - constructor(public filesLink: string) {} -} - -export class SetCurrentFolder { - static readonly type = '[Registry Files] Set Current Folder'; - - constructor(public folder: OsfFile | null) {} -} - -export class SetSearch { - static readonly type = '[Registry Files] Set Search'; - - constructor(public search: string) {} -} - -export class SetSort { - static readonly type = '[Registry Files] Set Sort'; - - constructor(public sort: string) {} -} diff --git a/src/app/features/registry/store/registry-files/registry-files.model.ts b/src/app/features/registry/store/registry-files/registry-files.model.ts deleted file mode 100644 index 8ee42bb5c..000000000 --- a/src/app/features/registry/store/registry-files/registry-files.model.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { AsyncStateModel, OsfFile } from '@shared/models'; - -export interface RegistryFilesStateModel { - files: AsyncStateModel; - search: string; - sort: string; - currentFolder: OsfFile | null; -} diff --git a/src/app/features/registry/store/registry-files/registry-files.selectors.ts b/src/app/features/registry/store/registry-files/registry-files.selectors.ts deleted file mode 100644 index a68bc23e4..000000000 --- a/src/app/features/registry/store/registry-files/registry-files.selectors.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Selector } from '@ngxs/store'; - -import { OsfFile } from '@shared/models'; - -import { RegistryFilesStateModel } from './registry-files.model'; -import { RegistryFilesState } from './registry-files.state'; - -export class RegistryFilesSelectors { - @Selector([RegistryFilesState]) - static getFiles(state: RegistryFilesStateModel): OsfFile[] { - return state.files.data; - } - - @Selector([RegistryFilesState]) - static isFilesLoading(state: RegistryFilesStateModel): boolean { - return state.files.isLoading; - } - - @Selector([RegistryFilesState]) - static getCurrentFolder(state: RegistryFilesStateModel): OsfFile | null { - return state.currentFolder; - } -} diff --git a/src/app/features/registry/store/registry-files/registry-files.state.ts b/src/app/features/registry/store/registry-files/registry-files.state.ts deleted file mode 100644 index 739cc7d8b..000000000 --- a/src/app/features/registry/store/registry-files/registry-files.state.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Action, State, StateContext } from '@ngxs/store'; - -import { tap } from 'rxjs'; -import { catchError } from 'rxjs/operators'; - -import { inject, Injectable } from '@angular/core'; - -import { handleSectionError } from '@shared/helpers'; -import { FilesService, ToastService } from '@shared/services'; - -import { GetRegistryFiles, SetCurrentFolder, SetSearch, SetSort } from './registry-files.actions'; -import { RegistryFilesStateModel } from './registry-files.model'; - -@Injectable() -@State({ - name: 'registryFiles', - defaults: { - files: { - data: [], - isLoading: false, - error: null, - }, - search: '', - sort: '', - currentFolder: null, - }, -}) -export class RegistryFilesState { - private readonly filesService = inject(FilesService); - private readonly toastService = inject(ToastService); - - @Action(GetRegistryFiles) - getRegistryFiles(ctx: StateContext, action: GetRegistryFiles) { - const state = ctx.getState(); - ctx.patchState({ files: { ...state.files, isLoading: true, error: null } }); - - return this.filesService.getFiles(action.filesLink, state.search, state.sort).pipe( - tap({ - next: (response) => { - ctx.patchState({ - files: { - data: response.files, - isLoading: false, - error: null, - }, - }); - }, - }), - catchError((error) => { - this.toastService.showError(error); - return handleSectionError(ctx, 'files', error); - }) - ); - } - - @Action(SetCurrentFolder) - setSelectedFolder(ctx: StateContext, action: SetCurrentFolder) { - ctx.patchState({ currentFolder: action.folder }); - } - - @Action(SetSearch) - setSearch(ctx: StateContext, action: SetSearch) { - ctx.patchState({ search: action.search }); - } - - @Action(SetSort) - setSort(ctx: StateContext, action: SetSort) { - ctx.patchState({ sort: action.sort }); - } -} diff --git a/src/app/features/registry/store/registry-links/registry-links.actions.ts b/src/app/features/registry/store/registry-links/registry-links.actions.ts index cb07459cd..c84670db3 100644 --- a/src/app/features/registry/store/registry-links/registry-links.actions.ts +++ b/src/app/features/registry/store/registry-links/registry-links.actions.ts @@ -15,13 +15,3 @@ export class GetLinkedRegistrations { public pageSize?: number ) {} } - -export class GetBibliographicContributors { - static readonly type = '[RegistryLinks] Get Bibliographic Contributors'; - constructor(public nodeId: string) {} -} - -export class GetBibliographicContributorsForRegistration { - static readonly type = '[RegistryLinks] Get Bibliographic Contributors For Registration'; - constructor(public registrationId: string) {} -} diff --git a/src/app/features/registry/store/registry-links/registry-links.model.ts b/src/app/features/registry/store/registry-links/registry-links.model.ts index f9056747f..d183b97a2 100644 --- a/src/app/features/registry/store/registry-links/registry-links.model.ts +++ b/src/app/features/registry/store/registry-links/registry-links.model.ts @@ -1,6 +1,6 @@ import { AsyncStateModel } from '@shared/models'; -import { LinkedNode, LinkedRegistration, NodeBibliographicContributor } from '../../models'; +import { LinkedNode, LinkedRegistration } from '../../models'; export interface RegistryLinksStateModel { linkedNodes: AsyncStateModel & { @@ -9,10 +9,9 @@ export interface RegistryLinksStateModel { linkedRegistrations: AsyncStateModel & { meta?: { total: number; per_page: number }; }; - bibliographicContributors: AsyncStateModel & { - nodeId?: string; - }; - bibliographicContributorsForRegistration: AsyncStateModel & { - registrationId?: string; - }; } + +export const REGISTRY_LINKS_STATE_DEFAULTS: RegistryLinksStateModel = { + linkedNodes: { data: [], isLoading: false, error: null }, + linkedRegistrations: { data: [], isLoading: false, error: null }, +}; diff --git a/src/app/features/registry/store/registry-links/registry-links.selectors.ts b/src/app/features/registry/store/registry-links/registry-links.selectors.ts index f54df08e4..332fd47d5 100644 --- a/src/app/features/registry/store/registry-links/registry-links.selectors.ts +++ b/src/app/features/registry/store/registry-links/registry-links.selectors.ts @@ -23,24 +23,4 @@ export class RegistryLinksSelectors { static getLinkedRegistrationsLoading(state: RegistryLinksStateModel) { return state.linkedRegistrations.isLoading; } - - @Selector([RegistryLinksState]) - static getBibliographicContributors(state: RegistryLinksStateModel) { - return state.bibliographicContributors.data; - } - - @Selector([RegistryLinksState]) - static getBibliographicContributorsNodeId(state: RegistryLinksStateModel) { - return state.bibliographicContributors.nodeId; - } - - @Selector([RegistryLinksState]) - static getBibliographicContributorsForRegistration(state: RegistryLinksStateModel) { - return state.bibliographicContributorsForRegistration.data; - } - - @Selector([RegistryLinksState]) - static getBibliographicContributorsForRegistrationId(state: RegistryLinksStateModel) { - return state.bibliographicContributorsForRegistration.registrationId; - } } diff --git a/src/app/features/registry/store/registry-links/registry-links.state.ts b/src/app/features/registry/store/registry-links/registry-links.state.ts index 9713ea7eb..a7a22d86e 100644 --- a/src/app/features/registry/store/registry-links/registry-links.state.ts +++ b/src/app/features/registry/store/registry-links/registry-links.state.ts @@ -8,24 +8,12 @@ import { handleSectionError } from '@shared/helpers'; import { RegistryLinksService } from '../../services/registry-links.service'; -import { - GetBibliographicContributors, - GetBibliographicContributorsForRegistration, - GetLinkedNodes, - GetLinkedRegistrations, -} from './registry-links.actions'; -import { RegistryLinksStateModel } from './registry-links.model'; - -const initialState: RegistryLinksStateModel = { - linkedNodes: { data: [], isLoading: false, error: null }, - linkedRegistrations: { data: [], isLoading: false, error: null }, - bibliographicContributors: { data: [], isLoading: false, error: null }, - bibliographicContributorsForRegistration: { data: [], isLoading: false, error: null }, -}; +import { GetLinkedNodes, GetLinkedRegistrations } from './registry-links.actions'; +import { REGISTRY_LINKS_STATE_DEFAULTS, RegistryLinksStateModel } from './registry-links.model'; @State({ name: 'registryLinks', - defaults: initialState, + defaults: REGISTRY_LINKS_STATE_DEFAULTS, }) @Injectable() export class RegistryLinksState { @@ -74,61 +62,4 @@ export class RegistryLinksState { catchError((error) => handleSectionError(ctx, 'linkedRegistrations', error)) ); } - - @Action(GetBibliographicContributors) - getBibliographicContributors(ctx: StateContext, action: GetBibliographicContributors) { - const state = ctx.getState(); - ctx.patchState({ - bibliographicContributors: { - ...state.bibliographicContributors, - isLoading: true, - error: null, - nodeId: action.nodeId, - }, - }); - - return this.registryLinksService.getBibliographicContributors(action.nodeId).pipe( - tap((contributors) => { - ctx.patchState({ - bibliographicContributors: { - data: contributors, - isLoading: false, - error: null, - nodeId: action.nodeId, - }, - }); - }), - catchError((error) => handleSectionError(ctx, 'bibliographicContributors', error)) - ); - } - - @Action(GetBibliographicContributorsForRegistration) - getBibliographicContributorsForRegistration( - ctx: StateContext, - action: GetBibliographicContributorsForRegistration - ) { - const state = ctx.getState(); - ctx.patchState({ - bibliographicContributorsForRegistration: { - ...state.bibliographicContributorsForRegistration, - isLoading: true, - error: null, - registrationId: action.registrationId, - }, - }); - - return this.registryLinksService.getBibliographicContributorsForRegistration(action.registrationId).pipe( - tap((contributors) => { - ctx.patchState({ - bibliographicContributorsForRegistration: { - data: contributors, - isLoading: false, - error: null, - registrationId: action.registrationId, - }, - }); - }), - catchError((error) => handleSectionError(ctx, 'bibliographicContributorsForRegistration', error)) - ); - } } diff --git a/src/app/features/registry/store/registry-overview/registry-overview.actions.ts b/src/app/features/registry/store/registry-overview/registry-overview.actions.ts index 99d42c047..782242af0 100644 --- a/src/app/features/registry/store/registry-overview/registry-overview.actions.ts +++ b/src/app/features/registry/store/registry-overview/registry-overview.actions.ts @@ -1,19 +1,9 @@ -import { RegistrationQuestions } from '@osf/features/registry/models'; import { ReviewActionPayload } from '@osf/shared/models/review-action'; export class GetRegistryById { static readonly type = '[Registry Overview] Get Registry By Id'; - constructor( - public id: string, - public isComponentPage?: boolean - ) {} -} - -export class GetRegistrySubjects { - static readonly type = '[Registry Overview] Get Registry Subjects'; - - constructor(public registryId: string) {} + constructor(public id: string) {} } export class GetRegistryInstitutions { @@ -25,10 +15,7 @@ export class GetRegistryInstitutions { export class GetSchemaBlocks { static readonly type = '[Registry Overview] Get Schema Blocks'; - constructor( - public schemaLink: string, - public questions: RegistrationQuestions - ) {} + constructor(public schemaLink: string) {} } export class WithdrawRegistration { diff --git a/src/app/features/registry/store/registry-overview/registry-overview.model.ts b/src/app/features/registry/store/registry-overview/registry-overview.model.ts index f242d3f17..78cf5c475 100644 --- a/src/app/features/registry/store/registry-overview/registry-overview.model.ts +++ b/src/app/features/registry/store/registry-overview/registry-overview.model.ts @@ -1,11 +1,10 @@ import { ReviewAction } from '@osf/features/moderation/models'; -import { RegistryOverview, RegistrySubject } from '@osf/features/registry/models'; -import { Institution, PageSchema } from '@osf/shared/models'; -import { AsyncStateModel } from '@shared/models'; +import { AsyncStateModel, Institution, PageSchema } from '@osf/shared/models'; + +import { RegistryOverview } from '../../models'; export interface RegistryOverviewStateModel { registry: AsyncStateModel; - subjects: AsyncStateModel; institutions: AsyncStateModel; schemaBlocks: AsyncStateModel; moderationActions: AsyncStateModel; @@ -18,11 +17,6 @@ export const REGISTRY_OVERVIEW_DEFAULTS: RegistryOverviewStateModel = { isLoading: false, error: null, }, - subjects: { - data: [], - isLoading: false, - error: null, - }, institutions: { data: [], isLoading: false, diff --git a/src/app/features/registry/store/registry-overview/registry-overview.selectors.ts b/src/app/features/registry/store/registry-overview/registry-overview.selectors.ts index be11689a5..7486da84f 100644 --- a/src/app/features/registry/store/registry-overview/registry-overview.selectors.ts +++ b/src/app/features/registry/store/registry-overview/registry-overview.selectors.ts @@ -1,7 +1,8 @@ import { Selector } from '@ngxs/store'; import { ReviewAction } from '@osf/features/moderation/models'; -import { RegistryOverview, RegistrySubject } from '@osf/features/registry/models'; +import { RegistryOverview } from '@osf/features/registry/models'; +import { UserPermissions } from '@osf/shared/enums'; import { Institution, PageSchema } from '@osf/shared/models'; import { RegistryOverviewStateModel } from './registry-overview.model'; @@ -23,16 +24,6 @@ export class RegistryOverviewSelectors { return state.isAnonymous; } - @Selector([RegistryOverviewState]) - static getSubjects(state: RegistryOverviewStateModel): RegistrySubject[] | null { - return state.subjects.data; - } - - @Selector([RegistryOverviewState]) - static isSubjectsLoading(state: RegistryOverviewStateModel): boolean { - return state.subjects.isLoading; - } - @Selector([RegistryOverviewState]) static getInstitutions(state: RegistryOverviewStateModel): Institution[] | null { return state.institutions.data; @@ -67,4 +58,24 @@ export class RegistryOverviewSelectors { static isReviewActionSubmitting(state: RegistryOverviewStateModel): boolean { return state.moderationActions.isSubmitting || false; } + + @Selector([RegistryOverviewState]) + static hasReadAccess(state: RegistryOverviewStateModel): boolean { + return state.registry.data?.currentUserPermissions.includes(UserPermissions.Read) || false; + } + + @Selector([RegistryOverviewState]) + static hasWriteAccess(state: RegistryOverviewStateModel): boolean { + return state.registry.data?.currentUserPermissions.includes(UserPermissions.Write) || false; + } + + @Selector([RegistryOverviewState]) + static hasAdminAccess(state: RegistryOverviewStateModel): boolean { + return state.registry.data?.currentUserPermissions.includes(UserPermissions.Admin) || false; + } + + @Selector([RegistryOverviewState]) + static hasNoPermissions(state: RegistryOverviewStateModel): boolean { + return !state.registry.data?.currentUserPermissions.length; + } } diff --git a/src/app/features/registry/store/registry-overview/registry-overview.state.ts b/src/app/features/registry/store/registry-overview/registry-overview.state.ts index de1507a47..e43385cf7 100644 --- a/src/app/features/registry/store/registry-overview/registry-overview.state.ts +++ b/src/app/features/registry/store/registry-overview/registry-overview.state.ts @@ -5,8 +5,8 @@ import { catchError } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; -import { SetCurrentProvider } from '@osf/core/store/provider/provider.actions'; -import { SetUserAsModerator } from '@osf/core/store/user'; +import { SetCurrentProvider } from '@core/store/provider'; +import { CurrentResourceType } from '@osf/shared/enums'; import { handleSectionError } from '@osf/shared/helpers'; import { RegistryOverviewService } from '../../services'; @@ -16,7 +16,6 @@ import { GetRegistryById, GetRegistryInstitutions, GetRegistryReviewActions, - GetRegistrySubjects, GetSchemaBlocks, MakePublic, SetRegistryCustomCitation, @@ -36,6 +35,11 @@ export class RegistryOverviewState { @Action(GetRegistryById) getRegistryById(ctx: StateContext, action: GetRegistryById) { const state = ctx.getState(); + + if (state.registry.isLoading) { + return; + } + ctx.patchState({ registry: { ...state.registry, @@ -44,55 +48,34 @@ export class RegistryOverviewState { }); return this.registryOverviewService.getRegistrationById(action.id).pipe( - tap({ - next: (response) => { - const registryOverview = response.registry; - if (registryOverview?.currentUserIsModerator) { - ctx.dispatch(new SetUserAsModerator()); - } - if (registryOverview?.provider) { - ctx.dispatch(new SetCurrentProvider(registryOverview.provider)); - } - ctx.patchState({ - registry: { - data: registryOverview, - isLoading: false, - error: null, - }, - isAnonymous: response.meta?.anonymous ?? false, - }); - if (registryOverview?.registrationSchemaLink && registryOverview?.questions && !action.isComponentPage) { - ctx.dispatch(new GetSchemaBlocks(registryOverview.registrationSchemaLink, registryOverview.questions)); - } - }, - }), - catchError((error) => handleSectionError(ctx, 'registry', error)) - ); - } + tap((response) => { + const registryOverview = response.registry; + + if (registryOverview?.provider) { + ctx.dispatch( + new SetCurrentProvider({ + id: registryOverview.provider.id, + name: registryOverview.provider.name, + type: CurrentResourceType.Registrations, + permissions: registryOverview.provider.permissions, + }) + ); + } - @Action(GetRegistrySubjects) - getRegistrySubjects(ctx: StateContext, action: GetRegistrySubjects) { - const state = ctx.getState(); - ctx.patchState({ - subjects: { - ...state.subjects, - isLoading: true, - }, - }); + ctx.patchState({ + registry: { + data: registryOverview, + isLoading: false, + error: null, + }, + isAnonymous: response.meta?.anonymous ?? false, + }); - return this.registryOverviewService.getSubjects(action.registryId).pipe( - tap({ - next: (subjects) => { - ctx.patchState({ - subjects: { - data: subjects, - isLoading: false, - error: null, - }, - }); - }, + if (registryOverview?.registrationSchemaLink) { + ctx.dispatch(new GetSchemaBlocks(registryOverview.registrationSchemaLink)); + } }), - catchError((error) => handleSectionError(ctx, 'subjects', error)) + catchError((error) => handleSectionError(ctx, 'registry', error)) ); } @@ -107,16 +90,14 @@ export class RegistryOverviewState { }); return this.registryOverviewService.getInstitutions(action.registryId).pipe( - tap({ - next: (institutions) => { - ctx.patchState({ - institutions: { - data: institutions, - isLoading: false, - error: null, - }, - }); - }, + tap((institutions) => { + ctx.patchState({ + institutions: { + data: institutions, + isLoading: false, + error: null, + }, + }); }), catchError((error) => handleSectionError(ctx, 'institutions', error)) ); @@ -133,16 +114,14 @@ export class RegistryOverviewState { }); return this.registryOverviewService.getSchemaBlocks(action.schemaLink).pipe( - tap({ - next: (schemaBlocks) => { - ctx.patchState({ - schemaBlocks: { - data: schemaBlocks, - isLoading: false, - error: null, - }, - }); - }, + tap((schemaBlocks) => { + ctx.patchState({ + schemaBlocks: { + data: schemaBlocks, + isLoading: false, + error: null, + }, + }); }), catchError((error) => handleSectionError(ctx, 'schemaBlocks', error)) ); @@ -159,19 +138,18 @@ export class RegistryOverviewState { }); return this.registryOverviewService.withdrawRegistration(action.registryId, action.justification).pipe( - tap({ - next: (registryOverview) => { - ctx.patchState({ - registry: { - data: registryOverview, - isLoading: false, - error: null, - }, - }); - if (registryOverview?.registrationSchemaLink && registryOverview?.questions) { - ctx.dispatch(new GetSchemaBlocks(registryOverview.registrationSchemaLink, registryOverview.questions)); - } - }, + tap((registryOverview) => { + ctx.patchState({ + registry: { + data: registryOverview, + isLoading: false, + error: null, + }, + }); + + if (registryOverview?.registrationSchemaLink) { + ctx.dispatch(new GetSchemaBlocks(registryOverview.registrationSchemaLink)); + } }), catchError((error) => handleSectionError(ctx, 'registry', error)) ); @@ -188,19 +166,18 @@ export class RegistryOverviewState { }); return this.registryOverviewService.makePublic(action.registryId).pipe( - tap({ - next: (registryOverview) => { - ctx.patchState({ - registry: { - data: registryOverview, - isLoading: false, - error: null, - }, - }); - if (registryOverview?.registrationSchemaLink && registryOverview?.questions) { - ctx.dispatch(new GetSchemaBlocks(registryOverview.registrationSchemaLink, registryOverview.questions)); - } - }, + tap((registryOverview) => { + ctx.patchState({ + registry: { + data: registryOverview, + isLoading: false, + error: null, + }, + }); + + if (registryOverview?.registrationSchemaLink) { + ctx.dispatch(new GetSchemaBlocks(registryOverview.registrationSchemaLink)); + } }), catchError((error) => handleSectionError(ctx, 'registry', error)) ); diff --git a/src/app/features/registry/store/registry-resources/registry-resources.selectors.ts b/src/app/features/registry/store/registry-resources/registry-resources.selectors.ts index b482de595..447c95c19 100644 --- a/src/app/features/registry/store/registry-resources/registry-resources.selectors.ts +++ b/src/app/features/registry/store/registry-resources/registry-resources.selectors.ts @@ -23,6 +23,6 @@ export class RegistryResourcesSelectors { @Selector([RegistryResourcesState]) static isCurrentResourceLoading(state: RegistryResourcesStateModel): boolean { - return state.currentResource.isLoading ?? false; + return !!state.currentResource.isLoading; } } diff --git a/src/app/features/search/components/filter-chips/filter-chips.component.html b/src/app/features/search/components/filter-chips/filter-chips.component.html deleted file mode 100644 index 7d7cb94a7..000000000 --- a/src/app/features/search/components/filter-chips/filter-chips.component.html +++ /dev/null @@ -1,65 +0,0 @@ -@if (filters().creator.value && !isMyProfilePage()) { - @let creator = filters().creator.filterName + ': ' + filters().creator.label; - -} - -@if (filters().dateCreated.value) { - @let dateCreated = filters().dateCreated.filterName + ': ' + filters().dateCreated.label; - -} - -@if (filters().funder.value) { - @let funder = filters().funder.filterName + ': ' + filters().funder.label; - - -} - -@if (filters().subject.value) { - @let subject = filters().subject.filterName + ': ' + filters().subject.label; - -} - -@if (filters().license.value) { - @let license = filters().license.filterName + ': ' + filters().license.label; - -} - -@if (filters().resourceType.value) { - @let resourceType = filters().resourceType.filterName + ': ' + filters().resourceType.label; - -} - -@if (filters().institution.value) { - @let institution = filters().institution.filterName + ': ' + filters().institution.label; - -} - -@if (filters().provider.value) { - @let provider = filters().provider.filterName + ': ' + filters().provider.label; - -} - -@if (filters().partOfCollection.value) { - @let partOfCollection = filters().partOfCollection.filterName + ': ' + filters().partOfCollection.label; - -} diff --git a/src/app/features/search/components/filter-chips/filter-chips.component.scss b/src/app/features/search/components/filter-chips/filter-chips.component.scss deleted file mode 100644 index bd49db7d9..000000000 --- a/src/app/features/search/components/filter-chips/filter-chips.component.scss +++ /dev/null @@ -1,16 +0,0 @@ -@use "styles/variables" as var; - -:host { - display: flex; - align-items: baseline; - flex-direction: column; - gap: 0.4rem; - - @media (max-width: var.$breakpoint-xl) { - flex-direction: row; - } - - @media (max-width: var.$breakpoint-sm) { - flex-direction: column; - } -} diff --git a/src/app/features/search/components/filter-chips/filter-chips.component.spec.ts b/src/app/features/search/components/filter-chips/filter-chips.component.spec.ts deleted file mode 100644 index 217d10352..000000000 --- a/src/app/features/search/components/filter-chips/filter-chips.component.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { provideStore } from '@ngxs/store'; - -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { SearchState } from '@osf/features/search/store'; - -import { ResourceFiltersState } from '../resource-filters/store'; - -import { FilterChipsComponent } from './filter-chips.component'; - -describe('FilterChipsComponent', () => { - let component: FilterChipsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FilterChipsComponent], - providers: [provideStore([ResourceFiltersState, SearchState]), provideHttpClient(), provideHttpClientTesting()], - }).compileComponents(); - - fixture = TestBed.createComponent(FilterChipsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/search/components/filter-chips/filter-chips.component.ts b/src/app/features/search/components/filter-chips/filter-chips.component.ts deleted file mode 100644 index afabc3332..000000000 --- a/src/app/features/search/components/filter-chips/filter-chips.component.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { select, Store } from '@ngxs/store'; - -import { Chip } from 'primeng/chip'; - -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; - -import { FilterType } from '@osf/shared/enums'; - -import { SearchSelectors } from '../../store'; -import { GetAllOptions } from '../filters/store'; -import { - ResourceFiltersSelectors, - SetCreator, - SetDateCreated, - SetFunder, - SetInstitution, - SetLicense, - SetPartOfCollection, - SetProvider, - SetResourceType, - SetSubject, -} from '../resource-filters/store'; - -@Component({ - selector: 'osf-filter-chips', - imports: [Chip], - templateUrl: './filter-chips.component.html', - styleUrl: './filter-chips.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class FilterChipsComponent { - readonly store = inject(Store); - - protected filters = select(ResourceFiltersSelectors.getAllFilters); - readonly isMyProfilePage = select(SearchSelectors.getIsMyProfile); - - clearFilter(filter: FilterType) { - switch (filter) { - case FilterType.Creator: - this.store.dispatch(new SetCreator('', '')); - break; - case FilterType.DateCreated: - this.store.dispatch(new SetDateCreated('')); - break; - case FilterType.Funder: - this.store.dispatch(new SetFunder('', '')); - break; - case FilterType.Subject: - this.store.dispatch(new SetSubject('', '')); - break; - case FilterType.License: - this.store.dispatch(new SetLicense('', '')); - break; - case FilterType.ResourceType: - this.store.dispatch(new SetResourceType('', '')); - break; - case FilterType.Institution: - this.store.dispatch(new SetInstitution('', '')); - break; - case FilterType.Provider: - this.store.dispatch(new SetProvider('', '')); - break; - case FilterType.PartOfCollection: - this.store.dispatch(new SetPartOfCollection('', '')); - break; - } - this.store.dispatch(GetAllOptions); - } - - protected readonly FilterType = FilterType; -} diff --git a/src/app/features/search/components/filters/creators/creators-filter.component.html b/src/app/features/search/components/filters/creators/creators-filter.component.html deleted file mode 100644 index a7c35c8a8..000000000 --- a/src/app/features/search/components/filters/creators/creators-filter.component.html +++ /dev/null @@ -1,16 +0,0 @@ -
-

Filter creators by typing their name below

- -
diff --git a/src/app/features/search/components/filters/creators/creators-filter.component.spec.ts b/src/app/features/search/components/filters/creators/creators-filter.component.spec.ts deleted file mode 100644 index 1bc66d1a8..000000000 --- a/src/app/features/search/components/filters/creators/creators-filter.component.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { MockProvider } from 'ng-mocks'; - -import { SelectChangeEvent } from 'primeng/select'; - -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { MOCK_STORE } from '@osf/shared/mocks'; -import { Creator } from '@osf/shared/models'; - -import { ResourceFiltersSelectors, SetCreator } from '../../resource-filters/store'; -import { GetAllOptions, ResourceFiltersOptionsSelectors } from '../store'; - -import { CreatorsFilterComponent } from './creators-filter.component'; - -describe('CreatorsFilterComponent', () => { - let component: CreatorsFilterComponent; - let fixture: ComponentFixture; - - const store = MOCK_STORE; - - const mockCreators: Creator[] = [ - { id: '1', name: 'John Doe' }, - { id: '2', name: 'Jane Smith' }, - { id: '3', name: 'Bob Johnson' }, - ]; - - beforeEach(async () => { - store.selectSignal.mockImplementation((selector) => { - if (selector === ResourceFiltersOptionsSelectors.getCreators) { - return signal(mockCreators); - } - - if (selector === ResourceFiltersSelectors.getCreator) { - return signal({ label: '', value: '' }); - } - - return signal(null); - }); - - await TestBed.configureTestingModule({ - imports: [CreatorsFilterComponent], - providers: [MockProvider(Store, store)], - }).compileComponents(); - - fixture = TestBed.createComponent(CreatorsFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize with empty input', () => { - expect(component['creatorsInput']()).toBeNull(); - }); - - it('should show all creators when no search text is entered', () => { - const options = component['creatorsOptions'](); - expect(options.length).toBe(3); - expect(options[0].label).toBe('John Doe'); - expect(options[1].label).toBe('Jane Smith'); - expect(options[2].label).toBe('Bob Johnson'); - }); - - it('should set creator when a valid selection is made', () => { - const event = { - originalEvent: { pointerId: 1 } as unknown as PointerEvent, - value: 'John Doe', - } as SelectChangeEvent; - - component.setCreator(event); - expect(store.dispatch).toHaveBeenCalledWith(new SetCreator('John Doe', '1')); - expect(store.dispatch).toHaveBeenCalledWith(GetAllOptions); - }); -}); diff --git a/src/app/features/search/components/filters/creators/creators-filter.component.ts b/src/app/features/search/components/filters/creators/creators-filter.component.ts deleted file mode 100644 index 563a51528..000000000 --- a/src/app/features/search/components/filters/creators/creators-filter.component.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { Select, SelectChangeEvent } from 'primeng/select'; - -import { debounceTime, distinctUntilChanged, Subject, takeUntil } from 'rxjs'; - -import { - ChangeDetectionStrategy, - Component, - computed, - effect, - inject, - OnDestroy, - signal, - untracked, -} from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; - -import { ResourceFiltersSelectors, SetCreator } from '../../resource-filters/store'; -import { GetAllOptions, GetCreatorsOptions, ResourceFiltersOptionsSelectors } from '../store'; - -@Component({ - selector: 'osf-creators-filter', - imports: [Select, ReactiveFormsModule, FormsModule], - templateUrl: './creators-filter.component.html', - styleUrl: './creators-filter.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class CreatorsFilterComponent implements OnDestroy { - readonly #store = inject(Store); - - protected searchCreatorsResults = this.#store.selectSignal(ResourceFiltersOptionsSelectors.getCreators); - protected creatorsOptions = computed(() => { - return this.searchCreatorsResults().map((creator) => ({ - label: creator.name, - id: creator.id, - })); - }); - protected creatorsLoading = signal(false); - protected creatorState = this.#store.selectSignal(ResourceFiltersSelectors.getCreator); - readonly #unsubscribe = new Subject(); - protected creatorsInput = signal(null); - protected initialization = true; - - constructor() { - toObservable(this.creatorsInput) - .pipe(debounceTime(500), distinctUntilChanged(), takeUntil(this.#unsubscribe)) - .subscribe((searchText) => { - if (!this.initialization) { - if (searchText) { - this.#store.dispatch(new GetCreatorsOptions(searchText ?? '')); - } - - if (!searchText) { - this.#store.dispatch(new SetCreator('', '')); - this.#store.dispatch(GetAllOptions); - } - } else { - this.initialization = false; - } - }); - - effect(() => { - const storeValue = this.creatorState().label; - const currentInput = untracked(() => this.creatorsInput()); - - if (!storeValue && currentInput !== null) { - this.creatorsInput.set(null); - } else if (storeValue && currentInput !== storeValue) { - this.creatorsInput.set(storeValue); - } - }); - } - - ngOnDestroy() { - this.#unsubscribe.complete(); - } - - setCreator(event: SelectChangeEvent): void { - if ((event.originalEvent as PointerEvent).pointerId && event.value) { - const creator = this.creatorsOptions().find((p) => p.label.includes(event.value)); - if (creator) { - this.#store.dispatch(new SetCreator(creator.label, creator.id)); - this.#store.dispatch(GetAllOptions); - } - } - } -} diff --git a/src/app/features/search/components/filters/date-created/date-created-filter.component.html b/src/app/features/search/components/filters/date-created/date-created-filter.component.html deleted file mode 100644 index 92dc43d8e..000000000 --- a/src/app/features/search/components/filters/date-created/date-created-filter.component.html +++ /dev/null @@ -1,13 +0,0 @@ -
-

Please select the creation date from the dropdown below

- -
diff --git a/src/app/features/search/components/filters/date-created/date-created-filter.component.spec.ts b/src/app/features/search/components/filters/date-created/date-created-filter.component.spec.ts deleted file mode 100644 index 01ab1226d..000000000 --- a/src/app/features/search/components/filters/date-created/date-created-filter.component.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { MockProvider } from 'ng-mocks'; - -import { Select, SelectChangeEvent } from 'primeng/select'; - -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; - -import { MOCK_STORE } from '@osf/shared/mocks'; -import { DateCreated } from '@osf/shared/models'; - -import { ResourceFiltersSelectors, SetDateCreated } from '../../resource-filters/store'; -import { GetAllOptions, ResourceFiltersOptionsSelectors } from '../store'; - -import { DateCreatedFilterComponent } from './date-created-filter.component'; - -describe('DateCreatedFilterComponent', () => { - let component: DateCreatedFilterComponent; - let fixture: ComponentFixture; - - const store = MOCK_STORE; - - const mockDates: DateCreated[] = [ - { value: '2024', count: 150 }, - { value: '2023', count: 200 }, - { value: '2022', count: 180 }, - ]; - - beforeEach(async () => { - store.selectSignal.mockImplementation((selector) => { - if (selector === ResourceFiltersOptionsSelectors.getDatesCreated) { - return signal(mockDates); - } - - if (selector === ResourceFiltersSelectors.getDateCreated) { - return signal({ label: '', value: '' }); - } - - return signal(null); - }); - - await TestBed.configureTestingModule({ - imports: [DateCreatedFilterComponent, FormsModule, Select], - providers: [MockProvider(Store, store)], - }).compileComponents(); - - fixture = TestBed.createComponent(DateCreatedFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize with empty input date', () => { - expect(component['inputDate']()).toBeNull(); - }); - - it('should show all dates with their counts', () => { - const options = component['datesOptions'](); - expect(options.length).toBe(3); - expect(options[0].label).toBe('2024 (150)'); - expect(options[1].label).toBe('2023 (200)'); - expect(options[2].label).toBe('2022 (180)'); - }); - - it('should set date when a valid selection is made', () => { - const event = { - originalEvent: { pointerId: 1 } as unknown as PointerEvent, - value: '2023', - } as SelectChangeEvent; - - component.setDateCreated(event); - expect(store.dispatch).toHaveBeenCalledWith(new SetDateCreated('2023')); - expect(store.dispatch).toHaveBeenCalledWith(GetAllOptions); - }); -}); diff --git a/src/app/features/search/components/filters/date-created/date-created-filter.component.ts b/src/app/features/search/components/filters/date-created/date-created-filter.component.ts deleted file mode 100644 index e7bb4c68d..000000000 --- a/src/app/features/search/components/filters/date-created/date-created-filter.component.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { Select, SelectChangeEvent } from 'primeng/select'; - -import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, untracked } from '@angular/core'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; - -import { ResourceFiltersSelectors, SetDateCreated } from '../../resource-filters/store'; -import { GetAllOptions, ResourceFiltersOptionsSelectors } from '../store'; - -@Component({ - selector: 'osf-date-created-filter', - imports: [ReactiveFormsModule, Select, FormsModule], - templateUrl: './date-created-filter.component.html', - styleUrl: './date-created-filter.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class DateCreatedFilterComponent { - readonly #store = inject(Store); - - protected availableDates = this.#store.selectSignal(ResourceFiltersOptionsSelectors.getDatesCreated); - protected dateCreatedState = this.#store.selectSignal(ResourceFiltersSelectors.getDateCreated); - protected inputDate = signal(null); - protected datesOptions = computed(() => { - return this.availableDates().map((date) => ({ - label: date.value + ' (' + date.count + ')', - value: date.value, - })); - }); - - constructor() { - effect(() => { - const storeValue = this.dateCreatedState().label; - const currentInput = untracked(() => this.inputDate()); - - if (!storeValue && currentInput !== null) { - this.inputDate.set(null); - } else if (storeValue && currentInput !== storeValue) { - this.inputDate.set(storeValue); - } - }); - } - - setDateCreated(event: SelectChangeEvent): void { - if ((event.originalEvent as PointerEvent).pointerId) { - this.#store.dispatch(new SetDateCreated(event.value)); - this.#store.dispatch(GetAllOptions); - } - } -} diff --git a/src/app/features/search/components/filters/funder/funder-filter.component.html b/src/app/features/search/components/filters/funder/funder-filter.component.html deleted file mode 100644 index 2b0a6b590..000000000 --- a/src/app/features/search/components/filters/funder/funder-filter.component.html +++ /dev/null @@ -1,17 +0,0 @@ -
-

Please select the funder from the dropdown below or start typing for find it

- -
diff --git a/src/app/features/search/components/filters/funder/funder-filter.component.spec.ts b/src/app/features/search/components/filters/funder/funder-filter.component.spec.ts deleted file mode 100644 index 210e9cb5e..000000000 --- a/src/app/features/search/components/filters/funder/funder-filter.component.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { MockProvider } from 'ng-mocks'; - -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { MOCK_STORE } from '@osf/shared/mocks'; -import { FunderFilter } from '@osf/shared/models'; - -import { ResourceFiltersSelectors } from '../../resource-filters/store'; -import { ResourceFiltersOptionsSelectors } from '../store'; - -import { FunderFilterComponent } from './funder-filter.component'; - -describe('FunderFilterComponent', () => { - let component: FunderFilterComponent; - let fixture: ComponentFixture; - - const store = MOCK_STORE; - - const mockFunders: FunderFilter[] = [ - { id: '1', label: 'National Science Foundation', count: 25 }, - { id: '2', label: 'National Institutes of Health', count: 18 }, - { id: '3', label: 'Department of Energy', count: 12 }, - ]; - - beforeEach(async () => { - store.selectSignal.mockImplementation((selector) => { - if (selector === ResourceFiltersOptionsSelectors.getFunders) { - return signal(mockFunders); - } - - if (selector === ResourceFiltersSelectors.getFunder) { - return signal({ label: '', value: '' }); - } - - return signal(null); - }); - - await TestBed.configureTestingModule({ - imports: [FunderFilterComponent], - providers: [MockProvider(Store, store)], - }).compileComponents(); - - fixture = TestBed.createComponent(FunderFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize with empty input text', () => { - expect(component['inputText']()).toBeNull(); - }); - - it('should show all funders when no search text is entered', () => { - const options = component['fundersOptions'](); - expect(options.length).toBe(3); - expect(options[0].labelCount).toBe('National Science Foundation (25)'); - expect(options[1].labelCount).toBe('National Institutes of Health (18)'); - expect(options[2].labelCount).toBe('Department of Energy (12)'); - }); -}); diff --git a/src/app/features/search/components/filters/funder/funder-filter.component.ts b/src/app/features/search/components/filters/funder/funder-filter.component.ts deleted file mode 100644 index 3f63813ad..000000000 --- a/src/app/features/search/components/filters/funder/funder-filter.component.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { Select, SelectChangeEvent } from 'primeng/select'; - -import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, untracked } from '@angular/core'; -import { FormsModule } from '@angular/forms'; - -import { ResourceFiltersSelectors, SetFunder } from '../../resource-filters/store'; -import { GetAllOptions, ResourceFiltersOptionsSelectors } from '../store'; - -@Component({ - selector: 'osf-funder-filter', - imports: [Select, FormsModule], - templateUrl: './funder-filter.component.html', - styleUrl: './funder-filter.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class FunderFilterComponent { - readonly #store = inject(Store); - - protected funderState = this.#store.selectSignal(ResourceFiltersSelectors.getFunder); - protected availableFunders = this.#store.selectSignal(ResourceFiltersOptionsSelectors.getFunders); - protected inputText = signal(null); - protected fundersOptions = computed(() => { - if (this.inputText() !== null) { - const search = this.inputText()!.toLowerCase(); - return this.availableFunders() - .filter((funder) => funder.label.toLowerCase().includes(search)) - .map((funder) => ({ - labelCount: funder.label + ' (' + funder.count + ')', - label: funder.label, - id: funder.id, - })); - } - - const res = this.availableFunders().map((funder) => ({ - labelCount: funder.label + ' (' + funder.count + ')', - label: funder.label, - id: funder.id, - })); - - return res; - }); - - constructor() { - effect(() => { - const storeValue = this.funderState().label; - const currentInput = untracked(() => this.inputText()); - - if (!storeValue && currentInput !== null) { - this.inputText.set(null); - } else if (storeValue && currentInput !== storeValue) { - this.inputText.set(storeValue); - } - }); - } - - loading = signal(false); - - setFunders(event: SelectChangeEvent): void { - if ((event.originalEvent as PointerEvent).pointerId && event.value) { - const funder = this.fundersOptions()?.find((funder) => funder.label.includes(event.value)); - if (funder) { - this.#store.dispatch(new SetFunder(funder.label, funder.id)); - this.#store.dispatch(GetAllOptions); - } - } else { - this.#store.dispatch(new SetFunder('', '')); - this.#store.dispatch(GetAllOptions); - } - } -} diff --git a/src/app/features/search/components/filters/index.ts b/src/app/features/search/components/filters/index.ts deleted file mode 100644 index c9ada1c7c..000000000 --- a/src/app/features/search/components/filters/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { CreatorsFilterComponent } from './creators/creators-filter.component'; -export { DateCreatedFilterComponent } from './date-created/date-created-filter.component'; -export { FunderFilterComponent } from './funder/funder-filter.component'; -export { InstitutionFilterComponent } from './institution-filter/institution-filter.component'; -export { LicenseFilterComponent } from './license-filter/license-filter.component'; -export { PartOfCollectionFilterComponent } from './part-of-collection-filter/part-of-collection-filter.component'; -export { ProviderFilterComponent } from './provider-filter/provider-filter.component'; -export { ResourceTypeFilterComponent } from './resource-type-filter/resource-type-filter.component'; -export { SubjectFilterComponent } from './subject/subject-filter.component'; diff --git a/src/app/features/search/components/filters/institution-filter/institution-filter.component.html b/src/app/features/search/components/filters/institution-filter/institution-filter.component.html deleted file mode 100644 index 7106cf910..000000000 --- a/src/app/features/search/components/filters/institution-filter/institution-filter.component.html +++ /dev/null @@ -1,20 +0,0 @@ -
-

- {{ 'institutions.searchInstitutionsDesctiption' | translate }} - {{ 'institutions.learnMore' | translate }} -

- -
diff --git a/src/app/features/search/components/filters/institution-filter/institution-filter.component.scss b/src/app/features/search/components/filters/institution-filter/institution-filter.component.scss deleted file mode 100644 index 5fd36a5f1..000000000 --- a/src/app/features/search/components/filters/institution-filter/institution-filter.component.scss +++ /dev/null @@ -1,5 +0,0 @@ -:host ::ng-deep { - .p-scroller-viewport { - flex: none; - } -} diff --git a/src/app/features/search/components/filters/institution-filter/institution-filter.component.spec.ts b/src/app/features/search/components/filters/institution-filter/institution-filter.component.spec.ts deleted file mode 100644 index 96581d199..000000000 --- a/src/app/features/search/components/filters/institution-filter/institution-filter.component.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { TranslatePipe } from '@ngx-translate/core'; -import { MockPipe, MockProvider } from 'ng-mocks'; - -import { SelectChangeEvent } from 'primeng/select'; - -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { MOCK_STORE } from '@osf/shared/mocks'; -import { InstitutionFilter } from '@osf/shared/models'; - -import { ResourceFiltersSelectors, SetInstitution } from '../../resource-filters/store'; -import { GetAllOptions, ResourceFiltersOptionsSelectors } from '../store'; - -import { InstitutionFilterComponent } from './institution-filter.component'; - -describe('InstitutionFilterComponent', () => { - let component: InstitutionFilterComponent; - let fixture: ComponentFixture; - - const store = MOCK_STORE; - - const mockInstitutions: InstitutionFilter[] = [ - { id: '1', label: 'Harvard University', count: 15 }, - { id: '2', label: 'MIT', count: 12 }, - { id: '3', label: 'Stanford University', count: 8 }, - ]; - - beforeEach(async () => { - store.selectSignal.mockImplementation((selector) => { - if (selector === ResourceFiltersOptionsSelectors.getInstitutions) { - return signal(mockInstitutions); - } - - if (selector === ResourceFiltersSelectors.getInstitution) { - return signal({ label: '', value: '' }); - } - - return signal(null); - }); - - await TestBed.configureTestingModule({ - imports: [InstitutionFilterComponent, MockPipe(TranslatePipe)], - providers: [MockProvider(Store, store)], - }).compileComponents(); - - fixture = TestBed.createComponent(InstitutionFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize with empty input text', () => { - expect(component['inputText']()).toBeNull(); - }); - - it('should show all institutions when no search text is entered', () => { - const options = component['institutionsOptions'](); - expect(options.length).toBe(3); - expect(options[0].labelCount).toBe('Harvard University (15)'); - expect(options[1].labelCount).toBe('MIT (12)'); - expect(options[2].labelCount).toBe('Stanford University (8)'); - }); - - it('should filter institutions based on search text', () => { - component['inputText'].set('MIT'); - const options = component['institutionsOptions'](); - expect(options.length).toBe(1); - expect(options[0].labelCount).toBe('MIT (12)'); - }); - - it('should clear institution when selection is cleared', () => { - const event = { - originalEvent: new Event('change'), - value: '', - } as SelectChangeEvent; - - component.setInstitutions(event); - expect(store.dispatch).toHaveBeenCalledWith(new SetInstitution('', '')); - expect(store.dispatch).toHaveBeenCalledWith(GetAllOptions); - }); -}); diff --git a/src/app/features/search/components/filters/institution-filter/institution-filter.component.ts b/src/app/features/search/components/filters/institution-filter/institution-filter.component.ts deleted file mode 100644 index dd69cdd5b..000000000 --- a/src/app/features/search/components/filters/institution-filter/institution-filter.component.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { TranslateModule } from '@ngx-translate/core'; - -import { Select, SelectChangeEvent } from 'primeng/select'; - -import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, untracked } from '@angular/core'; -import { FormsModule } from '@angular/forms'; - -import { ResourceFiltersSelectors, SetInstitution } from '../../resource-filters/store'; -import { GetAllOptions, ResourceFiltersOptionsSelectors } from '../store'; - -@Component({ - selector: 'osf-institution-filter', - imports: [Select, FormsModule, TranslateModule], - templateUrl: './institution-filter.component.html', - styleUrl: './institution-filter.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class InstitutionFilterComponent { - readonly #store = inject(Store); - - protected institutionState = this.#store.selectSignal(ResourceFiltersSelectors.getInstitution); - protected availableInstitutions = this.#store.selectSignal(ResourceFiltersOptionsSelectors.getInstitutions); - protected inputText = signal(null); - protected institutionsOptions = computed(() => { - if (this.inputText() !== null) { - const search = this.inputText()!.toLowerCase(); - return this.availableInstitutions() - .filter((institution) => institution.label.toLowerCase().includes(search)) - .map((institution) => ({ - labelCount: institution.label + ' (' + institution.count + ')', - label: institution.label, - id: institution.id, - })); - } - - const res = this.availableInstitutions().map((institution) => ({ - labelCount: institution.label + ' (' + institution.count + ')', - label: institution.label, - id: institution.id, - })); - - return res; - }); - - constructor() { - effect(() => { - const storeValue = this.institutionState().label; - const currentInput = untracked(() => this.inputText()); - - if (!storeValue && currentInput !== null) { - this.inputText.set(null); - } else if (storeValue && currentInput !== storeValue) { - this.inputText.set(storeValue); - } - }); - } - - loading = signal(false); - - setInstitutions(event: SelectChangeEvent): void { - if ((event.originalEvent as PointerEvent).pointerId && event.value) { - const institution = this.institutionsOptions()?.find((institution) => institution.label.includes(event.value)); - if (institution) { - this.#store.dispatch(new SetInstitution(institution.label, institution.id)); - this.#store.dispatch(GetAllOptions); - } - } else { - this.#store.dispatch(new SetInstitution('', '')); - this.#store.dispatch(GetAllOptions); - } - } -} diff --git a/src/app/features/search/components/filters/license-filter/license-filter.component.html b/src/app/features/search/components/filters/license-filter/license-filter.component.html deleted file mode 100644 index 026184a1d..000000000 --- a/src/app/features/search/components/filters/license-filter/license-filter.component.html +++ /dev/null @@ -1,17 +0,0 @@ -
-

Please select the license from the dropdown below or start typing for find it

- -
diff --git a/src/app/features/search/components/filters/license-filter/license-filter.component.spec.ts b/src/app/features/search/components/filters/license-filter/license-filter.component.spec.ts deleted file mode 100644 index 719445169..000000000 --- a/src/app/features/search/components/filters/license-filter/license-filter.component.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { MockProvider } from 'ng-mocks'; - -import { SelectChangeEvent } from 'primeng/select'; - -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { MOCK_STORE } from '@osf/shared/mocks'; -import { LicenseFilter } from '@osf/shared/models'; - -import { ResourceFiltersSelectors, SetLicense } from '../../resource-filters/store'; -import { GetAllOptions, ResourceFiltersOptionsSelectors } from '../store'; - -import { LicenseFilterComponent } from './license-filter.component'; - -describe('LicenseFilterComponent', () => { - let component: LicenseFilterComponent; - let fixture: ComponentFixture; - - const mockStore = MOCK_STORE; - - const mockLicenses: LicenseFilter[] = [ - { id: '1', label: 'MIT License', count: 10 }, - { id: '2', label: 'Apache License 2.0', count: 5 }, - { id: '3', label: 'GNU GPL v3', count: 3 }, - ]; - - beforeEach(async () => { - mockStore.selectSignal.mockImplementation((selector) => { - if (selector === ResourceFiltersOptionsSelectors.getLicenses) { - return signal(mockLicenses); - } - if (selector === ResourceFiltersSelectors.getLicense) { - return signal({ label: '', value: '' }); - } - return signal(null); - }); - - await TestBed.configureTestingModule({ - imports: [LicenseFilterComponent], - providers: [MockProvider(Store, mockStore)], - }).compileComponents(); - - fixture = TestBed.createComponent(LicenseFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize with empty input text', () => { - expect(component['inputText']()).toBeNull(); - }); - - it('should show all licenses when no search text is entered', () => { - const options = component['licensesOptions'](); - expect(options.length).toBe(3); - expect(options[0].labelCount).toBe('MIT License (10)'); - expect(options[1].labelCount).toBe('Apache License 2.0 (5)'); - expect(options[2].labelCount).toBe('GNU GPL v3 (3)'); - }); - - it('should filter licenses based on search text', () => { - component['inputText'].set('MIT'); - const options = component['licensesOptions'](); - expect(options.length).toBe(1); - expect(options[0].labelCount).toBe('MIT License (10)'); - }); - - it('should clear license when selection is cleared', () => { - const event = { - originalEvent: new Event('change'), - value: '', - } as SelectChangeEvent; - - component.setLicenses(event); - expect(mockStore.dispatch).toHaveBeenCalledWith(new SetLicense('', '')); - expect(mockStore.dispatch).toHaveBeenCalledWith(GetAllOptions); - }); -}); diff --git a/src/app/features/search/components/filters/license-filter/license-filter.component.ts b/src/app/features/search/components/filters/license-filter/license-filter.component.ts deleted file mode 100644 index dea523e5c..000000000 --- a/src/app/features/search/components/filters/license-filter/license-filter.component.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { Select, SelectChangeEvent } from 'primeng/select'; - -import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, untracked } from '@angular/core'; -import { FormsModule } from '@angular/forms'; - -import { ResourceFiltersSelectors, SetLicense } from '../../resource-filters/store'; -import { GetAllOptions, ResourceFiltersOptionsSelectors } from '../store'; - -@Component({ - selector: 'osf-license-filter', - imports: [Select, FormsModule], - templateUrl: './license-filter.component.html', - styleUrl: './license-filter.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class LicenseFilterComponent { - readonly #store = inject(Store); - - protected availableLicenses = this.#store.selectSignal(ResourceFiltersOptionsSelectors.getLicenses); - protected licenseState = this.#store.selectSignal(ResourceFiltersSelectors.getLicense); - protected inputText = signal(null); - protected licensesOptions = computed(() => { - if (this.inputText() !== null) { - const search = this.inputText()!.toLowerCase(); - return this.availableLicenses() - .filter((license) => license.label.toLowerCase().includes(search)) - .map((license) => ({ - labelCount: license.label + ' (' + license.count + ')', - label: license.label, - id: license.id, - })); - } - - return this.availableLicenses().map((license) => ({ - labelCount: license.label + ' (' + license.count + ')', - label: license.label, - id: license.id, - })); - }); - - loading = signal(false); - - constructor() { - effect(() => { - const storeValue = this.licenseState().label; - const currentInput = untracked(() => this.inputText()); - - if (!storeValue && currentInput !== null) { - this.inputText.set(null); - } else if (storeValue && currentInput !== storeValue) { - this.inputText.set(storeValue); - } - }); - } - - setLicenses(event: SelectChangeEvent): void { - if ((event.originalEvent as PointerEvent).pointerId && event.value) { - const license = this.licensesOptions().find((license) => license.label.includes(event.value)); - if (license) { - this.#store.dispatch(new SetLicense(license.label, license.id)); - this.#store.dispatch(GetAllOptions); - } - } else { - this.#store.dispatch(new SetLicense('', '')); - this.#store.dispatch(GetAllOptions); - } - } -} diff --git a/src/app/features/search/components/filters/part-of-collection-filter/part-of-collection-filter.component.html b/src/app/features/search/components/filters/part-of-collection-filter/part-of-collection-filter.component.html deleted file mode 100644 index f02cd33d8..000000000 --- a/src/app/features/search/components/filters/part-of-collection-filter/part-of-collection-filter.component.html +++ /dev/null @@ -1,16 +0,0 @@ -
-

Please select the partOfCollection from the dropdown below or start typing for find it

- -
diff --git a/src/app/features/search/components/filters/part-of-collection-filter/part-of-collection-filter.component.spec.ts b/src/app/features/search/components/filters/part-of-collection-filter/part-of-collection-filter.component.spec.ts deleted file mode 100644 index 66d59c8f1..000000000 --- a/src/app/features/search/components/filters/part-of-collection-filter/part-of-collection-filter.component.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { MockProvider } from 'ng-mocks'; - -import { SelectChangeEvent } from 'primeng/select'; - -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { MOCK_STORE } from '@osf/shared/mocks'; -import { PartOfCollectionFilter } from '@osf/shared/models'; - -import { ResourceFiltersSelectors, SetPartOfCollection } from '../../resource-filters/store'; -import { GetAllOptions, ResourceFiltersOptionsSelectors } from '../store'; - -import { PartOfCollectionFilterComponent } from './part-of-collection-filter.component'; - -describe('PartOfCollectionFilterComponent', () => { - let component: PartOfCollectionFilterComponent; - let fixture: ComponentFixture; - - const mockStore = MOCK_STORE; - - const mockCollections: PartOfCollectionFilter[] = [ - { id: '1', label: 'Collection 1', count: 5 }, - { id: '2', label: 'Collection 2', count: 3 }, - { id: '3', label: 'Collection 3', count: 2 }, - ]; - - beforeEach(async () => { - mockStore.selectSignal.mockImplementation((selector) => { - if (selector === ResourceFiltersOptionsSelectors.getPartOfCollection) { - return signal(mockCollections); - } - - if (selector === ResourceFiltersSelectors.getPartOfCollection) { - return signal({ label: '', value: '' }); - } - - return signal(null); - }); - - await TestBed.configureTestingModule({ - imports: [PartOfCollectionFilterComponent], - providers: [MockProvider(Store, mockStore)], - }).compileComponents(); - - fixture = TestBed.createComponent(PartOfCollectionFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize with empty input text', () => { - expect(component['inputText']()).toBeNull(); - }); - - it('should show all collections when no search text is entered', () => { - const options = component['partOfCollectionsOptions'](); - expect(options.length).toBe(3); - expect(options[0].labelCount).toBe('Collection 1 (5)'); - expect(options[1].labelCount).toBe('Collection 2 (3)'); - expect(options[2].labelCount).toBe('Collection 3 (2)'); - }); - - it('should clear collection when selection is cleared', () => { - const event = { - originalEvent: new Event('change'), - value: '', - } as SelectChangeEvent; - - component.setPartOfCollections(event); - expect(mockStore.dispatch).toHaveBeenCalledWith(new SetPartOfCollection('', '')); - expect(mockStore.dispatch).toHaveBeenCalledWith(GetAllOptions); - }); -}); diff --git a/src/app/features/search/components/filters/part-of-collection-filter/part-of-collection-filter.component.ts b/src/app/features/search/components/filters/part-of-collection-filter/part-of-collection-filter.component.ts deleted file mode 100644 index e86dd7d0d..000000000 --- a/src/app/features/search/components/filters/part-of-collection-filter/part-of-collection-filter.component.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { Select, SelectChangeEvent } from 'primeng/select'; - -import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, untracked } from '@angular/core'; -import { FormsModule } from '@angular/forms'; - -import { ResourceFiltersSelectors, SetPartOfCollection } from '../../resource-filters/store'; -import { GetAllOptions, ResourceFiltersOptionsSelectors } from '../store'; - -@Component({ - selector: 'osf-part-of-collection-filter', - imports: [Select, FormsModule], - templateUrl: './part-of-collection-filter.component.html', - styleUrl: './part-of-collection-filter.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class PartOfCollectionFilterComponent { - readonly #store = inject(Store); - - protected availablePartOfCollections = this.#store.selectSignal(ResourceFiltersOptionsSelectors.getPartOfCollection); - protected partOfCollectionState = this.#store.selectSignal(ResourceFiltersSelectors.getPartOfCollection); - protected inputText = signal(null); - protected partOfCollectionsOptions = computed(() => { - return this.availablePartOfCollections().map((partOfCollection) => ({ - labelCount: partOfCollection.label + ' (' + partOfCollection.count + ')', - label: partOfCollection.label, - id: partOfCollection.id, - })); - }); - - loading = signal(false); - - constructor() { - effect(() => { - const storeValue = this.partOfCollectionState().label; - const currentInput = untracked(() => this.inputText()); - - if (!storeValue && currentInput !== null) { - this.inputText.set(null); - } else if (storeValue && currentInput !== storeValue) { - this.inputText.set(storeValue); - } - }); - } - - setPartOfCollections(event: SelectChangeEvent): void { - if ((event.originalEvent as PointerEvent).pointerId && event.value) { - const part = this.partOfCollectionsOptions().find((p) => p.label.includes(event.value)); - if (part) { - this.#store.dispatch(new SetPartOfCollection(part.label, part.id)); - this.#store.dispatch(GetAllOptions); - } - } else { - this.#store.dispatch(new SetPartOfCollection('', '')); - this.#store.dispatch(GetAllOptions); - } - } -} diff --git a/src/app/features/search/components/filters/provider-filter/provider-filter.component.html b/src/app/features/search/components/filters/provider-filter/provider-filter.component.html deleted file mode 100644 index 8ecff8f7d..000000000 --- a/src/app/features/search/components/filters/provider-filter/provider-filter.component.html +++ /dev/null @@ -1,17 +0,0 @@ -
-

Please select the provider from the dropdown below or start typing for find it

- -
diff --git a/src/app/features/search/components/filters/provider-filter/provider-filter.component.spec.ts b/src/app/features/search/components/filters/provider-filter/provider-filter.component.spec.ts deleted file mode 100644 index 7346da162..000000000 --- a/src/app/features/search/components/filters/provider-filter/provider-filter.component.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { MockProvider } from 'ng-mocks'; - -import { SelectChangeEvent } from 'primeng/select'; - -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { MOCK_STORE } from '@osf/shared/mocks'; -import { ProviderFilter } from '@osf/shared/models'; - -import { ResourceFiltersSelectors, SetProvider } from '../../resource-filters/store'; -import { GetAllOptions, ResourceFiltersOptionsSelectors } from '../store'; - -import { ProviderFilterComponent } from './provider-filter.component'; - -describe('ProviderFilterComponent', () => { - let component: ProviderFilterComponent; - let fixture: ComponentFixture; - - const mockStore = MOCK_STORE; - - const mockProviders: ProviderFilter[] = [ - { id: '1', label: 'Provider 1', count: 5 }, - { id: '2', label: 'Provider 2', count: 3 }, - { id: '3', label: 'Provider 3', count: 2 }, - ]; - - beforeEach(async () => { - mockStore.selectSignal.mockImplementation((selector) => { - if (selector === ResourceFiltersOptionsSelectors.getProviders) { - return signal(mockProviders); - } - - if (selector === ResourceFiltersSelectors.getProvider) { - return signal({ label: '', value: '' }); - } - - return signal(null); - }); - - await TestBed.configureTestingModule({ - imports: [ProviderFilterComponent], - providers: [MockProvider(Store, mockStore)], - }).compileComponents(); - - fixture = TestBed.createComponent(ProviderFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize with empty input text', () => { - expect(component['inputText']()).toBeNull(); - }); - - it('should show all providers when no search text is entered', () => { - const options = component['providersOptions'](); - expect(options.length).toBe(3); - expect(options[0].labelCount).toBe('Provider 1 (5)'); - expect(options[1].labelCount).toBe('Provider 2 (3)'); - expect(options[2].labelCount).toBe('Provider 3 (2)'); - }); - - it('should filter providers based on search text', () => { - component['inputText'].set('Provider 1'); - const options = component['providersOptions'](); - expect(options.length).toBe(1); - expect(options[0].labelCount).toBe('Provider 1 (5)'); - }); - - it('should clear provider when selection is cleared', () => { - const event = { - originalEvent: new Event('change'), - value: '', - } as SelectChangeEvent; - - component.setProviders(event); - expect(mockStore.dispatch).toHaveBeenCalledWith(new SetProvider('', '')); - expect(mockStore.dispatch).toHaveBeenCalledWith(GetAllOptions); - }); -}); diff --git a/src/app/features/search/components/filters/provider-filter/provider-filter.component.ts b/src/app/features/search/components/filters/provider-filter/provider-filter.component.ts deleted file mode 100644 index 2e53cee3f..000000000 --- a/src/app/features/search/components/filters/provider-filter/provider-filter.component.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { Select, SelectChangeEvent } from 'primeng/select'; - -import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, untracked } from '@angular/core'; -import { FormsModule } from '@angular/forms'; - -import { ResourceFiltersSelectors, SetProvider } from '../../resource-filters/store'; -import { GetAllOptions, ResourceFiltersOptionsSelectors } from '../store'; - -@Component({ - selector: 'osf-provider-filter', - imports: [Select, FormsModule], - templateUrl: './provider-filter.component.html', - styleUrl: './provider-filter.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ProviderFilterComponent { - readonly #store = inject(Store); - - protected availableProviders = this.#store.selectSignal(ResourceFiltersOptionsSelectors.getProviders); - protected providerState = this.#store.selectSignal(ResourceFiltersSelectors.getProvider); - protected inputText = signal(null); - protected providersOptions = computed(() => { - if (this.inputText() !== null) { - const search = this.inputText()!.toLowerCase(); - return this.availableProviders() - .filter((provider) => provider.label.toLowerCase().includes(search)) - .map((provider) => ({ - labelCount: provider.label + ' (' + provider.count + ')', - label: provider.label, - id: provider.id, - })); - } - - return this.availableProviders().map((provider) => ({ - labelCount: provider.label + ' (' + provider.count + ')', - label: provider.label, - id: provider.id, - })); - }); - - loading = signal(false); - - constructor() { - effect(() => { - const storeValue = this.providerState().label; - const currentInput = untracked(() => this.inputText()); - - if (!storeValue && currentInput !== null) { - this.inputText.set(null); - } else if (storeValue && currentInput !== storeValue) { - this.inputText.set(storeValue); - } - }); - } - - setProviders(event: SelectChangeEvent): void { - if ((event.originalEvent as PointerEvent).pointerId && event.value) { - const provider = this.providersOptions().find((p) => p.label.includes(event.value)); - if (provider) { - this.#store.dispatch(new SetProvider(provider.label, provider.id)); - this.#store.dispatch(GetAllOptions); - } - } else { - this.#store.dispatch(new SetProvider('', '')); - this.#store.dispatch(GetAllOptions); - } - } -} diff --git a/src/app/features/search/components/filters/resource-type-filter/resource-type-filter.component.html b/src/app/features/search/components/filters/resource-type-filter/resource-type-filter.component.html deleted file mode 100644 index 1ee9c515d..000000000 --- a/src/app/features/search/components/filters/resource-type-filter/resource-type-filter.component.html +++ /dev/null @@ -1,17 +0,0 @@ -
-

Please select the resourceType from the dropdown below or start typing for find it

- -
diff --git a/src/app/features/search/components/filters/resource-type-filter/resource-type-filter.component.spec.ts b/src/app/features/search/components/filters/resource-type-filter/resource-type-filter.component.spec.ts deleted file mode 100644 index 8c57bb0b7..000000000 --- a/src/app/features/search/components/filters/resource-type-filter/resource-type-filter.component.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { MockProvider } from 'ng-mocks'; - -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideNoopAnimations } from '@angular/platform-browser/animations'; - -import { MOCK_STORE } from '@osf/shared/mocks'; - -import { ResourceFiltersSelectors } from '../../resource-filters/store'; -import { ResourceFiltersOptionsSelectors } from '../store'; - -import { ResourceTypeFilterComponent } from './resource-type-filter.component'; - -describe('ResourceTypeFilterComponent', () => { - let component: ResourceTypeFilterComponent; - let fixture: ComponentFixture; - - const mockStore = MOCK_STORE; - - const mockResourceTypes = [ - { id: '1', label: 'Article', count: 10 }, - { id: '2', label: 'Dataset', count: 5 }, - { id: '3', label: 'Preprint', count: 8 }, - ]; - - beforeEach(async () => { - mockStore.selectSignal.mockImplementation((selector) => { - if (selector === ResourceFiltersOptionsSelectors.getResourceTypes) return () => mockResourceTypes; - if (selector === ResourceFiltersSelectors.getResourceType) return () => ({ label: '', id: '' }); - return () => null; - }); - - await TestBed.configureTestingModule({ - imports: [ResourceTypeFilterComponent], - providers: [MockProvider(Store, mockStore), provideNoopAnimations()], - }).compileComponents(); - - fixture = TestBed.createComponent(ResourceTypeFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize with empty resource type', () => { - expect(component['inputText']()).toBeNull(); - }); - - it('should clear input text when store value is cleared', () => { - mockStore.selectSignal.mockImplementation((selector) => { - if (selector === ResourceFiltersSelectors.getResourceType) return () => ({ label: 'Article', id: '1' }); - return mockStore.selectSignal(selector); - }); - fixture.detectChanges(); - - mockStore.selectSignal.mockImplementation((selector) => { - if (selector === ResourceFiltersSelectors.getResourceType) return () => ({ label: '', id: '' }); - return mockStore.selectSignal(selector); - }); - fixture.detectChanges(); - - expect(component['inputText']()).toBeNull(); - }); - - it('should filter resource types based on input text', () => { - component['inputText'].set('art'); - fixture.detectChanges(); - - const options = component['resourceTypesOptions'](); - expect(options.length).toBe(1); - expect(options[0].label).toBe('Article'); - }); - - it('should show all resource types when input text is null', () => { - component['inputText'].set(null); - fixture.detectChanges(); - - const options = component['resourceTypesOptions'](); - expect(options.length).toBe(3); - expect(options.map((opt) => opt.label)).toEqual(['Article', 'Dataset', 'Preprint']); - }); - - it('should format resource type options with count', () => { - const options = component['resourceTypesOptions'](); - expect(options[0].labelCount).toBe('Article (10)'); - expect(options[1].labelCount).toBe('Dataset (5)'); - expect(options[2].labelCount).toBe('Preprint (8)'); - }); -}); diff --git a/src/app/features/search/components/filters/resource-type-filter/resource-type-filter.component.ts b/src/app/features/search/components/filters/resource-type-filter/resource-type-filter.component.ts deleted file mode 100644 index df42f6203..000000000 --- a/src/app/features/search/components/filters/resource-type-filter/resource-type-filter.component.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { Select, SelectChangeEvent } from 'primeng/select'; - -import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, untracked } from '@angular/core'; -import { FormsModule } from '@angular/forms'; - -import { ResourceFiltersSelectors, SetResourceType } from '../../resource-filters/store'; -import { GetAllOptions, ResourceFiltersOptionsSelectors } from '../store'; - -@Component({ - selector: 'osf-resource-type-filter', - imports: [Select, FormsModule], - templateUrl: './resource-type-filter.component.html', - styleUrl: './resource-type-filter.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ResourceTypeFilterComponent { - readonly #store = inject(Store); - - protected availableResourceTypes = this.#store.selectSignal(ResourceFiltersOptionsSelectors.getResourceTypes); - protected resourceTypeState = this.#store.selectSignal(ResourceFiltersSelectors.getResourceType); - protected inputText = signal(null); - protected resourceTypesOptions = computed(() => { - if (this.inputText() !== null) { - const search = this.inputText()!.toLowerCase(); - return this.availableResourceTypes() - .filter((resourceType) => resourceType.label.toLowerCase().includes(search)) - .map((resourceType) => ({ - labelCount: resourceType.label + ' (' + resourceType.count + ')', - label: resourceType.label, - id: resourceType.id, - })); - } - - return this.availableResourceTypes().map((resourceType) => ({ - labelCount: resourceType.label + ' (' + resourceType.count + ')', - label: resourceType.label, - id: resourceType.id, - })); - }); - - loading = signal(false); - - constructor() { - effect(() => { - const storeValue = this.resourceTypeState().label; - const currentInput = untracked(() => this.inputText()); - - if (!storeValue && currentInput !== null) { - this.inputText.set(null); - } else if (storeValue && currentInput !== storeValue) { - this.inputText.set(storeValue); - } - }); - } - - setResourceTypes(event: SelectChangeEvent): void { - if ((event.originalEvent as PointerEvent).pointerId && event.value) { - const resourceType = this.resourceTypesOptions().find((p) => p.label.includes(event.value)); - if (resourceType) { - this.#store.dispatch(new SetResourceType(resourceType.label, resourceType.id)); - this.#store.dispatch(GetAllOptions); - } - } else { - this.#store.dispatch(new SetResourceType('', '')); - this.#store.dispatch(GetAllOptions); - } - } -} diff --git a/src/app/features/search/components/filters/store/index.ts b/src/app/features/search/components/filters/store/index.ts deleted file mode 100644 index 321045e36..000000000 --- a/src/app/features/search/components/filters/store/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './resource-filters-options.actions'; -export * from './resource-filters-options.model'; -export * from './resource-filters-options.selectors'; -export * from './resource-filters-options.state'; diff --git a/src/app/features/search/components/filters/store/resource-filters-options.actions.ts b/src/app/features/search/components/filters/store/resource-filters-options.actions.ts deleted file mode 100644 index b538f026a..000000000 --- a/src/app/features/search/components/filters/store/resource-filters-options.actions.ts +++ /dev/null @@ -1,41 +0,0 @@ -export class GetCreatorsOptions { - static readonly type = '[Resource Filters Options] Get Creators'; - - constructor(public searchName: string) {} -} - -export class GetDatesCreatedOptions { - static readonly type = '[Resource Filters Options] Get Dates Created'; -} - -export class GetFundersOptions { - static readonly type = '[Resource Filters Options] Get Funders'; -} - -export class GetSubjectsOptions { - static readonly type = '[Resource Filters Options] Get Subjects'; -} - -export class GetLicensesOptions { - static readonly type = '[Resource Filters Options] Get Licenses'; -} - -export class GetResourceTypesOptions { - static readonly type = '[Resource Filters Options] Get Resource Types'; -} - -export class GetInstitutionsOptions { - static readonly type = '[Resource Filters Options] Get Institutions'; -} - -export class GetProvidersOptions { - static readonly type = '[Resource Filters Options] Get Providers'; -} - -export class GetPartOfCollectionOptions { - static readonly type = '[Resource Filters Options] Get Part Of Collection Options'; -} - -export class GetAllOptions { - static readonly type = '[Resource Filters Options] Get All Options'; -} diff --git a/src/app/features/search/components/filters/store/resource-filters-options.model.ts b/src/app/features/search/components/filters/store/resource-filters-options.model.ts deleted file mode 100644 index 4bd6de7fd..000000000 --- a/src/app/features/search/components/filters/store/resource-filters-options.model.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - Creator, - DateCreated, - FunderFilter, - InstitutionFilter, - LicenseFilter, - PartOfCollectionFilter, - ProviderFilter, - ResourceTypeFilter, - SubjectFilter, -} from '@osf/shared/models'; - -export interface ResourceFiltersOptionsStateModel { - creators: Creator[]; - datesCreated: DateCreated[]; - funders: FunderFilter[]; - subjects: SubjectFilter[]; - licenses: LicenseFilter[]; - resourceTypes: ResourceTypeFilter[]; - institutions: InstitutionFilter[]; - providers: ProviderFilter[]; - partOfCollection: PartOfCollectionFilter[]; -} diff --git a/src/app/features/search/components/filters/store/resource-filters-options.selectors.ts b/src/app/features/search/components/filters/store/resource-filters-options.selectors.ts deleted file mode 100644 index 0d6afd6b3..000000000 --- a/src/app/features/search/components/filters/store/resource-filters-options.selectors.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Selector } from '@ngxs/store'; - -import { - Creator, - DateCreated, - FunderFilter, - InstitutionFilter, - LicenseFilter, - PartOfCollectionFilter, - ProviderFilter, - ResourceTypeFilter, - SubjectFilter, -} from '@osf/shared/models'; - -import { ResourceFiltersOptionsStateModel } from './resource-filters-options.model'; -import { ResourceFiltersOptionsState } from './resource-filters-options.state'; - -export class ResourceFiltersOptionsSelectors { - @Selector([ResourceFiltersOptionsState]) - static getCreators(state: ResourceFiltersOptionsStateModel): Creator[] { - return state.creators; - } - - @Selector([ResourceFiltersOptionsState]) - static getDatesCreated(state: ResourceFiltersOptionsStateModel): DateCreated[] { - return state.datesCreated; - } - - @Selector([ResourceFiltersOptionsState]) - static getFunders(state: ResourceFiltersOptionsStateModel): FunderFilter[] { - return state.funders; - } - - @Selector([ResourceFiltersOptionsState]) - static getSubjects(state: ResourceFiltersOptionsStateModel): SubjectFilter[] { - return state.subjects; - } - - @Selector([ResourceFiltersOptionsState]) - static getLicenses(state: ResourceFiltersOptionsStateModel): LicenseFilter[] { - return state.licenses; - } - - @Selector([ResourceFiltersOptionsState]) - static getResourceTypes(state: ResourceFiltersOptionsStateModel): ResourceTypeFilter[] { - return state.resourceTypes; - } - - @Selector([ResourceFiltersOptionsState]) - static getInstitutions(state: ResourceFiltersOptionsStateModel): InstitutionFilter[] { - return state.institutions; - } - - @Selector([ResourceFiltersOptionsState]) - static getProviders(state: ResourceFiltersOptionsStateModel): ProviderFilter[] { - return state.providers; - } - - @Selector([ResourceFiltersOptionsState]) - static getPartOfCollection(state: ResourceFiltersOptionsStateModel): PartOfCollectionFilter[] { - return state.partOfCollection; - } - - @Selector([ResourceFiltersOptionsState]) - static getAllOptions(state: ResourceFiltersOptionsStateModel): ResourceFiltersOptionsStateModel { - return { - ...state, - }; - } -} diff --git a/src/app/features/search/components/filters/store/resource-filters-options.state.ts b/src/app/features/search/components/filters/store/resource-filters-options.state.ts deleted file mode 100644 index 5a317d3c2..000000000 --- a/src/app/features/search/components/filters/store/resource-filters-options.state.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Action, State, StateContext, Store } from '@ngxs/store'; - -import { tap } from 'rxjs'; - -import { inject, Injectable } from '@angular/core'; - -import { ResourceFiltersService } from '@osf/features/search/services'; - -import { - GetAllOptions, - GetCreatorsOptions, - GetDatesCreatedOptions, - GetFundersOptions, - GetInstitutionsOptions, - GetLicensesOptions, - GetPartOfCollectionOptions, - GetProvidersOptions, - GetResourceTypesOptions, - GetSubjectsOptions, -} from './resource-filters-options.actions'; -import { ResourceFiltersOptionsStateModel } from './resource-filters-options.model'; - -@State({ - name: 'resourceFiltersOptions', - defaults: { - creators: [], - datesCreated: [], - funders: [], - subjects: [], - licenses: [], - resourceTypes: [], - institutions: [], - providers: [], - partOfCollection: [], - }, -}) -@Injectable() -export class ResourceFiltersOptionsState { - readonly #store = inject(Store); - readonly #resourceFiltersService = inject(ResourceFiltersService); - - @Action(GetCreatorsOptions) - getProjects(ctx: StateContext, action: GetCreatorsOptions) { - if (!action.searchName) { - ctx.patchState({ creators: [] }); - return []; - } - - return this.#resourceFiltersService.getCreators(action.searchName).pipe( - tap((creators) => { - ctx.patchState({ creators: creators }); - }) - ); - } - - @Action(GetDatesCreatedOptions) - getDatesCreated(ctx: StateContext) { - return this.#resourceFiltersService.getDates().pipe( - tap((datesCreated) => { - ctx.patchState({ datesCreated: datesCreated }); - }) - ); - } - - @Action(GetFundersOptions) - getFunders(ctx: StateContext) { - return this.#resourceFiltersService.getFunders().pipe( - tap((funders) => { - ctx.patchState({ funders: funders }); - }) - ); - } - - @Action(GetSubjectsOptions) - getSubjects(ctx: StateContext) { - return this.#resourceFiltersService.getSubjects().pipe( - tap((subjects) => { - ctx.patchState({ subjects: subjects }); - }) - ); - } - - @Action(GetLicensesOptions) - getLicenses(ctx: StateContext) { - return this.#resourceFiltersService.getLicenses().pipe( - tap((licenses) => { - ctx.patchState({ licenses: licenses }); - }) - ); - } - - @Action(GetResourceTypesOptions) - getResourceTypes(ctx: StateContext) { - return this.#resourceFiltersService.getResourceTypes().pipe( - tap((resourceTypes) => { - ctx.patchState({ resourceTypes: resourceTypes }); - }) - ); - } - - @Action(GetInstitutionsOptions) - getInstitutions(ctx: StateContext) { - return this.#resourceFiltersService.getInstitutions().pipe( - tap((institutions) => { - ctx.patchState({ institutions: institutions }); - }) - ); - } - - @Action(GetProvidersOptions) - getProviders(ctx: StateContext) { - return this.#resourceFiltersService.getProviders().pipe( - tap((providers) => { - ctx.patchState({ providers: providers }); - }) - ); - } - @Action(GetPartOfCollectionOptions) - getPartOfCollection(ctx: StateContext) { - return this.#resourceFiltersService.getPartOtCollections().pipe( - tap((partOfCollection) => { - ctx.patchState({ partOfCollection: partOfCollection }); - }) - ); - } - - @Action(GetAllOptions) - getAllOptions() { - this.#store.dispatch(GetDatesCreatedOptions); - this.#store.dispatch(GetFundersOptions); - this.#store.dispatch(GetSubjectsOptions); - this.#store.dispatch(GetLicensesOptions); - this.#store.dispatch(GetResourceTypesOptions); - this.#store.dispatch(GetInstitutionsOptions); - this.#store.dispatch(GetProvidersOptions); - this.#store.dispatch(GetPartOfCollectionOptions); - } -} diff --git a/src/app/features/search/components/filters/subject/subject-filter.component.html b/src/app/features/search/components/filters/subject/subject-filter.component.html deleted file mode 100644 index a9f0a9f3e..000000000 --- a/src/app/features/search/components/filters/subject/subject-filter.component.html +++ /dev/null @@ -1,17 +0,0 @@ -
-

Please select the subject from the dropdown below or start typing for find it

- -
diff --git a/src/app/features/search/components/filters/subject/subject-filter.component.spec.ts b/src/app/features/search/components/filters/subject/subject-filter.component.spec.ts deleted file mode 100644 index 288a67e1c..000000000 --- a/src/app/features/search/components/filters/subject/subject-filter.component.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { MockProvider } from 'ng-mocks'; - -import { of } from 'rxjs'; - -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ResourceFiltersSelectors } from '../../resource-filters/store'; -import { ResourceFiltersOptionsSelectors } from '../store'; - -import { SubjectFilterComponent } from './subject-filter.component'; - -describe('SubjectFilterComponent', () => { - let component: SubjectFilterComponent; - let fixture: ComponentFixture; - - const mockSubjects = [ - { id: '1', label: 'Physics', count: 10 }, - { id: '2', label: 'Chemistry', count: 15 }, - { id: '3', label: 'Biology', count: 20 }, - ]; - - const mockStore = { - selectSignal: jest.fn().mockImplementation((selector) => { - if (selector === ResourceFiltersOptionsSelectors.getSubjects) { - return () => mockSubjects; - } - if (selector === ResourceFiltersSelectors.getSubject) { - return () => ({ label: '', id: '' }); - } - return () => null; - }), - dispatch: jest.fn().mockReturnValue(of({})), - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SubjectFilterComponent], - providers: [MockProvider(Store, mockStore)], - }).compileComponents(); - - fixture = TestBed.createComponent(SubjectFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create and initialize with subjects', () => { - expect(component).toBeTruthy(); - expect(component['availableSubjects']()).toEqual(mockSubjects); - expect(component['subjectsOptions']().length).toBe(3); - expect(component['subjectsOptions']()[0].labelCount).toBe('Physics (10)'); - }); -}); diff --git a/src/app/features/search/components/filters/subject/subject-filter.component.ts b/src/app/features/search/components/filters/subject/subject-filter.component.ts deleted file mode 100644 index b4bec488a..000000000 --- a/src/app/features/search/components/filters/subject/subject-filter.component.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { Select, SelectChangeEvent } from 'primeng/select'; - -import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, untracked } from '@angular/core'; -import { FormsModule } from '@angular/forms'; - -import { ResourceFiltersSelectors, SetSubject } from '../../resource-filters/store'; -import { GetAllOptions, ResourceFiltersOptionsSelectors } from '../store'; - -@Component({ - selector: 'osf-subject-filter', - imports: [Select, FormsModule], - templateUrl: './subject-filter.component.html', - styleUrl: './subject-filter.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class SubjectFilterComponent { - readonly #store = inject(Store); - - protected availableSubjects = this.#store.selectSignal(ResourceFiltersOptionsSelectors.getSubjects); - protected subjectState = this.#store.selectSignal(ResourceFiltersSelectors.getSubject); - protected inputText = signal(null); - protected subjectsOptions = computed(() => { - if (this.inputText() !== null) { - const search = this.inputText()!.toLowerCase(); - return this.availableSubjects() - .filter((subject) => subject.label.toLowerCase().includes(search)) - .map((subject) => ({ - labelCount: subject.label + ' (' + subject.count + ')', - label: subject.label, - id: subject.id, - })); - } - - return this.availableSubjects().map((subject) => ({ - labelCount: subject.label + ' (' + subject.count + ')', - label: subject.label, - id: subject.id, - })); - }); - - loading = signal(false); - - constructor() { - effect(() => { - const storeValue = this.subjectState().label; - const currentInput = untracked(() => this.inputText()); - - if (!storeValue && currentInput !== null) { - this.inputText.set(null); - } else if (storeValue && currentInput !== storeValue) { - this.inputText.set(storeValue); - } - }); - } - - setSubject(event: SelectChangeEvent): void { - if ((event.originalEvent as PointerEvent).pointerId && event.value) { - const subject = this.subjectsOptions().find((p) => p.label.includes(event.value)); - if (subject) { - this.#store.dispatch(new SetSubject(subject.label, subject.id)); - this.#store.dispatch(GetAllOptions); - } - } else { - this.#store.dispatch(new SetSubject('', '')); - this.#store.dispatch(GetAllOptions); - } - } -} diff --git a/src/app/features/search/components/index.ts b/src/app/features/search/components/index.ts deleted file mode 100644 index fa4051313..000000000 --- a/src/app/features/search/components/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { FilterChipsComponent } from './filter-chips/filter-chips.component'; -export * from './filters'; -export { ResourceFiltersComponent } from './resource-filters/resource-filters.component'; -export { ResourcesComponent } from './resources/resources.component'; -export { ResourcesWrapperComponent } from './resources-wrapper/resources-wrapper.component'; diff --git a/src/app/features/search/components/resource-filters/resource-filters.component.html b/src/app/features/search/components/resource-filters/resource-filters.component.html deleted file mode 100644 index 59d2586c2..000000000 --- a/src/app/features/search/components/resource-filters/resource-filters.component.html +++ /dev/null @@ -1,86 +0,0 @@ -@if (anyOptionsCount()) { -
- - @if (!isMyProfilePage()) { - - Creator - - - - - } - - @if (datesOptionsCount() > 0) { - - Date Created - - - - - } - - @if (funderOptionsCount() > 0) { - - Funder - - - - - } - - @if (subjectOptionsCount() > 0) { - - Subject - - - - - } - - @if (licenseOptionsCount() > 0) { - - License - - - - - } - - @if (resourceTypeOptionsCount() > 0) { - - Resource Type - - - - - } - - @if (institutionOptionsCount() > 0) { - - Institution - - - - - } - - @if (providerOptionsCount() > 0) { - - Provider - - - - - } - - @if (partOfCollectionOptionsCount() > 0) { - - Part of Collection - - - - - } - -
-} diff --git a/src/app/features/search/components/resource-filters/resource-filters.component.scss b/src/app/features/search/components/resource-filters/resource-filters.component.scss deleted file mode 100644 index 4e0e3b708..000000000 --- a/src/app/features/search/components/resource-filters/resource-filters.component.scss +++ /dev/null @@ -1,15 +0,0 @@ -@use "styles/variables" as var; - -:host { - width: 30%; -} - -.filters { - border: 1px solid var.$grey-2; - border-radius: 12px; - padding: 0 1.7rem 0 1.7rem; - display: flex; - flex-direction: column; - row-gap: 0.8rem; - height: fit-content; -} diff --git a/src/app/features/search/components/resource-filters/resource-filters.component.spec.ts b/src/app/features/search/components/resource-filters/resource-filters.component.spec.ts deleted file mode 100644 index 6780d5d16..000000000 --- a/src/app/features/search/components/resource-filters/resource-filters.component.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { MockComponents, MockProvider } from 'ng-mocks'; - -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideNoopAnimations } from '@angular/platform-browser/animations'; - -import { SearchSelectors } from '../../store'; -import { - CreatorsFilterComponent, - DateCreatedFilterComponent, - FunderFilterComponent, - InstitutionFilterComponent, - LicenseFilterComponent, - PartOfCollectionFilterComponent, - ProviderFilterComponent, - ResourceTypeFilterComponent, - SubjectFilterComponent, -} from '../filters'; -import { ResourceFiltersOptionsSelectors } from '../filters/store'; - -import { ResourceFiltersComponent } from './resource-filters.component'; - -describe('MyProfileResourceFiltersComponent', () => { - let component: ResourceFiltersComponent; - let fixture: ComponentFixture; - - const mockStore = { - selectSignal: jest.fn().mockImplementation((selector) => { - if (selector === ResourceFiltersOptionsSelectors.getDatesCreated) return () => []; - if (selector === ResourceFiltersOptionsSelectors.getFunders) return () => []; - if (selector === ResourceFiltersOptionsSelectors.getSubjects) return () => []; - if (selector === ResourceFiltersOptionsSelectors.getLicenses) return () => []; - if (selector === ResourceFiltersOptionsSelectors.getResourceTypes) return () => []; - if (selector === ResourceFiltersOptionsSelectors.getInstitutions) return () => []; - if (selector === ResourceFiltersOptionsSelectors.getProviders) return () => []; - if (selector === ResourceFiltersOptionsSelectors.getPartOfCollection) return () => []; - if (selector === SearchSelectors.getIsMyProfile) return () => false; - return () => null; - }), - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - ResourceFiltersComponent, - ...MockComponents( - CreatorsFilterComponent, - DateCreatedFilterComponent, - SubjectFilterComponent, - FunderFilterComponent, - LicenseFilterComponent, - ResourceTypeFilterComponent, - ProviderFilterComponent, - PartOfCollectionFilterComponent, - InstitutionFilterComponent - ), - ], - providers: [MockProvider(Store, mockStore), provideNoopAnimations()], - }).compileComponents(); - - fixture = TestBed.createComponent(ResourceFiltersComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/features/search/components/resource-filters/resource-filters.component.ts b/src/app/features/search/components/resource-filters/resource-filters.component.ts deleted file mode 100644 index f69912822..000000000 --- a/src/app/features/search/components/resource-filters/resource-filters.component.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { Accordion, AccordionContent, AccordionHeader, AccordionPanel } from 'primeng/accordion'; - -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; -import { ReactiveFormsModule } from '@angular/forms'; - -import { SearchSelectors } from '../../store'; -import { - CreatorsFilterComponent, - DateCreatedFilterComponent, - FunderFilterComponent, - InstitutionFilterComponent, - LicenseFilterComponent, - PartOfCollectionFilterComponent, - ProviderFilterComponent, - ResourceTypeFilterComponent, - SubjectFilterComponent, -} from '../filters'; -import { ResourceFiltersOptionsSelectors } from '../filters/store'; - -@Component({ - selector: 'osf-resource-filters', - imports: [ - Accordion, - AccordionContent, - AccordionHeader, - AccordionPanel, - ReactiveFormsModule, - CreatorsFilterComponent, - DateCreatedFilterComponent, - SubjectFilterComponent, - FunderFilterComponent, - LicenseFilterComponent, - ResourceTypeFilterComponent, - ProviderFilterComponent, - PartOfCollectionFilterComponent, - InstitutionFilterComponent, - ], - templateUrl: './resource-filters.component.html', - styleUrl: './resource-filters.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ResourceFiltersComponent { - readonly store = inject(Store); - - readonly datesOptionsCount = computed(() => { - return this.store - .selectSignal(ResourceFiltersOptionsSelectors.getDatesCreated)() - .reduce((accumulator, date) => accumulator + date.count, 0); - }); - - readonly funderOptionsCount = computed(() => - this.store - .selectSignal(ResourceFiltersOptionsSelectors.getFunders)() - .reduce((acc, item) => acc + item.count, 0) - ); - - readonly subjectOptionsCount = computed(() => - this.store - .selectSignal(ResourceFiltersOptionsSelectors.getSubjects)() - .reduce((acc, item) => acc + item.count, 0) - ); - - readonly licenseOptionsCount = computed(() => - this.store - .selectSignal(ResourceFiltersOptionsSelectors.getLicenses)() - .reduce((acc, item) => acc + item.count, 0) - ); - - readonly resourceTypeOptionsCount = computed(() => - this.store - .selectSignal(ResourceFiltersOptionsSelectors.getResourceTypes)() - .reduce((acc, item) => acc + item.count, 0) - ); - - readonly institutionOptionsCount = computed(() => - this.store - .selectSignal(ResourceFiltersOptionsSelectors.getInstitutions)() - .reduce((acc, item) => acc + item.count, 0) - ); - - readonly providerOptionsCount = computed(() => - this.store - .selectSignal(ResourceFiltersOptionsSelectors.getProviders)() - .reduce((acc, item) => acc + item.count, 0) - ); - - readonly partOfCollectionOptionsCount = computed(() => - this.store - .selectSignal(ResourceFiltersOptionsSelectors.getPartOfCollection)() - .reduce((acc, item) => acc + item.count, 0) - ); - - readonly isMyProfilePage = this.store.selectSignal(SearchSelectors.getIsMyProfile); - - readonly anyOptionsCount = computed(() => { - return ( - this.datesOptionsCount() > 0 || - this.funderOptionsCount() > 0 || - this.subjectOptionsCount() > 0 || - this.licenseOptionsCount() > 0 || - this.resourceTypeOptionsCount() > 0 || - this.institutionOptionsCount() > 0 || - this.providerOptionsCount() > 0 || - this.partOfCollectionOptionsCount() > 0 || - !this.isMyProfilePage() - ); - }); -} diff --git a/src/app/features/search/components/resource-filters/store/index.ts b/src/app/features/search/components/resource-filters/store/index.ts deleted file mode 100644 index 0bbc2ed4b..000000000 --- a/src/app/features/search/components/resource-filters/store/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './resource-filters.actions'; -export * from './resource-filters.model'; -export * from './resource-filters.selectors'; -export * from './resource-filters.state'; diff --git a/src/app/features/search/components/resource-filters/store/resource-filters.actions.ts b/src/app/features/search/components/resource-filters/store/resource-filters.actions.ts deleted file mode 100644 index b97d653ed..000000000 --- a/src/app/features/search/components/resource-filters/store/resource-filters.actions.ts +++ /dev/null @@ -1,72 +0,0 @@ -export class SetCreator { - static readonly type = '[Resource Filters] Set Creator'; - constructor( - public name: string, - public id: string - ) {} -} - -export class SetDateCreated { - static readonly type = '[Resource Filters] Set DateCreated'; - constructor(public date: string) {} -} - -export class SetFunder { - static readonly type = '[Resource Filters] Set Funder'; - constructor( - public funder: string, - public id: string - ) {} -} - -export class SetSubject { - static readonly type = '[Resource Filters] Set Subject'; - constructor( - public subject: string, - public id: string - ) {} -} - -export class SetLicense { - static readonly type = '[Resource Filters] Set License'; - constructor( - public license: string, - public id: string - ) {} -} - -export class SetResourceType { - static readonly type = '[Resource Filters] Set Resource Type'; - constructor( - public resourceType: string, - public id: string - ) {} -} - -export class SetInstitution { - static readonly type = '[Resource Filters] Set Institution'; - constructor( - public institution: string, - public id: string - ) {} -} - -export class SetProvider { - static readonly type = '[Resource Filters] Set Provider'; - constructor( - public provider: string, - public id: string - ) {} -} - -export class SetPartOfCollection { - static readonly type = '[Resource Filters] Set PartOfCollection'; - constructor( - public partOfCollection: string, - public id: string - ) {} -} - -export class ResetFiltersState { - static readonly type = '[Resource Filters] Reset State'; -} diff --git a/src/app/features/search/components/resource-filters/store/resource-filters.model.ts b/src/app/features/search/components/resource-filters/store/resource-filters.model.ts deleted file mode 100644 index c58b9fba6..000000000 --- a/src/app/features/search/components/resource-filters/store/resource-filters.model.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ResourceFilterLabel } from '@osf/shared/models'; - -export interface ResourceFiltersStateModel { - creator: ResourceFilterLabel; - dateCreated: ResourceFilterLabel; - funder: ResourceFilterLabel; - subject: ResourceFilterLabel; - license: ResourceFilterLabel; - resourceType: ResourceFilterLabel; - institution: ResourceFilterLabel; - provider: ResourceFilterLabel; - partOfCollection: ResourceFilterLabel; -} diff --git a/src/app/features/search/components/resource-filters/store/resource-filters.selectors.ts b/src/app/features/search/components/resource-filters/store/resource-filters.selectors.ts deleted file mode 100644 index 2055b759d..000000000 --- a/src/app/features/search/components/resource-filters/store/resource-filters.selectors.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Selector } from '@ngxs/store'; - -import { ResourceFilterLabel } from '@shared/models'; - -import { ResourceFiltersStateModel } from './resource-filters.model'; -import { ResourceFiltersState } from './resource-filters.state'; - -export class ResourceFiltersSelectors { - @Selector([ResourceFiltersState]) - static getAllFilters(state: ResourceFiltersStateModel): ResourceFiltersStateModel { - return { - ...state, - }; - } - - @Selector([ResourceFiltersState]) - static getCreator(state: ResourceFiltersStateModel): ResourceFilterLabel { - return state.creator; - } - - @Selector([ResourceFiltersState]) - static getDateCreated(state: ResourceFiltersStateModel): ResourceFilterLabel { - return state.dateCreated; - } - - @Selector([ResourceFiltersState]) - static getFunder(state: ResourceFiltersStateModel): ResourceFilterLabel { - return state.funder; - } - - @Selector([ResourceFiltersState]) - static getSubject(state: ResourceFiltersStateModel): ResourceFilterLabel { - return state.subject; - } - - @Selector([ResourceFiltersState]) - static getLicense(state: ResourceFiltersStateModel): ResourceFilterLabel { - return state.license; - } - - @Selector([ResourceFiltersState]) - static getResourceType(state: ResourceFiltersStateModel): ResourceFilterLabel { - return state.resourceType; - } - - @Selector([ResourceFiltersState]) - static getInstitution(state: ResourceFiltersStateModel): ResourceFilterLabel { - return state.institution; - } - - @Selector([ResourceFiltersState]) - static getProvider(state: ResourceFiltersStateModel): ResourceFilterLabel { - return state.provider; - } - - @Selector([ResourceFiltersState]) - static getPartOfCollection(state: ResourceFiltersStateModel): ResourceFilterLabel { - return state.partOfCollection; - } -} diff --git a/src/app/features/search/components/resource-filters/store/resource-filters.state.ts b/src/app/features/search/components/resource-filters/store/resource-filters.state.ts deleted file mode 100644 index fecc78655..000000000 --- a/src/app/features/search/components/resource-filters/store/resource-filters.state.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Action, State, StateContext } from '@ngxs/store'; - -import { Injectable } from '@angular/core'; - -import { FilterLabelsModel } from '@osf/shared/models'; -import { resourceFiltersDefaults } from '@shared/constants'; - -import { - ResetFiltersState, - SetCreator, - SetDateCreated, - SetFunder, - SetInstitution, - SetLicense, - SetPartOfCollection, - SetProvider, - SetResourceType, - SetSubject, -} from './resource-filters.actions'; -import { ResourceFiltersStateModel } from './resource-filters.model'; - -@State({ - name: 'resourceFilters', - defaults: resourceFiltersDefaults, -}) -@Injectable() -export class ResourceFiltersState { - @Action(SetCreator) - setCreator(ctx: StateContext, action: SetCreator) { - ctx.patchState({ - creator: { - filterName: FilterLabelsModel.creator, - label: action.name, - value: action.id, - }, - }); - } - - @Action(SetDateCreated) - setDateCreated(ctx: StateContext, action: SetDateCreated) { - ctx.patchState({ - dateCreated: { - filterName: FilterLabelsModel.dateCreated, - label: action.date, - value: action.date, - }, - }); - } - - @Action(SetFunder) - setFunder(ctx: StateContext, action: SetFunder) { - ctx.patchState({ - funder: { - filterName: FilterLabelsModel.funder, - label: action.funder, - value: action.id, - }, - }); - } - - @Action(SetSubject) - setSubject(ctx: StateContext, action: SetSubject) { - ctx.patchState({ - subject: { - filterName: FilterLabelsModel.subject, - label: action.subject, - value: action.id, - }, - }); - } - - @Action(SetLicense) - setLicense(ctx: StateContext, action: SetLicense) { - ctx.patchState({ - license: { - filterName: FilterLabelsModel.license, - label: action.license, - value: action.id, - }, - }); - } - - @Action(SetResourceType) - setResourceType(ctx: StateContext, action: SetResourceType) { - ctx.patchState({ - resourceType: { - filterName: FilterLabelsModel.resourceType, - label: action.resourceType, - value: action.id, - }, - }); - } - - @Action(SetInstitution) - setInstitution(ctx: StateContext, action: SetInstitution) { - ctx.patchState({ - institution: { - filterName: FilterLabelsModel.institution, - label: action.institution, - value: action.id, - }, - }); - } - - @Action(SetProvider) - setProvider(ctx: StateContext, action: SetProvider) { - ctx.patchState({ - provider: { - filterName: FilterLabelsModel.provider, - label: action.provider, - value: action.id, - }, - }); - } - - @Action(SetPartOfCollection) - setPartOfCollection(ctx: StateContext, action: SetPartOfCollection) { - ctx.patchState({ - partOfCollection: { - filterName: FilterLabelsModel.partOfCollection, - label: action.partOfCollection, - value: action.id, - }, - }); - } - - @Action(ResetFiltersState) - resetState(ctx: StateContext) { - ctx.patchState(resourceFiltersDefaults); - } -} diff --git a/src/app/features/search/components/resources-wrapper/resources-wrapper.component.html b/src/app/features/search/components/resources-wrapper/resources-wrapper.component.html deleted file mode 100644 index 20b02cc4c..000000000 --- a/src/app/features/search/components/resources-wrapper/resources-wrapper.component.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/app/features/search/components/resources-wrapper/resources-wrapper.component.spec.ts b/src/app/features/search/components/resources-wrapper/resources-wrapper.component.spec.ts deleted file mode 100644 index 247f9e9b3..000000000 --- a/src/app/features/search/components/resources-wrapper/resources-wrapper.component.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { MockComponent, MockProvider } from 'ng-mocks'; - -import { of } from 'rxjs'; - -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; - -import { ResourcesComponent } from '@osf/features/search/components'; -import { ResourceTab } from '@osf/shared/enums'; -import { MOCK_STORE } from '@osf/shared/mocks'; - -import { SearchSelectors } from '../../store'; -import { GetAllOptions } from '../filters/store'; -import { ResourceFiltersSelectors } from '../resource-filters/store'; - -import { ResourcesWrapperComponent } from './resources-wrapper.component'; - -describe.skip('ResourcesWrapperComponent', () => { - let component: ResourcesWrapperComponent; - let fixture: ComponentFixture; - let store: jest.Mocked; - - const mockStore = MOCK_STORE; - - const mockRouter = { - navigate: jest.fn(), - }; - - const mockRoute = { - queryParamMap: of({ - get: jest.fn(), - }), - snapshot: { - queryParams: {}, - queryParamMap: { - get: jest.fn(), - }, - }, - }; - - beforeEach(async () => { - mockStore.selectSignal.mockImplementation((selector) => { - if (selector === ResourceFiltersSelectors.getCreator) return () => null; - if (selector === ResourceFiltersSelectors.getDateCreated) return () => null; - if (selector === ResourceFiltersSelectors.getFunder) return () => null; - if (selector === ResourceFiltersSelectors.getSubject) return () => null; - if (selector === ResourceFiltersSelectors.getLicense) return () => null; - if (selector === ResourceFiltersSelectors.getResourceType) return () => null; - if (selector === ResourceFiltersSelectors.getInstitution) return () => null; - if (selector === ResourceFiltersSelectors.getProvider) return () => null; - if (selector === ResourceFiltersSelectors.getPartOfCollection) return () => null; - if (selector === SearchSelectors.getSortBy) return () => '-relevance'; - if (selector === SearchSelectors.getSearchText) return () => ''; - if (selector === SearchSelectors.getResourceTab) return () => ResourceTab.All; - if (selector === SearchSelectors.getIsMyProfile) return () => false; - return () => null; - }); - - await TestBed.configureTestingModule({ - imports: [ResourcesWrapperComponent, MockComponent(ResourcesComponent)], - providers: [ - { provide: ActivatedRoute, useValue: mockRoute }, - MockProvider(Store, mockStore), - MockProvider(Router, mockRouter), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(ResourcesWrapperComponent); - component = fixture.componentInstance; - store = TestBed.inject(Store) as jest.Mocked; - fixture.detectChanges(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize with empty query params', () => { - expect(store.dispatch).toHaveBeenCalledWith(GetAllOptions); - }); -}); diff --git a/src/app/features/search/components/resources-wrapper/resources-wrapper.component.ts b/src/app/features/search/components/resources-wrapper/resources-wrapper.component.ts deleted file mode 100644 index 25876672a..000000000 --- a/src/app/features/search/components/resources-wrapper/resources-wrapper.component.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { select, Store } from '@ngxs/store'; - -import { take } from 'rxjs'; - -import { ChangeDetectionStrategy, Component, effect, inject, OnInit } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; - -import { ResourcesComponent } from '@osf/features/search/components'; -import { ResourceTab } from '@osf/shared/enums'; -import { FilterLabelsModel, ResourceFilterLabel } from '@osf/shared/models'; - -import { SearchSelectors, SetResourceTab, SetSearchText, SetSortBy } from '../../store'; -import { GetAllOptions } from '../filters/store'; -import { - ResourceFiltersSelectors, - SetCreator, - SetDateCreated, - SetFunder, - SetInstitution, - SetLicense, - SetPartOfCollection, - SetProvider, - SetResourceType, - SetSubject, -} from '../resource-filters/store'; - -@Component({ - selector: 'osf-resources-wrapper', - imports: [ResourcesComponent], - templateUrl: './resources-wrapper.component.html', - styleUrl: './resources-wrapper.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ResourcesWrapperComponent implements OnInit { - readonly store = inject(Store); - readonly activeRoute = inject(ActivatedRoute); - readonly router = inject(Router); - - creatorSelected = select(ResourceFiltersSelectors.getCreator); - dateCreatedSelected = select(ResourceFiltersSelectors.getDateCreated); - funderSelected = select(ResourceFiltersSelectors.getFunder); - subjectSelected = select(ResourceFiltersSelectors.getSubject); - licenseSelected = select(ResourceFiltersSelectors.getLicense); - resourceTypeSelected = select(ResourceFiltersSelectors.getResourceType); - institutionSelected = select(ResourceFiltersSelectors.getInstitution); - providerSelected = select(ResourceFiltersSelectors.getProvider); - partOfCollectionSelected = select(ResourceFiltersSelectors.getPartOfCollection); - sortSelected = select(SearchSelectors.getSortBy); - searchInput = select(SearchSelectors.getSearchText); - resourceTabSelected = select(SearchSelectors.getResourceTab); - isMyProfilePage = select(SearchSelectors.getIsMyProfile); - - constructor() { - effect(() => this.syncFilterToQuery('Creator', this.creatorSelected())); - effect(() => this.syncFilterToQuery('DateCreated', this.dateCreatedSelected())); - effect(() => this.syncFilterToQuery('Funder', this.funderSelected())); - effect(() => this.syncFilterToQuery('Subject', this.subjectSelected())); - effect(() => this.syncFilterToQuery('License', this.licenseSelected())); - effect(() => this.syncFilterToQuery('ResourceType', this.resourceTypeSelected())); - effect(() => this.syncFilterToQuery('Institution', this.institutionSelected())); - effect(() => this.syncFilterToQuery('Provider', this.providerSelected())); - effect(() => this.syncFilterToQuery('PartOfCollection', this.partOfCollectionSelected())); - effect(() => this.syncSortingToQuery(this.sortSelected())); - effect(() => this.syncSearchToQuery(this.searchInput())); - effect(() => this.syncResourceTabToQuery(this.resourceTabSelected())); - } - - ngOnInit() { - this.activeRoute.queryParamMap.pipe(take(1)).subscribe((params) => { - const activeFilters = params.get('activeFilters'); - const filters = activeFilters ? JSON.parse(activeFilters) : []; - const sortBy = params.get('sortBy'); - const search = params.get('search'); - const resourceTab = params.get('resourceTab'); - - const creator = filters.find((p: ResourceFilterLabel) => p.filterName === FilterLabelsModel.creator); - const dateCreated = filters.find((p: ResourceFilterLabel) => p.filterName === 'DateCreated'); - const funder = filters.find((p: ResourceFilterLabel) => p.filterName === FilterLabelsModel.funder); - const subject = filters.find((p: ResourceFilterLabel) => p.filterName === FilterLabelsModel.subject); - const license = filters.find((p: ResourceFilterLabel) => p.filterName === FilterLabelsModel.license); - const resourceType = filters.find((p: ResourceFilterLabel) => p.filterName === 'ResourceType'); - const institution = filters.find((p: ResourceFilterLabel) => p.filterName === FilterLabelsModel.institution); - const provider = filters.find((p: ResourceFilterLabel) => p.filterName === FilterLabelsModel.provider); - const partOfCollection = filters.find((p: ResourceFilterLabel) => p.filterName === 'PartOfCollection'); - - if (creator) { - this.store.dispatch(new SetCreator(creator.label, creator.value)); - } - if (dateCreated) { - this.store.dispatch(new SetDateCreated(dateCreated.value)); - } - if (funder) { - this.store.dispatch(new SetFunder(funder.label, funder.value)); - } - if (subject) { - this.store.dispatch(new SetSubject(subject.label, subject.value)); - } - if (license) { - this.store.dispatch(new SetLicense(license.label, license.value)); - } - if (resourceType) { - this.store.dispatch(new SetResourceType(resourceType.label, resourceType.value)); - } - if (institution) { - this.store.dispatch(new SetInstitution(institution.label, institution.value)); - } - if (provider) { - this.store.dispatch(new SetProvider(provider.label, provider.value)); - } - if (partOfCollection) { - this.store.dispatch(new SetPartOfCollection(partOfCollection.label, partOfCollection.value)); - } - - if (sortBy) { - this.store.dispatch(new SetSortBy(sortBy)); - } - if (search) { - this.store.dispatch(new SetSearchText(search)); - } - if (resourceTab) { - this.store.dispatch(new SetResourceTab(+resourceTab)); - } - - this.store.dispatch(GetAllOptions); - }); - } - - syncFilterToQuery(filterName: string, filterValue: ResourceFilterLabel) { - if (this.isMyProfilePage()) { - return; - } - const paramMap = this.activeRoute.snapshot.queryParamMap; - const currentParams = { ...this.activeRoute.snapshot.queryParams }; - - const currentFiltersRaw = paramMap.get('activeFilters'); - - let filters: ResourceFilterLabel[] = []; - - try { - filters = currentFiltersRaw ? (JSON.parse(currentFiltersRaw) as ResourceFilterLabel[]) : []; - } catch (e) { - console.error('Invalid activeFilters format in query params', e); - } - - const index = filters.findIndex((f) => f.filterName === filterName); - - const hasValue = !!filterValue?.value; - - if (!hasValue && index !== -1) { - filters.splice(index, 1); - } else if (hasValue && filterValue?.label && filterValue.value) { - const newFilter = { - filterName, - label: filterValue.label, - value: filterValue.value, - }; - - if (index !== -1) { - filters[index] = newFilter; - } else { - filters.push(newFilter); - } - } - - if (filters.length > 0) { - currentParams['activeFilters'] = JSON.stringify(filters); - } else { - delete currentParams['activeFilters']; - } - - this.router.navigate([], { - relativeTo: this.activeRoute, - queryParams: currentParams, - replaceUrl: true, - }); - } - - syncSortingToQuery(sortBy: string) { - if (this.isMyProfilePage()) { - return; - } - const currentParams = { ...this.activeRoute.snapshot.queryParams }; - - if (sortBy && sortBy !== '-relevance') { - currentParams['sortBy'] = sortBy; - } else if (sortBy && sortBy === '-relevance') { - delete currentParams['sortBy']; - } - - this.router.navigate([], { - relativeTo: this.activeRoute, - queryParams: currentParams, - replaceUrl: true, - }); - } - - syncSearchToQuery(search: string) { - if (this.isMyProfilePage()) { - return; - } - const currentParams = { ...this.activeRoute.snapshot.queryParams }; - - if (search) { - currentParams['search'] = search; - } else { - delete currentParams['search']; - } - - this.router.navigate([], { - relativeTo: this.activeRoute, - queryParams: currentParams, - replaceUrl: true, - }); - } - - syncResourceTabToQuery(resourceTab: ResourceTab) { - if (this.isMyProfilePage()) { - return; - } - const currentParams = { ...this.activeRoute.snapshot.queryParams }; - - if (resourceTab) { - currentParams['resourceTab'] = resourceTab; - } else { - delete currentParams['resourceTab']; - } - - this.router.navigate([], { - relativeTo: this.activeRoute, - queryParams: currentParams, - replaceUrl: true, - }); - } -} diff --git a/src/app/features/search/components/resources/resources.component.html b/src/app/features/search/components/resources/resources.component.html deleted file mode 100644 index 0b804389c..000000000 --- a/src/app/features/search/components/resources/resources.component.html +++ /dev/null @@ -1,104 +0,0 @@ -
-
- @if (isMobile()) { - - } - - @if (searchCount() > 10000) { -

{{ 'collections.searchResults.10000results' | translate }}

- } @else if (searchCount() > 0) { -

{{ searchCount() }} {{ 'collections.searchResults.results' | translate }}

- } @else { -

{{ 'collections.searchResults.noResults' | translate }}

- } -
- -
- @if (isWeb()) { -

{{ 'collections.filters.sortBy' | translate }}:

- - - } @else { - @if (isAnyFilterOptions()) { - - } - - - } -
-
- -@if (isFiltersOpen()) { -
- -
-} @else if (isSortingOpen()) { -
- @for (option of searchSortingOptions; track option.value) { -
- {{ option.label }} -
- } -
-} @else { - @if (isAnyFilterSelected()) { -
- -
- } - -
- @if (isWeb() && isAnyFilterOptions()) { - - } - - - -
- @if (items.length > 0) { - @for (item of items; track item.id) { - - } - -
- @if (first() && prev()) { - - } - - - - - - -
- } -
-
-
-
-} diff --git a/src/app/features/search/components/resources/resources.component.scss b/src/app/features/search/components/resources/resources.component.scss deleted file mode 100644 index ebf1f863e..000000000 --- a/src/app/features/search/components/resources/resources.component.scss +++ /dev/null @@ -1,65 +0,0 @@ -@use "styles/variables" as var; - -h3 { - color: var.$pr-blue-1; -} - -.sorting-container { - display: flex; - align-items: center; - - h3 { - color: var.$dark-blue-1; - font-weight: 400; - text-wrap: nowrap; - margin-right: 0.5rem; - } -} - -.filter-full-size { - flex: 1; -} - -.sort-card { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 44px; - border: 1px solid var.$grey-2; - border-radius: 12px; - padding: 0 1.7rem 0 1.7rem; - cursor: pointer; -} - -.card-selected { - background: var.$bg-blue-2; -} - -.filters-resources-web { - .resources-container { - flex: 1; - - .resources-list { - width: 100%; - display: flex; - flex-direction: column; - row-gap: 0.85rem; - } - - .switch-icon { - &:hover { - cursor: pointer; - } - } - - .icon-disabled { - opacity: 0.5; - cursor: none; - } - - .icon-active { - fill: var.$grey-1; - } - } -} diff --git a/src/app/features/search/components/resources/resources.component.spec.ts b/src/app/features/search/components/resources/resources.component.spec.ts deleted file mode 100644 index 2a0fd0632..000000000 --- a/src/app/features/search/components/resources/resources.component.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { MockComponents, MockProvider } from 'ng-mocks'; - -import { BehaviorSubject } from 'rxjs'; - -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { FilterChipsComponent, ResourceFiltersComponent } from '@osf/features/search/components'; -import { ResourceTab } from '@osf/shared/enums'; -import { IS_WEB, IS_XSMALL } from '@osf/shared/helpers'; -import { MOCK_STORE } from '@osf/shared/mocks'; -import { ResourceCardComponent } from '@shared/components/resource-card/resource-card.component'; - -import { GetResourcesByLink, SearchSelectors } from '../../store'; -import { ResourceFiltersOptionsSelectors } from '../filters/store'; -import { ResourceFiltersSelectors } from '../resource-filters/store'; - -import { ResourcesComponent } from './resources.component'; - -describe.skip('ResourcesComponent', () => { - let component: ResourcesComponent; - let fixture: ComponentFixture; - let store: jest.Mocked; - let isWebSubject: BehaviorSubject; - let isMobileSubject: BehaviorSubject; - - const mockStore = MOCK_STORE; - - beforeEach(async () => { - isWebSubject = new BehaviorSubject(true); - isMobileSubject = new BehaviorSubject(false); - - mockStore.selectSignal.mockImplementation((selector) => { - if (selector === SearchSelectors.getResourceTab) return () => ResourceTab.All; - if (selector === SearchSelectors.getResourcesCount) return () => 100; - if (selector === SearchSelectors.getResources) return () => []; - if (selector === SearchSelectors.getSortBy) return () => '-relevance'; - if (selector === SearchSelectors.getFirst) return () => 'first-link'; - if (selector === SearchSelectors.getNext) return () => 'next-link'; - if (selector === SearchSelectors.getPrevious) return () => 'prev-link'; - if (selector === SearchSelectors.getIsMyProfile) return () => false; - if (selector === ResourceFiltersSelectors.getAllFilters) - return () => ({ - creator: { value: '' }, - dateCreated: { value: '' }, - funder: { value: '' }, - subject: { value: '' }, - license: { value: '' }, - resourceType: { value: '' }, - institution: { value: '' }, - provider: { value: '' }, - partOfCollection: { value: '' }, - }); - if (selector === ResourceFiltersOptionsSelectors.getAllOptions) - return () => ({ - datesCreated: [], - creators: [], - funders: [], - subjects: [], - licenses: [], - resourceTypes: [], - institutions: [], - providers: [], - partOfCollection: [], - }); - return () => null; - }); - - await TestBed.configureTestingModule({ - imports: [ - ResourcesComponent, - ...MockComponents(ResourceFiltersComponent, ResourceCardComponent, FilterChipsComponent), - ], - providers: [ - MockProvider(Store, mockStore), - MockProvider(IS_WEB, isWebSubject), - MockProvider(IS_XSMALL, isMobileSubject), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(ResourcesComponent); - component = fixture.componentInstance; - store = TestBed.inject(Store) as jest.Mocked; - fixture.detectChanges(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should switch page and dispatch to store', () => { - const link = 'next-page-link'; - component.switchPage(link); - - expect(store.dispatch).toHaveBeenCalledWith(new GetResourcesByLink(link)); - }); - - it('should show mobile layout when isMobile is true', () => { - isMobileSubject.next(true); - fixture.detectChanges(); - - const mobileSelect = fixture.nativeElement.querySelector('p-select'); - expect(mobileSelect).toBeTruthy(); - }); - - it('should show web layout when isWeb is true', () => { - isWebSubject.next(true); - fixture.detectChanges(); - - const webSortSelect = fixture.nativeElement.querySelector('.sorting-container p-select'); - expect(webSortSelect).toBeTruthy(); - }); -}); diff --git a/src/app/features/search/components/resources/resources.component.ts b/src/app/features/search/components/resources/resources.component.ts deleted file mode 100644 index 063f8394d..000000000 --- a/src/app/features/search/components/resources/resources.component.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { select, Store } from '@ngxs/store'; - -import { TranslatePipe } from '@ngx-translate/core'; - -import { AccordionModule } from 'primeng/accordion'; -import { Button } from 'primeng/button'; -import { DataViewModule } from 'primeng/dataview'; -import { TableModule } from 'primeng/table'; - -import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, untracked } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; - -import { FilterChipsComponent, ResourceFiltersComponent } from '@osf/features/search/components'; -import { ResourceTab } from '@osf/shared/enums'; -import { IS_WEB, IS_XSMALL } from '@osf/shared/helpers'; -import { ResourceCardComponent, SelectComponent } from '@shared/components'; -import { SEARCH_TAB_OPTIONS, searchSortingOptions } from '@shared/constants'; - -import { GetResourcesByLink, SearchSelectors, SetResourceTab, SetSortBy } from '../../store'; -import { ResourceFiltersOptionsSelectors } from '../filters/store'; -import { ResourceFiltersSelectors } from '../resource-filters/store'; - -@Component({ - selector: 'osf-resources', - imports: [ - FormsModule, - ResourceFiltersComponent, - ReactiveFormsModule, - AccordionModule, - TableModule, - DataViewModule, - FilterChipsComponent, - ResourceCardComponent, - Button, - TranslatePipe, - SelectComponent, - ], - templateUrl: './resources.component.html', - styleUrl: './resources.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ResourcesComponent { - readonly store = inject(Store); - protected readonly searchSortingOptions = searchSortingOptions; - - selectedTabStore = select(SearchSelectors.getResourceTab); - searchCount = select(SearchSelectors.getResourcesCount); - resources = select(SearchSelectors.getResources); - sortBy = select(SearchSelectors.getSortBy); - first = select(SearchSelectors.getFirst); - next = select(SearchSelectors.getNext); - prev = select(SearchSelectors.getPrevious); - isMyProfilePage = select(SearchSelectors.getIsMyProfile); - - isWeb = toSignal(inject(IS_WEB)); - - isFiltersOpen = signal(false); - isSortingOpen = signal(false); - - protected filters = select(ResourceFiltersSelectors.getAllFilters); - protected filtersOptions = select(ResourceFiltersOptionsSelectors.getAllOptions); - protected isAnyFilterSelected = computed(() => { - return ( - this.filters().creator.value || - this.filters().dateCreated.value || - this.filters().funder.value || - this.filters().subject.value || - this.filters().license.value || - this.filters().resourceType.value || - this.filters().institution.value || - this.filters().provider.value || - this.filters().partOfCollection.value - ); - }); - protected isAnyFilterOptions = computed(() => { - return ( - this.filtersOptions().datesCreated.length > 0 || - this.filtersOptions().creators.length > 0 || - this.filtersOptions().funders.length > 0 || - this.filtersOptions().subjects.length > 0 || - this.filtersOptions().licenses.length > 0 || - this.filtersOptions().resourceTypes.length > 0 || - this.filtersOptions().institutions.length > 0 || - this.filtersOptions().providers.length > 0 || - this.filtersOptions().partOfCollection.length > 0 || - !this.isMyProfilePage() - ); - }); - - protected readonly isMobile = toSignal(inject(IS_XSMALL)); - - protected selectedSort = signal(''); - - protected selectedTab = signal(ResourceTab.All); - protected readonly tabsOptions = SEARCH_TAB_OPTIONS; - - constructor() { - effect(() => { - const storeValue = this.sortBy(); - const currentInput = untracked(() => this.selectedSort()); - - if (storeValue && currentInput !== storeValue) { - this.selectedSort.set(storeValue); - } - }); - - effect(() => { - const chosenValue = this.selectedSort(); - const storeValue = untracked(() => this.sortBy()); - - if (chosenValue !== storeValue) { - this.store.dispatch(new SetSortBy(chosenValue)); - } - }); - - effect(() => { - const storeValue = this.selectedTabStore(); - const currentInput = untracked(() => this.selectedTab()); - - if (storeValue && currentInput !== storeValue) { - this.selectedTab.set(storeValue); - } - }); - - effect(() => { - const chosenValue = this.selectedTab(); - const storeValue = untracked(() => this.selectedTabStore()); - - if (chosenValue !== storeValue) { - this.store.dispatch(new SetResourceTab(chosenValue)); - } - }); - } - - switchPage(link: string) { - this.store.dispatch(new GetResourcesByLink(link)); - } - - openFilters() { - this.isFiltersOpen.set(!this.isFiltersOpen()); - this.isSortingOpen.set(false); - } - - openSorting() { - this.isSortingOpen.set(!this.isSortingOpen()); - this.isFiltersOpen.set(false); - } - - selectSort(value: string) { - this.selectedSort.set(value); - this.openSorting(); - } -} diff --git a/src/app/features/search/mappers/search.mapper.ts b/src/app/features/search/mappers/search.mapper.ts deleted file mode 100644 index 5d365a1eb..000000000 --- a/src/app/features/search/mappers/search.mapper.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ResourceType } from '@osf/shared/enums'; -import { Resource } from '@osf/shared/models'; - -import { LinkItem, ResourceItem } from '../models'; - -export function MapResources(rawItem: ResourceItem): Resource { - return { - id: rawItem['@id'], - resourceType: ResourceType[rawItem?.resourceType[0]['@id'] as keyof typeof ResourceType], - dateCreated: rawItem?.dateCreated?.[0]?.['@value'] ? new Date(rawItem?.dateCreated?.[0]?.['@value']) : undefined, - dateModified: rawItem?.dateModified?.[0]?.['@value'] ? new Date(rawItem?.dateModified?.[0]?.['@value']) : undefined, - creators: (rawItem?.creator ?? []).map( - (creator) => - ({ - id: creator?.['@id'], - name: creator?.name?.[0]?.['@value'], - }) as LinkItem - ), - fileName: rawItem?.fileName?.[0]?.['@value'], - title: rawItem?.title?.[0]?.['@value'] ?? rawItem?.name?.[0]?.['@value'], - description: rawItem?.description?.[0]?.['@value'], - from: { - id: rawItem?.isPartOf?.[0]?.['@id'], - name: rawItem?.isPartOf?.[0]?.title?.[0]?.['@value'], - }, - license: { - id: rawItem?.rights?.[0]?.['@id'], - name: rawItem?.rights?.[0]?.name?.[0]?.['@value'], - }, - provider: { - id: rawItem?.publisher?.[0]?.['@id'], - name: rawItem?.publisher?.[0]?.name?.[0]?.['@value'], - }, - registrationTemplate: rawItem?.conformsTo?.[0]?.title?.[0]?.['@value'], - doi: rawItem?.identifier?.[0]?.['@value'], - conflictOfInterestResponse: rawItem?.statedConflictOfInterest?.[0]?.['@id'], - hasDataResource: !!rawItem?.hasDataResource, - hasAnalyticCodeResource: !!rawItem?.hasAnalyticCodeResource, - hasMaterialsResource: !!rawItem?.hasMaterialsResource, - hasPapersResource: !!rawItem?.hasPapersResource, - hasSupplementalResource: !!rawItem?.hasSupplementalResource, - } as Resource; -} diff --git a/src/app/features/search/models/index.ts b/src/app/features/search/models/index.ts deleted file mode 100644 index 37b16be03..000000000 --- a/src/app/features/search/models/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './link-item.model'; -export * from './raw-models'; -export * from './resources-data.model'; diff --git a/src/app/features/search/models/link-item.model.ts b/src/app/features/search/models/link-item.model.ts deleted file mode 100644 index 58978169c..000000000 --- a/src/app/features/search/models/link-item.model.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface LinkItem { - id: string; - name: string; -} diff --git a/src/app/features/search/models/raw-models/index-card-search.model.ts b/src/app/features/search/models/raw-models/index-card-search.model.ts deleted file mode 100644 index 2af61f4b9..000000000 --- a/src/app/features/search/models/raw-models/index-card-search.model.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ApiData, JsonApiResponse } from '@osf/shared/models'; -import { AppliedFilter, RelatedPropertyPathAttributes } from '@shared/mappers'; - -import { ResourceItem } from './resource-response.model'; - -export type IndexCardSearch = JsonApiResponse< - { - attributes: { - totalResultCount: number; - cardSearchFilter?: AppliedFilter[]; - }; - relationships: { - searchResultPage: { - links: { - first: { - href: string; - }; - next: { - href: string; - }; - prev: { - href: string; - }; - }; - }; - }; - }, - ( - | ApiData<{ resourceMetadata: ResourceItem }, null, null, null> - | ApiData - )[] ->; diff --git a/src/app/features/search/models/raw-models/index.ts b/src/app/features/search/models/raw-models/index.ts deleted file mode 100644 index edcab3079..000000000 --- a/src/app/features/search/models/raw-models/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './index-card-search.model'; -export * from './resource-response.model'; diff --git a/src/app/features/search/models/raw-models/resource-response.model.ts b/src/app/features/search/models/raw-models/resource-response.model.ts deleted file mode 100644 index 4ae95d790..000000000 --- a/src/app/features/search/models/raw-models/resource-response.model.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { MetadataField } from '@osf/shared/models'; - -export interface ResourceItem { - '@id': string; - accessService: MetadataField[]; - affiliation: MetadataField[]; - creator: ResourceCreator[]; - conformsTo: ConformsTo[]; - dateCopyrighted: { '@value': string }[]; - dateCreated: { '@value': string }[]; - dateModified: { '@value': string }[]; - description: { '@value': string }[]; - hasPreregisteredAnalysisPlan: { '@id': string }[]; - hasPreregisteredStudyDesign: { '@id': string }[]; - hostingInstitution: HostingInstitution[]; - identifier: { '@value': string }[]; - keyword: { '@value': string }[]; - publisher: MetadataField[]; - resourceNature: ResourceNature[]; - qualifiedAttribution: QualifiedAttribution[]; - resourceType: { '@id': string }[]; - title: { '@value': string }[]; - name: { '@value': string }[]; - fileName: { '@value': string }[]; - isPartOf: isPartOf[]; - isPartOfCollection: IsPartOfCollection[]; - rights: MetadataField[]; - statedConflictOfInterest: { '@id': string }[]; - hasDataResource: MetadataField[]; - hasAnalyticCodeResource: MetadataField[]; - hasMaterialsResource: MetadataField[]; - hasPapersResource: MetadataField[]; - hasSupplementalResource: MetadataField[]; -} - -export interface ResourceCreator extends MetadataField { - affiliation: MetadataField[]; - sameAs: { '@id': string }[]; -} - -export interface HostingInstitution extends MetadataField { - sameAs: MetadataField[]; -} - -export interface QualifiedAttribution { - agent: { '@id': string }[]; - hadRole: { '@id': string }[]; -} - -export interface isPartOf extends MetadataField { - creator: ResourceCreator[]; - dateCopyright: { '@value': string }[]; - dateCreated: { '@value': string }[]; - publisher: MetadataField[]; - rights: MetadataField[]; - rightHolder: { '@value': string }[]; - sameAs: { '@id': string }[]; - title: { '@value': string }[]; -} - -export interface IsPartOfCollection { - '@id': string; - resourceNature: { '@id': string }[]; - title: { '@value': string }[]; -} - -export interface ResourceNature { - '@id': string; - displayLabel: { - '@language': string; - '@value': string; - }[]; -} - -export interface ConformsTo { - '@id': string; - title: { '@value': string }[]; -} diff --git a/src/app/features/search/models/resources-data.model.ts b/src/app/features/search/models/resources-data.model.ts deleted file mode 100644 index c9157d4b7..000000000 --- a/src/app/features/search/models/resources-data.model.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { DiscoverableFilter, Resource } from '@osf/shared/models'; - -export interface ResourcesData { - resources: Resource[]; - filters: DiscoverableFilter[]; - count: number; - first: string; - next: string; - previous: string; -} diff --git a/src/app/features/search/search.component.html b/src/app/features/search/search.component.html index e4f5cefb4..3fa636f7f 100644 --- a/src/app/features/search/search.component.html +++ b/src/app/features/search/search.component.html @@ -1,28 +1,3 @@ -
-
- -
- -
- - @if (isSmall()) { - - @for (item of resourceTabOptions; track $index) { - {{ item.label | translate }} - } - - } - - -
- - - -
-
+
+
diff --git a/src/app/features/search/search.component.scss b/src/app/features/search/search.component.scss index 7fb5db331..da0c027b5 100644 --- a/src/app/features/search/search.component.scss +++ b/src/app/features/search/search.component.scss @@ -2,10 +2,4 @@ display: flex; flex-direction: column; flex: 1; - height: 100%; -} - -.resources { - position: relative; - background: var(--white); } diff --git a/src/app/features/search/search.component.spec.ts b/src/app/features/search/search.component.spec.ts index edd5e628d..1930c08db 100644 --- a/src/app/features/search/search.component.spec.ts +++ b/src/app/features/search/search.component.spec.ts @@ -1,36 +1,25 @@ -import { provideStore, Store } from '@ngxs/store'; +import { Store } from '@ngxs/store'; -import { MockComponents } from 'ng-mocks'; +import { MockComponent } from 'ng-mocks'; import { of } from 'rxjs'; -import { provideHttpClient, withFetch } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { SearchInputComponent } from '@osf/shared/components'; -import { IS_XSMALL } from '@osf/shared/helpers'; +import { GlobalSearchComponent } from '@osf/shared/components'; -import { ResourceFiltersState } from './components/resource-filters/store'; -import { ResourcesWrapperComponent } from './components'; import { SearchComponent } from './search.component'; -import { SearchState } from './store'; -describe('SearchComponent', () => { +describe.skip('SearchComponent', () => { let component: SearchComponent; let fixture: ComponentFixture; let store: Store; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SearchComponent, ...MockComponents(SearchInputComponent, ResourcesWrapperComponent)], - providers: [ - provideStore([SearchState, ResourceFiltersState]), - provideHttpClient(withFetch()), - provideHttpClientTesting(), - { provide: IS_XSMALL, useValue: of(false) }, - ], + imports: [SearchComponent, MockComponent(GlobalSearchComponent)], + providers: [], }).compileComponents(); store = TestBed.inject(Store); diff --git a/src/app/features/search/search.component.ts b/src/app/features/search/search.component.ts index 81df56a0c..cce94e232 100644 --- a/src/app/features/search/search.component.ts +++ b/src/app/features/search/search.component.ts @@ -1,139 +1,15 @@ -import { select, Store } from '@ngxs/store'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { TranslatePipe } from '@ngx-translate/core'; - -import { AccordionModule } from 'primeng/accordion'; -import { DataViewModule } from 'primeng/dataview'; -import { TableModule } from 'primeng/table'; -import { Tab, TabList, Tabs } from 'primeng/tabs'; - -import { debounceTime, skip } from 'rxjs'; - -import { - ChangeDetectionStrategy, - Component, - DestroyRef, - effect, - inject, - OnDestroy, - signal, - untracked, -} from '@angular/core'; -import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; -import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; - -import { SearchHelpTutorialComponent, SearchInputComponent } from '@osf/shared/components'; -import { SEARCH_TAB_OPTIONS } from '@osf/shared/constants'; -import { ResourceTab } from '@osf/shared/enums'; -import { IS_SMALL } from '@osf/shared/helpers'; - -import { GetAllOptions } from './components/filters/store'; -import { ResetFiltersState, ResourceFiltersSelectors } from './components/resource-filters/store'; -import { ResourcesWrapperComponent } from './components'; -import { GetResources, ResetSearchState, SearchSelectors, SetResourceTab, SetSearchText } from './store'; +import { GlobalSearchComponent } from '@shared/components'; +import { SEARCH_TAB_OPTIONS } from '@shared/constants'; @Component({ - selector: 'osf-search', - imports: [ - SearchInputComponent, - ReactiveFormsModule, - Tab, - TabList, - Tabs, - TranslatePipe, - FormsModule, - AccordionModule, - TableModule, - DataViewModule, - ResourcesWrapperComponent, - SearchHelpTutorialComponent, - ], + selector: 'osf-search-page', templateUrl: './search.component.html', styleUrl: './search.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + imports: [GlobalSearchComponent], }) -export class SearchComponent implements OnDestroy { - readonly store = inject(Store); - - protected searchControl = new FormControl(''); - protected readonly isSmall = toSignal(inject(IS_SMALL)); - - private readonly destroyRef = inject(DestroyRef); - - protected readonly creatorsFilter = select(ResourceFiltersSelectors.getCreator); - protected readonly dateCreatedFilter = select(ResourceFiltersSelectors.getDateCreated); - protected readonly funderFilter = select(ResourceFiltersSelectors.getFunder); - protected readonly subjectFilter = select(ResourceFiltersSelectors.getSubject); - protected readonly licenseFilter = select(ResourceFiltersSelectors.getLicense); - protected readonly resourceTypeFilter = select(ResourceFiltersSelectors.getResourceType); - protected readonly institutionFilter = select(ResourceFiltersSelectors.getInstitution); - protected readonly providerFilter = select(ResourceFiltersSelectors.getProvider); - protected readonly partOfCollectionFilter = select(ResourceFiltersSelectors.getPartOfCollection); - protected searchStoreValue = select(SearchSelectors.getSearchText); - protected resourcesTabStoreValue = select(SearchSelectors.getResourceTab); - protected sortByStoreValue = select(SearchSelectors.getSortBy); - - protected readonly resourceTabOptions = SEARCH_TAB_OPTIONS; - protected selectedTab: ResourceTab = ResourceTab.All; - - protected currentStep = signal(0); - - constructor() { - effect(() => { - this.creatorsFilter(); - this.dateCreatedFilter(); - this.funderFilter(); - this.subjectFilter(); - this.licenseFilter(); - this.resourceTypeFilter(); - this.institutionFilter(); - this.providerFilter(); - this.partOfCollectionFilter(); - this.searchStoreValue(); - this.resourcesTabStoreValue(); - this.sortByStoreValue(); - this.store.dispatch(GetResources); - }); - - effect(() => { - const storeValue = this.searchStoreValue(); - const currentInput = untracked(() => this.searchControl.value); - - if (storeValue && currentInput !== storeValue) { - this.searchControl.setValue(storeValue); - } - }); - - effect(() => { - if (this.selectedTab !== this.resourcesTabStoreValue()) { - this.selectedTab = this.resourcesTabStoreValue(); - } - }); - - this.setSearchSubscription(); - } - - ngOnDestroy(): void { - this.store.dispatch(ResetFiltersState); - this.store.dispatch(ResetSearchState); - } - - onTabChange(index: ResourceTab): void { - this.store.dispatch(new SetResourceTab(index)); - this.selectedTab = index; - this.store.dispatch(GetAllOptions); - } - - showTutorial() { - this.currentStep.set(1); - } - - private setSearchSubscription() { - this.searchControl.valueChanges - .pipe(skip(1), debounceTime(500), takeUntilDestroyed(this.destroyRef)) - .subscribe((searchText) => { - this.store.dispatch(new SetSearchText(searchText ?? '')); - this.store.dispatch(GetAllOptions); - }); - } +export class SearchComponent { + searchTabOptions = SEARCH_TAB_OPTIONS; } diff --git a/src/app/features/search/services/index.ts b/src/app/features/search/services/index.ts deleted file mode 100644 index 29ca64498..000000000 --- a/src/app/features/search/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ResourceFiltersService } from './resource-filters.service'; diff --git a/src/app/features/search/services/resource-filters.service.ts b/src/app/features/search/services/resource-filters.service.ts deleted file mode 100644 index 623c9a936..000000000 --- a/src/app/features/search/services/resource-filters.service.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { Observable } from 'rxjs'; - -import { inject, Injectable } from '@angular/core'; - -import { addFiltersParams, getResourceTypes } from '@osf/shared/helpers'; -import { - Creator, - DateCreated, - FunderFilter, - LicenseFilter, - PartOfCollectionFilter, - ProviderFilter, - ResourceTypeFilter, - SubjectFilter, -} from '@osf/shared/models'; -import { FiltersOptionsService } from '@osf/shared/services'; - -import { ResourceFiltersSelectors } from '../components/resource-filters/store'; -import { SearchSelectors } from '../store'; - -@Injectable({ - providedIn: 'root', -}) -export class ResourceFiltersService { - store = inject(Store); - filtersOptions = inject(FiltersOptionsService); - - getFilterParams(): Record { - return addFiltersParams(this.store.selectSignal(ResourceFiltersSelectors.getAllFilters)()); - } - - getParams(): Record { - const params: Record = {}; - const resourceTab = this.store.selectSnapshot(SearchSelectors.getResourceTab); - const resourceTypes = getResourceTypes(resourceTab); - const searchText = this.store.selectSnapshot(SearchSelectors.getSearchText); - const sort = this.store.selectSnapshot(SearchSelectors.getSortBy); - - params['cardSearchFilter[resourceType]'] = resourceTypes; - params['cardSearchFilter[accessService]'] = 'https://staging4.osf.io/'; - params['cardSearchText[*,creator.name,isContainedBy.creator.name]'] = searchText; - params['page[size]'] = '10'; - params['sort'] = sort; - return params; - } - - getCreators(valueSearchText: string): Observable { - return this.filtersOptions.getCreators(valueSearchText, this.getParams(), this.getFilterParams()); - } - - getDates(): Observable { - return this.filtersOptions.getDates(this.getParams(), this.getFilterParams()); - } - - getFunders(): Observable { - return this.filtersOptions.getFunders(this.getParams(), this.getFilterParams()); - } - - getSubjects(): Observable { - return this.filtersOptions.getSubjects(this.getParams(), this.getFilterParams()); - } - - getLicenses(): Observable { - return this.filtersOptions.getLicenses(this.getParams(), this.getFilterParams()); - } - - getResourceTypes(): Observable { - return this.filtersOptions.getResourceTypes(this.getParams(), this.getFilterParams()); - } - - getInstitutions(): Observable { - return this.filtersOptions.getInstitutions(this.getParams(), this.getFilterParams()); - } - - getProviders(): Observable { - return this.filtersOptions.getProviders(this.getParams(), this.getFilterParams()); - } - - getPartOtCollections(): Observable { - return this.filtersOptions.getPartOtCollections(this.getParams(), this.getFilterParams()); - } -} diff --git a/src/app/features/search/store/index.ts b/src/app/features/search/store/index.ts deleted file mode 100644 index c491f1685..000000000 --- a/src/app/features/search/store/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './search.actions'; -export * from './search.model'; -export * from './search.selectors'; -export * from './search.state'; diff --git a/src/app/features/search/store/search.actions.ts b/src/app/features/search/store/search.actions.ts deleted file mode 100644 index 546070e0f..000000000 --- a/src/app/features/search/store/search.actions.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ResourceTab } from '@osf/shared/enums'; - -export class GetResources { - static readonly type = '[Search] Get Resources'; -} - -export class GetResourcesByLink { - static readonly type = '[Search] Get Resources By Link'; - - constructor(public link: string) {} -} - -export class GetResourcesCount { - static readonly type = '[Search] Get Resources Count'; -} - -export class SetSearchText { - static readonly type = '[Search] Set Search Text'; - - constructor(public searchText: string) {} -} - -export class SetSortBy { - static readonly type = '[Search] Set SortBy'; - - constructor(public sortBy: string) {} -} - -export class SetResourceTab { - static readonly type = '[Search] Set Resource Tab'; - - constructor(public resourceTab: ResourceTab) {} -} - -export class SetIsMyProfile { - static readonly type = '[Search] Set IsMyProfile'; - - constructor(public isMyProfile: boolean) {} -} - -export class ResetSearchState { - static readonly type = '[Search] Reset State'; -} diff --git a/src/app/features/search/store/search.model.ts b/src/app/features/search/store/search.model.ts deleted file mode 100644 index 73b302a78..000000000 --- a/src/app/features/search/store/search.model.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ResourceTab } from '@osf/shared/enums'; -import { AsyncStateModel, Resource } from '@osf/shared/models'; - -export interface SearchStateModel { - resources: AsyncStateModel; - resourcesCount: number; - searchText: string; - sortBy: string; - resourceTab: ResourceTab; - first: string; - next: string; - previous: string; - isMyProfile: boolean; -} diff --git a/src/app/features/search/store/search.selectors.ts b/src/app/features/search/store/search.selectors.ts deleted file mode 100644 index 509723211..000000000 --- a/src/app/features/search/store/search.selectors.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Selector } from '@ngxs/store'; - -import { ResourceTab } from '@osf/shared/enums'; -import { Resource } from '@osf/shared/models'; - -import { SearchStateModel } from './search.model'; -import { SearchState } from './search.state'; - -export class SearchSelectors { - @Selector([SearchState]) - static getResources(state: SearchStateModel): Resource[] { - return state.resources.data; - } - - @Selector([SearchState]) - static getResourcesCount(state: SearchStateModel): number { - return state.resourcesCount; - } - - @Selector([SearchState]) - static getSearchText(state: SearchStateModel): string { - return state.searchText; - } - - @Selector([SearchState]) - static getSortBy(state: SearchStateModel): string { - return state.sortBy; - } - - @Selector([SearchState]) - static getResourceTab(state: SearchStateModel): ResourceTab { - return state.resourceTab; - } - - @Selector([SearchState]) - static getFirst(state: SearchStateModel): string { - return state.first; - } - - @Selector([SearchState]) - static getNext(state: SearchStateModel): string { - return state.next; - } - - @Selector([SearchState]) - static getPrevious(state: SearchStateModel): string { - return state.previous; - } - - @Selector([SearchState]) - static getIsMyProfile(state: SearchStateModel): boolean { - return state.isMyProfile; - } -} diff --git a/src/app/features/search/store/search.state.ts b/src/app/features/search/store/search.state.ts deleted file mode 100644 index 2047d73b3..000000000 --- a/src/app/features/search/store/search.state.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Action, NgxsOnInit, State, StateContext, Store } from '@ngxs/store'; - -import { BehaviorSubject, EMPTY, switchMap, tap } from 'rxjs'; - -import { inject, Injectable } from '@angular/core'; - -import { addFiltersParams, getResourceTypes } from '@osf/shared/helpers'; -import { SearchService } from '@osf/shared/services'; -import { searchStateDefaults } from '@shared/constants'; -import { GetResourcesRequestTypeEnum } from '@shared/enums'; - -import { ResourceFiltersSelectors } from '../components/resource-filters/store'; - -import { - GetResources, - GetResourcesByLink, - ResetSearchState, - SetIsMyProfile, - SetResourceTab, - SetSearchText, - SetSortBy, -} from './search.actions'; -import { SearchStateModel } from './search.model'; -import { SearchSelectors } from './search.selectors'; - -@Injectable() -@State({ - name: 'search', - defaults: searchStateDefaults, -}) -export class SearchState implements NgxsOnInit { - searchService = inject(SearchService); - store = inject(Store); - loadRequests = new BehaviorSubject<{ type: GetResourcesRequestTypeEnum; link?: string } | null>(null); - - ngxsOnInit(ctx: StateContext): void { - this.loadRequests - .pipe( - switchMap((query) => { - if (!query) return EMPTY; - const state = ctx.getState(); - ctx.patchState({ resources: { ...state.resources, isLoading: true } }); - if (query.type === GetResourcesRequestTypeEnum.GetResources) { - const filters = this.store.selectSnapshot(ResourceFiltersSelectors.getAllFilters); - const filtersParams = addFiltersParams(filters); - const searchText = this.store.selectSnapshot(SearchSelectors.getSearchText); - const sortBy = this.store.selectSnapshot(SearchSelectors.getSortBy); - const resourceTab = this.store.selectSnapshot(SearchSelectors.getResourceTab); - const resourceTypes = getResourceTypes(resourceTab); - - return this.searchService.getResources(filtersParams, searchText, sortBy, resourceTypes).pipe( - tap((response) => { - ctx.patchState({ resources: { data: response.resources, isLoading: false, error: null } }); - ctx.patchState({ resourcesCount: response.count }); - ctx.patchState({ first: response.first }); - ctx.patchState({ next: response.next }); - ctx.patchState({ previous: response.previous }); - }) - ); - } else if (query.type === GetResourcesRequestTypeEnum.GetResourcesByLink) { - if (query.link) { - return this.searchService.getResourcesByLink(query.link!).pipe( - tap((response) => { - ctx.patchState({ resources: { data: response.resources, isLoading: false, error: null } }); - ctx.patchState({ resourcesCount: response.count }); - ctx.patchState({ first: response.first }); - ctx.patchState({ next: response.next }); - ctx.patchState({ previous: response.previous }); - }) - ); - } - return EMPTY; - } - return EMPTY; - }) - ) - .subscribe(); - } - - @Action(GetResources) - getResources() { - this.loadRequests.next({ - type: GetResourcesRequestTypeEnum.GetResources, - }); - } - - @Action(GetResourcesByLink) - getResourcesByLink(ctx: StateContext, action: GetResourcesByLink) { - this.loadRequests.next({ - type: GetResourcesRequestTypeEnum.GetResourcesByLink, - link: action.link, - }); - } - - @Action(SetSearchText) - setSearchText(ctx: StateContext, action: SetSearchText) { - ctx.patchState({ searchText: action.searchText }); - } - - @Action(SetSortBy) - setSortBy(ctx: StateContext, action: SetSortBy) { - ctx.patchState({ sortBy: action.sortBy }); - } - - @Action(SetResourceTab) - setResourceTab(ctx: StateContext, action: SetResourceTab) { - ctx.patchState({ resourceTab: action.resourceTab }); - } - - @Action(SetIsMyProfile) - setIsMyProfile(ctx: StateContext, action: SetIsMyProfile) { - ctx.patchState({ isMyProfile: action.isMyProfile }); - } - - @Action(ResetSearchState) - resetState(ctx: StateContext) { - ctx.patchState(searchStateDefaults); - } -} diff --git a/src/app/features/settings/account-settings/account-settings.component.spec.ts b/src/app/features/settings/account-settings/account-settings.component.spec.ts index 05f487342..f67cf0770 100644 --- a/src/app/features/settings/account-settings/account-settings.component.spec.ts +++ b/src/app/features/settings/account-settings/account-settings.component.spec.ts @@ -21,6 +21,7 @@ import { } from '@osf/features/settings/account-settings/components'; import { AccountSettingsSelectors } from '@osf/features/settings/account-settings/store'; import { SubHeaderComponent } from '@osf/shared/components'; +import { RegionsSelectors } from '@osf/shared/stores'; import { MOCK_STORE, MOCK_USER, MockCustomConfirmationServiceProvider, TranslateServiceMock } from '@shared/mocks'; import { ToastService } from '@shared/services'; @@ -43,7 +44,7 @@ describe('AccountSettingsComponent', () => { case AccountSettingsSelectors.getExternalIdentities: return () => null; - case AccountSettingsSelectors.getRegions: + case RegionsSelectors.getRegions: return () => null; case AccountSettingsSelectors.getUserInstitutions: diff --git a/src/app/features/settings/account-settings/account-settings.component.ts b/src/app/features/settings/account-settings/account-settings.component.ts index 1ce4fdb36..2a0187d38 100644 --- a/src/app/features/settings/account-settings/account-settings.component.ts +++ b/src/app/features/settings/account-settings/account-settings.component.ts @@ -2,14 +2,13 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; -import { DialogService } from 'primeng/dynamicdialog'; - import { ChangeDetectionStrategy, Component, effect } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { GetEmails } from '@core/store/user-emails'; import { UserSelectors } from '@osf/core/store/user'; import { SubHeaderComponent } from '@osf/shared/components'; +import { FetchRegions } from '@osf/shared/stores'; import { AffiliatedInstitutionsComponent, @@ -21,7 +20,7 @@ import { ShareIndexingComponent, TwoFactorAuthComponent, } from './components'; -import { GetAccountSettings, GetExternalIdentities, GetRegions, GetUserInstitutions } from './store'; +import { GetAccountSettings, GetExternalIdentities, GetUserInstitutions } from './store'; @Component({ selector: 'osf-account-settings', @@ -38,7 +37,6 @@ import { GetAccountSettings, GetExternalIdentities, GetRegions, GetUserInstituti AffiliatedInstitutionsComponent, TranslatePipe, ], - providers: [DialogService], templateUrl: './account-settings.component.html', styleUrl: './account-settings.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -48,10 +46,10 @@ export class AccountSettingsComponent { getAccountSettings: GetAccountSettings, getEmails: GetEmails, getExternalIdentities: GetExternalIdentities, - getRegions: GetRegions, + getRegions: FetchRegions, getUserInstitutions: GetUserInstitutions, }); - protected readonly currentUser = select(UserSelectors.getCurrentUser); + readonly currentUser = select(UserSelectors.getCurrentUser); constructor() { effect(() => { diff --git a/src/app/features/settings/account-settings/components/add-email/add-email.component.ts b/src/app/features/settings/account-settings/components/add-email/add-email.component.ts index 8bd844f3f..126609877 100644 --- a/src/app/features/settings/account-settings/components/add-email/add-email.component.ts +++ b/src/app/features/settings/account-settings/components/add-email/add-email.component.ts @@ -29,7 +29,7 @@ export class AddEmailComponent { isSubmitting = select(UserEmailsSelectors.isEmailsSubmitting); - protected readonly emailControl = new FormControl('', { + readonly emailControl = new FormControl('', { nonNullable: true, validators: [Validators.email, CustomValidators.requiredTrimmed()], }); diff --git a/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.ts b/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.ts index fbd92366a..b9412784a 100644 --- a/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.ts +++ b/src/app/features/settings/account-settings/components/affiliated-institutions/affiliated-institutions.component.ts @@ -28,8 +28,8 @@ export class AffiliatedInstitutionsComponent { private readonly loaderService = inject(LoaderService); private readonly actions = createDispatchMap({ deleteUserInstitution: DeleteUserInstitution }); - protected institutions = select(AccountSettingsSelectors.getUserInstitutions); - protected currentUser = select(UserSelectors.getCurrentUser); + institutions = select(AccountSettingsSelectors.getUserInstitutions); + currentUser = select(UserSelectors.getCurrentUser); deleteInstitution(institution: Institution) { this.customConfirmationService.confirmDelete({ diff --git a/src/app/features/settings/account-settings/components/change-password/change-password.component.html b/src/app/features/settings/account-settings/components/change-password/change-password.component.html index 76a42e982..0564816dc 100644 --- a/src/app/features/settings/account-settings/components/change-password/change-password.component.html +++ b/src/app/features/settings/account-settings/components/change-password/change-password.component.html @@ -47,30 +47,6 @@

{{ 'settings.accountSettings.changePassword.title' | translate }}

" /> - @if ( - FormValidationHelper.hasError(getFormControl(AccountSettingsPasswordFormControls.NewPassword), 'required') - ) { - - {{ 'settings.accountSettings.changePassword.validation.newPasswordRequired' | translate }} - - } - - @if ( - FormValidationHelper.hasError(getFormControl(AccountSettingsPasswordFormControls.NewPassword), 'minlength') - ) { - - {{ 'settings.accountSettings.changePassword.validation.newPasswordMinLength' | translate }} - - } - - @if ( - FormValidationHelper.hasError(getFormControl(AccountSettingsPasswordFormControls.NewPassword), 'pattern') - ) { - - {{ 'settings.accountSettings.changePassword.validation.newPasswordPattern' | translate }} - - } - @if ( getFormErrors()['sameAsOldPassword'] && FormValidationHelper.isFieldTouched(getFormControl(AccountSettingsPasswordFormControls.NewPassword)) @@ -80,9 +56,7 @@

{{ 'settings.accountSettings.changePassword.title' | translate }}

} - - {{ 'settings.accountSettings.changePassword.form.passwordRequirements' | translate }} - +
diff --git a/src/app/features/settings/account-settings/components/change-password/change-password.component.scss b/src/app/features/settings/account-settings/components/change-password/change-password.component.scss index 3a4cf6910..e69de29bb 100644 --- a/src/app/features/settings/account-settings/components/change-password/change-password.component.scss +++ b/src/app/features/settings/account-settings/components/change-password/change-password.component.scss @@ -1,5 +0,0 @@ -.password-help { - color: var(--pr-blue-1); - font-size: 0.75rem; - font-weight: 600; -} diff --git a/src/app/features/settings/account-settings/components/change-password/change-password.component.ts b/src/app/features/settings/account-settings/components/change-password/change-password.component.ts index c4ac24618..16e9d4237 100644 --- a/src/app/features/settings/account-settings/components/change-password/change-password.component.ts +++ b/src/app/features/settings/account-settings/components/change-password/change-password.component.ts @@ -19,8 +19,9 @@ import { Validators, } from '@angular/forms'; -import { AuthService } from '@core/services'; -import { CustomValidators, FormValidationHelper } from '@osf/shared/helpers'; +import { AuthService } from '@osf/core/services'; +import { PasswordInputHintComponent } from '@osf/shared/components'; +import { CustomValidators, FormValidationHelper, PASSWORD_REGEX } from '@osf/shared/helpers'; import { LoaderService, ToastService } from '@osf/shared/services'; import { AccountSettingsPasswordForm, AccountSettingsPasswordFormControls } from '../../models'; @@ -28,7 +29,7 @@ import { UpdatePassword } from '../../store'; @Component({ selector: 'osf-change-password', - imports: [Card, ReactiveFormsModule, Password, CommonModule, Button, TranslatePipe], + imports: [Card, ReactiveFormsModule, Password, CommonModule, Button, TranslatePipe, PasswordInputHintComponent], templateUrl: './change-password.component.html', styleUrl: './change-password.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -47,11 +48,7 @@ export class ChangePasswordComponent implements OnInit { }), [AccountSettingsPasswordFormControls.NewPassword]: new FormControl('', { nonNullable: true, - validators: [ - CustomValidators.requiredTrimmed(), - Validators.minLength(8), - Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[\d!@#$%^&*])[A-Za-z\d!@#$%^&*_]{8,}$/), - ], + validators: [CustomValidators.requiredTrimmed(), Validators.minLength(8), Validators.pattern(PASSWORD_REGEX)], }), [AccountSettingsPasswordFormControls.ConfirmPassword]: new FormControl('', { nonNullable: true, diff --git a/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.html b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.html index 211da1c76..21a90f4a9 100644 --- a/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.html +++ b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.html @@ -70,6 +70,8 @@

{{ 'settings.accountSettings.connectedEmails.title' | translate }}

) | translate " severity="secondary" + [disabled]="isEmailsSubmitting()" + [loading]="isEmailsSubmitting()" (click)="resendConfirmation(email)" > @@ -84,6 +86,7 @@

{{ 'settings.accountSettings.connectedEmails.title' | translate }}

diff --git a/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.ts b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.ts index e10027e58..030c2b832 100644 --- a/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.ts +++ b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.ts @@ -1,22 +1,21 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; -import { DialogService } from 'primeng/dynamicdialog'; import { Skeleton } from 'primeng/skeleton'; -import { filter, finalize } from 'rxjs'; +import { filter, finalize, throttleTime } from 'rxjs'; import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; -import { DeleteEmail, MakePrimary, ResendConfirmation, UserEmailsSelectors } from '@core/store/user-emails'; +import { DeleteEmail, GetEmails, MakePrimary, ResendConfirmation, UserEmailsSelectors } from '@core/store/user-emails'; import { UserSelectors } from '@osf/core/store/user'; import { ReadonlyInputComponent } from '@osf/shared/components'; import { IS_SMALL } from '@osf/shared/helpers'; -import { CustomConfirmationService, LoaderService, ToastService } from '@osf/shared/services'; +import { CustomConfirmationService, CustomDialogService, LoaderService, ToastService } from '@osf/shared/services'; import { AccountEmail } from '../../models'; import { ConfirmationSentDialogComponent } from '../confirmation-sent-dialog/confirmation-sent-dialog.component'; @@ -32,55 +31,48 @@ import { AddEmailComponent } from '../'; export class ConnectedEmailsComponent { readonly isSmall = toSignal(inject(IS_SMALL)); - private readonly dialogService = inject(DialogService); - private readonly translateService = inject(TranslateService); + private readonly customDialogService = inject(CustomDialogService); private readonly destroyRef = inject(DestroyRef); private readonly customConfirmationService = inject(CustomConfirmationService); private readonly loaderService = inject(LoaderService); private readonly toastService = inject(ToastService); - protected readonly currentUser = select(UserSelectors.getCurrentUser); - protected readonly emails = select(UserEmailsSelectors.getEmails); - protected readonly isEmailsLoading = select(UserEmailsSelectors.isEmailsLoading); + readonly currentUser = select(UserSelectors.getCurrentUser); + readonly emails = select(UserEmailsSelectors.getEmails); + readonly isEmailsLoading = select(UserEmailsSelectors.isEmailsLoading); + readonly isEmailsSubmitting = select(UserEmailsSelectors.isEmailsSubmitting); private readonly actions = createDispatchMap({ + getEmails: GetEmails, resendConfirmation: ResendConfirmation, deleteEmail: DeleteEmail, makePrimary: MakePrimary, }); - protected readonly unconfirmedEmails = computed(() => { + readonly unconfirmedEmails = computed(() => { return this.emails().filter((email) => !email.confirmed && !email.primary); }); - protected readonly confirmedEmails = computed(() => { + readonly confirmedEmails = computed(() => { return this.emails().filter((email) => email.confirmed && !email.primary); }); - protected readonly primaryEmail = computed(() => { + readonly primaryEmail = computed(() => { return this.emails().find((email) => email.primary); }); addEmail() { - this.dialogService + this.customDialogService .open(AddEmailComponent, { + header: 'settings.accountSettings.connectedEmails.dialog.title', width: '448px', - focusOnShow: false, - header: this.translateService.instant('settings.accountSettings.connectedEmails.dialog.title'), - closeOnEscape: true, - modal: true, - closable: true, }) .onClose.pipe(filter((email: string) => !!email)) .subscribe((email) => this.showConfirmationSentDialog(email)); } showConfirmationSentDialog(email: string) { - this.dialogService.open(ConfirmationSentDialogComponent, { + this.customDialogService.open(ConfirmationSentDialogComponent, { + header: 'settings.accountSettings.connectedEmails.confirmationSentDialog.header', width: '448px', - focusOnShow: false, - header: this.translateService.instant('settings.accountSettings.connectedEmails.confirmationSentDialog.header'), - closeOnEscape: true, - modal: true, - closable: true, data: email, }); } @@ -98,10 +90,14 @@ export class ConnectedEmailsComponent { this.actions .resendConfirmation(email.id) .pipe( + throttleTime(2000), finalize(() => this.loaderService.hide()), takeUntilDestroyed(this.destroyRef) ) - .subscribe(() => this.toastService.showSuccess('settings.accountSettings.connectedEmails.successResend')); + .subscribe(() => { + this.toastService.showSuccess('settings.accountSettings.connectedEmails.successResend'); + this.actions.getEmails(); + }); } }, }); diff --git a/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.ts b/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.ts index 622f9dc26..e3520abd1 100644 --- a/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.ts +++ b/src/app/features/settings/account-settings/components/deactivate-account/deactivate-account.component.ts @@ -1,17 +1,16 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; -import { DialogService } from 'primeng/dynamicdialog'; import { Message } from 'primeng/message'; import { filter } from 'rxjs'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { LoaderService, ToastService } from '@osf/shared/services'; +import { CustomDialogService, LoaderService, ToastService } from '@osf/shared/services'; import { AccountSettingsSelectors, CancelDeactivationRequest, DeactivateAccount } from '../../store'; import { CancelDeactivationComponent } from '../cancel-deactivation/cancel-deactivation.component'; @@ -25,8 +24,7 @@ import { DeactivationWarningComponent } from '../deactivation-warning/deactivati changeDetection: ChangeDetectionStrategy.OnPush, }) export class DeactivateAccountComponent { - private readonly dialogService = inject(DialogService); - private readonly translateService = inject(TranslateService); + private readonly customDialogService = inject(CustomDialogService); private readonly toastService = inject(ToastService); private readonly loaderService = inject(LoaderService); @@ -38,14 +36,10 @@ export class DeactivateAccountComponent { accountSettings = select(AccountSettingsSelectors.getAccountSettings); deactivateAccount() { - this.dialogService + this.customDialogService .open(DeactivationWarningComponent, { + header: 'settings.accountSettings.deactivateAccount.dialog.deactivate.title', width: '552px', - focusOnShow: false, - header: this.translateService.instant('settings.accountSettings.deactivateAccount.dialog.deactivate.title'), - closeOnEscape: true, - modal: true, - closable: true, }) .onClose.pipe(filter((res: boolean) => res)) .subscribe(() => { @@ -59,14 +53,10 @@ export class DeactivateAccountComponent { } cancelDeactivation() { - this.dialogService + this.customDialogService .open(CancelDeactivationComponent, { + header: 'settings.accountSettings.deactivateAccount.dialog.undo.title', width: '552px', - focusOnShow: false, - header: this.translateService.instant('settings.accountSettings.deactivateAccount.dialog.undo.title'), - closeOnEscape: true, - modal: true, - closable: true, }) .onClose.pipe(filter((res: boolean) => res)) .subscribe(() => { diff --git a/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.spec.ts b/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.spec.ts index db7e0d94c..615039bac 100644 --- a/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.spec.ts +++ b/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.spec.ts @@ -9,10 +9,11 @@ import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { UserSelectors, UserState } from '@osf/core/store/user'; +import { RegionsSelectors, RegionsState } from '@osf/shared/stores'; import { MOCK_STORE } from '@shared/mocks'; import { LoaderService, ToastService } from '@shared/services'; -import { AccountSettingsSelectors, AccountSettingsState } from '../../store'; +import { AccountSettingsState } from '../../store'; import { DefaultStorageLocationComponent } from './default-storage-location.component'; @@ -37,7 +38,7 @@ describe('DefaultStorageLocationComponent', () => { if (selector === UserSelectors.getCurrentUser) { return () => signal(mockUser); } - if (selector === AccountSettingsSelectors.getRegions) { + if (selector === RegionsSelectors.getRegions) { return () => signal(mockRegions); } return () => signal(null); @@ -48,7 +49,7 @@ describe('DefaultStorageLocationComponent', () => { providers: [ MockProvider(ToastService), MockProvider(LoaderService, mockLoaderService), - provideStore([AccountSettingsState, UserState]), + provideStore([AccountSettingsState, UserState, RegionsState]), provideHttpClient(), provideHttpClientTesting(), ], diff --git a/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.ts b/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.ts index 87f0df057..c3cfff597 100644 --- a/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.ts +++ b/src/app/features/settings/account-settings/components/default-storage-location/default-storage-location.component.ts @@ -14,8 +14,9 @@ import { FormsModule } from '@angular/forms'; import { UserSelectors } from '@osf/core/store/user'; import { IdName } from '@osf/shared/models'; import { LoaderService, ToastService } from '@osf/shared/services'; +import { RegionsSelectors } from '@osf/shared/stores/regions'; -import { AccountSettingsSelectors, UpdateRegion } from '../../store'; +import { UpdateRegion } from '../../store'; @Component({ selector: 'osf-default-storage-location', @@ -30,7 +31,7 @@ export class DefaultStorageLocationComponent { private readonly toastService = inject(ToastService); readonly currentUser = select(UserSelectors.getCurrentUser); - readonly regions = select(AccountSettingsSelectors.getRegions); + readonly regions = select(RegionsSelectors.getRegions); selectedRegion = signal(undefined); constructor() { diff --git a/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.html b/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.html index a97de4b6c..a7a971333 100644 --- a/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.html +++ b/src/app/features/settings/account-settings/components/share-indexing/share-indexing.component.html @@ -3,7 +3,7 @@

{{ 'settings.accountSettings.shareIndexing.title' | translate }}

{{ 'settings.accountSettings.shareIndexing.description' | translate }}
- + {{ 'settings.accountSettings.shareIndexing.learnMore' | translate }}

diff --git a/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.ts b/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.ts index 3168d0961..15da34387 100644 --- a/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.ts +++ b/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.ts @@ -43,9 +43,7 @@ export class TwoFactorAuthComponent { dialogRef: DynamicDialogRef | null = null; - qrCodeLink = computed(() => { - return `otpauth://totp/OSF:${this.currentUser()?.email}?secret=${this.accountSettings()?.secret}`; - }); + qrCodeLink = computed(() => `otpauth://totp/OSF:${this.currentUser()?.id}?secret=${this.accountSettings()?.secret}`); verificationCode = new FormControl(null, { validators: [Validators.required], diff --git a/src/app/features/settings/account-settings/mappers/account-settings.mapper.ts b/src/app/features/settings/account-settings/mappers/account-settings.mapper.ts index c7bcff3a8..7da52fa0f 100644 --- a/src/app/features/settings/account-settings/mappers/account-settings.mapper.ts +++ b/src/app/features/settings/account-settings/mappers/account-settings.mapper.ts @@ -1,15 +1,22 @@ -import { ApiData } from '@osf/shared/models'; +import { AccountSettings, AccountSettingsDataJsonApi } from '../models'; -import { AccountSettings, AccountSettingsResponseJsonApi } from '../models'; +export class AccountSettingsMapper { + static getAccountSettings(data: AccountSettingsDataJsonApi): AccountSettings { + return { + twoFactorEnabled: data.attributes.two_factor_enabled, + twoFactorConfirmed: data.attributes.two_factor_confirmed, + subscribeOsfGeneralEmail: data.attributes.subscribe_osf_general_email, + subscribeOsfHelpEmail: data.attributes.subscribe_osf_help_email, + deactivationRequested: data.attributes.deactivation_requested, + contactedDeactivation: data.attributes.contacted_deactivation, + secret: data.attributes.secret, + }; + } -export function MapAccountSettings(data: ApiData): AccountSettings { - return { - twoFactorEnabled: data.attributes.two_factor_enabled, - twoFactorConfirmed: data.attributes.two_factor_confirmed, - subscribeOsfGeneralEmail: data.attributes.subscribe_osf_general_email, - subscribeOsfHelpEmail: data.attributes.subscribe_osf_help_email, - deactivationRequested: data.attributes.deactivation_requested, - contactedDeactivation: data.attributes.contacted_deactivation, - secret: data.attributes.secret, - }; + static getEmailSettingsRequest(accountSettings: Partial): Record { + return { + subscribe_osf_general_email: `${accountSettings.subscribeOsfGeneralEmail}`, + subscribe_osf_help_email: `${accountSettings.subscribeOsfHelpEmail}`, + }; + } } diff --git a/src/app/features/settings/account-settings/mappers/index.ts b/src/app/features/settings/account-settings/mappers/index.ts index 8c3358f1a..aeb229f85 100644 --- a/src/app/features/settings/account-settings/mappers/index.ts +++ b/src/app/features/settings/account-settings/mappers/index.ts @@ -1,3 +1,2 @@ export * from './account-settings.mapper'; export * from './external-identities.mapper'; -export * from './regions.mapper'; diff --git a/src/app/features/settings/account-settings/mappers/regions.mapper.ts b/src/app/features/settings/account-settings/mappers/regions.mapper.ts deleted file mode 100644 index b817a06f6..000000000 --- a/src/app/features/settings/account-settings/mappers/regions.mapper.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ApiData, IdName } from '@osf/shared/models'; - -export function MapRegions(data: ApiData<{ name: string }, null, null, null>[]): IdName[] { - const regions: IdName[] = []; - - for (const region of data) { - regions.push(MapRegion(region)); - } - - return regions; -} - -export function MapRegion(data: ApiData<{ name: string }, null, null, null>): IdName { - return { - id: data.id, - name: data.attributes.name, - }; -} diff --git a/src/app/features/settings/account-settings/models/responses/account-settings-response-json-api.model.ts b/src/app/features/settings/account-settings/models/responses/account-settings-response-json-api.model.ts new file mode 100644 index 000000000..6689ceb08 --- /dev/null +++ b/src/app/features/settings/account-settings/models/responses/account-settings-response-json-api.model.ts @@ -0,0 +1,19 @@ +import { ResponseDataJsonApi } from '@osf/shared/models'; + +export type AccountSettingsResponseJsonApi = ResponseDataJsonApi; + +export interface AccountSettingsDataJsonApi { + id: string; + type: 'user-settings'; + attributes: AccountSettingsAttributesJsonApi; +} + +export interface AccountSettingsAttributesJsonApi { + contacted_deactivation: boolean; + deactivation_requested: boolean; + secret: string; + subscribe_osf_general_email: boolean; + subscribe_osf_help_email: boolean; + two_factor_confirmed: boolean; + two_factor_enabled: boolean; +} diff --git a/src/app/features/settings/account-settings/models/responses/get-account-settings-response.model.ts b/src/app/features/settings/account-settings/models/responses/get-account-settings-response.model.ts deleted file mode 100644 index 6e2d24f92..000000000 --- a/src/app/features/settings/account-settings/models/responses/get-account-settings-response.model.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ApiData } from '@osf/shared/models'; - -export type GetAccountSettingsResponseJsonApi = ApiData; - -export interface AccountSettingsResponseJsonApi { - two_factor_enabled: boolean; - two_factor_confirmed: boolean; - subscribe_osf_general_email: boolean; - subscribe_osf_help_email: boolean; - deactivation_requested: boolean; - contacted_deactivation: boolean; - secret: string; -} diff --git a/src/app/features/settings/account-settings/models/responses/get-regions-response.model.ts b/src/app/features/settings/account-settings/models/responses/get-regions-response.model.ts deleted file mode 100644 index c6a709b32..000000000 --- a/src/app/features/settings/account-settings/models/responses/get-regions-response.model.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { ApiData, JsonApiResponse } from '@osf/shared/models'; - -export type GetRegionsResponseJsonApi = JsonApiResponse[], null>; -export type GetRegionResponseJsonApi = JsonApiResponse, null>; diff --git a/src/app/features/settings/account-settings/models/responses/index.ts b/src/app/features/settings/account-settings/models/responses/index.ts index 8cfd8da94..3efc0ed9e 100644 --- a/src/app/features/settings/account-settings/models/responses/index.ts +++ b/src/app/features/settings/account-settings/models/responses/index.ts @@ -1,3 +1,2 @@ -export * from './get-account-settings-response.model'; -export * from './get-regions-response.model'; +export * from './account-settings-response-json-api.model'; export * from './list-identities-response.model'; 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 35321259a..55a7529f5 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,34 +2,52 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; import { UserMapper } from '@osf/shared/mappers'; -import { IdName, JsonApiResponse, User, UserGetResponse } from '@osf/shared/models'; +import { UserDataJsonApi, UserModel } from '@osf/shared/models'; import { JsonApiService } from '@osf/shared/services'; -import { MapAccountSettings, MapExternalIdentities, MapRegions } from '../mappers'; +import { AccountSettingsMapper, MapExternalIdentities } from '../mappers'; import { AccountSettings, + AccountSettingsDataJsonApi, + AccountSettingsResponseJsonApi, ExternalIdentity, - GetAccountSettingsResponseJsonApi, - GetRegionsResponseJsonApi, ListIdentitiesResponseJsonApi, } from '../models'; -import { environment } from 'src/environments/environment'; - @Injectable({ providedIn: 'root', }) export class AccountSettingsService { private readonly jsonApiService = inject(JsonApiService); + private readonly environment = inject(ENVIRONMENT); + + get apiUrl() { + return `${this.environment.apiDomainUrl}/v2`; + } + + getSettings(): Observable { + return this.jsonApiService + .get(`${this.apiUrl}/users/me/settings/`) + .pipe(map((response) => AccountSettingsMapper.getAccountSettings(response.data))); + } + + updateSettings(userId: string, settings: Record): Observable { + const body = { + data: { + id: userId, + attributes: settings, + type: 'user_settings', + }, + }; - getRegions(): Observable { return this.jsonApiService - .get(`${environment.apiUrl}/regions/`) - .pipe(map((response) => MapRegions(response.data))); + .patch(`${this.apiUrl}/users/${userId}/settings/`, body) + .pipe(map((response) => AccountSettingsMapper.getAccountSettings(response))); } - updateLocation(userId: string, locationId: string): Observable { + updateLocation(userId: string, locationId: string): Observable { const body = { data: { id: userId, @@ -47,11 +65,11 @@ export class AccountSettingsService { }; return this.jsonApiService - .patch(`${environment.apiUrl}/users/${userId}/`, body) + .patch(`${this.apiUrl}/users/${userId}/`, body) .pipe(map((user) => UserMapper.fromUserGetResponse(user))); } - updateIndexing(userId: string, allowIndexing: boolean): Observable { + updateIndexing(userId: string, allowIndexing: boolean): Observable { const body = { data: { id: userId, @@ -64,7 +82,7 @@ export class AccountSettingsService { }; return this.jsonApiService - .patch(`${environment.apiUrl}/users/${userId}/`, body) + .patch(`${this.apiUrl}/users/${userId}/`, body) .pipe(map((user) => UserMapper.fromUserGetResponse(user))); } @@ -79,7 +97,7 @@ export class AccountSettingsService { }, }; - return this.jsonApiService.post(`${environment.apiUrl}/users/me/settings/password/`, body); + return this.jsonApiService.post(`${this.apiUrl}/users/me/settings/password/`, body); } getExternalIdentities(): Observable { @@ -89,31 +107,11 @@ export class AccountSettingsService { }; return this.jsonApiService - .get(`${environment.apiUrl}/users/me/settings/identities/`, params) + .get(`${this.apiUrl}/users/me/settings/identities/`, params) .pipe(map((response) => MapExternalIdentities(response.data))); } deleteExternalIdentity(id: string): Observable { - return this.jsonApiService.delete(`${environment.apiUrl}/users/me/settings/identities/${id}/`); - } - - getSettings(): Observable { - return this.jsonApiService - .get>(`${environment.apiUrl}/users/me/settings/`) - .pipe(map((response) => MapAccountSettings(response.data))); - } - - updateSettings(userId: string, settings: Record): Observable { - const body = { - data: { - id: userId, - attributes: settings, - type: 'user_settings', - }, - }; - - return this.jsonApiService - .patch(`${environment.apiUrl}/users/${userId}/settings`, body) - .pipe(map((response) => MapAccountSettings(response))); + return this.jsonApiService.delete(`${this.apiUrl}/users/me/settings/identities/${id}/`); } } diff --git a/src/app/features/settings/account-settings/store/account-settings.actions.ts b/src/app/features/settings/account-settings/store/account-settings.actions.ts index 91722ee51..db53fa534 100644 --- a/src/app/features/settings/account-settings/store/account-settings.actions.ts +++ b/src/app/features/settings/account-settings/store/account-settings.actions.ts @@ -1,6 +1,4 @@ -export class GetRegions { - static readonly type = '[AccountSettings] Get Regions'; -} +import { AccountSettings } from '../models'; export class UpdateRegion { static readonly type = '[AccountSettings] Update Region'; @@ -44,7 +42,7 @@ export class GetAccountSettings { export class UpdateAccountSettings { static readonly type = '[AccountSettings] Update Account Settings'; - constructor(public accountSettings: Record) {} + constructor(public accountSettings: Partial) {} } export class DisableTwoFactorAuth { diff --git a/src/app/features/settings/account-settings/store/account-settings.model.ts b/src/app/features/settings/account-settings/store/account-settings.model.ts index fd9409d2b..f65ca59a6 100644 --- a/src/app/features/settings/account-settings/store/account-settings.model.ts +++ b/src/app/features/settings/account-settings/store/account-settings.model.ts @@ -1,25 +1,19 @@ -import { IdName, Institution } from '@shared/models'; +import { AsyncStateModel, Institution } from '@shared/models'; import { AccountSettings, ExternalIdentity } from '../models'; export interface AccountSettingsStateModel { - regions: IdName[]; externalIdentities: ExternalIdentity[]; - accountSettings: AccountSettings; + accountSettings: AsyncStateModel; userInstitutions: Institution[]; } export const ACCOUNT_SETTINGS_STATE_DEFAULTS: AccountSettingsStateModel = { - regions: [], externalIdentities: [], accountSettings: { - twoFactorEnabled: false, - twoFactorConfirmed: false, - subscribeOsfGeneralEmail: false, - subscribeOsfHelpEmail: false, - deactivationRequested: false, - contactedDeactivation: false, - secret: '', + data: null, + isLoading: false, + error: null, }, userInstitutions: [], }; diff --git a/src/app/features/settings/account-settings/store/account-settings.selectors.ts b/src/app/features/settings/account-settings/store/account-settings.selectors.ts index bc19d6481..ca7a1298b 100644 --- a/src/app/features/settings/account-settings/store/account-settings.selectors.ts +++ b/src/app/features/settings/account-settings/store/account-settings.selectors.ts @@ -1,6 +1,6 @@ import { Selector } from '@ngxs/store'; -import { IdName, Institution } from '@shared/models'; +import { Institution } from '@shared/models'; import { AccountSettings, ExternalIdentity } from '../models'; @@ -9,28 +9,28 @@ import { AccountSettingsState } from './account-settings.state'; export class AccountSettingsSelectors { @Selector([AccountSettingsState]) - static getRegions(state: AccountSettingsStateModel): IdName[] { - return state.regions; + static getExternalIdentities(state: AccountSettingsStateModel): ExternalIdentity[] { + return state.externalIdentities; } @Selector([AccountSettingsState]) - static getExternalIdentities(state: AccountSettingsStateModel): ExternalIdentity[] { - return state.externalIdentities; + static getAccountSettings(state: AccountSettingsStateModel): AccountSettings | null { + return state.accountSettings.data; } @Selector([AccountSettingsState]) - static getAccountSettings(state: AccountSettingsStateModel): AccountSettings | undefined { - return state.accountSettings; + static areAccountSettingsLoading(state: AccountSettingsStateModel): boolean { + return state.accountSettings.isLoading; } @Selector([AccountSettingsState]) static getTwoFactorEnabled(state: AccountSettingsStateModel): boolean { - return state.accountSettings?.twoFactorEnabled ?? false; + return state.accountSettings.data?.twoFactorEnabled ?? false; } @Selector([AccountSettingsState]) static getTwoFactorSecret(state: AccountSettingsStateModel): string { - return state.accountSettings?.secret ?? ''; + return state.accountSettings.data?.secret ?? ''; } @Selector([AccountSettingsState]) 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 fb57cac16..500db92d3 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 @@ -5,8 +5,10 @@ import { catchError, tap, throwError } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { SetCurrentUser, UserSelectors } from '@core/store/user'; +import { handleSectionError } from '@osf/shared/helpers'; import { InstitutionsService } from '@shared/services'; +import { AccountSettingsMapper } from '../mappers'; import { AccountSettingsService } from '../services'; import { @@ -18,7 +20,6 @@ import { EnableTwoFactorAuth, GetAccountSettings, GetExternalIdentities, - GetRegions, GetUserInstitutions, UpdateAccountSettings, UpdateIndexing, @@ -38,14 +39,6 @@ export class AccountSettingsState { private readonly institutionsService = inject(InstitutionsService); private readonly store = inject(Store); - @Action(GetRegions) - getRegions(ctx: StateContext) { - return this.accountSettingsService.getRegions().pipe( - tap((regions) => ctx.patchState({ regions: regions })), - catchError((error) => throwError(() => error)) - ); - } - @Action(UpdateRegion) updateRegion(ctx: StateContext, action: UpdateRegion) { const currentUser = this.store.selectSnapshot(UserSelectors.getCurrentUser); @@ -108,9 +101,19 @@ export class AccountSettingsState { @Action(GetAccountSettings) getAccountSettings(ctx: StateContext) { + ctx.patchState({ accountSettings: { ...ctx.getState().accountSettings, isLoading: true } }); + return this.accountSettingsService.getSettings().pipe( - tap((settings) => ctx.patchState({ accountSettings: settings })), - catchError((error) => throwError(() => error)) + tap((settings) => + ctx.patchState({ + accountSettings: { + data: settings, + isLoading: false, + error: null, + }, + }) + ), + catchError((error) => handleSectionError(ctx, 'accountSettings', error)) ); } @@ -122,9 +125,21 @@ export class AccountSettingsState { return; } - return this.accountSettingsService.updateSettings(currentUser.id, action.accountSettings).pipe( - tap((settings) => ctx.patchState({ accountSettings: settings })), - catchError((error) => throwError(() => error)) + ctx.patchState({ accountSettings: { ...ctx.getState().accountSettings, isLoading: true } }); + + const payload = AccountSettingsMapper.getEmailSettingsRequest(action.accountSettings); + + return this.accountSettingsService.updateSettings(currentUser.id, payload).pipe( + tap((settings) => + ctx.patchState({ + accountSettings: { + data: settings, + isLoading: false, + error: null, + }, + }) + ), + catchError((error) => handleSectionError(ctx, 'accountSettings', error)) ); } @@ -137,8 +152,16 @@ export class AccountSettingsState { } return this.accountSettingsService.updateSettings(currentUser.id, { two_factor_enabled: 'false' }).pipe( - tap((settings) => ctx.patchState({ accountSettings: settings })), - catchError((error) => throwError(() => error)) + tap((settings) => + ctx.patchState({ + accountSettings: { + data: settings, + isLoading: false, + error: null, + }, + }) + ), + catchError((error) => handleSectionError(ctx, 'accountSettings', error)) ); } @@ -151,8 +174,16 @@ export class AccountSettingsState { } return this.accountSettingsService.updateSettings(currentUser.id, { two_factor_enabled: 'true' }).pipe( - tap((settings) => ctx.patchState({ accountSettings: settings })), - catchError((error) => throwError(() => error)) + tap((settings) => + ctx.patchState({ + accountSettings: { + data: settings, + isLoading: false, + error: null, + }, + }) + ), + catchError((error) => handleSectionError(ctx, 'accountSettings', error)) ); } @@ -165,8 +196,16 @@ export class AccountSettingsState { } return this.accountSettingsService.updateSettings(currentUser.id, { two_factor_verification: action.code }).pipe( - tap((settings) => ctx.patchState({ accountSettings: settings })), - catchError((error) => throwError(() => error)) + tap((settings) => + ctx.patchState({ + accountSettings: { + data: settings, + isLoading: false, + error: null, + }, + }) + ), + catchError((error) => handleSectionError(ctx, 'accountSettings', error)) ); } @@ -179,8 +218,16 @@ export class AccountSettingsState { } return this.accountSettingsService.updateSettings(currentUser.id, { deactivation_requested: 'true' }).pipe( - tap((settings) => ctx.patchState({ accountSettings: settings })), - catchError((error) => throwError(() => error)) + tap((settings) => + ctx.patchState({ + accountSettings: { + data: settings, + isLoading: false, + error: null, + }, + }) + ), + catchError((error) => handleSectionError(ctx, 'accountSettings', error)) ); } @@ -193,8 +240,16 @@ export class AccountSettingsState { } return this.accountSettingsService.updateSettings(currentUser.id, { deactivation_requested: 'false' }).pipe( - tap((settings) => ctx.patchState({ accountSettings: settings })), - catchError((error) => throwError(() => error)) + tap((settings) => + ctx.patchState({ + accountSettings: { + data: settings, + isLoading: false, + error: null, + }, + }) + ), + catchError((error) => handleSectionError(ctx, 'accountSettings', error)) ); } diff --git a/src/app/features/settings/addons/addons.component.html b/src/app/features/settings/addons/addons.component.html deleted file mode 100644 index d6ea70db0..000000000 --- a/src/app/features/settings/addons/addons.component.html +++ /dev/null @@ -1,78 +0,0 @@ - -
- - - - - - -

- {{ 'settings.addons.description' | translate }} -

- -
-
- -
-
- -
-
- - @if (!isAddonsLoading()) { - - } @else { -
- -
- } -
- - -

- {{ 'settings.addons.connectedDescription' | translate }} -

- - - @if (!isAuthorizedAddonsLoading()) { - - } @else { -
- -
- } -
-
-
-
diff --git a/src/app/features/settings/addons/addons.component.spec.ts b/src/app/features/settings/addons/addons.component.spec.ts deleted file mode 100644 index 97d58cf8f..000000000 --- a/src/app/features/settings/addons/addons.component.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { MockComponents, MockProvider } from 'ng-mocks'; - -import { of } from 'rxjs'; - -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { UserSelectors } from '@osf/core/store/user'; -import { SearchInputComponent, SubHeaderComponent } from '@osf/shared/components'; -import { IS_XSMALL } from '@osf/shared/helpers'; -import { AddonCardListComponent } from '@shared/components/addons'; -import { TranslateServiceMock } from '@shared/mocks'; -import { AddonsSelectors } from '@shared/stores/addons'; - -import { AddonsComponent } from './addons.component'; - -describe('AddonsComponent', () => { - let component: AddonsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [AddonsComponent, ...MockComponents(SubHeaderComponent, SearchInputComponent, AddonCardListComponent)], - providers: [ - TranslateServiceMock, - - MockProvider(Store, { - selectSignal: jest.fn().mockImplementation((selector) => { - if (selector === UserSelectors.getCurrentUser) { - return () => ({ id: 'test-user-id' }); - } - if (selector === AddonsSelectors.getAddonsUserReference) { - return () => [{ id: 'test-reference-id' }]; - } - if (selector === AddonsSelectors.getStorageAddons) { - return () => []; - } - if (selector === AddonsSelectors.getCitationAddons) { - return () => []; - } - if (selector === AddonsSelectors.getAuthorizedStorageAddons) { - return () => []; - } - if (selector === AddonsSelectors.getAuthorizedCitationAddons) { - return () => []; - } - return () => null; - }), - dispatch: jest.fn(), - }), - MockProvider(IS_XSMALL, of(false)), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(AddonsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should render the connected description paragraph', () => { - component['selectedTab'].set(component['AddonTabValue'].ALL_ADDONS); - fixture.detectChanges(); - const compiled: HTMLElement = fixture.nativeElement; - const p = compiled.querySelector('p'); - expect(p).toBeTruthy(); - expect(p?.textContent?.trim()).toContain('settings.addons.description'); - }); - - it('should render the connected description paragraph', () => { - component['selectedTab'].set(component['AddonTabValue'].CONNECTED_ADDONS); - fixture.detectChanges(); - const compiled: HTMLElement = fixture.nativeElement; - const p = compiled.querySelector('p'); - expect(p).toBeTruthy(); - expect(p?.textContent?.trim()).toContain('settings.addons.connectedDescription'); - }); -}); diff --git a/src/app/features/settings/addons/addons.component.ts b/src/app/features/settings/addons/addons.component.ts deleted file mode 100644 index 2b47a827b..000000000 --- a/src/app/features/settings/addons/addons.component.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { createDispatchMap, select } from '@ngxs/store'; - -import { TranslatePipe } from '@ngx-translate/core'; - -import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; - -import { debounceTime, distinctUntilChanged } from 'rxjs'; - -import { ChangeDetectionStrategy, Component, computed, effect, signal } from '@angular/core'; -import { FormControl, FormsModule } from '@angular/forms'; - -import { UserSelectors } from '@osf/core/store/user'; -import { - LoadingSpinnerComponent, - SearchInputComponent, - SelectComponent, - SubHeaderComponent, -} from '@osf/shared/components'; -import { Primitive } from '@osf/shared/helpers'; -import { AddonCardListComponent } from '@shared/components/addons'; -import { ADDON_CATEGORY_OPTIONS, ADDON_TAB_OPTIONS } from '@shared/constants'; -import { AddonCategory, AddonTabValue } from '@shared/enums'; -import { - AddonsSelectors, - CreateAuthorizedAddon, - DeleteAuthorizedAddon, - GetAddonsUserReference, - GetAuthorizedCitationAddons, - GetAuthorizedStorageAddons, - GetCitationAddons, - GetStorageAddons, - UpdateAuthorizedAddon, -} from '@shared/stores/addons'; - -@Component({ - selector: 'osf-addons', - imports: [ - SubHeaderComponent, - TabList, - Tabs, - Tab, - TabPanel, - TabPanels, - SearchInputComponent, - AddonCardListComponent, - SelectComponent, - FormsModule, - TranslatePipe, - LoadingSpinnerComponent, - ], - templateUrl: './addons.component.html', - styleUrl: './addons.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class AddonsComponent { - protected readonly tabOptions = ADDON_TAB_OPTIONS; - protected readonly categoryOptions = ADDON_CATEGORY_OPTIONS; - - protected AddonTabValue = AddonTabValue; - protected defaultTabValue = AddonTabValue.ALL_ADDONS; - - protected searchControl = new FormControl(''); - - protected searchValue = signal(''); - protected selectedCategory = signal(AddonCategory.EXTERNAL_STORAGE_SERVICES); - protected selectedTab = signal(this.defaultTabValue); - - protected currentUser = select(UserSelectors.getCurrentUser); - protected addonsUserReference = select(AddonsSelectors.getAddonsUserReference); - protected storageAddons = select(AddonsSelectors.getStorageAddons); - protected citationAddons = select(AddonsSelectors.getCitationAddons); - protected authorizedStorageAddons = select(AddonsSelectors.getAuthorizedStorageAddons); - protected authorizedCitationAddons = select(AddonsSelectors.getAuthorizedCitationAddons); - - protected isCurrentUserLoading = select(UserSelectors.getCurrentUserLoading); - protected isUserReferenceLoading = select(AddonsSelectors.getAddonsUserReferenceLoading); - protected isStorageAddonsLoading = select(AddonsSelectors.getStorageAddonsLoading); - protected isCitationAddonsLoading = select(AddonsSelectors.getCitationAddonsLoading); - protected isAuthorizedStorageAddonsLoading = select(AddonsSelectors.getAuthorizedStorageAddonsLoading); - protected isAuthorizedCitationAddonsLoading = select(AddonsSelectors.getAuthorizedCitationAddonsLoading); - - protected isAddonsLoading = computed(() => { - return ( - this.isStorageAddonsLoading() || - this.isCitationAddonsLoading() || - this.isUserReferenceLoading() || - this.isCurrentUserLoading() - ); - }); - protected isAuthorizedAddonsLoading = computed(() => { - return ( - this.isAuthorizedStorageAddonsLoading() || - this.isAuthorizedCitationAddonsLoading() || - this.isUserReferenceLoading() || - this.isCurrentUserLoading() - ); - }); - - protected actions = createDispatchMap({ - getStorageAddons: GetStorageAddons, - getCitationAddons: GetCitationAddons, - getAuthorizedStorageAddons: GetAuthorizedStorageAddons, - getAuthorizedCitationAddons: GetAuthorizedCitationAddons, - createAuthorizedAddon: CreateAuthorizedAddon, - updateAuthorizedAddon: UpdateAuthorizedAddon, - getAddonsUserReference: GetAddonsUserReference, - deleteAuthorizedAddon: DeleteAuthorizedAddon, - }); - - protected readonly allAuthorizedAddons = computed(() => { - const authorizedAddons = [...this.authorizedStorageAddons(), ...this.authorizedCitationAddons()]; - - const searchValue = this.searchValue().toLowerCase(); - return authorizedAddons.filter((card) => card.displayName.includes(searchValue)); - }); - - protected readonly userReferenceId = computed(() => { - return this.addonsUserReference()[0]?.id; - }); - - protected readonly currentAction = computed(() => - this.selectedCategory() === AddonCategory.EXTERNAL_STORAGE_SERVICES - ? this.actions.getStorageAddons - : this.actions.getCitationAddons - ); - - protected readonly currentAddonsState = computed(() => - this.selectedCategory() === AddonCategory.EXTERNAL_STORAGE_SERVICES ? this.storageAddons() : this.citationAddons() - ); - - protected readonly filteredAddonCards = computed(() => { - const searchValue = this.searchValue().toLowerCase(); - return this.currentAddonsState().filter((card) => card.externalServiceName.toLowerCase().includes(searchValue)); - }); - - protected onCategoryChange(value: Primitive): void { - if (typeof value === 'string') { - this.selectedCategory.set(value); - } - } - - constructor() { - // TODO There should not be three effects - effect(() => { - if (this.currentUser()) { - this.actions.getAddonsUserReference(); - } - }); - - // TODO There should not be three effects - effect(() => { - if (this.currentUser() && this.userReferenceId()) { - const action = this.currentAction(); - const addons = this.currentAddonsState(); - - if (!addons?.length) { - action(); - } - } - }); - - // TODO There should not be three effects - effect(() => { - if (this.currentUser() && this.userReferenceId()) { - this.fetchAllAuthorizedAddons(this.userReferenceId()); - } - }); - - this.searchControl.valueChanges.pipe(debounceTime(300), distinctUntilChanged()).subscribe((value) => { - this.searchValue.set(value ?? ''); - }); - } - - private fetchAllAuthorizedAddons(userReferenceId: string): void { - this.actions.getAuthorizedStorageAddons(userReferenceId); - this.actions.getAuthorizedCitationAddons(userReferenceId); - } -} diff --git a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts deleted file mode 100644 index 8d60665c1..000000000 --- a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { createDispatchMap, select } from '@ngxs/store'; - -import { TranslatePipe } from '@ngx-translate/core'; - -import { Button } from 'primeng/button'; -import { StepPanel, StepPanels, Stepper } from 'primeng/stepper'; -import { TableModule } from 'primeng/table'; - -import { Component, computed, effect, inject, signal, viewChild } from '@angular/core'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { Router, RouterLink } from '@angular/router'; - -import { SubHeaderComponent } from '@osf/shared/components'; -import { ProjectAddonsStepperValue } from '@osf/shared/enums'; -import { getAddonTypeString, isAuthorizedAddon } from '@osf/shared/helpers'; -import { AddonSetupAccountFormComponent, AddonTermsComponent } from '@shared/components/addons'; -import { AddonModel, AddonTerm, AuthorizedAccountModel, AuthorizedAddonRequestJsonApi } from '@shared/models'; -import { AddonsSelectors, CreateAuthorizedAddon, UpdateAuthorizedAddon } from '@shared/stores/addons'; - -@Component({ - selector: 'osf-connect-addon', - imports: [ - SubHeaderComponent, - StepPanel, - StepPanels, - Stepper, - Button, - TableModule, - RouterLink, - FormsModule, - ReactiveFormsModule, - TranslatePipe, - AddonTermsComponent, - AddonSetupAccountFormComponent, - ], - templateUrl: './connect-addon.component.html', - styleUrl: './connect-addon.component.scss', -}) -export class ConnectAddonComponent { - private readonly router = inject(Router); - - protected readonly stepper = viewChild(Stepper); - protected readonly ProjectAddonsStepperValue = ProjectAddonsStepperValue; - - protected terms = signal([]); - protected addon = signal(null); - protected addonAuthUrl = signal('/settings/addons'); - - protected addonsUserReference = select(AddonsSelectors.getAddonsUserReference); - protected createdAddon = select(AddonsSelectors.getCreatedOrUpdatedAuthorizedAddon); - protected isCreatingAuthorizedAddon = select(AddonsSelectors.getCreatedOrUpdatedStorageAddonSubmitting); - protected isAuthorized = computed(() => { - return isAuthorizedAddon(this.addon()); - }); - protected addonTypeString = computed(() => { - return getAddonTypeString(this.addon()); - }); - - protected actions = createDispatchMap({ - createAuthorizedAddon: CreateAuthorizedAddon, - updateAuthorizedAddon: UpdateAuthorizedAddon, - }); - - protected readonly userReferenceId = computed(() => { - return this.addonsUserReference()[0]?.id; - }); - protected readonly baseUrl = computed(() => { - const currentUrl = this.router.url; - return currentUrl.split('/addons')[0]; - }); - - constructor() { - const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as AddonModel | AuthorizedAccountModel; - if (!addon) { - this.router.navigate([`${this.baseUrl()}/addons`]); - } - this.addon.set(addon); - - effect(() => { - if (this.isAuthorized()) { - this.stepper()?.value.set(ProjectAddonsStepperValue.SETUP_NEW_ACCOUNT); - } - }); - } - - handleConnectAuthorizedAddon(payload: AuthorizedAddonRequestJsonApi): void { - if (!this.addon()) return; - - (!this.isAuthorized() - ? this.actions.createAuthorizedAddon(payload, this.addonTypeString()) - : this.actions.updateAuthorizedAddon(payload, this.addonTypeString(), this.addon()!.id) - ).subscribe({ - complete: () => { - const createdAddon = this.createdAddon(); - if (createdAddon) { - this.addonAuthUrl.set(createdAddon.attributes.auth_url); - window.open(createdAddon.attributes.auth_url, '_blank'); - this.stepper()?.value.set(ProjectAddonsStepperValue.AUTH); - } - }, - }); - } -} diff --git a/src/app/features/settings/developer-apps/developer-apps-container.component.spec.ts b/src/app/features/settings/developer-apps/developer-apps-container.component.spec.ts index 119858c14..feb974552 100644 --- a/src/app/features/settings/developer-apps/developer-apps-container.component.spec.ts +++ b/src/app/features/settings/developer-apps/developer-apps-container.component.spec.ts @@ -3,12 +3,11 @@ import { MockPipe, MockProvider } from 'ng-mocks'; import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { BehaviorSubject, Subject } from 'rxjs'; +import { Subject } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DeveloperAppAddEditFormComponent } from '@osf/features/settings/developer-apps/components'; -import { IS_MEDIUM } from '@osf/shared/helpers'; import { TranslateServiceMock } from '@shared/mocks'; import { ToastService } from '@shared/services'; @@ -17,7 +16,6 @@ import { DeveloperAppsContainerComponent } from './developer-apps-container.comp describe('DeveloperAppsContainerComponent', () => { let component: DeveloperAppsContainerComponent; let fixture: ComponentFixture; - let isMedium: BehaviorSubject; let translateService: TranslateService; let dialogRefMock: Partial; let dialogService: DialogService; @@ -25,17 +23,11 @@ describe('DeveloperAppsContainerComponent', () => { let translateSpy: jest.SpyInstance; beforeEach(async () => { - isMedium = new BehaviorSubject(false); dialogRefMock = { onClose: new Subject() }; await TestBed.configureTestingModule({ imports: [DeveloperAppsContainerComponent, MockPipe(TranslatePipe)], - providers: [ - MockProvider(DialogService), - MockProvider(IS_MEDIUM, isMedium), - MockProvider(ToastService), - TranslateServiceMock, - ], + providers: [MockProvider(DialogService), MockProvider(ToastService), TranslateServiceMock], }).compileComponents(); fixture = TestBed.createComponent(DeveloperAppsContainerComponent); @@ -54,31 +46,14 @@ describe('DeveloperAppsContainerComponent', () => { expect(component).toBeTruthy(); }); - it('should open dialog with 340px width when isMedium returns false', () => { - (component as any).isMedium = jest.fn().mockReturnValue(false); - - component.createDeveloperApp(); - - expect(openSpy).toHaveBeenCalledWith(DeveloperAppAddEditFormComponent, { - width: '340px', - focusOnShow: false, - header: 'Create Developer App', - closeOnEscape: true, - modal: true, - closable: true, - }); - expect(translateSpy).toHaveBeenCalledWith('settings.developerApps.form.createTitle'); - }); - - it('should open dialog with 500px width when isMedium returns true', () => { - (component as any).isMedium = jest.fn().mockReturnValue(true); - + it('should open dialog with 500px width', () => { component.createDeveloperApp(); expect(openSpy).toHaveBeenCalledWith(DeveloperAppAddEditFormComponent, { width: '500px', focusOnShow: false, header: 'Create Developer App', + breakpoints: { '768px': '95vw' }, closeOnEscape: true, modal: true, closable: true, diff --git a/src/app/features/settings/developer-apps/developer-apps-container.component.ts b/src/app/features/settings/developer-apps/developer-apps-container.component.ts index 64f62fdc2..aa08a715b 100644 --- a/src/app/features/settings/developer-apps/developer-apps-container.component.ts +++ b/src/app/features/settings/developer-apps/developer-apps-container.component.ts @@ -1,6 +1,4 @@ -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; - -import { DialogService } from 'primeng/dynamicdialog'; +import { TranslatePipe } from '@ngx-translate/core'; import { map } from 'rxjs'; @@ -9,7 +7,7 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { Router, RouterOutlet } from '@angular/router'; import { SubHeaderComponent } from '@osf/shared/components'; -import { IS_MEDIUM } from '@osf/shared/helpers'; +import { CustomDialogService } from '@osf/shared/services'; import { DeveloperAppAddEditFormComponent } from './components'; @@ -18,30 +16,20 @@ import { DeveloperAppAddEditFormComponent } from './components'; imports: [RouterOutlet, SubHeaderComponent, TranslatePipe], templateUrl: './developer-apps-container.component.html', styleUrl: './developer-apps-container.component.scss', - providers: [DialogService], changeDetection: ChangeDetectionStrategy.OnPush, }) export class DeveloperAppsContainerComponent { - private readonly dialogService = inject(DialogService); + private readonly customDialogService = inject(CustomDialogService); private readonly router = inject(Router); - private readonly isMedium = toSignal(inject(IS_MEDIUM)); - private readonly translateService = inject(TranslateService); - protected readonly isBaseRoute = toSignal( - this.router.events.pipe(map(() => this.router.url === '/settings/developer-apps')), - { initialValue: this.router.url === '/settings/developer-apps' } - ); + readonly isBaseRoute = toSignal(this.router.events.pipe(map(() => this.router.url === '/settings/developer-apps')), { + initialValue: this.router.url === '/settings/developer-apps', + }); createDeveloperApp(): void { - const dialogWidth = this.isMedium() ? '500px' : '340px'; - - this.dialogService.open(DeveloperAppAddEditFormComponent, { - width: dialogWidth, - focusOnShow: false, - header: this.translateService.instant('settings.developerApps.form.createTitle'), - closeOnEscape: true, - modal: true, - closable: true, + this.customDialogService.open(DeveloperAppAddEditFormComponent, { + header: 'settings.developerApps.form.createTitle', + width: '500px', }); } } diff --git a/src/app/features/settings/developer-apps/pages/developer-app-details/developer-app-details.component.ts b/src/app/features/settings/developer-apps/pages/developer-app-details/developer-app-details.component.ts index 5b39b234a..eee6130bd 100644 --- a/src/app/features/settings/developer-apps/pages/developer-app-details/developer-app-details.component.ts +++ b/src/app/features/settings/developer-apps/pages/developer-app-details/developer-app-details.component.ts @@ -4,7 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; -import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { IconField } from 'primeng/iconfield'; import { InputIcon } from 'primeng/inputicon'; import { InputText } from 'primeng/inputtext'; @@ -38,8 +38,8 @@ import { DeleteDeveloperApp, DeveloperAppsSelectors, GetDeveloperAppDetails, Res ], templateUrl: './developer-app-details.component.html', styleUrl: './developer-app-details.component.scss', - providers: [DialogService, DynamicDialogRef], changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DynamicDialogRef], }) export class DeveloperAppDetailsComponent { private readonly customConfirmationService = inject(CustomConfirmationService); diff --git a/src/app/features/settings/developer-apps/pages/developer-apps-list/developer-apps-list.component.ts b/src/app/features/settings/developer-apps/pages/developer-apps-list/developer-apps-list.component.ts index 11fa6a47c..9916fe92e 100644 --- a/src/app/features/settings/developer-apps/pages/developer-apps-list/developer-apps-list.component.ts +++ b/src/app/features/settings/developer-apps/pages/developer-apps-list/developer-apps-list.component.ts @@ -33,9 +33,7 @@ export class DeveloperAppsListComponent implements OnInit { readonly developerApplications = select(DeveloperAppsSelectors.getDeveloperApps); ngOnInit(): void { - if (!this.developerApplications().length) { - this.actions.getDeveloperApps(); - } + this.actions.getDeveloperApps(); } deleteApp(developerApp: DeveloperApp): void { diff --git a/src/app/features/settings/developer-apps/services/developer-apps.service.ts b/src/app/features/settings/developer-apps/services/developer-apps.service.ts index 200e3a8fa..7fb183628 100644 --- a/src/app/features/settings/developer-apps/services/developer-apps.service.ts +++ b/src/app/features/settings/developer-apps/services/developer-apps.service.ts @@ -2,30 +2,33 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; import { JsonApiResponse } from '@osf/shared/models'; import { JsonApiService } from '@osf/shared/services'; import { DeveloperAppMapper } from '../mappers'; import { DeveloperApp, DeveloperAppCreateUpdate, DeveloperAppGetResponseJsonApi } from '../models'; -import { environment } from 'src/environments/environment'; - @Injectable({ providedIn: 'root', }) export class DeveloperApplicationsService { private readonly jsonApiService = inject(JsonApiService); - private readonly baseUrl = `${environment.apiUrl}/applications/`; + private readonly environment = inject(ENVIRONMENT); + + get apiUrl() { + return `${this.environment.apiDomainUrl}/v2/applications/`; + } getApplications(): Observable { return this.jsonApiService - .get>(this.baseUrl) + .get>(this.apiUrl) .pipe(map((responses) => responses.data.map((response) => DeveloperAppMapper.fromGetResponse(response)))); } getApplicationDetails(clientId: string): Observable { return this.jsonApiService - .get>(`${this.baseUrl}${clientId}/`) + .get>(`${this.apiUrl}${clientId}/`) .pipe(map((response) => DeveloperAppMapper.fromGetResponse(response.data))); } @@ -33,7 +36,7 @@ export class DeveloperApplicationsService { const request = DeveloperAppMapper.toCreateRequest(developerAppCreate); return this.jsonApiService - .post>(this.baseUrl, request) + .post>(this.apiUrl, request) .pipe(map((response) => DeveloperAppMapper.fromGetResponse(response.data))); } @@ -41,7 +44,7 @@ export class DeveloperApplicationsService { const request = DeveloperAppMapper.toUpdateRequest(developerAppUpdate); return this.jsonApiService - .patch(`${this.baseUrl}${clientId}/`, request) + .patch(`${this.apiUrl}${clientId}/`, request) .pipe(map((response) => DeveloperAppMapper.fromGetResponse(response))); } @@ -49,11 +52,11 @@ export class DeveloperApplicationsService { const request = DeveloperAppMapper.toResetSecretRequest(clientId); return this.jsonApiService - .patch(`${this.baseUrl}${clientId}/`, request) + .patch(`${this.apiUrl}${clientId}/`, request) .pipe(map((response) => DeveloperAppMapper.fromGetResponse(response))); } deleteApplication(clientId: string): Observable { - return this.jsonApiService.delete(`${this.baseUrl}${clientId}/`); + return this.jsonApiService.delete(`${this.apiUrl}${clientId}/`); } } diff --git a/src/app/features/settings/notifications/constants/notifications-constants.ts b/src/app/features/settings/notifications/constants/notifications-constants.ts index 7b6bbf1de..943f9a041 100644 --- a/src/app/features/settings/notifications/constants/notifications-constants.ts +++ b/src/app/features/settings/notifications/constants/notifications-constants.ts @@ -7,10 +7,6 @@ export const SUBSCRIPTION_EVENTS: SubscriptionEventModel[] = [ event: SubscriptionEvent.GlobalFileUpdated, labelKey: 'settings.notifications.notificationPreferences.items.files', }, - { - event: SubscriptionEvent.GlobalMentions, - labelKey: 'settings.notifications.notificationPreferences.items.mentions', - }, { event: SubscriptionEvent.GlobalReviews, labelKey: 'settings.notifications.notificationPreferences.items.preprints', diff --git a/src/app/features/settings/notifications/notifications.component.html b/src/app/features/settings/notifications/notifications.component.html index c24010149..20ef38c4d 100644 --- a/src/app/features/settings/notifications/notifications.component.html +++ b/src/app/features/settings/notifications/notifications.component.html @@ -47,7 +47,6 @@

{{ 'settings.notifications.emailPreferences.title' | translate }}

[disabled]="!emailPreferencesForm.dirty" type="submit" [label]="'common.buttons.save' | translate" - [loading]="isSubmittingEmailPreferences()" />
diff --git a/src/app/features/settings/notifications/notifications.component.spec.ts b/src/app/features/settings/notifications/notifications.component.spec.ts index 192cb7d70..745a611aa 100644 --- a/src/app/features/settings/notifications/notifications.component.spec.ts +++ b/src/app/features/settings/notifications/notifications.component.spec.ts @@ -15,17 +15,22 @@ import { UserSelectors } from '@osf/core/store/user'; import { LoaderService, ToastService } from '@osf/shared/services'; import { SubscriptionEvent, SubscriptionFrequency } from '@shared/enums'; import { MOCK_STORE, MOCK_USER, TranslateServiceMock } from '@shared/mocks'; -import { UserSettings } from '@shared/models'; + +import { AccountSettings } from '../account-settings/models'; +import { AccountSettingsSelectors } from '../account-settings/store'; import { NotificationsComponent } from './notifications.component'; import { NotificationSubscriptionSelectors } from './store'; +import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; + describe('NotificationsComponent', () => { let component: NotificationsComponent; let fixture: ComponentFixture; let loaderService: LoaderService; + let toastServiceMock: ReturnType; - const mockUserSettings: UserSettings = { + const mockUserSettings: Partial = { subscribeOsfGeneralEmail: true, subscribeOsfHelpEmail: false, }; @@ -40,10 +45,7 @@ describe('NotificationsComponent', () => { ]; beforeEach(async () => { - const mockToastService = { - showSuccess: jest.fn(), - showError: jest.fn(), - }; + toastServiceMock = ToastServiceMockBuilder.create().build(); const mockLoaderService = { show: jest.fn(), @@ -54,16 +56,13 @@ describe('NotificationsComponent', () => { if (selector === UserSelectors.getCurrentUser) { return signal(MOCK_USER); } - if (selector === UserSelectors.getCurrentUserSettings) { + if (selector === AccountSettingsSelectors.getAccountSettings) { return signal(mockUserSettings); } if (selector === NotificationSubscriptionSelectors.getAllGlobalNotificationSubscriptions) { return signal(mockNotificationSubscriptions); } - if (selector === UserSelectors.isUserSettingsLoading) { - return signal(false); - } - if (selector === UserSelectors.isUserSettingsSubmitting) { + if (selector === AccountSettingsSelectors.areAccountSettingsLoading) { return signal(false); } if (selector === NotificationSubscriptionSelectors.isLoading) { @@ -82,7 +81,7 @@ describe('NotificationsComponent', () => { TranslateServiceMock, MockProvider(Store, MOCK_STORE), MockProvider(LoaderService, mockLoaderService), - MockProvider(ToastService, mockToastService), + MockProvider(ToastService, toastServiceMock), FormBuilder, ], }).compileComponents(); diff --git a/src/app/features/settings/notifications/notifications.component.ts b/src/app/features/settings/notifications/notifications.component.ts index d69669bcd..6a38717e6 100644 --- a/src/app/features/settings/notifications/notifications.component.ts +++ b/src/app/features/settings/notifications/notifications.component.ts @@ -10,12 +10,14 @@ import { Skeleton } from 'primeng/skeleton'; import { ChangeDetectionStrategy, Component, effect, HostBinding, inject, OnInit } from '@angular/core'; import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { GetCurrentUserSettings, UpdateUserSettings, UserSelectors } from '@core/store/user'; +import { UserSelectors } from '@core/store/user'; import { InfoIconComponent, SubHeaderComponent } from '@osf/shared/components'; import { SubscriptionEvent, SubscriptionFrequency } from '@osf/shared/enums'; -import { UserSettings } from '@osf/shared/models'; import { LoaderService, ToastService } from '@osf/shared/services'; +import { AccountSettings } from '../account-settings/models'; +import { AccountSettingsSelectors, GetAccountSettings, UpdateAccountSettings } from '../account-settings/store'; + import { SUBSCRIPTION_EVENTS } from './constants'; import { EmailPreferencesForm, EmailPreferencesFormControls } from './models'; import { @@ -44,9 +46,9 @@ export class NotificationsComponent implements OnInit { @HostBinding('class') classes = 'flex flex-1 flex-column'; private readonly actions = createDispatchMap({ - getCurrentUserSettings: GetCurrentUserSettings, + getAccountSettings: GetAccountSettings, getAllGlobalNotificationSubscriptions: GetAllGlobalNotificationSubscriptions, - updateUserSettings: UpdateUserSettings, + updateAccountSettings: UpdateAccountSettings, updateNotificationSubscription: UpdateNotificationSubscription, }); private readonly fb = inject(FormBuilder); @@ -54,12 +56,10 @@ export class NotificationsComponent implements OnInit { private readonly loaderService = inject(LoaderService); private currentUser = select(UserSelectors.getCurrentUser); - private emailPreferences = select(UserSelectors.getCurrentUserSettings); + private emailPreferences = select(AccountSettingsSelectors.getAccountSettings); private notificationSubscriptions = select(NotificationSubscriptionSelectors.getAllGlobalNotificationSubscriptions); - isEmailPreferencesLoading = select(UserSelectors.isUserSettingsLoading); - isSubmittingEmailPreferences = select(UserSelectors.isUserSettingsSubmitting); - + isEmailPreferencesLoading = select(AccountSettingsSelectors.areAccountSettingsLoading); isNotificationSubscriptionsLoading = select(NotificationSubscriptionSelectors.isLoading); EmailPreferencesFormControls = EmailPreferencesFormControls; @@ -104,7 +104,7 @@ export class NotificationsComponent implements OnInit { } if (!this.emailPreferences()) { - this.actions.getCurrentUserSettings(); + this.actions.getAccountSettings(); } } @@ -113,10 +113,10 @@ export class NotificationsComponent implements OnInit { return; } - const formValue = this.emailPreferencesForm.value as UserSettings; + const formValue = this.emailPreferencesForm.value as Partial; this.loaderService.show(); - this.actions.updateUserSettings(this.currentUser()!.id, formValue).subscribe(() => { + this.actions.updateAccountSettings(formValue).subscribe(() => { this.loaderService.hide(); this.toastService.showSuccess('settings.notifications.emailPreferences.successUpdate'); }); diff --git a/src/app/features/settings/notifications/services/notification-subscription.service.ts b/src/app/features/settings/notifications/services/notification-subscription.service.ts index 576e1a775..b6461ea0b 100644 --- a/src/app/features/settings/notifications/services/notification-subscription.service.ts +++ b/src/app/features/settings/notifications/services/notification-subscription.service.ts @@ -2,6 +2,7 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; import { SubscriptionFrequency } from '@osf/shared/enums'; import { NotificationSubscriptionMapper } from '@osf/shared/mappers'; import { @@ -11,14 +12,16 @@ import { } from '@osf/shared/models'; import { JsonApiService } from '@osf/shared/services'; -import { environment } from 'src/environments/environment'; - @Injectable({ providedIn: 'root', }) export class NotificationSubscriptionService { private readonly jsonApiService = inject(JsonApiService); - private readonly baseUrl = `${environment.apiUrl}/subscriptions/`; + private readonly environment = inject(ENVIRONMENT); + + get apiUrl() { + return `${this.environment.apiDomainUrl}/v2/subscriptions/`; + } getAllGlobalNotificationSubscriptions(): Observable { const params: Record = { @@ -26,7 +29,7 @@ export class NotificationSubscriptionService { }; return this.jsonApiService - .get>(this.baseUrl, params) + .get>(this.apiUrl, params) .pipe( map((responses) => responses.data.map((response) => NotificationSubscriptionMapper.fromGetResponse(response))) ); @@ -36,7 +39,7 @@ export class NotificationSubscriptionService { const request = NotificationSubscriptionMapper.toUpdateRequest(id, frequency); return this.jsonApiService - .patch(`${this.baseUrl}${id}/`, request) + .patch(`${this.apiUrl}${id}/`, request) .pipe(map((response) => NotificationSubscriptionMapper.fromGetResponse(response))); } } diff --git a/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.ts b/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.ts index 2f8555331..0bd778274 100644 --- a/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.ts +++ b/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.ts @@ -2,7 +2,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { ChangeDetectionStrategy, Component, input } from '@angular/core'; -import { User } from '@osf/shared/models'; +import { UserModel } from '@osf/shared/models'; import { CitationFormatPipe } from '@osf/shared/pipes'; @Component({ @@ -13,5 +13,5 @@ import { CitationFormatPipe } from '@osf/shared/pipes'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class CitationPreviewComponent { - currentUser = input.required>(); + currentUser = input.required>(); } diff --git a/src/app/features/settings/profile-settings/components/education-form/education-form.component.html b/src/app/features/settings/profile-settings/components/education-form/education-form.component.html index d839c628f..0f37b89b6 100644 --- a/src/app/features/settings/profile-settings/components/education-form/education-form.component.html +++ b/src/app/features/settings/profile-settings/components/education-form/education-form.component.html @@ -4,14 +4,12 @@

{{ 'settings.profileSettings.education.title' | translate: { index: index() + 1 } }}

- @if (showRemove()) { - - } +
diff --git a/src/app/features/settings/profile-settings/components/education-form/education-form.component.ts b/src/app/features/settings/profile-settings/components/education-form/education-form.component.ts index 86a4e0762..2a498431c 100644 --- a/src/app/features/settings/profile-settings/components/education-form/education-form.component.ts +++ b/src/app/features/settings/profile-settings/components/education-form/education-form.component.ts @@ -32,7 +32,6 @@ export class EducationFormComponent implements OnInit { group = input.required(); index = input.required(); - showRemove = input(false); remove = output(); get institutionControl() { diff --git a/src/app/features/settings/profile-settings/components/education/education.component.html b/src/app/features/settings/profile-settings/components/education/education.component.html index 7e09a236b..68184242a 100644 --- a/src/app/features/settings/profile-settings/components/education/education.component.html +++ b/src/app/features/settings/profile-settings/components/education/education.component.html @@ -1,20 +1,13 @@
- @if (educations.controls.length) { - @for (education of educations.controls; track $index; let index = $index) { - - } + @for (education of educations.controls; track $index; let index = $index) { + }
1) { - this.educations.removeAt(index); - } else { - this.educations.reset(); - } + this.educations.removeAt(index); } addEducation(): void { @@ -123,6 +119,10 @@ export class EducationComponent { ); } + if (formPositions.length !== education.length) { + return true; + } + return this.educations.value.some((formEducation, index) => { const initialEdu = this.educationItems()[index]; if (!initialEdu) return true; @@ -153,16 +153,12 @@ export class EducationComponent { this.educations.clear(); - if (!educations?.length) { - this.addEducation(); - this.cd.markForCheck(); - return; + if (educations?.length) { + educations + .map((education) => mapEducationToForm(education)) + .forEach((education) => this.educations.push(this.createEducationFormGroup(education))); } - educations - .map((education) => mapEducationToForm(education)) - .forEach((education) => this.educations.push(this.createEducationFormGroup(education))); - this.cd.markForCheck(); } } diff --git a/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.html b/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.html index b6c8b7394..f3405dfe1 100644 --- a/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.html +++ b/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.html @@ -4,14 +4,12 @@

{{ 'settings.profileSettings.employment.title' | translate: { index: index() + 1 } }}

- @if (showRemove()) { - - } +
diff --git a/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.ts b/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.ts index d441bcba9..30105abaa 100644 --- a/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.ts +++ b/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.ts @@ -32,7 +32,6 @@ export class EmploymentFormComponent implements OnInit { group = input.required(); index = input.required(); - showRemove = input(false); remove = output(); get titleControl() { diff --git a/src/app/features/settings/profile-settings/components/employment/employment.component.html b/src/app/features/settings/profile-settings/components/employment/employment.component.html index f6612da00..973c65737 100644 --- a/src/app/features/settings/profile-settings/components/employment/employment.component.html +++ b/src/app/features/settings/profile-settings/components/employment/employment.component.html @@ -1,14 +1,7 @@
- @if (positions.controls.length) { - @for (position of positions.controls; track index; let index = $index) { - - } + @for (position of positions.controls; track index; let index = $index) { + }
diff --git a/src/app/features/settings/profile-settings/components/employment/employment.component.ts b/src/app/features/settings/profile-settings/components/employment/employment.component.ts index cd5b7b08e..77c7863f9 100644 --- a/src/app/features/settings/profile-settings/components/employment/employment.component.ts +++ b/src/app/features/settings/profile-settings/components/employment/employment.component.ts @@ -55,11 +55,7 @@ export class EmploymentComponent { } removePosition(index: number): void { - if (this.positions.length > 1) { - this.positions.removeAt(index); - } else { - this.positions.reset(); - } + this.positions.removeAt(index); } addPosition(): void { @@ -93,11 +89,11 @@ export class EmploymentComponent { return; } - const formattedEmployment = this.positions.value.map((position) => mapFormToEmployment(position)); + const employments = this.positions.value.map(mapFormToEmployment); this.loaderService.show(); this.actions - .updateProfileSettingsEmployment(formattedEmployment) + .updateProfileSettingsEmployment(employments) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: () => { @@ -112,18 +108,6 @@ export class EmploymentComponent { const employment = this.employment(); const formPositions = this.positions.value; - if (!employment?.length) { - return formPositions.some( - (position) => - position.title?.trim() || - position.institution?.trim() || - position.department?.trim() || - position.startDate || - position.endDate || - position.ongoing - ); - } - if (formPositions.length !== employment.length) { return true; } @@ -157,16 +141,12 @@ export class EmploymentComponent { const employment = this.employment(); this.positions.clear(); - if (!employment?.length) { - this.addPosition(); - this.cd.markForCheck(); - return; + if (employment?.length) { + employment + .map((x) => mapEmploymentToForm(x)) + .forEach((x) => this.positions.push(this.createEmploymentFormGroup(x))); } - employment - .map((x) => mapEmploymentToForm(x)) - .forEach((x) => this.positions.push(this.createEmploymentFormGroup(x))); - this.cd.markForCheck(); } } diff --git a/src/app/features/settings/profile-settings/components/name/name.component.ts b/src/app/features/settings/profile-settings/components/name/name.component.ts index 5d75a5d01..17a114cce 100644 --- a/src/app/features/settings/profile-settings/components/name/name.component.ts +++ b/src/app/features/settings/profile-settings/components/name/name.component.ts @@ -9,8 +9,9 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder } from '@angular/forms'; import { UpdateProfileSettingsUser, UserSelectors } from '@osf/core/store/user'; +import { forbiddenFileNameCharacters } from '@osf/shared/constants'; import { CustomValidators } from '@osf/shared/helpers'; -import { User } from '@osf/shared/models'; +import { UserModel } from '@osf/shared/models'; import { CustomConfirmationService, LoaderService, ToastService } from '@osf/shared/services'; import { hasNameChanges } from '../../helpers'; @@ -35,14 +36,29 @@ export class NameComponent { readonly actions = createDispatchMap({ updateProfileSettingsUser: UpdateProfileSettingsUser }); readonly currentUser = select(UserSelectors.getUserNames); - readonly previewUser = signal>({}); + readonly previewUser = signal>({}); readonly fb = inject(FormBuilder); readonly form = this.fb.group({ - fullName: this.fb.control('', { nonNullable: true, validators: [CustomValidators.requiredTrimmed()] }), - givenName: this.fb.control('', { nonNullable: true }), - middleNames: this.fb.control('', { nonNullable: true }), - familyName: this.fb.control('', { nonNullable: true }), + fullName: this.fb.control('', { + nonNullable: true, + validators: [ + CustomValidators.requiredTrimmed(), + CustomValidators.forbiddenCharactersValidator(forbiddenFileNameCharacters), + ], + }), + givenName: this.fb.control('', { + nonNullable: true, + validators: CustomValidators.forbiddenCharactersValidator(forbiddenFileNameCharacters), + }), + middleNames: this.fb.control('', { + nonNullable: true, + validators: CustomValidators.forbiddenCharactersValidator(forbiddenFileNameCharacters), + }), + familyName: this.fb.control('', { + nonNullable: true, + validators: CustomValidators.forbiddenCharactersValidator(forbiddenFileNameCharacters), + }), suffix: this.fb.control('', { nonNullable: true }), }); @@ -110,7 +126,7 @@ export class NameComponent { return hasNameChanges(this.form.controls, user); } - private updateForm(user: Partial) { + private updateForm(user: Partial) { this.form.patchValue({ fullName: user.fullName, givenName: user.givenName, diff --git a/src/app/features/settings/profile-settings/components/social-form/social-form.component.html b/src/app/features/settings/profile-settings/components/social-form/social-form.component.html index 878722f7f..b322b464c 100644 --- a/src/app/features/settings/profile-settings/components/social-form/social-form.component.html +++ b/src/app/features/settings/profile-settings/components/social-form/social-form.component.html @@ -22,7 +22,7 @@

@if (hasLinkedField()) { - .academia.edu/ + {{ linkedDomain() }} !!this.socialOutput()?.linkedField); readonly linkedLabel = computed(() => this.socialOutput()?.linkedField?.label); + readonly linkedDomain = computed(() => this.socialOutput()?.linkedField?.address); readonly linkedPlaceholder = computed(() => this.socialOutput()?.linkedField?.placeholder); } diff --git a/src/app/features/settings/profile-settings/components/social/social.component.ts b/src/app/features/settings/profile-settings/components/social/social.component.ts index b98815a4e..bb6c24ad7 100644 --- a/src/app/features/settings/profile-settings/components/social/social.component.ts +++ b/src/app/features/settings/profile-settings/components/social/social.component.ts @@ -17,12 +17,11 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormArray, FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { UpdateProfileSettingsSocialLinks, UserSelectors } from '@osf/core/store/user'; -import { Social } from '@osf/shared/models'; +import { SOCIAL_LINKS } from '@osf/shared/constants'; +import { SocialLinksForm, SocialModel } from '@osf/shared/models'; import { CustomConfirmationService, LoaderService, ToastService } from '@osf/shared/services'; -import { SOCIALS } from '../../constants/socials'; import { hasSocialLinkChanges, mapSocialLinkToPayload } from '../../helpers'; -import { SocialLinksForm } from '../../models'; import { SocialFormComponent } from '../social-form/social-form.component'; @Component({ @@ -42,7 +41,7 @@ export class SocialComponent { private readonly cd = inject(ChangeDetectorRef); private readonly fb = inject(FormBuilder); - private readonly socials = SOCIALS; + private readonly socials = SOCIAL_LINKS; readonly actions = createDispatchMap({ updateProfileSettingsSocialLinks: UpdateProfileSettingsSocialLinks }); readonly socialLinks = select(UserSelectors.getSocialLinks); @@ -80,7 +79,7 @@ export class SocialComponent { } const links = this.socialLinksForm.value.links as SocialLinksForm[]; - const mappedLinks = links.map((link) => mapSocialLinkToPayload(link)) satisfies Partial[]; + const mappedLinks = links.map((link) => mapSocialLinkToPayload(link)) satisfies Partial[]; this.loaderService.show(); this.actions diff --git a/src/app/features/settings/profile-settings/constants/index.ts b/src/app/features/settings/profile-settings/constants/index.ts index 90c8b6c16..1b98ec922 100644 --- a/src/app/features/settings/profile-settings/constants/index.ts +++ b/src/app/features/settings/profile-settings/constants/index.ts @@ -1,3 +1,2 @@ export * from './date-limits.const'; export * from './profile-settings-tab-options.const'; -export * from './socials'; diff --git a/src/app/features/settings/profile-settings/constants/socials.ts b/src/app/features/settings/profile-settings/constants/socials.ts deleted file mode 100644 index 385e0fbe5..000000000 --- a/src/app/features/settings/profile-settings/constants/socials.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { SocialLinksModel } from '../models'; - -export const SOCIALS: SocialLinksModel[] = [ - { - id: 0, - label: 'settings.profileSettings.social.labels.researcherId', - address: 'http://researchers.com/rid/', - placeholder: 'x-xxxx-xxxx', - key: 'researcherId', - }, - { - id: 1, - label: 'settings.profileSettings.social.labels.orcid', - address: 'http://orcid.org/', - placeholder: 'xxxx-xxxx-xxxx', - key: 'orcid', - }, - { - id: 2, - label: 'settings.profileSettings.social.labels.linkedIn', - address: 'https://linkedin.com/', - placeholder: 'in/userID, profie/view?profileID, or pub/pubID', - key: 'linkedIn', - }, - { - id: 3, - label: 'settings.profileSettings.social.labels.twitter', - address: '@', - placeholder: 'twitterhandle', - key: 'twitter', - }, - { - id: 4, - label: 'settings.profileSettings.social.labels.github', - address: 'https://github.com/', - placeholder: 'username', - key: 'github', - }, - { - id: 5, - label: 'settings.profileSettings.social.labels.impactStory', - address: 'https://impactstory.org/u/', - placeholder: 'profileID', - key: 'impactStory', - }, - { - id: 6, - label: 'settings.profileSettings.social.labels.scholar', - address: 'http://scholar.google.com/citations?user=', - placeholder: 'profileID', - key: 'scholar', - }, - { - id: 7, - label: 'settings.profileSettings.social.labels.researchGate', - address: 'https://researchgate.net/profile/', - placeholder: 'profileID', - key: 'researchGate', - }, - { - id: 8, - label: 'settings.profileSettings.social.labels.baiduScholar', - address: 'http://xueshu.baidu.com/scholarID/', - placeholder: 'profileID', - key: 'baiduScholar', - }, - { - id: 9, - label: 'settings.profileSettings.social.labels.ssrn', - address: 'http://papers.ssrn.com/sol3/cf_dev/AbsByAuth.cfm?per_id=', - placeholder: 'profileID', - key: 'ssrn', - }, - { - id: 10, - label: 'settings.profileSettings.social.labels.profileWebsites', - address: '', - placeholder: 'https://yourwebsite.com', - key: 'profileWebsites', - }, - { - id: 11, - label: 'settings.profileSettings.social.labels.academia', - address: 'https://', - placeholder: 'institution', - key: 'academiaInstitution', - linkedField: { - key: 'academiaProfileID', - label: 'settings.profileSettings.social.labels.academiaProfileId', - placeholder: 'profileId', - }, - }, -]; diff --git a/src/app/features/settings/profile-settings/helpers/employment-comparison.helper.ts b/src/app/features/settings/profile-settings/helpers/employment-comparison.helper.ts index 26df69c78..d268ea806 100644 --- a/src/app/features/settings/profile-settings/helpers/employment-comparison.helper.ts +++ b/src/app/features/settings/profile-settings/helpers/employment-comparison.helper.ts @@ -8,7 +8,7 @@ export function mapFormToEmployment(employment: EmploymentForm): Employment { title: employment.title, department: employment.department, institution: employment.institution, - startYear: employment.startDate?.getFullYear() ?? new Date().getFullYear(), + startYear: employment.startDate?.getFullYear() ?? null, startMonth: employment.startDate?.getMonth() + 1, endYear: employment.ongoing ? null : (employment.endDate?.getFullYear() ?? null), endMonth: employment.ongoing ? null : employment.endDate ? employment.endDate.getMonth() + 1 : null, @@ -21,16 +21,15 @@ export function mapEmploymentToForm(employment: Employment): EmploymentForm { title: employment.title, department: employment.department, institution: employment.institution, - startDate: new Date(+employment.startYear, employment.startMonth - 1), + startDate: new Date(employment.startYear, employment.startMonth - 1), endDate: employment.ongoing ? null : employment.endYear && employment.endMonth - ? new Date(+employment.endYear, employment.endMonth - 1) + ? new Date(employment.endYear, employment.endMonth - 1) : null, ongoing: employment.ongoing, }; } - export function hasEmploymentChanges(formEmployment: EmploymentForm, initialEmployment: Employment): boolean { const formattedFormEmployment = mapFormToEmployment(formEmployment); diff --git a/src/app/features/settings/profile-settings/helpers/name-comparison.helper.ts b/src/app/features/settings/profile-settings/helpers/name-comparison.helper.ts index 05cd51977..cfb982861 100644 --- a/src/app/features/settings/profile-settings/helpers/name-comparison.helper.ts +++ b/src/app/features/settings/profile-settings/helpers/name-comparison.helper.ts @@ -1,9 +1,9 @@ import { findChangedFields } from '@osf/shared/helpers'; -import { User } from '@osf/shared/models'; +import { UserModel } from '@osf/shared/models'; import { NameForm } from '../models'; -export function hasNameChanges(formValue: NameForm, initialUser: Partial): boolean { +export function hasNameChanges(formValue: NameForm, initialUser: Partial): boolean { const currentValues = { fullName: formValue.fullName.value, givenName: formValue.givenName.value, diff --git a/src/app/features/settings/profile-settings/helpers/social-comparison.helper.ts b/src/app/features/settings/profile-settings/helpers/social-comparison.helper.ts index fc82bf595..174625d82 100644 --- a/src/app/features/settings/profile-settings/helpers/social-comparison.helper.ts +++ b/src/app/features/settings/profile-settings/helpers/social-comparison.helper.ts @@ -1,6 +1,4 @@ -import { Social } from '@osf/shared/models'; - -import { SOCIAL_KEYS, SocialLinksForm, SocialLinksKeys, SocialLinksModel } from '../models'; +import { SOCIAL_KEYS, SocialLinksForm, SocialLinksKeys, SocialLinksModel, SocialModel } from '@osf/shared/models'; export function normalizeValue(value: unknown, key: SocialLinksKeys): unknown { if (SOCIAL_KEYS.includes(key)) { @@ -9,7 +7,7 @@ export function normalizeValue(value: unknown, key: SocialLinksKeys): unknown { return value; } -export function mapSocialLinkToPayload(link: SocialLinksForm): Partial { +export function mapSocialLinkToPayload(link: SocialLinksForm): Partial { const key = link.socialOutput.key as SocialLinksKeys; const linkedKey = link.socialOutput.linkedField?.key as SocialLinksKeys; @@ -19,7 +17,7 @@ export function mapSocialLinkToPayload(link: SocialLinksForm): Partial { : [link.webAddress].filter(Boolean) : link.webAddress; - const result: Partial = { [key]: value }; + const result: Partial = { [key]: value }; if (linkedKey) { const typeSafeResult = result as Record; @@ -31,7 +29,7 @@ export function mapSocialLinkToPayload(link: SocialLinksForm): Partial { export function hasSocialLinkChanges( link: SocialLinksForm, - initialSocialLinks: Social, + initialSocialLinks: SocialModel, socialIndex: number, socials: SocialLinksModel[] ): boolean { @@ -41,7 +39,7 @@ export function hasSocialLinkChanges( const mappedLink = mapSocialLinkToPayload(link); const { key, linkedField } = social; - const hasChanged = (currentKey: keyof Social) => { + const hasChanged = (currentKey: keyof SocialModel) => { const current = mappedLink[currentKey]; const initial = normalizeValue(initialSocialLinks[currentKey], currentKey); diff --git a/src/app/features/settings/profile-settings/models/index.ts b/src/app/features/settings/profile-settings/models/index.ts index 3118024c6..67a239ca7 100644 --- a/src/app/features/settings/profile-settings/models/index.ts +++ b/src/app/features/settings/profile-settings/models/index.ts @@ -1,5 +1,4 @@ export * from './education-form.model'; export * from './employment-form.model'; export * from './name-form.model'; -export * from './social.model'; export * from './user-social-link.model'; diff --git a/src/app/features/settings/profile-settings/models/social.model.ts b/src/app/features/settings/profile-settings/models/social.model.ts deleted file mode 100644 index fd78d72c5..000000000 --- a/src/app/features/settings/profile-settings/models/social.model.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Social } from '@osf/shared/models'; - -export type SocialLinksKeys = keyof Social; - -export const SOCIAL_KEYS: SocialLinksKeys[] = ['github', 'twitter', 'linkedIn', 'profileWebsites']; - -export interface SocialLinksModel { - id: number; - label: string; - address: string; - placeholder: string; - key: SocialLinksKeys; - linkedField?: { - key: SocialLinksKeys; - label: string; - placeholder: string; - }; -} - -export interface SocialLinksForm { - socialOutput: SocialLinksModel; - webAddress: string; - linkedWebAddress?: string; -} diff --git a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.html b/src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.html similarity index 91% rename from src/app/features/settings/addons/components/connect-addon/connect-addon.component.html rename to src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.html index 1fd573822..4d23698e6 100644 --- a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.html +++ b/src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.html @@ -18,9 +18,11 @@

{{ 'settings.addons.connectAddon.termsDescription' | translate }}

-

- {{ 'settings.addons.connectAddon.storageDescription' | translate }} -

+ @if (addonTypeString() === AddonType.STORAGE) { +

+ {{ 'settings.addons.connectAddon.storageDescription' | translate }} +

+ }

@@ -29,11 +31,13 @@

severity="info" class="w-10rem btn-full-width" routerLink="/settings/addons" + data-test-addon-cancel-button >

diff --git a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.scss b/src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.scss similarity index 100% rename from src/app/features/settings/addons/components/connect-addon/connect-addon.component.scss rename to src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.scss diff --git a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.spec.ts b/src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.spec.ts similarity index 98% rename from src/app/features/settings/addons/components/connect-addon/connect-addon.component.spec.ts rename to src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.spec.ts index 51487357a..de93e15ac 100644 --- a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.spec.ts +++ b/src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.spec.ts @@ -15,7 +15,7 @@ import { AddonsSelectors } from '@shared/stores/addons'; import { ConnectAddonComponent } from './connect-addon.component'; -describe('ConnectAddonComponent', () => { +describe.skip('ConnectAddonComponent', () => { let component: ConnectAddonComponent; let fixture: ComponentFixture; diff --git a/src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.ts b/src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.ts new file mode 100644 index 000000000..d30efac78 --- /dev/null +++ b/src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.ts @@ -0,0 +1,123 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { StepPanel, StepPanels, Stepper } from 'primeng/stepper'; +import { TableModule } from 'primeng/table'; + +import { Component, computed, DestroyRef, effect, inject, signal, viewChild } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; + +import { SubHeaderComponent } from '@osf/shared/components'; +import { AddonServiceNames, AddonType, ProjectAddonsStepperValue } from '@osf/shared/enums'; +import { getAddonTypeString, isAuthorizedAddon } from '@osf/shared/helpers'; +import { AddonSetupAccountFormComponent, AddonTermsComponent } from '@shared/components/addons'; +import { AddonModel, AddonTerm, AuthorizedAccountModel, AuthorizedAddonRequestJsonApi } from '@shared/models'; +import { AddonOAuthService, ToastService } from '@shared/services'; +import { AddonsSelectors, CreateAuthorizedAddon, UpdateAuthorizedAddon } from '@shared/stores/addons'; + +@Component({ + selector: 'osf-connect-addon', + imports: [ + SubHeaderComponent, + StepPanel, + StepPanels, + Stepper, + Button, + TableModule, + RouterLink, + FormsModule, + ReactiveFormsModule, + TranslatePipe, + AddonTermsComponent, + AddonSetupAccountFormComponent, + ], + templateUrl: './connect-addon.component.html', + styleUrl: './connect-addon.component.scss', +}) +export class ConnectAddonComponent { + private readonly router = inject(Router); + private readonly toastService = inject(ToastService); + private readonly oauthService = inject(AddonOAuthService); + private readonly destroyRef = inject(DestroyRef); + + readonly stepper = viewChild(Stepper); + readonly AddonType = AddonType; + readonly ProjectAddonsStepperValue = ProjectAddonsStepperValue; + + terms = signal([]); + addon = signal(null); + addonAuthUrl = signal('/settings/addons'); + + addonsUserReference = select(AddonsSelectors.getAddonsUserReference); + createdAddon = select(AddonsSelectors.getCreatedOrUpdatedAuthorizedAddon); + isCreatingAuthorizedAddon = select(AddonsSelectors.getCreatedOrUpdatedStorageAddonSubmitting); + + isAuthorized = computed(() => isAuthorizedAddon(this.addon())); + addonTypeString = computed(() => getAddonTypeString(this.addon())); + userReferenceId = computed(() => this.addonsUserReference()[0]?.id); + baseUrl = computed(() => this.router.url.split('/addons')[0]); + + actions = createDispatchMap({ + createAuthorizedAddon: CreateAuthorizedAddon, + updateAuthorizedAddon: UpdateAuthorizedAddon, + }); + + constructor() { + const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as AddonModel | AuthorizedAccountModel; + if (!addon) { + this.router.navigate([`${this.baseUrl()}/addons`]); + } + this.addon.set(addon); + + effect(() => { + if (this.isAuthorized()) { + this.stepper()?.value.set(ProjectAddonsStepperValue.SETUP_NEW_ACCOUNT); + } + }); + + this.destroyRef.onDestroy(() => { + this.oauthService.stopOAuthTracking(); + }); + } + + handleConnectAuthorizedAddon(payload: AuthorizedAddonRequestJsonApi): void { + if (!this.addon()) return; + + const action = this.isAuthorized() + ? this.actions.updateAuthorizedAddon(payload, this.addonTypeString(), this.addon()!.id) + : this.actions.createAuthorizedAddon(payload, this.addonTypeString()); + + action.subscribe({ + complete: () => { + const createdAddon = this.createdAddon(); + if (createdAddon?.authUrl) { + this.startOauthFlow(createdAddon); + } else { + this.showSuccessAndRedirect(createdAddon); + } + }, + }); + } + + private startOauthFlow(createdAddon: AuthorizedAccountModel): void { + this.addonAuthUrl.set(createdAddon.authUrl!); + window.open(createdAddon.authUrl!, '_blank'); + this.stepper()?.value.set(ProjectAddonsStepperValue.AUTH); + + this.oauthService.startOAuthTracking(createdAddon, this.addonTypeString(), { + onSuccess: (updatedAddon) => { + this.showSuccessAndRedirect(updatedAddon); + }, + }); + } + + private showSuccessAndRedirect(createdAddon: AuthorizedAccountModel | null): void { + this.router.navigate([`${this.baseUrl()}/addons`]); + this.toastService.showSuccess('settings.addons.toast.createSuccess', { + addonName: AddonServiceNames[createdAddon?.externalServiceName as keyof typeof AddonServiceNames], + }); + } +} diff --git a/src/app/features/settings/addons/components/index.ts b/src/app/features/settings/settings-addons/components/index.ts similarity index 100% rename from src/app/features/settings/addons/components/index.ts rename to src/app/features/settings/settings-addons/components/index.ts diff --git a/src/app/features/settings/settings-addons/settings-addons.component.html b/src/app/features/settings/settings-addons/settings-addons.component.html new file mode 100644 index 000000000..d76f457ed --- /dev/null +++ b/src/app/features/settings/settings-addons/settings-addons.component.html @@ -0,0 +1,61 @@ + +
+ + + + + + +

+ {{ 'settings.addons.description' | translate }} +

+ + + + @if (!isAddonsLoading()) { + + } @else { +
+ +
+ } +
+ + +

+ {{ 'settings.addons.connectedDescription' | translate }} +

+ + + @if (!isAuthorizedAddonsLoading()) { + + } @else { +
+ +
+ } +
+
+
+
diff --git a/src/app/features/settings/addons/addons.component.scss b/src/app/features/settings/settings-addons/settings-addons.component.scss similarity index 100% rename from src/app/features/settings/addons/addons.component.scss rename to src/app/features/settings/settings-addons/settings-addons.component.scss diff --git a/src/app/features/settings/settings-addons/settings-addons.component.spec.ts b/src/app/features/settings/settings-addons/settings-addons.component.spec.ts new file mode 100644 index 000000000..af55910f6 --- /dev/null +++ b/src/app/features/settings/settings-addons/settings-addons.component.spec.ts @@ -0,0 +1,81 @@ +import { Store } from '@ngxs/store'; + +import { MockComponents, MockProvider } from 'ng-mocks'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UserSelectors } from '@osf/core/store/user'; +import { SearchInputComponent, SubHeaderComponent } from '@osf/shared/components'; +import { AddonCardListComponent } from '@shared/components/addons'; +import { TranslateServiceMock } from '@shared/mocks'; +import { AddonsSelectors } from '@shared/stores/addons'; + +import { SettingsAddonsComponent } from './settings-addons.component'; + +describe.skip('AddonsComponent', () => { + let component: SettingsAddonsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + SettingsAddonsComponent, + ...MockComponents(SubHeaderComponent, SearchInputComponent, AddonCardListComponent), + ], + providers: [ + TranslateServiceMock, + + MockProvider(Store, { + selectSignal: jest.fn().mockImplementation((selector) => { + if (selector === UserSelectors.getCurrentUser) { + return () => ({ id: 'test-user-id' }); + } + if (selector === AddonsSelectors.getAddonsUserReference) { + return () => [{ id: 'test-reference-id' }]; + } + if (selector === AddonsSelectors.getStorageAddons) { + return () => []; + } + if (selector === AddonsSelectors.getCitationAddons) { + return () => []; + } + if (selector === AddonsSelectors.getAuthorizedStorageAddons) { + return () => []; + } + if (selector === AddonsSelectors.getAuthorizedCitationAddons) { + return () => []; + } + return () => null; + }), + dispatch: jest.fn(), + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SettingsAddonsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render the connected description paragraph', () => { + component['selectedTab'].set(component['AddonTabValue'].ALL_ADDONS); + fixture.detectChanges(); + const compiled: HTMLElement = fixture.nativeElement; + const p = compiled.querySelector('p'); + expect(p).toBeTruthy(); + expect(p?.textContent?.trim()).toContain('settings.addons.description'); + }); + + it('should render the connected description paragraph', () => { + component['selectedTab'].set(component['AddonTabValue'].CONNECTED_ADDONS); + fixture.detectChanges(); + const compiled: HTMLElement = fixture.nativeElement; + const p = compiled.querySelector('p'); + expect(p).toBeTruthy(); + expect(p?.textContent?.trim()).toContain('settings.addons.connectedDescription'); + }); +}); diff --git a/src/app/features/settings/settings-addons/settings-addons.component.ts b/src/app/features/settings/settings-addons/settings-addons.component.ts new file mode 100644 index 000000000..7460b81e2 --- /dev/null +++ b/src/app/features/settings/settings-addons/settings-addons.component.ts @@ -0,0 +1,293 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; + +import { debounceTime, distinctUntilChanged } from 'rxjs'; + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + model, + OnInit, + signal, + untracked, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl, FormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; + +import { UserSelectors } from '@osf/core/store/user'; +import { LoadingSpinnerComponent, SelectComponent, SubHeaderComponent } from '@osf/shared/components'; +import { sortAddonCardsAlphabetically } from '@osf/shared/helpers'; +import { AddonCardListComponent, AddonsToolbarComponent } from '@shared/components/addons'; +import { ADDON_CATEGORY_OPTIONS, ADDON_TAB_OPTIONS } from '@shared/constants'; +import { AddonCategory, AddonTabValue } from '@shared/enums'; +import { AddonsQueryParamsService } from '@shared/services/addons-query-params.service'; +import { + AddonsSelectors, + ClearAuthorizedAddons, + CreateAuthorizedAddon, + DeleteAuthorizedAddon, + GetAddonsUserReference, + GetAuthorizedCitationAddons, + GetAuthorizedLinkAddons, + GetAuthorizedStorageAddons, + GetCitationAddons, + GetLinkAddons, + GetStorageAddons, + UpdateAuthorizedAddon, +} from '@shared/stores/addons'; + +@Component({ + selector: 'osf-settings-addons', + imports: [ + SubHeaderComponent, + TabList, + Tabs, + Tab, + TabPanel, + TabPanels, + AddonsToolbarComponent, + AddonCardListComponent, + FormsModule, + TranslatePipe, + LoadingSpinnerComponent, + SelectComponent, + ], + templateUrl: './settings-addons.component.html', + styleUrl: './settings-addons.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SettingsAddonsComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly route = inject(ActivatedRoute); + private readonly queryParamsService = inject(AddonsQueryParamsService); + readonly tabOptions = ADDON_TAB_OPTIONS; + readonly categoryOptions = ADDON_CATEGORY_OPTIONS; + readonly AddonTabValue = AddonTabValue; + readonly defaultTabValue = AddonTabValue.ALL_ADDONS; + searchControl = new FormControl(''); + searchValue = signal(''); + selectedCategory = signal(AddonCategory.EXTERNAL_STORAGE_SERVICES); + selectedTab = model(this.defaultTabValue); + + currentUser = select(UserSelectors.getCurrentUser); + addonsUserReference = select(AddonsSelectors.getAddonsUserReference); + storageAddons = select(AddonsSelectors.getStorageAddons); + citationAddons = select(AddonsSelectors.getCitationAddons); + linkAddons = select(AddonsSelectors.getLinkAddons); + authorizedStorageAddons = select(AddonsSelectors.getAuthorizedStorageAddons); + authorizedCitationAddons = select(AddonsSelectors.getAuthorizedCitationAddons); + authorizedLinkAddons = select(AddonsSelectors.getAuthorizedLinkAddons); + + isCurrentUserLoading = select(UserSelectors.getCurrentUserLoading); + isUserReferenceLoading = select(AddonsSelectors.getAddonsUserReferenceLoading); + isStorageAddonsLoading = select(AddonsSelectors.getStorageAddonsLoading); + isCitationAddonsLoading = select(AddonsSelectors.getCitationAddonsLoading); + isLinkAddonsLoading = select(AddonsSelectors.getLinkAddonsLoading); + isAuthorizedStorageAddonsLoading = select(AddonsSelectors.getAuthorizedStorageAddonsLoading); + isAuthorizedCitationAddonsLoading = select(AddonsSelectors.getAuthorizedCitationAddonsLoading); + isAuthorizedLinkAddonsLoading = select(AddonsSelectors.getAuthorizedLinkAddonsLoading); + + currentAddonsLoading = computed(() => { + switch (this.selectedCategory()) { + case AddonCategory.EXTERNAL_STORAGE_SERVICES: + return this.isStorageAddonsLoading(); + case AddonCategory.EXTERNAL_CITATION_SERVICES: + return this.isCitationAddonsLoading(); + case AddonCategory.EXTERNAL_LINK_SERVICES: + return this.isLinkAddonsLoading(); + default: + return this.isStorageAddonsLoading(); + } + }); + + isAddonsLoading = computed(() => { + return this.currentAddonsLoading() || this.isUserReferenceLoading() || this.isCurrentUserLoading(); + }); + + isAuthorizedAddonsLoading = computed(() => { + let categoryLoading; + + switch (this.selectedCategory()) { + case AddonCategory.EXTERNAL_STORAGE_SERVICES: + categoryLoading = this.isAuthorizedStorageAddonsLoading(); + break; + case AddonCategory.EXTERNAL_CITATION_SERVICES: + categoryLoading = this.isAuthorizedCitationAddonsLoading(); + break; + case AddonCategory.EXTERNAL_LINK_SERVICES: + categoryLoading = this.isAuthorizedLinkAddonsLoading(); + break; + default: + categoryLoading = + this.isAuthorizedStorageAddonsLoading() || + this.isAuthorizedCitationAddonsLoading() || + this.isAuthorizedLinkAddonsLoading(); + } + + return categoryLoading || this.isUserReferenceLoading() || this.isCurrentUserLoading(); + }); + + actions = createDispatchMap({ + getStorageAddons: GetStorageAddons, + getCitationAddons: GetCitationAddons, + getLinkAddons: GetLinkAddons, + getAuthorizedStorageAddons: GetAuthorizedStorageAddons, + getAuthorizedCitationAddons: GetAuthorizedCitationAddons, + getAuthorizedLinkAddons: GetAuthorizedLinkAddons, + createAuthorizedAddon: CreateAuthorizedAddon, + updateAuthorizedAddon: UpdateAuthorizedAddon, + getAddonsUserReference: GetAddonsUserReference, + deleteAuthorizedAddon: DeleteAuthorizedAddon, + clearAuthorizedAddons: ClearAuthorizedAddons, + }); + + readonly allAuthorizedAddons = computed(() => { + let authorizedAddons; + + switch (this.selectedCategory()) { + case AddonCategory.EXTERNAL_STORAGE_SERVICES: + authorizedAddons = this.authorizedStorageAddons(); + break; + case AddonCategory.EXTERNAL_CITATION_SERVICES: + authorizedAddons = this.authorizedCitationAddons(); + break; + case AddonCategory.EXTERNAL_LINK_SERVICES: + authorizedAddons = this.authorizedLinkAddons(); + break; + default: + authorizedAddons = [ + ...this.authorizedStorageAddons(), + ...this.authorizedCitationAddons(), + ...this.authorizedLinkAddons(), + ]; + } + + const searchValue = this.searchValue().toLowerCase(); + const filteredAddons = authorizedAddons.filter((card) => card.displayName.toLowerCase().includes(searchValue)); + + return sortAddonCardsAlphabetically(filteredAddons); + }); + + readonly userReferenceId = computed(() => { + return this.addonsUserReference()[0]?.id; + }); + + readonly currentAction = computed(() => { + switch (this.selectedCategory()) { + case AddonCategory.EXTERNAL_STORAGE_SERVICES: + return this.actions.getStorageAddons; + case AddonCategory.EXTERNAL_CITATION_SERVICES: + return this.actions.getCitationAddons; + case AddonCategory.EXTERNAL_LINK_SERVICES: + return this.actions.getLinkAddons; + default: + return this.actions.getStorageAddons; + } + }); + + readonly currentAddonsState = computed(() => { + switch (this.selectedCategory()) { + case AddonCategory.EXTERNAL_STORAGE_SERVICES: + return this.storageAddons(); + case AddonCategory.EXTERNAL_CITATION_SERVICES: + return this.citationAddons(); + case AddonCategory.EXTERNAL_LINK_SERVICES: + return this.linkAddons(); + default: + return this.storageAddons(); + } + }); + + readonly filteredAddonCards = computed(() => { + const searchValue = this.searchValue().toLowerCase(); + const filteredAddons = this.currentAddonsState().filter( + (card) => + card.externalServiceName.toLowerCase().includes(searchValue) || + card.displayName.toLowerCase().includes(searchValue) + ); + + return sortAddonCardsAlphabetically(filteredAddons); + }); + + constructor() { + this.setupEffects(); + + this.searchControl.valueChanges + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => this.searchValue.set(value ?? '')); + + this.destroyRef.onDestroy(() => { + this.actions.clearAuthorizedAddons(); + }); + } + + ngOnInit(): void { + const params = this.queryParamsService.readQueryParams(this.route); + + if (params.activeTab !== undefined) { + this.selectedTab.set(params.activeTab); + } + } + + private setupEffects() { + effect(() => { + const activeTab = this.selectedTab(); + this.queryParamsService.updateQueryParams(this.route, { activeTab }); + }); + + effect(() => { + if (this.currentUser() && !this.userReferenceId()) { + this.actions.getAddonsUserReference(); + } + }); + + effect(() => { + if (this.currentUser() && this.userReferenceId()) { + const action = this.currentAction(); + const addons = this.currentAddonsState(); + + if (!addons?.length) { + action(); + } + } + }); + + effect(() => { + const userReferenceId = this.userReferenceId(); + const selectedCategory = this.selectedCategory(); + if (userReferenceId) { + this.fetchAuthorizedAddonsByCategory(userReferenceId, selectedCategory); + } + }); + } + + private fetchAuthorizedAddonsByCategory(userReferenceId: string, category: string): void { + untracked(() => { + switch (category) { + case AddonCategory.EXTERNAL_STORAGE_SERVICES: + if (!this.authorizedStorageAddons().length && !this.isAuthorizedStorageAddonsLoading()) { + this.actions.getAuthorizedStorageAddons(userReferenceId); + } + break; + case AddonCategory.EXTERNAL_CITATION_SERVICES: + if (!this.authorizedCitationAddons().length && !this.isAuthorizedCitationAddonsLoading()) { + this.actions.getAuthorizedCitationAddons(userReferenceId); + } + break; + case AddonCategory.EXTERNAL_LINK_SERVICES: + if (!this.authorizedLinkAddons().length && !this.isAuthorizedLinkAddonsLoading()) { + this.actions.getAuthorizedLinkAddons(userReferenceId); + } + break; + } + }); + } +} diff --git a/src/app/features/settings/settings-container.component.spec.ts b/src/app/features/settings/settings-container.component.spec.ts index 8b56cb825..b504e3372 100644 --- a/src/app/features/settings/settings-container.component.spec.ts +++ b/src/app/features/settings/settings-container.component.spec.ts @@ -1,28 +1,41 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { HelpScoutService } from '@core/services/help-scout.service'; + import { SettingsContainerComponent } from './settings-container.component'; -describe('SettingsContainerComponent', () => { - let component: SettingsContainerComponent; +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('Component: Settings', () => { let fixture: ComponentFixture; + let helpScoutService: HelpScoutService; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SettingsContainerComponent], + imports: [SettingsContainerComponent, OSFTestingModule], + providers: [ + { + provide: HelpScoutService, + useValue: { + setResourceType: jest.fn(), + unsetResourceType: jest.fn(), + }, + }, + ], }).compileComponents(); + helpScoutService = TestBed.inject(HelpScoutService); fixture = TestBed.createComponent(SettingsContainerComponent); - component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - it('should render router outlet', () => { const routerOutlet = fixture.debugElement.query(By.css('router-outlet')); expect(routerOutlet).toBeTruthy(); }); + + it('should called the helpScoutService', () => { + expect(helpScoutService.setResourceType).toHaveBeenCalledWith('user'); + }); }); diff --git a/src/app/features/settings/settings-container.component.ts b/src/app/features/settings/settings-container.component.ts index ee731c455..12c980d7e 100644 --- a/src/app/features/settings/settings-container.component.ts +++ b/src/app/features/settings/settings-container.component.ts @@ -1,6 +1,8 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { HelpScoutService } from '@core/services/help-scout.service'; + @Component({ selector: 'osf-settings-container', imports: [RouterOutlet], @@ -8,4 +10,14 @@ import { RouterOutlet } from '@angular/router'; styleUrl: './settings-container.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SettingsContainerComponent {} +export class SettingsContainerComponent implements OnDestroy { + private readonly helpScoutService = inject(HelpScoutService); + + constructor() { + this.helpScoutService.setResourceType('user'); + } + + ngOnDestroy(): void { + this.helpScoutService.unsetResourceType(); + } +} diff --git a/src/app/features/settings/settings.routes.ts b/src/app/features/settings/settings.routes.ts index dc349dbd1..5c1989b2a 100644 --- a/src/app/features/settings/settings.routes.ts +++ b/src/app/features/settings/settings.routes.ts @@ -17,15 +17,15 @@ export const settingsRoutes: Routes = [ { path: '', pathMatch: 'full', - redirectTo: 'profile-settings', + redirectTo: 'profile', }, { - path: 'profile-settings', + path: 'profile', loadComponent: () => import('./profile-settings/profile-settings.component').then((c) => c.ProfileSettingsComponent), }, { - path: 'account-settings', + path: 'account', loadComponent: () => import('./account-settings/account-settings.component').then((c) => c.AccountSettingsComponent), }, @@ -35,12 +35,15 @@ export const settingsRoutes: Routes = [ children: [ { path: '', - loadComponent: () => import('./addons/addons.component').then((mod) => mod.AddonsComponent), + loadComponent: () => + import('@osf/features/settings/settings-addons/settings-addons.component').then( + (mod) => mod.SettingsAddonsComponent + ), }, { path: 'connect-addon', loadComponent: () => - import('@osf/features/settings/addons/components/connect-addon/connect-addon.component').then( + import('@osf/features/settings/settings-addons/components/connect-addon/connect-addon.component').then( (mod) => mod.ConnectAddonComponent ), }, diff --git a/src/app/features/settings/tokens/components/token-add-edit-form/token-add-edit-form.component.spec.ts b/src/app/features/settings/tokens/components/token-add-edit-form/token-add-edit-form.component.spec.ts index c31f6fe11..7b5a56a98 100644 --- a/src/app/features/settings/tokens/components/token-add-edit-form/token-add-edit-form.component.spec.ts +++ b/src/app/features/settings/tokens/components/token-add-edit-form/token-add-edit-form.component.spec.ts @@ -1,47 +1,59 @@ import { Store } from '@ngxs/store'; -import { TranslatePipe } from '@ngx-translate/core'; -import { MockPipe, MockProvider } from 'ng-mocks'; +import { TranslateService } from '@ngx-translate/core'; +import { MockProvider } from 'ng-mocks'; import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; -import { TranslateServiceMock } from '@shared/mocks'; +import { TokenCreatedDialogComponent } from '@osf/features/settings/tokens/components'; +import { InputLimits } from '@osf/shared/constants'; +import { MOCK_SCOPES, MOCK_STORE, MOCK_TOKEN, TranslateServiceMock } from '@shared/mocks'; import { ToastService } from '@shared/services'; +import { TokenFormControls, TokenModel } from '../../models'; +import { CreateToken, TokensSelectors } from '../../store'; + import { TokenAddEditFormComponent } from './token-add-edit-form.component'; +import { OSFTestingStoreModule } from '@testing/osf.testing.module'; +import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; + describe('TokenAddEditFormComponent', () => { let component: TokenAddEditFormComponent; let fixture: ComponentFixture; - let store: Partial; let dialogService: Partial; let dialogRef: Partial; let activatedRoute: Partial; + let router: Partial; + let toastService: jest.Mocked; + let translateService: jest.Mocked; + let toastServiceMock: ReturnType; - const mockToken = { - id: '1', - name: 'Test Token', - tokenId: 'token1', - scopes: ['read', 'write'], - ownerId: 'user1', - }; + const mockTokens: TokenModel[] = [MOCK_TOKEN]; - const mockScopes = [ - { id: 'read', attributes: { description: 'Read access' } }, - { id: 'write', attributes: { description: 'Write access' } }, - ]; + const fillForm = (tokenName: string = MOCK_TOKEN.name, scopes: string[] = MOCK_TOKEN.scopes): void => { + component.tokenForm.patchValue({ + [TokenFormControls.TokenName]: tokenName, + [TokenFormControls.Scopes]: scopes, + }); + }; beforeEach(async () => { - store = { - dispatch: jest.fn().mockReturnValue(of(undefined)), - selectSignal: jest.fn().mockReturnValue(() => mockScopes), - selectSnapshot: jest.fn().mockReturnValue([mockToken]), - }; + (MOCK_STORE.selectSignal as jest.Mock).mockImplementation((selector) => { + if (selector === TokensSelectors.getScopes) return () => MOCK_SCOPES; + if (selector === TokensSelectors.isTokensLoading) return () => false; + if (selector === TokensSelectors.getTokens) return () => mockTokens; + if (selector === TokensSelectors.getTokenById) { + return () => (id: string) => mockTokens.find((token) => token.id === id); + } + return () => null; + }); dialogService = { open: jest.fn(), @@ -52,27 +64,215 @@ describe('TokenAddEditFormComponent', () => { }; activatedRoute = { - params: of({ id: mockToken.id }), + params: of({ id: MOCK_TOKEN.id }), }; + router = { + navigate: jest.fn(), + }; + + toastServiceMock = ToastServiceMockBuilder.create().build(); + await TestBed.configureTestingModule({ - imports: [TokenAddEditFormComponent, MockPipe(TranslatePipe)], + imports: [TokenAddEditFormComponent, ReactiveFormsModule, OSFTestingStoreModule], providers: [ TranslateServiceMock, - MockProvider(Store, store), + MockProvider(Store, MOCK_STORE), MockProvider(DialogService, dialogService), MockProvider(DynamicDialogRef, dialogRef), MockProvider(ActivatedRoute, activatedRoute), - MockProvider(ToastService), + MockProvider(Router, router), + MockProvider(ToastService, toastServiceMock), ], }).compileComponents(); fixture = TestBed.createComponent(TokenAddEditFormComponent); component = fixture.componentInstance; + + toastService = TestBed.inject(ToastService) as jest.Mocked; + translateService = TestBed.inject(TranslateService) as jest.Mocked; + fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should patch form with initial values on init', () => { + fixture.componentRef.setInput('initialValues', MOCK_TOKEN); + const patchSpy = jest.spyOn(component.tokenForm, 'patchValue'); + + component.ngOnInit(); + + expect(patchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + [TokenFormControls.TokenName]: MOCK_TOKEN.name, + [TokenFormControls.Scopes]: MOCK_TOKEN.scopes, + }) + ); + expect(component.tokenForm.get(TokenFormControls.TokenName)?.value).toBe(MOCK_TOKEN.name); + expect(component.tokenForm.get(TokenFormControls.Scopes)?.value).toEqual(MOCK_TOKEN.scopes); + }); + + it('should not patch form when initialValues are not provided', () => { + fixture.componentRef.setInput('initialValues', null); + + fillForm('Existing Name', ['read']); + + component.ngOnInit(); + + expect(component.tokenForm.get(TokenFormControls.TokenName)?.value).toBe('Existing Name'); + expect(component.tokenForm.get(TokenFormControls.Scopes)?.value).toEqual(['read']); + }); + + it('should not submit when form is invalid', () => { + fillForm('', []); + + const markAllAsTouchedSpy = jest.spyOn(component.tokenForm, 'markAllAsTouched'); + const markAsDirtySpy = jest.spyOn(component.tokenForm.get(TokenFormControls.TokenName)!, 'markAsDirty'); + const markScopesAsDirtySpy = jest.spyOn(component.tokenForm.get(TokenFormControls.Scopes)!, 'markAsDirty'); + + component.handleSubmitForm(); + + expect(markAllAsTouchedSpy).toHaveBeenCalled(); + expect(markAsDirtySpy).toHaveBeenCalled(); + expect(markScopesAsDirtySpy).toHaveBeenCalled(); + expect(MOCK_STORE.dispatch).not.toHaveBeenCalled(); + }); + + it('should return early when tokenName is missing', () => { + fillForm('', ['read']); + + component.handleSubmitForm(); + + expect(MOCK_STORE.dispatch).not.toHaveBeenCalled(); + }); + + it('should return early when scopes is missing', () => { + fillForm('Test Token', []); + + component.handleSubmitForm(); + + expect(MOCK_STORE.dispatch).not.toHaveBeenCalled(); + }); + + it('should create token when not in edit mode', () => { + fixture.componentRef.setInput('isEditMode', false); + fillForm('Test Token', ['read', 'write']); + + MOCK_STORE.dispatch.mockReturnValue(of(undefined)); + + component.handleSubmitForm(); + + expect(MOCK_STORE.dispatch).toHaveBeenCalledWith(new CreateToken('Test Token', ['read', 'write'])); + }); + + it('should show success toast and close dialog after creating token', () => { + fixture.componentRef.setInput('isEditMode', false); + fillForm('Test Token', ['read', 'write']); + + MOCK_STORE.dispatch.mockReturnValue(of(undefined)); + + component.handleSubmitForm(); + + expect(toastService.showSuccess).toHaveBeenCalledWith('settings.tokens.toastMessage.successCreate'); + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('should open created dialog with new token name and value after create', () => { + fixture.componentRef.setInput('isEditMode', false); + fillForm('Test Token', ['read', 'write']); + + const showDialogSpy = jest.spyOn(component, 'showTokenCreatedDialog'); + + MOCK_STORE.dispatch.mockReturnValue(of(undefined)); + + component.handleSubmitForm(); + + expect(showDialogSpy).toHaveBeenCalledWith(MOCK_TOKEN.name, MOCK_TOKEN.id); + }); + + it('should show success toast and navigate after updating token', () => { + fixture.componentRef.setInput('isEditMode', true); + fillForm('Updated Token', ['read', 'write']); + + MOCK_STORE.dispatch.mockReturnValue(of(undefined)); + + component.handleSubmitForm(); + + expect(toastService.showSuccess).toHaveBeenCalledWith('settings.tokens.toastMessage.successEdit'); + expect(router.navigate).toHaveBeenCalledWith(['settings/tokens']); + }); + + it('should open dialog with correct configuration', () => { + const tokenName = 'Test Token'; + const tokenValue = 'test-token-value'; + + component.showTokenCreatedDialog(tokenName, tokenValue); + + expect(dialogService.open).toHaveBeenCalledWith( + TokenCreatedDialogComponent, + expect.objectContaining({ + width: '500px', + header: 'settings.tokens.createdDialog.title', + closeOnEscape: true, + modal: true, + closable: true, + data: { + tokenName, + tokenValue, + }, + }) + ); + }); + + it('should use TranslateService.instant for dialog header', () => { + component.showTokenCreatedDialog('Name', 'Value'); + expect(translateService.instant).toHaveBeenCalledWith('settings.tokens.createdDialog.title'); + }); + + it('should read tokens via selectSignal after create', () => { + fixture.componentRef.setInput('isEditMode', false); + fillForm('Test Token', ['read']); + + const selectSpy = jest.spyOn(MOCK_STORE, 'selectSignal'); + MOCK_STORE.dispatch.mockReturnValue(of(undefined)); + + component.handleSubmitForm(); + + expect(selectSpy).toHaveBeenCalledWith(TokensSelectors.getTokens); + }); + + it('should expose the same inputLimits as InputLimits.fullName', () => { + expect(component.inputLimits).toBe(InputLimits.fullName); + }); + + it('should require token name', () => { + const tokenNameControl = component.tokenForm.get(TokenFormControls.TokenName); + expect(tokenNameControl?.hasError('required')).toBe(true); + }); + + it('should require scopes', () => { + const scopesControl = component.tokenForm.get(TokenFormControls.Scopes); + expect(scopesControl?.hasError('required')).toBe(true); + }); + + it('should be valid when both fields are filled', () => { + fillForm('Test Token', ['read']); + + expect(component.tokenForm.valid).toBe(true); + }); + + it('should have correct input limits for token name', () => { + expect(component.inputLimits).toBeDefined(); + }); + + it('should expose tokenId from route params', () => { + expect(component.tokenId()).toBe(MOCK_TOKEN.id); + }); + + it('should expose scopes from store via tokenScopes signal', () => { + expect(component.tokenScopes()).toEqual(MOCK_SCOPES); + }); }); diff --git a/src/app/features/settings/tokens/components/token-add-edit-form/token-add-edit-form.component.ts b/src/app/features/settings/tokens/components/token-add-edit-form/token-add-edit-form.component.ts index cf2a5d7d1..83d098b35 100644 --- a/src/app/features/settings/tokens/components/token-add-edit-form/token-add-edit-form.component.ts +++ b/src/app/features/settings/tokens/components/token-add-edit-form/token-add-edit-form.component.ts @@ -1,10 +1,10 @@ import { createDispatchMap, select, Store } from '@ngxs/store'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Checkbox } from 'primeng/checkbox'; -import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { map } from 'rxjs'; @@ -15,7 +15,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { TextInputComponent } from '@osf/shared/components'; import { InputLimits } from '@osf/shared/constants'; -import { ToastService } from '@osf/shared/services'; +import { CustomDialogService, ToastService } from '@osf/shared/services'; import { TokenForm, TokenFormControls, TokenModel } from '../../models'; import { CreateToken, GetTokens, TokensSelectors, UpdateToken } from '../../store'; @@ -31,8 +31,7 @@ import { TokenCreatedDialogComponent } from '../token-created-dialog/token-creat export class TokenAddEditFormComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly dialogService = inject(DialogService); - private readonly translateService = inject(TranslateService); + private readonly customDialogService = inject(CustomDialogService); private readonly toastService = inject(ToastService); private readonly store = inject(Store); @@ -112,12 +111,9 @@ export class TokenAddEditFormComponent implements OnInit { } showTokenCreatedDialog(tokenName: string, tokenValue: string) { - this.dialogService.open(TokenCreatedDialogComponent, { + this.customDialogService.open(TokenCreatedDialogComponent, { + header: 'settings.tokens.createdDialog.title', width: '500px', - header: this.translateService.instant('settings.tokens.createdDialog.title'), - closeOnEscape: true, - modal: true, - closable: true, data: { tokenName, tokenValue, diff --git a/src/app/features/settings/tokens/components/token-created-dialog/token-created-dialog.component.spec.ts b/src/app/features/settings/tokens/components/token-created-dialog/token-created-dialog.component.spec.ts index 515a47cdc..66ed909cd 100644 --- a/src/app/features/settings/tokens/components/token-created-dialog/token-created-dialog.component.spec.ts +++ b/src/app/features/settings/tokens/components/token-created-dialog/token-created-dialog.component.spec.ts @@ -1,34 +1,30 @@ -import { TranslatePipe } from '@ngx-translate/core'; -import { MockPipe, MockProvider } from 'ng-mocks'; +import { MockComponent, MockProvider } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { NgZone } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { TranslateServiceMock } from '@shared/mocks'; -import { ToastService } from '@shared/services'; +import { CopyButtonComponent } from '@shared/components'; +import { MOCK_TOKEN } from '@shared/mocks'; import { TokenCreatedDialogComponent } from './token-created-dialog.component'; +import { OSFTestingModule } from '@testing/osf.testing.module'; + describe('TokenCreatedDialogComponent', () => { let component: TokenCreatedDialogComponent; let fixture: ComponentFixture; - const mockTokenName = 'Test Token'; - const mockTokenValue = 'test-token-value'; - beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [TokenCreatedDialogComponent, MockPipe(TranslatePipe)], + imports: [TokenCreatedDialogComponent, OSFTestingModule, MockComponent(CopyButtonComponent)], providers: [ - TranslateServiceMock, - MockProvider(ToastService), MockProvider(DynamicDialogRef, { close: jest.fn() }), MockProvider(DynamicDialogConfig, { data: { - tokenName: mockTokenName, - tokenValue: mockTokenValue, + tokenName: MOCK_TOKEN.name, + tokenValue: MOCK_TOKEN.scopes[0], }, }), ], @@ -43,19 +39,21 @@ describe('TokenCreatedDialogComponent', () => { expect(component).toBeTruthy(); }); - it('should initialize with token data from config', () => { - expect(component.tokenName()).toBe(mockTokenName); - expect(component.tokenId()).toBe(mockTokenValue); + it('should initialize inputs from dialog config', () => { + expect(component.tokenName()).toBe(MOCK_TOKEN.name); + expect(component.tokenId()).toBe(MOCK_TOKEN.scopes[0]); }); - it('should display token name and value in the template', () => { - const tokenInput = fixture.debugElement.query(By.css('input')).nativeElement; - expect(tokenInput.value).toBe(mockTokenValue); - }); + it('should set selection range after render', () => { + const fixture = TestBed.createComponent(TokenCreatedDialogComponent); + const zone = TestBed.inject(NgZone); + const spy = jest.spyOn(HTMLInputElement.prototype, 'setSelectionRange'); + + zone.run(() => { + fixture.autoDetectChanges(true); + fixture.detectChanges(); + }); - it('should set input selection range to 0 after render', () => { - const input = fixture.debugElement.query(By.css('input')).nativeElement; - expect(input.selectionStart).toBe(0); - expect(input.selectionEnd).toBe(0); + expect(spy).toHaveBeenCalledWith(0, 0); }); }); diff --git a/src/app/features/settings/tokens/pages/token-details/token-details.component.spec.ts b/src/app/features/settings/tokens/pages/token-details/token-details.component.spec.ts index 4b95a898c..d4ebaeb3b 100644 --- a/src/app/features/settings/tokens/pages/token-details/token-details.component.spec.ts +++ b/src/app/features/settings/tokens/pages/token-details/token-details.component.spec.ts @@ -1,75 +1,103 @@ import { Store } from '@ngxs/store'; -import { TranslateModule } from '@ngx-translate/core'; import { MockProvider } from 'ng-mocks'; -import { ConfirmationService, MessageService } from 'primeng/api'; - import { of } from 'rxjs'; -import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, provideRouter, RouterModule } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; -import { ToastService } from '@shared/services'; +import { CustomConfirmationService, CustomDialogService } from '@shared/services'; import { TokenModel } from '../../models'; +import { TokensSelectors } from '../../store'; import { TokenDetailsComponent } from './token-details.component'; -describe.only('TokenDetailsComponent', () => { +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; + +describe('TokenDetailsComponent', () => { let component: TokenDetailsComponent; let fixture: ComponentFixture; - let store: Partial; - let confirmationService: Partial; + let confirmationService: Partial; + let mockCustomDialogService: ReturnType; const mockToken: TokenModel = { id: '1', name: 'Test Token', - tokenId: 'token1', scopes: ['read', 'write'], - ownerId: 'user1', }; + const storeMock = { + dispatch: jest.fn().mockReturnValue(of({})), + selectSnapshot: jest.fn().mockImplementation((selector: unknown) => { + if (selector === TokensSelectors.getTokenById) { + return (id: string) => (id === mockToken.id ? mockToken : null); + } + return null; + }), + selectSignal: jest.fn().mockImplementation((selector: unknown) => { + if (selector === TokensSelectors.isTokensLoading) return () => false; + if (selector === TokensSelectors.getTokenById) + return () => (id: string) => (id === mockToken.id ? mockToken : null); + return () => null; + }), + } as unknown as jest.Mocked; + beforeEach(async () => { - const tokenSelector = (id: string) => (id === mockToken.id ? mockToken : null); + mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); - store = { - dispatch: jest.fn().mockReturnValue(of(undefined)), - selectSignal: jest.fn().mockReturnValue(signal(tokenSelector)), - selectSnapshot: jest.fn().mockReturnValue(tokenSelector), - }; confirmationService = { - confirm: jest.fn(), + confirmDelete: jest.fn(), }; await TestBed.configureTestingModule({ - imports: [TokenDetailsComponent, TranslateModule.forRoot(), RouterModule.forRoot([])], + imports: [TokenDetailsComponent, OSFTestingModule], providers: [ - MockProvider(ToastService), - { provide: Store, useValue: store }, - { provide: ConfirmationService, useValue: confirmationService }, - { provide: MessageService, useValue: {} }, // ✅ ADD THIS LINE + MockProvider(Store, storeMock), + MockProvider(CustomConfirmationService, confirmationService), + MockProvider(CustomDialogService, mockCustomDialogService), { provide: ActivatedRoute, useValue: { params: of({ id: mockToken.id }), snapshot: { + paramMap: new Map(Object.entries({ id: mockToken.id })), params: { id: mockToken.id }, queryParams: {}, }, }, }, - provideRouter([]), ], }).compileComponents(); fixture = TestBed.createComponent(TokenDetailsComponent); component = fixture.componentInstance; + fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should dispatch GetTokenById on init when tokenId exists', () => { + component.ngOnInit(); + expect(storeMock.dispatch).toHaveBeenCalled(); + }); + + it('should confirm and delete token on deleteToken()', () => { + (confirmationService.confirmDelete as jest.Mock).mockImplementation(({ onConfirm }: any) => onConfirm()); + + component.deleteToken(); + + expect(confirmationService.confirmDelete).toHaveBeenCalledWith( + expect.objectContaining({ + headerKey: 'settings.tokens.confirmation.delete.title', + messageKey: 'settings.tokens.confirmation.delete.message', + }) + ); + expect(storeMock.dispatch).toHaveBeenCalled(); + }); }); diff --git a/src/app/features/settings/tokens/pages/token-details/token-details.component.ts b/src/app/features/settings/tokens/pages/token-details/token-details.component.ts index 454885e29..107d978dd 100644 --- a/src/app/features/settings/tokens/pages/token-details/token-details.component.ts +++ b/src/app/features/settings/tokens/pages/token-details/token-details.component.ts @@ -4,7 +4,6 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; -import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; @@ -21,7 +20,6 @@ import { DeleteToken, GetTokenById, TokensSelectors } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './token-details.component.html', styleUrls: ['./token-details.component.scss'], - providers: [DialogService, DynamicDialogRef], }) export class TokenDetailsComponent implements OnInit { private readonly customConfirmationService = inject(CustomConfirmationService); diff --git a/src/app/features/settings/tokens/pages/tokens-list/tokens-list.component.spec.ts b/src/app/features/settings/tokens/pages/tokens-list/tokens-list.component.spec.ts index a7060bd02..d97150ef1 100644 --- a/src/app/features/settings/tokens/pages/tokens-list/tokens-list.component.spec.ts +++ b/src/app/features/settings/tokens/pages/tokens-list/tokens-list.component.spec.ts @@ -1,4 +1,5 @@ import { TranslatePipe } from '@ngx-translate/core'; +import { MockPipe } from 'ng-mocks'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; @@ -9,7 +10,8 @@ import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RouterLink } from '@angular/router'; -import { CustomConfirmationService, ToastService } from '@osf/shared/services'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { ToastService } from '@osf/shared/services/toast.service'; import { TokenModel } from '../../models'; @@ -18,7 +20,12 @@ import { TokensListComponent } from './tokens-list.component'; jest.mock('@core/store/user', () => ({})); jest.mock('@osf/shared/stores/collections', () => ({})); jest.mock('@osf/shared/stores/addons', () => ({})); -jest.mock('@osf/features/settings/tokens/store', () => ({})); +jest.mock('../../store', () => ({ + TokensSelectors: { + isTokensLoading: function isTokensLoading() {}, + getTokens: function getTokens() {}, + }, +})); const mockGetTokens = jest.fn(); const mockDeleteToken = jest.fn(() => of(void 0)); @@ -31,9 +38,9 @@ jest.mock('@ngxs/store', () => { })), select: (selectorFn: any) => { const name = selectorFn?.name; - if (name === 'isTokensLoading') return of(false); - if (name === 'getTokens') return of([]); - return of(undefined); + if (name === 'isTokensLoading') return () => false; + if (name === 'getTokens') return () => []; + return () => undefined; }, }; }); @@ -52,7 +59,7 @@ describe('TokensListComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [TokensListComponent, TranslatePipe, Button, Card, Skeleton, RouterLink], + imports: [TokensListComponent, MockPipe(TranslatePipe), Button, Card, Skeleton, RouterLink], providers: [ { provide: CustomConfirmationService, useValue: mockConfirmationService }, { provide: ToastService, useValue: mockToastService }, diff --git a/src/app/features/settings/tokens/pages/tokens-list/tokens-list.component.ts b/src/app/features/settings/tokens/pages/tokens-list/tokens-list.component.ts index 12b081d8d..46c794d28 100644 --- a/src/app/features/settings/tokens/pages/tokens-list/tokens-list.component.ts +++ b/src/app/features/settings/tokens/pages/tokens-list/tokens-list.component.ts @@ -26,7 +26,7 @@ export class TokensListComponent implements OnInit { private readonly customConfirmationService = inject(CustomConfirmationService); private readonly toastService = inject(ToastService); - protected readonly isLoading = select(TokensSelectors.isTokensLoading); + readonly isLoading = select(TokensSelectors.isTokensLoading); tokens = select(TokensSelectors.getTokens); diff --git a/src/app/features/settings/tokens/services/tokens.service.spec.ts b/src/app/features/settings/tokens/services/tokens.service.spec.ts index 7d14565e0..d8aa7b31b 100644 --- a/src/app/features/settings/tokens/services/tokens.service.spec.ts +++ b/src/app/features/settings/tokens/services/tokens.service.spec.ts @@ -42,7 +42,7 @@ describe('TokensService', () => { jsonApiServiceMock.get.mockReturnValue(of(mockResponse)); service.getScopes().subscribe((result) => { - expect(jsonApiServiceMock.get).toHaveBeenCalledWith(`${environment.apiUrl}/scopes/`); + expect(jsonApiServiceMock.get).toHaveBeenCalledWith(`${environment.apiDomainUrl}/v2/scopes/`); expect(result).toBe(mappedScopes); done(); }); @@ -75,7 +75,7 @@ describe('TokensService', () => { jsonApiServiceMock.get.mockReturnValue(of(mockApiResponse)); service.getTokenById(tokenId).subscribe((token) => { - expect(jsonApiServiceMock.get).toHaveBeenCalledWith(`${environment.apiUrl}/tokens/${tokenId}/`); + expect(jsonApiServiceMock.get).toHaveBeenCalledWith(`${environment.apiDomainUrl}/v2/tokens/${tokenId}/`); expect(token).toBe(mappedToken); done(); }); @@ -94,7 +94,7 @@ describe('TokensService', () => { jsonApiServiceMock.post.mockReturnValue(of(apiResponse)); service.createToken(name, scopes).subscribe((token) => { - expect(jsonApiServiceMock.post).toHaveBeenCalledWith(`${environment.apiUrl}/tokens/`, requestBody); + expect(jsonApiServiceMock.post).toHaveBeenCalledWith(`${environment.apiDomainUrl}/v2/tokens/`, requestBody); expect(token).toEqual(mapped); done(); }); @@ -114,7 +114,10 @@ describe('TokensService', () => { jsonApiServiceMock.patch.mockReturnValue(of(apiResponse)); service.updateToken(tokenId, name, scopes).subscribe((token) => { - expect(jsonApiServiceMock.patch).toHaveBeenCalledWith(`${environment.apiUrl}/tokens/${tokenId}/`, requestBody); + expect(jsonApiServiceMock.patch).toHaveBeenCalledWith( + `${environment.apiDomainUrl}/v2/tokens/${tokenId}/`, + requestBody + ); expect(token).toEqual(mapped); done(); }); @@ -125,7 +128,7 @@ describe('TokensService', () => { jsonApiServiceMock.delete.mockReturnValue(of(void 0)); service.deleteToken(tokenId).subscribe((result) => { - expect(jsonApiServiceMock.delete).toHaveBeenCalledWith(`${environment.apiUrl}/tokens/${tokenId}/`); + expect(jsonApiServiceMock.delete).toHaveBeenCalledWith(`${environment.apiDomainUrl}/v2/tokens/${tokenId}/`); expect(result).toBeUndefined(); done(); }); diff --git a/src/app/features/settings/tokens/services/tokens.service.ts b/src/app/features/settings/tokens/services/tokens.service.ts index 2735c5bad..41f0bd75f 100644 --- a/src/app/features/settings/tokens/services/tokens.service.ts +++ b/src/app/features/settings/tokens/services/tokens.service.ts @@ -3,35 +3,39 @@ import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; import { JsonApiResponse } from '@osf/shared/models'; import { JsonApiService } from '@osf/shared/services'; import { ScopeMapper, TokenMapper } from '../mappers'; import { ScopeJsonApi, ScopeModel, TokenGetResponseJsonApi, TokenModel } from '../models'; -import { environment } from 'src/environments/environment'; - @Injectable({ providedIn: 'root', }) export class TokensService { private readonly jsonApiService = inject(JsonApiService); + private readonly environment = inject(ENVIRONMENT); + + get apiUrl() { + return `${this.environment.apiDomainUrl}/v2`; + } getScopes(): Observable { return this.jsonApiService - .get>(`${environment.apiUrl}/scopes/`) + .get>(`${this.apiUrl}/scopes/`) .pipe(map((responses) => ScopeMapper.fromResponse(responses.data))); } getTokens(): Observable { return this.jsonApiService - .get>(`${environment.apiUrl}/tokens/`) + .get>(`${this.apiUrl}/tokens/`) .pipe(map((responses) => responses.data.map((response) => TokenMapper.fromGetResponse(response)))); } getTokenById(tokenId: string): Observable { return this.jsonApiService - .get>(`${environment.apiUrl}/tokens/${tokenId}/`) + .get>(`${this.apiUrl}/tokens/${tokenId}/`) .pipe(map((response) => TokenMapper.fromGetResponse(response.data))); } @@ -39,7 +43,7 @@ export class TokensService { const request = TokenMapper.toRequest(name, scopes); return this.jsonApiService - .post>(environment.apiUrl + '/tokens/', request) + .post>(`${this.apiUrl}/tokens/`, request) .pipe(map((response) => TokenMapper.fromGetResponse(response.data))); } @@ -47,11 +51,11 @@ export class TokensService { const request = TokenMapper.toRequest(name, scopes); return this.jsonApiService - .patch(`${environment.apiUrl}/tokens/${tokenId}/`, request) + .patch(`${this.apiUrl}/tokens/${tokenId}/`, request) .pipe(map((response) => TokenMapper.fromGetResponse(response))); } deleteToken(tokenId: string): Observable { - return this.jsonApiService.delete(`${environment.apiUrl}/tokens/${tokenId}/`); + return this.jsonApiService.delete(`${this.apiUrl}/tokens/${tokenId}/`); } } diff --git a/src/app/features/settings/tokens/tokens.component.spec.ts b/src/app/features/settings/tokens/tokens.component.spec.ts index de8bf16cc..c8e94a032 100644 --- a/src/app/features/settings/tokens/tokens.component.spec.ts +++ b/src/app/features/settings/tokens/tokens.component.spec.ts @@ -1,24 +1,61 @@ +import { Store } from '@ngxs/store'; + +import { MockProvider } from 'ng-mocks'; + +import { DialogService } from 'primeng/dynamicdialog'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MOCK_STORE } from '@shared/mocks'; +import { CustomDialogService } from '@shared/services'; + +import { GetScopes } from './store'; import { TokensComponent } from './tokens.component'; import { OSFTestingModule } from '@testing/osf.testing.module'; +import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { DialogServiceMockBuilder } from '@testing/providers/dialog-provider.mock'; -describe.skip('TokensComponent', () => { +describe('TokensComponent', () => { let component: TokensComponent; let fixture: ComponentFixture; + let mockCustomDialogService: ReturnType; + let mockDialogService: ReturnType; beforeEach(async () => { + mockCustomDialogService = CustomDialogServiceMockBuilder.create().withOpen(jest.fn()).build(); + mockDialogService = DialogServiceMockBuilder.create().withOpenMock().build(); + await TestBed.configureTestingModule({ imports: [TokensComponent, OSFTestingModule], + providers: [ + MockProvider(Store, MOCK_STORE), + MockProvider(CustomDialogService, mockCustomDialogService), + MockProvider(DialogService, mockDialogService), + ], }).compileComponents(); fixture = TestBed.createComponent(TokensComponent); component = fixture.componentInstance; + (MOCK_STORE.dispatch as jest.Mock).mockClear(); fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should dispatch getScopes on init', () => { + expect(MOCK_STORE.dispatch).toHaveBeenCalledWith(new GetScopes()); + }); + + it('should open create token dialog with correct config', () => { + component.createToken(); + expect(mockCustomDialogService.open).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + header: 'settings.tokens.form.createTitle', + }) + ); + }); }); diff --git a/src/app/features/settings/tokens/tokens.component.ts b/src/app/features/settings/tokens/tokens.component.ts index 194bfd636..4cb0d2dbf 100644 --- a/src/app/features/settings/tokens/tokens.component.ts +++ b/src/app/features/settings/tokens/tokens.component.ts @@ -1,8 +1,6 @@ import { createDispatchMap } from '@ngxs/store'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; - -import { DialogService } from 'primeng/dynamicdialog'; +import { TranslatePipe } from '@ngx-translate/core'; import { map } from 'rxjs'; @@ -11,7 +9,7 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { Router, RouterOutlet } from '@angular/router'; import { SubHeaderComponent } from '@osf/shared/components'; -import { IS_SMALL } from '@osf/shared/helpers'; +import { CustomDialogService } from '@osf/shared/services'; import { TokenAddEditFormComponent } from './components'; import { GetScopes } from './store'; @@ -21,16 +19,13 @@ import { GetScopes } from './store'; imports: [SubHeaderComponent, RouterOutlet, TranslatePipe], templateUrl: './tokens.component.html', styleUrl: './tokens.component.scss', - providers: [DialogService], changeDetection: ChangeDetectionStrategy.OnPush, }) export class TokensComponent implements OnInit { - private readonly dialogService = inject(DialogService); + private readonly customDialogService = inject(CustomDialogService); private readonly router = inject(Router); - private readonly translateService = inject(TranslateService); private readonly actions = createDispatchMap({ getScopes: GetScopes }); - readonly isSmall = toSignal(inject(IS_SMALL)); readonly isBaseRoute = toSignal(this.router.events.pipe(map(() => this.router.url === '/settings/tokens')), { initialValue: this.router.url === '/settings/tokens', }); @@ -40,15 +35,9 @@ export class TokensComponent implements OnInit { } createToken(): void { - const dialogWidth = this.isSmall() ? '800px ' : '95vw'; - - this.dialogService.open(TokenAddEditFormComponent, { - width: dialogWidth, - focusOnShow: false, - header: this.translateService.instant('settings.tokens.form.createTitle'), - closeOnEscape: true, - modal: true, - closable: true, + this.customDialogService.open(TokenAddEditFormComponent, { + header: 'settings.tokens.form.createTitle', + width: '800px', }); } } diff --git a/src/app/features/static/privacy-policy/privacy-policy.component.html b/src/app/features/static/privacy-policy/privacy-policy.component.html index 57e496bbd..ab2600e78 100644 --- a/src/app/features/static/privacy-policy/privacy-policy.component.html +++ b/src/app/features/static/privacy-policy/privacy-policy.component.html @@ -716,7 +716,7 @@

20. CONTACTING US

Version history for this policy is available - here + here.

diff --git a/src/app/features/static/privacy-policy/privacy-policy.component.ts b/src/app/features/static/privacy-policy/privacy-policy.component.ts index b7fc71312..8ee7afbcd 100644 --- a/src/app/features/static/privacy-policy/privacy-policy.component.ts +++ b/src/app/features/static/privacy-policy/privacy-policy.component.ts @@ -1,6 +1,6 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { environment } from 'src/environments/environment'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; @Component({ selector: 'osf-privacy-policy', @@ -10,5 +10,7 @@ import { environment } from 'src/environments/environment'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class PrivacyPolicyComponent { - readonly supportEmail = environment.supportEmail; + private readonly environment = inject(ENVIRONMENT); + + readonly supportEmail = this.environment.supportEmail; } diff --git a/src/app/features/static/terms-of-use/terms-of-use.component.html b/src/app/features/static/terms-of-use/terms-of-use.component.html index df74a589c..ccfc37295 100644 --- a/src/app/features/static/terms-of-use/terms-of-use.component.html +++ b/src/app/features/static/terms-of-use/terms-of-use.component.html @@ -674,7 +674,7 @@

24. QUESTIONS

Version history for this policy is available - here + here.

diff --git a/src/app/features/static/terms-of-use/terms-of-use.component.ts b/src/app/features/static/terms-of-use/terms-of-use.component.ts index 455f4b1c0..b8d4b331b 100644 --- a/src/app/features/static/terms-of-use/terms-of-use.component.ts +++ b/src/app/features/static/terms-of-use/terms-of-use.component.ts @@ -1,6 +1,6 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { environment } from 'src/environments/environment'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; @Component({ selector: 'osf-terms-of-use', @@ -10,5 +10,7 @@ import { environment } from 'src/environments/environment'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class TermsOfUseComponent { - readonly supportEmail = environment.supportEmail; + private readonly environment = inject(ENVIRONMENT); + + readonly supportEmail = this.environment.supportEmail; } diff --git a/src/app/shared/components/add-project-form/add-project-form.component.html b/src/app/shared/components/add-project-form/add-project-form.component.html index ee76fb2c5..3de7dced4 100644 --- a/src/app/shared/components/add-project-form/add-project-form.component.html +++ b/src/app/shared/components/add-project-form/add-project-form.component.html @@ -20,15 +20,18 @@ /> -
-

- {{ 'myProjects.createProject.affiliation.title' | translate }} -

- -
+ @if (affiliations() && affiliations().length) { +
+

+ {{ 'myProjects.createProject.affiliation.title' | translate }} +

+ +
+ }