diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 00ec2fa8f7..89de307516 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -41,6 +41,10 @@ jobs: - name: Setup Docker Buildx uses: docker/setup-buildx-action@v1 + # https://github.com/docker/setup-qemu-action + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + # https://github.com/docker/login-action - name: Login to DockerHub # Only login if not a PR, as PRs only trigger a Docker build and not a push @@ -70,6 +74,7 @@ jobs: with: context: . file: ./Dockerfile + platforms: linux/amd64,linux/arm64 # For pull requests, we run the Docker build (to ensure no PR changes break the build), # but we ONLY do an image push to DockerHub if it's NOT a PR push: ${{ github.event_name != 'pull_request' }} diff --git a/.gitignore b/.gitignore index b27e636620..bdd0d4e589 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ package-lock.json .env /nbproject/ + +junit.xml diff --git a/README.md b/README.md index ca27ce9ebe..0e26d9e492 100644 --- a/README.md +++ b/README.md @@ -330,8 +330,11 @@ All E2E tests must be created under the `./cypress/integration/` folder, and mus * In the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner), you'll Cypress automatically visit the page. This first test will succeed, as all you are doing is making sure the _page exists_. * From here, you can use the [Selector Playground](https://docs.cypress.io/guides/core-concepts/test-runner#Selector-Playground) in the Cypress Test Runner window to determine how to tell Cypress to interact with a specific HTML element on that page. * Most commands start by telling Cypress to [get()](https://docs.cypress.io/api/commands/get) a specific element, using a CSS or jQuery style selector + * It's generally best not to rely on attributes like `class` and `id` in tests, as those are likely to change later on. Instead, you can add a `data-test` attribute to makes it clear that it's required for a test. * Cypress can then do actions like [click()](https://docs.cypress.io/api/commands/click) an element, or [type()](https://docs.cypress.io/api/commands/type) text in an input field, etc. - * Cypress can also validate that something occurs, using [should()](https://docs.cypress.io/api/commands/should) assertions. + * When running with server-side rendering enabled, the client first receives HTML without the JS; only once the page is rendered client-side do some elements (e.g. a button that toggles a Bootstrap dropdown) become fully interactive. This can trip up Cypress in some cases as it may try to `click` or `type` in an element that's not fully loaded yet, causing tests to fail. + * To work around this issue, define the attributes you use for Cypress selectors as `[attr.data-test]="'button' | ngBrowserOnly"`. This will only show the attribute in CSR HTML, forcing Cypress to wait until CSR is complete before interacting with the element. + * Cypress can also validate that something occurs, using [should()](https://docs.cypress.io/api/commands/should) assertions. * Any time you save your test file, the Cypress Test Runner will reload & rerun it. This allows you can see your results quickly as you write the tests & correct any broken tests rapidly. * Cypress also has a great guide on [writing your first test](https://on.cypress.io/writing-first-test) with much more info. Keep in mind, while the examples in the Cypress docs often involve Javascript files (.js), the same examples will work in our Typescript (.ts) e2e tests. diff --git a/cypress/integration/my-dspace.spec.ts b/cypress/integration/my-dspace.spec.ts index eb931adda7..fa923dbcbc 100644 --- a/cypress/integration/my-dspace.spec.ts +++ b/cypress/integration/my-dspace.spec.ts @@ -65,7 +65,7 @@ describe('My DSpace page', () => { cy.visit('/mydspace'); // Open the New Submission dropdown - cy.get('#dropdownSubmission').click(); + cy.get('button[data-test="submission-dropdown"]').click(); // Click on the "Item" type in that dropdown cy.get('#entityControlsDropdownMenu button[title="none"]').click(); @@ -98,7 +98,7 @@ describe('My DSpace page', () => { const id = subpaths[2]; // Click the "Save for Later" button to save this submission - cy.get('button#saveForLater').click(); + cy.get('ds-submission-form-footer [data-test="save-for-later"]').click(); // "Save for Later" should send us to MyDSpace cy.url().should('include', '/mydspace'); @@ -122,7 +122,7 @@ describe('My DSpace page', () => { cy.url().should('include', '/workspaceitems/' + id + '/edit'); // Discard our new submission by clicking Discard in Submission form & confirming - cy.get('button#discard').click(); + cy.get('ds-submission-form-footer [data-test="discard"]').click(); cy.get('button#discard_submit').click(); // Discarding should send us back to MyDSpace @@ -135,7 +135,7 @@ describe('My DSpace page', () => { cy.visit('/mydspace'); // Open the New Import dropdown - cy.get('#dropdownImport').click(); + cy.get('button[data-test="import-dropdown"]').click(); // Click on the "Item" type in that dropdown cy.get('#importControlsDropdownMenu button[title="none"]').click(); diff --git a/cypress/integration/search-page.spec.ts b/cypress/integration/search-page.spec.ts index de279c7f2e..623c370c56 100644 --- a/cypress/integration/search-page.spec.ts +++ b/cypress/integration/search-page.spec.ts @@ -24,7 +24,7 @@ describe('Search Page', () => { // Click each filter toggle to open *every* filter // (As we want to scan filter section for accessibility issues as well) - cy.get('.filter-toggle').click({ multiple: true }); + cy.get('[data-test="filter-toggle"]').click({ multiple: true }); // Analyze for accessibility issues testA11y( diff --git a/cypress/support/index.ts b/cypress/support/index.ts index d9b6409a0d..024b46cdde 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -21,7 +21,7 @@ import './commands'; import 'cypress-axe'; // Runs once before the first test in each "block" -before(() => { +beforeEach(() => { // Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie // This just ensures it doesn't get in the way of matching other objects in the page. cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true}'); diff --git a/package.json b/package.json index 236e4a7be5..d8c6aa355f 100644 --- a/package.json +++ b/package.json @@ -68,8 +68,8 @@ "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@ng-bootstrap/ng-bootstrap": "^11.0.0", - "@ng-dynamic-forms/core": "^14.0.1", - "@ng-dynamic-forms/ui-ng-bootstrap": "^14.0.1", + "@ng-dynamic-forms/core": "^15.0.0", + "@ng-dynamic-forms/ui-ng-bootstrap": "^15.0.0", "@ngrx/effects": "^13.0.2", "@ngrx/router-store": "^13.0.2", "@ngrx/store": "^13.0.2", @@ -77,7 +77,7 @@ "@ngx-translate/core": "^13.0.0", "@nicky-lenaers/ngx-scroll-to": "^9.0.0", "angular-idle-preload": "3.0.0", - "angulartics2": "^10.0.0", + "angulartics2": "^12.0.0", "bootstrap": "4.3.1", "caniuse-lite": "^1.0.30001165", "cerialize": "0.1.18", @@ -119,7 +119,7 @@ "prop-types": "^15.7.2", "react-copy-to-clipboard": "^5.0.1", "reflect-metadata": "^0.1.13", - "rxjs": "^6.6.3", + "rxjs": "^7.5.5", "sortablejs": "1.13.0", "tslib": "^2.0.0", "url-parse": "^1.5.6", @@ -155,14 +155,14 @@ "@typescript-eslint/eslint-plugin": "5.11.0", "@typescript-eslint/parser": "5.11.0", "axe-core": "^4.3.3", - "compression-webpack-plugin": "^3.0.1", + "compression-webpack-plugin": "^9.2.0", "copy-webpack-plugin": "^6.4.1", "cross-env": "^7.0.3", "css-loader": "^6.2.0", "css-minimizer-webpack-plugin": "^3.4.1", "cssnano": "^5.0.6", "cypress": "9.5.1", - "cypress-axe": "^0.13.0", + "cypress-axe": "^0.14.0", "debug-loader": "^0.0.1", "deep-freeze": "0.0.1", "dotenv": "^8.2.0", @@ -171,10 +171,11 @@ "eslint-plugin-import": "^2.25.4", "eslint-plugin-jsdoc": "^38.0.6", "eslint-plugin-unused-imports": "^2.0.0", + "express-static-gzip": "^2.1.5", "fork-ts-checker-webpack-plugin": "^6.0.3", "html-loader": "^1.3.2", "jasmine-core": "^3.8.0", - "jasmine-marbles": "0.6.0", + "jasmine-marbles": "0.9.2", "jasmine-spec-reporter": "~5.0.0", "karma": "^6.3.14", "karma-chrome-launcher": "~3.1.0", @@ -182,7 +183,7 @@ "karma-jasmine": "~4.0.0", "karma-jasmine-html-reporter": "^1.5.0", "karma-mocha-reporter": "2.2.5", - "ngx-mask": "^12.0.0", + "ngx-mask": "^13.1.7", "nodemon": "^2.0.15", "postcss": "^8.1", "postcss-apply": "0.12.0", @@ -196,7 +197,7 @@ "react": "^16.14.0", "react-dom": "^16.14.0", "rimraf": "^3.0.2", - "rxjs-spy": "^7.5.3", + "rxjs-spy": "^8.0.2", "sass": "~1.32.6", "sass-loader": "^12.6.0", "sass-resources-loader": "^2.1.1", @@ -210,4 +211,4 @@ "webpack-cli": "^4.2.0", "webpack-dev-server": "^4.5.0" } -} +} \ No newline at end of file diff --git a/server.ts b/server.ts index bc840fb73c..12a5a3ecc2 100644 --- a/server.ts +++ b/server.ts @@ -25,6 +25,7 @@ import * as morgan from 'morgan'; import * as express from 'express'; import * as bodyParser from 'body-parser'; import * as compression from 'compression'; +import * as expressStaticGzip from 'express-static-gzip'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; @@ -74,11 +75,15 @@ export function app() { /* * If production mode is enabled in the environment file: * - Enable Angular's production mode - * - Enable compression for response bodies. See [compression](https://github.com/expressjs/compression) + * - Enable compression for SSR reponses. See [compression](https://github.com/expressjs/compression) */ if (environment.production) { enableProdMode(); - server.use(compression()); + server.use(compression({ + // only compress responses we've marked as SSR + // otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin + filter: (_, res) => res.locals.ssr, + })); } /* @@ -150,8 +155,14 @@ export function app() { /* * Serve static resources (images, i18n messages, …) + * Handle pre-compressed files with [express-static-gzip](https://github.com/tkoenig89/express-static-gzip) */ - server.get('*.*', cacheControl, express.static(DIST_FOLDER, { index: false })); + server.get('*.*', cacheControl, expressStaticGzip(DIST_FOLDER, { + index: false, + enableBrotli: true, + orderPreference: ['br', 'gzip'], + })); + /* * Fallthrough to the IIIF viewer (must be included in the build). */ @@ -180,6 +191,7 @@ function ngApp(req, res) { providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] }, (err, data) => { if (hasNoValue(err) && hasValue(data)) { + res.locals.ssr = true; // mark response as SSR res.send(data); } else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') { // When this error occurs we can't fall back to CSR because the response has already been diff --git a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.html b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.html index 42a04b0de6..24901cc11d 100644 --- a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.html +++ b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.html @@ -1,6 +1,17 @@

{{'admin.metadata-import.page.help' | translate}}

+
+
+ + +
+ + {{'admin.metadata-import.page.validateOnly.hint' | translate}} + +
- - +
+ + +
diff --git a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.spec.ts b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.spec.ts index d663481b8c..814757ec71 100644 --- a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.spec.ts +++ b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.spec.ts @@ -87,8 +87,9 @@ describe('MetadataImportPageComponent', () => { comp.setFile(fileMock); }); - describe('if proceed button is pressed', () => { + describe('if proceed button is pressed without validate only', () => { beforeEach(fakeAsync(() => { + comp.validateOnly = false; const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; proceed.click(); fixture.detectChanges(); @@ -107,6 +108,28 @@ describe('MetadataImportPageComponent', () => { }); }); + describe('if proceed button is pressed with validate only', () => { + beforeEach(fakeAsync(() => { + comp.validateOnly = true; + const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('metadata-import script is invoked with -f fileName and the mockFile and -v validate-only', () => { + const parameterValues: ProcessParameter[] = [ + Object.assign(new ProcessParameter(), { name: '-f', value: 'filename.txt' }), + Object.assign(new ProcessParameter(), { name: '-v', value: true }), + ]; + expect(scriptService.invoke).toHaveBeenCalledWith(METADATA_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]); + }); + it('success notification is shown', () => { + expect(notificationService.success).toHaveBeenCalled(); + }); + it('redirected to process page', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/45'); + }); + }); + describe('if proceed is pressed; but script invoke fails', () => { beforeEach(fakeAsync(() => { jasmine.getEnv().allowRespy(true); diff --git a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.ts b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.ts index 3bdcca3084..deb16c0d73 100644 --- a/src/app/admin/admin-import-metadata-page/metadata-import-page.component.ts +++ b/src/app/admin/admin-import-metadata-page/metadata-import-page.component.ts @@ -30,6 +30,11 @@ export class MetadataImportPageComponent { */ fileObject: File; + /** + * The validate only flag + */ + validateOnly = true; + public constructor(private location: Location, protected translate: TranslateService, protected notificationsService: NotificationsService, @@ -62,6 +67,9 @@ export class MetadataImportPageComponent { const parameterValues: ProcessParameter[] = [ Object.assign(new ProcessParameter(), { name: '-f', value: this.fileObject.name }), ]; + if (this.validateOnly) { + parameterValues.push(Object.assign(new ProcessParameter(), { name: '-v', value: true })); + } this.scriptDataService.invoke(METADATA_IMPORT_SCRIPT_NAME, parameterValues, [this.fileObject]).pipe( getFirstCompletedRemoteData(), diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts b/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts index 2a5a544a40..3aca092b52 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts @@ -182,176 +182,4 @@ describe('AdminSidebarComponent', () => { expect(menuService.collapseMenuPreview).toHaveBeenCalled(); })); }); - - describe('menu', () => { - beforeEach(() => { - spyOn(menuService, 'addSection'); - }); - - describe('for regular user', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake(() => { - return observableOf(false); - }); - }); - - beforeEach(() => { - comp.createMenu(); - }); - - it('should not show site admin section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'admin_search', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'registries', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - parentID: 'registries', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'curation_tasks', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'workflow', visible: false, - })); - }); - - it('should not show edit_community', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'edit_community', visible: false, - })); - - }); - - it('should not show edit_collection', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'edit_collection', visible: false, - })); - }); - - it('should not show access control section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'access_control', visible: false, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - parentID: 'access_control', visible: false, - })); - }); - - // We check that the menu section has not been called with visible set to true - // The reason why we don't check if it has been called with visible set to false - // Is because the function does not get called unless a user is authorised - it('should not show the import section', () => { - expect(menuService.addSection).not.toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'import', visible: true, - })); - }); - - // We check that the menu section has not been called with visible set to true - // The reason why we don't check if it has been called with visible set to false - // Is because the function does not get called unless a user is authorised - it('should not show the export section', () => { - expect(menuService.addSection).not.toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'export', visible: true, - })); - }); - }); - - describe('for site admin', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.AdministratorOf); - }); - }); - - beforeEach(() => { - comp.createMenu(); - }); - - it('should contain site admin section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'admin_search', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'registries', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - parentID: 'registries', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'curation_tasks', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'workflow', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'workflow', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'import', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'export', visible: true, - })); - }); - }); - - describe('for community admin', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.IsCommunityAdmin); - }); - }); - - beforeEach(() => { - comp.createMenu(); - }); - - it('should show edit_community', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'edit_community', visible: true, - })); - }); - }); - - describe('for collection admin', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.IsCollectionAdmin); - }); - }); - - beforeEach(() => { - comp.createMenu(); - }); - - it('should show edit_collection', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'edit_collection', visible: true, - })); - }); - }); - - describe('for group admin', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID: FeatureID) => { - return observableOf(featureID === FeatureID.CanManageGroups); - }); - }); - - beforeEach(() => { - comp.createMenu(); - }); - - it('should show access control section', () => { - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - id: 'access_control', visible: true, - })); - expect(menuService.addSection).toHaveBeenCalledWith(comp.menuID, jasmine.objectContaining({ - parentID: 'access_control', visible: true, - })); - }); - }); - }); }); diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.ts b/src/app/admin/admin-sidebar/admin-sidebar.component.ts index fef6904177..b244039a25 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.ts @@ -1,45 +1,13 @@ import { Component, HostListener, Injector, OnInit } from '@angular/core'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { BehaviorSubject, combineLatest as observableCombineLatest, combineLatest, Observable } from 'rxjs'; -import { debounceTime, distinctUntilChanged, filter, first, map, take, withLatestFrom } from 'rxjs/operators'; +import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; +import { debounceTime, distinctUntilChanged, first, map, withLatestFrom } from 'rxjs/operators'; import { AuthService } from '../../core/auth/auth.service'; -import { - METADATA_EXPORT_SCRIPT_NAME, - METADATA_IMPORT_SCRIPT_NAME, - ScriptDataService -} from '../../core/data/processes/script-data.service'; import { slideHorizontal, slideSidebar } from '../../shared/animations/slide'; -import { - CreateCollectionParentSelectorComponent -} from '../../shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; -import { - CreateCommunityParentSelectorComponent -} from '../../shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; -import { - CreateItemParentSelectorComponent -} from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; -import { - EditCollectionSelectorComponent -} from '../../shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; -import { - EditCommunitySelectorComponent -} from '../../shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; -import { - EditItemSelectorComponent -} from '../../shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; -import { - ExportMetadataSelectorComponent -} from '../../shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component'; -import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model'; -import { OnClickMenuItemModel } from '../../shared/menu/menu-item/models/onclick.model'; -import { TextMenuItemModel } from '../../shared/menu/menu-item/models/text.model'; import { MenuComponent } from '../../shared/menu/menu.component'; import { MenuService } from '../../shared/menu/menu.service'; import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; -import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { MenuID } from '../../shared/menu/menu-id.model'; -import { MenuItemType } from '../../shared/menu/menu-item-type.model'; import { ActivatedRoute } from '@angular/router'; /** @@ -85,11 +53,9 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { constructor( protected menuService: MenuService, protected injector: Injector, - protected variableService: CSSVariableService, - protected authService: AuthService, - protected modalService: NgbModal, + private variableService: CSSVariableService, + private authService: AuthService, public authorizationService: AuthorizationDataService, - protected scriptDataService: ScriptDataService, public route: ActivatedRoute ) { super(menuService, injector, authorizationService, route); @@ -105,7 +71,6 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { this.authService.isAuthenticated() .subscribe((loggedIn: boolean) => { if (loggedIn) { - this.createMenu(); this.menuService.showMenu(this.menuID); } }); @@ -135,503 +100,6 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { }); } - /** - * Initialize all menu sections and items for this menu - */ - createMenu() { - this.createMainMenuSections(); - this.createSiteAdministratorMenuSections(); - this.createExportMenuSections(); - this.createImportMenuSections(); - this.createAccessControlMenuSections(); - } - - /** - * Initialize the main menu sections. - * edit_community / edit_collection is only included if the current user is a Community or Collection admin - */ - createMainMenuSections() { - combineLatest([ - this.authorizationService.isAuthorized(FeatureID.IsCollectionAdmin), - this.authorizationService.isAuthorized(FeatureID.IsCommunityAdmin), - this.authorizationService.isAuthorized(FeatureID.AdministratorOf) - ]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin]) => { - const menuList = [ - /* News */ - { - id: 'new', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.new' - } as TextMenuItemModel, - icon: 'plus', - index: 0 - }, - { - id: 'new_community', - parentID: 'new', - active: false, - visible: isCommunityAdmin, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.new_community', - function: () => { - this.modalService.open(CreateCommunityParentSelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'new_collection', - parentID: 'new', - active: false, - visible: isCommunityAdmin, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.new_collection', - function: () => { - this.modalService.open(CreateCollectionParentSelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'new_item', - parentID: 'new', - active: false, - visible: true, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.new_item', - function: () => { - this.modalService.open(CreateItemParentSelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'new_process', - parentID: 'new', - active: false, - visible: isCollectionAdmin, - model: { - type: MenuItemType.LINK, - text: 'menu.section.new_process', - link: '/processes/new' - } as LinkMenuItemModel, - }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'new_item_version', - // parentID: 'new', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.new_item_version', - // link: '' - // } as LinkMenuItemModel, - // }, - - /* Edit */ - { - id: 'edit', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.edit' - } as TextMenuItemModel, - icon: 'pencil-alt', - index: 1 - }, - { - id: 'edit_community', - parentID: 'edit', - active: false, - visible: isCommunityAdmin, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.edit_community', - function: () => { - this.modalService.open(EditCommunitySelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'edit_collection', - parentID: 'edit', - active: false, - visible: isCollectionAdmin, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.edit_collection', - function: () => { - this.modalService.open(EditCollectionSelectorComponent); - } - } as OnClickMenuItemModel, - }, - { - id: 'edit_item', - parentID: 'edit', - active: false, - visible: true, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.edit_item', - function: () => { - this.modalService.open(EditItemSelectorComponent); - } - } as OnClickMenuItemModel, - }, - - /* Statistics */ - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'statistics_task', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.statistics_task', - // link: '' - // } as LinkMenuItemModel, - // icon: 'chart-bar', - // index: 8 - // }, - - /* Control Panel */ - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'control_panel', - // active: false, - // visible: isSiteAdmin, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.control_panel', - // link: '' - // } as LinkMenuItemModel, - // icon: 'cogs', - // index: 9 - // }, - - /* Processes */ - { - id: 'processes', - active: false, - visible: isSiteAdmin, - model: { - type: MenuItemType.LINK, - text: 'menu.section.processes', - link: '/processes' - } as LinkMenuItemModel, - icon: 'terminal', - index: 10 - }, - ]; - menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, { - shouldPersistOnRouteChange: true - }))); - }); - } - - /** - * Create menu sections dependent on whether or not the current user is a site administrator and on whether or not - * the export scripts exist and the current user is allowed to execute them - */ - createExportMenuSections() { - const menuList = [ - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'export_community', - // parentID: 'export', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.export_community', - // link: '' - // } as LinkMenuItemModel, - // shouldPersistOnRouteChange: true - // }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'export_collection', - // parentID: 'export', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.export_collection', - // link: '' - // } as LinkMenuItemModel, - // shouldPersistOnRouteChange: true - // }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'export_item', - // parentID: 'export', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.export_item', - // link: '' - // } as LinkMenuItemModel, - // shouldPersistOnRouteChange: true - // }, - ]; - menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, menuSection)); - - observableCombineLatest([ - this.authorizationService.isAuthorized(FeatureID.AdministratorOf), - this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_EXPORT_SCRIPT_NAME) - ]).pipe( - filter(([authorized, metadataExportScriptExists]: boolean[]) => authorized && metadataExportScriptExists), - take(1) - ).subscribe(() => { - // Hides the export menu for unauthorised people - // If in the future more sub-menus are added, - // it should be reviewed if they need to be in this subscribe - this.menuService.addSection(this.menuID, { - id: 'export', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.export' - } as TextMenuItemModel, - icon: 'file-export', - index: 3, - shouldPersistOnRouteChange: true - }); - this.menuService.addSection(this.menuID, { - id: 'export_metadata', - parentID: 'export', - active: true, - visible: true, - model: { - type: MenuItemType.ONCLICK, - text: 'menu.section.export_metadata', - function: () => { - this.modalService.open(ExportMetadataSelectorComponent); - } - } as OnClickMenuItemModel, - shouldPersistOnRouteChange: true - }); - }); - } - - /** - * Create menu sections dependent on whether or not the current user is a site administrator and on whether or not - * the import scripts exist and the current user is allowed to execute them - */ - createImportMenuSections() { - const menuList = [ - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'import_batch', - // parentID: 'import', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.import_batch', - // link: '' - // } as LinkMenuItemModel, - // } - ]; - menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, { - shouldPersistOnRouteChange: true - }))); - - observableCombineLatest([ - this.authorizationService.isAuthorized(FeatureID.AdministratorOf), - this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_IMPORT_SCRIPT_NAME) - ]).pipe( - filter(([authorized, metadataImportScriptExists]: boolean[]) => authorized && metadataImportScriptExists), - take(1) - ).subscribe(() => { - // Hides the import menu for unauthorised people - // If in the future more sub-menus are added, - // it should be reviewed if they need to be in this subscribe - this.menuService.addSection(this.menuID, { - id: 'import', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.import' - } as TextMenuItemModel, - icon: 'file-import', - index: 2 - }); - this.menuService.addSection(this.menuID, { - id: 'import_metadata', - parentID: 'import', - active: true, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.import_metadata', - link: '/admin/metadata-import' - } as LinkMenuItemModel, - shouldPersistOnRouteChange: true - }); - }); - } - - /** - * Create menu sections dependent on whether or not the current user is a site administrator - */ - createSiteAdministratorMenuSections() { - this.authorizationService.isAuthorized(FeatureID.AdministratorOf).subscribe((authorized) => { - const menuList = [ - /* Admin Search */ - { - id: 'admin_search', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.admin_search', - link: '/admin/search' - } as LinkMenuItemModel, - icon: 'search', - index: 5 - }, - /* Registries */ - { - id: 'registries', - active: false, - visible: authorized, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.registries' - } as TextMenuItemModel, - icon: 'list', - index: 6 - }, - { - id: 'registries_metadata', - parentID: 'registries', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.registries_metadata', - link: 'admin/registries/metadata' - } as LinkMenuItemModel, - }, - { - id: 'registries_format', - parentID: 'registries', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.registries_format', - link: 'admin/registries/bitstream-formats' - } as LinkMenuItemModel, - }, - - /* Curation tasks */ - { - id: 'curation_tasks', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.curation_task', - link: 'admin/curation-tasks' - } as LinkMenuItemModel, - icon: 'filter', - index: 7 - }, - - /* Workflow */ - { - id: 'workflow', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.workflow', - link: '/admin/workflow' - } as LinkMenuItemModel, - icon: 'user-check', - index: 11 - }, - ]; - - menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, { - shouldPersistOnRouteChange: true - }))); - }); - } - - /** - * Create menu sections dependent on whether or not the current user can manage access control groups - */ - createAccessControlMenuSections() { - observableCombineLatest([ - this.authorizationService.isAuthorized(FeatureID.AdministratorOf), - this.authorizationService.isAuthorized(FeatureID.CanManageGroups) - ]).subscribe(([isSiteAdmin, canManageGroups]) => { - const menuList = [ - /* Access Control */ - { - id: 'access_control_people', - parentID: 'access_control', - active: false, - visible: isSiteAdmin, - model: { - type: MenuItemType.LINK, - text: 'menu.section.access_control_people', - link: '/access-control/epeople' - } as LinkMenuItemModel, - }, - { - id: 'access_control_groups', - parentID: 'access_control', - active: false, - visible: canManageGroups, - model: { - type: MenuItemType.LINK, - text: 'menu.section.access_control_groups', - link: '/access-control/groups' - } as LinkMenuItemModel, - }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'access_control_authorizations', - // parentID: 'access_control', - // active: false, - // visible: authorized, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.access_control_authorizations', - // link: '' - // } as LinkMenuItemModel, - // }, - { - id: 'access_control', - active: false, - visible: canManageGroups || isSiteAdmin, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.access_control' - } as TextMenuItemModel, - icon: 'key', - index: 4 - }, - ]; - - menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, { - shouldPersistOnRouteChange: true, - }))); - }); - } - @HostListener('focusin') public handleFocusIn() { this.inFocus$.next(true); diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index 57767b6f3e..6524edef77 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -70,6 +70,12 @@ export function getWorkflowItemModuleRoute() { return `/${WORKFLOW_ITEM_MODULE_PATH}`; } +export const WORKSPACE_ITEM_MODULE_PATH = 'workspaceitems'; + +export function getWorkspaceItemModuleRoute() { + return `/${WORKSPACE_ITEM_MODULE_PATH}`; +} + export function getDSORoute(dso: DSpaceObject): string { if (hasValue(dso)) { switch ((dso as any).type) { diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 88f7791b1b..f0869d9fb6 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -1,5 +1,5 @@ import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; +import { RouterModule, NoPreloading } from '@angular/router'; import { AuthBlockingGuard } from './core/auth/auth-blocking.guard'; import { AuthenticatedGuard } from './core/auth/authenticated.guard'; @@ -30,6 +30,7 @@ import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component import { GroupAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component'; import { ServerCheckGuard } from './core/server-check/server-check.guard'; +import { MenuResolver } from './menu.resolver'; @NgModule({ imports: [ @@ -39,6 +40,7 @@ import { ServerCheckGuard } from './core/server-check/server-check.guard'; path: '', canActivate: [AuthBlockingGuard], canActivateChild: [ServerCheckGuard], + resolve: [MenuResolver], children: [ { path: '', redirectTo: '/home', pathMatch: 'full' }, { @@ -217,6 +219,12 @@ import { ServerCheckGuard } from './core/server-check/server-check.guard'; ] } ], { + // enableTracing: true, + useHash: false, + scrollPositionRestoration: 'enabled', + anchorScrolling: 'enabled', + initialNavigation: 'enabledBlocking', + preloadingStrategy: NoPreloading, onSameUrlNavigation: 'reload', }) ], diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index a892e34a5a..9f215da46d 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -4,7 +4,7 @@ import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CommonModule, DOCUMENT } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; +import { Angulartics2GoogleAnalytics } from 'angulartics2'; // Load the implementations that should be tested import { AppComponent } from './app.component'; diff --git a/src/app/app.component.ts b/src/app/app.component.ts index db217ce161..6aa569d8e6 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -23,7 +23,7 @@ import { BehaviorSubject, Observable, of } from 'rxjs'; import { select, Store } from '@ngrx/store'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; -import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; +import { Angulartics2GoogleAnalytics } from 'angulartics2'; import { MetadataService } from './core/metadata/metadata.service'; import { HostWindowResizeAction } from './shared/host-window.actions'; @@ -72,7 +72,7 @@ export class AppComponent implements OnInit, AfterViewInit { /** * Whether or not the app is in the process of rerouting */ - isRouteLoading$: BehaviorSubject = new BehaviorSubject(true); + isRouteLoading$: BehaviorSubject = new BehaviorSubject(false); /** * Whether or not the theme is in the process of being swapped @@ -121,7 +121,7 @@ export class AppComponent implements OnInit, AfterViewInit { this.themeService.getThemeName$().subscribe((themeName: string) => { if (isPlatformBrowser(this.platformId)) { // the theme css will never download server side, so this should only happen on the browser - this.isThemeCSSLoading$.next(true); + this.distinctNext(this.isThemeCSSLoading$, true); } if (hasValue(themeName)) { this.loadGlobalThemeConfig(themeName); @@ -200,7 +200,7 @@ export class AppComponent implements OnInit, AfterViewInit { this.router.events.subscribe((event) => { if (event instanceof NavigationStart) { resolveEndFound = false; - this.isRouteLoading$.next(true); + this.distinctNext(this.isRouteLoading$, true); } else if (event instanceof ResolveEnd) { resolveEndFound = true; const activatedRouteSnapShot: ActivatedRouteSnapshot = event.state.root; @@ -213,16 +213,16 @@ export class AppComponent implements OnInit, AfterViewInit { } }) ).subscribe((changed) => { - this.isThemeLoading$.next(changed); + this.distinctNext(this.isThemeLoading$, changed); }); } else if ( event instanceof NavigationEnd || event instanceof NavigationCancel ) { if (!resolveEndFound) { - this.isThemeLoading$.next(false); + this.distinctNext(this.isThemeLoading$, false); } - this.isRouteLoading$.next(false); + this.distinctNext(this.isRouteLoading$, false); } }); } @@ -280,7 +280,7 @@ export class AppComponent implements OnInit, AfterViewInit { }); } // the fact that this callback is used, proves we're on the browser. - this.isThemeCSSLoading$.next(false); + this.distinctNext(this.isThemeCSSLoading$, false); }; head.appendChild(link); } @@ -375,4 +375,17 @@ export class AppComponent implements OnInit, AfterViewInit { } }); } + + /** + * Use nextValue to update a given BehaviorSubject, only if it differs from its current value + * + * @param bs a BehaviorSubject + * @param nextValue the next value for that BehaviorSubject + * @protected + */ + protected distinctNext(bs: BehaviorSubject, nextValue: T): void { + if (bs.getValue() !== nextValue) { + bs.next(nextValue); + } + } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index c133efdd5c..ff66ab6aa9 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -15,10 +15,6 @@ import { } from '@ng-dynamic-forms/core'; import { TranslateModule } from '@ngx-translate/core'; import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; - -import { AdminSidebarSectionComponent } from './admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component'; -import { AdminSidebarComponent } from './admin/admin-sidebar/admin-sidebar.component'; -import { ExpandableAdminSidebarSectionComponent } from './admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { appEffects } from './app.effects'; @@ -27,40 +23,20 @@ import { appReducers, AppState, storeModuleConfig } from './app.reducer'; import { CheckAuthenticationTokenAction } from './core/auth/auth.actions'; import { CoreModule } from './core/core.module'; import { ClientCookieService } from './core/services/client-cookie.service'; -import { FooterComponent } from './footer/footer.component'; -import { HeaderNavbarWrapperComponent } from './header-nav-wrapper/header-navbar-wrapper.component'; -import { HeaderComponent } from './header/header.component'; import { NavbarModule } from './navbar/navbar.module'; -import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; import { DSpaceRouterStateSerializer } from './shared/ngrx/dspace-router-state-serializer'; -import { NotificationComponent } from './shared/notifications/notification/notification.component'; -import { NotificationsBoardComponent } from './shared/notifications/notifications-board/notifications-board.component'; import { SharedModule } from './shared/shared.module'; -import { BreadcrumbsComponent } from './breadcrumbs/breadcrumbs.component'; import { environment } from '../environments/environment'; -import { ForbiddenComponent } from './forbidden/forbidden.component'; import { AuthInterceptor } from './core/auth/auth.interceptor'; import { LocaleInterceptor } from './core/locale/locale.interceptor'; import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor'; import { LogInterceptor } from './core/log/log.interceptor'; -import { RootComponent } from './root/root.component'; -import { ThemedRootComponent } from './root/themed-root.component'; -import { ThemedEntryComponentModule } from '../themes/themed-entry-component.module'; -import { ThemedPageNotFoundComponent } from './pagenotfound/themed-pagenotfound.component'; -import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component'; -import { ThemedHeaderComponent } from './header/themed-header.component'; -import { ThemedFooterComponent } from './footer/themed-footer.component'; -import { ThemedBreadcrumbsComponent } from './breadcrumbs/themed-breadcrumbs.component'; -import { ThemedHeaderNavbarWrapperComponent } from './header-nav-wrapper/themed-header-navbar-wrapper.component'; -import { IdleModalComponent } from './shared/idle-modal/idle-modal.component'; -import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component'; -import { PageInternalServerErrorComponent } from './page-internal-server-error/page-internal-server-error.component'; -import { ThemedAdminSidebarComponent } from './admin/admin-sidebar/themed-admin-sidebar.component'; +import { EagerThemesModule } from '../themes/eager-themes.module'; import { APP_CONFIG, AppConfig } from '../config/app-config.interface'; import { NgxMaskModule } from 'ngx-mask'; - import { StoreDevModules } from '../config/store/devtools'; +import { RootModule } from './root.module'; export function getConfig() { return environment; @@ -96,8 +72,9 @@ const IMPORTS = [ EffectsModule.forRoot(appEffects), StoreModule.forRoot(appReducers, storeModuleConfig), StoreRouterConnectingModule.forRoot(), - ThemedEntryComponentModule.withEntryComponents(), StoreDevModules, + EagerThemesModule, + RootModule, ]; const PROVIDERS = [ @@ -162,29 +139,6 @@ const PROVIDERS = [ const DECLARATIONS = [ AppComponent, - RootComponent, - ThemedRootComponent, - HeaderComponent, - ThemedHeaderComponent, - HeaderNavbarWrapperComponent, - ThemedHeaderNavbarWrapperComponent, - AdminSidebarComponent, - ThemedAdminSidebarComponent, - AdminSidebarSectionComponent, - ExpandableAdminSidebarSectionComponent, - FooterComponent, - ThemedFooterComponent, - PageNotFoundComponent, - ThemedPageNotFoundComponent, - NotificationComponent, - NotificationsBoardComponent, - BreadcrumbsComponent, - ThemedBreadcrumbsComponent, - ForbiddenComponent, - ThemedForbiddenComponent, - IdleModalComponent, - ThemedPageInternalServerErrorComponent, - PageInternalServerErrorComponent ]; const EXPORTS = [ diff --git a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts index 7f0e6815ed..142604c9b2 100644 --- a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts +++ b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts @@ -43,6 +43,10 @@ import { createPaginatedList } from '../../shared/testing/utils.test'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { MyDSpacePageComponent, SEARCH_CONFIG_SERVICE } from '../../my-dspace-page/my-dspace-page.component'; import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub'; +import { GroupDataService } from '../../core/eperson/group-data.service'; +import { LinkHeadService } from '../../core/services/link-head.service'; +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; describe('CollectionItemMapperComponent', () => { let comp: CollectionItemMapperComponent; @@ -143,6 +147,25 @@ describe('CollectionItemMapperComponent', () => { isAuthorized: observableOf(true) }); + const linkHeadService = jasmine.createSpyObj('linkHeadService', { + addTag: '' + }); + + const groupDataService = jasmine.createSpyObj('groupsDataService', { + findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + getGroupRegistryRouterLink: '', + getUUIDFromString: '', + }); + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test' + ] + })) + }); + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], @@ -159,7 +182,10 @@ describe('CollectionItemMapperComponent', () => { { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: ObjectSelectService, useValue: new ObjectSelectServiceStub() }, { provide: RouteService, useValue: routeServiceStub }, - { provide: AuthorizationDataService, useValue: authorizationDataService } + { provide: AuthorizationDataService, useValue: authorizationDataService }, + { provide: GroupDataService, useValue: groupDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, ] }).overrideComponent(CollectionItemMapperComponent, { set: { diff --git a/src/app/collection-page/collection-page.component.html b/src/app/collection-page/collection-page.component.html index 6e4437e0e0..72033649b0 100644 --- a/src/app/collection-page/collection-page.component.html +++ b/src/app/collection-page/collection-page.component.html @@ -1,8 +1,8 @@
-
-
+
+
@@ -13,8 +13,7 @@ + [alternateText]="'Collection Logo'"> diff --git a/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts index 985290a592..5a8ca5b7ab 100644 --- a/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts @@ -13,6 +13,8 @@ import { RouterTestingModule } from '@angular/router/testing'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ComcolModule } from '../../../shared/comcol/comcol.module'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; describe('CollectionRolesComponent', () => { @@ -79,6 +81,7 @@ describe('CollectionRolesComponent', () => { { provide: ActivatedRoute, useValue: route }, { provide: RequestService, useValue: requestService }, { provide: GroupDataService, useValue: groupDataService }, + { provide: NotificationsService, useClass: NotificationsServiceStub } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/collection-page/themed-collection-page.component.ts b/src/app/collection-page/themed-collection-page.component.ts index 82074e43e6..2faf418423 100644 --- a/src/app/collection-page/themed-collection-page.component.ts +++ b/src/app/collection-page/themed-collection-page.component.ts @@ -6,7 +6,7 @@ import { CollectionPageComponent } from './collection-page.component'; * Themed wrapper for CollectionPageComponent */ @Component({ - selector: 'ds-themed-community-page', + selector: 'ds-themed-collection-page', styleUrls: [], templateUrl: '../shared/theme-support/themed.component.html', }) diff --git a/src/app/community-page/edit-community-page/community-roles/community-roles.component.spec.ts b/src/app/community-page/edit-community-page/community-roles/community-roles.component.spec.ts index d1188df02d..baea5fb9d0 100644 --- a/src/app/community-page/edit-community-page/community-roles/community-roles.component.spec.ts +++ b/src/app/community-page/edit-community-page/community-roles/community-roles.component.spec.ts @@ -13,6 +13,8 @@ import { RouterTestingModule } from '@angular/router/testing'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ComcolModule } from '../../../shared/comcol/comcol.module'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; describe('CommunityRolesComponent', () => { @@ -64,6 +66,7 @@ describe('CommunityRolesComponent', () => { { provide: ActivatedRoute, useValue: route }, { provide: RequestService, useValue: requestService }, { provide: GroupDataService, useValue: groupDataService }, + { provide: NotificationsService, useClass: NotificationsServiceStub } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts index ec61fac613..c0ce5369ff 100644 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts +++ b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts @@ -25,6 +25,14 @@ import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; import { ThemeService } from '../../shared/theme-support/theme.service'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { FindListOptions } from '../../core/data/find-list-options.model'; +import { GroupDataService } from '../../core/eperson/group-data.service'; +import { LinkHeadService } from '../../core/services/link-head.service'; +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +import { SearchServiceStub } from '../../shared/testing/search-service.stub'; +import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub'; describe('CommunityPageSubCollectionList Component', () => { let comp: CommunityPageSubCollectionListComponent; @@ -122,6 +130,25 @@ describe('CommunityPageSubCollectionList Component', () => { themeService = getMockThemeService(); + const linkHeadService = jasmine.createSpyObj('linkHeadService', { + addTag: '' + }); + + const groupDataService = jasmine.createSpyObj('groupsDataService', { + findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + getGroupRegistryRouterLink: '', + getUUIDFromString: '', + }); + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test' + ] + })) + }); + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ @@ -138,6 +165,10 @@ describe('CommunityPageSubCollectionList Component', () => { { provide: PaginationService, useValue: paginationService }, { provide: SelectableListService, useValue: {} }, { provide: ThemeService, useValue: themeService }, + { provide: GroupDataService, useValue: groupDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts index 2bc829a3b0..3392ada994 100644 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts +++ b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts @@ -25,6 +25,13 @@ import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; import { ThemeService } from '../../shared/theme-support/theme.service'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { FindListOptions } from '../../core/data/find-list-options.model'; +import { GroupDataService } from '../../core/eperson/group-data.service'; +import { LinkHeadService } from '../../core/services/link-head.service'; +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub'; +import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; describe('CommunityPageSubCommunityListComponent Component', () => { let comp: CommunityPageSubCommunityListComponent; @@ -119,6 +126,25 @@ describe('CommunityPageSubCommunityListComponent Component', () => { } }; + const linkHeadService = jasmine.createSpyObj('linkHeadService', { + addTag: '' + }); + + const groupDataService = jasmine.createSpyObj('groupsDataService', { + findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + getGroupRegistryRouterLink: '', + getUUIDFromString: '', + }); + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test' + ] + })) + }); + const paginationService = new PaginationServiceStub(); themeService = getMockThemeService(); @@ -139,6 +165,10 @@ describe('CommunityPageSubCommunityListComponent Component', () => { { provide: PaginationService, useValue: paginationService }, { provide: SelectableListService, useValue: {} }, { provide: ThemeService, useValue: themeService }, + { provide: GroupDataService, useValue: groupDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index da38d730a5..4db4cba612 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -12,13 +12,13 @@ import { AuthStatus } from './models/auth-status.model'; import { ShortLivedToken } from './models/short-lived-token.model'; import { URLCombiner } from '../url-combiner/url-combiner'; import { RestRequest } from '../data/rest-request.model'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; /** * Abstract service to send authentication requests */ export abstract class AuthRequestService { protected linkName = 'authn'; - protected browseEndpoint = ''; protected shortlivedtokensEndpoint = 'shortlivedtokens'; constructor(protected halService: HALEndpointService, @@ -27,14 +27,21 @@ export abstract class AuthRequestService { ) { } - protected fetchRequest(request: RestRequest): Observable> { - return this.rdbService.buildFromRequestUUID(request.uuid).pipe( + protected fetchRequest(request: RestRequest, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.rdbService.buildFromRequestUUID(request.uuid, ...linksToFollow).pipe( getFirstCompletedRemoteData(), ); } - protected getEndpointByMethod(endpoint: string, method: string): string { - return isNotEmpty(method) ? `${endpoint}/${method}` : `${endpoint}`; + protected getEndpointByMethod(endpoint: string, method: string, ...linksToFollow: FollowLinkConfig[]): string { + let url = isNotEmpty(method) ? `${endpoint}/${method}` : `${endpoint}`; + if (linksToFollow?.length > 0) { + linksToFollow.forEach((link: FollowLinkConfig, index: number) => { + url += ((index === 0) ? '?' : '&') + `embed=${link.name}`; + }); + } + + return url; } public postToEndpoint(method: string, body?: any, options?: HttpOptions): Observable> { @@ -48,14 +55,14 @@ export abstract class AuthRequestService { distinctUntilChanged()); } - public getRequest(method: string, options?: HttpOptions): Observable> { + public getRequest(method: string, options?: HttpOptions, ...linksToFollow: FollowLinkConfig[]): Observable> { return this.halService.getEndpoint(this.linkName).pipe( filter((href: string) => isNotEmpty(href)), - map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), + map((endpointURL) => this.getEndpointByMethod(endpointURL, method, ...linksToFollow)), distinctUntilChanged(), map((endpointURL: string) => new GetRequest(this.requestService.generateRequestId(), endpointURL, undefined, options)), tap((request: GetRequest) => this.requestService.send(request)), - mergeMap((request: GetRequest) => this.fetchRequest(request)), + mergeMap((request: GetRequest) => this.fetchRequest(request, ...linksToFollow)), distinctUntilChanged()); } diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index 8cd587b61a..8ebc9f6cb0 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -192,7 +192,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, - blocking: true, + blocking: false, loading: true, idle: false }; @@ -212,7 +212,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, - blocking: true, + blocking: false, loading: true, idle: false }; @@ -558,7 +558,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, - blocking: true, + blocking: false, loading: true, authMethods: [], idle: false diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 2fc79a8861..6f47a3c20c 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -92,11 +92,15 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut }); case AuthActionTypes.AUTHENTICATED: + return Object.assign({}, state, { + loading: true, + blocking: true + }); + case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE: return Object.assign({}, state, { loading: true, - blocking: true }); case AuthActionTypes.AUTHENTICATED_ERROR: @@ -210,7 +214,6 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.RETRIEVE_AUTH_METHODS: return Object.assign({}, state, { loading: true, - blocking: true }); case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS: diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index ced8bb94c8..d63e300ce0 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -32,6 +32,8 @@ import { TranslateService } from '@ngx-translate/core'; import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { SetUserAsIdleAction, UnsetUserAsIdleAction } from './auth.actions'; +import { SpecialGroupDataMock, SpecialGroupDataMock$ } from '../../shared/testing/special-group.mock'; +import { cold } from 'jasmine-marbles'; describe('AuthService test', () => { @@ -56,6 +58,13 @@ describe('AuthService test', () => { let linkService; let hardRedirectService; + const AuthStatusWithSpecialGroups = Object.assign(new AuthStatus(), { + uuid: 'test', + authenticated: true, + okay: true, + specialGroups: SpecialGroupDataMock$ + }); + function init() { mockStore = jasmine.createSpyObj('store', { dispatch: {}, @@ -511,6 +520,19 @@ describe('AuthService test', () => { expect((authService as any).navigateToRedirectUrl).toHaveBeenCalled(); }); }); + + describe('getSpecialGroupsFromAuthStatus', () => { + beforeEach(() => { + spyOn(authRequest, 'getRequest').and.returnValue(createSuccessfulRemoteDataObject$(AuthStatusWithSpecialGroups)); + }); + + it('should call navigateToRedirectUrl with no url', () => { + const expectRes = cold('(a|)', { + a: SpecialGroupDataMock + }); + expect(authService.getSpecialGroupsFromAuthStatus()).toBeObservable(expectRes); + }); + }); }); describe('when user is not logged in', () => { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index f89fa21681..7796094e39 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -44,13 +44,18 @@ import { import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { RouteService } from '../services/route.service'; import { EPersonDataService } from '../eperson/eperson-data.service'; -import { getAllSucceededRemoteDataPayload } from '../shared/operators'; +import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData } from '../shared/operators'; import { AuthMethod } from './models/auth.method'; import { HardRedirectService } from '../services/hard-redirect.service'; import { RemoteData } from '../data/remote-data'; import { environment } from '../../../environments/environment'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; +import { buildPaginatedList, PaginatedList } from '../data/paginated-list.model'; +import { Group } from '../eperson/models/group.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { PageInfo } from '../shared/page-info.model'; +import { followLink } from '../../shared/utils/follow-link-config.model'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; @@ -211,6 +216,22 @@ export class AuthService { this.store.dispatch(new CheckAuthenticationTokenAction()); } + /** + * Return the special groups list embedded in the AuthStatus model + */ + public getSpecialGroupsFromAuthStatus(): Observable>> { + return this.authRequestService.getRequest('status', null, followLink('specialGroups')).pipe( + getFirstCompletedRemoteData(), + switchMap((status: RemoteData) => { + if (status.hasSucceeded) { + return status.payload.specialGroups; + } else { + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(),[])); + } + }) + ); + } + /** * Checks if token is present into storage and is not expired */ diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts index fbe6ed6476..d18b1ccf9a 100644 --- a/src/app/core/auth/models/auth-status.model.ts +++ b/src/app/core/auth/models/auth-status.model.ts @@ -5,6 +5,8 @@ import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; import { RemoteData } from '../../data/remote-data'; import { EPerson } from '../../eperson/models/eperson.model'; import { EPERSON } from '../../eperson/models/eperson.resource-type'; +import { Group } from '../../eperson/models/group.model'; +import { GROUP } from '../../eperson/models/group.resource-type'; import { HALLink } from '../../shared/hal-link.model'; import { ResourceType } from '../../shared/resource-type'; import { excludeFromEquals } from '../../utilities/equals.decorators'; @@ -13,6 +15,7 @@ import { AUTH_STATUS } from './auth-status.resource-type'; import { AuthTokenInfo } from './auth-token-info.model'; import { AuthMethod } from './auth.method'; import { CacheableObject } from '../../cache/cacheable-object.model'; +import { PaginatedList } from '../../data/paginated-list.model'; /** * Object that represents the authenticated status of a user @@ -61,6 +64,7 @@ export class AuthStatus implements CacheableObject { _links: { self: HALLink; eperson: HALLink; + specialGroups: HALLink; }; /** @@ -70,6 +74,13 @@ export class AuthStatus implements CacheableObject { @link(EPERSON) eperson?: Observable>; + /** + * The SpecialGroup of this auth status + * Will be undefined unless the SpecialGroup {@link HALLink} has been resolved. + */ + @link(GROUP, true) + specialGroups?: Observable>>; + /** * True if the token is valid, false if there was no token or the token wasn't valid */ diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 4d3637b3ef..fd219537a0 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -75,7 +75,6 @@ import { RegistryService } from './registry/registry.service'; import { RoleService } from './roles/role.service'; import { FeedbackDataService } from './feedback/feedback-data.service'; -import { ApiService } from './services/api.service'; import { ServerResponseService } from './services/server-response.service'; import { NativeWindowFactory, NativeWindowService } from './services/window.service'; import { BitstreamFormat } from './shared/bitstream-format.model'; @@ -166,6 +165,7 @@ import { GroupDataService } from './eperson/group-data.service'; import { SubmissionAccessesModel } from './config/models/config-submission-accesses.model'; import { AccessStatusObject } from '../shared/object-list/access-status-badge/access-status.model'; import { AccessStatusDataService } from './data/access-status-data.service'; +import { LinkHeadService } from './services/link-head.service'; import { ResearcherProfileService } from './profile/researcher-profile.service'; import { ProfileClaimService } from '../profile-page/profile-claim/profile-claim.service'; import { ResearcherProfile } from './profile/model/researcher-profile.model'; @@ -193,7 +193,6 @@ const DECLARATIONS = []; const EXPORTS = []; const PROVIDERS = [ - ApiService, AuthenticatedGuard, CommunityDataService, CollectionDataService, @@ -208,6 +207,7 @@ const PROVIDERS = [ SectionFormOperationsService, FormService, EPersonDataService, + LinkHeadService, HALEndpointService, HostWindowService, ItemDataService, diff --git a/src/app/core/pagination/pagination.service.ts b/src/app/core/pagination/pagination.service.ts index db80cc9476..a6f8052c4b 100644 --- a/src/app/core/pagination/pagination.service.ts +++ b/src/app/core/pagination/pagination.service.ts @@ -7,8 +7,8 @@ import { filter, map, take } from 'rxjs/operators'; import { SortDirection, SortOptions } from '../cache/models/sort-options.model'; import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { difference } from '../../shared/object.util'; -import { isNumeric } from 'rxjs/internal-compatibility'; import { FindListOptions } from '../data/find-list-options.model'; +import { isNumeric } from '../../shared/numeric.util'; @Injectable({ providedIn: 'root', diff --git a/src/app/core/resource-policy/resource-policy.service.spec.ts b/src/app/core/resource-policy/resource-policy.service.spec.ts index 59316c0098..b788a16520 100644 --- a/src/app/core/resource-policy/resource-policy.service.spec.ts +++ b/src/app/core/resource-policy/resource-policy.service.spec.ts @@ -19,6 +19,8 @@ import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils import { RestResponse } from '../cache/response.models'; import { RequestEntry } from '../data/request-entry.model'; import { FindListOptions } from '../data/find-list-options.model'; +import { EPersonDataService } from '../eperson/eperson-data.service'; +import { GroupDataService } from '../eperson/group-data.service'; describe('ResourcePolicyService', () => { let scheduler: TestScheduler; @@ -28,6 +30,8 @@ describe('ResourcePolicyService', () => { let objectCache: ObjectCacheService; let halService: HALEndpointService; let responseCacheEntry: RequestEntry; + let ePersonService: EPersonDataService; + let groupService: GroupDataService; const resourcePolicy: any = { id: '1', @@ -88,6 +92,8 @@ describe('ResourcePolicyService', () => { const resourcePolicyRD = createSuccessfulRemoteDataObject(resourcePolicy); const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + const ePersonEndpoint = 'EPERSON_EP'; + beforeEach(() => { scheduler = getTestScheduler(); @@ -105,6 +111,7 @@ describe('ResourcePolicyService', () => { removeByHrefSubstring: {}, getByHref: observableOf(responseCacheEntry), getByUUID: observableOf(responseCacheEntry), + setStaleByHrefSubstring: {}, }); rdbService = jasmine.createSpyObj('rdbService', { buildSingle: hot('a|', { @@ -117,6 +124,11 @@ describe('ResourcePolicyService', () => { a: resourcePolicyRD }) }); + ePersonService = jasmine.createSpyObj('ePersonService', { + getBrowseEndpoint: hot('a', { + a: ePersonEndpoint + }), + }); objectCache = {} as ObjectCacheService; const notificationsService = {} as NotificationsService; const http = {} as HttpClient; @@ -129,7 +141,9 @@ describe('ResourcePolicyService', () => { halService, notificationsService, http, - comparator + comparator, + ePersonService, + groupService ); spyOn((service as any).dataService, 'create').and.callThrough(); @@ -320,4 +334,17 @@ describe('ResourcePolicyService', () => { expect(result).toBeObservable(expected); }); }); + + describe('updateTarget', () => { + it('should create a new PUT request for eperson', () => { + const targetType = 'eperson'; + + const result = service.updateTarget(resourcePolicyId, requestURL, epersonUUID, targetType); + const expected = cold('a|', { + a: resourcePolicyRD + }); + expect(result).toBeObservable(expected); + }); + }); + }); diff --git a/src/app/core/resource-policy/resource-policy.service.ts b/src/app/core/resource-policy/resource-policy.service.ts index 065e58c53d..b0b7a6bd97 100644 --- a/src/app/core/resource-policy/resource-policy.service.ts +++ b/src/app/core/resource-policy/resource-policy.service.ts @@ -1,6 +1,6 @@ /* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; @@ -23,11 +23,19 @@ import { PaginatedList } from '../data/paginated-list.model'; import { ActionType } from './models/action-type.model'; import { RequestParam } from '../cache/models/request-param.model'; import { isNotEmpty } from '../../shared/empty.util'; -import { map } from 'rxjs/operators'; +import { map, take } from 'rxjs/operators'; import { NoContent } from '../shared/NoContent.model'; import { getFirstCompletedRemoteData } from '../shared/operators'; import { CoreState } from '../core-state.model'; import { FindListOptions } from '../data/find-list-options.model'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { PutRequest } from '../data/request.models'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { ResponseParsingService } from '../data/parsing.service'; +import { StatusCodeOnlyResponseParsingService } from '../data/status-code-only-response-parsing.service'; +import { HALLink } from '../shared/hal-link.model'; +import { EPersonDataService } from '../eperson/eperson-data.service'; +import { GroupDataService } from '../eperson/group-data.service'; /** @@ -44,7 +52,8 @@ class DataServiceImpl extends DataService { protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: ChangeAnalyzer) { + protected comparator: ChangeAnalyzer, + ) { super(); } @@ -68,7 +77,10 @@ export class ResourcePolicyService { protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { + protected comparator: DefaultChangeAnalyzer, + protected ePersonService: EPersonDataService, + protected groupService: GroupDataService, + ) { this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); } @@ -221,4 +233,44 @@ export class ResourcePolicyService { return this.dataService.searchBy(this.searchByResourceMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + /** + * Update the target of the resource policy + * @param resourcePolicyId the ID of the resource policy + * @param resourcePolicyHref the link to the resource policy + * @param targetUUID the UUID of the target to which the permission is being granted + * @param targetType the type of the target (eperson or group) to which the permission is being granted + */ + updateTarget(resourcePolicyId: string, resourcePolicyHref: string, targetUUID: string, targetType: string): Observable> { + + const targetService = targetType === 'eperson' ? this.ePersonService : this.groupService; + + const targetEndpoint$ = targetService.getBrowseEndpoint().pipe( + take(1), + map((endpoint: string) =>`${endpoint}/${targetUUID}`), + ); + + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + + const requestId = this.requestService.generateRequestId(); + + this.requestService.setStaleByHrefSubstring(`${this.dataService.getLinkPath()}/${resourcePolicyId}/${targetType}`); + + targetEndpoint$.subscribe((targetEndpoint) => { + const resourceEndpoint = resourcePolicyHref + '/' + targetType; + const request = new PutRequest(requestId, resourceEndpoint, targetEndpoint, options); + Object.assign(request, { + getResponseParser(): GenericConstructor { + return StatusCodeOnlyResponseParsingService; + } + }); + this.requestService.send(request); + }); + + return this.rdbService.buildFromRequestUUID(requestId); + + } + } diff --git a/src/app/core/services/api.service.ts b/src/app/core/services/api.service.ts deleted file mode 100644 index 4f8474e0c1..0000000000 --- a/src/app/core/services/api.service.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { throwError as observableThrowError } from 'rxjs'; -import { catchError } from 'rxjs/operators'; - -import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; - -@Injectable() -export class ApiService { - constructor(public _http: HttpClient) { - - } - - /** - * whatever domain/feature method name - */ - get(url: string, options?: any) { - return this._http.get(url, options).pipe( - catchError((err) => { - console.log('Error: ', err); - return observableThrowError(err); - })); - } - -} diff --git a/src/app/core/services/link-head.service.spec.ts b/src/app/core/services/link-head.service.spec.ts new file mode 100644 index 0000000000..017fe6af03 --- /dev/null +++ b/src/app/core/services/link-head.service.spec.ts @@ -0,0 +1,45 @@ +import { DOCUMENT } from '@angular/common'; +import { Renderer2, RendererFactory2 } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { MockProvider } from 'ng-mocks'; +import { LinkHeadService } from './link-head.service'; + +describe('LinkHeadService', () => { + + let service: LinkHeadService; + + const renderer2: Renderer2 = { + createRenderer: jasmine.createSpy('createRenderer'), + createElement: jasmine.createSpy('createElement'), + setAttribute: jasmine.createSpy('setAttribute'), + appendChild: jasmine.createSpy('appendChild') + } as unknown as Renderer2; + + beforeEach(waitForAsync(() => { + return TestBed.configureTestingModule({ + providers: [ + MockProvider(RendererFactory2, { + createRenderer: () => renderer2 + }), + { provide: Document, useExisting: DOCUMENT }, + ] + }); + })); + + beforeEach(() => { + service = new LinkHeadService(TestBed.inject(RendererFactory2), TestBed.inject(DOCUMENT)); + }); + + describe('link', () => { + it('should create a link tag', () => { + const link = service.addTag({ + href: 'test', + type: 'application/atom+xml', + rel: 'alternate', + title: 'Sitewide Atom feed' + }); + expect(link).not.toBeUndefined(); + }); + }); + +}); diff --git a/src/app/core/services/link-head.service.ts b/src/app/core/services/link-head.service.ts new file mode 100644 index 0000000000..d608618ca4 --- /dev/null +++ b/src/app/core/services/link-head.service.ts @@ -0,0 +1,90 @@ +import { Injectable, RendererFactory2, ViewEncapsulation, Inject } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; + +/** + * LinkHead Service injects tag into the head element during runtime. + */ +@Injectable() +export class LinkHeadService { + constructor( + private rendererFactory: RendererFactory2, + @Inject(DOCUMENT) private document + ) { + + } + + /** + * Method to create a Link tag in the HEAD of the html. + * @param tag LinkDefition is the paramaters to define a link tag. + * @returns Link tag that was created + */ + addTag(tag: LinkDefinition) { + + try { + const renderer = this.rendererFactory.createRenderer(this.document, { + id: '-1', + encapsulation: ViewEncapsulation.None, + styles: [], + data: {} + }); + + const link = renderer.createElement('link'); + const head = this.document.head; + + if (head === null) { + throw new Error(' not found within DOCUMENT.'); + } + + Object.keys(tag).forEach((prop: string) => { + return renderer.setAttribute(link, prop, tag[prop]); + }); + + renderer.appendChild(head, link); + return renderer; + } catch (e) { + console.error('Error within linkService : ', e); + } + } + + /** + * Removes a link tag in header based on the given attrSelector. + * @param attrSelector The attr assigned to a link tag which will be used to determine what link to remove. + */ + removeTag(attrSelector: string) { + if (attrSelector) { + try { + const renderer = this.rendererFactory.createRenderer(this.document, { + id: '-1', + encapsulation: ViewEncapsulation.None, + styles: [], + data: {} + }); + const head = this.document.head; + if (head === null) { + throw new Error(' not found within DOCUMENT.'); + } + const linkTags = this.document.querySelectorAll('link[' + attrSelector + ']'); + for (const link of linkTags) { + renderer.removeChild(head, link); + } + } catch (e) { + console.log('Error while removing tag ' + e.message); + } + } + } +} + +export declare type LinkDefinition = { + charset?: string; + crossorigin?: string; + href?: string; + hreflang?: string; + media?: string; + rel?: string; + rev?: string; + sizes?: string; + target?: string; + type?: string; +} & { + [prop: string]: string; + }; diff --git a/src/app/core/shared/hal-endpoint.service.spec.ts b/src/app/core/shared/hal-endpoint.service.spec.ts index b29b8f662e..78a296496a 100644 --- a/src/app/core/shared/hal-endpoint.service.spec.ts +++ b/src/app/core/shared/hal-endpoint.service.spec.ts @@ -3,7 +3,7 @@ import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from './hal-endpoint.service'; import { EndpointMapRequest } from '../data/request.models'; -import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { environment } from '../../../environments/environment'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; @@ -162,9 +162,9 @@ describe('HALEndpointService', () => { return observableOf(endpointMaps[param]); }); - observableCombineLatest([ + observableCombineLatest([ (service as any).getEndpointAt(start, 'one'), - (service as any).getEndpointAt(start, 'one', 'two') + (service as any).getEndpointAt(start, 'one', 'two'), ]).subscribe(([endpoint1, endpoint2]) => { expect(endpoint1).toEqual(one); expect(endpoint2).toEqual(two); diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index b2ceaa4964..32610c82fd 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -1,5 +1,5 @@ -import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { debounceTime, filter, find, map, switchMap, take, takeWhile } from 'rxjs/operators'; +import { combineLatest as observableCombineLatest, Observable, interval } from 'rxjs'; +import { filter, find, map, switchMap, take, takeWhile, debounce, debounceTime } from 'rxjs/operators'; import { hasNoValue, hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util'; import { SearchResult } from '../../shared/search/models/search-result.model'; import { PaginatedList } from '../data/paginated-list.model'; @@ -9,6 +9,17 @@ import { MetadataSchema } from '../metadata/metadata-schema.model'; import { BrowseDefinition } from './browse-definition.model'; import { DSpaceObject } from './dspace-object.model'; import { InjectionToken } from '@angular/core'; +import { MonoTypeOperatorFunction, SchedulerLike } from 'rxjs/internal/types'; + +/** + * Use this method instead of the RxJs debounceTime if you're waiting for debouncing in tests; + * debounceTime doesn't work with fakeAsync/tick anymore as of Angular 13.2.6 & RxJs 7.5.5 + * Workaround suggested in https://github.com/angular/angular/issues/44351#issuecomment-1107454054 + * todo: remove once the above issue is fixed + */ +export const debounceTimeWorkaround = (dueTime: number, scheduler?: SchedulerLike): MonoTypeOperatorFunction => { + return debounce(() => interval(dueTime, scheduler)); +}; export const DEBOUNCE_TIME_OPERATOR = new InjectionToken<(dueTime: number) => (source: Observable) => Observable>('debounceTime', { providedIn: 'root', diff --git a/src/app/core/submission/resolver/submission-object.resolver.ts b/src/app/core/submission/resolver/submission-object.resolver.ts new file mode 100644 index 0000000000..32f6c544e2 --- /dev/null +++ b/src/app/core/submission/resolver/submission-object.resolver.ts @@ -0,0 +1,43 @@ +import { DSpaceObject } from './../../shared/dspace-object.model'; +import { followLink } from './../../../shared/utils/follow-link-config.model'; +import { ChildHALResource } from './../../shared/child-hal-resource.model'; +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs'; +import { Store } from '@ngrx/store'; +import { switchMap } from 'rxjs/operators'; +import { DataService } from '../../data/data.service'; +import { RemoteData } from '../../data/remote-data'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; + +/** + * This class represents a resolver that requests a specific item before the route is activated + */ +@Injectable() +export class SubmissionObjectResolver implements Resolve> { + constructor( + protected dataService: DataService, + protected store: Store + ) { + } + + /** + * Method for resolving an item based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable<> Emits the found item based on the parameters in the current route, + * or an error if something went wrong + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { + const itemRD$ = this.dataService.findById(route.params.id, + true, + false, + followLink('item'), + ).pipe( + getFirstCompletedRemoteData(), + switchMap((wfiRD: RemoteData) => wfiRD.payload.item as Observable>), + getFirstCompletedRemoteData() + ); + return itemRD$; + } +} diff --git a/src/app/home-page/top-level-community-list/top-level-community-list.component.spec.ts b/src/app/home-page/top-level-community-list/top-level-community-list.component.spec.ts index eb52ca9243..2561770942 100644 --- a/src/app/home-page/top-level-community-list/top-level-community-list.component.spec.ts +++ b/src/app/home-page/top-level-community-list/top-level-community-list.component.spec.ts @@ -25,6 +25,13 @@ import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; import { ThemeService } from '../../shared/theme-support/theme.service'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { FindListOptions } from '../../core/data/find-list-options.model'; +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { GroupDataService } from '../../core/eperson/group-data.service'; +import { LinkHeadService } from '../../core/services/link-head.service'; +import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub'; describe('TopLevelCommunityList Component', () => { let comp: TopLevelCommunityListComponent; @@ -114,6 +121,25 @@ describe('TopLevelCommunityList Component', () => { themeService = getMockThemeService(); + const linkHeadService = jasmine.createSpyObj('linkHeadService', { + addTag: '' + }); + + const groupDataService = jasmine.createSpyObj('groupsDataService', { + findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + getGroupRegistryRouterLink: '', + getUUIDFromString: '', + }); + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test' + ] + })) + }); + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ @@ -130,6 +156,10 @@ describe('TopLevelCommunityList Component', () => { { provide: PaginationService, useValue: paginationService }, { provide: SelectableListService, useValue: {} }, { provide: ThemeService, useValue: themeService }, + { provide: GroupDataService, useValue: groupDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html index 24db0ec300..5d1edb847f 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html @@ -27,7 +27,7 @@ + [attr.data-test]="'download-button' | dsBrowserOnly">
- diff --git a/src/app/shared/remote-data.utils.ts b/src/app/shared/remote-data.utils.ts index 2a7dee6383..50b7d7f9f9 100644 --- a/src/app/shared/remote-data.utils.ts +++ b/src/app/shared/remote-data.utils.ts @@ -61,7 +61,7 @@ export function createFailedRemoteDataObject(errorMessage?: string, statusCod * @param timeCompleted the moment when the remoteData was completed */ export function createFailedRemoteDataObject$(errorMessage?: string, statusCode?: number, timeCompleted?: number): Observable> { - return observableOf(createFailedRemoteDataObject(errorMessage, statusCode, timeCompleted)); + return observableOf(createFailedRemoteDataObject(errorMessage, statusCode, timeCompleted)); } /** @@ -85,7 +85,7 @@ export function createPendingRemoteDataObject(lastVerified = FIXED_TIMESTAMP) * @param lastVerified the moment when the remoteData was last verified */ export function createPendingRemoteDataObject$(lastVerified?: number): Observable> { - return observableOf(createPendingRemoteDataObject(lastVerified)); + return observableOf(createPendingRemoteDataObject(lastVerified)); } /** diff --git a/src/app/shared/resource-policies/edit/resource-policy-edit.component.ts b/src/app/shared/resource-policies/edit/resource-policy-edit.component.ts index a515eef675..1c5a0c4a5f 100644 --- a/src/app/shared/resource-policies/edit/resource-policy-edit.component.ts +++ b/src/app/shared/resource-policies/edit/resource-policy-edit.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { BehaviorSubject, Observable } from 'rxjs'; +import { BehaviorSubject, Observable, of, combineLatest as observableCombineLatest, } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; @@ -88,16 +88,33 @@ export class ResourcePolicyEditComponent implements OnInit { type: RESOURCE_POLICY.value, _links: this.resourcePolicy._links }); - this.resourcePolicyService.update(updatedObject).pipe( + + const updateTargetSucceeded$ = event.updateTarget ? this.resourcePolicyService.updateTarget( + this.resourcePolicy.id, this.resourcePolicy._links.self.href, event.target.uuid, event.target.type + ).pipe( getFirstCompletedRemoteData(), - ).subscribe((responseRD: RemoteData) => { - this.processing$.next(false); - if (responseRD && responseRD.hasSucceeded) { - this.notificationsService.success(null, this.translate.get('resource-policies.edit.page.success.content')); - this.redirectToAuthorizationsPage(); - } else { - this.notificationsService.error(null, this.translate.get('resource-policies.edit.page.failure.content')); + map((responseRD) => responseRD && responseRD.hasSucceeded) + ) : of(true); + + const updateResourcePolicySucceeded$ = this.resourcePolicyService.update(updatedObject).pipe( + getFirstCompletedRemoteData(), + map((responseRD) => responseRD && responseRD.hasSucceeded) + ); + + observableCombineLatest([updateTargetSucceeded$, updateResourcePolicySucceeded$]).subscribe( + ([updateTargetSucceeded, updateResourcePolicySucceeded]) => { + this.processing$.next(false); + if (updateTargetSucceeded && updateResourcePolicySucceeded) { + this.notificationsService.success(null, this.translate.get('resource-policies.edit.page.success.content')); + this.redirectToAuthorizationsPage(); + } else if (updateResourcePolicySucceeded) { // everything except target has been updated + this.notificationsService.error(null, this.translate.get('resource-policies.edit.page.target-failure.content')); + } else if (updateTargetSucceeded) { // only target has been updated + this.notificationsService.error(null, this.translate.get('resource-policies.edit.page.other-failure.content')); + } else { // nothing has been updated + this.notificationsService.error(null, this.translate.get('resource-policies.edit.page.failure.content')); + } } - }); + ); } } diff --git a/src/app/shared/resource-policies/form/resource-policy-form.component.html b/src/app/shared/resource-policies/form/resource-policy-form.component.html index 42475a955b..66c1fc400e 100644 --- a/src/app/shared/resource-policies/form/resource-policy-form.component.html +++ b/src/app/shared/resource-policies/form/resource-policy-form.component.html @@ -7,25 +7,23 @@ [displayCancel]="false"> + + + + + + + diff --git a/src/app/shared/resource-policies/form/resource-policy-form.component.spec.ts b/src/app/shared/resource-policies/form/resource-policy-form.component.spec.ts index 5cc6397118..e555522c79 100644 --- a/src/app/shared/resource-policies/form/resource-policy-form.component.spec.ts +++ b/src/app/shared/resource-policies/form/resource-policy-form.component.spec.ts @@ -242,6 +242,7 @@ describe('ResourcePolicyFormComponent test suite', () => { fixture = TestBed.createComponent(ResourcePolicyFormComponent); comp = fixture.componentInstance; compAsAny = fixture.componentInstance; + compAsAny.resourcePolicy = resourcePolicy; comp.isProcessing = observableOf(false); }); @@ -253,6 +254,8 @@ describe('ResourcePolicyFormComponent test suite', () => { }); it('should init form model properly', () => { + epersonService.findByHref.and.returnValue(observableOf(undefined)); + groupService.findByHref.and.returnValue(observableOf(undefined)); spyOn(compAsAny, 'isFormValid').and.returnValue(observableOf(false)); spyOn(compAsAny, 'initModelsValue').and.callThrough(); spyOn(compAsAny, 'buildResourcePolicyForm').and.callThrough(); @@ -261,12 +264,12 @@ describe('ResourcePolicyFormComponent test suite', () => { expect(compAsAny.buildResourcePolicyForm).toHaveBeenCalled(); expect(compAsAny.initModelsValue).toHaveBeenCalled(); expect(compAsAny.formModel.length).toBe(5); - expect(compAsAny.subs.length).toBe(0); + expect(compAsAny.subs.length).toBe(1); }); it('should can set grant', () => { - expect(comp.canSetGrant()).toBeTruthy(); + expect(comp.isBeingEdited()).toBeTruthy(); }); it('should not have a target name', () => { @@ -279,7 +282,7 @@ describe('ResourcePolicyFormComponent test suite', () => { expect(compAsAny.reset.emit).toHaveBeenCalled(); }); - it('should update resource policy grant object properly', () => { + it('should update resource policy grant object properly', () => { comp.updateObjectSelected(EPersonMock, true); expect(comp.resourcePolicyGrant).toEqual(EPersonMock); @@ -301,6 +304,7 @@ describe('ResourcePolicyFormComponent test suite', () => { comp = fixture.componentInstance; compAsAny = fixture.componentInstance; comp.resourcePolicy = resourcePolicy; + compAsAny.resourcePolicy = resourcePolicy; comp.isProcessing = observableOf(false); compAsAny.ePersonService.findByHref.and.returnValue( observableOf(createSuccessfulRemoteDataObject({})).pipe(delay(100)) @@ -343,8 +347,8 @@ describe('ResourcePolicyFormComponent test suite', () => { }); }); - it('should not can set grant', () => { - expect(comp.canSetGrant()).toBeFalsy(); + it('should be being edited', () => { + expect(comp.isBeingEdited()).toBeTrue(); }); it('should have a target name', () => { @@ -398,6 +402,7 @@ describe('ResourcePolicyFormComponent test suite', () => { type: 'group', uuid: GroupMock.id }; + eventPayload.updateTarget = false; scheduler = getTestScheduler(); scheduler.schedule(() => comp.onSubmit()); diff --git a/src/app/shared/resource-policies/form/resource-policy-form.component.ts b/src/app/shared/resource-policies/form/resource-policy-form.component.ts index 7ae699340e..223e610908 100644 --- a/src/app/shared/resource-policies/form/resource-policy-form.component.ts +++ b/src/app/shared/resource-policies/form/resource-policy-form.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; import { Observable, @@ -41,6 +41,7 @@ import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; import { GroupDataService } from '../../../core/eperson/group-data.service'; import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; import { RequestService } from '../../../core/data/request.service'; +import { NgbModal, NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap'; export interface ResourcePolicyEvent { object: ResourcePolicy; @@ -48,6 +49,7 @@ export interface ResourcePolicyEvent { type: string, uuid: string }; + updateTarget: boolean; } @Component({ @@ -83,6 +85,8 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { */ @Output() submit: EventEmitter = new EventEmitter(); + @ViewChild('content') content: ElementRef; + /** * The form id * @type {string} @@ -125,6 +129,10 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { */ private subs: Subscription[] = []; + navActiveId: string; + + resourcePolicyTargetUpdated = false; + /** * Initialize instance variables * @@ -133,6 +141,7 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { * @param {FormService} formService * @param {GroupDataService} groupService * @param {RequestService} requestService + * @param modalService */ constructor( private dsoNameService: DSONameService, @@ -140,6 +149,7 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { private formService: FormService, private groupService: GroupDataService, private requestService: RequestService, + private modalService: NgbModal, ) { } @@ -151,7 +161,7 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { this.formId = this.formService.getUniqueId('resource-policy-form'); this.formModel = this.buildResourcePolicyForm(); - if (!this.canSetGrant()) { + if (this.isBeingEdited()) { const epersonRD$ = this.ePersonService.findByHref(this.resourcePolicy._links.eperson.href, false).pipe( getFirstSucceededRemoteData() ); @@ -169,6 +179,7 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { filter(() => this.isActive), ).subscribe((dsoRD: RemoteData) => { this.resourcePolicyGrant = dsoRD.payload; + this.navActiveId = String(dsoRD.payload.type); this.resourcePolicyTargetName$.next(this.getResourcePolicyTargetName()); }) ); @@ -193,19 +204,12 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { */ private buildResourcePolicyForm(): DynamicFormControlModel[] { const formModel: DynamicFormControlModel[] = []; - // TODO to be removed when https://jira.lyrasis.org/browse/DS-4477 will be implemented - const policyTypeConf = Object.assign({}, RESOURCE_POLICY_FORM_POLICY_TYPE_CONFIG, { - disabled: isNotEmpty(this.resourcePolicy) - }); - // TODO to be removed when https://jira.lyrasis.org/browse/DS-4477 will be implemented - const actionConf = Object.assign({}, RESOURCE_POLICY_FORM_ACTION_TYPE_CONFIG, { - disabled: isNotEmpty(this.resourcePolicy) - }); + formModel.push( new DsDynamicInputModel(RESOURCE_POLICY_FORM_NAME_CONFIG), new DsDynamicTextAreaModel(RESOURCE_POLICY_FORM_DESCRIPTION_CONFIG), - new DynamicSelectModel(policyTypeConf), - new DynamicSelectModel(actionConf) + new DynamicSelectModel(RESOURCE_POLICY_FORM_POLICY_TYPE_CONFIG), + new DynamicSelectModel(RESOURCE_POLICY_FORM_ACTION_TYPE_CONFIG) ); const startDateModel = new DynamicDatePickerModel( @@ -255,8 +259,8 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { * * @return true if is possible, false otherwise */ - canSetGrant(): boolean { - return isEmpty(this.resourcePolicy); + isBeingEdited(): boolean { + return !isEmpty(this.resourcePolicy); } /** @@ -272,8 +276,10 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { * Update reference to the eperson or group that will be granted the permission */ updateObjectSelected(object: DSpaceObject, isEPerson: boolean): void { + this.resourcePolicyTargetUpdated = true; this.resourcePolicyGrant = object; this.resourcePolicyGrantType = isEPerson ? 'eperson' : 'group'; + this.resourcePolicyTargetName$.next(this.getResourcePolicyTargetName()); } /** @@ -297,6 +303,7 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { type: this.resourcePolicyGrantType, uuid: this.resourcePolicyGrant.id }; + eventPayload.updateTarget = this.resourcePolicyTargetUpdated; this.submit.emit(eventPayload); }); } @@ -329,4 +336,12 @@ export class ResourcePolicyFormComponent implements OnInit, OnDestroy { .filter((subscription) => hasValue(subscription)) .forEach((subscription) => subscription.unsubscribe()); } + + onNavChange(changeEvent: NgbNavChangeEvent) { + // if a policy is being edited it should not be possible to switch between group and eperson + if (this.isBeingEdited()) { + changeEvent.preventDefault(); + this.modalService.open(this.content); + } + } } diff --git a/src/app/shared/rss-feed/rss.component.html b/src/app/shared/rss-feed/rss.component.html new file mode 100644 index 0000000000..91140c50c5 --- /dev/null +++ b/src/app/shared/rss-feed/rss.component.html @@ -0,0 +1,5 @@ + +
+ +
+
diff --git a/src/app/shared/rss-feed/rss.component.scss b/src/app/shared/rss-feed/rss.component.scss new file mode 100644 index 0000000000..91310eddcb --- /dev/null +++ b/src/app/shared/rss-feed/rss.component.scss @@ -0,0 +1,12 @@ +:host { + .dropdown-toggle::after { + display: none; + } + .dropdown-item { + padding-left: 20px; + } +} + +.margin-right { + margin-right: .5em; +} diff --git a/src/app/shared/rss-feed/rss.component.spec.ts b/src/app/shared/rss-feed/rss.component.spec.ts new file mode 100644 index 0000000000..fc19c65e60 --- /dev/null +++ b/src/app/shared/rss-feed/rss.component.spec.ts @@ -0,0 +1,113 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { RemoteData } from '../../core/data/remote-data'; +import { GroupDataService } from '../../core/eperson/group-data.service'; +import { PaginationService } from '../../core/pagination/pagination.service'; +import { LinkHeadService } from '../../core/services/link-head.service'; +import { Collection } from '../../core/shared/collection.model'; +import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; +import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; +import { PaginationServiceStub } from '../testing/pagination-service.stub'; +import { createPaginatedList } from '../testing/utils.test'; +import { RSSComponent } from './rss.component'; +import { of as observableOf } from 'rxjs'; +import { SearchConfigurationServiceStub } from '../testing/search-configuration-service.stub'; +import { PaginatedSearchOptions } from '../search/models/paginated-search-options.model'; +import { Router } from '@angular/router'; +import { RouterMock } from '../mocks/router.mock'; + + + +describe('RssComponent', () => { + let comp: RSSComponent; + let options: SortOptions; + let fixture: ComponentFixture; + let uuid: string; + let query: string; + let groupDataService: GroupDataService; + let linkHeadService: LinkHeadService; + let configurationDataService: ConfigurationDataService; + let paginationService; + + beforeEach(waitForAsync(() => { + const mockCollection: Collection = Object.assign(new Collection(), { + id: 'ce41d451-97ed-4a9c-94a1-7de34f16a9f4', + name: 'test-collection', + _links: { + mappedItems: { + href: 'https://rest.api/collections/ce41d451-97ed-4a9c-94a1-7de34f16a9f4/mappedItems' + }, + self: { + href: 'https://rest.api/collections/ce41d451-97ed-4a9c-94a1-7de34f16a9f4' + } + } + }); + configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'test', + values: [ + 'org.dspace.ctask.general.ProfileFormats = test' + ] + })) + }); + linkHeadService = jasmine.createSpyObj('linkHeadService', { + addTag: '' + }); + const mockCollectionRD: RemoteData = createSuccessfulRemoteDataObject(mockCollection); + const mockSearchOptions = observableOf(new PaginatedSearchOptions({ + pagination: Object.assign(new PaginationComponentOptions(), { + id: 'search-page-configuration', + pageSize: 10, + currentPage: 1 + }), + sort: new SortOptions('dc.title', SortDirection.ASC), + })); + groupDataService = jasmine.createSpyObj('groupsDataService', { + findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + getGroupRegistryRouterLink: '', + getUUIDFromString: '', + }); + paginationService = new PaginationServiceStub(); + const searchConfigService = { + paginatedSearchOptions: mockSearchOptions + }; + TestBed.configureTestingModule({ + providers: [ + { provide: GroupDataService, useValue: groupDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() }, + { provide: PaginationService, useValue: paginationService }, + { provide: Router, useValue: new RouterMock() } + ], + declarations: [RSSComponent] + }).compileComponents(); + })); + + beforeEach(() => { + options = new SortOptions('dc.title', SortDirection.DESC); + uuid = '2cfcf65e-0a51-4bcb-8592-b8db7b064790'; + query = 'test'; + fixture = TestBed.createComponent(RSSComponent); + comp = fixture.componentInstance; + }); + + it('should formulate the correct url given params in url', () => { + const route = comp.formulateRoute(uuid, 'opensearch', options, query); + expect(route).toBe('/opensearch/search?format=atom&scope=2cfcf65e-0a51-4bcb-8592-b8db7b064790&sort=dc.title&sort_direction=DESC&query=test'); + }); + + it('should skip uuid if its null', () => { + const route = comp.formulateRoute(null, 'opensearch', options, query); + expect(route).toBe('/opensearch/search?format=atom&sort=dc.title&sort_direction=DESC&query=test'); + }); + + it('should default to query * if none provided', () => { + const route = comp.formulateRoute(null, 'opensearch', options, null); + expect(route).toBe('/opensearch/search?format=atom&sort=dc.title&sort_direction=DESC&query=*'); + }); +}); + diff --git a/src/app/shared/rss-feed/rss.component.ts b/src/app/shared/rss-feed/rss.component.ts new file mode 100644 index 0000000000..3fdb859bdc --- /dev/null +++ b/src/app/shared/rss-feed/rss.component.ts @@ -0,0 +1,163 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + OnInit, + ViewEncapsulation +} from '@angular/core'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { GroupDataService } from '../../core/eperson/group-data.service'; +import { LinkHeadService } from '../../core/services/link-head.service'; +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { environment } from '../../../../src/environments/environment'; +import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +import { SortOptions } from '../../core/cache/models/sort-options.model'; +import { PaginationService } from '../../core/pagination/pagination.service'; +import { Router } from '@angular/router'; +import { map, switchMap } from 'rxjs/operators'; +import { PaginatedSearchOptions } from '../search/models/paginated-search-options.model'; +import { RemoteData } from '../../core/data/remote-data'; + + +/** + * The Rss feed button componenet. + */ +@Component({ + exportAs: 'rssComponent', + selector: 'ds-rss', + styleUrls: ['rss.component.scss'], + templateUrl: 'rss.component.html', + changeDetection: ChangeDetectionStrategy.Default, + encapsulation: ViewEncapsulation.Emulated +}) +export class RSSComponent implements OnInit, OnDestroy { + + route$: BehaviorSubject; + + isEnabled$: BehaviorSubject = new BehaviorSubject(null); + + uuid: string; + configuration$: Observable; + sortOption$: Observable; + + subs: Subscription[] = []; + + constructor(private groupDataService: GroupDataService, + private linkHeadService: LinkHeadService, + private configurationService: ConfigurationDataService, + private searchConfigurationService: SearchConfigurationService, + private router: Router, + protected paginationService: PaginationService) { + } + /** + * Removes the linktag created when the component gets removed from the page. + */ + ngOnDestroy(): void { + this.linkHeadService.removeTag("rel='alternate'"); + this.subs.forEach(sub => { + sub.unsubscribe(); + }); + } + + + /** + * Generates the link tags and the url to opensearch when the component is loaded. + */ + ngOnInit(): void { + this.configuration$ = this.searchConfigurationService.getCurrentConfiguration('default'); + + this.subs.push(this.configurationService.findByPropertyName('websvc.opensearch.enable').pipe( + getFirstCompletedRemoteData(), + ).subscribe((result) => { + if (result.hasSucceeded) { + const enabled = (result.payload.values[0] === 'true'); + this.isEnabled$.next(enabled); + } + })); + this.subs.push(this.configurationService.findByPropertyName('websvc.opensearch.svccontext').pipe( + getFirstCompletedRemoteData(), + map((result: RemoteData) => { + if (result.hasSucceeded) { + return result.payload.values[0]; + } + return null; + }), + switchMap((openSearchUri: string) => + this.searchConfigurationService.paginatedSearchOptions.pipe( + map((searchOptions: PaginatedSearchOptions) => ({ openSearchUri, searchOptions })) + ) + ), + ).subscribe(({ openSearchUri, searchOptions }) => { + if (!openSearchUri) { + return null; + } + this.uuid = this.groupDataService.getUUIDFromString(this.router.url); + const route = environment.rest.baseUrl + this.formulateRoute(this.uuid, openSearchUri, searchOptions.sort, searchOptions.query); + this.addLinks(route); + this.linkHeadService.addTag({ + href: environment.rest.baseUrl + '/' + openSearchUri + '/service', + type: 'application/atom+xml', + rel: 'search', + title: 'Dspace' + }); + this.route$ = new BehaviorSubject(route); + })); + } + + /** + * Function created a route given the different params available to opensearch + * @param uuid The uuid if a scope is present + * @param opensearch openSearch uri + * @param sort The sort options for the opensearch request + * @param query The query string that was provided in the search + * @returns The combine URL to opensearch + */ + formulateRoute(uuid: string, opensearch: string, sort: SortOptions, query: string): string { + let route = 'search?format=atom'; + if (uuid) { + route += `&scope=${uuid}`; + } + if (sort && sort.direction && sort.field && sort.field !== 'id') { + route += `&sort=${sort.field}&sort_direction=${sort.direction}`; + } + if (query) { + route += `&query=${query}`; + } else { + route += `&query=*`; + } + route = '/' + opensearch + '/' + route; + return route; + } + + /** + * Check if the router url contains the specified route + * + * @param {string} route + * @returns + * @memberof MyComponent + */ + hasRoute(route: string) { + return this.router.url.includes(route); + } + + /** + * Creates tags in the header of the page + * @param route The composed url to opensearch + */ + addLinks(route: string): void { + this.linkHeadService.addTag({ + href: route, + type: 'application/atom+xml', + rel: 'alternate', + title: 'Sitewide Atom feed' + }); + route = route.replace('format=atom', 'format=rss'); + this.linkHeadService.addTag({ + href: route, + type: 'application/rss+xml', + rel: 'alternate', + title: 'Sitewide RSS feed' + }); + } +} diff --git a/src/app/shared/search-form/search-form.component.html b/src/app/shared/search-form/search-form.component.html index 354e30233a..226962cc61 100644 --- a/src/app/shared/search-form/search-form.component.html +++ b/src/app/shared/search-form/search-form.component.html @@ -4,10 +4,10 @@
- - +
diff --git a/src/app/shared/search-form/search-form.component.spec.ts b/src/app/shared/search-form/search-form.component.spec.ts index 934f00b10c..4b5844f660 100644 --- a/src/app/shared/search-form/search-form.component.spec.ts +++ b/src/app/shared/search-form/search-form.component.spec.ts @@ -13,7 +13,10 @@ import { SearchConfigurationService } from '../../core/shared/search/search-conf import { PaginationServiceStub } from '../testing/pagination-service.stub'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; -import { FindListOptions } from '../../core/data/find-list-options.model'; +import { BrowserOnlyMockPipe } from '../testing/browser-only-mock.pipe'; +import { SearchServiceStub } from '../testing/search-service.stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../testing/router.stub'; describe('SearchFormComponent', () => { let comp: SearchFormComponent; @@ -21,23 +24,28 @@ describe('SearchFormComponent', () => { let de: DebugElement; let el: HTMLElement; + const router = new RouterStub(); + const searchService = new SearchServiceStub(); const paginationService = new PaginationServiceStub(); - - const searchConfigService = {paginationID: 'test-id'}; + const searchConfigService = { paginationID: 'test-id' }; + const dspaceObjectService = { + findById: () => createSuccessfulRemoteDataObject$(undefined), + }; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [FormsModule, RouterTestingModule, TranslateModule.forRoot()], providers: [ - { - provide: SearchService, - useValue: {} - }, + { provide: Router, useValue: router }, + { provide: SearchService, useValue: searchService }, { provide: PaginationService, useValue: paginationService }, { provide: SearchConfigurationService, useValue: searchConfigService }, - { provide: DSpaceObjectDataService, useValue: { findById: () => createSuccessfulRemoteDataObject$(undefined)} } + { provide: DSpaceObjectDataService, useValue: dspaceObjectService }, ], - declarations: [SearchFormComponent] + declarations: [ + SearchFormComponent, + BrowserOnlyMockPipe, + ] }).compileComponents(); })); @@ -90,6 +98,81 @@ describe('SearchFormComponent', () => { expect(scopeSelect.textContent).toBe(testCommunity.name); })); + + describe('updateSearch', () => { + const query = 'THOR'; + const scope = 'MCU'; + let searchQuery = {}; + + it('should navigate to the search page even when no parameters are provided', () => { + comp.updateSearch(searchQuery); + + expect(router.navigate).toHaveBeenCalledWith(comp.getSearchLinkParts(), { + queryParams: searchQuery, + queryParamsHandling: 'merge' + }); + }); + + it('should navigate to the search page with parameters only query if only query is provided', () => { + searchQuery = { + query: query + }; + + comp.updateSearch(searchQuery); + + expect(router.navigate).toHaveBeenCalledWith(comp.getSearchLinkParts(), { + queryParams: searchQuery, + queryParamsHandling: 'merge' + }); + }); + + it('should navigate to the search page with parameters only query if only scope is provided', () => { + searchQuery = { + scope: scope + }; + + comp.updateSearch(searchQuery); + + expect(router.navigate).toHaveBeenCalledWith(comp.getSearchLinkParts(), { + queryParams: searchQuery, + queryParamsHandling: 'merge' + }); + }); + }); + + describe('when the scope variable is used', () => { + const query = 'THOR'; + const scope = 'MCU'; + let searchQuery = {}; + + beforeEach(() => { + spyOn(comp, 'updateSearch'); + }); + + it('should only search in the provided scope', () => { + searchQuery = { + query: query, + scope: scope + }; + + comp.scope = scope; + comp.onSubmit(searchQuery); + + expect(comp.updateSearch).toHaveBeenCalledWith(searchQuery); + }); + + it('should not create searchQuery with the scope if an empty scope is provided', () => { + searchQuery = { + query: query + }; + + comp.scope = ''; + comp.onSubmit(searchQuery); + + expect(comp.updateSearch).toHaveBeenCalledWith(searchQuery); + }); + }); + // it('should call updateSearch when clicking the submit button with correct parameters', fakeAsync(() => { // comp.query = 'Test String' // fixture.detectChanges(); @@ -112,7 +195,7 @@ describe('SearchFormComponent', () => { // // expect(comp.updateSearch).toHaveBeenCalledWith({ scope: scope, query: query }); // })); - }); +}); export const objects: DSpaceObject[] = [ Object.assign(new Community(), { diff --git a/src/app/shared/search-form/search-form.component.ts b/src/app/shared/search-form/search-form.component.ts index caf6a91046..7ea51e4c1e 100644 --- a/src/app/shared/search-form/search-form.component.ts +++ b/src/app/shared/search-form/search-form.component.ts @@ -98,6 +98,9 @@ export class SearchFormComponent implements OnInit { * @param data Values submitted using the form */ onSubmit(data: any) { + if (isNotEmpty(this.scope)) { + data = Object.assign(data, { scope: this.scope }); + } this.updateSearch(data); this.submitSearch.emit(data); } diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-filter.component.html index 230f072772..452433e165 100644 --- a/src/app/shared/search/search-filters/search-filter/search-filter.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.html @@ -4,6 +4,7 @@ class="filter-name d-flex" [attr.aria-controls]="regionId" [id]="toggleId" [attr.aria-expanded]="false" [attr.aria-label]="((collapsed$ | async) ? 'search.filters.filter.expand' : 'search.filters.filter.collapse') | translate" + [attr.data-test]="'filter-toggle' | dsBrowserOnly" >
{{'search.filters.filter.' + filter.name + '.head'| translate}} diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts b/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts index 662b380ce8..7abe45ca8c 100644 --- a/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts @@ -13,6 +13,7 @@ import { FilterType } from '../../models/filter-type.model'; import { SearchConfigurationServiceStub } from '../../../testing/search-configuration-service.stub'; import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component'; import { SequenceService } from '../../../../core/shared/sequence.service'; +import { BrowserOnlyMockPipe } from '../../../testing/browser-only-mock.pipe'; describe('SearchFilterComponent', () => { let comp: SearchFilterComponent; @@ -62,7 +63,10 @@ describe('SearchFilterComponent', () => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule], - declarations: [SearchFilterComponent], + declarations: [ + SearchFilterComponent, + BrowserOnlyMockPipe, + ], providers: [ { provide: SearchService, useValue: searchServiceStub }, { diff --git a/src/app/shared/search/search-filters/search-filters.component.spec.ts b/src/app/shared/search/search-filters/search-filters.component.spec.ts index f84de65fb0..ec1a51a1c4 100644 --- a/src/app/shared/search/search-filters/search-filters.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filters.component.spec.ts @@ -80,7 +80,7 @@ describe('SearchFiltersComponent', () => { expect(comp.initFilters).toHaveBeenCalledTimes(1); - refreshFiltersEmitter.next(); + refreshFiltersEmitter.next(null); expect(comp.initFilters).toHaveBeenCalledTimes(2); }); diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index cfbf6adc5a..d02a121972 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -283,14 +283,13 @@ import { } from './bitstream-request-a-copy-page/bitstream-request-a-copy-page.component'; import { DsSelectComponent } from './ds-select/ds-select.component'; import { LogInOidcComponent } from './log-in/methods/oidc/log-in-oidc.component'; -import { - ThemedItemListPreviewComponent -} from './object-list/my-dspace-result-list-element/item-list-preview/themed-item-list-preview.component'; +import { ThemedItemListPreviewComponent } from './object-list/my-dspace-result-list-element/item-list-preview/themed-item-list-preview.component'; +import { RSSComponent } from './rss-feed/rss.component'; import { ExternalLinkMenuItemComponent } from './menu/menu-item/external-link-menu-item.component'; +import { BrowserOnlyPipe } from './utils/browser-only.pipe'; import { PersonPageClaimButtonComponent } from './dso-page/person-page-claim-button/person-page-claim-button.component'; const MODULES = [ - // Do NOT include UniversalModule, HttpModule, or JsonpModule here CommonModule, SortablejsModule, FileUploadModule, @@ -330,7 +329,8 @@ const PIPES = [ ObjectKeysPipe, ObjectValuesPipe, ConsolePipe, - ObjNgFor + ObjNgFor, + BrowserOnlyPipe, ]; const COMPONENTS = [ @@ -353,6 +353,7 @@ const COMPONENTS = [ AbstractListableElementComponent, ObjectCollectionComponent, PaginationComponent, + RSSComponent, SearchFormComponent, PageWithSidebarComponent, SidebarDropdownComponent, diff --git a/src/app/shared/sidebar/filter/sidebar-filter.component.html b/src/app/shared/sidebar/filter/sidebar-filter.component.html index bd392aa715..79afaa7583 100644 --- a/src/app/shared/sidebar/filter/sidebar-filter.component.html +++ b/src/app/shared/sidebar/filter/sidebar-filter.component.html @@ -1,5 +1,5 @@
-
+
{{ label | translate }}
diff --git a/src/app/shared/testing/auth-request-service.stub.ts b/src/app/shared/testing/auth-request-service.stub.ts index 0dc57427dd..0094324518 100644 --- a/src/app/shared/testing/auth-request-service.stub.ts +++ b/src/app/shared/testing/auth-request-service.stub.ts @@ -34,6 +34,9 @@ export class AuthRequestServiceStub { }, eperson: { href: this.mockUser._links.self.href + }, + specialGroups: { + href: this.mockUser._links.self.href } }; } else { @@ -62,6 +65,9 @@ export class AuthRequestServiceStub { }, eperson: { href: this.mockUser._links.self.href + }, + specialGroups: { + href: this.mockUser._links.self.href } }; } else { diff --git a/src/app/shared/testing/browser-only-mock.pipe.ts b/src/app/shared/testing/browser-only-mock.pipe.ts new file mode 100644 index 0000000000..12e5a7b2d7 --- /dev/null +++ b/src/app/shared/testing/browser-only-mock.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +/** + * Support dsBrowserOnly in unit tests. + */ +@Pipe({ + name: 'dsBrowserOnly' +}) +export class BrowserOnlyMockPipe implements PipeTransform { + transform(value: string): string | undefined { + return value; + } +} diff --git a/src/app/shared/testing/menu-service.stub.ts b/src/app/shared/testing/menu-service.stub.ts index 14eccfda67..926232bad0 100644 --- a/src/app/shared/testing/menu-service.stub.ts +++ b/src/app/shared/testing/menu-service.stub.ts @@ -1,5 +1,6 @@ import { Observable, of as observableOf } from 'rxjs'; import { MenuSection } from '../menu/menu-section.model'; +import { MenuState } from '../menu/menu-state.model'; import { MenuID } from '../menu/menu-id.model'; export class MenuServiceStub { @@ -77,6 +78,10 @@ export class MenuServiceStub { return observableOf(true); } + getMenu(id: MenuID): Observable { // todo: resolve import + return observableOf({} as MenuState); + } + getMenuTopSections(id: MenuID): Observable { return observableOf([this.visibleSection1, this.visibleSection2]); } diff --git a/src/app/shared/testing/search-configuration-service.stub.ts b/src/app/shared/testing/search-configuration-service.stub.ts index 80744ba59a..78b358f0d4 100644 --- a/src/app/shared/testing/search-configuration-service.stub.ts +++ b/src/app/shared/testing/search-configuration-service.stub.ts @@ -17,6 +17,10 @@ export class SearchConfigurationServiceStub { return observableOf('test-id'); } + getCurrentQuery(a) { + return observableOf(a); + } + getCurrentConfiguration(a) { return observableOf(a); } diff --git a/src/app/shared/testing/special-group.mock.ts b/src/app/shared/testing/special-group.mock.ts new file mode 100644 index 0000000000..f1102e0584 --- /dev/null +++ b/src/app/shared/testing/special-group.mock.ts @@ -0,0 +1,56 @@ +import { Observable } from 'rxjs'; + +import { EPersonMock } from './eperson.mock'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; +import { Group } from '../../core/eperson/models/group.model'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; +import { RemoteData } from '../../core/data/remote-data'; + +export const SpecialGroupMock2: Group = Object.assign(new Group(), { + handle: null, + subgroups: [], + epersons: [], + permanent: true, + selfRegistered: false, + _links: { + self: { + href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid2', + }, + subgroups: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid2/subgroups' }, + object: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid2/object' }, + epersons: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid2/epersons' } + }, + _name: 'testgroupname2', + id: 'testgroupid2', + uuid: 'testgroupid2', + type: 'specialGroups', + // object: createSuccessfulRemoteDataObject$({ name: 'testspecialGroupsid2objectName'}) +}); + +export const SpecialGroupMock: Group = Object.assign(new Group(), { + handle: null, + subgroups: [SpecialGroupMock2], + epersons: [EPersonMock], + selfRegistered: false, + permanent: false, + _links: { + self: { + href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid', + }, + subgroups: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid/subgroups' }, + object: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid2/object' }, + epersons: { href: 'https://rest.api/server/api/eperson/specialGroups/testgroupid/epersons' } + }, + _name: 'testgroupname', + id: 'testgroupid', + uuid: 'testgroupid', + type: 'specialGroups', +}); + +export const SpecialGroupDataMock: RemoteData> = createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), [SpecialGroupMock2,SpecialGroupMock])); +export const SpecialGroupDataMock$: Observable>> = createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [SpecialGroupMock2,SpecialGroupMock])); +export const EmptySpecialGroupDataMock$: Observable>> = createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); + + + diff --git a/src/app/shared/testing/test-module.ts b/src/app/shared/testing/test-module.ts index b43adde8ae..7d45dd41f3 100644 --- a/src/app/shared/testing/test-module.ts +++ b/src/app/shared/testing/test-module.ts @@ -5,6 +5,7 @@ import { SharedModule } from '../shared.module'; import { NgComponentOutletDirectiveStub } from './ng-component-outlet-directive.stub'; import { QueryParamsDirectiveStub } from './query-params-directive.stub'; import { RouterLinkDirectiveStub } from './router-link-directive.stub'; +import { BrowserOnlyMockPipe } from './browser-only-mock.pipe'; /** * This module isn't used. It serves to prevent the AoT compiler @@ -21,7 +22,8 @@ import { RouterLinkDirectiveStub } from './router-link-directive.stub'; QueryParamsDirectiveStub, MySimpleItemActionComponent, RouterLinkDirectiveStub, - NgComponentOutletDirectiveStub + NgComponentOutletDirectiveStub, + BrowserOnlyMockPipe, ], exports: [ QueryParamsDirectiveStub diff --git a/src/app/shared/utils/browser-only.pipe.ts b/src/app/shared/utils/browser-only.pipe.ts new file mode 100644 index 0000000000..e3ee3df69d --- /dev/null +++ b/src/app/shared/utils/browser-only.pipe.ts @@ -0,0 +1,35 @@ +import { Inject, Pipe, PipeTransform, PLATFORM_ID } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; + +/** + * A pipe that only returns its input when run in the browser. + * Used to distinguish client-side rendered content from server-side rendered content. + * + * When used with attributes as in + * ``` + * [attr.data-test]="'something' | dsBrowserOnly" + * ``` + * the server-side rendered HTML will not contain the `data-test` attribute. + * When rendered client-side, the HTML will contain `data-test="something"` + * + * This can be useful for end-to-end testing elements that need JS (that isn't included in SSR HTML) to function: + * By depending on `dsBrowserOnly` attributes in tests we can make sure we wait + * until such components are fully interactive before trying to interact with them. + */ +@Pipe({ + name: 'dsBrowserOnly' +}) +export class BrowserOnlyPipe implements PipeTransform { + constructor( + @Inject(PLATFORM_ID) private platformID: any, + ) { + } + + transform(value: string): string | undefined { + if (isPlatformBrowser((this.platformID))) { + return value; + } else { + return undefined; + } + } +} diff --git a/src/app/shared/view-mode-switch/view-mode-switch.component.html b/src/app/shared/view-mode-switch/view-mode-switch.component.html index 2863a4832b..5f70bc699c 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.html +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.html @@ -7,7 +7,7 @@ routerLinkActive="active" [class.active]="currentMode === viewModeEnum.ListElement" class="btn btn-secondary" - data-test="list-view"> + [attr.data-test]="'list-view' | dsBrowserOnly"> + [attr.data-test]="'grid-view' | dsBrowserOnly"> + [attr.data-test]="'detail-view' | dsBrowserOnly">
diff --git a/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts b/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts index d361857413..5780ea5cf3 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts @@ -9,6 +9,7 @@ import { SearchService } from '../../core/shared/search/search.service'; import { ViewModeSwitchComponent } from './view-mode-switch.component'; import { SearchServiceStub } from '../testing/search-service.stub'; import { ViewMode } from '../../core/shared/view-mode.model'; +import { BrowserOnlyMockPipe } from '../testing/browser-only-mock.pipe'; @Component({ template: '' }) class DummyComponent { @@ -36,7 +37,8 @@ describe('ViewModeSwitchComponent', () => { ], declarations: [ ViewModeSwitchComponent, - DummyComponent + DummyComponent, + BrowserOnlyMockPipe, ], providers: [ { provide: SearchService, useValue: searchService }, diff --git a/src/app/shared/vocabulary-treeview/vocabulary-tree-flat-data-source.ts b/src/app/shared/vocabulary-treeview/vocabulary-tree-flat-data-source.ts index 9d093874b8..23ac6fcf8f 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-tree-flat-data-source.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-tree-flat-data-source.ts @@ -38,7 +38,7 @@ export class VocabularyTreeFlatDataSource extends DataSource { this._treeControl.expansionModel.changed, this._flattenedData ]; - return merge(...changes).pipe(map(() => { + return merge(...changes).pipe(map((): F[] => { this._expandedData.next( this._treeFlattener.expandFlattenedNodes(this._flattenedData.value, this._treeControl)); return this._expandedData.value; diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts index ef84a290dd..c1c64c80bd 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.service.spec.ts @@ -2,7 +2,7 @@ import { TestBed, waitForAsync } from '@angular/core/testing'; import { TestScheduler } from 'rxjs/testing'; import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; -import { getTestScheduler, hot } from 'jasmine-marbles'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { VocabularyTreeviewService } from './vocabulary-treeview.service'; import { VocabularyService } from '../../core/submission/vocabularies/vocabulary.service'; @@ -14,6 +14,8 @@ import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models import { buildPaginatedList } from '../../core/data/paginated-list.model'; import { createSuccessfulRemoteDataObject } from '../remote-data.utils'; import { VocabularyEntry } from '../../core/submission/vocabularies/models/vocabulary-entry.model'; +import { expand, map, switchMap } from 'rxjs/operators'; +import { from as observableFrom } from 'rxjs'; describe('VocabularyTreeviewService test suite', () => { @@ -320,10 +322,25 @@ describe('VocabularyTreeviewService test suite', () => { scheduler.schedule(() => service.searchByQuery(vocabularyOptions)); scheduler.flush(); - searchChildNode.childrenChange.next([searchChildNode3]); - searchItemNode.childrenChange.next([searchChildNode]); - expect(serviceAsAny.dataChange.value.length).toEqual(1); - expect(serviceAsAny.dataChange.value).toEqual([searchItemNode]); + // We can't check the tree by comparing root TreeviewNodes directly in this particular test; + // Since RxJs 7, BehaviorSubjects can no longer be reliably compared because of the new currentObservers property + // (see https://github.com/ReactiveX/rxjs/pull/6842) + const levels$ = serviceAsAny.dataChange.pipe( + expand((nodes: TreeviewNode[]) => { // recursively apply: + return observableFrom(nodes).pipe( // for each node in the array... + switchMap(node => node.childrenChange) // ...map it to the array its child nodes. + ); // because we only have one child per node in this case, + }), // this results in an array of nodes for each level of the tree. + map((nodes: TreeviewNode[]) => nodes.map(node => node.item)), // finally, replace nodes with their vocab entries + ); + + // Confirm that this corresponds to the hierarchy we set up above + expect(levels$).toBeObservable(cold('-(abcd)', { + a: [item], + b: [child], + c: [child3], + d: [] // ensure that grandchild has no children & the recursion stopped there + })); }); }); diff --git a/src/app/statistics/google-analytics.service.spec.ts b/src/app/statistics/google-analytics.service.spec.ts index c9a267a76f..0c6bc2bc51 100644 --- a/src/app/statistics/google-analytics.service.spec.ts +++ b/src/app/statistics/google-analytics.service.spec.ts @@ -1,5 +1,5 @@ import { GoogleAnalyticsService } from './google-analytics.service'; -import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; +import { Angulartics2GoogleAnalytics } from 'angulartics2'; import { ConfigurationDataService } from '../core/data/configuration-data.service'; import { createFailedRemoteDataObject$, diff --git a/src/app/statistics/google-analytics.service.ts b/src/app/statistics/google-analytics.service.ts index 94e5ad20af..0b52f54c4f 100644 --- a/src/app/statistics/google-analytics.service.ts +++ b/src/app/statistics/google-analytics.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@angular/core'; -import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; +import { Angulartics2GoogleAnalytics } from 'angulartics2'; import { ConfigurationDataService } from '../core/data/configuration-data.service'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; import { isEmpty } from '../shared/empty.util'; diff --git a/src/app/submission/form/footer/submission-form-footer.component.html b/src/app/submission/form/footer/submission-form-footer.component.html index 7013c55667..e898e1c220 100644 --- a/src/app/submission/form/footer/submission-form-footer.component.html +++ b/src/app/submission/form/footer/submission-form-footer.component.html @@ -3,6 +3,7 @@