diff --git a/config/config.example.yml b/config/config.example.yml index 7a20cef94c..a1b2f3f579 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -17,12 +17,34 @@ ui: # Trust X-FORWARDED-* headers from proxies (default = true) useProxies: true -# Angular Server Side Rendering (SSR) settings -ssr: +# Angular Universal / Server Side Rendering (SSR) settings +universal: # Whether to tell Angular to inline "critical" styles into the server-side rendered HTML. # Determining which styles are critical is a relatively expensive operation; this option is # 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. + # 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 # The REST API server settings # NOTE: these settings define which (publicly available) REST API to use. They are usually @@ -33,6 +55,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: @@ -82,7 +107,7 @@ cache: anonymousCache: # Maximum number of pages to cache. Default is zero (0) which means anonymous user cache is disabled. # As all pages are cached in server memory, increasing this value will increase memory needs. - # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. + # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. max: 0 # Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached # copy is automatically refreshed on the next request. @@ -392,7 +417,7 @@ vocabularies: vocabulary: 'srsc' enabled: true -# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. +# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. comcolSelectionSort: sortField: 'dc.title' sortDirection: 'ASC' @@ -411,6 +436,15 @@ liveRegion: # The visibility of the live region. Setting this to true is only useful for debugging purposes. isVisible: false + +# Search settings +search: + # 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 + # Configuration for storing accessibility settings, used by the AccessibilitySettingsService accessibility: # The duration in days after which the accessibility settings cookie expires diff --git a/cypress/e2e/admin-add-new-modals.cy.ts b/cypress/e2e/admin-add-new-modals.cy.ts index 0179ca7a7c..332d44da13 100644 --- a/cypress/e2e/admin-add-new-modals.cy.ts +++ b/cypress/e2e/admin-add-new-modals.cy.ts @@ -9,6 +9,7 @@ describe('Admin Add New Modals', () => { it('Add new Community modal should pass accessibility tests', () => { // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); cy.get('#sidebar-collapse-toggle').click(); // Click on entry of menu @@ -23,6 +24,7 @@ describe('Admin Add New Modals', () => { it('Add new Collection modal should pass accessibility tests', () => { // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); cy.get('#sidebar-collapse-toggle').click(); // Click on entry of menu @@ -37,6 +39,7 @@ describe('Admin Add New Modals', () => { it('Add new Item modal should pass accessibility tests', () => { // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); cy.get('#sidebar-collapse-toggle').click(); // Click on entry of menu diff --git a/cypress/e2e/admin-edit-modals.cy.ts b/cypress/e2e/admin-edit-modals.cy.ts index 79190bfce9..8ba524d5be 100644 --- a/cypress/e2e/admin-edit-modals.cy.ts +++ b/cypress/e2e/admin-edit-modals.cy.ts @@ -9,6 +9,7 @@ describe('Admin Edit Modals', () => { it('Edit Community modal should pass accessibility tests', () => { // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); cy.get('#sidebar-collapse-toggle').click(); // Click on entry of menu @@ -23,6 +24,7 @@ describe('Admin Edit Modals', () => { it('Edit Collection modal should pass accessibility tests', () => { // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); cy.get('#sidebar-collapse-toggle').click(); // Click on entry of menu @@ -37,6 +39,7 @@ describe('Admin Edit Modals', () => { it('Edit Item modal should pass accessibility tests', () => { // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); cy.get('#sidebar-collapse-toggle').click(); // Click on entry of menu diff --git a/cypress/e2e/admin-export-modals.cy.ts b/cypress/e2e/admin-export-modals.cy.ts index 55d952cad8..24a184fd35 100644 --- a/cypress/e2e/admin-export-modals.cy.ts +++ b/cypress/e2e/admin-export-modals.cy.ts @@ -9,6 +9,7 @@ describe('Admin Export Modals', () => { it('Export metadata modal should pass accessibility tests', () => { // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); cy.get('#sidebar-collapse-toggle').click(); // Click on entry of menu @@ -23,6 +24,7 @@ describe('Admin Export Modals', () => { it('Export batch modal should pass accessibility tests', () => { // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').trigger('mouseover'); cy.get('#sidebar-collapse-toggle').click(); // Click on entry of menu diff --git a/package.json b/package.json index ce5449fa33..1ab4e54262 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dspace-angular", - "version": "7.6.3-next", + "version": "7.6.4-next", "scripts": { "ng": "ng", "config:watch": "nodemon", @@ -60,7 +60,7 @@ "@angular/platform-browser-dynamic": "^15.2.10", "@angular/platform-server": "^15.2.10", "@angular/router": "^15.2.10", - "@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": "^15.0.0", @@ -80,7 +80,7 @@ "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.30.0", "date-fns-tz": "^1.3.7", "deepmerge": "^4.3.1", @@ -91,18 +91,18 @@ "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", "klaro": "^0.7.21", "lodash": "^4.17.21", "lru-cache": "^7.14.1", "markdown-it": "^13.0.2", "markdown-it-mathjax3": "^4.3.2", - "mirador": "^3.4.2", + "mirador": "^3.4.3", "mirador-dl-plugin": "^0.13.0", "mirador-share-plugin": "^0.16.0", "morgan": "^1.10.0", @@ -110,6 +110,7 @@ "ng2-nouislider": "^2.0.0", "ngx-infinite-scroll": "^15.0.0", "ngx-pagination": "6.0.3", + "ngx-skeleton-loader": "^7.0.0", "ngx-sortablejs": "^11.1.0", "ngx-ui-switch": "^14.1.0", "nouislider": "^15.8.1", @@ -145,7 +146,7 @@ "@types/grecaptcha": "^3.0.9", "@types/jasmine": "~3.6.0", "@types/js-cookie": "2.2.6", - "@types/lodash": "^4.17.13", + "@types/lodash": "^4.17.15", "@types/node": "^14.18.63", "@types/sanitize-html": "^2.13.0", "@typescript-eslint/eslint-plugin": "^5.62.0", @@ -156,13 +157,13 @@ "cross-env": "^7.0.3", "csstype": "^3.1.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.5.0", "eslint-plugin-import": "^2.31.0", "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-unused-imports": "^2.0.0", "express-static-gzip": "^2.2.0", @@ -177,7 +178,7 @@ "ng-mocks": "^14.13.2", "ngx-mask": "^13.1.7", "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", @@ -186,7 +187,7 @@ "react-copy-to-clipboard": "^5.1.0", "react-dom": "^16.14.0", "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 23d29723c6..cfab230ef5 100644 --- a/server.ts +++ b/server.ts @@ -79,6 +79,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() { @@ -176,7 +179,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 })); @@ -185,7 +188,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 })); @@ -238,7 +241,7 @@ export function app() { * The callback function to serve server side angular */ function ngApp(req, res) { - if (environment.universal.preboot) { + if (environment.universal.preboot && req.method === 'GET' && (req.path === '/' || environment.universal.paths.some(pathPrefix => req.path.startsWith(pathPrefix)))) { // Render the page to user via SSR (server side rendering) serverSideRender(req, res); } else { @@ -269,6 +272,11 @@ function serverSideRender(req, res, sendToUser: boolean = true) { requestUrl: req.originalUrl, }, (err, data) => { if (hasNoValue(err) && hasValue(data)) { + // Replace REST URL with UI URL + if (environment.universal.replaceRestUrl && REST_BASE_URL !== environment.rest.baseUrl) { + data = data.replace(new RegExp(REST_BASE_URL, 'g'), environment.rest.baseUrl); + } + // save server side rendered page to cache (if any are enabled) saveToCache(req, data); if (sendToUser) { @@ -621,7 +629,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/epeople-registry/epeople-registry.component.spec.ts b/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts index e2cee5e935..d948732321 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 @@ -27,6 +27,7 @@ import { RequestService } from '../../core/data/request.service'; import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { FindListOptions } from '../../core/data/find-list-options.model'; +import {BtnDisabledDirective} from '../../shared/btn-disabled.directive'; describe('EPeopleRegistryComponent', () => { let component: EPeopleRegistryComponent; @@ -131,7 +132,7 @@ describe('EPeopleRegistryComponent', () => { } }), ], - declarations: [EPeopleRegistryComponent], + declarations: [EPeopleRegistryComponent, BtnDisabledDirective], providers: [ { provide: EPersonDataService, useValue: ePersonDataServiceStub }, { provide: NotificationsService, useValue: new NotificationsServiceStub() }, @@ -239,7 +240,8 @@ describe('EPeopleRegistryComponent', () => { it('should be disabled', () => { ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button')); ePeopleDeleteButton.forEach((deleteButton: DebugElement) => { - expect(deleteButton.nativeElement.disabled).toBe(true); + expect(deleteButton.nativeElement.getAttribute('aria-disabled')).toBe('true'); + expect(deleteButton.nativeElement.classList.contains('disabled')).toBeTrue(); }); }); }); 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 d129e94ff9..769ff84c33 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 f8d526fe39..0dd30b2f24 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 @@ -35,6 +35,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { RouterStub } from '../../../shared/testing/router.stub'; import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; import { RouterTestingModule } from '@angular/router/testing'; +import {BtnDisabledDirective} from '../../../shared/btn-disabled.directive'; describe('EPersonFormComponent', () => { let component: EPersonFormComponent; @@ -195,7 +196,7 @@ describe('EPersonFormComponent', () => { RouterTestingModule, TranslateModule.forRoot(), ], - declarations: [EPersonFormComponent], + declarations: [EPersonFormComponent, BtnDisabledDirective], providers: [ { provide: EPersonDataService, useValue: ePersonDataServiceStub }, { provide: GroupDataService, useValue: groupsDataService }, @@ -481,7 +482,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/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 e1fc69026b..984442ebc4 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 @@ -34,7 +34,7 @@
- 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 521c771cfa..05307b86ee 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 @@
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 3eb83ebe8a..ed53f8ab2f 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 { TestScheduler } from 'rxjs/testing'; import { By } from '@angular/platform-browser'; import { VarDirective } from '../../../../shared/utils/var.directive'; import { ContentSourceSetSerializer } from '../../../../core/shared/content-source-set-serializer'; +import {BtnDisabledDirective} from '../../../../shared/btn-disabled.directive'; describe('CollectionSourceControlsComponent', () => { let comp: CollectionSourceControlsComponent; @@ -100,7 +101,7 @@ describe('CollectionSourceControlsComponent', () => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule], - declarations: [CollectionSourceControlsComponent, VarDirective], + declarations: [CollectionSourceControlsComponent, VarDirective, BtnDisabledDirective], providers: [ {provide: ScriptDataService, useValue: scriptDataService}, {provide: ProcessDataService, useValue: processDataService}, @@ -189,9 +190,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; @@ -201,9 +203,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.component.html b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html index 1fe121e898..2839ba8fbf 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 @@
- diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 4ad856fd88..751ac44049 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -116,12 +116,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 18131dea63..234e7272a6 100644 --- a/src/app/core/data/base/base-data.service.ts +++ b/src/app/core/data/base/base-data.service.ts @@ -6,7 +6,7 @@ * http://www.dspace.org/license/ */ -import { AsyncSubject, from as observableFrom, Observable, of as observableOf } from 'rxjs'; +import { AsyncSubject, from as observableFrom, Observable, of as observableOf, shareReplay } from 'rxjs'; import { map, mergeMap, skipWhile, switchMap, take, tap, toArray } from 'rxjs/operators'; import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; @@ -264,6 +264,7 @@ export class BaseDataService implements HALDataServic isNotEmptyOperator(), take(1), map((href: string) => this.buildHrefFromFindOptions(href, {}, [], ...linksToFollow)), + shareReplay(1), ); const startTime: number = new Date().getTime(); @@ -299,6 +300,7 @@ export class BaseDataService implements HALDataServic isNotEmptyOperator(), take(1), map((href: string) => this.buildHrefFromFindOptions(href, options, [], ...linksToFollow)), + shareReplay(1), ); 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 89178f8dd2..1f4b2efaf6 100644 --- a/src/app/core/data/bitstream-data.service.spec.ts +++ b/src/app/core/data/bitstream-data.service.spec.ts @@ -21,6 +21,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import objectContaining = jasmine.objectContaining; import { RemoteData } from './remote-data'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RequestParam } from '../cache/models/request-param.model'; describe('BitstreamDataService', () => { let service: BitstreamDataService; @@ -132,4 +133,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 bf8e3585df..2e507173df 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -1,13 +1,14 @@ import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable, EMPTY } from 'rxjs'; import { find, map, switchMap, take } from 'rxjs/operators'; import { hasValue } from '../../shared/empty.util'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { FollowLinkConfig, followLink } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { Bitstream } from '../shared/bitstream.model'; import { BITSTREAM } from '../shared/bitstream.resource-type'; +import { getFirstCompletedRemoteData } from '../shared/operators'; import { Bundle } from '../shared/bundle.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; @@ -171,7 +172,7 @@ export class BitstreamDataService extends IdentifiableDataService imp searchParams.push(new RequestParam('sequenceId', sequenceId)); } if (hasValue(filename)) { - searchParams.push(new RequestParam('filename', encodeURIComponent(filename))); + searchParams.push(new RequestParam('filename', filename)); } const hrefObs = this.getSearchByHref( @@ -201,6 +202,38 @@ export class BitstreamDataService extends IdentifiableDataService imp return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); } + + /** + * + * Make a request to get primary bitstream + * in all current use cases, and having it simplifies this method + * + * @param item the {@link Item} the {@link Bundle} is a part of + * @param bundleName the name of the {@link Bundle} we want to find + * {@link Bitstream}s for + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * 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, options?: FindListOptions): Observable { + return this.bundleService.findByItemAndName(item, bundleName, useCachedVersionIfAvailable, reRequestOnStale, options, followLink('primaryBitstream')).pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (!rd.hasSucceeded) { + return EMPTY; + } + return rd.payload.primaryBitstream.pipe( + getFirstCompletedRemoteData(), + map((rdb: RemoteData) => rdb.hasSucceeded ? rdb.payload : null), + ); + }), + ); + } + /** * Make a new FindListRequest with given search method * diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index 19f0e73706..78ae204fe7 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -75,10 +75,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/data/relationship-type-data.service.ts b/src/app/core/data/relationship-type-data.service.ts index 81612d0278..263920a615 100644 --- a/src/app/core/data/relationship-type-data.service.ts +++ b/src/app/core/data/relationship-type-data.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; -import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { map, mergeMap, switchMap, toArray } from 'rxjs/operators'; +import { combineLatest as observableCombineLatest, EMPTY, expand, from, Observable, reduce } from 'rxjs'; +import { map, mergeMap, toArray } from 'rxjs/operators'; import { hasValue } from '../../shared/empty.util'; import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -62,34 +62,49 @@ export class RelationshipTypeDataService extends BaseDataService { // Retrieve all relationship types from the server in a single page - return this.findAllData.findAll({ currentPage: 1, elementsPerPage: 9999 }, true, true, followLink('leftType'), followLink('rightType')) - .pipe( - getFirstSucceededRemoteData(), - // Emit each type in the page array separately - switchMap((typeListRD: RemoteData>) => typeListRD.payload.page), - // Check each type individually, to see if it matches the provided types - mergeMap((relationshipType: RelationshipType) => { - if (relationshipType.leftwardType === relationshipTypeLabel) { - return this.checkType(relationshipType, firstItemType, secondItemType); - } else if (relationshipType.rightwardType === relationshipTypeLabel) { - return this.checkType(relationshipType, secondItemType, firstItemType); - } else { - return [null]; - } - }), - // Wait for all types to be checked and emit once, with the results combined back into an - // array - toArray(), - // Look for a match in the array and emit it if found, or null if one isn't found - map((types: RelationshipType[]) => { - const match = types.find((type: RelationshipType) => hasValue(type)); - if (hasValue(match)) { - return match; - } else { - return null; - } - }), - ); + const initialPageInfo = { currentPage: 1, elementsPerPage: 20 }; + return this.findAllData.findAll(initialPageInfo, true, true, followLink('leftType'), followLink('rightType')) + .pipe( + getFirstSucceededRemoteData(), + // Emit each type in the page array separately + expand((typeListRD: RemoteData>) => { + const currentPage = typeListRD.payload.pageInfo.currentPage; + const totalPages = typeListRD.payload.pageInfo.totalPages; + if (currentPage < totalPages) { + const nextPageInfo = { currentPage: currentPage + 1, elementsPerPage: 20 }; + return this.findAllData.findAll(nextPageInfo, true, true, followLink('leftType'), followLink('rightType')).pipe( + getFirstSucceededRemoteData() + ); + } else { + return EMPTY; + } + }), + // Collect all pages into a single array + reduce((acc: RelationshipType[], typeListRD: RemoteData>) => acc.concat(typeListRD.payload.page), []), + mergeMap((relationshipTypes: RelationshipType[]) => from(relationshipTypes)), + // Check each type individually, to see if it matches the provided types + mergeMap((relationshipType: RelationshipType) => { + if (relationshipType.leftwardType === relationshipTypeLabel) { + return this.checkType(relationshipType, firstItemType, secondItemType); + } else if (relationshipType.rightwardType === relationshipTypeLabel) { + return this.checkType(relationshipType, secondItemType, firstItemType); + } else { + return [null]; + } + }), + // Wait for all types to be checked and emit once, with the results combined back into an + // array + toArray(), + // Look for a match in the array and emit it if found, or null if one isn't found + map((types: RelationshipType[]) => { + const match = types.find((type: RelationshipType) => hasValue(type)); + if (hasValue(match)) { + return match; + } else { + return null; + } + }), + ); } /** 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/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 204c925e6b..5659a75f0e 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -45,6 +45,7 @@ import { CoreState } from '../core-state.model'; import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; import { getDownloadableBitstream } from '../shared/bitstream.operators'; import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface'; +import { FindListOptions } from '../data/find-list-options.model'; /** * The base selector function to select the metaTag section in the store @@ -306,6 +307,7 @@ export class MetadataService { '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 6bd5828921..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,4 +1,6 @@ import { TestBed } from '@angular/core/testing'; + +import { environment } from '../../../environments/environment.test'; import { ServerHardRedirectService } from './server-hard-redirect.service'; describe('ServerHardRedirectService', () => { @@ -6,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(() => { @@ -67,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 d71318d7b8..280dbd22cb 100644 --- a/src/app/core/services/server-hard-redirect.service.ts +++ b/src/app/core/services/server-hard-redirect.service.ts @@ -2,6 +2,8 @@ import { Inject, Injectable } from '@angular/core'; import { Request, Response } from 'express'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { HardRedirectService } from './hard-redirect.service'; +import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface'; +import { isNotEmpty } from '../../shared/empty.util'; /** * Service for performing hard redirects within the server app module @@ -10,6 +12,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, ) { @@ -25,17 +28,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'); @@ -49,9 +57,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 47449bda5c..f96759a75e 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 @@ -24,31 +24,31 @@
- - @@ -76,13 +76,13 @@
- - 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 7067c44fbb..eef3e45fa9 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 @@ -16,6 +16,7 @@ import { DATA_SERVICE_FACTORY } from '../../core/data/base/data-service.decorato import { Operation } from 'fast-json-patch'; import { RemoteData } from '../../core/data/remote-data'; import { Observable } from 'rxjs/internal/Observable'; +import {BtnDisabledDirective} from '../../shared/btn-disabled.directive'; const ADD_BTN = 'add'; const REINSTATE_BTN = 'reinstate'; @@ -71,7 +72,7 @@ describe('DsoEditMetadataComponent', () => { notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']); TestBed.configureTestingModule({ - declarations: [DsoEditMetadataComponent, VarDirective], + declarations: [DsoEditMetadataComponent, VarDirective, BtnDisabledDirective], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], providers: [ TestDataService, @@ -180,7 +181,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/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.html b/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.html index b78202d0fe..cbc68ef7cf 100644 --- a/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.html +++ b/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.html @@ -12,4 +12,9 @@ + + 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 @@
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 @@
- +
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 dc4f4cf412..c7735f0579 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 @@ -11,6 +11,7 @@ import { Store } from '@ngrx/store'; import { By } from '@angular/platform-browser'; import { LogOutAction } from '../../core/auth/auth.actions'; import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; +import {BtnDisabledDirective} from '../../shared/btn-disabled.directive'; describe('EndUserAgreementComponent', () => { let component: EndUserAgreementComponent; @@ -49,7 +50,7 @@ describe('EndUserAgreementComponent', () => { init(); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], - declarations: [EndUserAgreementComponent], + declarations: [EndUserAgreementComponent, BtnDisabledDirective], providers: [ { provide: EndUserAgreementService, useValue: endUserAgreementService }, { provide: NotificationsService, useValue: notificationsService }, @@ -81,7 +82,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/feedback/feedback-form/feedback-form.component.html b/src/app/info/feedback/feedback-form/feedback-form.component.html index 6ca584c82e..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,9 +41,9 @@
- +
-
\ No newline at end of file + 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 c3d38a2876..7b7c6e28b6 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 { Router } from '@angular/router'; import { RouterMock } from '../../../shared/mocks/router.mock'; import { NativeWindowService } from '../../../core/services/window.service'; import { NativeWindowMockFactory } from '../../../shared/mocks/mock-native-window-ref'; +import {BtnDisabledDirective} from '../../../shared/btn-disabled.directive'; describe('FeedbackFormComponent', () => { @@ -38,7 +39,7 @@ describe('FeedbackFormComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], - declarations: [FeedbackFormComponent], + declarations: [FeedbackFormComponent, BtnDisabledDirective], providers: [ { provide: RouteService, useValue: routeServiceStub }, { provide: UntypedFormBuilder, useValue: new UntypedFormBuilder() }, @@ -72,7 +73,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', () => { @@ -83,7 +85,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/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 2d4ac89fcc..5b4b1bda52 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 @@ 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 2df1c363b0..9447e2560a 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}} - + 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 5364d5b9aa..1b37a615c4 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 @@
- - 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 7570119b3a..7d7087e02d 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 @@ -4,6 +4,7 @@ import { ItemOperationComponent } from './item-operation.component'; import { TranslateModule } from '@ngx-translate/core'; import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; +import {BtnDisabledDirective} from '../../../shared/btn-disabled.directive'; describe('ItemOperationComponent', () => { let itemOperation: ItemOperation; @@ -14,7 +15,7 @@ describe('ItemOperationComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], - declarations: [ItemOperationComponent] + declarations: [ItemOperationComponent, BtnDisabledDirective] }).compileComponents(); })); @@ -40,7 +41,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-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 a4cc100377..27faee89a7 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}} - 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 183720b2bb..4ff09154cd 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 @@ -356,7 +356,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/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/versions/item-versions.component.html b/src/app/item-page/versions/item-versions.component.html index ed7a186637..09e8f21e72 100644 --- a/src/app/item-page/versions/item-versions.component.html +++ b/src/app/item-page/versions/item-versions.component.html @@ -65,7 +65,7 @@ @@ -8,7 +8,7 @@ ngbDropdown *ngIf="(moreThanOne$ | async)"> + + + + + + +
-
- {{'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 72dc4c9348..3250a65367 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 @@ -74,7 +74,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(); @@ -88,7 +88,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 4c7fe3bdf7..8cc31085a4 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 @@ -1,14 +1,18 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Optional, Output } from '@angular/core'; import { ScriptDataService } from '../../../core/data/processes/script-data.service'; import { Script } from '../../scripts/script.model'; -import { Observable, Subscription } from 'rxjs'; -import { distinctUntilChanged, filter, map, switchMap, take } from 'rxjs/operators'; -import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../../../core/shared/operators'; +import { BehaviorSubject, Subscription, tap } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { + getRemoteDataPayload, + getFirstCompletedRemoteData +} from '../../../core/shared/operators'; import { PaginatedList } from '../../../core/data/paginated-list.model'; -import { ActivatedRoute, Params, Router } from '@angular/router'; -import { hasNoValue, hasValue } from '../../../shared/empty.util'; +import { ActivatedRoute, Router } from '@angular/router'; +import { hasValue } from '../../../shared/empty.util'; import { ControlContainer, NgForm } from '@angular/forms'; import { controlContainerFactory } from '../process-form.component'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; const SCRIPT_QUERY_PARAMETER = 'script'; @@ -31,10 +35,20 @@ export class ScriptsSelectComponent implements OnInit, OnDestroy { /** * All available scripts */ - scripts$: Observable; + scripts: Script[] = []; + private _selectedScript: Script; private routeSub: Subscription; + private _isLastPage = false; + + scriptOptions: FindListOptions = { + elementsPerPage: 20, + currentPage: 1, + }; + + isLoading$: BehaviorSubject = new BehaviorSubject(false); + constructor( private scriptService: ScriptDataService, private router: Router, @@ -47,31 +61,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