diff --git a/.eslintrc.json b/.eslintrc.json index 5fb4c12171..888c968b5c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -293,7 +293,8 @@ ], "rules": { // Custom DSpace Angular rules - "dspace-angular-html/themed-component-usages": "error" + "dspace-angular-html/themed-component-usages": "error", + "dspace-angular-html/no-disabled-attribute-on-button": "error" } }, { diff --git a/config/config.example.yml b/config/config.example.yml index 1c130a26cf..ab8c56e166 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -24,7 +24,30 @@ ssr: # disabled (false) by default to boost server performance at the expense of loading smoothness. inlineCriticalCss: false # Path prefixes to enable SSR for. By default these are limited to paths of primary DSpace objects. - paths: [ '/home', '/items/', '/entities/', '/collections/', '/communities/', '/bitstream/', '/bitstreams/', '/handle/' ] + # NOTE: The "/handle/" path ensures Handle redirects work via SSR. The "/reload/" path ensures + # hard refreshes (e.g. after login) trigger SSR while fully reloading the page. + paths: [ '/home', '/items/', '/entities/', '/collections/', '/communities/', '/bitstream/', '/bitstreams/', '/handle/', '/reload/' ] + # Whether to enable rendering of Search component on SSR. + # If set to true the component will be included in the HTML returned from the server side rendering. + # If set to false the component will not be included in the HTML returned from the server side rendering. + enableSearchComponent: false + # Whether to enable rendering of Browse component on SSR. + # If set to true the component will be included in the HTML returned from the server side rendering. + # If set to false the component will not be included in the HTML returned from the server side rendering. + enableBrowseComponent: false + # Enable state transfer from the server-side application to the client-side application. + # Defaults to true. + # Note: When using an external application cache layer, it's recommended not to transfer the state to avoid caching it. + # Disabling it ensures that dynamic state information is not inadvertently cached, which can improve security and + # ensure that users always use the most up-to-date state. + transferState: true + # When a different REST base URL is used for the server-side application, the generated state contains references to + # REST resources with the internal URL configured. By default, these internal URLs are replaced with public URLs. + # Disable this setting to avoid URL replacement during SSR. In this the state is not transferred to avoid security issues. + replaceRestUrl: true + # Enable request performance profiling data collection and printing the results in the server console. + # Defaults to false. Enabling in production is NOT recommended + #enablePerformanceProfiler: false # The REST API server settings # NOTE: these settings define which (publicly available) REST API to use. They are usually @@ -35,6 +58,9 @@ rest: port: 443 # NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript nameSpace: /server + # Provide a different REST url to be used during SSR execution. It must contain the whole url including protocol, server port and + # server namespace (uncomment to use it). + #ssrBaseUrl: http://localhost:8080/server # Caching settings cache: @@ -450,6 +476,12 @@ search: enabled: false # List of filters to enable in "Advanced Search" dropdown filter: [ 'title', 'author', 'subject', 'entityType' ] + # + # Number used to render n UI elements called loading skeletons that act as placeholders. + # These elements indicate that some content will be loaded in their stead. + # Since we don't know how many filters will be loaded before we receive a response from the server we use this parameter for the skeletons count. + # e.g. If we set 5 then 5 loading skeletons will be visualized before the actual filters are retrieved. + defaultFiltersCount: 5 # Notify metrics diff --git a/docs/lint/html/index.md b/docs/lint/html/index.md index 15d693843c..e134e1070f 100644 --- a/docs/lint/html/index.md +++ b/docs/lint/html/index.md @@ -2,3 +2,4 @@ _______ - [`dspace-angular-html/themed-component-usages`](./rules/themed-component-usages.md): Themeable components should be used via the selector of their `ThemedComponent` wrapper class +- [`dspace-angular-html/no-disabled-attribute-on-button`](./rules/no-disabled-attribute-on-button.md): Buttons should use the `dsBtnDisabled` directive instead of the HTML `disabled` attribute. diff --git a/docs/lint/html/rules/no-disabled-attribute-on-button.md b/docs/lint/html/rules/no-disabled-attribute-on-button.md new file mode 100644 index 0000000000..d9d39ce82c --- /dev/null +++ b/docs/lint/html/rules/no-disabled-attribute-on-button.md @@ -0,0 +1,78 @@ +[DSpace ESLint plugins](../../../../lint/README.md) > [HTML rules](../index.md) > `dspace-angular-html/no-disabled-attribute-on-button` +_______ + +Buttons should use the `dsBtnDisabled` directive instead of the HTML `disabled` attribute. + This should be done to ensure that users with a screen reader are able to understand that the a button button is present, and that it is disabled. + The native html disabled attribute does not allow users to navigate to the button by keyboard, and thus they have no way of knowing that the button is present. + +_______ + +[Source code](../../../../lint/src/rules/html/no-disabled-attribute-on-button.ts) + +### Examples + + +#### Valid code + +##### should use [dsBtnDisabled] in HTML templates + +```html +Submit +``` + +##### disabled attribute is still valid on non-button elements + +```html + +``` + +##### [disabled] attribute is still valid on non-button elements + +```html + +``` + +##### angular dynamic attributes that use disabled are still valid + +```html +Submit +``` + + + + +#### Invalid code & automatic fixes + +##### should not use disabled attribute in HTML templates + +```html +Submit +``` +Will produce the following error(s): +``` +Buttons should use the `dsBtnDisabled` directive instead of the `disabled` attribute. +``` + +Result of `yarn lint --fix`: +```html +Submit +``` + + +##### should not use [disabled] attribute in HTML templates + +```html +Submit +``` +Will produce the following error(s): +``` +Buttons should use the `dsBtnDisabled` directive instead of the `disabled` attribute. +``` + +Result of `yarn lint --fix`: +```html +Submit +``` + + + diff --git a/lint/src/rules/html/index.ts b/lint/src/rules/html/index.ts index 7c1370ae2d..3d425c3ad4 100644 --- a/lint/src/rules/html/index.ts +++ b/lint/src/rules/html/index.ts @@ -10,10 +10,13 @@ import { bundle, RuleExports, } from '../../util/structure'; +import * as noDisabledAttributeOnButton from './no-disabled-attribute-on-button'; import * as themedComponentUsages from './themed-component-usages'; const index = [ themedComponentUsages, + noDisabledAttributeOnButton, + ] as unknown as RuleExports[]; export = { diff --git a/lint/src/rules/html/no-disabled-attribute-on-button.ts b/lint/src/rules/html/no-disabled-attribute-on-button.ts new file mode 100644 index 0000000000..bf1a72d70d --- /dev/null +++ b/lint/src/rules/html/no-disabled-attribute-on-button.ts @@ -0,0 +1,147 @@ +import { + TmplAstBoundAttribute, + TmplAstTextAttribute, +} from '@angular-eslint/bundled-angular-compiler'; +import { TemplateParserServices } from '@angular-eslint/utils'; +import { + ESLintUtils, + TSESLint, +} from '@typescript-eslint/utils'; + +import { + DSpaceESLintRuleInfo, + NamedTests, +} from '../../util/structure'; +import { getSourceCode } from '../../util/typescript'; + +export enum Message { + USE_DSBTN_DISABLED = 'mustUseDsBtnDisabled', +} + +export const info = { + name: 'no-disabled-attribute-on-button', + meta: { + docs: { + description: `Buttons should use the \`dsBtnDisabled\` directive instead of the HTML \`disabled\` attribute. + This should be done to ensure that users with a screen reader are able to understand that the a button button is present, and that it is disabled. + The native html disabled attribute does not allow users to navigate to the button by keyboard, and thus they have no way of knowing that the button is present.`, + }, + type: 'problem', + fixable: 'code', + schema: [], + messages: { + [Message.USE_DSBTN_DISABLED]: 'Buttons should use the `dsBtnDisabled` directive instead of the `disabled` attribute.', + }, + }, + defaultOptions: [], +} as DSpaceESLintRuleInfo; + +export const rule = ESLintUtils.RuleCreator.withoutDocs({ + ...info, + create(context: TSESLint.RuleContext) { + const parserServices = getSourceCode(context).parserServices as TemplateParserServices; + + /** + * Some dynamic angular inputs will have disabled as name because of how Angular handles this internally (e.g [class.disabled]="isDisabled") + * But these aren't actually the disabled attribute we're looking for, we can determine this by checking the details of the keySpan + */ + function isOtherAttributeDisabled(node: TmplAstBoundAttribute | TmplAstTextAttribute): boolean { + // if the details are not null, and the details are not 'disabled', then it's not the disabled attribute we're looking for + return node.keySpan?.details !== null && node.keySpan?.details !== 'disabled'; + } + + /** + * Replace the disabled text with [dsBtnDisabled] in the template + */ + function replaceDisabledText(text: string ): string { + const hasBrackets = text.includes('[') && text.includes(']'); + const newDisabledText = hasBrackets ? 'dsBtnDisabled' : '[dsBtnDisabled]="true"'; + return text.replace('disabled', newDisabledText); + } + + function inputIsChildOfButton(node: any): boolean { + return (node.parent?.tagName === 'button' || node.parent?.name === 'button'); + } + + function reportAndFix(node: TmplAstBoundAttribute | TmplAstTextAttribute) { + if (!inputIsChildOfButton(node) || isOtherAttributeDisabled(node)) { + return; + } + + const sourceSpan = node.sourceSpan; + context.report({ + messageId: Message.USE_DSBTN_DISABLED, + loc: parserServices.convertNodeSourceSpanToLoc(sourceSpan), + fix(fixer) { + const templateText = sourceSpan.start.file.content; + const disabledText = templateText.slice(sourceSpan.start.offset, sourceSpan.end.offset); + const newText = replaceDisabledText(disabledText); + return fixer.replaceTextRange([sourceSpan.start.offset, sourceSpan.end.offset], newText); + }, + }); + } + + return { + 'BoundAttribute[name="disabled"]'(node: TmplAstBoundAttribute) { + reportAndFix(node); + }, + 'TextAttribute[name="disabled"]'(node: TmplAstTextAttribute) { + reportAndFix(node); + }, + }; + }, +}); + +export const tests = { + plugin: info.name, + valid: [ + { + name: 'should use [dsBtnDisabled] in HTML templates', + code: ` +Submit + `, + }, + { + name: 'disabled attribute is still valid on non-button elements', + code: ` + + `, + }, + { + name: '[disabled] attribute is still valid on non-button elements', + code: ` + + `, + }, + { + name: 'angular dynamic attributes that use disabled are still valid', + code: ` +Submit + `, + }, + ], + invalid: [ + { + name: 'should not use disabled attribute in HTML templates', + code: ` +Submit + `, + errors: [{ messageId: Message.USE_DSBTN_DISABLED }], + output: ` +Submit + `, + }, + { + name: 'should not use [disabled] attribute in HTML templates', + code: ` +Submit + `, + errors: [{ messageId: Message.USE_DSBTN_DISABLED }], + output: ` +Submit + `, + }, + ], +} as NamedTests; + +export default rule; diff --git a/package-lock.json b/package-lock.json index 904bcc5c74..b5ad2f6bdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@angular/platform-server": "^17.3.12", "@angular/router": "^17.3.12", "@angular/ssr": "^17.3.11", - "@babel/runtime": "7.26.0", + "@babel/runtime": "7.26.7", "@kolkov/ngx-gallery": "^2.0.1", "@ng-bootstrap/ng-bootstrap": "^11.0.0", "@ng-dynamic-forms/core": "^16.0.0", @@ -39,27 +39,27 @@ "colors": "^1.4.0", "compression": "^1.7.5", "cookie-parser": "1.4.7", - "core-js": "^3.39.0", + "core-js": "^3.40.0", "date-fns": "^2.29.3", "date-fns-tz": "^1.3.7", "deepmerge": "^4.3.1", "ejs": "^3.1.10", - "express": "^4.21.1", + "express": "^4.21.2", "express-rate-limit": "^5.1.3", "fast-json-patch": "^3.1.1", "filesize": "^6.1.0", "http-proxy-middleware": "^2.0.7", "http-terminator": "^3.2.0", - "isbot": "^5.1.21", + "isbot": "^5.1.22", "js-cookie": "2.2.1", "js-yaml": "^4.1.0", "json5": "^2.2.3", - "jsonschema": "1.4.1", + "jsonschema": "1.5.0", "jwt-decode": "^3.1.2", "lodash": "^4.17.21", "lru-cache": "^7.14.1", "markdown-it": "^13.0.1", - "mirador": "^3.4.2", + "mirador": "^3.4.3", "mirador-dl-plugin": "^0.13.0", "mirador-share-plugin": "^0.16.0", "morgan": "^1.10.0", @@ -67,6 +67,7 @@ "ng2-nouislider": "^2.0.0", "ngx-infinite-scroll": "^16.0.0", "ngx-pagination": "6.0.3", + "ngx-skeleton-loader": "^9.0.0", "ngx-ui-switch": "^14.1.0", "nouislider": "^15.7.1", "orejime": "^2.3.1", @@ -99,7 +100,7 @@ "@types/grecaptcha": "^3.0.9", "@types/jasmine": "~3.6.0", "@types/js-cookie": "2.2.6", - "@types/lodash": "^4.17.14", + "@types/lodash": "^4.17.15", "@types/node": "^14.14.9", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", @@ -110,7 +111,7 @@ "copy-webpack-plugin": "^6.4.1", "cross-env": "^7.0.3", "cypress": "^13.17.0", - "cypress-axe": "^1.5.0", + "cypress-axe": "^1.6.0", "deep-freeze": "0.0.1", "eslint": "^8.39.0", "eslint-plugin-deprecation": "^1.4.1", @@ -119,7 +120,7 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-import-newlines": "^1.3.1", "eslint-plugin-jsdoc": "^45.0.0", - "eslint-plugin-jsonc": "^2.18.2", + "eslint-plugin-jsonc": "^2.19.1", "eslint-plugin-lodash": "^7.4.0", "eslint-plugin-rxjs": "^5.0.3", "eslint-plugin-simple-import-sort": "^10.0.0", @@ -137,12 +138,12 @@ "ng-mocks": "^14.13.2", "ngx-mask": "14.2.4", "nodemon": "^2.0.22", - "postcss": "^8.4", + "postcss": "^8.5", "postcss-import": "^14.0.0", "postcss-loader": "^4.0.3", "postcss-preset-env": "^7.4.2", "rimraf": "^3.0.2", - "sass": "~1.83.1", + "sass": "~1.84.0", "sass-loader": "^12.6.0", "sass-resources-loader": "^2.2.5", "ts-node": "^8.10.2", @@ -3868,9 +3869,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", - "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", + "integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==", + "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -6897,10 +6899,11 @@ "dev": true }, "node_modules/@types/lodash": { - "version": "4.17.14", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.14.tgz", - "integrity": "sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==", - "dev": true + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==", + "dev": true, + "license": "MIT" }, "node_modules/@types/mime": { "version": "1.3.5", @@ -9920,9 +9923,9 @@ } }, "node_modules/core-js": { - "version": "3.39.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz", - "integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==", + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.40.0.tgz", + "integrity": "sha512-7vsMc/Lty6AGnn7uFpYT56QesI5D2Y/UkgKounk87OP9Z2H9Z8kj6jzcSGAxFmUtDOS0ntK6lbQz+Nsa0Jj6mQ==", "hasInstallScript": true, "funding": { "type": "opencollective", @@ -10080,10 +10083,11 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -10328,16 +10332,17 @@ } }, "node_modules/cypress-axe": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/cypress-axe/-/cypress-axe-1.5.0.tgz", - "integrity": "sha512-Hy/owCjfj+25KMsecvDgo4fC/781ccL+e8p+UUYoadGVM2ogZF9XIKbiM6KI8Y3cEaSreymdD6ZzccbI2bY0lQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/cypress-axe/-/cypress-axe-1.6.0.tgz", + "integrity": "sha512-C/ij50G8eebBrl/WsGT7E+T/SFyIsRZ3Epx9cRTLrPL9Y1GcxlQGFoAVdtSFWRrHSCWXq9HC6iJQMaI89O9yvQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, "peerDependencies": { "axe-core": "^3 || ^4", - "cypress": "^10 || ^11 || ^12 || ^13" + "cypress": "^10 || ^11 || ^12 || ^13 || ^14" } }, "node_modules/cypress/node_modules/ansi-styles": { @@ -11984,10 +11989,11 @@ } }, "node_modules/eslint-plugin-jsonc": { - "version": "2.18.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsonc/-/eslint-plugin-jsonc-2.18.2.tgz", - "integrity": "sha512-SDhJiSsWt3nItl/UuIv+ti4g3m4gpGkmnUJS9UWR3TrpyNsIcnJoBRD7Kof6cM4Rk3L0wrmY5Tm3z7ZPjR2uGg==", + "version": "2.19.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsonc/-/eslint-plugin-jsonc-2.19.1.tgz", + "integrity": "sha512-MmlAOaZK1+Lg7YoCZPGRjb88ZjT+ct/KTsvcsbZdBm+w8WMzGx+XEmexk0m40P1WV9G2rFV7X3klyRGRpFXEjA==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "eslint-compat-utils": "^0.6.0", @@ -12486,9 +12492,9 @@ "dev": true }, "node_modules/express": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", - "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -12509,7 +12515,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -12524,6 +12530,10 @@ }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express-rate-limit": { @@ -14474,9 +14484,10 @@ } }, "node_modules/isbot": { - "version": "5.1.21", - "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.21.tgz", - "integrity": "sha512-0q3naRVpENL0ReKHeNcwn/G7BDynp0DqZUckKyFtM9+hmpnPqgm8+8wbjiVZ0XNhq1wPQV28/Pb8Snh5adeUHA==", + "version": "5.1.22", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.22.tgz", + "integrity": "sha512-RqCFY3cJy3c2y1I+rMn81cfzAR4XJwfPBC+M8kffUjbPzxApzyyv7Tbm1C/gXXq2dSCuD238pKFEWlQMTWsTFw==", + "license": "Unlicense", "engines": { "node": ">=18" } @@ -15016,9 +15027,9 @@ ] }, "node_modules/jsonschema": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.4.1.tgz", - "integrity": "sha512-S6cATIPVv1z0IlxdN+zUk5EPjkGCdnhN4wVSBlvoUO1tOLJootbo9CquNJmbIh4yikWHiUedhRYrNPn1arpEmQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.5.0.tgz", + "integrity": "sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw==", "engines": { "node": "*" } @@ -16618,9 +16629,9 @@ } }, "node_modules/mirador": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/mirador/-/mirador-3.4.2.tgz", - "integrity": "sha512-Gd7G4NkXq6/qD/De5soYspSo9VykAzrGFunKqUI3x9WShoZP23pYIEPoC/96tvfk3KMv+UbAUxDp99Xeo7vnVQ==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/mirador/-/mirador-3.4.3.tgz", + "integrity": "sha512-yHoug0MHy4e9apykbbBhK+4CmbZS94zMxmugw2E2VX6iB0b2PKKY0JfYr/QfXh9P29YnWAbymaXJVpgbHVpTVw==", "dependencies": { "@material-ui/core": "^4.12.3", "@material-ui/icons": "^4.9.1", @@ -16941,6 +16952,19 @@ "@angular/core": ">=13.0.0" } }, + "node_modules/ngx-skeleton-loader": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/ngx-skeleton-loader/-/ngx-skeleton-loader-9.0.0.tgz", + "integrity": "sha512-aO4/V6oGdZGNcTjasTg/fwzJJYl/ZmNKgCukOEQdUK3GSFOZtB/3GGULMJuZ939hk3Hzqh1OBiLfIM1SqTfhqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/common": ">=16.0.0", + "@angular/core": ">=16.0.0" + } + }, "node_modules/ngx-ui-switch": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/ngx-ui-switch/-/ngx-ui-switch-14.1.0.tgz", @@ -18188,9 +18212,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" }, "node_modules/path-type": { "version": "4.0.0", @@ -18408,9 +18432,9 @@ } }, "node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", + "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", "funding": [ { "type": "opencollective", @@ -18426,7 +18450,7 @@ } ], "dependencies": { - "nanoid": "^3.3.7", + "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -20477,10 +20501,11 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sass": { - "version": "1.83.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.83.1.tgz", - "integrity": "sha512-EVJbDaEs4Rr3F0glJzFSOvtg2/oy2V/YrGFPqPY24UqcLDWcI9ZY5sN+qyO3c/QCZwzgfirvhXvINiJCE/OLcA==", + "version": "1.84.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.84.0.tgz", + "integrity": "sha512-XDAbhEPJRxi7H0SxrnOpiXFQoUJHwkR2u3Zc4el+fK/Tt5Hpzw5kkQ59qVDfvdaUq6gCrEZIbySFBM2T9DNKHg==", "dev": true, + "license": "MIT", "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", diff --git a/package.json b/package.json index 482a400bcb..fb6cf658de 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "@angular/platform-server": "^17.3.12", "@angular/router": "^17.3.12", "@angular/ssr": "^17.3.11", - "@babel/runtime": "7.26.0", + "@babel/runtime": "7.26.7", "@kolkov/ngx-gallery": "^2.0.1", "@ng-bootstrap/ng-bootstrap": "^11.0.0", "@ng-dynamic-forms/core": "^16.0.0", @@ -126,27 +126,27 @@ "colors": "^1.4.0", "compression": "^1.7.5", "cookie-parser": "1.4.7", - "core-js": "^3.39.0", + "core-js": "^3.40.0", "date-fns": "^2.29.3", "date-fns-tz": "^1.3.7", "deepmerge": "^4.3.1", "ejs": "^3.1.10", - "express": "^4.21.1", + "express": "^4.21.2", "express-rate-limit": "^5.1.3", "fast-json-patch": "^3.1.1", "filesize": "^6.1.0", "http-proxy-middleware": "^2.0.7", "http-terminator": "^3.2.0", - "isbot": "^5.1.21", + "isbot": "^5.1.22", "js-cookie": "2.2.1", "js-yaml": "^4.1.0", "json5": "^2.2.3", - "jsonschema": "1.4.1", + "jsonschema": "1.5.0", "jwt-decode": "^3.1.2", "lodash": "^4.17.21", "lru-cache": "^7.14.1", "markdown-it": "^13.0.1", - "mirador": "^3.4.2", + "mirador": "^3.4.3", "mirador-dl-plugin": "^0.13.0", "mirador-share-plugin": "^0.16.0", "morgan": "^1.10.0", @@ -154,6 +154,7 @@ "ng2-nouislider": "^2.0.0", "ngx-infinite-scroll": "^16.0.0", "ngx-pagination": "6.0.3", + "ngx-skeleton-loader": "^9.0.0", "ngx-ui-switch": "^14.1.0", "nouislider": "^15.7.1", "orejime": "^2.3.1", @@ -186,7 +187,7 @@ "@types/grecaptcha": "^3.0.9", "@types/jasmine": "~3.6.0", "@types/js-cookie": "2.2.6", - "@types/lodash": "^4.17.14", + "@types/lodash": "^4.17.15", "@types/node": "^14.14.9", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", @@ -197,7 +198,7 @@ "copy-webpack-plugin": "^6.4.1", "cross-env": "^7.0.3", "cypress": "^13.17.0", - "cypress-axe": "^1.5.0", + "cypress-axe": "^1.6.0", "deep-freeze": "0.0.1", "eslint": "^8.39.0", "eslint-plugin-deprecation": "^1.4.1", @@ -206,7 +207,7 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-import-newlines": "^1.3.1", "eslint-plugin-jsdoc": "^45.0.0", - "eslint-plugin-jsonc": "^2.18.2", + "eslint-plugin-jsonc": "^2.19.1", "eslint-plugin-lodash": "^7.4.0", "eslint-plugin-rxjs": "^5.0.3", "eslint-plugin-simple-import-sort": "^10.0.0", @@ -224,12 +225,12 @@ "ng-mocks": "^14.13.2", "ngx-mask": "14.2.4", "nodemon": "^2.0.22", - "postcss": "^8.4", + "postcss": "^8.5", "postcss-import": "^14.0.0", "postcss-loader": "^4.0.3", "postcss-preset-env": "^7.4.2", "rimraf": "^3.0.2", - "sass": "~1.83.1", + "sass": "~1.84.0", "sass-loader": "^12.6.0", "sass-resources-loader": "^2.2.5", "ts-node": "^8.10.2", diff --git a/server.ts b/server.ts index 1276621e9d..499fe5c4ff 100644 --- a/server.ts +++ b/server.ts @@ -81,6 +81,9 @@ let anonymousCache: LRU; // extend environment with app config for server extendEnvironmentWithAppConfig(environment, appConfig); +// The REST server base URL +const REST_BASE_URL = environment.rest.ssrBaseUrl || environment.rest.baseUrl; + // The Express app is exported so that it can be used by serverless Functions. export function app() { @@ -156,7 +159,7 @@ export function app() { * Proxy the sitemaps */ router.use('/sitemap**', createProxyMiddleware({ - target: `${environment.rest.baseUrl}/sitemaps`, + target: `${REST_BASE_URL}/sitemaps`, pathRewrite: path => path.replace(environment.ui.nameSpace, '/'), changeOrigin: true, })); @@ -165,7 +168,7 @@ export function app() { * Proxy the linksets */ router.use('/signposting**', createProxyMiddleware({ - target: `${environment.rest.baseUrl}`, + target: `${REST_BASE_URL}`, pathRewrite: path => path.replace(environment.ui.nameSpace, '/'), changeOrigin: true, })); @@ -266,6 +269,11 @@ function serverSideRender(req, res, next, sendToUser: boolean = true) { }) .then((html) => { if (hasValue(html)) { + // Replace REST URL with UI URL + if (environment.ssr.replaceRestUrl && REST_BASE_URL !== environment.rest.baseUrl) { + html = html.replace(new RegExp(REST_BASE_URL, 'g'), environment.rest.baseUrl); + } + // save server side rendered page to cache (if any are enabled) saveToCache(req, html); if (sendToUser) { @@ -623,7 +631,7 @@ function start() { * The callback function to serve health check requests */ function healthCheck(req, res) { - const baseUrl = `${environment.rest.baseUrl}${environment.actuators.endpointPath}`; + const baseUrl = `${REST_BASE_URL}${environment.actuators.endpointPath}`; axios.get(baseUrl) .then((response) => { res.status(response.status).send(response.data); diff --git a/src/app/access-control/bulk-access/bulk-access.component.html b/src/app/access-control/bulk-access/bulk-access.component.html index c164cc5c31..cda6b805bc 100644 --- a/src/app/access-control/bulk-access/bulk-access.component.html +++ b/src/app/access-control/bulk-access/bulk-access.component.html @@ -10,7 +10,7 @@ {{ 'access-control-cancel' | translate }} - + {{ 'access-control-execute' | translate }} diff --git a/src/app/access-control/bulk-access/bulk-access.component.ts b/src/app/access-control/bulk-access/bulk-access.component.ts index bd8e893b59..a1608d27d0 100644 --- a/src/app/access-control/bulk-access/bulk-access.component.ts +++ b/src/app/access-control/bulk-access/bulk-access.component.ts @@ -14,6 +14,7 @@ import { } from 'rxjs/operators'; import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer'; import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; import { BulkAccessBrowseComponent } from './browse/bulk-access-browse.component'; @@ -27,6 +28,7 @@ import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.com TranslateModule, BulkAccessSettingsComponent, BulkAccessBrowseComponent, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts b/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts index c636b72d56..cd7441022c 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts @@ -42,6 +42,7 @@ import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { EPerson } from '../../core/eperson/models/eperson.model'; import { PaginationService } from '../../core/pagination/pagination.service'; import { PageInfo } from '../../core/shared/page-info.model'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { FormBuilderService } from '../../shared/form/builder/form-builder.service'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { getMockFormBuilderService } from '../../shared/mocks/form-builder-service.mock'; @@ -151,7 +152,7 @@ describe('EPeopleRegistryComponent', () => { paginationService = new PaginationServiceStub(); TestBed.configureTestingModule({ imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, RouterTestingModule.withRoutes([]), - TranslateModule.forRoot(), EPeopleRegistryComponent], + TranslateModule.forRoot(), EPeopleRegistryComponent, BtnDisabledDirective], providers: [ { provide: EPersonDataService, useValue: ePersonDataServiceStub }, { provide: NotificationsService, useValue: new NotificationsServiceStub() }, diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html index ae1046be45..3aa4d66b05 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html @@ -25,7 +25,7 @@ - + {{'admin.access-control.epeople.actions.reset' | translate}} diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts index e61f95d6e5..c5c9407377 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts @@ -43,6 +43,7 @@ import { GroupDataService } from '../../../core/eperson/group-data.service'; import { EPerson } from '../../../core/eperson/models/eperson.model'; import { PaginationService } from '../../../core/pagination/pagination.service'; import { PageInfo } from '../../../core/shared/page-info.model'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; import { FormComponent } from '../../../shared/form/form.component'; import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; @@ -221,7 +222,7 @@ describe('EPersonFormComponent', () => { route = new ActivatedRouteStub(); router = new RouterStub(); TestBed.configureTestingModule({ - imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, + imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BtnDisabledDirective, BrowserModule, RouterModule.forRoot([]), TranslateModule.forRoot(), EPersonFormComponent, @@ -516,7 +517,8 @@ describe('EPersonFormComponent', () => { // ePersonDataServiceStub.activeEPerson = eperson; spyOn(component.epersonService, 'deleteEPerson').and.returnValue(createSuccessfulRemoteDataObject$('No Content', 204)); const deleteButton = fixture.debugElement.query(By.css('.delete-button')); - expect(deleteButton.nativeElement.disabled).toBe(false); + expect(deleteButton.nativeElement.getAttribute('aria-disabled')).toBeNull(); + expect(deleteButton.nativeElement.classList.contains('disabled')).toBeFalse(); deleteButton.triggerEventHandler('click', null); fixture.detectChanges(); expect(component.epersonService.deleteEPerson).toHaveBeenCalledWith(eperson); diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts index 942481c4fd..c7b57c986d 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -65,6 +65,7 @@ import { import { PageInfo } from '../../../core/shared/page-info.model'; import { Registration } from '../../../core/shared/registration.model'; import { TYPE_REQUEST_FORGOT } from '../../../register-email-form/register-email-form.component'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component'; import { hasValue } from '../../../shared/empty.util'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; @@ -92,6 +93,7 @@ import { ValidateEmailNotTaken } from './validators/email-taken.validator'; PaginationComponent, RouterLink, HasNoValuePipe, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.html b/src/app/access-control/group-registry/group-form/members-list/members-list.component.html index 591d80bdd5..d289d036bb 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.html +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.html @@ -35,14 +35,14 @@ @@ -122,7 +122,7 @@ diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts index 9b123ae447..22934394c8 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts @@ -54,6 +54,7 @@ import { getFirstCompletedRemoteData, getRemoteDataPayload, } from '../../../../core/shared/operators'; +import { BtnDisabledDirective } from '../../../../shared/btn-disabled.directive'; import { ContextHelpDirective } from '../../../../shared/context-help.directive'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; @@ -113,6 +114,7 @@ export interface EPersonListActionConfig { RouterLink, NgClass, NgForOf, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/access-control/group-registry/groups-registry.component.html b/src/app/access-control/group-registry/groups-registry.component.html index c2d998d954..4150e2560c 100644 --- a/src/app/access-control/group-registry/groups-registry.component.html +++ b/src/app/access-control/group-registry/groups-registry.component.html @@ -69,7 +69,7 @@ { imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, TranslateModule.forRoot(), GroupsRegistryComponent, + BtnDisabledDirective, ], providers: [GroupsRegistryComponent, { provide: DSONameService, useValue: new DSONameServiceMock() }, @@ -278,7 +280,8 @@ describe('GroupsRegistryComponent', () => { const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit')); expect(editButtonsFound.length).toEqual(2); editButtonsFound.forEach((editButtonFound) => { - expect(editButtonFound.nativeElement.disabled).toBeFalse(); + expect(editButtonFound.nativeElement.getAttribute('aria-disabled')).toBeNull(); + expect(editButtonFound.nativeElement.classList.contains('disabled')).toBeFalse(); }); }); @@ -312,7 +315,8 @@ describe('GroupsRegistryComponent', () => { const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit')); expect(editButtonsFound.length).toEqual(2); editButtonsFound.forEach((editButtonFound) => { - expect(editButtonFound.nativeElement.disabled).toBeFalse(); + expect(editButtonFound.nativeElement.getAttribute('aria-disabled')).toBeNull(); + expect(editButtonFound.nativeElement.classList.contains('disabled')).toBeFalse(); }); }); }); @@ -331,7 +335,8 @@ describe('GroupsRegistryComponent', () => { const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit')); expect(editButtonsFound.length).toEqual(2); editButtonsFound.forEach((editButtonFound) => { - expect(editButtonFound.nativeElement.disabled).toBeTrue(); + expect(editButtonFound.nativeElement.getAttribute('aria-disabled')).toBe('true'); + expect(editButtonFound.nativeElement.classList.contains('disabled')).toBeTrue(); }); }); }); diff --git a/src/app/access-control/group-registry/groups-registry.component.ts b/src/app/access-control/group-registry/groups-registry.component.ts index dec3dc955d..07b7317a1e 100644 --- a/src/app/access-control/group-registry/groups-registry.component.ts +++ b/src/app/access-control/group-registry/groups-registry.component.ts @@ -62,6 +62,7 @@ import { getRemoteDataPayload, } from '../../core/shared/operators'; import { PageInfo } from '../../core/shared/page-info.model'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { hasValue } from '../../shared/empty.util'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; @@ -84,6 +85,7 @@ import { followLink } from '../../shared/utils/follow-link-config.model'; NgSwitchCase, NgbTooltipModule, NgForOf, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items.component.html b/src/app/admin/admin-reports/filtered-items/filtered-items.component.html index 4b6679bdbc..a765c4a190 100644 --- a/src/app/admin/admin-reports/filtered-items/filtered-items.component.html +++ b/src/app/admin/admin-reports/filtered-items/filtered-items.component.html @@ -54,7 +54,7 @@ + - – + – @@ -158,8 +158,8 @@ {{'admin.reports.commons.page' | translate}} {{ currentPage + 1 }} {{'admin.reports.commons.of' | translate}} {{ pageCount() }} - {{'admin.reports.commons.previous-page' | translate}} - {{'admin.reports.commons.next-page' | translate}} + {{'admin.reports.commons.previous-page' | translate}} + {{'admin.reports.commons.next-page' | translate}} diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts b/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts index 4cb93aa9be..04ee4894ec 100644 --- a/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts +++ b/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts @@ -43,6 +43,7 @@ import { getFirstSucceededRemoteListPayload } from 'src/app/core/shared/operator import { isEmpty } from 'src/app/shared/empty.util'; import { environment } from 'src/environments/environment'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { FiltersComponent } from '../filters-section/filters-section.component'; import { FilteredItems } from './filtered-items-model'; import { OptionVO } from './option-vo.model'; @@ -64,6 +65,7 @@ import { QueryPredicate } from './query-predicate.model'; NgIf, NgForOf, FiltersComponent, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts index 9bd90ea7f1..37a4d0c30f 100644 --- a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts +++ b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts @@ -85,7 +85,7 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC this.sidebarActiveBg$ = this.variableService.getVariable('--ds-admin-sidebar-active-bg'); this.isSidebarCollapsed$ = this.menuService.isMenuCollapsed(this.menuID); this.isSidebarPreviewCollapsed$ = this.menuService.isMenuPreviewCollapsed(this.menuID); - this.isExpanded$ = combineLatestObservable([this.active, this.isSidebarCollapsed$, this.isSidebarPreviewCollapsed$]).pipe( + this.isExpanded$ = combineLatestObservable([this.active$, this.isSidebarCollapsed$, this.isSidebarPreviewCollapsed$]).pipe( map(([active, sidebarCollapsed, sidebarPreviewCollapsed]) => (active && (!sidebarCollapsed || !sidebarPreviewCollapsed))), ); } diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 61a04c8adb..77b29206cb 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -54,6 +54,7 @@ import { } from './app-routes'; import { BROWSE_BY_DECORATOR_MAP } from './browse-by/browse-by-switcher/browse-by-decorator'; import { AuthInterceptor } from './core/auth/auth.interceptor'; +import { DspaceRestInterceptor } from './core/dspace-rest/dspace-rest.interceptor'; import { LocaleInterceptor } from './core/locale/locale.interceptor'; import { LogInterceptor } from './core/log/log.interceptor'; import { @@ -148,6 +149,11 @@ export const commonAppConfig: ApplicationConfig = { useClass: LogInterceptor, multi: true, }, + { + provide: HTTP_INTERCEPTORS, + useClass: DspaceRestInterceptor, + multi: true, + }, // register the dynamic matcher used by form. MUST be provided by the app module ...DYNAMIC_MATCHER_PROVIDERS, provideCore(), diff --git a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html index f7d2c60832..259ab599cb 100644 --- a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html +++ b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html @@ -1,6 +1,6 @@ - - + + @@ -27,7 +27,7 @@ - diff --git a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts index 9243a36491..7da9e040ce 100644 --- a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts +++ b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts @@ -261,7 +261,7 @@ describe('EditBitstreamPageComponent', () => { }); it('should select the correct format', () => { - expect(rawForm.formatContainer.selectedFormat).toEqual(selectedFormat.id); + expect(rawForm.formatContainer.selectedFormat).toEqual(selectedFormat.shortDescription); }); it('should put the \"New Format\" input on invisible', () => { @@ -292,7 +292,13 @@ describe('EditBitstreamPageComponent', () => { describe('when an unknown format is selected', () => { beforeEach(() => { - comp.updateNewFormatLayout(allFormats[0].id); + comp.onChange({ + model: { + id: 'selectedFormat', + value: allFormats[0], + }, + }); + comp.updateNewFormatLayout(); }); it('should remove the invisible class from the \"New Format\" input', () => { @@ -394,9 +400,10 @@ describe('EditBitstreamPageComponent', () => { describe('when selected format has changed', () => { beforeEach(() => { - comp.formGroup.patchValue({ - formatContainer: { - selectedFormat: allFormats[2].id, + comp.onChange({ + model: { + id: 'selectedFormat', + value: allFormats[2], }, }); fixture.detectChanges(); diff --git a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts index 36b0816ade..9f1a84c19d 100644 --- a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts +++ b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts @@ -21,7 +21,6 @@ import { DynamicFormLayout, DynamicFormService, DynamicInputModel, - DynamicSelectModel, } from '@ng-dynamic-forms/core'; import { TranslateModule, @@ -39,23 +38,24 @@ import { filter, map, switchMap, + take, tap, } from 'rxjs/operators'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { FindAllDataImpl } from '../../core/data/base/find-all-data'; import { BitstreamDataService } from '../../core/data/bitstream-data.service'; import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service'; -import { PaginatedList } from '../../core/data/paginated-list.model'; import { PrimaryBitstreamService } from '../../core/data/primary-bitstream.service'; import { RemoteData } from '../../core/data/remote-data'; import { Bitstream } from '../../core/shared/bitstream.model'; import { BitstreamFormat } from '../../core/shared/bitstream-format.model'; +import { BITSTREAM_FORMAT } from '../../core/shared/bitstream-format.resource-type'; import { BitstreamFormatSupportLevel } from '../../core/shared/bitstream-format-support-level'; import { Bundle } from '../../core/shared/bundle.model'; import { Item } from '../../core/shared/item.model'; import { Metadata } from '../../core/shared/metadata.utils'; import { - getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload, @@ -72,6 +72,7 @@ import { ErrorComponent } from '../../shared/error/error.component'; import { DynamicCustomSwitchModel } from '../../shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model'; import { DsDynamicInputModel } from '../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model'; import { DsDynamicTextAreaModel } from '../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-textarea.model'; +import { DynamicScrollableDropdownModel } from '../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; import { FormComponent } from '../../shared/form/form.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; @@ -109,12 +110,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { */ bitstreamRD$: Observable>; - /** - * The formats their remote data observable - * Tracks changes and updates the view - */ - bitstreamFormatsRD$: Observable>>; - /** * The UUID of the primary bitstream for this bundle */ @@ -130,11 +125,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { */ originalFormat: BitstreamFormat; - /** - * A list of all available bitstream formats - */ - formats: BitstreamFormat[]; - /** * @type {string} Key prefix used to generate form messages */ @@ -178,7 +168,10 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { /** * Options for fetching all bitstream formats */ - findAllOptions = { elementsPerPage: 9999 }; + findAllOptions = { + elementsPerPage: 20, + currentPage: 1, + }; /** * The Dynamic Input Model for the file's name @@ -218,9 +211,22 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { /** * The Dynamic Input Model for the selected format */ - selectedFormatModel = new DynamicSelectModel({ + selectedFormatModel = new DynamicScrollableDropdownModel({ id: 'selectedFormat', name: 'selectedFormat', + displayKey: 'shortDescription', + repeatable: false, + metadataFields: [], + submissionId: '', + hasSelectableMetadata: false, + resourceType: BITSTREAM_FORMAT, + formatFunction: (format: BitstreamFormat | string) => { + if (format instanceof BitstreamFormat) { + return hasValue(format) && format.supportLevel === BitstreamFormatSupportLevel.Unknown ? this.translate.instant(this.KEY_PREFIX + 'selectedFormat.unknown') : format.shortDescription; + } else { + return format; + } + }, }); /** @@ -438,6 +444,11 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { * @private */ private bundle: Bundle; + /** + * The currently selected format + * @private + */ + private selectedFormat: BitstreamFormat; constructor(private route: ActivatedRoute, private router: Router, @@ -463,18 +474,12 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { this.itemId = this.route.snapshot.queryParams.itemId; this.entityType = this.route.snapshot.queryParams.entityType; this.bitstreamRD$ = this.route.data.pipe(map((data: any) => data.bitstream)); - this.bitstreamFormatsRD$ = this.bitstreamFormatService.findAll(this.findAllOptions); const bitstream$ = this.bitstreamRD$.pipe( getFirstSucceededRemoteData(), getRemoteDataPayload(), ); - const allFormats$ = this.bitstreamFormatsRD$.pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - ); - const bundle$ = bitstream$.pipe( switchMap((bitstream: Bitstream) => bitstream.bundle), getFirstSucceededRemoteDataPayload(), @@ -490,24 +495,31 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { switchMap((bundle: Bundle) => bundle.item), getFirstSucceededRemoteDataPayload(), ); + const format$ = bitstream$.pipe( + switchMap(bitstream => bitstream.format), + getFirstSucceededRemoteDataPayload(), + ); + this.subs.push( observableCombineLatest( bitstream$, - allFormats$, bundle$, primaryBitstream$, item$, - ).pipe() - .subscribe(([bitstream, allFormats, bundle, primaryBitstream, item]) => { - this.bitstream = bitstream as Bitstream; - this.formats = allFormats.page; - this.bundle = bundle; - // hasValue(primaryBitstream) because if there's no primaryBitstream on the bundle it will - // be a success response, but empty - this.primaryBitstreamUUID = hasValue(primaryBitstream) ? primaryBitstream.uuid : null; - this.itemId = item.uuid; - this.setIiifStatus(this.bitstream); - }), + format$, + ).subscribe(([bitstream, bundle, primaryBitstream, item, format]) => { + this.bitstream = bitstream as Bitstream; + this.bundle = bundle; + this.selectedFormat = format; + // hasValue(primaryBitstream) because if there's no primaryBitstream on the bundle it will + // be a success response, but empty + this.primaryBitstreamUUID = hasValue(primaryBitstream) ? primaryBitstream.uuid : null; + this.itemId = item.uuid; + this.setIiifStatus(this.bitstream); + }), + format$.pipe(take(1)).subscribe( + (format) => this.originalFormat = format, + ), ); this.subs.push( @@ -523,7 +535,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { */ setForm() { this.formGroup = this.formService.createFormGroup(this.formModel); - this.updateFormatModel(); this.updateForm(this.bitstream); this.updateFieldTranslations(); } @@ -542,6 +553,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { description: bitstream.firstMetadataValue('dc.description'), }, formatContainer: { + selectedFormat: this.selectedFormat.shortDescription, newFormat: hasValue(bitstream.firstMetadata('dc.format')) ? bitstream.firstMetadata('dc.format').value : undefined, }, }); @@ -561,36 +573,16 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { }, }); } - this.bitstream.format.pipe( - getAllSucceededRemoteDataPayload(), - ).subscribe((format: BitstreamFormat) => { - this.originalFormat = format; - this.formGroup.patchValue({ - formatContainer: { - selectedFormat: format.id, - }, - }); - this.updateNewFormatLayout(format.id); - }); + this.updateNewFormatLayout(); } - /** - * Create the list of unknown format IDs an add options to the selectedFormatModel - */ - updateFormatModel() { - this.selectedFormatModel.options = this.formats.map((format: BitstreamFormat) => - Object.assign({ - value: format.id, - label: this.isUnknownFormat(format.id) ? this.translate.instant(this.KEY_PREFIX + 'selectedFormat.unknown') : format.shortDescription, - })); - } /** * Update the layout of the "Other Format" input depending on the selected format * @param selectedId */ - updateNewFormatLayout(selectedId: string) { - if (this.isUnknownFormat(selectedId)) { + updateNewFormatLayout() { + if (this.isUnknownFormat()) { this.formLayout.newFormat.grid.host = this.newFormatBaseLayout; } else { this.formLayout.newFormat.grid.host = this.newFormatBaseLayout + ' invisible'; @@ -601,9 +593,8 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { * Is the provided format (id) part of the list of unknown formats? * @param id */ - isUnknownFormat(id: string): boolean { - const format = this.formats.find((f: BitstreamFormat) => f.id === id); - return hasValue(format) && format.supportLevel === BitstreamFormatSupportLevel.Unknown; + isUnknownFormat(): boolean { + return hasValue(this.selectedFormat) && this.selectedFormat.supportLevel === BitstreamFormatSupportLevel.Unknown; } /** @@ -635,7 +626,8 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { onChange(event) { const model = event.model; if (model.id === this.selectedFormatModel.id) { - this.updateNewFormatLayout(model.value); + this.selectedFormat = model.value; + this.updateNewFormatLayout(); } } @@ -645,8 +637,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { onSubmit() { const updatedValues = this.formGroup.getRawValue(); const updatedBitstream = this.formToBitstream(updatedValues); - const selectedFormat = this.formats.find((f: BitstreamFormat) => f.id === updatedValues.formatContainer.selectedFormat); - const isNewFormat = selectedFormat.id !== this.originalFormat.id; + const isNewFormat = this.selectedFormat.id !== this.originalFormat.id; const isPrimary = updatedValues.fileNamePrimaryContainer.primaryBitstream; const wasPrimary = this.primaryBitstreamUUID === this.bitstream.uuid; @@ -698,7 +689,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { bundle$ = observableOf(this.bundle); } if (isNewFormat) { - bitstream$ = this.bitstreamService.updateFormat(this.bitstream, selectedFormat).pipe( + bitstream$ = this.bitstreamService.updateFormat(this.bitstream, this.selectedFormat).pipe( getFirstCompletedRemoteData(), map((formatResponse: RemoteData) => { if (hasValue(formatResponse) && formatResponse.hasFailed) { @@ -856,4 +847,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { .forEach((subscription) => subscription.unsubscribe()); } + findAllFormatsServiceFactory() { + return () => this.bitstreamFormatService as any as FindAllDataImpl; + } } diff --git a/src/app/browse-by/browse-by-date/browse-by-date.component.spec.ts b/src/app/browse-by/browse-by-date/browse-by-date.component.spec.ts index edd7cd951a..fad573705c 100644 --- a/src/app/browse-by/browse-by-date/browse-by-date.component.spec.ts +++ b/src/app/browse-by/browse-by-date/browse-by-date.component.spec.ts @@ -2,10 +2,13 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectorRef, NO_ERRORS_SCHEMA, + PLATFORM_ID, } from '@angular/core'; import { ComponentFixture, + fakeAsync, TestBed, + tick, waitForAsync, } from '@angular/core/testing'; import { @@ -26,6 +29,7 @@ import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search- import { SortDirection } from '../../core/cache/models/sort-options.model'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { PaginationService } from '../../core/pagination/pagination.service'; +import { BrowseEntry } from '../../core/shared/browse-entry.model'; import { Community } from '../../core/shared/community.model'; import { Item } from '../../core/shared/item.model'; import { ThemedBrowseByComponent } from '../../shared/browse-by/themed-browse-by.component'; @@ -123,6 +127,7 @@ describe('BrowseByDateComponent', () => { { provide: ChangeDetectorRef, useValue: mockCdRef }, { provide: Store, useValue: {} }, { provide: APP_CONFIG, useValue: environment }, + { provide: PLATFORM_ID, useValue: 'browser' }, ], schemas: [NO_ERRORS_SCHEMA], }) @@ -172,4 +177,33 @@ describe('BrowseByDateComponent', () => { //expect(comp.startsWithOptions[0]).toEqual(new Date().getUTCFullYear()); expect(comp.startsWithOptions[0]).toEqual(1960); }); + + describe('when rendered in SSR', () => { + beforeEach(() => { + comp.platformId = 'server'; + spyOn((comp as any).browseService, 'getBrowseItemsFor'); + }); + + it('should not call getBrowseItemsFor on init', (done) => { + comp.ngOnInit(); + expect((comp as any).browseService.getBrowseItemsFor).not.toHaveBeenCalled(); + comp.loading$.subscribe((res) => { + expect(res).toBeFalsy(); + done(); + }); + }); + }); + + describe('when rendered in CSR', () => { + beforeEach(() => { + comp.platformId = 'browser'; + spyOn((comp as any).browseService, 'getBrowseItemsFor').and.returnValue(createSuccessfulRemoteDataObject$(new BrowseEntry())); + }); + + it('should call getBrowseItemsFor on init', fakeAsync(() => { + comp.ngOnInit(); + tick(100); + expect((comp as any).browseService.getBrowseItemsFor).toHaveBeenCalled(); + })); + }); }); diff --git a/src/app/browse-by/browse-by-date/browse-by-date.component.ts b/src/app/browse-by/browse-by-date/browse-by-date.component.ts index 11818ff5f1..1ebdcc838e 100644 --- a/src/app/browse-by/browse-by-date/browse-by-date.component.ts +++ b/src/app/browse-by/browse-by-date/browse-by-date.component.ts @@ -1,5 +1,6 @@ import { AsyncPipe, + isPlatformServer, NgIf, } from '@angular/common'; import { @@ -7,6 +8,7 @@ import { Component, Inject, OnInit, + PLATFORM_ID, } from '@angular/core'; import { ActivatedRoute, @@ -17,10 +19,11 @@ import { TranslateModule } from '@ngx-translate/core'; import { combineLatest as observableCombineLatest, Observable, + of as observableOf, } from 'rxjs'; import { + distinctUntilChanged, map, - take, } from 'rxjs/operators'; import { ThemedBrowseByComponent } from 'src/app/shared/browse-by/themed-browse-by.component'; @@ -28,6 +31,7 @@ import { APP_CONFIG, AppConfig, } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { BrowseService } from '../../core/browse/browse.service'; import { @@ -38,13 +42,7 @@ import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.serv import { RemoteData } from '../../core/data/remote-data'; import { PaginationService } from '../../core/pagination/pagination.service'; import { Item } from '../../core/shared/item.model'; -import { ThemedComcolPageBrowseByComponent } from '../../shared/comcol/comcol-page-browse-by/themed-comcol-page-browse-by.component'; -import { ThemedComcolPageContentComponent } from '../../shared/comcol/comcol-page-content/themed-comcol-page-content.component'; -import { ThemedComcolPageHandleComponent } from '../../shared/comcol/comcol-page-handle/themed-comcol-page-handle.component'; -import { ComcolPageHeaderComponent } from '../../shared/comcol/comcol-page-header/comcol-page-header.component'; -import { ComcolPageLogoComponent } from '../../shared/comcol/comcol-page-logo/comcol-page-logo.component'; import { isValidDate } from '../../shared/date.util'; -import { DsoEditMenuComponent } from '../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; import { hasValue, isNotEmpty, @@ -52,7 +50,6 @@ import { import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { StartsWithType } from '../../shared/starts-with/starts-with-type'; -import { VarDirective } from '../../shared/utils/var.directive'; import { BrowseByMetadataComponent, browseParamsToOptions, @@ -64,15 +61,8 @@ import { templateUrl: '../browse-by-metadata/browse-by-metadata.component.html', standalone: true, imports: [ - VarDirective, AsyncPipe, - ComcolPageHeaderComponent, - ComcolPageLogoComponent, NgIf, - ThemedComcolPageHandleComponent, - ThemedComcolPageContentComponent, - DsoEditMenuComponent, - ThemedComcolPageBrowseByComponent, TranslateModule, ThemedLoadingComponent, ThemedBrowseByComponent, @@ -99,27 +89,34 @@ export class BrowseByDateComponent extends BrowseByMetadataComponent implements @Inject(APP_CONFIG) public appConfig: AppConfig, public dsoNameService: DSONameService, protected cdRef: ChangeDetectorRef, + @Inject(PLATFORM_ID) public platformId: any, ) { - super(route, browseService, dsoService, paginationService, router, appConfig, dsoNameService); + super(route, browseService, dsoService, paginationService, router, appConfig, dsoNameService, platformId); } ngOnInit(): void { + if (!this.renderOnServerSide && !environment.ssr.enableBrowseComponent && isPlatformServer(this.platformId)) { + this.loading$ = observableOf(false); + return; + } const sortConfig = new SortOptions('default', SortDirection.ASC); this.startsWithType = StartsWithType.date; this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig); this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); + const routeParams$: Observable = observableCombineLatest([ + this.route.params, + this.route.queryParams, + ]).pipe( + map(([params, queryParams]: [Params, Params]) => Object.assign({}, params, queryParams)), + distinctUntilChanged((prev: Params, curr: Params) => prev.id === curr.id && prev.startsWith === curr.startsWith), + ); this.subs.push( - observableCombineLatest( - [ this.route.params.pipe(take(1)), - this.route.queryParams, - this.scope$, - this.currentPagination$, - this.currentSort$, - ]).pipe( - map(([routeParams, queryParams, scope, currentPage, currentSort]) => { - return [Object.assign({}, routeParams, queryParams), scope, currentPage, currentSort]; - }), - ).subscribe(([params, scope, currentPage, currentSort]: [Params, string, PaginationComponentOptions, SortOptions]) => { + observableCombineLatest([ + routeParams$, + this.scope$, + this.currentPagination$, + this.currentSort$, + ]).subscribe(([params, scope, currentPage, currentSort]: [Params, string, PaginationComponentOptions, SortOptions]) => { const metadataKeys = params.browseDefinition ? params.browseDefinition.metadataKeys : this.defaultMetadataKeys; this.browseId = params.id || this.defaultBrowseId; this.startsWith = +params.startsWith || params.startsWith; diff --git a/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.html b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.html index 22e564ac27..6642b724bb 100644 --- a/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.html +++ b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.html @@ -1,4 +1,4 @@ - + {{ 'collection.delete.text' | translate:{ dso: dsoNameService.getName(dso) } }} - + {{'collection.delete.cancel' | translate}} - + {{'collection.delete.processing' | translate}} {{'collection.delete.confirm' | translate}} diff --git a/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts b/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts index dbb5f8846f..acc716b52a 100644 --- a/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts +++ b/src/app/collection-page/delete-collection-page/delete-collection-page.component.ts @@ -15,6 +15,7 @@ import { import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { CollectionDataService } from '../../core/data/collection-data.service'; import { Collection } from '../../core/shared/collection.model'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { DeleteComColPageComponent } from '../../shared/comcol/comcol-forms/delete-comcol-page/delete-comcol-page.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { VarDirective } from '../../shared/utils/var.directive'; @@ -31,6 +32,7 @@ import { VarDirective } from '../../shared/utils/var.directive'; AsyncPipe, NgIf, VarDirective, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html index 7edaadb0a1..1e09758bd1 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html @@ -19,32 +19,32 @@ {{'collection.source.controls.test.submit' | translate}} + [dsBtnDisabled]="true"> {{'collection.source.controls.test.running' | translate}} {{'collection.source.controls.import.submit' | translate}} + [dsBtnDisabled]="true"> {{'collection.source.controls.import.running' | translate}} {{'collection.source.controls.reset.submit' | translate}} + [dsBtnDisabled]="true"> {{'collection.source.controls.reset.running' | translate}} diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts index 0caf9b0418..cbe3de1483 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts @@ -22,6 +22,7 @@ import { Collection } from '../../../../core/shared/collection.model'; import { ContentSource } from '../../../../core/shared/content-source.model'; import { ContentSourceSetSerializer } from '../../../../core/shared/content-source-set-serializer'; import { Process } from '../../../../process-page/processes/process.model'; +import { BtnDisabledDirective } from '../../../../shared/btn-disabled.directive'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; @@ -104,7 +105,7 @@ describe('CollectionSourceControlsComponent', () => { requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']); TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), RouterTestingModule, CollectionSourceControlsComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule, CollectionSourceControlsComponent, VarDirective, BtnDisabledDirective], providers: [ { provide: ScriptDataService, useValue: scriptDataService }, { provide: ProcessDataService, useValue: processDataService }, @@ -193,9 +194,10 @@ describe('CollectionSourceControlsComponent', () => { const buttons = fixture.debugElement.queryAll(By.css('button')); - expect(buttons[0].nativeElement.disabled).toBeTrue(); - expect(buttons[1].nativeElement.disabled).toBeTrue(); - expect(buttons[2].nativeElement.disabled).toBeTrue(); + buttons.forEach(button => { + expect(button.nativeElement.getAttribute('aria-disabled')).toBe('true'); + expect(button.nativeElement.classList.contains('disabled')).toBeTrue(); + }); }); it('should be enabled when isEnabled is true', () => { comp.shouldShow = true; @@ -205,9 +207,10 @@ describe('CollectionSourceControlsComponent', () => { const buttons = fixture.debugElement.queryAll(By.css('button')); - expect(buttons[0].nativeElement.disabled).toBeFalse(); - expect(buttons[1].nativeElement.disabled).toBeFalse(); - expect(buttons[2].nativeElement.disabled).toBeFalse(); + buttons.forEach(button => { + expect(button.nativeElement.getAttribute('aria-disabled')).toBe('false'); + expect(button.nativeElement.classList.contains('disabled')).toBeFalse(); + }); }); it('should call the corresponding button when clicked', () => { spyOn(comp, 'testConfiguration'); diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts index fdd65a43c7..e35a64af16 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts @@ -40,6 +40,7 @@ import { } from '../../../../core/shared/operators'; import { Process } from '../../../../process-page/processes/process.model'; import { ProcessStatus } from '../../../../process-page/processes/process-status.model'; +import { BtnDisabledDirective } from '../../../../shared/btn-disabled.directive'; import { hasValue } from '../../../../shared/empty.util'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { VarDirective } from '../../../../shared/utils/var.directive'; @@ -56,6 +57,7 @@ import { VarDirective } from '../../../../shared/utils/var.directive'; AsyncPipe, NgIf, VarDirective, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html index 8f807c1aee..7aa1f1a8b7 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html @@ -1,7 +1,7 @@ {{"item.edit.metadata.discard-button" | translate}} @@ -12,7 +12,7 @@ {{"item.edit.metadata.reinstate-button" | translate}} {{"item.edit.metadata.save-button" | translate}} @@ -45,7 +45,7 @@ {{"item.edit.metadata.discard-button" | translate}} @@ -56,7 +56,7 @@ {{"item.edit.metadata.reinstate-button" | translate}} {{"item.edit.metadata.save-button" | translate}} diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts index 43614e43a6..afeb2e2352 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts @@ -56,6 +56,7 @@ import { getFirstCompletedRemoteData, getFirstSucceededRemoteData, } from '../../../core/shared/operators'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { hasNoValue, hasValue, @@ -81,6 +82,7 @@ import { CollectionSourceControlsComponent } from './collection-source-controls/ ThemedLoadingComponent, FormComponent, CollectionSourceControlsComponent, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/community-page/delete-community-page/delete-community-page.component.html b/src/app/community-page/delete-community-page/delete-community-page.component.html index b241a7027c..b5d215e3b6 100644 --- a/src/app/community-page/delete-community-page/delete-community-page.component.html +++ b/src/app/community-page/delete-community-page/delete-community-page.component.html @@ -6,10 +6,10 @@ {{ 'community.delete.text' | translate:{ dso: dsoNameService.getName(dso) } }} - + {{'community.delete.cancel' | translate}} - + {{'community.delete.processing' | translate}} {{'community.delete.confirm' | translate}} diff --git a/src/app/community-page/delete-community-page/delete-community-page.component.ts b/src/app/community-page/delete-community-page/delete-community-page.component.ts index f35e2d6bd2..9c19a5eb47 100644 --- a/src/app/community-page/delete-community-page/delete-community-page.component.ts +++ b/src/app/community-page/delete-community-page/delete-community-page.component.ts @@ -15,6 +15,7 @@ import { import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { CommunityDataService } from '../../core/data/community-data.service'; import { Community } from '../../core/shared/community.model'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { DeleteComColPageComponent } from '../../shared/comcol/comcol-forms/delete-comcol-page/delete-comcol-page.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { VarDirective } from '../../shared/utils/var.directive'; @@ -31,6 +32,7 @@ import { VarDirective } from '../../shared/utils/var.directive'; AsyncPipe, VarDirective, NgIf, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 84d4c53210..d011d27059 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -129,12 +129,24 @@ export class AuthInterceptor implements HttpInterceptor { */ private sortAuthMethods(authMethodModels: AuthMethod[]): AuthMethod[] { const sortedAuthMethodModels: AuthMethod[] = []; + let passwordAuthFound = false; + let ldapAuthFound = false; + authMethodModels.forEach((method) => { if (method.authMethodType === AuthMethodType.Password) { sortedAuthMethodModels.push(method); + passwordAuthFound = true; + } + if (method.authMethodType === AuthMethodType.Ldap) { + ldapAuthFound = true; } }); + // Using password authentication method to provide UI for LDAP authentication even if password auth is not present in server + if (ldapAuthFound && !(passwordAuthFound)) { + sortedAuthMethodModels.push(new AuthMethod(AuthMethodType.Password,0)); + } + authMethodModels.forEach((method) => { if (method.authMethodType !== AuthMethodType.Password) { sortedAuthMethodModels.push(method); diff --git a/src/app/core/data/base/base-data.service.ts b/src/app/core/data/base/base-data.service.ts index df4e9b7a48..da36461240 100644 --- a/src/app/core/data/base/base-data.service.ts +++ b/src/app/core/data/base/base-data.service.ts @@ -11,6 +11,7 @@ import { from as observableFrom, Observable, of as observableOf, + shareReplay, } from 'rxjs'; import { map, @@ -288,6 +289,10 @@ export class BaseDataService implements HALDataServic isNotEmptyOperator(), take(1), map((href: string) => this.buildHrefFromFindOptions(href, {}, [], ...linksToFollow)), + shareReplay({ + bufferSize: 1, + refCount: true, + }), ); const startTime: number = new Date().getTime(); @@ -343,6 +348,10 @@ export class BaseDataService implements HALDataServic isNotEmptyOperator(), take(1), map((href: string) => this.buildHrefFromFindOptions(href, options, [], ...linksToFollow)), + shareReplay({ + bufferSize: 1, + refCount: true, + }), ); const startTime: number = new Date().getTime(); diff --git a/src/app/core/data/bitstream-data.service.spec.ts b/src/app/core/data/bitstream-data.service.spec.ts index 91b5132ce0..6aa75065d4 100644 --- a/src/app/core/data/bitstream-data.service.spec.ts +++ b/src/app/core/data/bitstream-data.service.spec.ts @@ -16,6 +16,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { Bitstream } from '../shared/bitstream.model'; import { BitstreamFormat } from '../shared/bitstream-format.model'; @@ -176,4 +177,30 @@ describe('BitstreamDataService', () => { expect(service.invalidateByHref).toHaveBeenCalledWith('fake-bitstream2-self'); }); }); + + describe('findByItemHandle', () => { + it('should encode the filename correctly in the search parameters', () => { + const handle = '123456789/1234'; + const sequenceId = '5'; + const filename = 'file with spaces.pdf'; + const searchParams = [ + new RequestParam('handle', handle), + new RequestParam('sequenceId', sequenceId), + new RequestParam('filename', filename), + ]; + const linksToFollow: FollowLinkConfig[] = []; + + spyOn(service as any, 'getSearchByHref').and.callThrough(); + + service.getSearchByHref('byItemHandle', { searchParams }, ...linksToFollow).subscribe((href) => { + expect(service.getSearchByHref).toHaveBeenCalledWith( + 'byItemHandle', + { searchParams }, + ...linksToFollow, + ); + + expect(href).toBe(`${url}/bitstreams/search/byItemHandle?handle=123456789%2F1234&sequenceId=5&filename=file%20with%20spaces.pdf`); + }); + }); + }); }); diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index b8776d2530..9455a456fa 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -241,11 +241,12 @@ export class BitstreamDataService extends IdentifiableDataService imp * no valid cached version. Defaults to true * @param reRequestOnStale Whether or not the request should automatically be re- * requested after the response becomes stale + * @param options the {@link FindListOptions} for the request * @return {Observable} * Return an observable that contains primary bitstream information or null */ - public findPrimaryBitstreamByItemAndName(item: Item, bundleName: string, useCachedVersionIfAvailable = true, reRequestOnStale = true): Observable { - return this.bundleService.findByItemAndName(item, bundleName, useCachedVersionIfAvailable, reRequestOnStale, followLink('primaryBitstream')).pipe( + public findPrimaryBitstreamByItemAndName(item: Item, bundleName: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, options?: FindListOptions): Observable { + return this.bundleService.findByItemAndName(item, bundleName, useCachedVersionIfAvailable, reRequestOnStale, options, followLink('primaryBitstream')).pipe( getFirstCompletedRemoteData(), switchMap((rd: RemoteData) => { if (!rd.hasSucceeded) { diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index 5d552c9bf0..79f877fadd 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -78,10 +78,14 @@ export class BundleDataService extends IdentifiableDataService implement * requested after the response becomes stale * @param linksToFollow List of {@link FollowLinkConfig} that indicate which * {@link HALLink}s should be automatically resolved + * @param options the {@link FindListOptions} for the request */ // TODO should be implemented rest side - findByItemAndName(item: Item, bundleName: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.findAllByItem(item, { elementsPerPage: 9999 }, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( + findByItemAndName(item: Item, bundleName: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, options?: FindListOptions, ...linksToFollow: FollowLinkConfig[]): Observable> { + //Since we filter by bundleName where the pagination options are not indicated we need to load all the possible bundles. + // This is a workaround, in substitution of the previously recursive call with expand + const paginationOptions = options ?? { elementsPerPage: 9999 }; + return this.findAllByItem(item, paginationOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( map((rd: RemoteData>) => { if (hasValue(rd.payload) && hasValue(rd.payload.page)) { const matchingBundle = rd.payload.page.find((bundle: Bundle) => diff --git a/src/app/core/dspace-rest/dspace-rest.interceptor.spec.ts b/src/app/core/dspace-rest/dspace-rest.interceptor.spec.ts new file mode 100644 index 0000000000..4a47ffe9fd --- /dev/null +++ b/src/app/core/dspace-rest/dspace-rest.interceptor.spec.ts @@ -0,0 +1,194 @@ +import { + HTTP_INTERCEPTORS, + HttpClient, +} from '@angular/common/http'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { + APP_CONFIG, + AppConfig, +} from '../../../config/app-config.interface'; +import { DspaceRestInterceptor } from './dspace-rest.interceptor'; +import { DspaceRestService } from './dspace-rest.service'; + +describe('DspaceRestInterceptor', () => { + let httpMock: HttpTestingController; + let httpClient: HttpClient; + const appConfig: Partial = { + rest: { + ssl: false, + host: 'localhost', + port: 8080, + nameSpace: '/server', + baseUrl: 'http://api.example.com/server', + }, + }; + const appConfigWithSSR: Partial = { + rest: { + ssl: false, + host: 'localhost', + port: 8080, + nameSpace: '/server', + baseUrl: 'http://api.example.com/server', + ssrBaseUrl: 'http://ssr.example.com/server', + }, + }; + + describe('When SSR base URL is not set ', () => { + describe('and it\'s in the browser', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + DspaceRestService, + { + provide: HTTP_INTERCEPTORS, + useClass: DspaceRestInterceptor, + multi: true, + }, + { provide: APP_CONFIG, useValue: appConfig }, + { provide: PLATFORM_ID, useValue: 'browser' }, + ], + }); + + httpMock = TestBed.inject(HttpTestingController); + httpClient = TestBed.inject(HttpClient); + }); + + it('should not modify the request', () => { + const url = 'http://api.example.com/server/items'; + httpClient.get(url).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const req = httpMock.expectOne(url); + expect(req.request.url).toBe(url); + req.flush({}); + httpMock.verify(); + }); + }); + + describe('and it\'s in SSR mode', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + DspaceRestService, + { + provide: HTTP_INTERCEPTORS, + useClass: DspaceRestInterceptor, + multi: true, + }, + { provide: APP_CONFIG, useValue: appConfig }, + { provide: PLATFORM_ID, useValue: 'server' }, + ], + }); + + httpMock = TestBed.inject(HttpTestingController); + httpClient = TestBed.inject(HttpClient); + }); + + it('should not replace the base URL', () => { + const url = 'http://api.example.com/server/items'; + + httpClient.get(url).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const req = httpMock.expectOne(url); + expect(req.request.url).toBe(url); + req.flush({}); + httpMock.verify(); + }); + }); + }); + + describe('When SSR base URL is set ', () => { + describe('and it\'s in the browser', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + DspaceRestService, + { + provide: HTTP_INTERCEPTORS, + useClass: DspaceRestInterceptor, + multi: true, + }, + { provide: APP_CONFIG, useValue: appConfigWithSSR }, + { provide: PLATFORM_ID, useValue: 'browser' }, + ], + }); + + httpMock = TestBed.inject(HttpTestingController); + httpClient = TestBed.inject(HttpClient); + }); + + it('should not modify the request', () => { + const url = 'http://api.example.com/server/items'; + httpClient.get(url).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const req = httpMock.expectOne(url); + expect(req.request.url).toBe(url); + req.flush({}); + httpMock.verify(); + }); + }); + + describe('and it\'s in SSR mode', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + DspaceRestService, + { + provide: HTTP_INTERCEPTORS, + useClass: DspaceRestInterceptor, + multi: true, + }, + { provide: APP_CONFIG, useValue: appConfigWithSSR }, + { provide: PLATFORM_ID, useValue: 'server' }, + ], + }); + + httpMock = TestBed.inject(HttpTestingController); + httpClient = TestBed.inject(HttpClient); + }); + + it('should replace the base URL', () => { + const url = 'http://api.example.com/server/items'; + const ssrBaseUrl = appConfigWithSSR.rest.ssrBaseUrl; + + httpClient.get(url).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const req = httpMock.expectOne(ssrBaseUrl + '/items'); + expect(req.request.url).toBe(ssrBaseUrl + '/items'); + req.flush({}); + httpMock.verify(); + }); + + it('should not replace any query param containing the base URL', () => { + const url = 'http://api.example.com/server/items?url=http://api.example.com/server/item/1'; + const ssrBaseUrl = appConfigWithSSR.rest.ssrBaseUrl; + + httpClient.get(url).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const req = httpMock.expectOne(ssrBaseUrl + '/items?url=http://api.example.com/server/item/1'); + expect(req.request.url).toBe(ssrBaseUrl + '/items?url=http://api.example.com/server/item/1'); + req.flush({}); + httpMock.verify(); + }); + }); + }); +}); diff --git a/src/app/core/dspace-rest/dspace-rest.interceptor.ts b/src/app/core/dspace-rest/dspace-rest.interceptor.ts new file mode 100644 index 0000000000..efd2c12b5d --- /dev/null +++ b/src/app/core/dspace-rest/dspace-rest.interceptor.ts @@ -0,0 +1,52 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest, +} from '@angular/common/http'; +import { + Inject, + Injectable, + PLATFORM_ID, +} from '@angular/core'; +import { Observable } from 'rxjs'; + +import { + APP_CONFIG, + AppConfig, +} from '../../../config/app-config.interface'; +import { isEmpty } from '../../shared/empty.util'; + +@Injectable() +/** + * This Interceptor is used to use the configured base URL for the request made during SSR execution + */ +export class DspaceRestInterceptor implements HttpInterceptor { + + /** + * Contains the configured application base URL + * @protected + */ + protected baseUrl: string; + protected ssrBaseUrl: string; + + constructor( + @Inject(APP_CONFIG) protected appConfig: AppConfig, + @Inject(PLATFORM_ID) private platformId: string, + ) { + this.baseUrl = this.appConfig.rest.baseUrl; + this.ssrBaseUrl = this.appConfig.rest.ssrBaseUrl; + } + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + if (isPlatformBrowser(this.platformId) || isEmpty(this.ssrBaseUrl) || this.baseUrl === this.ssrBaseUrl) { + return next.handle(request); + } + + // Different SSR Base URL specified so replace it in the current request url + const url = request.url.replace(this.baseUrl, this.ssrBaseUrl); + const newRequest: HttpRequest = request.clone({ url }); + return next.handle(newRequest); + } +} diff --git a/src/app/core/metadata/head-tag.service.ts b/src/app/core/metadata/head-tag.service.ts index 270e5fde72..8041bb3a4a 100644 --- a/src/app/core/metadata/head-tag.service.ts +++ b/src/app/core/metadata/head-tag.service.ts @@ -50,6 +50,7 @@ import { coreSelector } from '../core.selectors'; import { CoreState } from '../core-state.model'; import { BundleDataService } from '../data/bundle-data.service'; import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +import { FindListOptions } from '../data/find-list-options.model'; import { PaginatedList } from '../data/paginated-list.model'; import { RemoteData } from '../data/remote-data'; import { RootDataService } from '../data/root-data.service'; @@ -331,6 +332,7 @@ export class HeadTagService { 'ORIGINAL', true, true, + new FindListOptions(), followLink('primaryBitstream'), followLink('bitstreams', { findListOptions: { diff --git a/src/app/core/services/server-hard-redirect.service.spec.ts b/src/app/core/services/server-hard-redirect.service.spec.ts index 27777ed7ba..a904a8e66c 100644 --- a/src/app/core/services/server-hard-redirect.service.spec.ts +++ b/src/app/core/services/server-hard-redirect.service.spec.ts @@ -1,5 +1,6 @@ import { TestBed } from '@angular/core/testing'; +import { environment } from '../../../environments/environment.test'; import { ServerHardRedirectService } from './server-hard-redirect.service'; describe('ServerHardRedirectService', () => { @@ -7,7 +8,7 @@ describe('ServerHardRedirectService', () => { const mockRequest = jasmine.createSpyObj(['get']); const mockResponse = jasmine.createSpyObj(['redirect', 'end']); - const service: ServerHardRedirectService = new ServerHardRedirectService(mockRequest, mockResponse); + let service: ServerHardRedirectService = new ServerHardRedirectService(environment, mockRequest, mockResponse); const origin = 'https://test-host.com:4000'; beforeEach(() => { @@ -68,4 +69,23 @@ describe('ServerHardRedirectService', () => { }); }); + describe('when SSR base url is set', () => { + const redirect = 'https://private-url:4000/server/api/bitstreams/uuid'; + const replacedUrl = 'https://public-url/server/api/bitstreams/uuid'; + const environmentWithSSRUrl: any = { ...environment, ...{ ...environment.rest, rest: { + ssrBaseUrl: 'https://private-url:4000/server', + baseUrl: 'https://public-url/server', + } } }; + service = new ServerHardRedirectService(environmentWithSSRUrl, mockRequest, mockResponse); + + beforeEach(() => { + service.redirect(redirect); + }); + + it('should perform a 302 redirect', () => { + expect(mockResponse.redirect).toHaveBeenCalledWith(302, replacedUrl); + expect(mockResponse.end).toHaveBeenCalled(); + }); + }); + }); diff --git a/src/app/core/services/server-hard-redirect.service.ts b/src/app/core/services/server-hard-redirect.service.ts index e1ded1568c..1592d9bf1c 100644 --- a/src/app/core/services/server-hard-redirect.service.ts +++ b/src/app/core/services/server-hard-redirect.service.ts @@ -7,10 +7,15 @@ import { Response, } from 'express'; +import { + APP_CONFIG, + AppConfig, +} from '../../../config/app-config.interface'; import { REQUEST, RESPONSE, } from '../../../express.tokens'; +import { isNotEmpty } from '../../shared/empty.util'; import { HardRedirectService } from './hard-redirect.service'; /** @@ -20,6 +25,7 @@ import { HardRedirectService } from './hard-redirect.service'; export class ServerHardRedirectService extends HardRedirectService { constructor( + @Inject(APP_CONFIG) protected appConfig: AppConfig, @Inject(REQUEST) protected req: Request, @Inject(RESPONSE) protected res: Response, ) { @@ -35,17 +41,22 @@ export class ServerHardRedirectService extends HardRedirectService { * optional HTTP status code to use for redirect (default = 302, which is a temporary redirect) */ redirect(url: string, statusCode?: number) { - if (url === this.req.url) { return; } + let redirectUrl = url; + // If redirect url contains SSR base url then replace with public base url + if (isNotEmpty(this.appConfig.rest.ssrBaseUrl) && this.appConfig.rest.baseUrl !== this.appConfig.rest.ssrBaseUrl) { + redirectUrl = url.replace(this.appConfig.rest.ssrBaseUrl, this.appConfig.rest.baseUrl); + } + if (this.res.finished) { const req: any = this.req; req._r_count = (req._r_count || 0) + 1; console.warn('Attempted to redirect on a finished response. From', - this.req.url, 'to', url); + this.req.url, 'to', redirectUrl); if (req._r_count > 10) { console.error('Detected a redirection loop. killing the nodejs process'); @@ -59,9 +70,9 @@ export class ServerHardRedirectService extends HardRedirectService { status = 302; } - console.log(`Redirecting from ${this.req.url} to ${url} with ${status}`); + console.info(`Redirecting from ${this.req.url} to ${redirectUrl} with ${status}`); - this.res.redirect(status, url); + this.res.redirect(status, redirectUrl); this.res.end(); // I haven't found a way to correctly stop Angular rendering. // So we just let it end its work, though we have already closed diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html index f3bbef3a4d..2262782687 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html @@ -77,29 +77,29 @@ + [dsBtnDisabled]="isVirtual || mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE || (saving$ | async)" (click)="edit.emit()"> + [dsBtnDisabled]="isVirtual || (saving$ | async)" (click)="confirm.emit(true)"> + [dsBtnDisabled]="isVirtual || (mdValue.change && mdValue.change !== DsoEditMetadataChangeTypeEnum.ADD) || mdValue.editing || (saving$ | async)" (click)="remove.emit()"> + [dsBtnDisabled]="isVirtual || (!mdValue.change && mdValue.reordered) || (!mdValue.change && !mdValue.editing) || (saving$ | async)" (click)="undo.emit()"> diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts index e96959c1d1..196a13ec4a 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts @@ -34,6 +34,7 @@ import { VIRTUAL_METADATA_PREFIX, } from '../../../core/shared/metadata.models'; import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { DsDynamicOneboxComponent } from '../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component'; import { DsDynamicScrollableDropdownComponent } from '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; import { ThemedTypeBadgeComponent } from '../../../shared/object-collection/shared/badges/type-badge/themed-type-badge.component'; @@ -188,6 +189,7 @@ describe('DsoEditMetadataValueComponent', () => { RouterTestingModule.withRoutes([]), DsoEditMetadataValueComponent, VarDirective, + BtnDisabledDirective, ], providers: [ { provide: RelationshipDataService, useValue: relationshipService }, @@ -524,7 +526,14 @@ describe('DsoEditMetadataValueComponent', () => { }); it(`should${disabled ? ' ' : ' not '}be disabled`, () => { - expect(btn.nativeElement.disabled).toBe(disabled); + if (disabled) { + expect(btn.nativeElement.getAttribute('aria-disabled')).toBe('true'); + expect(btn.nativeElement.classList.contains('disabled')).toBeTrue(); + } else { + // Can be null or false, depending on if button was ever disabled so just check not true + expect(btn.nativeElement.getAttribute('aria-disabled')).not.toBe('true'); + expect(btn.nativeElement.classList.contains('disabled')).toBeFalse(); + } }); } else { it('should not exist', () => { diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts index 12c2dce966..4b79ae303c 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts @@ -67,6 +67,7 @@ import { import { Vocabulary } from '../../../core/submission/vocabularies/models/vocabulary.model'; import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model'; import { getItemPageRoute } from '../../../item-page/item-page-routing-paths'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { isNotEmpty } from '../../../shared/empty.util'; import { DsDynamicOneboxComponent } from '../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component'; import { @@ -94,7 +95,7 @@ import { styleUrls: ['./dso-edit-metadata-value.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'], templateUrl: './dso-edit-metadata-value.component.html', standalone: true, - imports: [VarDirective, CdkDrag, NgClass, NgIf, FormsModule, DebounceDirective, RouterLink, ThemedTypeBadgeComponent, NgbTooltipModule, CdkDragHandle, AsyncPipe, TranslateModule, DsDynamicScrollableDropdownComponent, DsDynamicOneboxComponent, AuthorityConfidenceStateDirective], + imports: [VarDirective, CdkDrag, NgClass, NgIf, FormsModule, DebounceDirective, RouterLink, ThemedTypeBadgeComponent, NgbTooltipModule, CdkDragHandle, AsyncPipe, TranslateModule, DsDynamicScrollableDropdownComponent, DsDynamicOneboxComponent, AuthorityConfidenceStateDirective, BtnDisabledDirective], }) /** * Component displaying a single editable row for a metadata value diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html index f8b193f4a0..54392a00b0 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html @@ -1,18 +1,18 @@ - {{ dsoType + '.edit.metadata.add-button' | translate }} - {{ dsoType + '.edit.metadata.reinstate-button' | translate }} - @@ -21,7 +21,7 @@ {{ dsoType + '.edit.metadata.discard-button' | translate }} @@ -77,13 +77,13 @@ - {{ dsoType + '.edit.metadata.reinstate-button' | translate }} - @@ -92,7 +92,7 @@ {{ dsoType + '.edit.metadata.discard-button' | translate }} diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts index 32e57e003d..cbe2c18792 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.spec.ts @@ -22,6 +22,7 @@ import { Item } from '../../core/shared/item.model'; import { ITEM } from '../../core/shared/item.resource-type'; import { MetadataValue } from '../../core/shared/metadata.models'; import { AlertComponent } from '../../shared/alert/alert.component'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TestDataService } from '../../shared/testing/test-data-service.mock'; @@ -94,6 +95,7 @@ describe('DsoEditMetadataComponent', () => { RouterTestingModule.withRoutes([]), DsoEditMetadataComponent, VarDirective, + BtnDisabledDirective, ], providers: [ { provide: APP_DATA_SERVICES_MAP, useValue: mockDataServiceMap }, @@ -216,7 +218,13 @@ describe('DsoEditMetadataComponent', () => { }); it(`should${disabled ? ' ' : ' not '}be disabled`, () => { - expect(btn.nativeElement.disabled).toBe(disabled); + if (disabled) { + expect(btn.nativeElement.getAttribute('aria-disabled')).toBe('true'); + expect(btn.nativeElement.classList.contains('disabled')).toBeTrue(); + } else { + expect(btn.nativeElement.getAttribute('aria-disabled')).not.toBe('true'); + expect(btn.nativeElement.classList.contains('disabled')).toBeFalse(); + } }); } else { it('should not exist', () => { diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts index 234a8539c4..4a40e3c26a 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts @@ -47,6 +47,7 @@ import { getFirstCompletedRemoteData } from '../../core/shared/operators'; import { ResourceType } from '../../core/shared/resource-type'; import { AlertComponent } from '../../shared/alert/alert.component'; import { AlertType } from '../../shared/alert/alert-type'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { hasNoValue, hasValue, @@ -66,7 +67,7 @@ import { MetadataFieldSelectorComponent } from './metadata-field-selector/metada styleUrls: ['./dso-edit-metadata.component.scss'], templateUrl: './dso-edit-metadata.component.html', standalone: true, - imports: [NgIf, DsoEditMetadataHeadersComponent, MetadataFieldSelectorComponent, DsoEditMetadataValueHeadersComponent, DsoEditMetadataValueComponent, NgFor, DsoEditMetadataFieldValuesComponent, AlertComponent, ThemedLoadingComponent, AsyncPipe, TranslateModule], + imports: [NgIf, DsoEditMetadataHeadersComponent, MetadataFieldSelectorComponent, DsoEditMetadataValueHeadersComponent, DsoEditMetadataValueComponent, NgFor, DsoEditMetadataFieldValuesComponent, AlertComponent, ThemedLoadingComponent, AsyncPipe, TranslateModule, BtnDisabledDirective], }) /** * Component showing a table of all metadata on a DSpaceObject and options to modify them diff --git a/src/app/forgot-password/forgot-password-form/forgot-password-form.component.html b/src/app/forgot-password/forgot-password-form/forgot-password-form.component.html index 189d0bee40..6cff2903b9 100644 --- a/src/app/forgot-password/forgot-password-form/forgot-password-form.component.html +++ b/src/app/forgot-password/forgot-password-form/forgot-password-form.component.html @@ -28,7 +28,7 @@ {{'forgot-password.form.submit' | translate}} diff --git a/src/app/forgot-password/forgot-password-form/forgot-password-form.component.ts b/src/app/forgot-password/forgot-password-form/forgot-password-form.component.ts index 7b2b1beedd..68f0f9d203 100644 --- a/src/app/forgot-password/forgot-password-form/forgot-password-form.component.ts +++ b/src/app/forgot-password/forgot-password-form/forgot-password-form.component.ts @@ -29,6 +29,7 @@ import { } from '../../core/shared/operators'; import { Registration } from '../../core/shared/registration.model'; import { ProfilePageSecurityFormComponent } from '../../profile-page/profile-page-security-form/profile-page-security-form.component'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { BrowserOnlyPipe } from '../../shared/utils/browser-only.pipe'; @@ -42,6 +43,7 @@ import { BrowserOnlyPipe } from '../../shared/utils/browser-only.pipe'; ProfilePageSecurityFormComponent, AsyncPipe, NgIf, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.html b/src/app/info/end-user-agreement/end-user-agreement.component.html index 2ab0005c69..ceb2ad23a4 100644 --- a/src/app/info/end-user-agreement/end-user-agreement.component.html +++ b/src/app/info/end-user-agreement/end-user-agreement.component.html @@ -7,7 +7,7 @@ {{ 'info.end-user-agreement.buttons.cancel' | translate }} - {{ 'info.end-user-agreement.buttons.save' | translate }} + {{ 'info.end-user-agreement.buttons.save' | translate }} diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts b/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts index fbb5ebc49b..88cb46e377 100644 --- a/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts +++ b/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts @@ -16,6 +16,7 @@ import { of as observableOf } from 'rxjs'; import { LogOutAction } from '../../core/auth/auth.actions'; import { AuthService } from '../../core/auth/auth.service'; import { EndUserAgreementService } from '../../core/end-user-agreement/end-user-agreement.service'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; import { EndUserAgreementComponent } from './end-user-agreement.component'; @@ -57,7 +58,7 @@ describe('EndUserAgreementComponent', () => { beforeEach(waitForAsync(() => { init(); TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), EndUserAgreementComponent], + imports: [TranslateModule.forRoot(), EndUserAgreementComponent, BtnDisabledDirective], providers: [ { provide: EndUserAgreementService, useValue: endUserAgreementService }, { provide: NotificationsService, useValue: notificationsService }, @@ -95,7 +96,8 @@ describe('EndUserAgreementComponent', () => { it('should disable the save button', () => { const button = fixture.debugElement.query(By.css('#button-save')).nativeElement; - expect(button.disabled).toBeTruthy(); + expect(button.getAttribute('aria-disabled')).toBe('true'); + expect(button.classList.contains('disabled')).toBeTrue(); }); }); diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.ts b/src/app/info/end-user-agreement/end-user-agreement.component.ts index 5c10c02432..e9835945f5 100644 --- a/src/app/info/end-user-agreement/end-user-agreement.component.ts +++ b/src/app/info/end-user-agreement/end-user-agreement.component.ts @@ -23,6 +23,7 @@ import { AppState } from '../../app.reducer'; import { LogOutAction } from '../../core/auth/auth.actions'; import { AuthService } from '../../core/auth/auth.service'; import { EndUserAgreementService } from '../../core/end-user-agreement/end-user-agreement.service'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { isNotEmpty } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { EndUserAgreementContentComponent } from './end-user-agreement-content/end-user-agreement-content.component'; @@ -32,7 +33,7 @@ import { EndUserAgreementContentComponent } from './end-user-agreement-content/e templateUrl: './end-user-agreement.component.html', styleUrls: ['./end-user-agreement.component.scss'], standalone: true, - imports: [EndUserAgreementContentComponent, FormsModule, TranslateModule], + imports: [EndUserAgreementContentComponent, FormsModule, TranslateModule, BtnDisabledDirective], }) /** * Component displaying the End User Agreement and an option to accept it diff --git a/src/app/info/feedback/feedback-form/feedback-form.component.html b/src/app/info/feedback/feedback-form/feedback-form.component.html index a3437d1f0e..c5c4b460a2 100644 --- a/src/app/info/feedback/feedback-form/feedback-form.component.html +++ b/src/app/info/feedback/feedback-form/feedback-form.component.html @@ -41,7 +41,7 @@ - {{ 'info.feedback.send' | translate }} + {{ 'info.feedback.send' | translate }} diff --git a/src/app/info/feedback/feedback-form/feedback-form.component.spec.ts b/src/app/info/feedback/feedback-form/feedback-form.component.spec.ts index 4329c923c4..56275ad5a3 100644 --- a/src/app/info/feedback/feedback-form/feedback-form.component.spec.ts +++ b/src/app/info/feedback/feedback-form/feedback-form.component.spec.ts @@ -18,6 +18,7 @@ import { FeedbackDataService } from '../../../core/feedback/feedback-data.servic import { Feedback } from '../../../core/feedback/models/feedback.model'; import { RouteService } from '../../../core/services/route.service'; import { NativeWindowService } from '../../../core/services/window.service'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { NativeWindowMockFactory } from '../../../shared/mocks/mock-native-window-ref'; import { RouterMock } from '../../../shared/mocks/router.mock'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; @@ -45,7 +46,7 @@ describe('FeedbackFormComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), FeedbackFormComponent], + imports: [TranslateModule.forRoot(), FeedbackFormComponent, BtnDisabledDirective], providers: [ { provide: RouteService, useValue: routeServiceStub }, { provide: UntypedFormBuilder, useValue: new UntypedFormBuilder() }, @@ -79,7 +80,8 @@ describe('FeedbackFormComponent', () => { }); it('should have disabled button', () => { - expect(de.query(By.css('button')).nativeElement.disabled).toBeTrue(); + expect(de.query(By.css('button')).nativeElement.getAttribute('aria-disabled')).toBe('true'); + expect(de.query(By.css('button')).nativeElement.classList.contains('disabled')).toBeTrue(); }); describe('when message is inserted', () => { @@ -90,7 +92,8 @@ describe('FeedbackFormComponent', () => { }); it('should not have disabled button', () => { - expect(de.query(By.css('button')).nativeElement.disabled).toBeFalse(); + expect(de.query(By.css('button')).nativeElement.getAttribute('aria-disabled')).toBe('false'); + expect(de.query(By.css('button')).nativeElement.classList.contains('disabled')).toBeFalse(); }); it('on submit should call createFeedback of feedbackDataServiceStub service', () => { diff --git a/src/app/info/feedback/feedback-form/feedback-form.component.ts b/src/app/info/feedback/feedback-form/feedback-form.component.ts index 59be526f17..db98a2ee01 100644 --- a/src/app/info/feedback/feedback-form/feedback-form.component.ts +++ b/src/app/info/feedback/feedback-form/feedback-form.component.ts @@ -30,6 +30,7 @@ import { import { NoContent } from '../../../core/shared/NoContent.model'; import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; import { URLCombiner } from '../../../core/url-combiner/url-combiner'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { ErrorComponent } from '../../../shared/error/error.component'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; @@ -38,7 +39,7 @@ import { NotificationsService } from '../../../shared/notifications/notification templateUrl: './feedback-form.component.html', styleUrls: ['./feedback-form.component.scss'], standalone: true, - imports: [FormsModule, ReactiveFormsModule, NgIf, ErrorComponent, TranslateModule], + imports: [FormsModule, ReactiveFormsModule, NgIf, ErrorComponent, TranslateModule, BtnDisabledDirective], }) /** * Component displaying the contents of the Feedback Statement diff --git a/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.html b/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.html index 3c08c2fb65..5f42ccee74 100644 --- a/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.html +++ b/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.html @@ -79,7 +79,7 @@ {{'bitstream-request-a-copy.submit' | translate}} diff --git a/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.ts b/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.ts index bd12d3675e..454725f2e6 100644 --- a/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.ts +++ b/src/app/item-page/bitstreams/request-a-copy/bitstream-request-a-copy-page.component.ts @@ -55,6 +55,7 @@ import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload, } from '../../../core/shared/operators'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { hasValue, isNotEmpty, @@ -71,6 +72,7 @@ import { getItemPageRoute } from '../../item-page-routing-paths'; AsyncPipe, ReactiveFormsModule, NgIf, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html index 9d8f384e16..24a6a7509a 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html @@ -16,7 +16,7 @@ class="fas fa-undo-alt"> {{"item.edit.bitstreams.reinstate-button" | translate}} - @@ -24,7 +24,7 @@ {{"item.edit.bitstreams.discard-button" | translate}} @@ -39,6 +39,9 @@ [isFirstTable]="isFirst" aria-describedby="reorder-description"> + + {{'item.edit.bitstreams.load-more.link' | translate}} + @@ -54,7 +57,7 @@ class="fas fa-undo-alt"> {{"item.edit.bitstreams.reinstate-button" | translate}} - @@ -62,7 +65,7 @@ {{"item.edit.bitstreams.discard-button" | translate}} diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts index 143723d447..c10586db47 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts @@ -1,4 +1,9 @@ -import { CommonModule } from '@angular/common'; +import { + AsyncPipe, + CommonModule, + NgForOf, + NgIf, +} from '@angular/common'; import { ChangeDetectorRef, Component, @@ -15,16 +20,22 @@ import { TranslateModule, TranslateService, } from '@ngx-translate/core'; +import { Operation } from 'fast-json-patch'; import { + BehaviorSubject, combineLatest, Observable, Subscription, } from 'rxjs'; import { + filter, map, switchMap, take, + tap, } from 'rxjs/operators'; +import { AlertComponent } from 'src/app/shared/alert/alert.component'; +import { AlertType } from 'src/app/shared/alert/alert-type'; import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { BitstreamDataService } from '../../../core/data/bitstream-data.service'; @@ -40,10 +51,14 @@ import { getFirstSucceededRemoteData, getRemoteDataPayload, } from '../../../core/shared/operators'; -import { AlertComponent } from '../../../shared/alert/alert.component'; -import { AlertType } from '../../../shared/alert/alert-type'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; +import { + hasValue, + isNotEmpty, +} from '../../../shared/empty.util'; import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { ResponsiveTableSizes } from '../../../shared/responsive-table-sizes/responsive-table-sizes'; import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model'; import { ObjectValuesPipe } from '../../../shared/utils/object-values-pipe'; @@ -58,12 +73,16 @@ import { ItemEditBitstreamBundleComponent } from './item-edit-bitstream-bundle/i templateUrl: './item-bitstreams.component.html', imports: [ CommonModule, + AsyncPipe, TranslateModule, ItemEditBitstreamBundleComponent, RouterLink, + NgIf, VarDirective, + NgForOf, ThemedLoadingComponent, AlertComponent, + BtnDisabledDirective, ], providers: [ObjectValuesPipe], standalone: true, @@ -77,9 +96,18 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme protected readonly AlertType = AlertType; /** - * The currently listed bundles + * All bundles for the current item */ - bundles$: Observable; + private bundlesSubject = new BehaviorSubject([]); + + /** + * The page options to use for fetching the bundles + */ + bundlesOptions: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'bundles-pagination-options', + currentPage: 1, + pageSize: 10, + }); /** * The bootstrap sizes used for the columns within this table @@ -98,6 +126,18 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme */ itemUpdateSubscription: Subscription; + /** + * The flag indicating to show the load more link + */ + showLoadMoreLink$: BehaviorSubject = new BehaviorSubject(true); + + /** + * The list of bundles for the current item as an observable + */ + get bundles$(): Observable { + return this.bundlesSubject.asObservable(); + } + /** * An observable which emits a boolean which represents whether the service is currently handling a 'move' request */ @@ -127,14 +167,7 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme * Actions to perform after the item has been initialized */ postItemInit(): void { - const bundlesOptions = this.itemBitstreamsService.getInitialBundlesPaginationOptions(); - this.isProcessingMoveRequest = this.itemBitstreamsService.getPerformingMoveRequest$(); - - this.bundles$ = this.itemService.getBundles(this.item.id, new PaginatedSearchOptions({ pagination: bundlesOptions })).pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - map((bundlePage: PaginatedList) => bundlePage.page), - ); + this.loadBundles(1); } /** @@ -199,6 +232,26 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme this.notificationsPrefix = 'item.edit.bitstreams.notifications.'; } + /** + * Load bundles for the current item + * @param currentPage The current page to load + */ + loadBundles(currentPage?: number) { + this.bundlesOptions = Object.assign(new PaginationComponentOptions(), this.bundlesOptions, { + currentPage: currentPage || this.bundlesOptions.currentPage + 1, + }); + this.itemService.getBundles(this.item.id, new PaginatedSearchOptions({ pagination: this.bundlesOptions })).pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + tap((bundlesPL: PaginatedList) => + this.showLoadMoreLink$.next(bundlesPL.pageInfo.currentPage < bundlesPL.pageInfo.totalPages), + ), + map((bundlePage: PaginatedList) => bundlePage.page), + ).subscribe((bundles: Bundle[]) => { + this.bundlesSubject.next([...this.bundlesSubject.getValue(), ...bundles]); + }); + } + /** * Submit the current changes @@ -208,7 +261,7 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme submit() { this.submitting = true; - const removedResponses$ = this.itemBitstreamsService.removeMarkedBitstreams(this.bundles$); + const removedResponses$ = this.itemBitstreamsService.removeMarkedBitstreams(this.bundles$.pipe(take(1))); // Perform the setup actions from above in order and display notifications removedResponses$.subscribe((responses: RemoteData) => { @@ -217,6 +270,56 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme }); } + /** + * A bitstream was dropped in a new location. Send out a Move Patch request to the REST API, display notifications, + * refresh the bundle's cache (so the lists can properly reload) and call the event's callback function (which will + * navigate the user to the correct page) + * @param bundle The bundle to send patch requests to + * @param event The event containing the index the bitstream came from and was dropped to + */ + dropBitstream(bundle: Bundle, event: any) { + this.zone.runOutsideAngular(() => { + if (hasValue(event) && hasValue(event.fromIndex) && hasValue(event.toIndex) && hasValue(event.finish)) { + const moveOperation = { + op: 'move', + from: `/_links/bitstreams/${event.fromIndex}/href`, + path: `/_links/bitstreams/${event.toIndex}/href`, + } as Operation; + this.bundleService.patch(bundle, [moveOperation]).pipe(take(1)).subscribe((response: RemoteData) => { + this.zone.run(() => { + this.displayNotifications('item.edit.bitstreams.notifications.move', [response]); + // Remove all cached requests from this bundle and call the event's callback when the requests are cleared + this.requestService.removeByHrefSubstring(bundle.self).pipe( + filter((isCached) => isCached), + take(1), + ).subscribe(() => event.finish()); + }); + }); + } + }); + } + + /** + * Display notifications + * - Error notification for each failed response with their message + * - Success notification in case there's at least one successful response + * @param key The i18n key for the notification messages + * @param responses The returned responses to display notifications for + */ + displayNotifications(key: string, responses: RemoteData[]) { + if (isNotEmpty(responses)) { + const failedResponses = responses.filter((response: RemoteData) => hasValue(response) && response.hasFailed); + const successfulResponses = responses.filter((response: RemoteData) => hasValue(response) && response.hasSucceeded); + + failedResponses.forEach((response: RemoteData) => { + this.notificationsService.error(this.translateService.instant(`${key}.failed.title`), response.errorMessage); + }); + if (successfulResponses.length > 0) { + this.notificationsService.success(this.translateService.instant(`${key}.saved.title`), this.translateService.instant(`${key}.saved.content`)); + } + } + } + /** * Request the object updates service to discard all current changes to this item * Shows a notification to remind the user that they can undo this diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html index 06201b1cbe..348118a05c 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html @@ -113,13 +113,13 @@ title="{{'item.edit.bitstreams.edit.buttons.edit' | translate}}"> - - diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts index bcdcc8d62d..969a971ce9 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts @@ -49,6 +49,7 @@ import { getAllSucceededRemoteData, paginatedListToArray, } from '../../../../core/shared/operators'; +import { BtnDisabledDirective } from '../../../../shared/btn-disabled.directive'; import { hasNoValue, hasValue, @@ -83,6 +84,7 @@ import { NgbDropdownModule, CdkDrag, BrowserOnlyPipe, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/item-page/edit-item-page/item-delete/item-delete.component.html b/src/app/item-page/edit-item-page/item-delete/item-delete.component.html index ed6ba3eabc..25780d989c 100644 --- a/src/app/item-page/edit-item-page/item-delete/item-delete.component.html +++ b/src/app/item-page/edit-item-page/item-delete/item-delete.component.html @@ -86,10 +86,10 @@ - {{confirmMessage | translate}} - {{cancelMessage| translate}} diff --git a/src/app/item-page/edit-item-page/item-delete/item-delete.component.ts b/src/app/item-page/edit-item-page/item-delete/item-delete.component.ts index ae791ccd8e..c044f62e65 100644 --- a/src/app/item-page/edit-item-page/item-delete/item-delete.component.ts +++ b/src/app/item-page/edit-item-page/item-delete/item-delete.component.ts @@ -56,6 +56,7 @@ import { getRemoteDataPayload, } from '../../../core/shared/operators'; import { ViewMode } from '../../../core/shared/view-mode.model'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { hasValue, isNotEmpty, @@ -109,6 +110,7 @@ class RelationshipDTO { VarDirective, NgForOf, RouterLink, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/item-page/edit-item-page/item-move/item-move.component.html b/src/app/item-page/edit-item-page/item-move/item-move.component.html index 8c47d4f781..63378f5afe 100644 --- a/src/app/item-page/edit-item-page/item-move/item-move.component.html +++ b/src/app/item-page/edit-item-page/item-move/item-move.component.html @@ -40,7 +40,7 @@ {{'item.edit.move.cancel' | translate}} - + {{'item.edit.move.save-button' | translate}} @@ -48,7 +48,7 @@ {{'item.edit.move.processing' | translate}} - + {{"item.edit.move.discard-button" | translate}} diff --git a/src/app/item-page/edit-item-page/item-move/item-move.component.ts b/src/app/item-page/edit-item-page/item-move/item-move.component.ts index 1115260901..07098aab11 100644 --- a/src/app/item-page/edit-item-page/item-move/item-move.component.ts +++ b/src/app/item-page/edit-item-page/item-move/item-move.component.ts @@ -37,6 +37,7 @@ import { getRemoteDataPayload, } from '../../../core/shared/operators'; import { SearchService } from '../../../core/shared/search/search.service'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { AuthorizedCollectionSelectorComponent } from '../../../shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { followLink } from '../../../shared/utils/follow-link-config.model'; @@ -56,6 +57,7 @@ import { AsyncPipe, AuthorizedCollectionSelectorComponent, NgIf, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/item-page/edit-item-page/item-operation/item-operation.component.html b/src/app/item-page/edit-item-page/item-operation/item-operation.component.html index 0977876a19..06f5e2901f 100644 --- a/src/app/item-page/edit-item-page/item-operation/item-operation.component.html +++ b/src/app/item-page/edit-item-page/item-operation/item-operation.component.html @@ -5,12 +5,12 @@ - + {{'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate}} - + {{'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate}} diff --git a/src/app/item-page/edit-item-page/item-operation/item-operation.component.spec.ts b/src/app/item-page/edit-item-page/item-operation/item-operation.component.spec.ts index e2e6da826f..85ad62de93 100644 --- a/src/app/item-page/edit-item-page/item-operation/item-operation.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-operation/item-operation.component.spec.ts @@ -6,6 +6,7 @@ import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { ItemOperationComponent } from './item-operation.component'; import { ItemOperation } from './itemOperation.model'; @@ -17,7 +18,7 @@ describe('ItemOperationComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), ItemOperationComponent], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), ItemOperationComponent, BtnDisabledDirective], }).compileComponents(); })); @@ -43,7 +44,8 @@ describe('ItemOperationComponent', () => { const span = fixture.debugElement.query(By.css('.action-label span')).nativeElement; expect(span.textContent).toContain('item.edit.tabs.status.buttons.key1.label'); const button = fixture.debugElement.query(By.css('button')).nativeElement; - expect(button.disabled).toBeTrue(); + expect(button.getAttribute('aria-disabled')).toBe('true'); + expect(button.classList.contains('disabled')).toBeTrue(); expect(button.textContent).toContain('item.edit.tabs.status.buttons.key1.button'); }); }); diff --git a/src/app/item-page/edit-item-page/item-operation/item-operation.component.ts b/src/app/item-page/edit-item-page/item-operation/item-operation.component.ts index 9cf4b30e6c..7c3793cc57 100644 --- a/src/app/item-page/edit-item-page/item-operation/item-operation.component.ts +++ b/src/app/item-page/edit-item-page/item-operation/item-operation.component.ts @@ -7,6 +7,7 @@ import { RouterLink } from '@angular/router'; import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { ItemOperation } from './itemOperation.model'; @Component({ @@ -17,6 +18,7 @@ import { ItemOperation } from './itemOperation.model'; RouterLink, NgbTooltipModule, NgIf, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html index e32938590f..06e3a4ace7 100644 --- a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html +++ b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html @@ -1,6 +1,6 @@ {{relationshipMessageKey$ | async | translate}} - + {{"item.edit.relationships.edit.buttons.add" | translate}} diff --git a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts index 73405c83f6..f470b09341 100644 --- a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts @@ -388,7 +388,8 @@ describe('EditRelationshipListComponent', () => { comp.hasChanges = observableOf(true); fixture.detectChanges(); const element = de.query(By.css('.btn-success')); - expect(element.nativeElement?.disabled).toBeTrue(); + expect(element.nativeElement?.getAttribute('aria-disabled')).toBe('true'); + expect(element.nativeElement?.classList.contains('disabled')).toBeTrue(); }); }); diff --git a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts index 7f1d9fc5db..be74be2fa0 100644 --- a/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts +++ b/src/app/item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts @@ -65,6 +65,7 @@ import { getFirstSucceededRemoteDataPayload, getRemoteDataPayload, } from '../../../../core/shared/operators'; +import { BtnDisabledDirective } from '../../../../shared/btn-disabled.directive'; import { hasNoValue, hasValue, @@ -100,6 +101,7 @@ import { EditRelationshipComponent } from '../edit-relationship/edit-relationshi TranslateModule, NgClass, ThemedLoadingComponent, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html b/src/app/item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html index e65cd237a3..8cd86d597b 100644 --- a/src/app/item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html +++ b/src/app/item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html @@ -9,12 +9,12 @@ - - diff --git a/src/app/item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts b/src/app/item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts index f79d0ee0d1..e711de94c6 100644 --- a/src/app/item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts +++ b/src/app/item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts @@ -37,6 +37,7 @@ import { getRemoteDataPayload, } from '../../../../core/shared/operators'; import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { BtnDisabledDirective } from '../../../../shared/btn-disabled.directive'; import { hasValue, isNotEmpty, @@ -54,6 +55,7 @@ import { VirtualMetadataComponent } from '../../virtual-metadata/virtual-metadat NgIf, TranslateModule, VirtualMetadataComponent, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.html b/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.html index 4c8ea49f99..0ee2e6acfd 100644 --- a/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.html +++ b/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.html @@ -35,7 +35,7 @@ {{ 'item.edit.metadata.discard-button' | translate }} @@ -45,7 +45,7 @@ {{ 'item.edit.metadata.reinstate-button' | translate }} diff --git a/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.ts b/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.ts index 7b0cc46647..42309116f2 100644 --- a/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.ts +++ b/src/app/item-page/edit-item-page/item-relationships/item-relationships.component.ts @@ -42,6 +42,7 @@ import { } from '../../../core/shared/operators'; import { AlertComponent } from '../../../shared/alert/alert.component'; import { AlertType } from '../../../shared/alert/alert-type'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { followLink } from '../../../shared/utils/follow-link-config.model'; @@ -67,6 +68,7 @@ import { EditRelationshipListWrapperComponent } from './edit-relationship-list-w TranslateModule, VarDirective, EditRelationshipListWrapperComponent, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.html b/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.html index 3217680815..a64b4ce548 100644 --- a/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.html +++ b/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.html @@ -19,7 +19,7 @@ 1"> {{ "media-viewer.previous" | translate }} @@ -27,7 +27,7 @@ {{ "media-viewer.next" | translate }} diff --git a/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.ts b/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.ts index 700431ff3f..6f962bfa8c 100644 --- a/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.ts +++ b/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.ts @@ -12,6 +12,7 @@ import { Bitstream } from 'src/app/core/shared/bitstream.model'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { CaptionInfo } from './caption-info'; import { languageHelper } from './language-helper'; @@ -27,6 +28,7 @@ import { languageHelper } from './language-helper'; NgbDropdownModule, TranslateModule, NgIf, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.html b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.html index b53c400385..905372ba8c 100644 --- a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.html +++ b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.html @@ -48,7 +48,7 @@ + [dsBtnDisabled]="(unlinkProcessing | async)"> {{ 'person.page.orcid.unlink' | translate }} 0) && !(itemsRD?.hasSucceeded && itemsRD?.payload && itemsRD?.payload?.page?.length > 0)" message="{{'loading.default' | translate}}"> 0"> objects.length" class="float-left" id="view-more"> - {{'item.page.related-items.view-more' | - translate:{ amount: (itemsRD?.payload?.totalElements - (incrementBy * objects.length) < incrementBy) ? itemsRD?.payload?.totalElements - (incrementBy * objects.length) : incrementBy } }} + {{'item.page.related-items.view-more' | + translate:{ amount: (itemsRD?.payload?.totalElements - (incrementBy * objects.length) < incrementBy) ? itemsRD?.payload?.totalElements - (incrementBy * objects.length) : incrementBy } }} {{label}} 1" class="float-right" id="view-less"> - {{'item.page.related-items.view-less' | - translate:{ amount: itemsRD?.payload?.page?.length } }} + {{'item.page.related-items.view-less' | + translate:{ amount: itemsRD?.payload?.page?.length } }} {{label}} diff --git a/src/app/item-page/versions/item-versions-row-element-version/item-versions-row-element-version.component.html b/src/app/item-page/versions/item-versions-row-element-version/item-versions-row-element-version.component.html index a664245668..a22ba008d8 100644 --- a/src/app/item-page/versions/item-versions-row-element-version/item-versions-row-element-version.component.html +++ b/src/app/item-page/versions/item-versions-row-element-version/item-versions-row-element-version.component.html @@ -31,7 +31,7 @@ @@ -41,7 +41,7 @@ diff --git a/src/app/item-page/versions/item-versions-row-element-version/item-versions-row-element-version.component.ts b/src/app/item-page/versions/item-versions-row-element-version/item-versions-row-element-version.component.ts index 3d017c38a4..e142f7854c 100644 --- a/src/app/item-page/versions/item-versions-row-element-version/item-versions-row-element-version.component.ts +++ b/src/app/item-page/versions/item-versions-row-element-version/item-versions-row-element-version.component.ts @@ -49,6 +49,7 @@ import { VersionHistory } from '../../../core/shared/version-history.model'; import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model'; import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { getItemEditVersionhistoryRoute, @@ -67,6 +68,7 @@ import { ItemVersionsSummaryModalComponent } from '../item-versions-summary-moda TranslateModule, NgClass, NgIf, + BtnDisabledDirective, ], templateUrl: './item-versions-row-element-version.component.html', styleUrl: './item-versions-row-element-version.component.scss', diff --git a/src/app/item-page/versions/item-versions.component.html b/src/app/item-page/versions/item-versions.component.html index ce5107aaa2..ad2f216838 100644 --- a/src/app/item-page/versions/item-versions.component.html +++ b/src/app/item-page/versions/item-versions.component.html @@ -67,7 +67,7 @@ diff --git a/src/app/item-page/versions/item-versions.component.spec.ts b/src/app/item-page/versions/item-versions.component.spec.ts index ee3c0110c8..b6ef946815 100644 --- a/src/app/item-page/versions/item-versions.component.spec.ts +++ b/src/app/item-page/versions/item-versions.component.spec.ts @@ -42,6 +42,7 @@ import { VersionHistory } from '../../core/shared/version-history.model'; import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service'; import { WorkspaceitemDataService } from '../../core/submission/workspaceitem-data.service'; import { AlertComponent } from '../../shared/alert/alert.component'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { PaginationComponent } from '../../shared/pagination/pagination.component'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; @@ -158,7 +159,7 @@ describe('ItemVersionsComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), RouterModule.forRoot([]), CommonModule, FormsModule, ReactiveFormsModule, BrowserModule, ItemVersionsComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterModule.forRoot([]), CommonModule, FormsModule, ReactiveFormsModule, BrowserModule, ItemVersionsComponent, VarDirective, BtnDisabledDirective], providers: [ { provide: PaginationService, useValue: new PaginationServiceStub() }, { provide: UntypedFormBuilder, useValue: new UntypedFormBuilder() }, @@ -234,8 +235,9 @@ describe('ItemVersionsComponent', () => { it('should not disable the delete button', () => { const deleteButtons: DebugElement[] = fixture.debugElement.queryAll(By.css('.version-row-element-delete')); expect(deleteButtons.length).not.toBe(0); - deleteButtons.forEach((btn: DebugElement) => { - expect(btn.nativeElement.disabled).toBe(false); + deleteButtons.forEach((btn) => { + expect(btn.nativeElement.getAttribute('aria-disabled')).toBe('false'); + expect(btn.nativeElement.classList.contains('disabled')).toBeFalse(); }); }); diff --git a/src/app/item-page/versions/item-versions.component.ts b/src/app/item-page/versions/item-versions.component.ts index 43a6fd44a4..90d70b90d3 100644 --- a/src/app/item-page/versions/item-versions.component.ts +++ b/src/app/item-page/versions/item-versions.component.ts @@ -48,6 +48,7 @@ import { Version } from '../../core/shared/version.model'; import { VersionHistory } from '../../core/shared/version-history.model'; import { AlertComponent } from '../../shared/alert/alert.component'; import { AlertType } from '../../shared/alert/alert-type'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { hasValue, hasValueOperator, @@ -85,6 +86,7 @@ interface VersionDTO { NgIf, PaginationComponent, TranslateModule, + BtnDisabledDirective, ], }) diff --git a/src/app/menu-resolver.service.ts b/src/app/menu-resolver.service.ts index 683111b32a..31210883ae 100644 --- a/src/app/menu-resolver.service.ts +++ b/src/app/menu-resolver.service.ts @@ -169,6 +169,7 @@ export class MenuResolverService { this.createExportMenuSections(); this.createImportMenuSections(); this.createAccessControlMenuSections(); + this.createReportMenuSections(); return this.waitForMenu$(MenuID.ADMIN); } diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.html b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.html index 0157110035..70905d95ee 100644 --- a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.html +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.html @@ -1,6 +1,6 @@ @@ -10,7 +10,7 @@ ngbDropdown *ngIf="(moreThanOne$ | async)"> diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.ts b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.ts index 86960725a5..cf242670b3 100644 --- a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.ts +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.ts @@ -27,6 +27,7 @@ import { FindListOptions } from '../../../core/data/find-list-options.model'; import { PaginatedList } from '../../../core/data/paginated-list.model'; import { RemoteData } from '../../../core/data/remote-data'; import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { hasValue } from '../../../shared/empty.util'; import { EntityDropdownComponent } from '../../../shared/entity-dropdown/entity-dropdown.component'; import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe'; @@ -45,6 +46,7 @@ import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe'; TranslateModule, BrowserOnlyPipe, NgIf, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.html b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.html index 730b1db1c7..c68968e91f 100644 --- a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.html +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.html @@ -1,6 +1,6 @@ + [dsBtnDisabled]="(initialized$ | async) !== true" (click)="openDialog(singleEntity)" role="button"> @@ -8,7 +8,7 @@ ngbDropdown *ngIf="(moreThanOne$ | async)"> diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.ts b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.ts index 02c714b4fb..f06df3082e 100644 --- a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.ts +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.ts @@ -28,6 +28,7 @@ import { FindListOptions } from '../../../core/data/find-list-options.model'; import { PaginatedList } from '../../../core/data/paginated-list.model'; import { RemoteData } from '../../../core/data/remote-data'; import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { ThemedCreateItemParentSelectorComponent } from '../../../shared/dso-selector/modal-wrappers/create-item-parent-selector/themed-create-item-parent-selector.component'; import { hasValue } from '../../../shared/empty.util'; import { EntityDropdownComponent } from '../../../shared/entity-dropdown/entity-dropdown.component'; @@ -47,6 +48,7 @@ import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe'; TranslateModule, BrowserOnlyPipe, NgIf, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html index 7f9b4a546f..0af5c706b7 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html @@ -1,35 +1,37 @@ - + [id]="'expandable-navbar-section-' + section.id" + (mouseenter)="onMouseEnter($event)" + (mouseleave)="onMouseLeave($event)" + data-test="navbar-section-wrapper"> + - - - - - - - + + + + + + + + diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts index d03c8d89eb..6f374b3aa5 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.spec.ts @@ -1,6 +1,11 @@ -import { Component } from '@angular/core'; +import { + Component, + DebugElement, +} from '@angular/core'; import { ComponentFixture, + fakeAsync, + flush, TestBed, waitForAsync, } from '@angular/core/testing'; @@ -10,9 +15,11 @@ import { of as observableOf } from 'rxjs'; import { HostWindowService } from '../../shared/host-window.service'; import { MenuService } from '../../shared/menu/menu.service'; +import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model'; +import { MenuSection } from '../../shared/menu/menu-section.model'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; import { MenuServiceStub } from '../../shared/testing/menu-service.stub'; -import { VarDirective } from '../../shared/utils/var.directive'; +import { HoverOutsideDirective } from '../../shared/utils/hover-outside.directive'; import { ExpandableNavbarSectionComponent } from './expandable-navbar-section.component'; describe('ExpandableNavbarSectionComponent', () => { @@ -23,11 +30,17 @@ describe('ExpandableNavbarSectionComponent', () => { describe('on larger screens', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, ExpandableNavbarSectionComponent, TestComponent, VarDirective], + imports: [ + ExpandableNavbarSectionComponent, + HoverOutsideDirective, + NoopAnimationsModule, + TestComponent, + ], providers: [ { provide: 'sectionDataProvider', useValue: {} }, { provide: MenuService, useValue: menuService }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, + TestComponent, ], }).compileComponents(); })); @@ -41,10 +54,6 @@ describe('ExpandableNavbarSectionComponent', () => { fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - describe('when the mouse enters the section header (while inactive)', () => { beforeEach(() => { spyOn(component, 'onMouseEnter').and.callThrough(); @@ -141,6 +150,8 @@ describe('ExpandableNavbarSectionComponent', () => { }); describe('when spacebar is pressed on section header (while inactive)', () => { + let sidebarToggler: DebugElement; + beforeEach(() => { spyOn(component, 'toggleSection').and.callThrough(); spyOn(menuService, 'toggleActiveSection'); @@ -149,15 +160,27 @@ describe('ExpandableNavbarSectionComponent', () => { component.ngOnInit(); fixture.detectChanges(); - const sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]')); - // dispatch the (keyup.space) action used in our component HTML - sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' })); + sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]')); }); it('should call toggleSection on the menuService', () => { + // dispatch the (keyup.space) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space', key: ' ' })); + expect(component.toggleSection).toHaveBeenCalled(); expect(menuService.toggleActiveSection).toHaveBeenCalled(); }); + + // Should not do anything in order to work correctly with NVDA: https://www.nvaccess.org/ + it('should not do anything on keydown space', () => { + const event: Event = new KeyboardEvent('keydown', { code: 'Space', key: ' ' }); + spyOn(event, 'preventDefault').and.callThrough(); + + // dispatch the (keyup.space) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); }); describe('when spacebar is pressed on section header (while active)', () => { @@ -179,12 +202,116 @@ describe('ExpandableNavbarSectionComponent', () => { expect(menuService.toggleActiveSection).toHaveBeenCalled(); }); }); + + describe('when enter is pressed on section header (while inactive)', () => { + let sidebarToggler: DebugElement; + + beforeEach(() => { + spyOn(component, 'toggleSection').and.callThrough(); + spyOn(menuService, 'toggleActiveSection'); + // Make sure section is 'inactive'. Requires calling ngOnInit() to update component 'active' property. + spyOn(menuService, 'isSectionActive').and.returnValue(observableOf(false)); + component.ngOnInit(); + fixture.detectChanges(); + + sidebarToggler = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]')); + }); + + // Should not do anything in order to work correctly with NVDA: https://www.nvaccess.org/ + it('should not do anything on keydown space', () => { + const event: Event = new KeyboardEvent('keydown', { code: 'Enter' }); + spyOn(event, 'preventDefault').and.callThrough(); + + // dispatch the (keyup.space) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); + + describe('when arrow down is pressed on section header', () => { + it('should call activateSection', () => { + spyOn(component, 'activateSection').and.callThrough(); + + const sidebarToggler: DebugElement = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]')); + // dispatch the (keydown.ArrowDown) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowDown' })); + + expect(component.focusOnFirstChildSection).toBe(true); + expect(component.activateSection).toHaveBeenCalled(); + }); + }); + + describe('when tab is pressed on section header', () => { + it('should call deactivateSection', () => { + spyOn(component, 'deactivateSection').and.callThrough(); + + const sidebarToggler: DebugElement = fixture.debugElement.query(By.css('[data-test="navbar-section-toggler"]')); + // dispatch the (keydown.ArrowDown) action used in our component HTML + sidebarToggler.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Tab' })); + + expect(component.deactivateSection).toHaveBeenCalled(); + }); + }); + + describe('navigateDropdown', () => { + beforeEach(fakeAsync(() => { + jasmine.getEnv().allowRespy(true); + spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([ + Object.assign(new MenuSection(), { + id: 'subSection1', + model: Object.assign(new LinkMenuItemModel(), { + type: 'TEST_LINK', + }), + parentId: component.section.id, + }), + Object.assign(new MenuSection(), { + id: 'subSection2', + model: Object.assign(new LinkMenuItemModel(), { + type: 'TEST_LINK', + }), + parentId: component.section.id, + }), + ])); + component.ngOnInit(); + flush(); + fixture.detectChanges(); + component.focusOnFirstChildSection = true; + component.active$.next(true); + fixture.detectChanges(); + })); + + it('should close the modal on Tab', () => { + spyOn(menuService, 'deactivateSection').and.callThrough(); + + const firstSubsection: DebugElement = fixture.debugElement.queryAll(By.css('.dropdown-menu a[role="menuitem"]'))[0]; + firstSubsection.nativeElement.focus(); + firstSubsection.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Tab' })); + + expect(menuService.deactivateSection).toHaveBeenCalled(); + }); + + it('should close the modal on Escape', () => { + spyOn(menuService, 'deactivateSection').and.callThrough(); + + const firstSubsection: DebugElement = fixture.debugElement.queryAll(By.css('.dropdown-menu a[role="menuitem"]'))[0]; + firstSubsection.nativeElement.focus(); + firstSubsection.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Escape' })); + + expect(menuService.deactivateSection).toHaveBeenCalled(); + }); + }); }); describe('on smaller, mobile screens', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, ExpandableNavbarSectionComponent, TestComponent, VarDirective], + imports: [ + ExpandableNavbarSectionComponent, + HoverOutsideDirective, + NoopAnimationsModule, + TestComponent, + ], providers: [ { provide: 'sectionDataProvider', useValue: {} }, { provide: MenuService, useValue: menuService }, @@ -253,7 +380,9 @@ describe('ExpandableNavbarSectionComponent', () => { // declare a test component @Component({ selector: 'ds-test-cmp', - template: ``, + template: ` + link + `, standalone: true, }) class TestComponent { diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts index 92da978af7..dc3db79aca 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.ts @@ -5,10 +5,12 @@ import { NgIf, } from '@angular/common'; import { + AfterViewChecked, Component, HostListener, Inject, Injector, + OnDestroy, OnInit, } from '@angular/core'; import { RouterLinkActive } from '@angular/router'; @@ -19,7 +21,8 @@ import { slide } from '../../shared/animations/slide'; import { HostWindowService } from '../../shared/host-window.service'; import { MenuService } from '../../shared/menu/menu.service'; import { MenuID } from '../../shared/menu/menu-id.model'; -import { VarDirective } from '../../shared/utils/var.directive'; +import { MenuSection } from '../../shared/menu/menu-section.model'; +import { HoverOutsideDirective } from '../../shared/utils/hover-outside.directive'; import { NavbarSectionComponent } from '../navbar-section/navbar-section.component'; /** @@ -31,9 +34,17 @@ import { NavbarSectionComponent } from '../navbar-section/navbar-section.compone styleUrls: ['./expandable-navbar-section.component.scss'], animations: [slide], standalone: true, - imports: [VarDirective, RouterLinkActive, NgComponentOutlet, NgIf, NgFor, AsyncPipe], + imports: [ + AsyncPipe, + HoverOutsideDirective, + NgComponentOutlet, + NgFor, + NgIf, + RouterLinkActive, + ], }) -export class ExpandableNavbarSectionComponent extends NavbarSectionComponent implements OnInit { +export class ExpandableNavbarSectionComponent extends NavbarSectionComponent implements AfterViewChecked, OnInit, OnDestroy { + /** * This section resides in the Public Navbar */ @@ -44,6 +55,11 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp */ mouseEntered = false; + /** + * Whether the section was expanded + */ + focusOnFirstChildSection = false; + /** * True if screen size was small before a resize event */ @@ -54,6 +70,18 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp */ isMobile$: Observable; + /** + * Boolean used to add the event listeners to the items in the expandable menu when expanded. This is done for + * performance reasons, there is currently an *ngIf on the menu to prevent the {@link HoverOutsideDirective} to tank + * performance when not expanded. + */ + addArrowEventListeners = false; + + /** + * List of current dropdown items who have event listeners + */ + private dropdownItems: NodeListOf; + @HostListener('window:resize', ['$event']) onResize() { this.isMobile$.pipe( @@ -68,29 +96,80 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp }); } - constructor(@Inject('sectionDataProvider') menuSection, - protected menuService: MenuService, - protected injector: Injector, - private windowService: HostWindowService, + constructor( + @Inject('sectionDataProvider') public section: MenuSection, + protected menuService: MenuService, + protected injector: Injector, + protected windowService: HostWindowService, ) { - super(menuSection, menuService, injector); + super(section, menuService, injector); this.isMobile$ = this.windowService.isMobile(); } ngOnInit() { super.ngOnInit(); + this.subs.push(this.active$.subscribe((active: boolean) => { + if (active === true) { + this.addArrowEventListeners = true; + } else { + this.focusOnFirstChildSection = undefined; + this.unsubscribeFromEventListeners(); + } + })); + } + + ngAfterViewChecked(): void { + if (this.addArrowEventListeners) { + this.dropdownItems = document.querySelectorAll(`#${this.expandableNavbarSectionId()} *[role="menuitem"]`); + this.dropdownItems.forEach((item: HTMLElement) => { + item.addEventListener('keydown', this.navigateDropdown.bind(this)); + }); + if (this.focusOnFirstChildSection && this.dropdownItems.length > 0) { + this.dropdownItems.item(0).focus(); + } + this.addArrowEventListeners = false; + } + } + + ngOnDestroy(): void { + super.ngOnDestroy(); + this.unsubscribeFromEventListeners(); + } + + /** + * Activate this section if it's currently inactive, deactivate it when it's currently active. + * Also saves whether this toggle was performed by a keyboard event (non-click event) in order to know if thi first + * item should be focussed when activating a section. + * + * @param {Event} event The user event that triggered this method + */ + override toggleSection(event: Event): void { + this.focusOnFirstChildSection = event.type !== 'click'; + super.toggleSection(event); + } + + /** + * Removes all the current event listeners on the dropdown items (called when the menu is closed & on component + * destruction) + */ + unsubscribeFromEventListeners(): void { + if (this.dropdownItems) { + this.dropdownItems.forEach((item: HTMLElement) => { + item.removeEventListener('keydown', this.navigateDropdown.bind(this)); + }); + this.dropdownItems = undefined; + } } /** * When the mouse enters the section toggler activate the menu section * @param $event - * @param isActive */ - onMouseEnter($event: Event, isActive: boolean) { + onMouseEnter($event: Event): void { this.isMobile$.pipe( first(), ).subscribe((isMobile) => { - if (!isMobile && !isActive && !this.mouseEntered) { + if (!isMobile && !this.active$.value && !this.mouseEntered) { this.activateSection($event); } this.mouseEntered = true; @@ -100,13 +179,12 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp /** * When the mouse leaves the section toggler deactivate the menu section * @param $event - * @param isActive */ - onMouseLeave($event: Event, isActive: boolean) { + onMouseLeave($event: Event): void { this.isMobile$.pipe( first(), ).subscribe((isMobile) => { - if (!isMobile && isActive && this.mouseEntered) { + if (!isMobile && this.active$.value && this.mouseEntered) { this.deactivateSection($event); } this.mouseEntered = false; @@ -115,9 +193,60 @@ export class ExpandableNavbarSectionComponent extends NavbarSectionComponent imp /** * returns the ID of the DOM element representing the navbar section - * @param sectionId */ - expandableNavbarSectionId(sectionId: string) { - return `expandable-navbar-section-${sectionId}-dropdown`; + expandableNavbarSectionId(): string { + return `expandable-navbar-section-${this.section.id}-dropdown`; + } + + /** + * Handles the navigation between the menu items + * + * @param event + */ + navigateDropdown(event: KeyboardEvent): void { + if (event.code === 'Tab') { + this.deactivateSection(event, false); + return; + } else if (event.code === 'Escape') { + this.deactivateSection(event, false); + (document.querySelector(`a[aria-controls="${this.expandableNavbarSectionId()}"]`) as HTMLElement)?.focus(); + return; + } + event.preventDefault(); + event.stopPropagation(); + + const items: NodeListOf = document.querySelectorAll(`#${this.expandableNavbarSectionId()} *[role="menuitem"]`); + if (items.length === 0) { + return; + } + const currentIndex: number = Array.from(items).findIndex((item: Element) => item === event.target); + + if (event.key === 'ArrowDown') { + (items[(currentIndex + 1) % items.length] as HTMLElement).focus(); + } else if (event.key === 'ArrowUp') { + (items[(currentIndex - 1 + items.length) % items.length] as HTMLElement).focus(); + } + } + + /** + * Handles all the keydown events on the dropdown toggle + * + * @param event + */ + keyDown(event: KeyboardEvent): void { + switch (event.code) { + // Works for both Tab & Shift Tab + case 'Tab': + this.deactivateSection(event, false); + break; + case 'ArrowDown': + this.focusOnFirstChildSection = true; + this.activateSection(event); + break; + case 'Space': + case 'Enter': + event.preventDefault(); + break; + } } } diff --git a/src/app/notifications/qa/events/quality-assurance-events.component.html b/src/app/notifications/qa/events/quality-assurance-events.component.html index 8a9f40e2b2..6620d80f84 100644 --- a/src/app/notifications/qa/events/quality-assurance-events.component.html +++ b/src/app/notifications/qa/events/quality-assurance-events.component.html @@ -138,7 +138,7 @@ @@ -146,7 +146,7 @@ @@ -161,7 +161,7 @@ class="btn btn-outline-success btn-sm button-width" ngbTooltip="{{'quality-assurance.event.action.import' | translate}}" container="body" - [disabled]="eventElement.isRunning" + [dsBtnDisabled]="eventElement.isRunning" (click)="modalChoice('ACCEPTED', eventElement, acceptModal)" [attr.aria-label]="'quality-assurance.event.action.import' | translate" > @@ -171,7 +171,7 @@ class="btn btn-outline-success btn-sm button-width" ngbTooltip="{{'quality-assurance.event.action.accept' | translate}}" container="body" - [disabled]="eventElement.isRunning" + [dsBtnDisabled]="eventElement.isRunning" (click)="executeAction('ACCEPTED', eventElement)" [attr.aria-label]="'quality-assurance.event.action.accept' | translate" > @@ -180,7 +180,7 @@ @@ -190,7 +190,7 @@ *ngIf="(isAdmin$ | async)" ngbTooltip="{{'quality-assurance.event.action.reject' | translate}}" container="body" - [disabled]="eventElement.isRunning" + [dsBtnDisabled]="eventElement.isRunning" (click)="openModal('REJECTED', eventElement, rejectModal)" [attr.aria-label]="'quality-assurance.event.action.reject' | translate" > @@ -200,7 +200,7 @@ *ngIf="(isAdmin$ | async) === false" ngbTooltip="{{'quality-assurance.event.action.undo' | translate }}" container="body" - [disabled]="eventElement.isRunning" + [dsBtnDisabled]="eventElement.isRunning" [attr.aria-label]="'quality-assurance.event.action.undo' | translate" (click)="openModal('UNDO', eventElement, undoModal)"> @@ -210,7 +210,7 @@ diff --git a/src/app/notifications/qa/events/quality-assurance-events.component.ts b/src/app/notifications/qa/events/quality-assurance-events.component.ts index df6d36d226..23fc73345d 100644 --- a/src/app/notifications/qa/events/quality-assurance-events.component.ts +++ b/src/app/notifications/qa/events/quality-assurance-events.component.ts @@ -65,6 +65,7 @@ import { } from '../../../core/shared/operators'; import { getItemPageRoute } from '../../../item-page/item-page-routing-paths'; import { AlertComponent } from '../../../shared/alert/alert.component'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { hasValue } from '../../../shared/empty.util'; import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; @@ -86,7 +87,7 @@ import { EPersonDataComponent } from './ePerson-data/ePerson-data.component'; templateUrl: './quality-assurance-events.component.html', styleUrls: ['./quality-assurance-events.component.scss'], standalone: true, - imports: [AlertComponent, NgIf, ThemedLoadingComponent, PaginationComponent, NgFor, RouterLink, NgbTooltipModule, AsyncPipe, TranslateModule, EPersonDataComponent], + imports: [AlertComponent, NgIf, ThemedLoadingComponent, PaginationComponent, NgFor, RouterLink, NgbTooltipModule, AsyncPipe, TranslateModule, EPersonDataComponent, BtnDisabledDirective], }) export class QualityAssuranceEventsComponent implements OnInit, OnDestroy { /** diff --git a/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.html b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.html index 026f863412..4b9bbe64f3 100644 --- a/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.html +++ b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.html @@ -32,8 +32,8 @@ - {{(labelPrefix + label + '.clear'|translate)}} - {{(labelPrefix + label + '.search'|translate)}} + {{(labelPrefix + label + '.clear'|translate)}} + {{(labelPrefix + label + '.search'|translate)}} @@ -66,6 +66,6 @@ {{ (labelPrefix + label + '.cancel' | translate) }} - {{ (labelPrefix + label + '.bound' | translate) }} + {{ (labelPrefix + label + '.bound' | translate) }} diff --git a/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.ts b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.ts index 99f57b84ca..4954a32747 100644 --- a/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.ts +++ b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.ts @@ -30,6 +30,7 @@ import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { Item } from '../../../core/shared/item.model'; import { SearchService } from '../../../core/shared/search/search.service'; import { AlertComponent } from '../../../shared/alert/alert.component'; +import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; import { hasValue, isNotEmpty, @@ -105,7 +106,7 @@ export interface QualityAssuranceEventData { styleUrls: ['./project-entry-import-modal.component.scss'], templateUrl: './project-entry-import-modal.component.html', standalone: true, - imports: [RouterLink, NgIf, FormsModule, ThemedLoadingComponent, ThemedSearchResultsComponent, AlertComponent, AsyncPipe, TranslateModule], + imports: [RouterLink, NgIf, FormsModule, ThemedLoadingComponent, ThemedSearchResultsComponent, AlertComponent, AsyncPipe, TranslateModule, BtnDisabledDirective], }) /** * Component to display a modal window for linking a project to an Quality Assurance event diff --git a/src/app/notifications/suggestion-actions/suggestion-actions.component.html b/src/app/notifications/suggestion-actions/suggestion-actions.component.html index 2a46191dee..342919d4ae 100644 --- a/src/app/notifications/suggestion-actions/suggestion-actions.component.html +++ b/src/app/notifications/suggestion-actions/suggestion-actions.component.html @@ -21,7 +21,7 @@ {{ ignoreSuggestionLabel() | translate}} - + {{ 'suggestion.seeEvidence' | translate}} {{ 'suggestion.hideEvidence' | translate}} diff --git a/src/app/notifications/suggestion-actions/suggestion-actions.component.ts b/src/app/notifications/suggestion-actions/suggestion-actions.component.ts index 36d58acbbf..0362e50f4c 100644 --- a/src/app/notifications/suggestion-actions/suggestion-actions.component.ts +++ b/src/app/notifications/suggestion-actions/suggestion-actions.component.ts @@ -15,6 +15,7 @@ import { take } from 'rxjs/operators'; import { Suggestion } from '../../core/notifications/suggestions/models/suggestion.model'; import { Collection } from '../../core/shared/collection.model'; import { ItemType } from '../../core/shared/item-relationships/item-type.model'; +import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { ThemedCreateItemParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/themed-create-item-parent-selector.component'; import { EntityDropdownComponent } from '../../shared/entity-dropdown/entity-dropdown.component'; import { SuggestionApproveAndImport } from '../suggestion-list-element/suggestion-approve-and-import'; @@ -31,6 +32,7 @@ import { SuggestionApproveAndImport } from '../suggestion-list-element/suggestio TranslateModule, NgIf, NgbDropdownModule, + BtnDisabledDirective, ], standalone: true, }) diff --git a/src/app/notifications/suggestion-targets/suggestion-targets.effects.ts b/src/app/notifications/suggestion-targets/suggestion-targets.effects.ts index de92e98ef8..4a1b413872 100644 --- a/src/app/notifications/suggestion-targets/suggestion-targets.effects.ts +++ b/src/app/notifications/suggestion-targets/suggestion-targets.effects.ts @@ -13,6 +13,10 @@ import { switchMap, tap, } from 'rxjs/operators'; +import { ConfigurationDataService } from 'src/app/core/data/configuration-data.service'; +import { RemoteData } from 'src/app/core/data/remote-data'; +import { ConfigurationProperty } from 'src/app/core/shared/configuration-property.model'; +import { getFirstCompletedRemoteData } from 'src/app/core/shared/operators'; import { AuthActionTypes, @@ -72,14 +76,23 @@ export class SuggestionTargetsEffects { ), { dispatch: false }); /** - * Show a notification on error. + * Retrieve the current user suggestions after retrieving the authenticated user */ retrieveUserTargets$ = createEffect(() => this.actions$.pipe( ofType(AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS), switchMap((action: RetrieveAuthenticatedEpersonSuccessAction) => { - return this.suggestionsService.retrieveCurrentUserSuggestions(action.payload).pipe( - map((suggestionTargets: SuggestionTarget[]) => new AddUserSuggestionsAction(suggestionTargets)), - ); + return this.configurationService.findByPropertyName('researcher-profile.entity-type').pipe( + getFirstCompletedRemoteData(), + switchMap((configRD: RemoteData ) => { + if (configRD.hasSucceeded && configRD.payload.values.length > 0) { + return this.suggestionsService.retrieveCurrentUserSuggestions(action.payload).pipe( + map((suggestionTargets: SuggestionTarget[]) => new AddUserSuggestionsAction(suggestionTargets)), + ); + } else { + return of(new AddUserSuggestionsAction([])); + } + }, + )); }))); /** @@ -91,16 +104,35 @@ export class SuggestionTargetsEffects { return this.store$.select((state: any) => state.core.auth.userId) .pipe( switchMap((userId: string) => { - return this.suggestionsService.retrieveCurrentUserSuggestions(userId) - .pipe( - map((suggestionTargets: SuggestionTarget[]) => new AddUserSuggestionsAction(suggestionTargets)), - catchError((error: unknown) => { - if (error instanceof Error) { - console.error(error.message); - } - return of(new RefreshUserSuggestionsErrorAction()); - }), - ); + if (!userId) { + return of(new AddUserSuggestionsAction([])); + } + return this.configurationService.findByPropertyName('researcher-profile.entity-type').pipe( + getFirstCompletedRemoteData(), + switchMap((configRD: RemoteData ) => { + if (configRD.hasSucceeded && configRD.payload.values.length > 0) { + return this.suggestionsService.retrieveCurrentUserSuggestions(userId) + .pipe( + map((suggestionTargets: SuggestionTarget[]) => new AddUserSuggestionsAction(suggestionTargets)), + catchError((error: unknown) => { + if (error instanceof Error) { + console.error(error.message); + } + return of(new RefreshUserSuggestionsErrorAction()); + }), + ); + } else { + return of(new AddUserSuggestionsAction([])); + } + }, + ), + catchError((error: unknown) => { + if (error instanceof Error) { + console.error(error.message); + } + return of(new RefreshUserSuggestionsErrorAction()); + }), + ); }), catchError((error: unknown) => { if (error instanceof Error) { @@ -119,6 +151,7 @@ export class SuggestionTargetsEffects { * @param {TranslateService} translate * @param {NotificationsService} notificationsService * @param {SuggestionsService} suggestionsService + * @param {ConfigurationDataService} configurationService */ constructor( private actions$: Actions, @@ -126,6 +159,7 @@ export class SuggestionTargetsEffects { private translate: TranslateService, private notificationsService: NotificationsService, private suggestionsService: SuggestionsService, + private configurationService: ConfigurationDataService, ) { } } diff --git a/src/app/process-page/form/scripts-select/scripts-select.component.html b/src/app/process-page/form/scripts-select/scripts-select.component.html index 9cd86d1a6d..5c161f8d8d 100644 --- a/src/app/process-page/form/scripts-select/scripts-select.component.html +++ b/src/app/process-page/form/scripts-select/scripts-select.component.html @@ -1,20 +1,47 @@ - - {{'process.new.select-script' | translate}} - - {{'process.new.select-script.placeholder' | translate}} - - {{script.name}} - - - + + + + + + + {{ script.name }} + + + + + + + + + + + - - {{'process.new.select-script.required' | translate}} - + + {{ 'process.new.select-script.required' | translate }} + + diff --git a/src/app/process-page/form/scripts-select/scripts-select.component.scss b/src/app/process-page/form/scripts-select/scripts-select.component.scss index e69de29bb2..4f9ca62310 100644 --- a/src/app/process-page/form/scripts-select/scripts-select.component.scss +++ b/src/app/process-page/form/scripts-select/scripts-select.component.scss @@ -0,0 +1,23 @@ +.dropdown-item { + padding: 0.35rem 1rem; + + &:active { + color: white !important; + } +} + +.scrollable-menu { + height: auto; + max-height: var(--ds-dropdown-menu-max-height); + overflow-x: hidden; +} + +li:not(:last-of-type) .dropdown-item { + border-bottom: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color); +} + +#entityControlsDropdownMenu { + outline: 0; + left: 0 !important; + box-shadow: var(--bs-btn-focus-box-shadow); +} diff --git a/src/app/process-page/form/scripts-select/scripts-select.component.spec.ts b/src/app/process-page/form/scripts-select/scripts-select.component.spec.ts index 6b8c39eb8f..7b095926fb 100644 --- a/src/app/process-page/form/scripts-select/scripts-select.component.spec.ts +++ b/src/app/process-page/form/scripts-select/scripts-select.component.spec.ts @@ -87,7 +87,7 @@ describe('ScriptsSelectComponent', () => { fixture.detectChanges(); tick(); - const select = fixture.debugElement.query(By.css('select')); + const select = fixture.debugElement.query(By.css('#process-script')); select.triggerEventHandler('blur', null); fixture.detectChanges(); @@ -101,7 +101,7 @@ describe('ScriptsSelectComponent', () => { fixture.detectChanges(); tick(); - const select = fixture.debugElement.query(By.css('select')); + const select = fixture.debugElement.query(By.css('#process-script')); select.triggerEventHandler('blur', null); fixture.detectChanges(); diff --git a/src/app/process-page/form/scripts-select/scripts-select.component.ts b/src/app/process-page/form/scripts-select/scripts-select.component.ts index 63c11bd91a..9eccb7ceff 100644 --- a/src/app/process-page/form/scripts-select/scripts-select.component.ts +++ b/src/app/process-page/form/scripts-select/scripts-select.component.ts @@ -19,32 +19,29 @@ import { } from '@angular/forms'; import { ActivatedRoute, - Params, Router, } from '@angular/router'; +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; +import { InfiniteScrollModule } from 'ngx-infinite-scroll'; import { - Observable, + BehaviorSubject, Subscription, } from 'rxjs'; import { - distinctUntilChanged, - filter, map, - switchMap, - take, + tap, } from 'rxjs/operators'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; import { PaginatedList } from '../../../core/data/paginated-list.model'; import { ScriptDataService } from '../../../core/data/processes/script-data.service'; import { - getFirstSucceededRemoteData, + getFirstCompletedRemoteData, getRemoteDataPayload, } from '../../../core/shared/operators'; -import { - hasNoValue, - hasValue, -} from '../../../shared/empty.util'; +import { hasValue } from '../../../shared/empty.util'; +import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; import { Script } from '../../scripts/script.model'; import { controlContainerFactory } from '../process-form-factory'; @@ -61,7 +58,7 @@ const SCRIPT_QUERY_PARAMETER = 'script'; useFactory: controlContainerFactory, deps: [[new Optional(), NgForm]] }], standalone: true, - imports: [NgIf, FormsModule, NgFor, AsyncPipe, TranslateModule], + imports: [NgIf, FormsModule, NgFor, AsyncPipe, TranslateModule, InfiniteScrollModule, ThemedLoadingComponent, NgbDropdownModule], }) export class ScriptsSelectComponent implements OnInit, OnDestroy { /** @@ -71,9 +68,19 @@ export class ScriptsSelectComponent implements OnInit, OnDestroy { /** * All available scripts */ - scripts$: Observable
{{ 'community.delete.text' | translate:{ dso: dsoNameService.getName(dso) } }}