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 + +``` + +##### 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 + +``` + + + + +#### Invalid code & automatic fixes + +##### should not use disabled attribute in HTML templates + +```html + +``` +Will produce the following error(s): +``` +Buttons should use the `dsBtnDisabled` directive instead of the `disabled` attribute. +``` + +Result of `yarn lint --fix`: +```html + +``` + + +##### should not use [disabled] attribute in HTML templates + +```html + +``` +Will produce the following error(s): +``` +Buttons should use the `dsBtnDisabled` directive instead of the `disabled` attribute. +``` + +Result of `yarn lint --fix`: +```html + +``` + + + 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: ` + + `, + }, + { + 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: ` + + `, + }, + ], + invalid: [ + { + name: 'should not use disabled attribute in HTML templates', + code: ` + + `, + errors: [{ messageId: Message.USE_DSBTN_DISABLED }], + output: ` + + `, + }, + { + name: 'should not use [disabled] attribute in HTML templates', + code: ` + + `, + errors: [{ messageId: Message.USE_DSBTN_DISABLED }], + output: ` + + `, + }, + ], +} 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 @@ - 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 @@
-
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 @@
  - +
@@ -158,8 +158,8 @@ {{'admin.reports.commons.page' | translate}} {{ currentPage + 1 }} {{'admin.reports.commons.of' | translate}} {{ pageCount() }}
- - + + 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 @@ -
+
@@ -8,7 +8,7 @@ ngbDropdown *ngIf="(moreThanOne$ | async)"> - + +
@@ -66,6 +66,6 @@
- +
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 @@ - + + + + + + +
-
- {{'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; + scripts: Script[] = []; + private _selectedScript: Script; - private routeSub: Subscription; + private subscription: Subscription; + + private _isLastPage = false; + + scriptOptions: FindListOptions = { + elementsPerPage: 20, + currentPage: 1, + }; + + isLoading$: BehaviorSubject = new BehaviorSubject(false); constructor( private scriptService: ScriptDataService, @@ -87,31 +94,46 @@ export class ScriptsSelectComponent implements OnInit, OnDestroy { * Checks if the route contains a script ID and auto selects this scripts */ ngOnInit() { - this.scripts$ = this.scriptService.findAll({ elementsPerPage: 9999 }) - .pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - map((paginatedList: PaginatedList