diff --git a/angular.json b/angular.json index ca15e5e62..b87b677be 100644 --- a/angular.json +++ b/angular.json @@ -62,7 +62,12 @@ "node_modules/ngx-markdown-editor/assets/highlight.js/highlight.min.js", "node_modules/ngx-markdown-editor/assets/marked.min.js", "src/assets/js/ace/snippetsMarkdown.js" - ] + ], + "server": "src/main.server.ts", + "outputMode": "server", + "ssr": { + "entry": "src/server.ts" + } }, "configurations": { "production": { @@ -146,29 +151,27 @@ }, "serve": { "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "hmr": false + }, "configurations": { "production": { "buildTarget": "osf:build:production" }, "development": { - "buildTarget": "osf:build:development", - "hmr": true + "buildTarget": "osf:build:development" }, "docker": { - "buildTarget": "osf:build:docker", - "hmr": true + "buildTarget": "osf:build:docker" }, "staging": { - "buildTarget": "osf:build:staging", - "hmr": true + "buildTarget": "osf:build:staging" }, "test": { - "buildTarget": "osf:build:test", - "hmr": false + "buildTarget": "osf:build:test" }, "test-osf": { - "buildTarget": "osf:build:test-osf", - "hmr": false + "buildTarget": "osf:build:test-osf" } }, "defaultConfiguration": "development" diff --git a/package-lock.json b/package-lock.json index 266e54309..b32aa8dc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,9 @@ "@angular/forms": "^19.2.0", "@angular/platform-browser": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0", + "@angular/platform-server": "^19.2.0", "@angular/router": "^19.2.0", + "@angular/ssr": "^19.2.0", "@centerforopenscience/markdown-it-atrules": "^0.1.1", "@citation-js/core": "^0.7.18", "@citation-js/plugin-csl": "^0.7.18", @@ -35,6 +37,7 @@ "cedar-embeddable-editor": "1.2.2", "chart.js": "^4.4.9", "diff": "^8.0.2", + "express": "^4.18.2", "markdown-it": "^14.1.0", "markdown-it-anchor": "^9.2.0", "markdown-it-toc-done-right": "^4.2.0", @@ -59,10 +62,12 @@ "@commitlint/cli": "^19.7.1", "@commitlint/config-conventional": "^19.7.1", "@compodoc/compodoc": "^1.1.26", + "@types/express": "^4.17.17", "@types/gapi": "^0.0.47", "@types/gapi.auth2": "^0.0.61", "@types/jest": "^29.5.14", "@types/markdown-it": "^14.1.2", + "@types/node": "^18.18.0", "angular-eslint": "19.1.0", "angularx-qrcode": "^19.0.0", "eslint": "^9.20.0", @@ -132,17 +137,17 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-19.2.19.tgz", - "integrity": "sha512-uIxi6Vzss6+ycljVhkyPUPWa20w8qxJL9lEn0h6+sX/fhM8Djt0FHIuTQjoX58EoMaQ/1jrXaRaGimkbaFcG9A==", + "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.19", - "@angular-devkit/build-webpack": "0.1902.19", - "@angular-devkit/core": "19.2.19", - "@angular/build": "19.2.19", + "@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", @@ -153,7 +158,7 @@ "@babel/preset-env": "7.26.9", "@babel/runtime": "7.26.10", "@discoveryjs/json-ext": "0.6.3", - "@ngtools/webpack": "19.2.19", + "@ngtools/webpack": "19.2.17", "@vitejs/plugin-basic-ssl": "1.2.0", "ansi-colors": "4.1.3", "autoprefixer": "10.4.20", @@ -207,7 +212,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.19", + "@angular/ssr": "^19.2.17", "@web/test-runner": "^0.20.0", "browser-sync": "^3.0.2", "jest": "^29.5.0", @@ -257,50 +262,6 @@ } } }, - "node_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/architect": { - "version": "0.1902.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.19.tgz", - "integrity": "sha512-iexYDIYpGAeAU7T60bGcfrGwtq1bxpZixYxWuHYiaD1b5baQgNSfd1isGEOh37GgDNsf4In9i2LOLPm0wBdtgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "19.2.19", - "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_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/core": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.19.tgz", - "integrity": "sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==", - "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" - }, - "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" - }, - "peerDependencies": { - "chokidar": "^4.0.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, "node_modules/@angular-devkit/build-angular/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -312,13 +273,13 @@ } }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.1902.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1902.19.tgz", - "integrity": "sha512-x2tlGg5CsUveFzuRuqeHknSbGirSAoRynEh+KqPRGK0G3WpMViW/M8SuVurecasegfIrDWtYZ4FnVxKqNbKwXQ==", + "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.19", + "@angular-devkit/architect": "0.1902.17", "rxjs": "7.8.1" }, "engines": { @@ -331,50 +292,6 @@ "webpack-dev-server": "^5.0.2" } }, - "node_modules/@angular-devkit/build-webpack/node_modules/@angular-devkit/architect": { - "version": "0.1902.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.19.tgz", - "integrity": "sha512-iexYDIYpGAeAU7T60bGcfrGwtq1bxpZixYxWuHYiaD1b5baQgNSfd1isGEOh37GgDNsf4In9i2LOLPm0wBdtgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "19.2.19", - "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_modules/@angular-devkit/build-webpack/node_modules/@angular-devkit/core": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.19.tgz", - "integrity": "sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==", - "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" - }, - "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" - }, - "peerDependencies": { - "chokidar": "^4.0.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, "node_modules/@angular-devkit/build-webpack/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -630,14 +547,14 @@ } }, "node_modules/@angular/build": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.2.19.tgz", - "integrity": "sha512-SFzQ1bRkNFiOVu+aaz+9INmts7tDUrsHLEr9HmARXr9qk5UmR8prlw39p2u+Bvi6/lCiJ18TZMQQl9mGyr63lg==", + "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.19", + "@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", @@ -660,7 +577,7 @@ "sass": "1.85.0", "semver": "7.7.1", "source-map-support": "0.5.21", - "vite": "6.4.1", + "vite": "6.3.6", "watchpack": "2.4.2" }, "engines": { @@ -677,7 +594,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.19", + "@angular/ssr": "^19.2.17", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^19.0.0 || ^19.2.0-next.0", @@ -715,60 +632,6 @@ } } }, - "node_modules/@angular/build/node_modules/@angular-devkit/architect": { - "version": "0.1902.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.19.tgz", - "integrity": "sha512-iexYDIYpGAeAU7T60bGcfrGwtq1bxpZixYxWuHYiaD1b5baQgNSfd1isGEOh37GgDNsf4In9i2LOLPm0wBdtgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "19.2.19", - "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_modules/@angular/build/node_modules/@angular-devkit/core": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.19.tgz", - "integrity": "sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==", - "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" - }, - "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" - }, - "peerDependencies": { - "chokidar": "^4.0.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/@angular/build/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_modules/@angular/cdk": { "version": "19.2.19", "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.19.tgz", @@ -997,6 +860,26 @@ "@angular/platform-browser": "19.2.15" } }, + "node_modules/@angular/platform-server": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@angular/platform-server/-/platform-server-19.2.15.tgz", + "integrity": "sha512-VKuEmzFylYLnFjjFTctnbckgYdXEyt3wU0AwT3uuLrSU/3EgfHlqd33ONuYaIxSRES81GaLcV9cc9uiZYT2QMg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0", + "xhr2": "^0.2.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/common": "19.2.15", + "@angular/compiler": "19.2.15", + "@angular/core": "19.2.15", + "@angular/platform-browser": "19.2.15", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/router": { "version": "19.2.15", "resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.15.tgz", @@ -1015,6 +898,26 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@angular/ssr": { + "version": "19.2.19", + "resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-19.2.19.tgz", + "integrity": "sha512-7HqC3K99DdzDakB/4mkqGqY6REQNMxskU1VVkH9D7SthZSuxhWIMVBojVhBDd+JOUYiyQlwEGMBevbrgbtfKlQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^19.0.0 || ^19.2.0-next.0", + "@angular/core": "^19.0.0 || ^19.2.0-next.0", + "@angular/platform-server": "^19.0.0 || ^19.2.0-next.0", + "@angular/router": "^19.0.0 || ^19.2.0-next.0" + }, + "peerDependenciesMeta": { + "@angular/platform-server": { + "optional": true + } + } + }, "node_modules/@arr/every": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@arr/every/-/every-1.0.1.tgz", @@ -5876,9 +5779,9 @@ } }, "node_modules/@jsonjoy.com/buffers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", - "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.0.0.tgz", + "integrity": "sha512-NDigYR3PHqCnQLXYyoLbnEdzMMvzeiCWo1KOut7Q0CoIqg9tUAPKJ1iq/2nFhc5kZtexzutNY0LFjdwWL3Dw3Q==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5910,20 +5813,19 @@ } }, "node_modules/@jsonjoy.com/json-pack": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", - "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", + "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": { "@jsonjoy.com/base64": "^1.1.2", - "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/buffers": "^1.0.0", "@jsonjoy.com/codegen": "^1.0.0", - "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/json-pointer": "^1.0.1", "@jsonjoy.com/util": "^1.9.0", "hyperdyperid": "^1.2.0", - "thingies": "^2.5.0", - "tree-dump": "^1.1.0" + "thingies": "^2.5.0" }, "engines": { "node": ">=10.0" @@ -6602,9 +6504,9 @@ "license": "MIT" }, "node_modules/@ngtools/webpack": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.19.tgz", - "integrity": "sha512-R9aeTrOBiRVl8I698JWPniUAAEpSvzc8SUGWSM5UXWMcHnWqd92cOnJJ1aXDGJZKXrbhMhCBx9Dglmcks5IDpg==", + "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": { @@ -7555,9 +7457,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "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" ], @@ -7597,9 +7499,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "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" ], @@ -7625,9 +7527,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "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" ], @@ -7681,9 +7583,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "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" ], @@ -7723,9 +7625,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "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" ], @@ -8270,22 +8172,22 @@ "license": "MIT" }, "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", - "@types/serve-static": "^1" + "@types/serve-static": "*" } }, "node_modules/@types/express-serve-static-core": { - "version": "4.19.7", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", - "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", "dev": true, "license": "MIT", "dependencies": { @@ -8433,13 +8335,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", - "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.12.0" + "undici-types": "~5.26.4" } }, "node_modules/@types/node-forge": { @@ -8474,12 +8376,13 @@ "license": "MIT" }, "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", "dev": true, "license": "MIT", "dependencies": { + "@types/mime": "^1", "@types/node": "*" } }, @@ -8494,26 +8397,15 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", + "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" + "@types/send": "*" } }, "node_modules/@types/sockjs": { @@ -9352,7 +9244,6 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, "license": "MIT", "dependencies": { "mime-types": "~2.1.34", @@ -9366,7 +9257,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -9856,7 +9746,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "dev": true, "license": "MIT" }, "node_modules/array-ify": { @@ -10456,37 +10345,28 @@ } }, "node_modules/body-parser": { - "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==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "dev": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" - } - }, - "node_modules/body-parser/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" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/bonjour-service": { @@ -10834,7 +10714,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -11042,7 +10921,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -11056,7 +10934,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -11892,7 +11769,6 @@ "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" @@ -11905,7 +11781,6 @@ "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", @@ -11926,7 +11801,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -11988,7 +11862,6 @@ "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -11998,7 +11871,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "dev": true, "license": "MIT" }, "node_modules/copy-anything": { @@ -12549,9 +12421,9 @@ } }, "node_modules/default-browser": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", - "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -12566,9 +12438,9 @@ } }, "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", "dev": true, "license": "MIT", "engines": { @@ -12763,7 +12635,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -12773,7 +12644,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8", @@ -12781,9 +12651,9 @@ } }, "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "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, @@ -12957,7 +12827,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -12992,7 +12861,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, "license": "MIT" }, "node_modules/ejs": { @@ -13287,7 +13155,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13297,7 +13164,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13314,7 +13180,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -13445,7 +13310,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, "license": "MIT" }, "node_modules/escape-string-regexp": { @@ -14044,7 +13908,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -14148,40 +14011,39 @@ "license": "Apache-2.0" }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", - "dev": true, + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.13.0", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -14198,7 +14060,6 @@ "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", @@ -14223,7 +14084,6 @@ "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" @@ -14233,14 +14093,12 @@ "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" @@ -14250,7 +14108,6 @@ "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", @@ -14269,7 +14126,6 @@ "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" @@ -14279,7 +14135,6 @@ "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" @@ -14292,7 +14147,6 @@ "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" @@ -14302,7 +14156,6 @@ "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" @@ -14318,7 +14171,6 @@ "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", @@ -14334,7 +14186,6 @@ "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", @@ -14355,7 +14206,6 @@ "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", @@ -14380,7 +14230,6 @@ "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" @@ -14390,7 +14239,6 @@ "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" @@ -14400,7 +14248,6 @@ "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", @@ -14866,7 +14713,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -15029,7 +14875,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -15064,7 +14909,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -15167,9 +15011,9 @@ } }, "node_modules/glob-to-regex.js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", - "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "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": { @@ -15318,7 +15162,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -15516,7 +15359,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -15777,7 +15619,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, "license": "MIT", "dependencies": { "depd": "2.0.0", @@ -15794,7 +15635,6 @@ "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" @@ -19018,9 +18858,9 @@ } }, "node_modules/launch-editor": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.12.0.tgz", - "integrity": "sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.11.1.tgz", + "integrity": "sha512-SEET7oNfgSaB6Ym0jufAdCeo3meJVeCaaDyzRygy0xsp2BFKCprcfHljTq4QkzTLUxEKkFK6OK4811YM2oSrRg==", "dev": true, "license": "MIT", "dependencies": { @@ -20780,7 +20620,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -20803,9 +20642,9 @@ } }, "node_modules/memfs": { - "version": "4.51.0", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.51.0.tgz", - "integrity": "sha512-4zngfkVM/GpIhC8YazOsM6E8hoB33NP0BCESPOA6z7qaL6umPJNqkO8CNYaLV2FB2MV6H1O3x2luHHOSqppv+A==", + "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": { @@ -20838,7 +20677,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -20865,7 +20703,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -20902,7 +20739,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, "license": "MIT", "bin": { "mime": "cli.js" @@ -20915,7 +20751,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -20925,7 +20760,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -21284,7 +21118,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/msgpackr": { @@ -21542,9 +21375,9 @@ } }, "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.2.tgz", + "integrity": "sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw==", "dev": true, "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { @@ -22241,7 +22074,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -22345,7 +22177,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -23022,7 +22853,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -23107,7 +22937,6 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "dev": true, "license": "MIT" }, "node_modules/path-type": { @@ -23610,7 +23439,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -23624,7 +23452,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.10" @@ -23975,7 +23802,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -25115,7 +24941,6 @@ "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", @@ -25131,7 +24956,6 @@ "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" @@ -25141,14 +24965,12 @@ "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" @@ -25158,7 +24980,6 @@ "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" @@ -25168,7 +24989,6 @@ "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", @@ -25193,7 +25013,6 @@ "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" @@ -25203,7 +25022,6 @@ "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" @@ -25269,7 +25087,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, "license": "ISC" }, "node_modules/shallow-clone": { @@ -25325,7 +25142,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -25345,7 +25161,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -25362,7 +25177,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -25381,7 +25195,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -27104,7 +26917,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.6" @@ -27728,9 +27540,9 @@ } }, "node_modules/undici-types": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", - "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true, "license": "MIT" }, @@ -27850,7 +27662,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -28045,7 +27856,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4.0" @@ -28111,7 +27921,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -28133,9 +27942,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "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", "dependencies": { @@ -28208,9 +28017,9 @@ } }, "node_modules/vite/node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "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" ], @@ -28222,9 +28031,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "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" ], @@ -28236,9 +28045,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "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" ], @@ -28250,9 +28059,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "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" ], @@ -28264,9 +28073,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "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" ], @@ -28278,9 +28087,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "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" ], @@ -28292,9 +28101,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "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" ], @@ -28306,9 +28115,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "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" ], @@ -28320,9 +28129,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "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" ], @@ -28334,9 +28143,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "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" ], @@ -28348,9 +28157,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "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" ], @@ -28362,9 +28171,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "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" ], @@ -28376,9 +28185,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "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" ], @@ -28390,9 +28199,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "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" ], @@ -28404,9 +28213,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "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" ], @@ -28418,9 +28227,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "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" ], @@ -28432,9 +28241,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "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" ], @@ -28475,9 +28284,9 @@ } }, "node_modules/vite/node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", + "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", "dev": true, "license": "MIT", "dependencies": { @@ -28491,28 +28300,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@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" } }, @@ -29472,6 +29281,15 @@ "node": ">=4" } }, + "node_modules/xhr2": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.2.1.tgz", + "integrity": "sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", diff --git a/package.json b/package.json index 513090c34..1c9a244b6 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "test:coverage": "jest --coverage && npm run test:display", "test:check-coverage-thresholds": "node .github/scripts/check-coverage-thresholds.js", "test:display": "node .github/counter/counter.test.display.js", - "watch": "ng build --watch --configuration development" + "watch": "ng build --watch --configuration development", + "serve:ssr:osf": "node dist/osf/server/server.mjs" }, "private": true, "dependencies": { @@ -40,7 +41,9 @@ "@angular/forms": "^19.2.0", "@angular/platform-browser": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0", + "@angular/platform-server": "^19.2.0", "@angular/router": "^19.2.0", + "@angular/ssr": "^19.2.0", "@centerforopenscience/markdown-it-atrules": "^0.1.1", "@citation-js/core": "^0.7.18", "@citation-js/plugin-csl": "^0.7.18", @@ -59,6 +62,7 @@ "cedar-embeddable-editor": "1.2.2", "chart.js": "^4.4.9", "diff": "^8.0.2", + "express": "^4.18.2", "markdown-it": "^14.1.0", "markdown-it-anchor": "^9.2.0", "markdown-it-toc-done-right": "^4.2.0", @@ -83,10 +87,12 @@ "@commitlint/cli": "^19.7.1", "@commitlint/config-conventional": "^19.7.1", "@compodoc/compodoc": "^1.1.26", + "@types/express": "^4.17.17", "@types/gapi": "^0.0.47", "@types/gapi.auth2": "^0.0.61", "@types/jest": "^29.5.14", "@types/markdown-it": "^14.1.2", + "@types/node": "^18.18.0", "angular-eslint": "19.1.0", "angularx-qrcode": "^19.0.0", "eslint": "^9.20.0", diff --git a/src/app/app.component.ts b/src/app/app.component.ts index f40b224ab..03eee262a 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,8 +1,9 @@ -import { Actions, createDispatchMap, ofActionSuccessful, select } from '@ngxs/store'; +import { createDispatchMap, select } from '@ngxs/store'; -import { take, timer } from 'rxjs'; +import { switchMap, timer } from 'rxjs'; -import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit, PLATFORM_ID } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NavigationCancel, @@ -38,8 +39,9 @@ export class AppComponent implements OnInit { private readonly customDialogService = inject(CustomDialogService); private readonly router = inject(Router); private readonly environment = inject(ENVIRONMENT); - private readonly actions$ = inject(Actions); private readonly actions = createDispatchMap({ getCurrentUser: GetCurrentUser, getEmails: GetEmails }); + private readonly platformId = inject(PLATFORM_ID); + private readonly isBrowser = isPlatformBrowser(this.platformId); private readonly loaderService = inject(LoaderService); unverifiedEmails = select(UserEmailsSelectors.getUnverifiedEmails); @@ -53,32 +55,36 @@ export class AppComponent implements OnInit { } ngOnInit(): void { - this.actions.getCurrentUser(); + this.actions + .getCurrentUser() + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap(() => this.actions.getEmails()) + ) + .subscribe(); - this.actions$.pipe(ofActionSuccessful(GetCurrentUser), take(1)).subscribe(() => { - this.actions.getEmails(); - }); - - this.router.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { - if (event instanceof NavigationStart) { - this.loaderService.show(); - } else if ( - event instanceof NavigationEnd || - event instanceof NavigationCancel || - event instanceof NavigationError - ) { - timer(500) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => this.loaderService.hide()); - } + if (this.isBrowser) { + this.router.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { + if (event instanceof NavigationStart) { + this.loaderService.show(); + } else if ( + event instanceof NavigationEnd || + event instanceof NavigationCancel || + event instanceof NavigationError + ) { + timer(500) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.loaderService.hide()); + } - if (this.environment.googleTagManagerId && event instanceof NavigationEnd) { - this.googleTagManagerService.pushTag({ - event: 'page', - pageName: event.urlAfterRedirects, - }); - } - }); + if (this.environment.googleTagManagerId && event instanceof NavigationEnd) { + this.googleTagManagerService.pushTag({ + event: 'page', + pageName: event.urlAfterRedirects, + }); + } + }); + } } private showEmailDialog() { diff --git a/src/app/app.config.server.ts b/src/app/app.config.server.ts new file mode 100644 index 000000000..1f4702fcd --- /dev/null +++ b/src/app/app.config.server.ts @@ -0,0 +1,12 @@ +import { ApplicationConfig, mergeApplicationConfig } from '@angular/core'; +import { provideServerRendering } from '@angular/platform-server'; +import { provideServerRouting } from '@angular/ssr'; + +import { appConfig } from './app.config'; +import { serverRoutes } from './app.routes.server'; + +const serverConfig: ApplicationConfig = { + providers: [provideServerRendering(), provideServerRouting(serverRoutes)], +}; + +export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 4ab27fe0b..8c68eae0a 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -8,6 +8,7 @@ import { DialogService } from 'primeng/dynamicdialog'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { ApplicationConfig, ErrorHandler, importProvidersFrom, provideZoneChangeDetection } from '@angular/core'; +import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter, withInMemoryScrolling } from '@angular/router'; @@ -52,6 +53,7 @@ export const appConfig: ApplicationConfig = { provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'top', anchorScrolling: 'enabled' })), provideStore(STATES), provideZoneChangeDetection({ eventCoalescing: true }), + provideClientHydration(withEventReplay()), SENTRY_PROVIDER, ], }; diff --git a/src/app/app.routes.server.ts b/src/app/app.routes.server.ts new file mode 100644 index 000000000..a46f725ba --- /dev/null +++ b/src/app/app.routes.server.ts @@ -0,0 +1,64 @@ +import { RenderMode, ServerRoute } from '@angular/ssr'; + +export const serverRoutes: ServerRoute[] = [ + { + path: 'terms-of-use', + renderMode: RenderMode.Server, + }, + { + path: 'privacy-policy', + renderMode: RenderMode.Server, + }, + { + path: 'forbidden', + renderMode: RenderMode.Server, + }, + { + path: 'request-access/:id', + renderMode: RenderMode.Server, + }, + { + path: 'not-found', + renderMode: RenderMode.Server, + }, + { + path: 'preprints/:providerId/:id', + renderMode: RenderMode.Server, + }, + { + path: 'dashboard', + renderMode: RenderMode.Client, + }, + { + path: 'my-projects', + renderMode: RenderMode.Client, + }, + { + path: 'my-registrations', + renderMode: RenderMode.Client, + }, + { + path: 'my-preprints', + renderMode: RenderMode.Client, + }, + { + path: 'profile', + renderMode: RenderMode.Client, + }, + { + path: 'settings/**', + renderMode: RenderMode.Client, + }, + { + path: ':id/overview', + renderMode: RenderMode.Server, + }, + { + path: ':id', + renderMode: RenderMode.Server, + }, + { + path: '**', + renderMode: RenderMode.Client, + }, +]; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index b33808b45..e908cd10a 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -4,10 +4,10 @@ import { Routes } from '@angular/router'; import { authGuard } from '@core/guards/auth.guard'; import { isFileGuard } from '@core/guards/is-file.guard'; +import { isProjectGuard } from '@core/guards/is-project.guard'; +import { isRegistryGuard } from '@core/guards/is-registry.guard'; import { redirectIfLoggedInGuard } from '@core/guards/redirect-if-logged-in.guard'; -import { isProjectGuard } from './core/guards/is-project.guard'; -import { isRegistryGuard } from './core/guards/is-registry.guard'; import { MyPreprintsState } from './features/preprints/store/my-preprints'; import { ProfileState } from './features/profile/store'; import { RegistriesState } from './features/registries/store'; @@ -192,13 +192,11 @@ export const routes: Routes = [ path: ':id', canMatch: [isProjectGuard], loadChildren: () => import('./features/project/project.routes').then((m) => m.projectRoutes), - providers: [provideStates([ProjectsState, BookmarksState])], }, { path: ':id', canMatch: [isRegistryGuard], loadChildren: () => import('./features/registry/registry.routes').then((m) => m.registryRoutes), - providers: [provideStates([BookmarksState])], }, { path: '**', diff --git a/src/app/core/components/nav-menu/nav-menu.component.ts b/src/app/core/components/nav-menu/nav-menu.component.ts index 4f4d28086..dfd71016d 100644 --- a/src/app/core/components/nav-menu/nav-menu.component.ts +++ b/src/app/core/components/nav-menu/nav-menu.component.ts @@ -6,7 +6,8 @@ import { PanelMenuModule } from 'primeng/panelmenu'; import { filter, map } from 'rxjs'; -import { Component, computed, inject, output } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { Component, computed, inject, output, PLATFORM_ID } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router'; @@ -20,8 +21,8 @@ import { UserSelectors } from '@core/store/user'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; import { ReviewPermissions } from '@osf/shared/enums/review-permissions.enum'; -import { getViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; import { WrapFnPipe } from '@osf/shared/pipes/wrap-fn.pipe'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { CurrentResourceSelectors, GetResourceDetails } from '@osf/shared/stores/current-resource'; @Component({ @@ -36,6 +37,8 @@ export class NavMenuComponent { private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); private readonly authService = inject(AuthService); + private readonly platformId = inject(PLATFORM_ID); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); private readonly isAuthenticated = select(UserSelectors.isAuthenticated); private readonly currentResource = select(CurrentResourceSelectors.getCurrentResource); @@ -68,7 +71,7 @@ export class NavMenuComponent { this.provider()?.permissions?.includes(ReviewPermissions.ViewSubmissions), isCollections: this.isCollectionsRoute() || false, currentUrl: this.router.url, - isViewOnly: !!getViewOnlyParam(this.router), + viewOnly: this.viewOnlyService.getViewOnlyParam(this.router), permissions: this.currentResource()?.permissions, }; @@ -109,7 +112,7 @@ export class NavMenuComponent { } goToLink(item: CustomMenuItem) { - if (item.id === 'support' || item.id === 'donate') { + if (isPlatformBrowser(this.platformId) && (item.id === 'support' || item.id === 'donate')) { window.open(item.url, '_blank'); } diff --git a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.ts b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.ts index ca87aca36..30f039af9 100644 --- a/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.ts +++ b/src/app/core/components/osf-banners/cookie-consent-banner/cookie-consent-banner.component.ts @@ -4,7 +4,8 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Message } from 'primeng/message'; -import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject, PLATFORM_ID, signal } from '@angular/core'; import { fadeInOutAnimation } from '@core/animations/fade.in-out.animation'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; @@ -41,13 +42,16 @@ export class CookieConsentBannerComponent { * Cookie service used to persist dismissal state in the browser. */ private readonly cookies = inject(CookieService); + private readonly platformId = inject(PLATFORM_ID); /** * Initializes the component and sets the banner display * based on the existence of the cookie. */ constructor() { - this.displayBanner.set(!this.cookies.check(this.cookieName)); + if (isPlatformBrowser(this.platformId)) { + this.displayBanner.set(!this.cookies.check(this.cookieName)); + } } /** @@ -56,8 +60,10 @@ export class CookieConsentBannerComponent { * - Hides the banner immediately. */ acceptCookies() { - const expireDate = new Date('9999-12-31T23:59:59Z'); - this.cookies.set(this.cookieName, 'true', expireDate, '/'); + if (isPlatformBrowser(this.platformId)) { + const expireDate = new Date('9999-12-31T23:59:59Z'); + this.cookies.set(this.cookieName, 'true', expireDate, '/'); + } this.displayBanner.set(false); } } diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts index 2632ce03d..05b269412 100644 --- a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts +++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.ts @@ -2,8 +2,8 @@ import { CookieService } from 'ngx-cookie-service'; import { MessageModule } from 'primeng/message'; -import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core'; +import { CommonModule, isPlatformBrowser } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject, OnInit, PLATFORM_ID, signal } from '@angular/core'; import { fadeInOutAnimation } from '@core/animations/fade.in-out.animation'; @@ -52,6 +52,7 @@ export class MaintenanceBannerComponent implements OnInit { * Cookie service used to persist dismissal state in the browser. */ private readonly cookies = inject(CookieService); + private readonly platformId = inject(PLATFORM_ID); /** * The cookie name used to store whether the user dismissed the banner. @@ -69,7 +70,10 @@ export class MaintenanceBannerComponent implements OnInit { * - If not dismissed, triggers a fetch of current maintenance status */ ngOnInit(): void { - this.dismissed.set(this.cookies.check(this.cookieName)); + if (isPlatformBrowser(this.platformId)) { + this.dismissed.set(this.cookies.check(this.cookieName)); + } + if (!this.dismissed()) { this.fetchMaintenanceStatus(); } @@ -93,7 +97,10 @@ export class MaintenanceBannerComponent implements OnInit { * - Updates the `dismissed` and `maintenance` signals */ dismiss(): void { - this.cookies.set(this.cookieName, '1', this.cookieDurationHours, '/'); + if (isPlatformBrowser(this.platformId)) { + this.cookies.set(this.cookieName, '1', this.cookieDurationHours, '/'); + } + this.dismissed.set(true); this.maintenance.set(null); } diff --git a/src/app/core/guards/auth.guard.spec.ts b/src/app/core/guards/auth.guard.spec.ts index cc5dfcdb8..88910e9ed 100644 --- a/src/app/core/guards/auth.guard.spec.ts +++ b/src/app/core/guards/auth.guard.spec.ts @@ -1,41 +1,164 @@ -import { inject } from '@angular/core'; +import { MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; + +import { runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; import { AuthService } from '@core/services/auth.service'; +import { GetCurrentUser, UserSelectors } from '@osf/core/store/user'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { authGuard } from './auth.guard'; -jest.mock('@angular/core', () => ({ - ...jest.requireActual('@angular/core'), - inject: jest.fn(), -})); +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; -describe.skip('authGuard (functional)', () => { - let mockAuthService: jest.Mocked; +describe('authGuard', () => { + let router: Router; + let authService: AuthService; + let viewOnlyHelper: ViewOnlyLinkHelperService; beforeEach(() => { - mockAuthService = { - isAuthenticated: jest.fn(), - } as unknown as jest.Mocked; + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [], + actions: [], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + MockProvider(AuthService, { + navigateToSignIn: jest.fn(), + }), + MockProvider(ViewOnlyLinkHelperService, { + hasViewOnlyParam: jest.fn(), + }), + ], + }); + + router = TestBed.inject(Router); + authService = TestBed.inject(AuthService); + viewOnlyHelper = TestBed.inject(ViewOnlyLinkHelperService); + jest.clearAllMocks(); }); - it('should return true when user is authenticated', () => { - (inject as jest.Mock).mockImplementation((token) => { - if (token === AuthService) return mockAuthService; - }); + it('should return true when view-only param exists', () => { + jest.spyOn(viewOnlyHelper, 'hasViewOnlyParam').mockReturnValue(true); - const result = authGuard({} as any, {} as any); + const result = runInInjectionContext(TestBed, () => { + return authGuard({} as any, {} as any); + }); expect(result).toBe(true); + expect(viewOnlyHelper.hasViewOnlyParam).toHaveBeenCalledWith(router); + expect(authService.navigateToSignIn).not.toHaveBeenCalled(); }); - it('should navigate to sign-in and return false when user is not authenticated', () => { - (inject as jest.Mock).mockImplementation((token) => { - if (token === AuthService) return mockAuthService; + it('should return true when user is authenticated', (done) => { + jest.spyOn(viewOnlyHelper, 'hasViewOnlyParam').mockReturnValue(false); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: UserSelectors.isAuthenticated, + value: true, + }, + ], + actions: [ + { + action: GetCurrentUser, + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + MockProvider(AuthService, { + navigateToSignIn: jest.fn(), + }), + MockProvider(ViewOnlyLinkHelperService, { + hasViewOnlyParam: jest.fn().mockReturnValue(false), + }), + ], }); - const result = authGuard({} as any, {} as any); + router = TestBed.inject(Router); + authService = TestBed.inject(AuthService); - expect(mockAuthService.navigateToSignIn).toHaveBeenCalled(); - expect(result).toBe(false); + runInInjectionContext(TestBed, () => { + const result = authGuard({} as any, {} as any); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(true); + expect(authService.navigateToSignIn).not.toHaveBeenCalled(); + done(); + }); + } else { + expect(result).toBe(true); + done(); + } + }); + }); + + it('should navigate to sign-in and return false when user is not authenticated', (done) => { + jest.spyOn(viewOnlyHelper, 'hasViewOnlyParam').mockReturnValue(false); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: UserSelectors.isAuthenticated, + value: false, + }, + ], + actions: [ + { + action: GetCurrentUser, + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + MockProvider(AuthService, { + navigateToSignIn: jest.fn(), + }), + MockProvider(ViewOnlyLinkHelperService, { + hasViewOnlyParam: jest.fn().mockReturnValue(false), + }), + ], + }); + + router = TestBed.inject(Router); + authService = TestBed.inject(AuthService); + + runInInjectionContext(TestBed, () => { + const result = authGuard({} as any, {} as any); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(authService.navigateToSignIn).toHaveBeenCalled(); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); }); }); diff --git a/src/app/core/guards/auth.guard.ts b/src/app/core/guards/auth.guard.ts index d20108e71..e0a7b5155 100644 --- a/src/app/core/guards/auth.guard.ts +++ b/src/app/core/guards/auth.guard.ts @@ -7,26 +7,21 @@ import { CanActivateFn, Router } from '@angular/router'; import { AuthService } from '@core/services/auth.service'; import { GetCurrentUser, UserSelectors } from '@osf/core/store/user'; -import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; export const authGuard: CanActivateFn = () => { const store = inject(Store); const router = inject(Router); const authService = inject(AuthService); + const viewOnlyHelper = inject(ViewOnlyLinkHelperService); - const isAuthenticated = store.selectSnapshot(UserSelectors.isAuthenticated); - - if (isAuthenticated) { - return true; - } - - if (hasViewOnlyParam(router)) { + if (viewOnlyHelper.hasViewOnlyParam(router)) { return true; } return store.dispatch(GetCurrentUser).pipe( - switchMap(() => { - return store.select(UserSelectors.isAuthenticated).pipe( + switchMap(() => + store.select(UserSelectors.isAuthenticated).pipe( take(1), map((isAuthenticated) => { if (!isAuthenticated) { @@ -36,7 +31,7 @@ export const authGuard: CanActivateFn = () => { return true; }) - ); - }) + ) + ) ); }; diff --git a/src/app/core/guards/is-file.guard.spec.ts b/src/app/core/guards/is-file.guard.spec.ts new file mode 100644 index 000000000..512fbd45a --- /dev/null +++ b/src/app/core/guards/is-file.guard.spec.ts @@ -0,0 +1,492 @@ +import { of } from 'rxjs'; + +import { PLATFORM_ID, runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CurrentResource } from '@osf/shared/models/current-resource.model'; +import { CurrentResourceSelectors, GetResource } from '@osf/shared/stores/current-resource'; + +import { isFileGuard } from './is-file.guard'; + +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('isFileGuard', () => { + let router: Router; + + const createMockResource = (overrides?: Partial): CurrentResource => ({ + id: 'file-id', + type: CurrentResourceType.Files, + permissions: [], + ...overrides, + }); + + const createMockSegments = (path: string, secondPath?: string) => { + const segments = [{ path }] as any[]; + if (secondPath) { + segments.push({ path: secondPath }); + } + return segments; + }; + + beforeEach(() => { + Object.defineProperty(window, 'location', { + writable: true, + value: { + href: 'http://localhost/test', + }, + }); + + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [], + actions: [], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + { + provide: PLATFORM_ID, + useValue: 'browser', + }, + ], + }); + + router = TestBed.inject(Router); + jest.clearAllMocks(); + }); + + it('should return false when id is missing', () => { + const result = runInInjectionContext(TestBed, () => { + return isFileGuard({} as any, []); + }); + + expect(result).toBe(false); + }); + + it('should return false when resource is not found', (done) => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: null, + }, + ], + actions: [ + { + action: new GetResource('file-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + { + provide: PLATFORM_ID, + useValue: 'browser', + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isFileGuard({} as any, createMockSegments('file-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); + }); + + it('should return false when resource id does not match exactly', (done) => { + const resource = createMockResource({ id: 'different-id' }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + ], + actions: [ + { + action: new GetResource('file-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + { + provide: PLATFORM_ID, + useValue: 'browser', + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isFileGuard({} as any, createMockSegments('file-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); + }); + + it('should return true for Files with metadata path', (done) => { + const resource = createMockResource({ + id: 'file-id', + type: CurrentResourceType.Files, + }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + ], + actions: [ + { + action: new GetResource('file-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + { + provide: PLATFORM_ID, + useValue: 'browser', + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isFileGuard({} as any, createMockSegments('file-id', 'metadata')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(true); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }); + } else { + expect(result).toBe(true); + done(); + } + }); + }); + + it('should navigate and return false for Files with parentId', (done) => { + const resource = createMockResource({ + id: 'file-id', + type: CurrentResourceType.Files, + parentId: 'parent-id', + }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + ], + actions: [ + { + action: new GetResource('file-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + { + provide: PLATFORM_ID, + useValue: 'browser', + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isFileGuard({} as any, createMockSegments('file-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['/', 'parent-id', 'files', 'file-id'], {}); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); + }); + + it('should return true for Files without parentId', (done) => { + const resource = createMockResource({ + id: 'file-id', + type: CurrentResourceType.Files, + }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + ], + actions: [ + { + action: new GetResource('file-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + { + provide: PLATFORM_ID, + useValue: 'browser', + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isFileGuard({} as any, createMockSegments('file-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(true); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }); + } else { + expect(result).toBe(true); + done(); + } + }); + }); + + it('should return false for other resource types', (done) => { + const resource = createMockResource({ + id: 'resource-id', + type: CurrentResourceType.Projects, + }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + ], + actions: [ + { + action: new GetResource('resource-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + { + provide: PLATFORM_ID, + useValue: 'browser', + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isFileGuard({} as any, createMockSegments('resource-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); + }); + + it('should include view_only param in navigation when present in router.url (server-side)', (done) => { + const resource = createMockResource({ + id: 'file-id', + type: CurrentResourceType.Files, + parentId: 'parent-id', + }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + ], + actions: [ + { + action: new GetResource('file-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test?view_only=abc123').build(), + }, + { + provide: PLATFORM_ID, + useValue: 'server', + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isFileGuard({} as any, createMockSegments('file-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['/', 'parent-id', 'files', 'file-id'], { + queryParams: { view_only: 'abc123' }, + }); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); + }); + + it('should include view_only param in navigation when present in window.location (browser)', (done) => { + const resource = createMockResource({ + id: 'file-id', + type: CurrentResourceType.Files, + parentId: 'parent-id', + }); + + Object.defineProperty(window, 'location', { + writable: true, + value: { + href: 'http://localhost/test?view_only=xyz789', + }, + }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + ], + actions: [ + { + action: new GetResource('file-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().withUrl('/test').build(), + }, + { + provide: PLATFORM_ID, + useValue: 'browser', + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isFileGuard({} as any, createMockSegments('file-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['/', 'parent-id', 'files', 'file-id'], { + queryParams: { view_only: 'xyz789' }, + }); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); + }); +}); diff --git a/src/app/core/guards/is-file.guard.ts b/src/app/core/guards/is-file.guard.ts index dea7fe8e1..482e257f4 100644 --- a/src/app/core/guards/is-file.guard.ts +++ b/src/app/core/guards/is-file.guard.ts @@ -2,7 +2,8 @@ import { Store } from '@ngxs/store'; import { map, switchMap } from 'rxjs/operators'; -import { inject } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { inject, PLATFORM_ID } from '@angular/core'; import { CanMatchFn, Route, Router, UrlSegment } from '@angular/router'; import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; @@ -11,31 +12,31 @@ import { CurrentResourceSelectors, GetResource } from '@osf/shared/stores/curren export const isFileGuard: CanMatchFn = (route: Route, segments: UrlSegment[]) => { const store = inject(Store); const router = inject(Router); + const platformId = inject(PLATFORM_ID); + const isBrowser = isPlatformBrowser(platformId); const id = segments[0]?.path; const isMetadataPath = segments[1]?.path === 'metadata'; - const urlObj = new URL(window.location.href); - const viewOnly = urlObj.searchParams.get('view_only'); - const extras = viewOnly ? { queryParams: { view_only: viewOnly } } : {}; + let viewOnly: string | null = null; - if (!id) { - return false; - } + if (isBrowser) { + const urlObj = new URL(window.location.href); + viewOnly = urlObj.searchParams.get('view_only'); + } else { + const routerUrl = router.url; + const queryParams = routerUrl.split('?')[1]; - const currentResource = store.selectSnapshot(CurrentResourceSelectors.getCurrentResource); - if (currentResource && currentResource.id === id) { - if (currentResource.type === CurrentResourceType.Files) { - if (isMetadataPath) { - return true; - } - if (currentResource.parentId) { - router.navigate(['/', currentResource.parentId, 'files', id], extras); - return false; - } + if (queryParams) { + const params = new URLSearchParams(queryParams); + viewOnly = params.get('view_only'); } + } + + const extras = viewOnly ? { queryParams: { view_only: viewOnly } } : {}; - return currentResource.type === CurrentResourceType.Files; + if (!id) { + return false; } return store.dispatch(new GetResource(id)).pipe( @@ -49,11 +50,13 @@ export const isFileGuard: CanMatchFn = (route: Route, segments: UrlSegment[]) => if (isMetadataPath) { return true; } + if (resource.parentId) { router.navigate(['/', resource.parentId, 'files', id], extras); return false; } } + return resource.type === CurrentResourceType.Files; }) ); diff --git a/src/app/core/guards/is-project.guard.spec.ts b/src/app/core/guards/is-project.guard.spec.ts new file mode 100644 index 000000000..41df9a57c --- /dev/null +++ b/src/app/core/guards/is-project.guard.spec.ts @@ -0,0 +1,444 @@ +import { of } from 'rxjs'; + +import { runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +import { UserSelectors } from '@core/store/user'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CurrentResource } from '@osf/shared/models/current-resource.model'; +import { CurrentResourceSelectors, GetResource } from '@osf/shared/stores/current-resource'; + +import { isProjectGuard } from './is-project.guard'; + +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('isProjectGuard', () => { + let router: Router; + + const createMockResource = (overrides?: Partial): CurrentResource => ({ + id: 'test-id', + type: CurrentResourceType.Projects, + permissions: [], + ...overrides, + }); + + const createMockSegments = (path: string) => [{ path }] as any[]; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [], + actions: [], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + jest.clearAllMocks(); + }); + + it('should return false when id is missing', () => { + const result = runInInjectionContext(TestBed, () => { + return isProjectGuard({} as any, []); + }); + + expect(result).toBe(false); + }); + + it('should return false when resource is not found', (done) => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: null, + }, + ], + actions: [ + { + action: new GetResource('test-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isProjectGuard({} as any, createMockSegments('test-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); + }); + + it('should return false when id does not start with resource.id', (done) => { + const resource = createMockResource({ id: 'different-id' }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + ], + actions: [ + { + action: new GetResource('test-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isProjectGuard({} as any, createMockSegments('test-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); + }); + + it('should navigate and return true for Projects with parentId', (done) => { + const resource = createMockResource({ + id: 'parent-id', + type: CurrentResourceType.Projects, + parentId: 'parent-id', + }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + ], + actions: [ + { + action: new GetResource('parent-id/child-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isProjectGuard({} as any, createMockSegments('parent-id/child-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(true); + expect(router.navigate).toHaveBeenCalledWith(['/', 'parent-id', 'files', 'parent-id/child-id'], { + queryParamsHandling: 'preserve', + }); + done(); + }); + } else { + expect(result).toBe(true); + done(); + } + }); + }); + + it('should return true for Projects without parentId', (done) => { + const resource = createMockResource({ + id: 'project-id', + type: CurrentResourceType.Projects, + }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + ], + actions: [ + { + action: new GetResource('project-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isProjectGuard({} as any, createMockSegments('project-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(true); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }); + } else { + expect(result).toBe(true); + done(); + } + }); + }); + + it('should navigate and return true for Preprints with parentId', (done) => { + const resource = createMockResource({ + id: 'parent-id', + type: CurrentResourceType.Preprints, + parentId: 'parent-id', + }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + ], + actions: [ + { + action: new GetResource('parent-id/child-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isProjectGuard({} as any, createMockSegments('parent-id/child-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(true); + expect(router.navigate).toHaveBeenCalledWith(['/preprints', 'parent-id', 'parent-id/child-id']); + done(); + }); + } else { + expect(result).toBe(true); + done(); + } + }); + }); + + it('should navigate to profile and return false for Users when current user matches', (done) => { + const resource = createMockResource({ + id: 'user-id', + type: CurrentResourceType.Users, + }); + const currentUser = { id: 'user-id' }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + { + selector: UserSelectors.getCurrentUser, + value: currentUser, + }, + ], + actions: [ + { + action: new GetResource('user-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isProjectGuard({} as any, createMockSegments('user-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['/profile']); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); + }); + + it('should navigate to user page and return false for Users when current user does not match', (done) => { + const resource = createMockResource({ + id: 'user-id', + type: CurrentResourceType.Users, + }); + const currentUser = { id: 'different-user-id' }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + { + selector: UserSelectors.getCurrentUser, + value: currentUser, + }, + ], + actions: [ + { + action: new GetResource('user-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isProjectGuard({} as any, createMockSegments('user-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['/user', 'user-id']); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); + }); + + it('should return false for other resource types', (done) => { + const resource = createMockResource({ + id: 'resource-id', + type: CurrentResourceType.Files, + }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + ], + actions: [ + { + action: new GetResource('resource-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isProjectGuard({} as any, createMockSegments('resource-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); + }); +}); diff --git a/src/app/core/guards/is-project.guard.ts b/src/app/core/guards/is-project.guard.ts index e41a461af..f8921a40d 100644 --- a/src/app/core/guards/is-project.guard.ts +++ b/src/app/core/guards/is-project.guard.ts @@ -19,32 +19,6 @@ export const isProjectGuard: CanMatchFn = (route: Route, segments: UrlSegment[]) return false; } - const currentResource = store.selectSnapshot(CurrentResourceSelectors.getCurrentResource); - const currentUser = store.selectSnapshot(UserSelectors.getCurrentUser); - - if (currentResource && !id.startsWith(currentResource.id)) { - if (currentResource.type === CurrentResourceType.Projects && currentResource.parentId) { - router.navigate(['/', currentResource.parentId, 'files', id], { queryParamsHandling: 'preserve' }); - return true; - } - - if (currentResource.type === CurrentResourceType.Preprints && currentResource.parentId) { - router.navigate(['/preprints', currentResource.parentId, id]); - return true; - } - - if (currentResource.type === CurrentResourceType.Users) { - if (currentUser && currentUser.id === currentResource.id) { - router.navigate(['/profile']); - } else { - router.navigate(['/user', id]); - } - return false; - } - - return currentResource.type === CurrentResourceType.Projects; - } - return store.dispatch(new GetResource(id)).pipe( switchMap(() => store.select(CurrentResourceSelectors.getCurrentResource)), map((resource) => { @@ -63,11 +37,14 @@ export const isProjectGuard: CanMatchFn = (route: Route, segments: UrlSegment[]) } if (resource.type === CurrentResourceType.Users) { + const currentUser = store.selectSnapshot(UserSelectors.getCurrentUser); + if (currentUser && currentUser.id === resource.id) { router.navigate(['/profile']); } else { router.navigate(['/user', id]); } + return false; } diff --git a/src/app/core/guards/is-registry.guard.spec.ts b/src/app/core/guards/is-registry.guard.spec.ts new file mode 100644 index 000000000..60346f3ed --- /dev/null +++ b/src/app/core/guards/is-registry.guard.spec.ts @@ -0,0 +1,444 @@ +import { of } from 'rxjs'; + +import { runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +import { UserSelectors } from '@core/store/user'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CurrentResource } from '@osf/shared/models/current-resource.model'; +import { CurrentResourceSelectors, GetResource } from '@osf/shared/stores/current-resource'; + +import { isRegistryGuard } from './is-registry.guard'; + +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('isRegistryGuard', () => { + let router: Router; + + const createMockResource = (overrides?: Partial): CurrentResource => ({ + id: 'test-id', + type: CurrentResourceType.Registrations, + permissions: [], + ...overrides, + }); + + const createMockSegments = (path: string) => [{ path }] as any[]; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [], + actions: [], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + jest.clearAllMocks(); + }); + + it('should return false when id is missing', () => { + const result = runInInjectionContext(TestBed, () => { + return isRegistryGuard({} as any, []); + }); + + expect(result).toBe(false); + }); + + it('should return false when resource is not found', (done) => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: null, + }, + ], + actions: [ + { + action: new GetResource('test-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isRegistryGuard({} as any, createMockSegments('test-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); + }); + + it('should return false when id does not start with resource.id', (done) => { + const resource = createMockResource({ id: 'different-id' }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + ], + actions: [ + { + action: new GetResource('test-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isRegistryGuard({} as any, createMockSegments('test-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); + }); + + it('should navigate and return true for Registrations with parentId', (done) => { + const resource = createMockResource({ + id: 'parent-id', + type: CurrentResourceType.Registrations, + parentId: 'parent-id', + }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + ], + actions: [ + { + action: new GetResource('parent-id/child-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isRegistryGuard({} as any, createMockSegments('parent-id/child-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(true); + expect(router.navigate).toHaveBeenCalledWith(['/', 'parent-id', 'files', 'parent-id/child-id'], { + queryParamsHandling: 'preserve', + }); + done(); + }); + } else { + expect(result).toBe(true); + done(); + } + }); + }); + + it('should return true for Registrations without parentId', (done) => { + const resource = createMockResource({ + id: 'registration-id', + type: CurrentResourceType.Registrations, + }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + ], + actions: [ + { + action: new GetResource('registration-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isRegistryGuard({} as any, createMockSegments('registration-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(true); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }); + } else { + expect(result).toBe(true); + done(); + } + }); + }); + + it('should navigate and return true for Preprints with parentId', (done) => { + const resource = createMockResource({ + id: 'parent-id', + type: CurrentResourceType.Preprints, + parentId: 'parent-id', + }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + ], + actions: [ + { + action: new GetResource('parent-id/child-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isRegistryGuard({} as any, createMockSegments('parent-id/child-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(true); + expect(router.navigate).toHaveBeenCalledWith(['/preprints', 'parent-id', 'parent-id/child-id']); + done(); + }); + } else { + expect(result).toBe(true); + done(); + } + }); + }); + + it('should navigate to profile and return false for Users when current user matches', (done) => { + const resource = createMockResource({ + id: 'user-id', + type: CurrentResourceType.Users, + }); + const currentUser = { id: 'user-id' }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + { + selector: UserSelectors.getCurrentUser, + value: currentUser, + }, + ], + actions: [ + { + action: new GetResource('user-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isRegistryGuard({} as any, createMockSegments('user-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['/profile']); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); + }); + + it('should navigate to user page and return false for Users when current user does not match', (done) => { + const resource = createMockResource({ + id: 'user-id', + type: CurrentResourceType.Users, + }); + const currentUser = { id: 'different-user-id' }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + { + selector: UserSelectors.getCurrentUser, + value: currentUser, + }, + ], + actions: [ + { + action: new GetResource('user-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isRegistryGuard({} as any, createMockSegments('user-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['/user', 'user-id']); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); + }); + + it('should return false for other resource types', (done) => { + const resource = createMockResource({ + id: 'resource-id', + type: CurrentResourceType.Files, + }); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: CurrentResourceSelectors.getCurrentResource, + value: resource, + }, + ], + actions: [ + { + action: new GetResource('resource-id'), + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); + + runInInjectionContext(TestBed, () => { + const result = isRegistryGuard({} as any, createMockSegments('resource-id')); + + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); + }); +}); diff --git a/src/app/core/guards/is-registry.guard.ts b/src/app/core/guards/is-registry.guard.ts index 9c9369019..a187a25b9 100644 --- a/src/app/core/guards/is-registry.guard.ts +++ b/src/app/core/guards/is-registry.guard.ts @@ -19,32 +19,6 @@ export const isRegistryGuard: CanMatchFn = (route: Route, segments: UrlSegment[] return false; } - const currentResource = store.selectSnapshot(CurrentResourceSelectors.getCurrentResource); - const currentUser = store.selectSnapshot(UserSelectors.getCurrentUser); - - if (currentResource && !id.startsWith(currentResource.id)) { - if (currentResource.type === CurrentResourceType.Registrations && currentResource.parentId) { - router.navigate(['/', currentResource.parentId, 'files', id], { queryParamsHandling: 'preserve' }); - return true; - } - - if (currentResource.type === CurrentResourceType.Preprints && currentResource.parentId) { - router.navigate(['/preprints', currentResource.parentId, id]); - return true; - } - - if (currentResource.type === CurrentResourceType.Users) { - if (currentUser && currentUser.id === currentResource.id) { - router.navigate(['/profile']); - } else { - router.navigate(['/user', id]); - } - return false; - } - - return currentResource.type === CurrentResourceType.Registrations; - } - return store.dispatch(new GetResource(id)).pipe( switchMap(() => store.select(CurrentResourceSelectors.getCurrentResource)), map((resource) => { @@ -63,11 +37,14 @@ export const isRegistryGuard: CanMatchFn = (route: Route, segments: UrlSegment[] } if (resource.type === CurrentResourceType.Users) { + const currentUser = store.selectSnapshot(UserSelectors.getCurrentUser); + if (currentUser && currentUser.id === resource.id) { router.navigate(['/profile']); } else { router.navigate(['/user', id]); } + return false; } diff --git a/src/app/core/guards/redirect-if-logged-in.guard.spec.ts b/src/app/core/guards/redirect-if-logged-in.guard.spec.ts index f3855b273..a12976489 100644 --- a/src/app/core/guards/redirect-if-logged-in.guard.spec.ts +++ b/src/app/core/guards/redirect-if-logged-in.guard.spec.ts @@ -1,51 +1,120 @@ +import { of } from 'rxjs'; + +import { runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; -import { AuthService } from '@core/services/auth.service'; +import { GetCurrentUser, UserSelectors } from '@osf/core/store/user'; import { redirectIfLoggedInGuard } from './redirect-if-logged-in.guard'; -jest.mock('@angular/core', () => ({ - ...(jest.requireActual('@angular/core') as any), - inject: jest.fn(), -})); - -const inject = jest.requireMock('@angular/core').inject as jest.Mock; - -describe.skip('redirectIfLoggedInGuard', () => { - const mockAuthService = { - isAuthenticated: jest.fn(), - }; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; - const mockRouter = { - navigate: jest.fn(), - }; +describe('redirectIfLoggedInGuard', () => { + let router: Router; beforeEach(() => { - jest.clearAllMocks(); - inject.mockImplementation((token) => { - if (token === AuthService) return mockAuthService; - if (token === Router) return mockRouter; - return null; + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [], + actions: [], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], }); + + router = TestBed.inject(Router); + jest.clearAllMocks(); }); - it('should return false and call router.navigate if user is authenticated', () => { - mockAuthService.isAuthenticated.mockReturnValue(true); + it('should navigate to dashboard and return false when user is authenticated', (done) => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: UserSelectors.isAuthenticated, + value: true, + }, + ], + actions: [ + { + action: GetCurrentUser, + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); - const result = redirectIfLoggedInGuard({} as any, {} as any); + runInInjectionContext(TestBed, () => { + const result = redirectIfLoggedInGuard({} as any, {} as any); - expect(mockAuthService.isAuthenticated).toHaveBeenCalled(); - expect(mockRouter.navigate).toHaveBeenCalledWith(['/dashboard']); - expect(result).toBeUndefined(); + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['/dashboard']); + done(); + }); + } else { + expect(result).toBe(false); + done(); + } + }); }); - it('should return true and not call router.navigate if user is not authenticated', () => { - mockAuthService.isAuthenticated.mockReturnValue(false); + it('should return true and not navigate when user is not authenticated', (done) => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: UserSelectors.isAuthenticated, + value: false, + }, + ], + actions: [ + { + action: GetCurrentUser, + value: of(true), + }, + ], + }), + { + provide: Router, + useValue: RouterMockBuilder.create().build(), + }, + ], + }); + + router = TestBed.inject(Router); - const result = redirectIfLoggedInGuard({} as any, {} as any); + runInInjectionContext(TestBed, () => { + const result = redirectIfLoggedInGuard({} as any, {} as any); - expect(mockAuthService.isAuthenticated).toHaveBeenCalled(); - expect(mockRouter.navigate).not.toHaveBeenCalled(); - expect(result).toBe(true); + if (typeof result === 'object' && 'subscribe' in result) { + result.subscribe((value) => { + expect(value).toBe(true); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }); + } else { + expect(result).toBe(true); + done(); + } + }); }); }); diff --git a/src/app/core/guards/redirect-if-logged-in.guard.ts b/src/app/core/guards/redirect-if-logged-in.guard.ts index b89bcb1eb..9e6232945 100644 --- a/src/app/core/guards/redirect-if-logged-in.guard.ts +++ b/src/app/core/guards/redirect-if-logged-in.guard.ts @@ -11,25 +11,19 @@ export const redirectIfLoggedInGuard: CanActivateFn = () => { const store = inject(Store); const router = inject(Router); - const isAuthenticated = store.selectSnapshot(UserSelectors.isAuthenticated); - - if (isAuthenticated) { - router.navigate(['/dashboard']); - return false; - } - return store.dispatch(GetCurrentUser).pipe( - switchMap(() => { - return store.select(UserSelectors.isAuthenticated).pipe( + switchMap(() => + store.select(UserSelectors.isAuthenticated).pipe( take(1), map((isAuthenticated) => { if (isAuthenticated) { router.navigate(['/dashboard']); return false; } + return true; }) - ); - }) + ) + ) ); }; diff --git a/src/app/core/guards/registration-moderation.guard.ts b/src/app/core/guards/registration-moderation.guard.ts index b9286fa5b..89fd02224 100644 --- a/src/app/core/guards/registration-moderation.guard.ts +++ b/src/app/core/guards/registration-moderation.guard.ts @@ -16,7 +16,9 @@ export const registrationModerationGuard: CanActivateFn = (route) => { if (provider?.reviewsWorkflow) { return true; } + const id = route.params['providerId']; + return store.dispatch(new GetRegistryProvider(id)).pipe( switchMap(() => { return store.select(RegistrationProviderSelectors.getBrandedProvider).pipe( diff --git a/src/app/core/guards/view-only.guard.ts b/src/app/core/guards/view-only.guard.ts index 4cd3f5117..6410c406d 100644 --- a/src/app/core/guards/view-only.guard.ts +++ b/src/app/core/guards/view-only.guard.ts @@ -2,12 +2,13 @@ import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; import { VIEW_ONLY_EXCLUDED_ROUTES } from '@core/constants/view-only-excluded-routes.const'; -import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; export const viewOnlyGuard: CanActivateFn = (route) => { const router = inject(Router); + const viewOnlyHelper = inject(ViewOnlyLinkHelperService); - if (!hasViewOnlyParam(router)) { + if (!viewOnlyHelper.hasViewOnlyParam(router)) { return true; } @@ -23,7 +24,7 @@ export const viewOnlyGuard: CanActivateFn = (route) => { const urlSegments = router.url.split('/'); const resourceId = urlSegments[1]; - const viewOnlyParam = new URLSearchParams(window.location.search).get('view_only'); + const viewOnlyParam = viewOnlyHelper.getViewOnlyParam(router); if (resourceId && viewOnlyParam) { router.navigate([resourceId, 'overview'], { diff --git a/src/app/core/helpers/nav-menu.helper.ts b/src/app/core/helpers/nav-menu.helper.ts index b126d2dc7..4b6a79954 100644 --- a/src/app/core/helpers/nav-menu.helper.ts +++ b/src/app/core/helpers/nav-menu.helper.ts @@ -8,7 +8,6 @@ import { } from '@core/constants/nav-items.constant'; import { RouteContext } from '@core/models/route-context.model'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; -import { getViewOnlyParamFromUrl } from '@osf/shared/helpers/view-only.helper'; import { CustomMenuItem } from '../models/custom-menu-item.model'; @@ -96,7 +95,7 @@ function updateProjectMenuItem(item: CustomMenuItem, ctx: RouteContext): CustomM if (hasProject) { let menuItems = PROJECT_MENU_ITEMS; - if (ctx.isViewOnly) { + if (ctx.viewOnly) { const allowedViewOnlyItems = VIEW_ONLY_PROJECT_MENU_ITEMS; menuItems = PROJECT_MENU_ITEMS.filter((menuItem) => allowedViewOnlyItems.includes(menuItem.id || '')); } @@ -124,7 +123,7 @@ function updateProjectMenuItem(item: CustomMenuItem, ctx: RouteContext): CustomM items: menuItems.map((menuItem) => ({ ...menuItem, routerLink: [ctx.resourceId as string, menuItem.routerLink], - queryParams: ctx.isViewOnly ? { view_only: getViewOnlyParamFromUrl(ctx.currentUrl) } : undefined, + queryParams: ctx.viewOnly ? { view_only: ctx.viewOnly } : undefined, })), }; } @@ -143,7 +142,7 @@ function updateRegistryMenuItem(item: CustomMenuItem, ctx: RouteContext): Custom if (hasRegistry) { let menuItems = REGISTRATION_MENU_ITEMS; - if (ctx.isViewOnly) { + if (ctx.viewOnly) { const allowedViewOnlyItems = VIEW_ONLY_REGISTRY_MENU_ITEMS; menuItems = REGISTRATION_MENU_ITEMS.filter((menuItem) => allowedViewOnlyItems.includes(menuItem.id || '')); } @@ -165,7 +164,7 @@ function updateRegistryMenuItem(item: CustomMenuItem, ctx: RouteContext): Custom return { ...menuItem, routerLink: [ctx.resourceId as string, menuItem.routerLink], - queryParams: ctx.isViewOnly ? { view_only: getViewOnlyParamFromUrl(ctx.currentUrl) } : undefined, + queryParams: ctx.viewOnly ? { view_only: ctx.viewOnly } : undefined, }; }), }; @@ -199,7 +198,7 @@ function updatePreprintMenuItem(item: CustomMenuItem, ctx: RouteContext): Custom items: PREPRINT_MENU_ITEMS.map((menuItem) => ({ ...menuItem, routerLink: ['preprints', ctx.providerId, ctx.resourceId as string], - queryParams: ctx.isViewOnly ? { view_only: getViewOnlyParamFromUrl(ctx.currentUrl) } : undefined, + queryParams: ctx.viewOnly ? { view_only: ctx.viewOnly } : undefined, })), }; } diff --git a/src/app/core/interceptors/auth.interceptor.ts b/src/app/core/interceptors/auth.interceptor.ts index 9c73826bc..0ed51e98f 100644 --- a/src/app/core/interceptors/auth.interceptor.ts +++ b/src/app/core/interceptors/auth.interceptor.ts @@ -2,37 +2,55 @@ import { CookieService } from 'ngx-cookie-service'; import { Observable } from 'rxjs'; +import { isPlatformBrowser } from '@angular/common'; import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http'; -import { inject } from '@angular/core'; +import { inject, PLATFORM_ID, REQUEST } from '@angular/core'; export const authInterceptor: HttpInterceptorFn = ( req: HttpRequest, next: HttpHandlerFn ): Observable> => { const cookieService = inject(CookieService); + const platformId = inject(PLATFORM_ID); + const serverRequest = inject(REQUEST, { optional: true }); - const csrfToken = cookieService.get('api-csrf'); - - if (!req.url.includes('/api.crossref.org/funders')) { - const headers: Record = {}; + if (req.url.includes('/api.crossref.org/funders')) { + return next(req); + } - headers['Accept'] = req.responseType === 'text' ? '*/*' : 'application/vnd.api+json;version=2.20'; + const headers: Record = {}; + headers['Accept'] = req.responseType === 'text' ? '*/*' : 'application/vnd.api+json;version=2.20'; - if (!req.headers.has('Content-Type')) { - headers['Content-Type'] = 'application/vnd.api+json'; - } + if (!req.headers.has('Content-Type')) { + headers['Content-Type'] = 'application/vnd.api+json'; + } + if (isPlatformBrowser(platformId)) { + const csrfToken = cookieService.get('api-csrf'); if (csrfToken) { headers['X-CSRFToken'] = csrfToken; } - const authReq = req.clone({ - setHeaders: headers, - withCredentials: true, - }); + const authReq = req.clone({ setHeaders: headers, withCredentials: true }); return next(authReq); - } else { - return next(req); } + + if (serverRequest) { + const cookieHeader = serverRequest.headers.get('cookie') || ''; + if (cookieHeader) { + if (isPlatformBrowser(platformId)) { + headers['Cookie'] = cookieHeader; + } + + const csrfMatch = cookieHeader.match(/api-csrf=([^;]+)/); + if (csrfMatch) { + headers['X-CSRFToken'] = csrfMatch[1]; + } + } + } + + const authReq = req.clone({ setHeaders: headers }); + + return next(authReq); }; diff --git a/src/app/core/interceptors/error.interceptor.ts b/src/app/core/interceptors/error.interceptor.ts index d4f038c89..b87629369 100644 --- a/src/app/core/interceptors/error.interceptor.ts +++ b/src/app/core/interceptors/error.interceptor.ts @@ -1,16 +1,17 @@ import { throwError } from 'rxjs'; import { catchError } from 'rxjs/operators'; +import { isPlatformBrowser } from '@angular/common'; import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; -import { inject } from '@angular/core'; +import { inject, PLATFORM_ID } from '@angular/core'; import { Router } from '@angular/router'; import { ERROR_MESSAGES } from '@core/constants/error-messages'; import { SENTRY_TOKEN } from '@core/provider/sentry.provider'; import { AuthService } from '@core/services/auth.service'; -import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; import { LoaderService } from '@osf/shared/services/loader.service'; import { ToastService } from '@osf/shared/services/toast.service'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { BYPASS_ERROR_INTERCEPTOR } from './error-interceptor.tokens'; @@ -20,6 +21,8 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => { const router = inject(Router); const authService = inject(AuthService); const sentry = inject(SENTRY_TOKEN); + const platformId = inject(PLATFORM_ID); + const viewOnlyHelper = inject(ViewOnlyLinkHelperService); return next(req).pipe( catchError((error: HttpErrorResponse) => { @@ -29,7 +32,7 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => { return throwError(() => error); } - if (error.error instanceof ErrorEvent) { + if (isPlatformBrowser(platformId) && error.error instanceof ErrorEvent) { errorMessage = error.error.message; } else { if (error.error?.errors?.[0]?.detail) { @@ -50,8 +53,10 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => { } if (error.status === 401) { - if (!hasViewOnlyParam(router)) { - authService.logout(); + if (!viewOnlyHelper.hasViewOnlyParam(router)) { + if (isPlatformBrowser(platformId)) { + authService.logout(); + } } return throwError(() => error); } diff --git a/src/app/core/interceptors/view-only.interceptor.ts b/src/app/core/interceptors/view-only.interceptor.ts index e77c731ab..fe3ae6fa1 100644 --- a/src/app/core/interceptors/view-only.interceptor.ts +++ b/src/app/core/interceptors/view-only.interceptor.ts @@ -4,15 +4,16 @@ import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angul import { inject } from '@angular/core'; import { Router } from '@angular/router'; -import { getViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; export const viewOnlyInterceptor: HttpInterceptorFn = ( req: HttpRequest, next: HttpHandlerFn ): Observable> => { const router = inject(Router); + const viewOnlyHelper = inject(ViewOnlyLinkHelperService); - const viewOnlyParam = getViewOnlyParam(router); + const viewOnlyParam = viewOnlyHelper.getViewOnlyParam(router); if (!req.url.includes('/api.crossref.org/funders') && viewOnlyParam) { if (req.url.includes('view_only=')) { diff --git a/src/app/core/models/route-context.model.ts b/src/app/core/models/route-context.model.ts index fa6f20a7b..d0707d9a0 100644 --- a/src/app/core/models/route-context.model.ts +++ b/src/app/core/models/route-context.model.ts @@ -3,15 +3,15 @@ import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; export interface RouteContext { resourceId: string | undefined; providerId?: string; - isProject: boolean; wikiPageVisible?: boolean; + isProject: boolean; isRegistry: boolean; isPreprint: boolean; + isCollections: boolean; preprintReviewsPageVisible?: boolean; registrationModerationPageVisible?: boolean; collectionModerationPageVisible?: boolean; - isCollections: boolean; currentUrl?: string; - isViewOnly?: boolean; + viewOnly?: string | null; permissions?: UserPermissions[]; } diff --git a/src/app/core/provider/application.initialization.provider.ts b/src/app/core/provider/application.initialization.provider.ts index b022860e9..10e11a5d9 100644 --- a/src/app/core/provider/application.initialization.provider.ts +++ b/src/app/core/provider/application.initialization.provider.ts @@ -1,10 +1,10 @@ -import { inject, provideAppInitializer } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { inject, PLATFORM_ID, provideAppInitializer } from '@angular/core'; import { OSFConfigService } from '@core/services/osf-config.service'; import { ENVIRONMENT } from './environment.provider'; -import { BrowserAgent } from '@newrelic/browser-agent/loaders/browser-agent'; import * as Sentry from '@sentry/angular'; import { GoogleTagManagerConfiguration } from 'angular-google-tag-manager'; @@ -19,32 +19,38 @@ import { GoogleTagManagerConfiguration } from 'angular-google-tag-manager'; */ export function initializeApplication() { return async () => { + const platformId = inject(PLATFORM_ID); const configService = inject(OSFConfigService); const googleTagManagerConfiguration = inject(GoogleTagManagerConfiguration); const environment = inject(ENVIRONMENT); await configService.load(); - const googleTagManagerId = environment.googleTagManagerId; + if (isPlatformBrowser(platformId)) { + const googleTagManagerId = environment.googleTagManagerId; - if (googleTagManagerId) { - googleTagManagerConfiguration.set({ id: googleTagManagerId }); - } + if (googleTagManagerId) { + googleTagManagerConfiguration.set({ id: googleTagManagerId }); + } + + const dsn = environment.sentryDsn; - const dsn = environment.sentryDsn; - if (dsn) { - // More Options - // https://docs.sentry.io/platforms/javascript/guides/angular/configuration/options/ - Sentry.init({ - dsn, - environment: environment.production ? 'production' : 'development', - maxBreadcrumbs: 50, - sampleRate: 1.0, - integrations: [], - }); + if (dsn) { + // More Options + // https://docs.sentry.io/platforms/javascript/guides/angular/configuration/options/ + Sentry.init({ + dsn, + environment: environment.production ? 'production' : 'development', + maxBreadcrumbs: 50, + sampleRate: 1.0, + integrations: [], + }); + } } - if (environment.newRelicEnabled) { + if (environment.newRelicEnabled && isPlatformBrowser(platformId)) { + const { BrowserAgent } = await import('@newrelic/browser-agent/loaders/browser-agent'); + const newRelicConfig = { enabled: environment.newRelicEnabled, init: { diff --git a/src/app/core/services/auth.service.ts b/src/app/core/services/auth.service.ts index 871fa81b5..b51cb58b9 100644 --- a/src/app/core/services/auth.service.ts +++ b/src/app/core/services/auth.service.ts @@ -2,7 +2,8 @@ import { createDispatchMap } from '@ngxs/store'; import { CookieService } from 'ngx-cookie-service'; -import { inject, Injectable } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { inject, Injectable, PLATFORM_ID } from '@angular/core'; import { SignUpModel } from '@core/models/sign-up.model'; import { ENVIRONMENT } from '@core/provider/environment.provider'; @@ -19,6 +20,7 @@ export class AuthService { private readonly cookieService = inject(CookieService); private readonly loaderService = inject(LoaderService); private readonly environment = inject(ENVIRONMENT); + private readonly platformId = inject(PLATFORM_ID); private readonly actions = createDispatchMap({ clearCurrentUser: ClearCurrentUser }); get apiUrl() { @@ -34,34 +36,51 @@ export class AuthService { } navigateToSignIn(): void { + if (!isPlatformBrowser(this.platformId)) { + return; + } + this.loaderService.show(); const loginUrl = `${this.casUrl}/login?${urlParam({ service: `${this.webUrl}/login`, next: window.location.href })}`; window.location.href = loginUrl; } navigateToOrcidSignIn(): void { + if (!isPlatformBrowser(this.platformId)) { + return; + } + const loginUrl = `${this.casUrl}/login?${urlParam({ redirectOrcid: 'true', service: `${this.webUrl}/login`, next: window.location.href, })}`; + window.location.href = loginUrl; } navigateToInstitutionSignIn(): void { + if (!isPlatformBrowser(this.platformId)) { + return; + } + const loginUrl = `${this.casUrl}/login?${urlParam({ campaign: 'institution', service: `${this.webUrl}/login`, next: window.location.href, })}`; + window.location.href = loginUrl; } logout(): void { this.loaderService.show(); - this.cookieService.deleteAll(); this.actions.clearCurrentUser(); - window.location.href = `${this.webUrl}/logout/?next=${encodeURIComponent('/')}`; + + if (isPlatformBrowser(this.platformId)) { + this.cookieService.deleteAll(); + window.location.href = `${this.webUrl}/logout/?next=${encodeURIComponent('/')}`; + } } register(payload: SignUpModel) { diff --git a/src/app/core/services/help-scout.service.ts b/src/app/core/services/help-scout.service.ts index e8a00b71b..1278a9276 100644 --- a/src/app/core/services/help-scout.service.ts +++ b/src/app/core/services/help-scout.service.ts @@ -1,6 +1,7 @@ import { Store } from '@ngxs/store'; -import { effect, inject, Injectable } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { effect, inject, Injectable, PLATFORM_ID } from '@angular/core'; import { WINDOW } from '@core/provider/window.provider'; import { UserSelectors } from '@osf/core/store/user'; @@ -22,6 +23,7 @@ export class HelpScoutService { * Used to access and manipulate `dataLayer` for tracking. */ private window = inject(WINDOW); + private platformId = inject(PLATFORM_ID); /** * Angular Store instance used to access application state via NgRx. @@ -51,19 +53,23 @@ export class HelpScoutService { * - `resourceType`: undefined */ constructor() { - if (this.window.dataLayer) { - this.window.dataLayer.loggedIn = false; - this.window.dataLayer.resourceType = undefined; - } else { - this.window.dataLayer = { - loggedIn: false, - resourceType: undefined, - }; - } + if (isPlatformBrowser(this.platformId)) { + if (this.window.dataLayer) { + this.window.dataLayer.loggedIn = false; + this.window.dataLayer.resourceType = undefined; + } else { + this.window.dataLayer = { + loggedIn: false, + resourceType: undefined, + }; + } - effect(() => { - this.window.dataLayer.loggedIn = this.isAuthenticated(); - }); + effect(() => { + if (this.window.dataLayer) { + this.window.dataLayer.loggedIn = this.isAuthenticated(); + } + }); + } } /** @@ -72,13 +78,17 @@ export class HelpScoutService { * @param resourceType - The name of the resource (e.g., 'project', 'node') */ setResourceType(resourceType: string): void { - this.window.dataLayer.resourceType = resourceType; + if (isPlatformBrowser(this.platformId) && this.window.dataLayer) { + this.window.dataLayer.resourceType = resourceType; + } } /** * Clears the `resourceType` from the `dataLayer`, setting it to `undefined`. */ unsetResourceType(): void { - this.window.dataLayer.resourceType = undefined; + if (isPlatformBrowser(this.platformId) && this.window.dataLayer) { + this.window.dataLayer.resourceType = undefined; + } } } diff --git a/src/app/core/services/osf-config.service.ts b/src/app/core/services/osf-config.service.ts index 6d23156f3..8a237ddd9 100644 --- a/src/app/core/services/osf-config.service.ts +++ b/src/app/core/services/osf-config.service.ts @@ -1,7 +1,8 @@ import { catchError, lastValueFrom, of, shareReplay } from 'rxjs'; +import { isPlatformBrowser } from '@angular/common'; import { HttpClient } from '@angular/common/http'; -import { inject, Injectable } from '@angular/core'; +import { inject, Injectable, PLATFORM_ID } from '@angular/core'; import { ConfigModel } from '@core/models/config.model'; import { ENVIRONMENT } from '@core/provider/environment.provider'; @@ -23,6 +24,7 @@ export class OSFConfigService { * Injected via Angular's dependency injection system. */ private http: HttpClient = inject(HttpClient); + private platformId = inject(PLATFORM_ID); /** * Injected instance of the application environment configuration. @@ -38,9 +40,10 @@ export class OSFConfigService { /** * Loads the configuration from the JSON file if not already loaded. * Ensures that only one request is made. + * On the server, this is skipped as config is only needed in the browser. */ async load(): Promise { - if (!this.config) { + if (!this.config && isPlatformBrowser(this.platformId)) { this.config = await lastValueFrom( this.http.get('/assets/config/config.json').pipe( shareReplay(1), diff --git a/src/app/core/services/storage.service.ts b/src/app/core/services/storage.service.ts new file mode 100644 index 000000000..f30868b89 --- /dev/null +++ b/src/app/core/services/storage.service.ts @@ -0,0 +1,27 @@ +import { isPlatformBrowser } from '@angular/common'; +import { inject, Injectable, PLATFORM_ID } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class StorageService { + private platformId = inject(PLATFORM_ID); + + getItem(key: string): string | null { + if (isPlatformBrowser(this.platformId)) { + return window.localStorage.getItem(key); + } + + return null; + } + + setItem(key: string, value: string): void { + if (isPlatformBrowser(this.platformId)) { + window.localStorage.setItem(key, value); + } + } + + removeItem(key: string): void { + if (isPlatformBrowser(this.platformId)) { + window.localStorage.removeItem(key); + } + } +} diff --git a/src/app/core/store/user/user.selectors.ts b/src/app/core/store/user/user.selectors.ts index 548dddc08..7b42ca0ad 100644 --- a/src/app/core/store/user/user.selectors.ts +++ b/src/app/core/store/user/user.selectors.ts @@ -11,9 +11,7 @@ import { UserState } from './user.state'; export class UserSelectors { @Selector([UserState]) static getCurrentUser(state: UserStateModel): UserModel | null { - return state.currentUser.data || localStorage.getItem('currentUser') - ? JSON.parse(localStorage.getItem('currentUser')!) - : null; + return state.currentUser.data; } @Selector([UserState]) @@ -53,7 +51,7 @@ export class UserSelectors { @Selector([UserState]) static isAuthenticated(state: UserStateModel): boolean { - return !!state.currentUser.data || !!localStorage.getItem('currentUser'); + return !!state.currentUser.data; } @Selector([UserState]) diff --git a/src/app/core/store/user/user.state.ts b/src/app/core/store/user/user.state.ts index c07fb1b5d..a5bdb2e88 100644 --- a/src/app/core/store/user/user.state.ts +++ b/src/app/core/store/user/user.state.ts @@ -4,6 +4,7 @@ import { tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { StorageService } from '@core/services/storage.service'; import { UserService } from '@core/services/user.service'; import { ProfileSettingsKey } from '@osf/shared/enums/profile-settings-key.enum'; import { removeNullable } from '@osf/shared/helpers/remove-nullable.helper'; @@ -30,11 +31,13 @@ import { USER_STATE_INITIAL, UserStateModel } from './user.model'; @Injectable() export class UserState { private userService = inject(UserService); + private storage = inject(StorageService); @Action(GetCurrentUser) getCurrentUser(ctx: StateContext) { - const currentUser = localStorage.getItem('currentUser'); - const activeFlags = localStorage.getItem('activeFlags'); + const currentUser = this.storage.getItem('currentUser'); + const activeFlags = this.storage.getItem('activeFlags'); + if (activeFlags) { ctx.patchState({ activeFlags: JSON.parse(activeFlags), @@ -74,10 +77,11 @@ export class UserState { }); if (data.currentUser) { - localStorage.setItem('currentUser', JSON.stringify(data.currentUser)); + this.storage.setItem('currentUser', JSON.stringify(data.currentUser)); } + if (data.activeFlags) { - localStorage.setItem('activeFlags', JSON.stringify(data.activeFlags)); + this.storage.setItem('activeFlags', JSON.stringify(data.activeFlags)); } }) ); @@ -93,7 +97,7 @@ export class UserState { }, }); - localStorage.setItem('currentUser', JSON.stringify(action.user)); + this.storage.setItem('currentUser', JSON.stringify(action.user)); } @Action(UpdateProfileSettingsEmployment) @@ -116,7 +120,7 @@ export class UserState { }, }); - localStorage.setItem('currentUser', JSON.stringify(user)); + this.storage.setItem('currentUser', JSON.stringify(user)); }) ); } @@ -141,7 +145,7 @@ export class UserState { }, }); - localStorage.setItem('currentUser', JSON.stringify(user)); + this.storage.setItem('currentUser', JSON.stringify(user)); }) ); } @@ -166,7 +170,7 @@ export class UserState { }, }); - localStorage.setItem('currentUser', JSON.stringify(user)); + this.storage.setItem('currentUser', JSON.stringify(user)); }) ); } @@ -198,7 +202,7 @@ export class UserState { }, }); - localStorage.setItem('currentUser', JSON.stringify(user)); + this.storage.setItem('currentUser', JSON.stringify(user)); }) ); } @@ -229,7 +233,7 @@ export class UserState { }, }, }); - localStorage.setItem('currentUser', JSON.stringify(response)); + this.storage.setItem('currentUser', JSON.stringify(response)); } }) ); @@ -246,6 +250,6 @@ export class UserState { activeFlags: [], }); - localStorage.removeItem('currentUser'); + this.storage.removeItem('currentUser'); } } diff --git a/src/app/core/theme/semantic.ts b/src/app/core/theme/semantic.ts index e96a52f7d..7c4225fb6 100644 --- a/src/app/core/theme/semantic.ts +++ b/src/app/core/theme/semantic.ts @@ -24,5 +24,17 @@ export const semantic = { }; function getCssVariableValue(variableName: string): string { + if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') { + const fallbackColors: Record = { + '--pr-blue-1': '#337ab7', + '--bg-blue-3': '#f1f8fd', + '--green-1': '#357935', + '--red-1': '#b73333', + '--blue-1': '#3792b1', + }; + + return fallbackColors[variableName] || ''; + } + return getComputedStyle(document.documentElement).getPropertyValue(variableName).trim(); } diff --git a/src/app/features/analytics/analytics.component.ts b/src/app/features/analytics/analytics.component.ts index f27d9b343..29494fd25 100644 --- a/src/app/features/analytics/analytics.component.ts +++ b/src/app/features/analytics/analytics.component.ts @@ -28,8 +28,8 @@ import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-link-message/view-only-link-message.component'; import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; import { Primitive } from '@osf/shared/helpers/types.helper'; -import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; import { DatasetInput } from '@osf/shared/models/charts/dataset-input'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { AnalyticsKpiComponent } from './components'; import { DATE_RANGE_OPTIONS } from './constants'; @@ -66,11 +66,12 @@ export class AnalyticsComponent implements OnInit { private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); private readonly translateService = inject(TranslateService); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); readonly resourceId = toSignal(this.route.parent?.params.pipe(map((params) => params['id'])) ?? of(undefined)); readonly resourceType = toSignal(this.route.data.pipe(map((params) => params['resourceType'])) ?? of(undefined)); - hasViewOnly = computed(() => hasViewOnlyParam(this.router)); + hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); analytics = select(AnalyticsSelectors.getMetrics(this.resourceId())); relatedCounts = select(AnalyticsSelectors.getRelatedCounts(this.resourceId())); diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts index 4a3193719..60378f381 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts @@ -21,18 +21,16 @@ import { FormGroup } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { UserSelectors } from '@core/store/user'; -import { AddToCollectionSteps } from '@osf/features/collections/enums'; -import { - ClearAddToCollectionState, - CreateCollectionSubmission, -} from '@osf/features/collections/store/add-to-collection'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; +import { CanDeactivateComponent } from '@osf/shared/models/can-deactivate.interface'; import { BrandService } from '@osf/shared/services/brand.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import { HeaderStyleHelper } from '@shared/helpers/header-style.helper'; -import { CanDeactivateComponent } from '@shared/models/can-deactivate.interface'; -import { CollectionsSelectors, GetCollectionProvider } from '@shared/stores/collections'; -import { ProjectsSelectors } from '@shared/stores/projects/projects.selectors'; +import { HeaderStyleService } from '@osf/shared/services/header-style.service'; +import { CollectionsSelectors, GetCollectionProvider } from '@osf/shared/stores/collections'; +import { ProjectsSelectors } from '@osf/shared/stores/projects'; + +import { AddToCollectionSteps } from '../../enums'; +import { ClearAddToCollectionState, CreateCollectionSubmission } from '../../store/add-to-collection'; import { AddToCollectionConfirmationDialogComponent } from './add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component'; import { CollectionMetadataStepComponent } from './collection-metadata-step/collection-metadata-step.component'; @@ -62,6 +60,8 @@ export class AddToCollectionComponent implements CanDeactivateComponent { private readonly route = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); private readonly customDialogService = inject(CustomDialogService); + private readonly brandService = inject(BrandService); + private readonly headerStyleHelper = inject(HeaderStyleService); readonly AddToCollectionSteps = AddToCollectionSteps; @@ -158,8 +158,8 @@ export class AddToCollectionComponent implements CanDeactivateComponent { const provider = this.collectionProvider(); if (provider && provider.brand) { - BrandService.applyBranding(provider.brand); - HeaderStyleHelper.applyHeaderStyles(provider.brand.secondaryColor, provider.brand.backgroundColor || ''); + this.brandService.applyBranding(provider.brand); + this.headerStyleHelper.applyHeaderStyles(provider.brand.secondaryColor, provider.brand.backgroundColor || ''); } }); } @@ -169,8 +169,8 @@ export class AddToCollectionComponent implements CanDeactivateComponent { this.actions.clearAddToCollectionState(); this.allowNavigation.set(false); - HeaderStyleHelper.resetToDefaults(); - BrandService.resetBranding(); + this.headerStyleHelper.resetToDefaults(); + this.brandService.resetBranding(); }); } diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.ts b/src/app/features/collections/components/collections-discover/collections-discover.component.ts index 61ff5ddc8..cd7929102 100644 --- a/src/app/features/collections/components/collections-discover/collections-discover.component.ts +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.ts @@ -14,10 +14,10 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ClearCurrentProvider } from '@core/store/provider'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; -import { HeaderStyleHelper } from '@osf/shared/helpers/header-style.helper'; import { CollectionsFilters } from '@osf/shared/models/collections/collections-filters.model'; import { BrandService } from '@osf/shared/services/brand.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { HeaderStyleService } from '@osf/shared/services/header-style.service'; import { ClearCollections, ClearCollectionSubmissions, @@ -54,6 +54,8 @@ export class CollectionsDiscoverComponent { private customDialogService = inject(CustomDialogService); private querySyncService = inject(CollectionsQuerySyncService); private destroyRef = inject(DestroyRef); + private brandService = inject(BrandService); + private headerStyleHelper = inject(HeaderStyleService); searchControl = new FormControl(''); providerId = signal(''); @@ -118,8 +120,8 @@ export class CollectionsDiscoverComponent { const provider = this.collectionProvider(); if (provider && provider.brand) { - BrandService.applyBranding(provider.brand); - HeaderStyleHelper.applyHeaderStyles(provider.brand.secondaryColor, provider.brand.backgroundColor || ''); + this.brandService.applyBranding(provider.brand); + this.headerStyleHelper.applyHeaderStyles(provider.brand.secondaryColor, provider.brand.backgroundColor || ''); } }); @@ -151,8 +153,8 @@ export class CollectionsDiscoverComponent { this.destroyRef.onDestroy(() => { this.actions.clearCollections(); - HeaderStyleHelper.resetToDefaults(); - BrandService.resetBranding(); + this.headerStyleHelper.resetToDefaults(); + this.brandService.resetBranding(); }); } diff --git a/src/app/features/files/components/file-keywords/file-keywords.component.ts b/src/app/features/files/components/file-keywords/file-keywords.component.ts index 83bf1893e..dc8fbabf4 100644 --- a/src/app/features/files/components/file-keywords/file-keywords.component.ts +++ b/src/app/features/files/components/file-keywords/file-keywords.component.ts @@ -14,7 +14,7 @@ import { Router } from '@angular/router'; import { InputLimits } from '@osf/shared/constants/input-limits.const'; import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper'; -import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { FilesSelectors, UpdateTags } from '../../store'; @@ -29,12 +29,13 @@ export class FileKeywordsComponent { private readonly actions = createDispatchMap({ updateTags: UpdateTags }); private readonly destroyRef = inject(DestroyRef); private readonly router = inject(Router); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); readonly tags = select(FilesSelectors.getFileTags); readonly isTagsLoading = select(FilesSelectors.isFileTagsLoading); readonly file = select(FilesSelectors.getOpenedFile); readonly hasWriteAccess = select(FilesSelectors.hasWriteAccess); - readonly hasViewOnly = computed(() => hasViewOnlyParam(this.router)); + readonly hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); keywordControl = new FormControl('', { nonNullable: true, diff --git a/src/app/features/files/components/file-metadata/file-metadata.component.ts b/src/app/features/files/components/file-metadata/file-metadata.component.ts index eee47d70a..ab68392f5 100644 --- a/src/app/features/files/components/file-metadata/file-metadata.component.ts +++ b/src/app/features/files/components/file-metadata/file-metadata.component.ts @@ -13,8 +13,8 @@ import { ActivatedRoute, Router } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { languageCodes } from '@osf/shared/constants/language.const'; -import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { LanguageCodeModel } from '@shared/models/language-code.model'; import { FileMetadataFields } from '../../constants'; @@ -35,11 +35,12 @@ export class FileMetadataComponent { private readonly router = inject(Router); private readonly customDialogService = inject(CustomDialogService); private readonly environment = inject(ENVIRONMENT); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); fileMetadata = select(FilesSelectors.getFileCustomMetadata); isLoading = select(FilesSelectors.isFileMetadataLoading); hasWriteAccess = select(FilesSelectors.hasWriteAccess); - hasViewOnly = computed(() => hasViewOnlyParam(this.router)); + hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); readonly languageCodes = languageCodes; diff --git a/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.ts b/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.ts index e554a4734..6f1e8fa07 100644 --- a/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.ts +++ b/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.ts @@ -9,7 +9,7 @@ import { ChangeDetectionStrategy, Component, computed, inject, input } from '@an import { Router } from '@angular/router'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; -import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { FilesSelectors } from '../../store'; @@ -22,11 +22,12 @@ import { FilesSelectors } from '../../store'; }) export class FileResourceMetadataComponent { private readonly router = inject(Router); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); resourceType = input('nodes'); resourceMetadata = select(FilesSelectors.getResourceMetadata); contributors = select(FilesSelectors.getContributors); isResourceMetadataLoading = select(FilesSelectors.isResourceMetadataLoading); isResourceContributorsLoading = select(FilesSelectors.isResourceContributorsLoading); - hasViewOnly = computed(() => hasViewOnlyParam(this.router)); + hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); } diff --git a/src/app/features/files/pages/file-detail/file-detail.component.ts b/src/app/features/files/pages/file-detail/file-detail.component.ts index ec366758d..9dc06ff04 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.ts +++ b/src/app/features/files/pages/file-detail/file-detail.component.ts @@ -44,11 +44,11 @@ import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header import { MetadataResourceEnum } from '@osf/shared/enums/metadata-resource.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { pathJoin } from '@osf/shared/helpers/path-join.helper'; -import { getViewOnlyParam, hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; import { ToastService } from '@osf/shared/services/toast.service'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { FileDetailsModel } from '@shared/models/files/file.model'; import { MetadataTabsModel } from '@shared/models/metadata-tabs.model'; @@ -107,6 +107,7 @@ export class FileDetailComponent { private readonly metaTags = inject(MetaTagsService); private readonly datePipe = inject(DatePipe); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); private readonly translateService = inject(TranslateService); private readonly environment = inject(ENVIRONMENT); private readonly clipboard = inject(Clipboard); @@ -144,7 +145,7 @@ export class FileDetailComponent { isFileRevisionLoading = select(FilesSelectors.isFileRevisionsLoading); hasWriteAccess = select(FilesSelectors.hasWriteAccess); - hasViewOnly = computed(() => hasViewOnlyParam(this.router)); + hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); safeLink: SafeResourceUrl | null = null; resourceId = ''; @@ -472,7 +473,7 @@ export class FileDetailComponent { if (version) downloadUrlObj.searchParams.set('version', version); if (this.hasViewOnly()) { - const viewOnlyParam = getViewOnlyParam(); + const viewOnlyParam = this.viewOnlyService.getViewOnlyParam(); if (viewOnlyParam) downloadUrlObj.searchParams.set('view_only', viewOnlyParam); } diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index 4bf8617cd..a7dc18e84 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -67,11 +67,11 @@ import { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enu import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; -import { getViewOnlyParamFromUrl, hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { FilesService } from '@osf/shared/services/files.service'; import { ToastService } from '@osf/shared/services/toast.service'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { CurrentResourceSelectors, GetResourceDetails } from '@osf/shared/stores/current-resource'; import { ConfiguredAddonModel } from '@shared/models/addons/configured-addon.model'; import { StorageItem } from '@shared/models/addons/storage-item.model'; @@ -129,6 +129,7 @@ export class FilesComponent { private readonly environment = inject(ENVIRONMENT); private readonly customConfirmationService = inject(CustomConfirmationService); private readonly toastService = inject(ToastService); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); private readonly webUrl = this.environment.webUrl; private readonly apiDomainUrl = this.environment.apiDomainUrl; @@ -229,7 +230,7 @@ export class FilesComponent { this.activeRoute.parent?.parent?.snapshot.data['resourceType'] || ResourceType.Project ); - readonly hasViewOnly = computed(() => hasViewOnlyParam(this.router)); + readonly hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); readonly canEdit = computed(() => { const details = this.resourceDetails(); @@ -628,7 +629,7 @@ export class FilesComponent { navigateToFile(file: FileModel) { const extras = this.hasViewOnly() - ? { queryParams: { view_only: getViewOnlyParamFromUrl(this.router.url) } } + ? { queryParams: { view_only: this.viewOnlyService.getViewOnlyParamFromUrl(this.router.url) } } : undefined; const url = this.router.serializeUrl(this.router.createUrlTree(['/', file.guid], extras)); diff --git a/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts b/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts index 6ce949170..29dbccf91 100644 --- a/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts +++ b/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts @@ -199,6 +199,7 @@ export class CedarTemplateFormComponent { this.emitData.emit(finalData as CedarRecordDataBinding); } } + handleEmailShare(): void { const url = window.location.href; window.location.href = `mailto:?subject=${this.schemaName()}&body=${url}`; diff --git a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.ts b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.ts index 29eb2571b..8228a9cd1 100644 --- a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.ts +++ b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.ts @@ -5,7 +5,18 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; import { Skeleton } from 'primeng/skeleton'; -import { ChangeDetectionStrategy, Component, computed, effect, inject, input, OnDestroy, output } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + OnDestroy, + output, + PLATFORM_ID, +} from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ENVIRONMENT } from '@core/provider/environment.provider'; @@ -48,6 +59,8 @@ import { PreprintDoiSectionComponent } from '../preprint-doi-section/preprint-do }) export class GeneralInformationComponent implements OnDestroy { private readonly environment = inject(ENVIRONMENT); + private readonly platformId = inject(PLATFORM_ID); + private readonly isBrowser = isPlatformBrowser(this.platformId); readonly ApplicabilityStatus = ApplicabilityStatus; readonly PreregLinkInfo = PreregLinkInfo; @@ -87,7 +100,9 @@ export class GeneralInformationComponent implements OnDestroy { } ngOnDestroy(): void { - this.actions.resetContributorsState(); + if (this.isBrowser) { + this.actions.resetContributorsState(); + } } handleLoadMoreContributors(): void { diff --git a/src/app/features/preprints/components/preprint-services/preprint-services.component.html b/src/app/features/preprints/components/preprint-services/preprint-services.component.html index c4f3cc58a..b2bb4246f 100644 --- a/src/app/features/preprints/components/preprint-services/preprint-services.component.html +++ b/src/app/features/preprints/components/preprint-services/preprint-services.component.html @@ -7,7 +7,7 @@

{{ 'preprints.services.title' | translate }}

@for (preprintProvider of preprintProvidersToAdvertise(); track preprintProvider.id) { { const mockPreprintId = 'test_preprint_123'; beforeEach(async () => { - jest.spyOn(BrowserTabHelper, 'updateTabStyles').mockImplementation(() => {}); - jest.spyOn(BrowserTabHelper, 'resetToDefaults').mockImplementation(() => {}); - jest.spyOn(HeaderStyleHelper, 'applyHeaderStyles').mockImplementation(() => {}); - jest.spyOn(HeaderStyleHelper, 'resetToDefaults').mockImplementation(() => {}); - jest.spyOn(BrandService, 'applyBranding').mockImplementation(() => {}); - jest.spyOn(BrandService, 'resetBranding').mockImplementation(() => {}); - routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); routeMock = ActivatedRouteMockBuilder.create() .withParams({ providerId: mockProviderId, preprintId: mockPreprintId }) @@ -63,9 +56,11 @@ describe('CreateNewVersionComponent', () => { providers: [ TranslationServiceMock, MockProvider(BrandService), + MockProvider(BrowserTabService), + MockProvider(HeaderStyleService), MockProvider(Router, routerMock), MockProvider(ActivatedRoute, routeMock), - { provide: IS_WEB, useValue: of(true) }, + MockProvider(IS_WEB, of(true)), provideMockStore({ signals: [ { @@ -101,10 +96,6 @@ describe('CreateNewVersionComponent', () => { jest.restoreAllMocks(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - it('should initialize with correct default values', () => { expect(component.PreprintSteps).toBe(PreprintSteps); expect(component.newVersionSteps).toBe(createNewVersionStepsConst); diff --git a/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts b/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts index c3d69bbde..253a9d8df 100644 --- a/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts +++ b/src/app/features/preprints/pages/create-new-version/create-new-version.component.ts @@ -22,11 +22,11 @@ import { ActivatedRoute, Router } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; -import { BrowserTabHelper } from '@osf/shared/helpers/browser-tab.helper'; -import { HeaderStyleHelper } from '@osf/shared/helpers/header-style.helper'; import { CanDeactivateComponent } from '@osf/shared/models/can-deactivate.interface'; import { StepOption } from '@osf/shared/models/step-option.model'; import { BrandService } from '@osf/shared/services/brand.service'; +import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; +import { HeaderStyleService } from '@osf/shared/services/header-style.service'; import { FileStepComponent, ReviewStepComponent } from '../../components'; import { createNewVersionStepsConst } from '../../constants'; @@ -51,6 +51,9 @@ export class CreateNewVersionComponent implements OnInit, OnDestroy, CanDeactiva private route = inject(ActivatedRoute); private router = inject(Router); + private brandService = inject(BrandService); + private headerStyleHelper = inject(HeaderStyleService); + private browserTabHelper = inject(BrowserTabService); private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId'])) ?? of(undefined)); private preprintId = toSignal(this.route.params.pipe(map((params) => params['preprintId'])) ?? of(undefined)); @@ -78,13 +81,13 @@ export class CreateNewVersionComponent implements OnInit, OnDestroy, CanDeactiva if (provider) { this.actions.setSelectedPreprintProviderId(provider.id); - BrandService.applyBranding(provider.brand); - HeaderStyleHelper.applyHeaderStyles( + this.brandService.applyBranding(provider.brand); + this.headerStyleHelper.applyHeaderStyles( provider.brand.primaryColor, provider.brand.secondaryColor, provider.brand.heroBackgroundImageUrl ); - BrowserTabHelper.updateTabStyles(provider.faviconUrl, provider.name); + this.browserTabHelper.updateTabStyles(provider.faviconUrl, provider.name); } }); } @@ -101,9 +104,9 @@ export class CreateNewVersionComponent implements OnInit, OnDestroy, CanDeactiva } ngOnDestroy() { - HeaderStyleHelper.resetToDefaults(); - BrandService.resetBranding(); - BrowserTabHelper.resetToDefaults(); + this.headerStyleHelper.resetToDefaults(); + this.brandService.resetBranding(); + this.browserTabHelper.resetToDefaults(); this.actions.resetState(); } diff --git a/src/app/features/preprints/pages/landing/preprints-landing.component.ts b/src/app/features/preprints/pages/landing/preprints-landing.component.ts index b2d04393e..b6bcfc29d 100644 --- a/src/app/features/preprints/pages/landing/preprints-landing.component.ts +++ b/src/app/features/preprints/pages/landing/preprints-landing.component.ts @@ -47,6 +47,7 @@ export class PreprintsLandingComponent implements OnInit, OnDestroy { searchControl = new FormControl(''); private readonly environment = inject(ENVIRONMENT); + private readonly brandService = inject(BrandService); readonly supportEmail = this.environment.supportEmail; private readonly OSF_PROVIDER_ID = this.environment.defaultProvider; @@ -69,7 +70,7 @@ export class PreprintsLandingComponent implements OnInit, OnDestroy { const provider = this.osfPreprintProvider(); if (provider) { - BrandService.applyBranding(provider.brand); + this.brandService.applyBranding(provider.brand); } }); } @@ -81,7 +82,7 @@ export class PreprintsLandingComponent implements OnInit, OnDestroy { } ngOnDestroy() { - BrandService.resetBranding(); + this.brandService.resetBranding(); } redirectToSearchPageWithValue() { diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts index 80a825706..1fbcfc53f 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.spec.ts @@ -1,10 +1,17 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; +import { HttpTestingController } from '@angular/common/http/testing'; +import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideServerRendering } from '@angular/platform-server'; import { ActivatedRoute, Router } from '@angular/router'; +import { HelpScoutService } from '@core/services/help-scout.service'; +import { PrerenderReadyService } from '@core/services/prerender-ready.service'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; @@ -30,13 +37,18 @@ import { PreprintProvidersSelectors } from '../../store/preprint-providers'; import { PreprintDetailsComponent } from './preprint-details.component'; import { MOCK_CONTRIBUTOR } from '@testing/mocks/contributors.mock'; +import { DataciteMockFactory } from '@testing/mocks/datacite.service.mock'; import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; import { PREPRINT_REQUEST_MOCK } from '@testing/mocks/preprint-request.mock'; import { REVIEW_ACTION_MOCK } from '@testing/mocks/review-action.mock'; +import { ToastServiceMock } from '@testing/mocks/toast.service.mock'; import { TranslationServiceMock } from '@testing/mocks/translation.service.mock'; import { OSFTestingModule } from '@testing/osf.testing.module'; import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { HelpScoutServiceMockFactory } from '@testing/providers/help-scout.service.mock'; +import { MetaTagsServiceMockFactory } from '@testing/providers/meta-tags.service.mock'; +import { PrerenderReadyServiceMockFactory } from '@testing/providers/prerender-ready.service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; @@ -98,6 +110,7 @@ describe('PreprintDetailsComponent', () => { ], providers: [ TranslationServiceMock, + ToastServiceMock, MockProvider(Router, routerMock), MockProvider(ActivatedRoute, activatedRouteMock), MockProvider(DataciteService, dataciteService), @@ -171,14 +184,6 @@ describe('PreprintDetailsComponent', () => { fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize with correct default values', () => { - expect(component.classes).toBe('flex-1 flex flex-column w-full'); - }); - it('should return preprint from store', () => { const preprint = component.preprint(); expect(preprint).toBe(mockPreprint); @@ -297,14 +302,6 @@ describe('PreprintDetailsComponent', () => { expect(() => component.createNewVersionClicked()).not.toThrow(); }); - it('should have correct CSS classes', () => { - expect(component.classes).toBe('flex-1 flex flex-column w-full'); - }); - - it('should call dataciteService.logIdentifiableView on init', () => { - expect(dataciteService.logIdentifiableView).toHaveBeenCalledWith(component.preprint$); - }); - it('should handle preprint with different states', () => { const acceptedPreprint = { ...mockPreprint, reviewsState: ReviewsState.Accepted }; jest.spyOn(component, 'preprint').mockReturnValue(acceptedPreprint); @@ -337,11 +334,6 @@ describe('PreprintDetailsComponent', () => { expect(withdrawable).toBe(true); }); - it('should handle hasReadWriteAccess correctly', () => { - const hasAccess = component['hasWriteAccess'](); - expect(typeof hasAccess).toBe('boolean'); - }); - it('should handle preprint without write permissions', () => { const preprintWithoutWrite = { ...mockPreprint, @@ -353,3 +345,154 @@ describe('PreprintDetailsComponent', () => { expect(hasAccess).toBe(false); }); }); + +describe('PreprintDetailsComponent SSR Tests', () => { + let component: PreprintDetailsComponent; + let fixture: ComponentFixture; + let httpMock: HttpTestingController; + let mockActivatedRoute: ReturnType; + let mockRouter: ReturnType; + let store: Store; + + const mockPreprint = PREPRINT_MOCK; + const mockProvider = PREPRINT_PROVIDER_DETAILS_MOCK; + const mockContributors = [MOCK_CONTRIBUTOR]; + + beforeEach(async () => { + mockRouter = RouterMockBuilder.create().build(); + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ providerId: 'osf', id: 'preprint-1' }).build(); + + await TestBed.configureTestingModule({ + imports: [ + PreprintDetailsComponent, + OSFTestingModule, + ...MockComponents( + PreprintFileSectionComponent, + ShareAndDownloadComponent, + GeneralInformationComponent, + AdditionalInfoComponent, + StatusBannerComponent, + PreprintTombstoneComponent, + PreprintWarningBannerComponent, + ModerationStatusBannerComponent, + PreprintMakeDecisionComponent, + PreprintMetricsInfoComponent + ), + ], + providers: [ + provideServerRendering(), + { provide: PLATFORM_ID, useValue: 'server' }, + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + MockProvider(CustomDialogService, CustomDialogServiceMockBuilder.create().build()), + MockProvider(DataciteService, DataciteMockFactory()), + MockProvider(MetaTagsService, MetaTagsServiceMockFactory()), + MockProvider(PrerenderReadyService, PrerenderReadyServiceMockFactory()), + MockProvider(HelpScoutService, HelpScoutServiceMockFactory()), + TranslationServiceMock, + ToastServiceMock, + provideMockStore({ + signals: [ + { + selector: PreprintProvidersSelectors.getPreprintProviderDetails('osf'), + value: mockProvider, + }, + { + selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, + value: false, + }, + { + selector: PreprintSelectors.getPreprint, + value: mockPreprint, + }, + { + selector: PreprintSelectors.isPreprintLoading, + value: false, + }, + { + selector: ContributorsSelectors.getBibliographicContributors, + value: mockContributors, + }, + { + selector: ContributorsSelectors.isBibliographicContributorsLoading, + value: false, + }, + { + selector: PreprintSelectors.getPreprintReviewActions, + value: [], + }, + { + selector: PreprintSelectors.arePreprintReviewActionsLoading, + value: false, + }, + { + selector: PreprintSelectors.getPreprintRequests, + value: [], + }, + { + selector: PreprintSelectors.arePreprintRequestsLoading, + value: false, + }, + { + selector: PreprintSelectors.getPreprintRequestActions, + value: [], + }, + { + selector: PreprintSelectors.arePreprintRequestActionsLoading, + value: false, + }, + { + selector: PreprintSelectors.hasAdminAccess, + value: false, + }, + { + selector: PreprintSelectors.hasWriteAccess, + value: false, + }, + { + selector: PreprintSelectors.getPreprintMetrics, + value: null, + }, + { + selector: PreprintSelectors.arePreprintMetricsLoading, + value: false, + }, + ], + }), + ], + }).compileComponents(); + + httpMock = TestBed.inject(HttpTestingController); + store = TestBed.inject(Store); + fixture = TestBed.createComponent(PreprintDetailsComponent); + component = fixture.componentInstance; + }); + + it('should render PreprintDetailsComponent server-side without errors', () => { + expect(() => { + fixture.detectChanges(); + }).not.toThrow(); + expect(component).toBeTruthy(); + }); + + it('should not access browser-only APIs during SSR', () => { + const platformId = TestBed.inject(PLATFORM_ID); + expect(platformId).toBe('server'); + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should not call browser-only actions in ngOnDestroy during SSR', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + fixture.detectChanges(); + dispatchSpy.mockClear(); + component.ngOnDestroy(); + + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + afterEach(() => { + httpMock.verify(); + }); +}); diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts index 9bf3f3088..ac9522746 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts @@ -7,7 +7,7 @@ import { Skeleton } from 'primeng/skeleton'; import { catchError, EMPTY, filter, map, of } from 'rxjs'; -import { DatePipe } from '@angular/common'; +import { DatePipe, isPlatformBrowser } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, @@ -19,6 +19,7 @@ import { inject, OnDestroy, OnInit, + PLATFORM_ID, } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; @@ -101,6 +102,8 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { private readonly datePipe = inject(DatePipe); private readonly dataciteService = inject(DataciteService); private readonly prerenderReady = inject(PrerenderReadyService); + private readonly platformId = inject(PLATFORM_ID); + private readonly isBrowser = isPlatformBrowser(this.platformId); private readonly environment = inject(ENVIRONMENT); @@ -305,8 +308,11 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { } ngOnDestroy() { - this.actions.resetState(); - this.actions.clearCurrentProvider(); + if (this.isBrowser) { + this.actions.resetState(); + this.actions.clearCurrentProvider(); + } + this.helpScoutService.unsetResourceType(); } diff --git a/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.spec.ts b/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.spec.ts index 819db4e62..21ccc8848 100644 --- a/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.spec.ts @@ -5,9 +5,9 @@ import { FormControl } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component'; -import { BrowserTabHelper } from '@osf/shared/helpers/browser-tab.helper'; -import { HeaderStyleHelper } from '@osf/shared/helpers/header-style.helper'; import { BrandService } from '@osf/shared/services/brand.service'; +import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; +import { HeaderStyleService } from '@osf/shared/services/header-style.service'; import { PreprintProviderHeroComponent } from '../../components'; import { PreprintProviderDetails } from '../../models'; @@ -29,13 +29,6 @@ describe('PreprintProviderDiscoverComponent', () => { const mockProviderId = 'osf'; beforeEach(async () => { - jest.spyOn(BrowserTabHelper, 'updateTabStyles').mockImplementation(() => {}); - jest.spyOn(BrowserTabHelper, 'resetToDefaults').mockImplementation(() => {}); - jest.spyOn(HeaderStyleHelper, 'applyHeaderStyles').mockImplementation(() => {}); - jest.spyOn(HeaderStyleHelper, 'resetToDefaults').mockImplementation(() => {}); - jest.spyOn(BrandService, 'applyBranding').mockImplementation(() => {}); - jest.spyOn(BrandService, 'resetBranding').mockImplementation(() => {}); - routeMock = ActivatedRouteMockBuilder.create() .withParams({ providerId: mockProviderId }) .withQueryParams({}) @@ -48,6 +41,9 @@ describe('PreprintProviderDiscoverComponent', () => { ...MockComponents(PreprintProviderHeroComponent, GlobalSearchComponent), ], providers: [ + MockProvider(BrandService), + MockProvider(BrowserTabService), + MockProvider(HeaderStyleService), MockProvider(ActivatedRoute, routeMock), provideMockStore({ signals: [ @@ -69,10 +65,6 @@ describe('PreprintProviderDiscoverComponent', () => { fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - it('should initialize with correct default values', () => { expect(component.providerId).toBe(mockProviderId); expect(component.classes).toBe('flex-1 flex flex-column w-full h-full'); diff --git a/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.ts b/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.ts index 298f4624f..577947d2f 100644 --- a/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.ts +++ b/src/app/features/preprints/pages/preprint-provider-discover/preprint-provider-discover.component.ts @@ -6,9 +6,9 @@ import { ActivatedRoute } from '@angular/router'; import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { BrowserTabHelper } from '@osf/shared/helpers/browser-tab.helper'; -import { HeaderStyleHelper } from '@osf/shared/helpers/header-style.helper'; import { BrandService } from '@osf/shared/services/brand.service'; +import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; +import { HeaderStyleService } from '@osf/shared/services/header-style.service'; import { SetDefaultFilterValue, SetResourceType } from '@osf/shared/stores/global-search'; import { PreprintProviderHeroComponent } from '../../components'; @@ -25,6 +25,9 @@ export class PreprintProviderDiscoverComponent implements OnInit, OnDestroy { @HostBinding('class') classes = 'flex-1 flex flex-column w-full h-full'; private readonly activatedRoute = inject(ActivatedRoute); + private readonly brandService = inject(BrandService); + private readonly headerStyleHelper = inject(HeaderStyleService); + private readonly browserTabHelper = inject(BrowserTabService); private actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, @@ -50,21 +53,21 @@ export class PreprintProviderDiscoverComponent implements OnInit, OnDestroy { this.actions.setResourceType(ResourceType.Preprint); this.defaultSearchFiltersInitialized.set(true); - BrandService.applyBranding(provider.brand); - HeaderStyleHelper.applyHeaderStyles( + this.brandService.applyBranding(provider.brand); + this.headerStyleHelper.applyHeaderStyles( provider.brand.primaryColor, provider.brand.secondaryColor, provider.brand.heroBackgroundImageUrl ); - BrowserTabHelper.updateTabStyles(provider.faviconUrl, provider.name); + this.browserTabHelper.updateTabStyles(provider.faviconUrl, provider.name); } }, }); } ngOnDestroy() { - HeaderStyleHelper.resetToDefaults(); - BrandService.resetBranding(); - BrowserTabHelper.resetToDefaults(); + this.headerStyleHelper.resetToDefaults(); + this.brandService.resetBranding(); + this.browserTabHelper.resetToDefaults(); } } diff --git a/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.spec.ts b/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.spec.ts index 363c14880..8c307f9ab 100644 --- a/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.spec.ts @@ -3,9 +3,9 @@ import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; -import { BrowserTabHelper } from '@osf/shared/helpers/browser-tab.helper'; -import { HeaderStyleHelper } from '@osf/shared/helpers/header-style.helper'; import { BrandService } from '@osf/shared/services/brand.service'; +import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; +import { HeaderStyleService } from '@osf/shared/services/header-style.service'; import { AdvisoryBoardComponent, @@ -36,13 +36,6 @@ describe('PreprintProviderOverviewComponent', () => { const mockProviderId = 'osf'; beforeEach(async () => { - jest.spyOn(BrowserTabHelper, 'updateTabStyles').mockImplementation(() => {}); - jest.spyOn(BrowserTabHelper, 'resetToDefaults').mockImplementation(() => {}); - jest.spyOn(HeaderStyleHelper, 'applyHeaderStyles').mockImplementation(() => {}); - jest.spyOn(HeaderStyleHelper, 'resetToDefaults').mockImplementation(() => {}); - jest.spyOn(BrandService, 'applyBranding').mockImplementation(() => {}); - jest.spyOn(BrandService, 'resetBranding').mockImplementation(() => {}); - routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); routeMock = ActivatedRouteMockBuilder.create() .withParams({ providerId: mockProviderId }) @@ -62,6 +55,8 @@ describe('PreprintProviderOverviewComponent', () => { ], providers: [ MockProvider(BrandService), + MockProvider(BrowserTabService), + MockProvider(HeaderStyleService), MockProvider(Router, routerMock), MockProvider(ActivatedRoute, routeMock), provideMockStore({ @@ -92,10 +87,6 @@ describe('PreprintProviderOverviewComponent', () => { fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - it('should initialize with correct default values', () => { expect(component.preprintProvider).toBeDefined(); expect(component.isPreprintProviderLoading).toBeDefined(); diff --git a/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.ts b/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.ts index 05cc5d4bc..c38ab13f4 100644 --- a/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.ts +++ b/src/app/features/preprints/pages/preprint-provider-overview/preprint-provider-overview.component.ts @@ -6,9 +6,9 @@ import { ChangeDetectionStrategy, Component, effect, inject, OnDestroy, OnInit } import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; -import { BrowserTabHelper } from '@osf/shared/helpers/browser-tab.helper'; -import { HeaderStyleHelper } from '@osf/shared/helpers/header-style.helper'; import { BrandService } from '@osf/shared/services/brand.service'; +import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; +import { HeaderStyleService } from '@osf/shared/services/header-style.service'; import { AdvisoryBoardComponent, @@ -37,6 +37,9 @@ import { export class PreprintProviderOverviewComponent implements OnInit, OnDestroy { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly brandService = inject(BrandService); + private readonly headerStyleHelper = inject(HeaderStyleService); + private readonly browserTabHelper = inject(BrowserTabService); private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId'])) ?? of(undefined)); private actions = createDispatchMap({ @@ -54,13 +57,13 @@ export class PreprintProviderOverviewComponent implements OnInit, OnDestroy { const provider = this.preprintProvider(); if (provider) { - BrandService.applyBranding(provider.brand); - HeaderStyleHelper.applyHeaderStyles( + this.brandService.applyBranding(provider.brand); + this.headerStyleHelper.applyHeaderStyles( provider.brand.primaryColor, provider.brand.secondaryColor, provider.brand.heroBackgroundImageUrl ); - BrowserTabHelper.updateTabStyles(provider.faviconUrl, provider.name); + this.browserTabHelper.updateTabStyles(provider.faviconUrl, provider.name); } }); } @@ -71,9 +74,9 @@ export class PreprintProviderOverviewComponent implements OnInit, OnDestroy { } ngOnDestroy() { - HeaderStyleHelper.resetToDefaults(); - BrandService.resetBranding(); - BrowserTabHelper.resetToDefaults(); + this.headerStyleHelper.resetToDefaults(); + this.brandService.resetBranding(); + this.browserTabHelper.resetToDefaults(); } redirectToDiscoverPageWithValue(searchValue: string) { diff --git a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts index c20849a1c..50920be2a 100644 --- a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts +++ b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts @@ -2,16 +2,15 @@ import { MockComponents, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; -import { BrowserTabHelper } from '@osf/shared/helpers/browser-tab.helper'; -import { HeaderStyleHelper } from '@osf/shared/helpers/header-style.helper'; import { StepOption } from '@osf/shared/models/step-option.model'; import { BrandService } from '@osf/shared/services/brand.service'; +import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; +import { HeaderStyleService } from '@osf/shared/services/header-style.service'; import { AuthorAssertionsStepComponent, @@ -45,13 +44,6 @@ describe('SubmitPreprintStepperComponent', () => { const mockProviderId = 'osf'; beforeEach(async () => { - jest.spyOn(BrowserTabHelper, 'updateTabStyles').mockImplementation(() => {}); - jest.spyOn(BrowserTabHelper, 'resetToDefaults').mockImplementation(() => {}); - jest.spyOn(HeaderStyleHelper, 'applyHeaderStyles').mockImplementation(() => {}); - jest.spyOn(HeaderStyleHelper, 'resetToDefaults').mockImplementation(() => {}); - jest.spyOn(BrandService, 'applyBranding').mockImplementation(() => {}); - jest.spyOn(BrandService, 'resetBranding').mockImplementation(() => {}); - routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); routeMock = ActivatedRouteMockBuilder.create() .withParams({ providerId: mockProviderId }) @@ -75,9 +67,11 @@ describe('SubmitPreprintStepperComponent', () => { ], providers: [ MockProvider(BrandService), + MockProvider(BrowserTabService), + MockProvider(HeaderStyleService), MockProvider(Router, routerMock), MockProvider(ActivatedRoute, routeMock), - { provide: IS_WEB, useValue: of(true) }, + MockProvider(IS_WEB, of(true)), provideMockStore({ signals: [ { @@ -95,7 +89,6 @@ describe('SubmitPreprintStepperComponent', () => { ], }), ], - schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(SubmitPreprintStepperComponent); @@ -103,10 +96,6 @@ describe('SubmitPreprintStepperComponent', () => { fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - it('should initialize with correct default values', () => { expect(component.SubmitStepsEnum).toBe(PreprintSteps); expect(component.classes).toBe('flex-1 flex flex-column w-full'); diff --git a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts index 16d621336..6bfcfb1ea 100644 --- a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts +++ b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts @@ -23,11 +23,11 @@ import { ActivatedRoute } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; -import { BrowserTabHelper } from '@osf/shared/helpers/browser-tab.helper'; -import { HeaderStyleHelper } from '@osf/shared/helpers/header-style.helper'; import { CanDeactivateComponent } from '@osf/shared/models/can-deactivate.interface'; import { StepOption } from '@osf/shared/models/step-option.model'; import { BrandService } from '@osf/shared/services/brand.service'; +import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; +import { HeaderStyleService } from '@osf/shared/services/header-style.service'; import { AuthorAssertionsStepComponent, @@ -69,6 +69,9 @@ export class SubmitPreprintStepperComponent implements OnInit, OnDestroy, CanDea @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; private readonly route = inject(ActivatedRoute); + private readonly brandService = inject(BrandService); + private readonly headerStyleHelper = inject(HeaderStyleService); + private readonly browserTabHelper = inject(BrowserTabService); private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId'])) ?? of(undefined)); @@ -115,13 +118,13 @@ export class SubmitPreprintStepperComponent implements OnInit, OnDestroy, CanDea if (provider) { this.actions.setSelectedPreprintProviderId(provider.id); - BrandService.applyBranding(provider.brand); - HeaderStyleHelper.applyHeaderStyles( + this.brandService.applyBranding(provider.brand); + this.headerStyleHelper.applyHeaderStyles( provider.brand.primaryColor, provider.brand.secondaryColor, provider.brand.heroBackgroundImageUrl ); - BrowserTabHelper.updateTabStyles(provider.faviconUrl, provider.name); + this.browserTabHelper.updateTabStyles(provider.faviconUrl, provider.name); } }); } @@ -135,9 +138,9 @@ export class SubmitPreprintStepperComponent implements OnInit, OnDestroy, CanDea } ngOnDestroy() { - HeaderStyleHelper.resetToDefaults(); - BrandService.resetBranding(); - BrowserTabHelper.resetToDefaults(); + this.headerStyleHelper.resetToDefaults(); + this.brandService.resetBranding(); + this.browserTabHelper.resetToDefaults(); this.actions.deletePreprint(); this.actions.resetState(); } diff --git a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts index 0e399ebe9..7a57296bf 100644 --- a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts +++ b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts @@ -2,16 +2,15 @@ import { MockComponents, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; -import { BrowserTabHelper } from '@osf/shared/helpers/browser-tab.helper'; -import { HeaderStyleHelper } from '@osf/shared/helpers/header-style.helper'; import { StepOption } from '@osf/shared/models/step-option.model'; import { BrandService } from '@osf/shared/services/brand.service'; +import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; +import { HeaderStyleService } from '@osf/shared/services/header-style.service'; import { AuthorAssertionsStepComponent, @@ -48,13 +47,6 @@ describe('UpdatePreprintStepperComponent', () => { const mockPreprintId = 'test_preprint_123'; beforeEach(async () => { - jest.spyOn(BrowserTabHelper, 'updateTabStyles').mockImplementation(() => {}); - jest.spyOn(BrowserTabHelper, 'resetToDefaults').mockImplementation(() => {}); - jest.spyOn(HeaderStyleHelper, 'applyHeaderStyles').mockImplementation(() => {}); - jest.spyOn(HeaderStyleHelper, 'resetToDefaults').mockImplementation(() => {}); - jest.spyOn(BrandService, 'applyBranding').mockImplementation(() => {}); - jest.spyOn(BrandService, 'resetBranding').mockImplementation(() => {}); - routerMock = RouterMockBuilder.create().withNavigate(jest.fn().mockResolvedValue(true)).build(); routeMock = ActivatedRouteMockBuilder.create() .withParams({ providerId: mockProviderId, preprintId: mockPreprintId }) @@ -77,9 +69,11 @@ describe('UpdatePreprintStepperComponent', () => { ], providers: [ MockProvider(BrandService), + MockProvider(BrowserTabService), + MockProvider(HeaderStyleService), MockProvider(Router, routerMock), MockProvider(ActivatedRoute, routeMock), - { provide: IS_WEB, useValue: of(true) }, + MockProvider(IS_WEB, of(true)), provideMockStore({ signals: [ { @@ -101,7 +95,6 @@ describe('UpdatePreprintStepperComponent', () => { ], }), ], - schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(UpdatePreprintStepperComponent); @@ -109,10 +102,6 @@ describe('UpdatePreprintStepperComponent', () => { fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - it('should initialize with correct default values', () => { expect(component.PreprintSteps).toBe(PreprintSteps); expect(component.classes).toBe('flex-1 flex flex-column w-full'); diff --git a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts index dc068ad76..f7023e75a 100644 --- a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts +++ b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts @@ -24,9 +24,9 @@ import { ActivatedRoute } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; -import { BrowserTabHelper } from '@osf/shared/helpers/browser-tab.helper'; -import { HeaderStyleHelper } from '@osf/shared/helpers/header-style.helper'; import { BrandService } from '@osf/shared/services/brand.service'; +import { BrowserTabService } from '@osf/shared/services/browser-tab.service'; +import { HeaderStyleService } from '@osf/shared/services/header-style.service'; import { CanDeactivateComponent } from '@shared/models/can-deactivate.interface'; import { StepOption } from '@shared/models/step-option.model'; @@ -69,6 +69,9 @@ export class UpdatePreprintStepperComponent implements OnInit, OnDestroy, CanDea @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; private readonly route = inject(ActivatedRoute); + private readonly brandService = inject(BrandService); + private readonly headerStyleHelper = inject(HeaderStyleService); + private readonly browserTabHelper = inject(BrowserTabService); private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId'])) ?? of(undefined)); private preprintId = toSignal(this.route.params.pipe(map((params) => params['preprintId'])) ?? of(undefined)); @@ -141,13 +144,13 @@ export class UpdatePreprintStepperComponent implements OnInit, OnDestroy, CanDea if (provider) { this.actions.setSelectedPreprintProviderId(provider.id); - BrandService.applyBranding(provider.brand); - HeaderStyleHelper.applyHeaderStyles( + this.brandService.applyBranding(provider.brand); + this.headerStyleHelper.applyHeaderStyles( provider.brand.primaryColor, provider.brand.secondaryColor, provider.brand.heroBackgroundImageUrl ); - BrowserTabHelper.updateTabStyles(provider.faviconUrl, provider.name); + this.browserTabHelper.updateTabStyles(provider.faviconUrl, provider.name); } }); } @@ -168,9 +171,9 @@ export class UpdatePreprintStepperComponent implements OnInit, OnDestroy, CanDea } ngOnDestroy() { - HeaderStyleHelper.resetToDefaults(); - BrandService.resetBranding(); - BrowserTabHelper.resetToDefaults(); + this.headerStyleHelper.resetToDefaults(); + this.brandService.resetBranding(); + this.browserTabHelper.resetToDefaults(); this.actions.resetState(); } diff --git a/src/app/features/project/overview/components/files-widget/files-widget.component.ts b/src/app/features/project/overview/components/files-widget/files-widget.component.ts index c86155858..8b6c926b4 100644 --- a/src/app/features/project/overview/components/files-widget/files-widget.component.ts +++ b/src/app/features/project/overview/components/files-widget/files-widget.component.ts @@ -6,6 +6,7 @@ import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; import { TabsModule } from 'primeng/tabs'; +import { isPlatformBrowser } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -15,6 +16,7 @@ import { inject, input, model, + PLATFORM_ID, signal, } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; @@ -32,7 +34,6 @@ import { import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; import { SelectComponent } from '@osf/shared/components/select/select.component'; import { Primitive } from '@osf/shared/helpers/types.helper'; -import { getViewOnlyParamFromUrl, hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; import { ConfiguredAddonModel } from '@osf/shared/models/addons/configured-addon.model'; import { FileModel } from '@osf/shared/models/files/file.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; @@ -40,6 +41,7 @@ import { FileLabelModel } from '@osf/shared/models/files/file-label.model'; import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; import { ProjectModel } from '@osf/shared/models/projects/projects.models'; import { SelectOption } from '@osf/shared/models/select-option.model'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; @Component({ selector: 'osf-files-widget', @@ -57,6 +59,9 @@ export class FilesWidgetComponent { private readonly environment = inject(ENVIRONMENT); private readonly destroyRef = inject(DestroyRef); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); + private readonly platformId = inject(PLATFORM_ID); + private readonly isBrowser = isPlatformBrowser(this.platformId); readonly files = select(FilesSelectors.getFiles); readonly filesTotalCount = select(FilesSelectors.getFilesTotalCount); @@ -90,7 +95,7 @@ export class FilesWidgetComponent { return []; }); - readonly hasViewOnly = computed(() => hasViewOnlyParam(this.router)); + readonly hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); private readonly actions = createDispatchMap({ getFiles: GetFiles, @@ -149,7 +154,9 @@ export class FilesWidgetComponent { }); this.destroyRef.onDestroy(() => { - this.actions.resetState(); + if (this.isBrowser) { + this.actions.resetState(); + } }); } @@ -220,7 +227,7 @@ export class FilesWidgetComponent { navigateToFile(file: FileModel) { const extras = this.hasViewOnly() - ? { queryParams: { view_only: getViewOnlyParamFromUrl(this.router.url) } } + ? { queryParams: { view_only: this.viewOnlyService.getViewOnlyParamFromUrl(this.router.url) } } : undefined; const url = this.router.serializeUrl(this.router.createUrlTree(['/', file.guid], extras)); diff --git a/src/app/features/project/overview/components/project-recent-activity/project-recent-activity.component.ts b/src/app/features/project/overview/components/project-recent-activity/project-recent-activity.component.ts index 5b4305004..0f531e93e 100644 --- a/src/app/features/project/overview/components/project-recent-activity/project-recent-activity.component.ts +++ b/src/app/features/project/overview/components/project-recent-activity/project-recent-activity.component.ts @@ -4,7 +4,18 @@ import { TranslatePipe } from '@ngx-translate/core'; import { PaginatorState } from 'primeng/paginator'; -import { ChangeDetectionStrategy, Component, computed, effect, input, OnDestroy, signal } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + OnDestroy, + PLATFORM_ID, + signal, +} from '@angular/core'; import { RecentActivityListComponent } from '@osf/shared/components/recent-activity/recent-activity-list.component'; import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; @@ -19,6 +30,9 @@ import { ActivityLogsSelectors, ClearActivityLogs, GetActivityLogs } from '@osf/ export class ProjectRecentActivityComponent implements OnDestroy { projectId = input(); + private readonly platformId = inject(PLATFORM_ID); + private readonly isBrowser = isPlatformBrowser(this.platformId); + pageSize = signal(5); currentPage = signal(1); @@ -42,7 +56,9 @@ export class ProjectRecentActivityComponent implements OnDestroy { } ngOnDestroy(): void { - this.actions.clearActivityLogsStore(); + if (this.isBrowser) { + this.actions.clearActivityLogsStore(); + } } onPageChange(event: PaginatorState) { diff --git a/src/app/features/project/overview/project-overview.component.spec.ts b/src/app/features/project/overview/project-overview.component.spec.ts index f6bb02721..61373020d 100644 --- a/src/app/features/project/overview/project-overview.component.spec.ts +++ b/src/app/features/project/overview/project-overview.component.spec.ts @@ -4,7 +4,10 @@ import { MockComponents, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; +import { HttpTestingController } from '@angular/common/http/testing'; +import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideServerRendering } from '@angular/platform-server'; import { ActivatedRoute, Router } from '@angular/router'; import { @@ -15,9 +18,9 @@ import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/ import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-link-message/view-only-link-message.component'; import { Mode } from '@osf/shared/enums/mode.enum'; -import { AnalyticsService } from '@osf/shared/services/analytics.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { AddonsSelectors, ClearConfiguredAddons } from '@osf/shared/stores/addons'; import { GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks'; import { ClearCollections, CollectionsSelectors } from '@osf/shared/stores/collections'; @@ -40,7 +43,6 @@ import { ClearProjectOverview, GetComponents, GetProjectById, ProjectOverviewSel import { MOCK_PROJECT_OVERVIEW } from '@testing/mocks/project-overview.mock'; import { OSFTestingModule } from '@testing/osf.testing.module'; -import { AnalyticsServiceMockFactory } from '@testing/providers/analytics.service.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'; @@ -118,7 +120,6 @@ describe('ProjectOverviewComponent', () => { MockProvider(ActivatedRoute, activatedRouteMock), MockProvider(CustomDialogService, customDialogServiceMock), MockProvider(ToastService, toastService), - MockProvider(AnalyticsService, AnalyticsServiceMockFactory()), ], }).compileComponents(); @@ -174,3 +175,127 @@ describe('ProjectOverviewComponent', () => { expect(store.dispatch).toHaveBeenCalledWith(expect.any(ClearConfiguredAddons)); }); }); + +describe('ProjectOverviewComponent SSR Tests', () => { + let component: ProjectOverviewComponent; + let fixture: ComponentFixture; + let httpMock: HttpTestingController; + let mockActivatedRoute: ReturnType; + let mockRouter: ReturnType; + let store: Store; + + const mockProject: ProjectOverviewModel = { + ...MOCK_PROJECT_OVERVIEW, + id: 'project-123', + title: 'Test Project', + parentId: 'parent-123', + rootParentId: 'root-123', + isPublic: true, + }; + + beforeEach(async () => { + mockRouter = RouterMockBuilder.create().withUrl('/projects/project-123').build(); + const parentRoute = { + params: of({ id: 'project-123' }), + snapshot: { params: { id: 'project-123' }, queryParams: {} }, + } as any; + mockActivatedRoute = Object.assign( + ActivatedRouteMockBuilder.create().withParams({ id: 'project-123' }).withQueryParams({}).build(), + { parent: parentRoute } + ); + + await TestBed.configureTestingModule({ + imports: [ + ProjectOverviewComponent, + OSFTestingModule, + ...MockComponents( + SubHeaderComponent, + LoadingSpinnerComponent, + OverviewWikiComponent, + OverviewComponentsComponent, + LinkedResourcesComponent, + ProjectRecentActivityComponent, + ProjectOverviewToolbarComponent, + ProjectOverviewMetadataComponent, + FilesWidgetComponent, + ViewOnlyLinkMessageComponent, + OverviewParentProjectComponent, + CitationAddonCardComponent + ), + ], + providers: [ + provideServerRendering(), + { provide: PLATFORM_ID, useValue: 'server' }, + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + MockProvider(CustomDialogService, CustomDialogServiceMockBuilder.create().build()), + MockProvider(ToastService, { showSuccess: jest.fn() }), + MockProvider(ViewOnlyLinkHelperService, { hasViewOnlyParam: jest.fn().mockReturnValue(false) }), + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: mockProject }, + { selector: ProjectOverviewSelectors.getProjectLoading, value: false }, + { selector: ProjectOverviewSelectors.isProjectAnonymous, value: false }, + { selector: ProjectOverviewSelectors.hasWriteAccess, value: true }, + { selector: ProjectOverviewSelectors.hasAdminAccess, value: true }, + { selector: ProjectOverviewSelectors.isWikiEnabled, value: true }, + { selector: ProjectOverviewSelectors.getParentProject, value: null }, + { selector: ProjectOverviewSelectors.getParentProjectLoading, value: false }, + { selector: ProjectOverviewSelectors.getStorage, value: null }, + { selector: ProjectOverviewSelectors.isStorageLoading, value: false }, + { selector: CollectionsModerationSelectors.getCollectionSubmissions, value: [] }, + { selector: CollectionsModerationSelectors.getCurrentReviewAction, value: null }, + { selector: CollectionsModerationSelectors.getCurrentReviewActionLoading, value: false }, + { selector: CollectionsSelectors.getCollectionProvider, value: null }, + { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, + { selector: CurrentResourceSelectors.getResourceWithChildren, value: [] }, + { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, + { selector: AddonsSelectors.getAddonsResourceReference, value: [] }, + { selector: AddonsSelectors.getConfiguredCitationAddons, value: [] }, + { selector: AddonsSelectors.getOperationInvocation, value: null }, + ], + }), + ], + }).compileComponents(); + + httpMock = TestBed.inject(HttpTestingController); + store = TestBed.inject(Store); + fixture = TestBed.createComponent(ProjectOverviewComponent); + component = fixture.componentInstance; + }); + + it('should render ProjectOverviewComponent server-side without errors', () => { + expect(() => { + fixture.detectChanges(); + }).not.toThrow(); + expect(component).toBeTruthy(); + }); + + it('should not access browser-only APIs during SSR', () => { + const platformId = TestBed.inject(PLATFORM_ID); + expect(platformId).toBe('server'); + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should execute constructor effects without errors in SSR context', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + fixture.detectChanges(); + expect(dispatchSpy).toHaveBeenCalled(); + expect(component).toBeTruthy(); + }); + + it('should not call browser-only actions in ngOnDestroy during SSR', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + fixture.detectChanges(); + dispatchSpy.mockClear(); + fixture.destroy(); + + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + afterEach(() => { + httpMock.verify(); + }); +}); diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index 07f6a046a..5f2f09c15 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -7,6 +7,7 @@ import { Message } from 'primeng/message'; import { map, of } from 'rxjs'; +import { isPlatformBrowser } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -16,6 +17,7 @@ import { HostBinding, inject, OnInit, + PLATFORM_ID, } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; @@ -32,9 +34,9 @@ import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-link-message/view-only-link-message.component'; import { Mode } from '@osf/shared/enums/mode.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { AddonsSelectors, ClearConfiguredAddons, @@ -47,7 +49,6 @@ import { ClearCollections, CollectionsSelectors, GetCollectionProvider } from '@ import { CurrentResourceSelectors, GetResourceWithChildren } from '@osf/shared/stores/current-resource'; import { GetLinkedResources } from '@osf/shared/stores/node-links'; import { ClearWiki, GetHomeWiki } from '@osf/shared/stores/wiki'; -import { AnalyticsService } from '@shared/services/analytics.service'; import { CitationAddonCardComponent } from './components/citation-addon-card/citation-addon-card.component'; import { FilesWidgetComponent } from './components/files-widget/files-widget.component'; @@ -99,8 +100,10 @@ export class ProjectOverviewComponent implements OnInit { private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); private readonly toastService = inject(ToastService); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); private readonly customDialogService = inject(CustomDialogService); - readonly analyticsService = inject(AnalyticsService); + private readonly platformId = inject(PLATFORM_ID); + private readonly isBrowser = isPlatformBrowser(this.platformId); submissions = select(CollectionsModerationSelectors.getCollectionSubmissions); collectionProvider = select(CollectionsSelectors.getCollectionProvider); @@ -154,6 +157,7 @@ export class ProjectOverviewComponent implements OnInit { }); submissionReviewStatus = computed(() => this.currentReviewAction()?.toState); + hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); showDecisionButton = computed( () => @@ -162,8 +166,6 @@ export class ProjectOverviewComponent implements OnInit { this.submissionReviewStatus() !== SubmissionReviewStatus.Rejected ); - hasViewOnly = computed(() => hasViewOnlyParam(this.router)); - filesRootOption = computed(() => ({ value: this.currentProject()?.id ?? '', label: this.currentProject()?.title ?? '', @@ -285,12 +287,14 @@ export class ProjectOverviewComponent implements OnInit { } private setupCleanup(): void { - this.destroyRef.onDestroy(() => { - this.actions.clearProjectOverview(); - this.actions.clearWiki(); - this.actions.clearCollections(); - this.actions.clearCollectionModeration(); - this.actions.clearConfiguredAddons(); - }); + if (this.isBrowser) { + this.destroyRef.onDestroy(() => { + this.actions.clearProjectOverview(); + this.actions.clearWiki(); + this.actions.clearCollections(); + this.actions.clearCollectionModeration(); + this.actions.clearConfiguredAddons(); + }); + } } } diff --git a/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.html b/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.html index b3a940436..7e7ca253f 100644 --- a/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.html +++ b/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.html @@ -183,7 +183,7 @@

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

-
+ {{ 'settings.addons.connectAddon.startOauth' | translate }} diff --git a/src/app/features/project/project.component.ts b/src/app/features/project/project.component.ts index 0071a1733..47407f439 100644 --- a/src/app/features/project/project.component.ts +++ b/src/app/features/project/project.component.ts @@ -54,7 +54,7 @@ export class ProjectComponent implements OnDestroy { private readonly prerenderReady = inject(PrerenderReadyService); private readonly router = inject(Router); private readonly analyticsService = inject(AnalyticsService); - currentResource = select(CurrentResourceSelectors.getCurrentResource); + private readonly currentResource = select(CurrentResourceSelectors.getCurrentResource); readonly identifiersForDatacite$ = toObservable(select(ProjectOverviewSelectors.getIdentifiers)).pipe( map((identifiers) => (identifiers?.length ? { identifiers } : null)) @@ -69,7 +69,8 @@ export class ProjectComponent implements OnDestroy { readonly institutions = select(ProjectOverviewSelectors.getInstitutions); readonly isInstitutionsLoading = select(ProjectOverviewSelectors.isInstitutionsLoading); - private projectId = toSignal(this.route.params.pipe(map((params) => params['id']))); + private readonly lastMetaTagsProjectId = signal(null); + private readonly projectId = toSignal(this.route.params.pipe(map((params) => params['id']))); private readonly allDataLoaded = computed( () => @@ -80,8 +81,6 @@ export class ProjectComponent implements OnDestroy { !!this.currentProject() ); - private readonly lastMetaTagsProjectId = signal(null); - private readonly actions = createDispatchMap({ getProject: GetProjectById, getLicense: GetProjectLicense, diff --git a/src/app/features/project/project.routes.ts b/src/app/features/project/project.routes.ts index 9ae274792..870ee6e8b 100644 --- a/src/app/features/project/project.routes.ts +++ b/src/app/features/project/project.routes.ts @@ -5,6 +5,7 @@ import { Routes } from '@angular/router'; import { viewOnlyGuard } from '@core/guards/view-only.guard'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { ActivityLogsState } from '@osf/shared/stores/activity-logs'; +import { BookmarksState } from '@osf/shared/stores/bookmarks'; import { CitationsState } from '@osf/shared/stores/citations'; import { CollectionsState } from '@osf/shared/stores/collections'; import { DuplicatesState } from '@osf/shared/stores/duplicates'; @@ -24,7 +25,7 @@ export const projectRoutes: Routes = [ { path: '', loadComponent: () => import('../project/project.component').then((mod) => mod.ProjectComponent), - providers: [provideStates([ProjectOverviewState])], + providers: [provideStates([BookmarksState, ProjectOverviewState])], children: [ { path: '', diff --git a/src/app/features/project/wiki/wiki.component.ts b/src/app/features/project/wiki/wiki.component.ts index 155d01b20..d48f5ac6b 100644 --- a/src/app/features/project/wiki/wiki.component.ts +++ b/src/app/features/project/wiki/wiki.component.ts @@ -17,9 +17,9 @@ import { EditSectionComponent } from '@osf/shared/components/wiki/edit-section/e import { ViewSectionComponent } from '@osf/shared/components/wiki/view-section/view-section.component'; import { WikiListComponent } from '@osf/shared/components/wiki/wiki-list/wiki-list.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; import { WikiModes } from '@osf/shared/models/wiki/wiki.model'; import { ToastService } from '@osf/shared/services/toast.service'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; import { ClearWiki, @@ -60,7 +60,8 @@ export class WikiComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); - private toastService = inject(ToastService); + private readonly toastService = inject(ToastService); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); WikiModes = WikiModes; homeWikiName = 'Home'; @@ -78,7 +79,8 @@ export class WikiComponent { isWikiVersionSubmitting = select(WikiSelectors.getWikiVersionSubmitting); isWikiVersionLoading = select(WikiSelectors.getWikiVersionsLoading); isCompareVersionLoading = select(WikiSelectors.getCompareVersionsLoading); - hasViewOnly = computed(() => hasViewOnlyParam(this.router)); + isAnonymous = select(WikiSelectors.isWikiAnonymous); + hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); hasWriteAccess = select(CurrentResourceSelectors.hasWriteAccess); 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 52f770e9f..3ba019880 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 @@ -10,9 +10,9 @@ import { Router } from '@angular/router'; import { PreprintsHelpDialogComponent } from '@osf/features/preprints/components'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; -import { HeaderStyleHelper } from '@osf/shared/helpers/header-style.helper'; import { BrandService } from '@osf/shared/services/brand.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { HeaderStyleService } from '@osf/shared/services/header-style.service'; import { RegistryProviderDetails } from '@shared/models/provider/registry-provider.model'; import { DecodeHtmlPipe } from '@shared/pipes/decode-html.pipe'; @@ -26,6 +26,8 @@ import { DecodeHtmlPipe } from '@shared/pipes/decode-html.pipe'; export class RegistryProviderHeroComponent implements OnDestroy { private readonly router = inject(Router); private readonly customDialogService = inject(CustomDialogService); + private readonly brandService = inject(BrandService); + private readonly headerStyleHelper = inject(HeaderStyleService); private readonly WHITE = '#ffffff'; searchControl = input(new FormControl()); @@ -42,8 +44,8 @@ export class RegistryProviderHeroComponent implements OnDestroy { const provider = this.provider(); if (provider?.brand) { - BrandService.applyBranding(provider.brand); - HeaderStyleHelper.applyHeaderStyles( + this.brandService.applyBranding(provider.brand); + this.headerStyleHelper.applyHeaderStyles( this.WHITE, provider.brand.primaryColor, provider.brand.heroBackgroundImageUrl @@ -53,8 +55,8 @@ export class RegistryProviderHeroComponent implements OnDestroy { } ngOnDestroy() { - HeaderStyleHelper.resetToDefaults(); - BrandService.resetBranding(); + this.headerStyleHelper.resetToDefaults(); + this.brandService.resetBranding(); } openHelpDialog() { diff --git a/src/app/features/registry/pages/registry-components/registry-components.component.ts b/src/app/features/registry/pages/registry-components/registry-components.component.ts index f4a9c8c31..b7d346adf 100644 --- a/src/app/features/registry/pages/registry-components/registry-components.component.ts +++ b/src/app/features/registry/pages/registry-components/registry-components.component.ts @@ -8,7 +8,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-link-message/view-only-link-message.component'; -import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { RegistrationLinksCardComponent } from '../../components/registration-links-card/registration-links-card.component'; import { GetRegistryComponents, RegistryComponentsSelectors } from '../../store/registry-components'; @@ -29,12 +29,13 @@ import { GetRegistryComponents, RegistryComponentsSelectors } from '../../store/ export class RegistryComponentsComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); private registryId = signal(''); actions = createDispatchMap({ getRegistryComponents: GetRegistryComponents }); - hasViewOnly = computed(() => hasViewOnlyParam(this.router)); + hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); registryComponents = select(RegistryComponentsSelectors.getRegistryComponents); registryComponentsLoading = select(RegistryComponentsSelectors.getRegistryComponentsLoading); 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 724c807b4..e94064a8a 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 @@ -33,7 +33,7 @@ } @else {
- @if (schemaResponse() && !schemaResponse()?.isOriginalResponse && !isInitialState) { + @if (schemaResponse() && !schemaResponse()?.isOriginalResponse && !isInitialState()) {
diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts index 506a9108d..ed58b1a97 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts @@ -1,36 +1,136 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; + +import { HttpTestingController } from '@angular/common/http/testing'; +import { PLATFORM_ID, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideServerRendering } from '@angular/platform-server'; +import { ActivatedRoute, Router } from '@angular/router'; import { DataResourcesComponent } from '@osf/shared/components/data-resources/data-resources.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-link-message/view-only-link-message.component'; +import { RegistrationReviewStates } from '@osf/shared/enums/registration-review-states.enum'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; +import { GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks'; +import { ContributorsSelectors, GetBibliographicContributors } from '@osf/shared/stores/contributors'; import { ArchivingMessageComponent } from '../../components/archiving-message/archiving-message.component'; import { RegistrationOverviewToolbarComponent } from '../../components/registration-overview-toolbar/registration-overview-toolbar.component'; import { RegistryBlocksSectionComponent } from '../../components/registry-blocks-section/registry-blocks-section.component'; +import { RegistryMakeDecisionComponent } from '../../components/registry-make-decision/registry-make-decision.component'; import { RegistryOverviewMetadataComponent } from '../../components/registry-overview-metadata/registry-overview-metadata.component'; import { RegistryRevisionsComponent } from '../../components/registry-revisions/registry-revisions.component'; import { RegistryStatusesComponent } from '../../components/registry-statuses/registry-statuses.component'; import { WithdrawnMessageComponent } from '../../components/withdrawn-message/withdrawn-message.component'; -import { RegistrySelectors } from '../../store/registry'; +import { + CreateSchemaResponse, + GetRegistryById, + GetRegistryReviewActions, + GetRegistrySchemaResponses, + GetSchemaBlocks, + RegistrySelectors, +} from '../../store/registry'; import { RegistryOverviewComponent } from './registry-overview.component'; +import { MOCK_REGISTRATION_OVERVIEW_MODEL } from '@testing/mocks/registration-overview-model.mock'; +import { createMockSchemaResponse } from '@testing/mocks/schema-response.mock'; +import { ToastServiceMock } from '@testing/mocks/toast.service.mock'; import { OSFTestingModule } from '@testing/osf.testing.module'; import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistryOverviewComponent', () => { let component: RegistryOverviewComponent; let fixture: ComponentFixture; - let mockCustomDialogService: ReturnType; + let store: Store; + let httpMock: HttpTestingController; - beforeEach(async () => { - mockCustomDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - await TestBed.configureTestingModule({ + const createTestBed = (options?: { + isSSR?: boolean; + registryId?: string; + queryParams?: Record; + registry?: any; + schemaResponses?: any[]; + hasViewOnly?: boolean; + }) => { + const registryId = options?.registryId || 'registry-1'; + const mockRouter = RouterMockBuilder.create().withUrl('/registries/registry-1').build(); + const parentRoute = { + params: of({ id: registryId }), + snapshot: { params: { id: registryId }, queryParams: {} }, + } as any; + const mockActivatedRoute = Object.assign( + ActivatedRouteMockBuilder.create() + .withParams({ id: registryId }) + .withQueryParams(options?.queryParams || {}) + .build(), + { parent: parentRoute } + ); + + const mockCustomDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); + const mockLoaderService = new LoaderServiceMock(); + const mockViewOnlyHelper = { + hasViewOnlyParam: jest.fn().mockReturnValue(options?.hasViewOnly || false), + }; + + const defaultRegistry = options?.registry || null; + const defaultSchemaResponses = options?.schemaResponses || []; + + const providers: any[] = [ + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + MockProvider(CustomDialogService, mockCustomDialogService), + MockProvider(LoaderService, mockLoaderService), + MockProvider(ToastService, ToastServiceMock.useValue), + MockProvider(ViewOnlyLinkHelperService, mockViewOnlyHelper), + provideMockStore({ + actions: [ + { action: new GetRegistryById(registryId), value: of(void 0) }, + { action: new GetBookmarksCollectionId(), value: of(void 0) }, + { action: new GetBibliographicContributors(registryId, ResourceType.Registration), value: of(void 0) }, + { action: new GetSchemaBlocks(defaultRegistry?.registrationSchemaLink || ''), value: of(void 0) }, + { action: new GetRegistrySchemaResponses(registryId), value: of(void 0) }, + { action: new CreateSchemaResponse(registryId), value: of({ id: 'revision-1' }) }, + { action: new GetRegistryReviewActions(registryId), value: of(void 0) }, + ], + signals: [ + { selector: RegistrySelectors.getRegistry, value: defaultRegistry }, + { selector: RegistrySelectors.isRegistryLoading, value: false }, + { selector: RegistrySelectors.isRegistryAnonymous, value: false }, + { selector: RegistrySelectors.getSchemaResponses, value: defaultSchemaResponses }, + { selector: RegistrySelectors.isSchemaResponsesLoading, value: false }, + { selector: RegistrySelectors.getSchemaBlocks, value: [] }, + { selector: RegistrySelectors.isSchemaBlocksLoading, value: false }, + { selector: RegistrySelectors.areReviewActionsLoading, value: false }, + { selector: RegistrySelectors.getSchemaResponse, value: defaultSchemaResponses[0] || null }, + { selector: RegistrySelectors.hasWriteAccess, value: false }, + { selector: RegistrySelectors.hasAdminAccess, value: false }, + { selector: ContributorsSelectors.getBibliographicContributors, value: [] }, + { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, + { selector: ContributorsSelectors.hasMoreBibliographicContributors, value: false }, + ], + }), + ]; + + if (options?.isSSR) { + providers.push(provideServerRendering(), { provide: PLATFORM_ID, useValue: 'server' }); + } + + return TestBed.configureTestingModule({ imports: [ RegistryOverviewComponent, OSFTestingModule, @@ -48,58 +148,508 @@ describe('RegistryOverviewComponent', () => { ViewOnlyLinkMessageComponent ), ], - providers: [ - MockProvider(CustomDialogService, mockCustomDialogService), - provideMockStore({ - signals: [ - { selector: RegistrySelectors.getRegistry, value: null }, - { selector: RegistrySelectors.isRegistryLoading, value: false }, - { selector: RegistrySelectors.isRegistryAnonymous, value: false }, - { selector: RegistrySelectors.getInstitutions, value: [] }, - { selector: RegistrySelectors.isInstitutionsLoading, value: false }, - { selector: RegistrySelectors.getSchemaBlocks, value: [] }, - { selector: RegistrySelectors.isSchemaBlocksLoading, value: false }, - { selector: RegistrySelectors.areReviewActionsLoading, value: false }, - { selector: RegistrySelectors.getSchemaResponse, value: null }, - { selector: RegistrySelectors.getSchemaResponseLoading, value: false }, - { selector: RegistrySelectors.hasWriteAccess, value: false }, - { selector: RegistrySelectors.hasAdminAccess, value: false }, - { selector: RegistrySelectors.getReviewActions, value: [] }, - { selector: RegistrySelectors.isReviewActionSubmitting, value: false }, - ], - }), - ], - }).compileComponents(); + providers, + }); + }; + + beforeEach(async () => { + await createTestBed().compileComponents(); fixture = TestBed.createComponent(RegistryOverviewComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + jest.clearAllMocks(); }); it('should create', () => { expect(component).toBeTruthy(); }); - it('should handle loading states', () => { - expect(component.isRegistryLoading()).toBe(false); - expect(component.isSchemaBlocksLoading()).toBe(false); - expect(component.areReviewActionsLoading()).toBe(false); + describe('Initialization', () => { + it('should initialize with default values', () => { + expect(component.isModeration).toBe(false); + expect(component.revisionId).toBeUndefined(); + expect(component.revisionInProgress).toBeUndefined(); + expect(component.selectedRevisionIndex()).toBe(0); + }); + + it('should handle loading states', () => { + expect(component.isRegistryLoading()).toBe(false); + expect(component.isSchemaBlocksLoading()).toBe(false); + expect(component.areReviewActionsLoading()).toBe(false); + }); + + it('should handle registry data', () => { + expect(component.registry()).toBeNull(); + expect(component.isAnonymous()).toBe(false); + expect(component.schemaBlocks()).toEqual([]); + expect(component.currentRevision()).toBeNull(); + }); + + it('should handle permissions', () => { + expect(component.hasWriteAccess()).toBe(false); + expect(component.hasAdminAccess()).toBe(false); + }); }); - it('should handle registry data', () => { - expect(component.registry()).toBeNull(); - expect(component.isAnonymous()).toBe(false); - expect(component.schemaBlocks()).toEqual([]); - expect(component.currentRevision()).toBeNull(); + describe('Computed Properties', () => { + beforeEach(async () => { + TestBed.resetTestingModule(); + await createTestBed({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + }).compileComponents(); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should compute hasViewOnly based on router query params', async () => { + TestBed.resetTestingModule(); + const mockViewOnlyHelper = { + hasViewOnlyParam: jest.fn().mockReturnValue(true), + }; + await createTestBed({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + hasViewOnly: true, + }).compileComponents(); + TestBed.overrideProvider(ViewOnlyLinkHelperService, { useValue: mockViewOnlyHelper }); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(component.hasViewOnly()).toBe(true); + }); + + it('should compute hasViewOnly as false when view-only param is not present', async () => { + TestBed.resetTestingModule(); + const mockViewOnlyHelper = { + hasViewOnlyParam: jest.fn().mockReturnValue(false), + }; + await createTestBed({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + hasViewOnly: false, + }).compileComponents(); + TestBed.overrideProvider(ViewOnlyLinkHelperService, { useValue: mockViewOnlyHelper }); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(component.hasViewOnly()).toBe(false); + }); + + it('should compute showToolbar as false when archiving', async () => { + const archivingRegistry = { ...MOCK_REGISTRATION_OVERVIEW_MODEL, archiving: true }; + TestBed.resetTestingModule(); + await createTestBed({ registry: archivingRegistry }).compileComponents(); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(component.showToolbar()).toBe(false); + }); + + it('should compute showToolbar as false when withdrawn', async () => { + const withdrawnRegistry = { ...MOCK_REGISTRATION_OVERVIEW_MODEL, withdrawn: true }; + TestBed.resetTestingModule(); + await createTestBed({ registry: withdrawnRegistry }).compileComponents(); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(component.showToolbar()).toBe(false); + }); + + it('should compute showToolbar as true when not archiving or withdrawn', () => { + expect(component.showToolbar()).toBe(true); + }); + + it('should compute isInitialState based on reviewsState', async () => { + const initialStateRegistry = { + ...MOCK_REGISTRATION_OVERVIEW_MODEL, + reviewsState: RegistrationReviewStates.Initial, + }; + TestBed.resetTestingModule(); + await createTestBed({ registry: initialStateRegistry }).compileComponents(); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(component.isInitialState()).toBe(true); + }); + + it('should compute canMakeDecision when in moderation mode', async () => { + TestBed.resetTestingModule(); + await createTestBed({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + queryParams: { mode: 'moderator' }, + }).compileComponents(); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(component.canMakeDecision()).toBe(true); + }); + + it('should compute canMakeDecision as false when archiving', async () => { + const archivingRegistry = { ...MOCK_REGISTRATION_OVERVIEW_MODEL, archiving: true }; + TestBed.resetTestingModule(); + await createTestBed({ + registry: archivingRegistry, + queryParams: { mode: 'moderator' }, + }).compileComponents(); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(component.canMakeDecision()).toBe(false); + }); + + it('should compute isRootRegistration when rootParentId matches id', async () => { + const rootRegistry = { + ...MOCK_REGISTRATION_OVERVIEW_MODEL, + rootParentId: MOCK_REGISTRATION_OVERVIEW_MODEL.id, + }; + TestBed.resetTestingModule(); + await createTestBed({ registry: rootRegistry }).compileComponents(); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(component.isRootRegistration()).toBe(true); + }); + + it('should compute isRootRegistration when rootParentId is null', async () => { + const noRootRegistry = { ...MOCK_REGISTRATION_OVERVIEW_MODEL, rootParentId: null }; + TestBed.resetTestingModule(); + await createTestBed({ registry: noRootRegistry }).compileComponents(); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(component.isRootRegistration()).toBe(true); + }); + + it('should compute schemaResponse based on selectedRevisionIndex', async () => { + const schemaResponses = [ + createMockSchemaResponse('revision-1', RevisionReviewStates.Approved), + createMockSchemaResponse('revision-2', RevisionReviewStates.Approved), + ]; + TestBed.resetTestingModule(); + await createTestBed({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + schemaResponses, + }).compileComponents(); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(component.schemaResponse()?.id).toBe('revision-1'); + component.openRevision(1); + expect(component.schemaResponse()?.id).toBe('revision-2'); + }); }); - it('should handle permissions', () => { - expect(component.hasWriteAccess()).toBe(false); - expect(component.hasAdminAccess()).toBe(false); + describe('Methods', () => { + beforeEach(async () => { + TestBed.resetTestingModule(); + await createTestBed({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + }).compileComponents(); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should open revision and update selectedRevisionIndex', () => { + const revisionIndex = 2; + component.openRevision(revisionIndex); + expect(component.selectedRevisionIndex()).toBe(revisionIndex); + }); + + it('should call onUpdateRegistration and navigate to justification page', async () => { + TestBed.resetTestingModule(); + const mockLoaderService = new LoaderServiceMock(); + const showSpy = jest.spyOn(mockLoaderService, 'show'); + const schemaResponse = createMockSchemaResponse('revision-1', RevisionReviewStates.Approved); + const navigateSpy = jest.fn(); + const testRegistry = { ...MOCK_REGISTRATION_OVERVIEW_MODEL, id: 'registry-1' }; + await createTestBed({ + registry: testRegistry, + }).compileComponents(); + TestBed.overrideProvider(LoaderService, { useValue: mockLoaderService }); + TestBed.overrideProvider(Router, { + useValue: { + ...RouterMockBuilder.create().withUrl('/registries/registry-1').build(), + navigate: navigateSpy, + }, + }); + const mockStore = TestBed.inject(Store); + jest.spyOn(mockStore, 'selectSignal').mockImplementation((selector: any) => { + if (selector === RegistrySelectors.getSchemaResponse) { + return signal(schemaResponse); + } + if (selector === RegistrySelectors.getRegistry) { + return signal(testRegistry); + } + return signal(null); + }); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.onUpdateRegistration('registry-1'); + + expect(showSpy).toHaveBeenCalled(); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(navigateSpy).toHaveBeenCalledWith([`/registries/revisions/revision-1/justification`]); + }); + + it('should navigate to justification page when continuing update for approved revision', async () => { + TestBed.resetTestingModule(); + const navigateSpy = jest.fn(); + await createTestBed({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + }).compileComponents(); + TestBed.overrideProvider(Router, { + useValue: { + ...RouterMockBuilder.create().withUrl('/registries/registry-1').build(), + navigate: navigateSpy, + }, + }); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + component.revisionInProgress = createMockSchemaResponse('revision-1', RevisionReviewStates.Approved); + + component.onContinueUpdateRegistration(); + + expect(navigateSpy).toHaveBeenCalledWith([`/registries/revisions/revision-1/justification`]); + }); + + it('should navigate to review page when continuing update for unapproved revision', async () => { + TestBed.resetTestingModule(); + const navigateSpy = jest.fn(); + await createTestBed({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + }).compileComponents(); + TestBed.overrideProvider(Router, { + useValue: { + ...RouterMockBuilder.create().withUrl('/registries/registry-1').build(), + navigate: navigateSpy, + }, + }); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + component.revisionInProgress = createMockSchemaResponse('revision-1', RevisionReviewStates.Unapproved); + + component.onContinueUpdateRegistration(); + + expect(navigateSpy).toHaveBeenCalledWith([`/registries/revisions/revision-1/review`]); + }); + + it('should handle open make decision dialog', async () => { + const registryId = 'registry-1'; + TestBed.resetTestingModule(); + const mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); + const openSpy = jest.spyOn(mockCustomDialogService, 'open'); + const navigateSpy = jest.fn(); + const navigateByUrlSpy = jest.fn().mockResolvedValue(true); + const showSuccessSpy = jest.fn(); + + await createTestBed({ + registry: { ...MOCK_REGISTRATION_OVERVIEW_MODEL, id: registryId }, + queryParams: { revisionId: 'revision-1' }, + }).compileComponents(); + TestBed.overrideProvider(CustomDialogService, { useValue: mockCustomDialogService }); + TestBed.overrideProvider(Router, { + useValue: { + ...RouterMockBuilder.create().withUrl('/registries/registry-1').build(), + navigate: navigateSpy, + navigateByUrl: navigateByUrlSpy, + }, + }); + TestBed.overrideProvider(ToastService, { + useValue: { + showSuccess: showSuccessSpy, + }, + }); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + const mockDialogRef = { + onClose: { + pipe: jest.fn(() => + of({ + action: 'accept', + }) + ), + }, + }; + openSpy.mockReturnValue(mockDialogRef as any); + + component.handleOpenMakeDecisionDialog(); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(openSpy).toHaveBeenCalledWith(RegistryMakeDecisionComponent, expect.any(Object)); + expect(showSuccessSpy).toHaveBeenCalledWith('moderation.makeDecision.acceptSuccess'); + expect(navigateByUrlSpy).toHaveBeenCalled(); + }); }); - it('should open revision', () => { - const revisionIndex = 1; - component.openRevision(revisionIndex); - expect(component.selectedRevisionIndex()).toBe(revisionIndex); + describe('Effects', () => { + it('should dispatch actions when registry ID is available', async () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + TestBed.resetTestingModule(); + await createTestBed({ + registryId: 'registry-1', + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + }).compileComponents(); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(dispatchSpy).toHaveBeenCalled(); + }); + + it('should fetch schema blocks and responses when registry is available', async () => { + const registry = { + ...MOCK_REGISTRATION_OVERVIEW_MODEL, + id: 'registry-1', + registrationSchemaLink: 'https://example.com/schema', + }; + + TestBed.resetTestingModule(); + await createTestBed({ registry }).compileComponents(); + const mockStore = TestBed.inject(Store); + const dispatchSpy = jest.spyOn(mockStore, 'dispatch'); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + const calls = dispatchSpy.mock.calls.map((call) => call[0]); + const hasSchemaBlocks = calls.some( + (action) => action instanceof GetSchemaBlocks && (action as any).schemaLink === registry.registrationSchemaLink + ); + const hasSchemaResponses = calls.some( + (action) => action instanceof GetRegistrySchemaResponses && (action as any).registryId === registry.id + ); + expect(hasSchemaBlocks || hasSchemaResponses).toBe(true); + }); + + it('should handle query params for revisionId and mode', async () => { + TestBed.resetTestingModule(); + await createTestBed({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + queryParams: { revisionId: 'revision-1', mode: 'moderator' }, + }).compileComponents(); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(component.revisionId).toBe('revision-1'); + expect(component.isModeration).toBe(true); + }); + }); + + describe('State-based Behavior', () => { + it('should handle archiving registry', async () => { + const archivingRegistry = { ...MOCK_REGISTRATION_OVERVIEW_MODEL, archiving: true }; + + TestBed.resetTestingModule(); + await createTestBed({ registry: archivingRegistry }).compileComponents(); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.showToolbar()).toBe(false); + expect(component.canMakeDecision()).toBe(false); + }); + + it('should handle withdrawn registry', async () => { + const withdrawnRegistry = { ...MOCK_REGISTRATION_OVERVIEW_MODEL, withdrawn: true }; + + TestBed.resetTestingModule(); + await createTestBed({ registry: withdrawnRegistry }).compileComponents(); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.showToolbar()).toBe(false); + expect(component.canMakeDecision()).toBe(false); + }); + + it('should handle different review states', async () => { + const pendingRegistry = { + ...MOCK_REGISTRATION_OVERVIEW_MODEL, + reviewsState: RegistrationReviewStates.Pending, + }; + + TestBed.resetTestingModule(); + await createTestBed({ registry: pendingRegistry }).compileComponents(); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.isInitialState()).toBe(false); + }); + + it('should find revision in progress from schema responses', async () => { + const schemaResponses = [ + createMockSchemaResponse('revision-1', RevisionReviewStates.Approved), + createMockSchemaResponse('revision-2', RevisionReviewStates.RevisionInProgress), + createMockSchemaResponse('revision-3', RevisionReviewStates.Approved), + ]; + + TestBed.resetTestingModule(); + await createTestBed({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + schemaResponses, + }).compileComponents(); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + component.openRevision(1); + fixture.detectChanges(); + + expect(component.revisionInProgress?.id).toBe('revision-2'); + }); + }); + + describe('SSR', () => { + beforeEach(async () => { + TestBed.resetTestingModule(); + await createTestBed({ + isSSR: true, + registryId: 'registry-1', + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + }).compileComponents(); + fixture = TestBed.createComponent(RegistryOverviewComponent); + component = fixture.componentInstance; + }); + + it('should render server-side without errors', () => { + expect(() => { + fixture.detectChanges(); + }).not.toThrow(); + expect(component).toBeTruthy(); + }); + + it('should not access browser-only APIs during SSR', () => { + const platformId = TestBed.inject(PLATFORM_ID); + expect(platformId).toBe('server'); + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should execute constructor effects without errors in SSR context', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + fixture.detectChanges(); + expect(dispatchSpy).toHaveBeenCalled(); + expect(component).toBeTruthy(); + }); }); }); 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 168adc55e..69466b6e5 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 @@ -20,6 +20,7 @@ import { import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; +import { CreateSchemaResponse } from '@osf/features/registries/store'; import { DataResourcesComponent } from '@osf/shared/components/data-resources/data-resources.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; @@ -28,13 +29,13 @@ import { RegistrationReviewStates } from '@osf/shared/enums/registration-review- import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; import { toCamelCase } from '@osf/shared/helpers/camel-case'; -import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; +import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { LoaderService } from '@osf/shared/services/loader.service'; import { ToastService } from '@osf/shared/services/toast.service'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks'; import { ContributorsSelectors, GetBibliographicContributors } from '@osf/shared/stores/contributors'; -import { SchemaResponse } from '@shared/models/registration/schema-response.model'; import { ArchivingMessageComponent } from '../../components/archiving-message/archiving-message.component'; import { RegistrationOverviewToolbarComponent } from '../../components/registration-overview-toolbar/registration-overview-toolbar.component'; @@ -45,7 +46,6 @@ import { RegistryRevisionsComponent } from '../../components/registry-revisions/ import { RegistryStatusesComponent } from '../../components/registry-statuses/registry-statuses.component'; import { WithdrawnMessageComponent } from '../../components/withdrawn-message/withdrawn-message.component'; import { - CreateSchemaResponse, GetRegistryById, GetRegistryReviewActions, GetRegistrySchemaResponses, @@ -81,6 +81,7 @@ export class RegistryOverviewComponent { private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); private readonly toastService = inject(ToastService); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); private readonly customDialogService = inject(CustomDialogService); private readonly loaderService = inject(LoaderService); @@ -101,8 +102,14 @@ export class RegistryOverviewComponent { readonly hasWriteAccess = select(RegistrySelectors.hasWriteAccess); readonly hasAdminAccess = select(RegistrySelectors.hasAdminAccess); + isModeration = false; + revisionId: string | null = null; revisionInProgress: SchemaResponse | undefined; + selectedRevisionIndex = signal(0); + hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); + showToolbar = computed(() => !this.registry()?.archiving && !this.registry()?.withdrawn); + isInitialState = computed(() => this.registry()?.reviewsState === RegistrationReviewStates.Initial); canMakeDecision = computed(() => !this.registry()?.archiving && !this.registry()?.withdrawn && this.isModeration); isRootRegistration = computed(() => { @@ -121,10 +128,6 @@ export class RegistryOverviewComponent { return index !== null ? schemaResponses[index] : null; }); - readonly selectedRevisionIndex = signal(0); - - showToolbar = computed(() => !this.registry()?.archiving && !this.registry()?.withdrawn); - private readonly actions = createDispatchMap({ getRegistryById: GetRegistryById, getBookmarksId: GetBookmarksCollectionId, @@ -135,15 +138,6 @@ export class RegistryOverviewComponent { getBibliographicContributors: GetBibliographicContributors, }); - revisionId: string | null = null; - isModeration = false; - - hasViewOnly = computed(() => hasViewOnlyParam(this.router)); - - get isInitialState(): boolean { - return this.registry()?.reviewsState === RegistrationReviewStates.Initial; - } - constructor() { effect(() => { const registry = this.registry(); diff --git a/src/app/features/registry/pages/registry-wiki/registry-wiki.component.ts b/src/app/features/registry/pages/registry-wiki/registry-wiki.component.ts index 322808dfd..2a8d92633 100644 --- a/src/app/features/registry/pages/registry-wiki/registry-wiki.component.ts +++ b/src/app/features/registry/pages/registry-wiki/registry-wiki.component.ts @@ -17,8 +17,8 @@ import { CompareSectionComponent } from '@osf/shared/components/wiki/compare-sec import { ViewSectionComponent } from '@osf/shared/components/wiki/view-section/view-section.component'; import { WikiListComponent } from '@osf/shared/components/wiki/wiki-list/wiki-list.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; import { WikiModes } from '@osf/shared/models/wiki/wiki.model'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { ClearWiki, GetCompareVersionContent, @@ -52,6 +52,7 @@ export class RegistryWikiComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); WikiModes = WikiModes; wikiModes = select(WikiSelectors.getWikiModes); @@ -65,7 +66,7 @@ export class RegistryWikiComponent { isWikiVersionLoading = select(WikiSelectors.getWikiVersionsLoading); componentsWikiList = select(WikiSelectors.getComponentsWikiList); - hasViewOnly = computed(() => hasViewOnlyParam(this.router)); + hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); readonly resourceId = this.route.parent?.snapshot.params['id']; diff --git a/src/app/features/registry/registry.component.ts b/src/app/features/registry/registry.component.ts index edd9ae630..ddc1d89d9 100644 --- a/src/app/features/registry/registry.component.ts +++ b/src/app/features/registry/registry.component.ts @@ -2,7 +2,7 @@ import { createDispatchMap, select } from '@ngxs/store'; import { filter, map } from 'rxjs'; -import { DatePipe } from '@angular/common'; +import { DatePipe, isPlatformBrowser } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -12,6 +12,7 @@ import { HostBinding, inject, OnDestroy, + PLATFORM_ID, signal, } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; @@ -47,10 +48,13 @@ export class RegistryComponent implements OnDestroy { private readonly dataciteService = inject(DataciteService); private readonly destroyRef = inject(DestroyRef); private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); private readonly helpScoutService = inject(HelpScoutService); private readonly environment = inject(ENVIRONMENT); private readonly prerenderReady = inject(PrerenderReadyService); - readonly analyticsService = inject(AnalyticsService); + private readonly analyticsService = inject(AnalyticsService); + private readonly platformId = inject(PLATFORM_ID); + private readonly isBrowser = isPlatformBrowser(this.platformId); private readonly actions = createDispatchMap({ getRegistryWithRelatedData: GetRegistryWithRelatedData, @@ -71,6 +75,8 @@ export class RegistryComponent implements OnDestroy { readonly license = select(RegistrySelectors.getLicense); readonly isLicenseLoading = select(RegistrySelectors.isLicenseLoading); + private readonly lastMetaTagsRegistryId = signal(null); + private readonly allDataLoaded = computed( () => !this.isRegistryLoading() && @@ -79,9 +85,6 @@ export class RegistryComponent implements OnDestroy { !!this.registry() ); - private readonly lastMetaTagsRegistryId = signal(null); - readonly router = inject(Router); - constructor() { this.prerenderReady.setNotReady(); this.helpScoutService.setResourceType('registration'); @@ -127,7 +130,10 @@ export class RegistryComponent implements OnDestroy { } ngOnDestroy(): void { - this.actions.clearCurrentProvider(); + if (this.isBrowser) { + this.actions.clearCurrentProvider(); + } + this.helpScoutService.unsetResourceType(); } diff --git a/src/app/features/registry/registry.routes.ts b/src/app/features/registry/registry.routes.ts index db19cd0af..752cbf703 100644 --- a/src/app/features/registry/registry.routes.ts +++ b/src/app/features/registry/registry.routes.ts @@ -4,6 +4,7 @@ import { Routes } from '@angular/router'; import { viewOnlyGuard } from '@core/guards/view-only.guard'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { BookmarksState } from '@osf/shared/stores/bookmarks'; import { CitationsState } from '@osf/shared/stores/citations'; import { DuplicatesState } from '@osf/shared/stores/duplicates'; import { RegistrationProviderState } from '@osf/shared/stores/registration-provider'; @@ -23,7 +24,7 @@ export const registryRoutes: Routes = [ { path: '', component: RegistryComponent, - providers: [provideStates([RegistryState, RegistrationProviderState])], + providers: [provideStates([BookmarksState, RegistryState, RegistrationProviderState])], children: [ { path: '', diff --git a/src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.html b/src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.html index 4d23698e6..d67a91b8f 100644 --- a/src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.html +++ b/src/app/features/settings/settings-addons/components/connect-addon/connect-addon.component.html @@ -78,7 +78,7 @@

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

- + {{ 'settings.addons.connectAddon.startOauth' | translate }}

diff --git a/src/app/shared/components/file-menu/file-menu.component.ts b/src/app/shared/components/file-menu/file-menu.component.ts index 6135fb4bc..08ae3b5e2 100644 --- a/src/app/shared/components/file-menu/file-menu.component.ts +++ b/src/app/shared/components/file-menu/file-menu.component.ts @@ -8,8 +8,8 @@ import { Component, computed, inject, input, output, viewChild } from '@angular/ import { Router } from '@angular/router'; import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; -import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; import { MenuManagerService } from '@osf/shared/services/menu-manager.service'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { FileMenuAction, FileMenuData, FileMenuFlags } from '@shared/models/files/file-menu-action.model'; @Component({ @@ -21,12 +21,14 @@ import { FileMenuAction, FileMenuData, FileMenuFlags } from '@shared/models/file export class FileMenuComponent { private router = inject(Router); private menuManager = inject(MenuManagerService); + private viewOnlyService = inject(ViewOnlyLinkHelperService); + isFolder = input(false); allowedActions = input({} as FileMenuFlags); menu = viewChild.required('menu'); action = output(); - hasViewOnly = computed(() => hasViewOnlyParam(this.router)); + hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); private readonly allMenuItems: MenuItem[] = [ { diff --git a/src/app/shared/components/files-tree/files-tree.component.ts b/src/app/shared/components/files-tree/files-tree.component.ts index 083b19bdc..c094a6ec6 100644 --- a/src/app/shared/components/files-tree/files-tree.component.ts +++ b/src/app/shared/components/files-tree/files-tree.component.ts @@ -6,7 +6,7 @@ import { PrimeTemplate } from 'primeng/api'; import { Tree, TreeNodeDropEvent, TreeNodeSelectEvent, TreeScrollIndexChangeEvent } from 'primeng/tree'; import { Clipboard } from '@angular/cdk/clipboard'; -import { DatePipe } from '@angular/common'; +import { DatePipe, isPlatformBrowser } from '@angular/common'; import { AfterViewInit, ChangeDetectionStrategy, @@ -20,6 +20,7 @@ import { input, OnDestroy, output, + PLATFORM_ID, signal, viewChild, } from '@angular/core'; @@ -34,7 +35,6 @@ import { embedDynamicJs, embedStaticHtml } from '@osf/features/files/constants'; import { StopPropagationDirective } from '@osf/shared/directives/stop-propagation.directive'; import { FileKind } from '@osf/shared/enums/file-kind.enum'; import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; -import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; import { FilesMapper } from '@osf/shared/mappers/files/files.mapper'; import { FileSizePipe } from '@osf/shared/pipes/file-size.pipe'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; @@ -42,6 +42,7 @@ import { CustomDialogService } from '@osf/shared/services/custom-dialog.service' import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { FilesService } from '@osf/shared/services/files.service'; import { ToastService } from '@osf/shared/services/toast.service'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { FileModel } from '@shared/models/files/file.model'; import { FileFolderModel } from '@shared/models/files/file-folder.model'; import { FileLabelModel } from '@shared/models/files/file-label.model'; @@ -77,9 +78,11 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { readonly customConfirmationService = inject(CustomConfirmationService); readonly customDialogService = inject(CustomDialogService); readonly dataciteService = inject(DataciteService); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); private readonly destroyRef = inject(DestroyRef); private readonly environment = inject(ENVIRONMENT); + private readonly platformId = inject(PLATFORM_ID); readonly clipboard = inject(Clipboard); files = input.required(); @@ -119,7 +122,7 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { isDragOver = signal(false); isLoadingMore = signal(false); - hasViewOnly = computed(() => hasViewOnlyParam(this.router) || this.viewOnly()); + hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router) || this.viewOnly()); visibleFilesCount = computed((): number => { const height = parseInt(this.scrollHeight(), 10); return Math.ceil(height / this.virtualScrollItemSize); @@ -374,19 +377,25 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { } downloadFile(link: string): void { - window.open(link)?.focus(); + if (isPlatformBrowser(this.platformId)) { + window.open(link)?.focus(); + } } openLink(link: string): void { - window.location.href = link; + if (isPlatformBrowser(this.platformId)) { + window.location.href = link; + } } openLinkNewTab(link: string): void { - window.open(link, '_blank', 'noopener,noreferrer'); + if (isPlatformBrowser(this.platformId)) { + window.open(link, '_blank', 'noopener,noreferrer'); + } } downloadFolder(downloadLink: string): void { - if (downloadLink) { + if (isPlatformBrowser(this.platformId) && downloadLink) { const link = this.filesService.getFolderDownloadLink(downloadLink); window.open(link, '_blank')?.focus(); } diff --git a/src/app/shared/components/global-search/global-search.component.ts b/src/app/shared/components/global-search/global-search.component.ts index 8032c0126..d49d78328 100644 --- a/src/app/shared/components/global-search/global-search.component.ts +++ b/src/app/shared/components/global-search/global-search.component.ts @@ -145,11 +145,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { this.actions.setResourceType(resourceTab); this.updateUrlWithTab(resourceTab); - this.actions.fetchResources().subscribe({ - next: () => { - this.updateUrlWithFilterOptions(this.filterOptions()); - }, - }); + this.actions.fetchResources().subscribe(() => this.updateUrlWithFilterOptions(this.filterOptions())); } onSortChanged(sortBy: string): void { @@ -158,11 +154,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { } onPageChanged(link: string): void { - this.actions.getResourcesByLink(link).subscribe({ - next: () => { - this.scrollToTop(); - }, - }); + this.actions.getResourcesByLink(link).subscribe(() => this.scrollToTop()); } scrollToTop() { diff --git a/src/app/shared/components/resource-citations/resource-citations.component.ts b/src/app/shared/components/resource-citations/resource-citations.component.ts index 406c82261..fadb05b38 100644 --- a/src/app/shared/components/resource-citations/resource-citations.component.ts +++ b/src/app/shared/components/resource-citations/resource-citations.component.ts @@ -28,8 +28,8 @@ import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; -import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; import { ToastService } from '@osf/shared/services/toast.service'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { CitationStyle } from '@shared/models/citations/citation-style.model'; import { CustomOption } from '@shared/models/select-option.model'; import { @@ -71,6 +71,7 @@ export class ResourceCitationsComponent { private readonly clipboard = inject(Clipboard); private readonly toastService = inject(ToastService); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); private readonly filterSubject = new Subject(); customCitationChange = output(); @@ -90,7 +91,7 @@ export class ResourceCitationsComponent { ); customCitationInput = new FormControl(''); - readonly hasViewOnly = computed(() => hasViewOnlyParam(this.router)); + readonly hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); actions = createDispatchMap({ getDefaultCitations: GetDefaultCitations, diff --git a/src/app/shared/components/socials-share-button/socials-share-button.component.scss b/src/app/shared/components/socials-share-button/socials-share-button.component.scss index 5a4fc67ca..d2373875e 100644 --- a/src/app/shared/components/socials-share-button/socials-share-button.component.scss +++ b/src/app/shared/components/socials-share-button/socials-share-button.component.scss @@ -1,3 +1,7 @@ +:host { + display: flex; +} + .social-link { background-color: var(--pr-blue-1); border-radius: 0.25rem; diff --git a/src/app/shared/components/view-only-link-message/view-only-link-message.component.ts b/src/app/shared/components/view-only-link-message/view-only-link-message.component.ts index a9766134b..643790381 100644 --- a/src/app/shared/components/view-only-link-message/view-only-link-message.component.ts +++ b/src/app/shared/components/view-only-link-message/view-only-link-message.component.ts @@ -3,7 +3,8 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Message } from 'primeng/message'; -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject, PLATFORM_ID } from '@angular/core'; @Component({ selector: 'osf-view-only-link-message', @@ -13,7 +14,13 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ViewOnlyLinkMessageComponent { + private platformId = inject(PLATFORM_ID); + handleLeaveViewOnlyView(): void { + if (!isPlatformBrowser(this.platformId)) { + return; + } + const currentUrl = new URL(window.location.href); currentUrl.searchParams.delete('view_only'); diff --git a/src/app/shared/directives/scroll-top.directive.ts b/src/app/shared/directives/scroll-top.directive.ts index bbd14ea78..819d26f68 100644 --- a/src/app/shared/directives/scroll-top.directive.ts +++ b/src/app/shared/directives/scroll-top.directive.ts @@ -1,6 +1,7 @@ import { filter } from 'rxjs'; -import { DestroyRef, Directive, ElementRef, inject } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { DestroyRef, Directive, ElementRef, inject, PLATFORM_ID } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NavigationEnd, Router } from '@angular/router'; @@ -11,6 +12,7 @@ export class ScrollTopOnRouteChangeDirective { private el = inject(ElementRef); private router = inject(Router); private destroyRef = inject(DestroyRef); + private platformId = inject(PLATFORM_ID); constructor() { this.router.events @@ -25,7 +27,7 @@ export class ScrollTopOnRouteChangeDirective { route = route.firstChild; } - if (route.snapshot.data['scrollToTop'] !== false) { + if (isPlatformBrowser(this.platformId) && route.snapshot.data['scrollToTop'] !== false) { (this.el.nativeElement as HTMLElement).scrollTo({ top: 0, behavior: 'instant', diff --git a/src/app/shared/helpers/browser-tab.helper.ts b/src/app/shared/helpers/browser-tab.helper.ts deleted file mode 100644 index f0df8afef..000000000 --- a/src/app/shared/helpers/browser-tab.helper.ts +++ /dev/null @@ -1,22 +0,0 @@ -export class BrowserTabHelper { - private static readonly DEFAULT_FAVICON = '/favicon.ico'; - private static readonly DEFAULT_TITLE = 'OSF'; - - static updateTabStyles(faviconUrl: string, title: string) { - if (faviconUrl) { - const faviconElement = document.querySelector("link[rel*='icon']") as HTMLLinkElement; - faviconElement.href = faviconUrl; - } - - if (title) { - document.title = title; - } - } - - static resetToDefaults() { - const faviconElement = document.querySelector("link[rel*='icon']") as HTMLLinkElement; - faviconElement.href = this.DEFAULT_FAVICON; - - document.title = this.DEFAULT_TITLE; - } -} diff --git a/src/app/shared/helpers/header-style.helper.ts b/src/app/shared/helpers/header-style.helper.ts deleted file mode 100644 index e40447b77..000000000 --- a/src/app/shared/helpers/header-style.helper.ts +++ /dev/null @@ -1,17 +0,0 @@ -export class HeaderStyleHelper { - static applyHeaderStyles(textColor: string, backgroundColor?: string, backgroundImageUrl?: string) { - const root = document.documentElement; - - root.style.setProperty('--header-color', textColor); - root.style.setProperty('--header-background-color', backgroundColor || ''); - root.style.setProperty('--header-background-image-url', `url(${backgroundImageUrl || ''})`); - } - - static resetToDefaults() { - const root = document.documentElement; - - root.style.setProperty('--header-color', ''); - root.style.setProperty('--header-background-color', ''); - root.style.setProperty('--header-background-image-url', ''); - } -} diff --git a/src/app/shared/helpers/view-only.helper.ts b/src/app/shared/helpers/view-only.helper.ts deleted file mode 100644 index a92d5234d..000000000 --- a/src/app/shared/helpers/view-only.helper.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Router } from '@angular/router'; - -export function hasViewOnlyParam(router: Router): boolean { - const currentUrl = router.url; - const routerParams = new URLSearchParams(currentUrl.split('?')[1] || ''); - const windowParams = new URLSearchParams(window.location.search); - - return routerParams.has('view_only') || windowParams.has('view_only'); -} - -export function getViewOnlyParam(router?: Router): string | null { - let currentUrl = ''; - - if (router) { - currentUrl = router.url; - } - - const routerParams = new URLSearchParams(currentUrl.split('?')[1] || ''); - const windowParams = new URLSearchParams(window.location.search); - - return routerParams.get('view_only') || windowParams.get('view_only'); -} - -export function getViewOnlyParamFromUrl(currentUrl?: string): string | null { - if (!currentUrl) return null; - - const routerParams = new URLSearchParams(currentUrl.split('?')[1] || ''); - const windowParams = new URLSearchParams(window.location.search); - - return routerParams.get('view_only') || windowParams.get('view_only'); -} diff --git a/src/app/shared/mappers/view-only-links.mapper.ts b/src/app/shared/mappers/view-only-links.mapper.ts index e31b339cc..354649c88 100644 --- a/src/app/shared/mappers/view-only-links.mapper.ts +++ b/src/app/shared/mappers/view-only-links.mapper.ts @@ -5,19 +5,23 @@ import { } from '../models/view-only-links/view-only-link.model'; import { ViewOnlyLinkJsonApi, - ViewOnlyLinksResponseJsonApi, + ViewOnlyLinksResponsesJsonApi, } from '../models/view-only-links/view-only-link-response.model'; import { UserMapper } from './user'; export class ViewOnlyLinksMapper { - static fromResponse(response: ViewOnlyLinksResponseJsonApi, projectId: string): PaginatedViewOnlyLinksModel { + static fromResponse( + response: ViewOnlyLinksResponsesJsonApi, + projectId: string, + webUrl: string + ): PaginatedViewOnlyLinksModel { const items: ViewOnlyLinkModel[] = response.data.map((item) => { const creator = UserMapper.getUserInfo(item.embeds.creator); return { id: item.id, - link: `${document.baseURI}${projectId}/overview?view_only=${item.attributes.key}`, + link: `${webUrl}/${projectId}/overview?view_only=${item.attributes.key}`, dateCreated: item.attributes.date_created, key: item.attributes.key, name: item.attributes.name, @@ -46,13 +50,17 @@ export class ViewOnlyLinksMapper { }; } - static fromSingleResponse(response: ViewOnlyLinkJsonApi, projectId: string): PaginatedViewOnlyLinksModel { + static fromSingleResponse( + response: ViewOnlyLinkJsonApi, + projectId: string, + webUrl: string + ): PaginatedViewOnlyLinksModel { const item = response; const creator = UserMapper.getUserInfo(item.embeds.creator); const mappedItem: ViewOnlyLinkModel = { id: item.id, - link: `${document.baseURI}${projectId}/overview?view_only=${item.attributes.key}`, + link: `${webUrl}/${projectId}/overview?view_only=${item.attributes.key}`, dateCreated: item.attributes.date_created, key: item.attributes.key, name: item.attributes.name, diff --git a/src/app/shared/models/view-only-links/view-only-link-response.model.ts b/src/app/shared/models/view-only-links/view-only-link-response.model.ts index 06bbfb1a9..75567f68d 100644 --- a/src/app/shared/models/view-only-links/view-only-link-response.model.ts +++ b/src/app/shared/models/view-only-links/view-only-link-response.model.ts @@ -1,12 +1,9 @@ -import { MetaJsonApi } from '../common/json-api.model'; +import { ResponseJsonApi } from '../common/json-api.model'; import { BaseNodeDataJsonApi } from '../nodes/base-node-data-json-api.model'; import { UserDataErrorResponseJsonApi } from '../user/user-json-api.model'; -export interface ViewOnlyLinksResponseJsonApi { - data: ViewOnlyLinkJsonApi[]; - links: PaginationLinksJsonApi; - meta: MetaJsonApi; -} +export type ViewOnlyLinksResponsesJsonApi = ResponseJsonApi; +export type ViewOnlyLinksResponseJsonApi = ResponseJsonApi; export interface ViewOnlyLinkJsonApi { id: string; @@ -24,15 +21,3 @@ export interface ViewOnlyLinkJsonApi { }; }; } - -export interface LinkWithMetaJsonApi { - href: string; - meta: Record; -} - -interface PaginationLinksJsonApi { - first: string | null; - last: string | null; - prev: string | null; - next: string | null; -} diff --git a/src/app/shared/services/activity-logs/activity-log-url-builder.service.ts b/src/app/shared/services/activity-logs/activity-log-url-builder.service.ts index 761d70e53..e13408b3e 100644 --- a/src/app/shared/services/activity-logs/activity-log-url-builder.service.ts +++ b/src/app/shared/services/activity-logs/activity-log-url-builder.service.ts @@ -1,4 +1,5 @@ -import { Injectable } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { inject, Injectable, PLATFORM_ID } from '@angular/core'; import { ActivityLog } from '@osf/shared/models/activity-logs/activity-logs.model'; @@ -6,6 +7,8 @@ import { ActivityLog } from '@osf/shared/models/activity-logs/activity-logs.mode providedIn: 'root', }) export class ActivityLogUrlBuilderService { + private readonly platformId = inject(PLATFORM_ID); + buildAHrefElement(url: string | undefined, value: string): string { const safeUrl = url || ''; const relativeUrl = this.toRelativeUrl(safeUrl); @@ -147,6 +150,10 @@ export class ActivityLogUrlBuilderService { private toRelativeUrl(url: string): string { if (!url) return ''; + if (!isPlatformBrowser(this.platformId)) { + return url; + } + try { const parser = document.createElement('a'); parser.href = url; diff --git a/src/app/shared/services/addons/addon-oauth.service.ts b/src/app/shared/services/addons/addon-oauth.service.ts index d95fa3f48..3ebfaae3c 100644 --- a/src/app/shared/services/addons/addon-oauth.service.ts +++ b/src/app/shared/services/addons/addon-oauth.service.ts @@ -1,6 +1,7 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { DestroyRef, inject, Injectable, signal } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { DestroyRef, inject, Injectable, PLATFORM_ID, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { OAuthCallbacks } from '@osf/shared/models/addons/addon-utils.models'; @@ -12,6 +13,7 @@ import { AddonsSelectors, DeleteAuthorizedAddon, GetAuthorizedStorageOauthToken }) export class AddonOAuthService { private destroyRef = inject(DestroyRef); + private platformId = inject(PLATFORM_ID); private pendingOauth = signal(false); private createdAddon = signal(null); @@ -33,7 +35,9 @@ export class AddonOAuthService { this.addonTypeString.set(addonTypeString); this.callbacks.set(callbacks); - document.addEventListener('visibilitychange', this.boundOnVisibilityChange); + if (isPlatformBrowser(this.platformId)) { + document.addEventListener('visibilitychange', this.boundOnVisibilityChange); + } } stopOAuthTracking(): void { @@ -41,7 +45,7 @@ export class AddonOAuthService { } private onVisibilityChange(): void { - if (document.visibilityState === 'visible' && this.pendingOauth()) { + if (isPlatformBrowser(this.platformId) && document.visibilityState === 'visible' && this.pendingOauth()) { this.checkOauthSuccess(); } } @@ -71,7 +75,9 @@ export class AddonOAuthService { private completeOauthFlow(updatedAddon?: AuthorizedAccountModel): void { this.pendingOauth.set(false); - document.removeEventListener('visibilitychange', this.boundOnVisibilityChange); + if (isPlatformBrowser(this.platformId)) { + document.removeEventListener('visibilitychange', this.boundOnVisibilityChange); + } if (updatedAddon && this.callbacks()?.onSuccess) { const originalAddon = this.createdAddon(); @@ -87,7 +93,9 @@ export class AddonOAuthService { private cleanupService(): void { this.cleanupIncompleteOAuthAddon(); - document.removeEventListener('visibilitychange', this.boundOnVisibilityChange); + if (isPlatformBrowser(this.platformId)) { + document.removeEventListener('visibilitychange', this.boundOnVisibilityChange); + } this.resetServiceData(); } diff --git a/src/app/shared/services/analytics.service.ts b/src/app/shared/services/analytics.service.ts index e5b428fa8..f76aabbd9 100644 --- a/src/app/shared/services/analytics.service.ts +++ b/src/app/shared/services/analytics.service.ts @@ -1,6 +1,7 @@ import { Observable } from 'rxjs'; -import { inject, Injectable } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { inject, Injectable, PLATFORM_ID } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { CurrentResource } from '@osf/shared/models/current-resource.model'; @@ -10,6 +11,7 @@ import { JsonApiService } from '@osf/shared/services/json-api.service'; export class AnalyticsService { private readonly jsonApiService = inject(JsonApiService); private readonly environment = inject(ENVIRONMENT); + private readonly platformId = inject(PLATFORM_ID); get apiDomainUrl() { return `${this.environment.apiDomainUrl}/_/metrics/events/counted_usage/`; @@ -20,7 +22,17 @@ export class AnalyticsService { const attributes = Object.fromEntries( Object.entries(all_attrs).filter(([_, value]: [unknown, unknown]) => typeof value !== 'undefined') ); - const pageTitle = document.title === 'OSF' ? `OSF | ${resource.title}` : document.title; + + let pageTitle = 'OSF'; + let pageUrl = ''; + let refererUrl = ''; + + if (isPlatformBrowser(this.platformId)) { + pageTitle = document.title === 'OSF' ? `OSF | ${resource.title}` : document.title; + pageUrl = document.URL; + refererUrl = document.referrer; + } + return { data: { type: 'counted-usage', @@ -28,9 +40,9 @@ export class AnalyticsService { ...attributes, action_labels: ['web', 'view'], pageview_info: { - page_url: document.URL, + page_url: pageUrl, page_title: pageTitle, - referer_url: document.referrer, + referer_url: refererUrl, route_name: `angular-osf-web.${routeName}`, }, }, diff --git a/src/app/shared/services/brand.service.ts b/src/app/shared/services/brand.service.ts index 72740585d..3d236ae32 100644 --- a/src/app/shared/services/brand.service.ts +++ b/src/app/shared/services/brand.service.ts @@ -1,8 +1,21 @@ +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { inject, Injectable, PLATFORM_ID } from '@angular/core'; + import { BrandModel } from '../models/brand/brand.model'; +@Injectable({ + providedIn: 'root', +}) export class BrandService { - static applyBranding(brand: BrandModel): void { - const root = document.documentElement; + private readonly document = inject(DOCUMENT); + private readonly platformId = inject(PLATFORM_ID); + + applyBranding(brand: BrandModel): void { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + const root = this.document.documentElement; root.style.setProperty('--branding-primary-color', brand.primaryColor); root.style.setProperty('--branding-secondary-color', brand.secondaryColor); @@ -11,8 +24,12 @@ export class BrandService { root.style.setProperty('--branding-hero-background-image-url', `url(${brand.heroBackgroundImageUrl})`); } - static resetBranding(): void { - const root = document.documentElement; + resetBranding(): void { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + const root = this.document.documentElement; root.style.setProperty('--branding-primary-color', ''); root.style.setProperty('--branding-secondary-color', ''); diff --git a/src/app/shared/services/browser-tab.service.ts b/src/app/shared/services/browser-tab.service.ts new file mode 100644 index 000000000..9c6a419c6 --- /dev/null +++ b/src/app/shared/services/browser-tab.service.ts @@ -0,0 +1,41 @@ +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { inject, Injectable, PLATFORM_ID } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class BrowserTabService { + private static readonly DEFAULT_FAVICON = '/favicon.ico'; + private static readonly DEFAULT_TITLE = 'OSF'; + + private readonly document = inject(DOCUMENT); + private readonly platformId = inject(PLATFORM_ID); + + updateTabStyles(faviconUrl: string, title: string) { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + if (faviconUrl) { + const faviconElement = this.document.querySelector("link[rel*='icon']") as HTMLLinkElement; + if (faviconElement) { + faviconElement.href = faviconUrl; + } + } + + if (title) { + this.document.title = title; + } + } + + resetToDefaults() { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + const faviconElement = this.document.querySelector("link[rel*='icon']") as HTMLLinkElement; + if (faviconElement) { + faviconElement.href = BrowserTabService.DEFAULT_FAVICON; + } + + this.document.title = BrowserTabService.DEFAULT_TITLE; + } +} diff --git a/src/app/shared/services/datacite/datacite.service.ts b/src/app/shared/services/datacite/datacite.service.ts index 935333b5c..38ed9f53d 100644 --- a/src/app/shared/services/datacite/datacite.service.ts +++ b/src/app/shared/services/datacite/datacite.service.ts @@ -1,7 +1,8 @@ import { EMPTY, filter, map, Observable, of, switchMap, take } from 'rxjs'; +import { isPlatformBrowser } from '@angular/common'; import { HttpClient, HttpContext } from '@angular/common/http'; -import { inject, Injectable } from '@angular/core'; +import { inject, Injectable, PLATFORM_ID } from '@angular/core'; import { BYPASS_ERROR_INTERCEPTOR } from '@core/interceptors/error-interceptor.tokens'; import { ENVIRONMENT } from '@core/provider/environment.provider'; @@ -15,6 +16,7 @@ import { IdentifiersResponseJsonApi } from '@osf/shared/models/identifiers/ident export class DataciteService { private readonly http: HttpClient = inject(HttpClient); private readonly environment = inject(ENVIRONMENT); + private readonly platformId = inject(PLATFORM_ID); get apiDomainUrl() { return this.environment.apiDomainUrl; @@ -74,31 +76,38 @@ export class DataciteService { /** * Internal helper to log a specific Datacite event for a given DOI. + * Only tracks in browser environment using sendBeacon with HTTP POST fallback. * * @param event - The Datacite event type (VIEW or DOWNLOAD). * @param doi - The DOI (Digital Object Identifier) of the resource. - * @returns An Observable that completes when the HTTP POST is sent, - * or EMPTY if DOI or repo ID is missing. + * @returns An Observable that completes when the tracking request is sent, + * or EMPTY if DOI, repo ID is missing, or not in browser. */ private logActivity(event: DataciteEvent, doi: string): Observable { if (!doi || !this.dataciteTrackerRepoId) { return EMPTY; } + + if (!isPlatformBrowser(this.platformId)) { + return EMPTY; + } + const payload = { n: event, u: window.location.href, i: this.dataciteTrackerRepoId, p: doi, }; + const success = navigator.sendBeacon(this.dataciteTrackerAddress, JSON.stringify(payload)); + if (success) { return of(void 0); } else { - const headers = { - 'Content-Type': 'application/json', - }; + const headers = { 'Content-Type': 'application/json' }; const context = new HttpContext(); context.set(BYPASS_ERROR_INTERCEPTOR, true); + return this.http .post(this.dataciteTrackerAddress, payload, { headers, diff --git a/src/app/shared/services/google-file-picker.download.service.ts b/src/app/shared/services/google-file-picker.download.service.ts index 76be19951..0973f17a6 100644 --- a/src/app/shared/services/google-file-picker.download.service.ts +++ b/src/app/shared/services/google-file-picker.download.service.ts @@ -1,7 +1,7 @@ import { Observable, Subscriber } from 'rxjs'; -import { DOCUMENT } from '@angular/common'; -import { Inject, Injectable } from '@angular/core'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; /** * Injectable service to load the Google Picker API script dynamically. @@ -21,7 +21,10 @@ export class GoogleFilePickerDownloadService { * * @param document - The Angular-injected reference to the global `document`. */ - constructor(@Inject(DOCUMENT) private document: Document) {} + constructor( + @Inject(DOCUMENT) private document: Document, + @Inject(PLATFORM_ID) private platformId: string + ) {} /** * Dynamically loads the Google Picker script if it hasn't already been loaded. @@ -59,6 +62,11 @@ export class GoogleFilePickerDownloadService { */ public loadGapiModules(): Observable { return new Observable((observer: Subscriber) => { + if (!isPlatformBrowser(this.platformId) || !window.gapi) { + observer.error('GAPI not available'); + return; + } + window.gapi.load('client:picker', { callback: () => { observer.next(); diff --git a/src/app/shared/services/header-style.service.ts b/src/app/shared/services/header-style.service.ts new file mode 100644 index 000000000..4bf8c70d2 --- /dev/null +++ b/src/app/shared/services/header-style.service.ts @@ -0,0 +1,32 @@ +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { inject, Injectable, PLATFORM_ID } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class HeaderStyleService { + private readonly document = inject(DOCUMENT); + private readonly platformId = inject(PLATFORM_ID); + + applyHeaderStyles(textColor: string, backgroundColor?: string, backgroundImageUrl?: string) { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + const root = this.document.documentElement; + + root.style.setProperty('--header-color', textColor); + root.style.setProperty('--header-background-color', backgroundColor || ''); + root.style.setProperty('--header-background-image-url', `url(${backgroundImageUrl || ''})`); + } + + resetToDefaults() { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + const root = this.document.documentElement; + + root.style.setProperty('--header-color', ''); + root.style.setProperty('--header-background-color', ''); + root.style.setProperty('--header-background-image-url', ''); + } +} diff --git a/src/app/shared/services/meta-tags.service.ts b/src/app/shared/services/meta-tags.service.ts index 8cea2c55d..3bfcb37eb 100644 --- a/src/app/shared/services/meta-tags.service.ts +++ b/src/app/shared/services/meta-tags.service.ts @@ -1,7 +1,7 @@ import { catchError, map, Observable, of, switchMap, tap } from 'rxjs'; -import { DOCUMENT } from '@angular/common'; -import { DestroyRef, effect, Inject, inject, Injectable, signal } from '@angular/core'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { DestroyRef, effect, Inject, inject, Injectable, PLATFORM_ID, signal } from '@angular/core'; import { Meta, MetaDefinition, Title } from '@angular/platform-browser'; import { ENVIRONMENT } from '@core/provider/environment.provider'; @@ -22,6 +22,7 @@ export class MetaTagsService { private readonly metadataRecords: MetadataRecordsService = inject(MetadataRecordsService); private readonly environment = inject(ENVIRONMENT); private readonly prerenderReady = inject(PrerenderReadyService); + private readonly platformId = inject(PLATFORM_ID); get webUrl() { return this.environment.webUrl; @@ -79,6 +80,12 @@ export class MetaTagsService { } clearMetaTags(): void { + if (!isPlatformBrowser(this.platformId)) { + this.areMetaTagsApplied.set(false); + this.prerenderReady.setNotReady(); + return; + } + const elementsToRemove = this.document.querySelectorAll(`.${this.metaTagClass}`); if (elementsToRemove.length === 0) { @@ -282,6 +289,10 @@ export class MetaTagsService { } private dispatchZoteroEvent(): void { + if (!isPlatformBrowser(this.platformId)) { + return; + } + const event = new Event('ZoteroItemUpdated', { bubbles: true, cancelable: true, diff --git a/src/app/shared/services/view-only-link-helper.service.ts b/src/app/shared/services/view-only-link-helper.service.ts new file mode 100644 index 000000000..e600722d6 --- /dev/null +++ b/src/app/shared/services/view-only-link-helper.service.ts @@ -0,0 +1,53 @@ +import { isPlatformBrowser } from '@angular/common'; +import { inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { Router } from '@angular/router'; + +import { WINDOW } from '@core/provider/window.provider'; + +@Injectable({ providedIn: 'root' }) +export class ViewOnlyLinkHelperService { + private readonly platformId = inject(PLATFORM_ID); + private readonly window = inject(WINDOW); + + hasViewOnlyParam(router: Router): boolean { + const currentUrl = router.url; + const routerParams = new URLSearchParams(currentUrl.split('?')[1] || ''); + + if (isPlatformBrowser(this.platformId) && this.window.location?.search) { + const windowParams = new URLSearchParams(this.window.location.search); + return routerParams.has('view_only') || windowParams.has('view_only'); + } + + return routerParams.has('view_only'); + } + + getViewOnlyParam(router?: Router): string | null { + let currentUrl = ''; + + if (router) { + currentUrl = router.url; + } + + const routerParams = new URLSearchParams(currentUrl.split('?')[1] || ''); + + if (isPlatformBrowser(this.platformId) && this.window.location?.search) { + const windowParams = new URLSearchParams(this.window.location.search); + return routerParams.get('view_only') || windowParams.get('view_only'); + } + + return routerParams.get('view_only'); + } + + getViewOnlyParamFromUrl(currentUrl?: string): string | null { + if (!currentUrl) return null; + + const routerParams = new URLSearchParams(currentUrl.split('?')[1] || ''); + + if (isPlatformBrowser(this.platformId) && this.window.location?.search) { + const windowParams = new URLSearchParams(this.window.location.search); + return routerParams.get('view_only') || windowParams.get('view_only'); + } + + return routerParams.get('view_only'); + } +} diff --git a/src/app/shared/services/view-only-links.service.ts b/src/app/shared/services/view-only-links.service.ts index 32da38445..23cc23764 100644 --- a/src/app/shared/services/view-only-links.service.ts +++ b/src/app/shared/services/view-only-links.service.ts @@ -3,7 +3,6 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { JsonApiResponse } from '@shared/models/common/json-api.model'; import { ResourceType } from '../enums/resource-type.enum'; import { ViewOnlyLinksMapper } from '../mappers/view-only-links.mapper'; @@ -11,6 +10,7 @@ import { PaginatedViewOnlyLinksModel } from '../models/view-only-links/view-only import { ViewOnlyLinkJsonApi, ViewOnlyLinksResponseJsonApi, + ViewOnlyLinksResponsesJsonApi, } from '../models/view-only-links/view-only-link-response.model'; import { JsonApiService } from './json-api.service'; @@ -36,8 +36,8 @@ export class ViewOnlyLinksService { const params: Record = { 'embed[]': ['creator', 'nodes'] }; return this.jsonApiService - .get(`${this.apiUrl}/${resourcePath}/${projectId}/view_only_links/`, params) - .pipe(map((response) => ViewOnlyLinksMapper.fromResponse(response, projectId))); + .get(`${this.apiUrl}/${resourcePath}/${projectId}/view_only_links/`, params) + .pipe(map((response) => ViewOnlyLinksMapper.fromResponse(response, projectId, this.environment.webUrl))); } createViewOnlyLink( @@ -50,10 +50,10 @@ export class ViewOnlyLinksService { const params: Record = { 'embed[]': ['creator', 'nodes'] }; return this.jsonApiService - .post< - JsonApiResponse - >(`${this.apiUrl}/${resourcePath}/${projectId}/view_only_links/`, data, params) - .pipe(map((response) => ViewOnlyLinksMapper.fromSingleResponse(response.data, projectId))); + .post(`${this.apiUrl}/${resourcePath}/${projectId}/view_only_links/`, data, params) + .pipe( + map((response) => ViewOnlyLinksMapper.fromSingleResponse(response.data, projectId, this.environment.webUrl)) + ); } deleteLink(projectId: string, resourceType: ResourceType, linkId: string): Observable { diff --git a/src/environments/environment.ts b/src/environments/environment.ts index a7725102f..cbffe140e 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -13,11 +13,11 @@ export const environment = { /** * Base URL of the OSF web application. */ - webUrl: 'https://staging4.osf.io', + webUrl: 'https://staging3.osf.io', /** * Domain URL used for JSON:API v2 services. */ - apiDomainUrl: 'https://api.staging4.osf.io', + apiDomainUrl: 'https://api.staging3.osf.io', /** * Base URL for SHARE discovery search (Trove). */ @@ -25,7 +25,7 @@ export const environment = { /** * URL for the OSF Addons API (v1). */ - addonsApiUrl: 'https://addons.staging4.osf.io/v1', + addonsApiUrl: 'https://addons.staging3.osf.io/v1', /** * API endpoint for funder metadata resolution via Crossref. */ @@ -33,7 +33,7 @@ export const environment = { /** * URL for OSF Central Authentication Service (CAS). */ - casUrl: 'https://accounts.staging4.osf.io', + casUrl: 'https://accounts.staging3.osf.io', /** * Site key used for reCAPTCHA v2 validation in staging. */ diff --git a/src/index.html b/src/index.html index 717c99e2b..ff649bd7f 100644 --- a/src/index.html +++ b/src/index.html @@ -11,9 +11,9 @@ + - diff --git a/src/main.server.ts b/src/main.server.ts new file mode 100644 index 000000000..9a18ee887 --- /dev/null +++ b/src/main.server.ts @@ -0,0 +1,8 @@ +import { bootstrapApplication, BootstrapContext } from '@angular/platform-browser'; + +import { AppComponent } from './app/app.component'; +import { config } from './app/app.config.server'; + +const bootstrap = (context: BootstrapContext) => bootstrapApplication(AppComponent, config, context); + +export default bootstrap; diff --git a/src/main.ts b/src/main.ts index e005d74a2..67d73f19f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,9 +3,7 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { AppComponent } from '@osf/app.component'; import { appConfig } from '@osf/app.config'; -bootstrapApplication(AppComponent, { - providers: [...appConfig.providers], -}).catch((err) => +bootstrapApplication(AppComponent, appConfig).catch((err) => // eslint-disable-next-line no-console console.error(err) ); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 000000000..49313061e --- /dev/null +++ b/src/server.ts @@ -0,0 +1,66 @@ +import { + AngularNodeAppEngine, + createNodeRequestHandler, + isMainModule, + writeResponseToNodeResponse, +} from '@angular/ssr/node'; + +import express from 'express'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const serverDistFolder = dirname(fileURLToPath(import.meta.url)); +const browserDistFolder = resolve(serverDistFolder, '../browser'); + +const app = express(); +const angularApp = new AngularNodeAppEngine(); + +/** + * Example Express Rest API endpoints can be defined here. + * Uncomment and define endpoints as necessary. + * + * Example: + * ```ts + * app.get('/api/**', (req, res) => { + * // Handle API request + * }); + * ``` + */ + +/** + * Serve static files from /browser + */ +app.use( + express.static(browserDistFolder, { + maxAge: '1y', + index: false, + redirect: false, + }) +); + +/** + * Handle all other requests by rendering the Angular application. + */ +app.use('/**', (req, res, next) => { + angularApp + .handle(req) + .then((response) => (response ? writeResponseToNodeResponse(response, res) : next())) + .catch(next); +}); + +/** + * Start the server if this module is the main entry point. + * The server listens on the port defined by the `PORT` environment variable, or defaults to 4000. + */ +if (isMainModule(import.meta.url)) { + const port = process.env['PORT'] || 4000; + app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`Node Express server listening on http://localhost:${port}`); + }); +} + +/** + * Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions. + */ +export const reqHandler = createNodeRequestHandler(app); diff --git a/tsconfig.app.json b/tsconfig.app.json index c4a1c3c28..0dba49a02 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -4,9 +4,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", - "types": [], + "types": ["node"], "typeRoots": ["src/@types", "node_modules/@types"] }, - "files": ["src/main.ts"], + "files": ["src/main.ts", "src/main.server.ts", "src/server.ts"], "include": ["src/**/*.d.ts"] }