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/config/config.example.yml b/config/config.example.yml index 77134d0075..898b47784f 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -164,10 +164,12 @@ browseBy: # The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items) defaultLowerLimit: 1900 -# Item Page Config +# Item Config item: edit: undoTimeout: 10000 # 10 seconds + # Show the item access status label in items lists + showAccessStatuses: false # Collection Page Config collection: 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 75e22b40f3..c0e8a994fa 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", @@ -154,14 +154,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", @@ -170,10 +170,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", @@ -181,7 +182,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", @@ -195,7 +196,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", @@ -209,4 +210,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-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts index dedada5f5f..334d69f19a 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts @@ -18,6 +18,8 @@ import { ItemAdminSearchResultGridElementComponent } from './item-admin-search-r import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; import { ThemeService } from '../../../../../shared/theme-support/theme.service'; +import { AccessStatusDataService } from 'src/app/core/data/access-status-data.service'; +import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model'; describe('ItemAdminSearchResultGridElementComponent', () => { let component: ItemAdminSearchResultGridElementComponent; @@ -31,6 +33,12 @@ describe('ItemAdminSearchResultGridElementComponent', () => { } }; + const mockAccessStatusDataService = { + findAccessStatusFor(item: Item): Observable> { + return createSuccessfulRemoteDataObject$(new AccessStatusObject()); + } + }; + const mockThemeService = getMockThemeService(); function init() { @@ -55,6 +63,7 @@ describe('ItemAdminSearchResultGridElementComponent', () => { { provide: TruncatableService, useValue: mockTruncatableService }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: ThemeService, useValue: mockThemeService }, + { provide: AccessStatusDataService, useValue: mockAccessStatusDataService }, ], schemas: [NO_ERRORS_SCHEMA] }) 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/bitstream-page/bitstream-page-routing.module.ts b/src/app/bitstream-page/bitstream-page-routing.module.ts index 27b9db9a05..0bdda29ddf 100644 --- a/src/app/bitstream-page/bitstream-page-routing.module.ts +++ b/src/app/bitstream-page/bitstream-page-routing.module.ts @@ -10,6 +10,9 @@ import { ResourcePolicyResolver } from '../shared/resource-policies/resolvers/re import { ResourcePolicyEditComponent } from '../shared/resource-policies/edit/resource-policy-edit.component'; import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component'; import { LegacyBitstreamUrlResolver } from './legacy-bitstream-url.resolver'; +import { BitstreamBreadcrumbResolver } from '../core/breadcrumbs/bitstream-breadcrumb.resolver'; +import { BitstreamBreadcrumbsService } from '../core/breadcrumbs/bitstream-breadcrumbs.service'; +import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; const EDIT_BITSTREAM_PATH = ':id/edit'; const EDIT_BITSTREAM_AUTHORIZATIONS_PATH = ':id/authorizations'; @@ -48,7 +51,8 @@ const EDIT_BITSTREAM_AUTHORIZATIONS_PATH = ':id/authorizations'; path: EDIT_BITSTREAM_PATH, component: EditBitstreamPageComponent, resolve: { - bitstream: BitstreamPageResolver + bitstream: BitstreamPageResolver, + breadcrumb: BitstreamBreadcrumbResolver, }, canActivate: [AuthenticatedGuard] }, @@ -67,15 +71,17 @@ const EDIT_BITSTREAM_AUTHORIZATIONS_PATH = ':id/authorizations'; { path: 'edit', resolve: { + breadcrumb: I18nBreadcrumbResolver, resourcePolicy: ResourcePolicyResolver }, component: ResourcePolicyEditComponent, - data: { title: 'resource-policies.edit.page.title', showBreadcrumbs: true } + data: { breadcrumbKey: 'item.edit', title: 'resource-policies.edit.page.title', showBreadcrumbs: true } }, { path: '', resolve: { - bitstream: BitstreamPageResolver + bitstream: BitstreamPageResolver, + breadcrumb: BitstreamBreadcrumbResolver, }, component: BitstreamAuthorizationsComponent, data: { title: 'bitstream.edit.authorizations.title', showBreadcrumbs: true } @@ -86,6 +92,8 @@ const EDIT_BITSTREAM_AUTHORIZATIONS_PATH = ':id/authorizations'; ], providers: [ BitstreamPageResolver, + BitstreamBreadcrumbResolver, + BitstreamBreadcrumbsService ] }) export class BitstreamPageRoutingModule { diff --git a/src/app/bitstream-page/bitstream-page.resolver.ts b/src/app/bitstream-page/bitstream-page.resolver.ts index fd9d5b351b..be92041dfc 100644 --- a/src/app/bitstream-page/bitstream-page.resolver.ts +++ b/src/app/bitstream-page/bitstream-page.resolver.ts @@ -7,6 +7,15 @@ import { BitstreamDataService } from '../core/data/bitstream-data.service'; import { followLink, FollowLinkConfig } from '../shared/utils/follow-link-config.model'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; +/** + * The self links defined in this list are expected to be requested somewhere in the near future + * Requesting them as embeds will limit the number of requests + */ + export const BITSTREAM_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ + followLink('bundle', {}, followLink('item')), + followLink('format') +]; + /** * This class represents a resolver that requests a specific bitstream before the route is activated */ @@ -34,9 +43,6 @@ export class BitstreamPageResolver implements Resolve> { * Requesting them as embeds will limit the number of requests */ get followLinks(): FollowLinkConfig[] { - return [ - followLink('bundle', {}, followLink('item')), - followLink('format') - ]; + return BITSTREAM_PAGE_LINKS_TO_FOLLOW; } } 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/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/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.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/breadcrumbs/bitstream-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts new file mode 100644 index 0000000000..b2ddade682 --- /dev/null +++ b/src/app/core/breadcrumbs/bitstream-breadcrumb.resolver.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; + +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { Bitstream } from '../shared/bitstream.model'; +import { BitstreamDataService } from '../data/bitstream-data.service'; +import { BITSTREAM_PAGE_LINKS_TO_FOLLOW } from '../../bitstream-page/bitstream-page.resolver'; +import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; +import { BitstreamBreadcrumbsService } from './bitstream-breadcrumbs.service'; + +/** + * The class that resolves the BreadcrumbConfig object for an Item + */ +@Injectable({ + providedIn: 'root' +}) +export class BitstreamBreadcrumbResolver extends DSOBreadcrumbResolver { + constructor( + protected breadcrumbService: BitstreamBreadcrumbsService, protected dataService: BitstreamDataService) { + super(breadcrumbService, dataService); + } + + /** + * Method that returns the follow links to already resolve + * The self links defined in this list are expected to be requested somewhere in the near future + * Requesting them as embeds will limit the number of requests + */ + get followLinks(): FollowLinkConfig[] { + return BITSTREAM_PAGE_LINKS_TO_FOLLOW; + } + +} diff --git a/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts b/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts new file mode 100644 index 0000000000..333886ed3d --- /dev/null +++ b/src/app/core/breadcrumbs/bitstream-breadcrumbs.service.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@angular/core'; + +import { Observable, of as observableOf } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; + +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { DSONameService } from './dso-name.service'; +import { ChildHALResource } from '../shared/child-hal-resource.model'; +import { LinkService } from '../cache/builders/link.service'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { RemoteData } from '../data/remote-data'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { getDSORoute } from '../../app-routing-paths'; +import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; +import { BitstreamDataService } from '../data/bitstream-data.service'; +import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../shared/operators'; +import { Bitstream } from '../shared/bitstream.model'; +import { Bundle } from '../shared/bundle.model'; +import { Item } from '../shared/item.model'; +import { BITSTREAM_PAGE_LINKS_TO_FOLLOW } from '../../bitstream-page/bitstream-page.resolver'; + +/** + * Service to calculate DSpaceObject breadcrumbs for a single part of the route + */ +@Injectable({ + providedIn: 'root' +}) +export class BitstreamBreadcrumbsService extends DSOBreadcrumbsService { + constructor( + protected bitstreamService: BitstreamDataService, + protected linkService: LinkService, + protected dsoNameService: DSONameService + ) { + super(linkService, dsoNameService); + } + + /** + * Method to recursively calculate the breadcrumbs + * This method returns the name and url of the key and all its parent DSOs recursively, top down + * @param key The key (a DSpaceObject) used to resolve the breadcrumb + * @param url The url to use as a link for this breadcrumb + */ + getBreadcrumbs(key: ChildHALResource & DSpaceObject, url: string): Observable { + const label = this.dsoNameService.getName(key); + const crumb = new Breadcrumb(label, url); + + return this.getOwningItem(key.uuid).pipe( + switchMap((parentRD: RemoteData) => { + if (isNotEmpty(parentRD) && hasValue(parentRD.payload)) { + const parent = parentRD.payload; + return super.getBreadcrumbs(parent, getDSORoute(parent)); + } + return observableOf([]); + + }), + map((breadcrumbs: Breadcrumb[]) => [...breadcrumbs, crumb]) + ); + } + + getOwningItem(uuid: string): Observable> { + return this.bitstreamService.findById(uuid, true, true, ...BITSTREAM_PAGE_LINKS_TO_FOLLOW).pipe( + getFirstCompletedRemoteData(), + getRemoteDataPayload(), + switchMap((bitstream: Bitstream) => { + if (hasValue(bitstream)) { + return bitstream.bundle.pipe( + getFirstCompletedRemoteData(), + getRemoteDataPayload(), + switchMap((bundle: Bundle) => { + if (hasValue(bundle)) { + return bundle.item.pipe( + getFirstCompletedRemoteData(), + ); + } else { + return observableOf(undefined); + } + }) + ); + } else { + return observableOf(undefined); + } + }) + ); + } +} diff --git a/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts index 23fff18537..a5884ca3c9 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts @@ -20,8 +20,8 @@ import { getDSORoute } from '../../app-routing-paths'; }) export class DSOBreadcrumbsService implements BreadcrumbsProviderService { constructor( - private linkService: LinkService, - private dsoNameService: DSONameService + protected linkService: LinkService, + protected dsoNameService: DSONameService ) { } diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index e9e242dbc0..31fb5a9233 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'; @@ -137,6 +136,7 @@ import { SiteAdministratorGuard } from './data/feature-authorization/feature-aut import { Registration } from './shared/registration.model'; import { MetadataSchemaDataService } from './data/metadata-schema-data.service'; import { MetadataFieldDataService } from './data/metadata-field-data.service'; +import { DsDynamicTypeBindRelationService } from '../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service'; import { TokenResponseParsingService } from './auth/token-response-parsing.service'; import { SubmissionCcLicenseDataService } from './submission/submission-cc-license-data.service'; import { SubmissionCcLicence } from './submission/models/submission-cc-license.model'; @@ -163,6 +163,9 @@ import { SequenceService } from './shared/sequence.service'; import { CoreState } from './core-state.model'; 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'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -187,7 +190,6 @@ const DECLARATIONS = []; const EXPORTS = []; const PROVIDERS = [ - ApiService, AuthenticatedGuard, CommunityDataService, CollectionDataService, @@ -202,6 +204,7 @@ const PROVIDERS = [ SectionFormOperationsService, FormService, EPersonDataService, + LinkHeadService, HALEndpointService, HostWindowService, ItemDataService, @@ -220,6 +223,7 @@ const PROVIDERS = [ MyDSpaceResponseParsingService, ServerResponseService, BrowseService, + AccessStatusDataService, SubmissionCcLicenseDataService, SubmissionCcLicenseUrlDataService, SubmissionFormsConfigService, @@ -250,6 +254,7 @@ const PROVIDERS = [ ClaimedTaskDataService, PoolTaskDataService, BitstreamDataService, + DsDynamicTypeBindRelationService, EntityTypeService, ContentSourceResponseParsingService, ItemTemplateDataService, @@ -346,7 +351,8 @@ export const models = UsageReport, Root, SearchConfig, - SubmissionAccessesModel + SubmissionAccessesModel, + AccessStatusObject ]; @NgModule({ diff --git a/src/app/core/data/access-status-data.service.spec.ts b/src/app/core/data/access-status-data.service.spec.ts new file mode 100644 index 0000000000..d81b9384f3 --- /dev/null +++ b/src/app/core/data/access-status-data.service.spec.ts @@ -0,0 +1,81 @@ +import { RequestService } from './request.service'; +import { getMockRequestService } from '../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { GetRequest } from './request.models'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { Observable } from 'rxjs'; +import { RemoteData } from './remote-data'; +import { hasNoValue } from '../../shared/empty.util'; +import { AccessStatusDataService } from './access-status-data.service'; +import { Item } from '../shared/item.model'; + +const url = 'fake-url'; + +describe('AccessStatusDataService', () => { + let service: AccessStatusDataService; + let requestService: RequestService; + let notificationsService: any; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: any; + + const itemId = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + const mockItem: Item = Object.assign(new Item(), { + id: itemId, + name: 'test-item', + _links: { + accessStatus: { + href: `https://rest.api/items/${itemId}/accessStatus` + }, + self: { + href: `https://rest.api/items/${itemId}` + } + } + }); + + describe('when the requests are successful', () => { + beforeEach(() => { + createService(); + }); + + describe('when calling findAccessStatusFor', () => { + let contentSource$; + + beforeEach(() => { + contentSource$ = service.findAccessStatusFor(mockItem); + }); + + it('should send a new GetRequest', fakeAsync(() => { + contentSource$.subscribe(); + tick(); + expect(requestService.send).toHaveBeenCalledWith(jasmine.any(GetRequest), true); + })); + }); + }); + + /** + * Create an AccessStatusDataService used for testing + * @param reponse$ Supply a RemoteData to be returned by the REST API (optional) + */ + function createService(reponse$?: Observable>) { + requestService = getMockRequestService(); + let buildResponse$ = reponse$; + if (hasNoValue(reponse$)) { + buildResponse$ = createSuccessfulRemoteDataObject$({}); + } + rdbService = jasmine.createSpyObj('rdbService', { + buildFromRequestUUID: buildResponse$, + buildSingle: buildResponse$ + }); + objectCache = jasmine.createSpyObj('objectCache', { + remove: jasmine.createSpy('remove') + }); + halService = new HALEndpointServiceStub(url); + notificationsService = new NotificationsServiceStub(); + service = new AccessStatusDataService(null, halService, null, notificationsService, objectCache, rdbService, requestService, null); + } +}); diff --git a/src/app/core/data/access-status-data.service.ts b/src/app/core/data/access-status-data.service.ts new file mode 100644 index 0000000000..09843fac9b --- /dev/null +++ b/src/app/core/data/access-status-data.service.ts @@ -0,0 +1,45 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { DataService } from './data.service'; +import { RequestService } from './request.service'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { CoreState } from '../core-state.model'; +import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model'; +import { ACCESS_STATUS } from 'src/app/shared/object-list/access-status-badge/access-status.resource-type'; +import { Observable } from 'rxjs'; +import { RemoteData } from './remote-data'; +import { Item } from '../shared/item.model'; + +@Injectable() +@dataService(ACCESS_STATUS) +export class AccessStatusDataService extends DataService { + + protected linkPath = 'accessStatus'; + + constructor( + protected comparator: DefaultChangeAnalyzer, + protected halService: HALEndpointService, + protected http: HttpClient, + protected notificationsService: NotificationsService, + protected objectCache: ObjectCacheService, + protected rdbService: RemoteDataBuildService, + protected requestService: RequestService, + protected store: Store, + ) { + super(); + } + + /** + * Returns {@link RemoteData} of {@link AccessStatusObject} that is the access status of the given item + * @param item Item we want the access status of + */ + findAccessStatusFor(item: Item): Observable> { + return this.findByHref(item._links.accessStatus.href); + } +} diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index cc1e3b6e20..a4ed9f882f 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -10,12 +10,13 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { RestResponse } from '../cache/response.models'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { ItemDataService } from './item-data.service'; -import { DeleteRequest, PostRequest } from './request.models'; +import { DeleteRequest, GetRequest, PostRequest } from './request.models'; import { RequestService } from './request.service'; import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { CoreState } from '../core-state.model'; import { RequestEntry } from './request-entry.model'; import { FindListOptions } from './find-list-options.model'; +import { HALEndpointServiceStub } from 'src/app/shared/testing/hal-endpoint-service.stub'; describe('ItemDataService', () => { let scheduler: TestScheduler; @@ -36,13 +37,11 @@ describe('ItemDataService', () => { }) as RequestService; const rdbService = getMockRemoteDataBuildService(); - const itemEndpoint = 'https://rest.api/core/items'; + const itemEndpoint = 'https://rest.api/core'; const store = {} as Store; const objectCache = {} as ObjectCacheService; - const halEndpointService = jasmine.createSpyObj('halService', { - getEndpoint: observableOf(itemEndpoint) - }); + const halEndpointService: any = new HALEndpointServiceStub(itemEndpoint); const bundleService = jasmine.createSpyObj('bundleService', { findByHref: {} }); diff --git a/src/app/core/data/version-history-data.service.spec.ts b/src/app/core/data/version-history-data.service.spec.ts index 207093b4d5..26ed370026 100644 --- a/src/app/core/data/version-history-data.service.spec.ts +++ b/src/app/core/data/version-history-data.service.spec.ts @@ -151,7 +151,7 @@ describe('VersionHistoryDataService', () => { describe('when getVersionsEndpoint is called', () => { it('should return the correct value', () => { service.getVersionsEndpoint(versionHistoryId).subscribe((res) => { - expect(res).toBe(url + '/versions'); + expect(res).toBe(url + '/versionhistories/version-history-id/versions'); }); }); }); diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index 6114f13511..632816d90b 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -163,6 +163,22 @@ describe('MetadataService', () => { }); })); + it('route titles should overwrite dso titles', fakeAsync(() => { + (translateService.get as jasmine.Spy).and.returnValues(of('DSpace :: '), of('Translated Route Title')); + (metadataService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(ItemMock), + title: 'route.title.key', + } + } + }); + tick(); + expect(title.setTitle).toHaveBeenCalledTimes(2); + expect((title.setTitle as jasmine.Spy).calls.argsFor(0)).toEqual(['Test PowerPoint Document']); + expect((title.setTitle as jasmine.Spy).calls.argsFor(1)).toEqual(['DSpace :: Translated Route Title']); + })); + it('other navigation should add title and description', fakeAsync(() => { (translateService.get as jasmine.Spy).and.returnValues(of('DSpace :: '), of('Dummy Title'), of('This is a dummy item component for testing!')); (metadataService as any).processRouteChange({ diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 570f0f77ca..3a438edab0 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -109,6 +109,11 @@ export class MetadataService { private processRouteChange(routeInfo: any): void { this.clearMetaTags(); + if (hasValue(routeInfo.data.value.dso) && hasValue(routeInfo.data.value.dso.payload)) { + this.currentObject.next(routeInfo.data.value.dso.payload); + this.setDSOMetaTags(); + } + if (routeInfo.data.value.title) { const titlePrefix = this.translate.get('repository.title.prefix'); const title = this.translate.get(routeInfo.data.value.title, routeInfo.data.value); @@ -122,11 +127,6 @@ export class MetadataService { this.addMetaTag('description', translatedDescription); }); } - - if (hasValue(routeInfo.data.value.dso) && hasValue(routeInfo.data.value.dso.payload)) { - this.currentObject.next(routeInfo.data.value.dso.payload); - this.setDSOMetaTags(); - } } private getCurrentRoute(route: ActivatedRoute): ActivatedRoute { 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/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/bitstream.model.ts b/src/app/core/shared/bitstream.model.ts index baf2f82635..c855325d2d 100644 --- a/src/app/core/shared/bitstream.model.ts +++ b/src/app/core/shared/bitstream.model.ts @@ -7,13 +7,13 @@ import { BITSTREAM_FORMAT } from './bitstream-format.resource-type'; import { BITSTREAM } from './bitstream.resource-type'; import { DSpaceObject } from './dspace-object.model'; import { HALLink } from './hal-link.model'; -import { HALResource } from './hal-resource.model'; import {BUNDLE} from './bundle.resource-type'; import {Bundle} from './bundle.model'; +import { ChildHALResource } from './child-hal-resource.model'; @typedObject @inheritSerialization(DSpaceObject) -export class Bitstream extends DSpaceObject implements HALResource { +export class Bitstream extends DSpaceObject implements ChildHALResource { static type = BITSTREAM; /** @@ -66,4 +66,8 @@ export class Bitstream extends DSpaceObject implements HALResource { */ @link(BUNDLE) bundle?: Observable>; + + getParentLinkKey(): keyof this['_links'] { + return 'format'; + } } 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/item.model.ts b/src/app/core/shared/item.model.ts index d98c22225e..49ca7750b4 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -21,6 +21,8 @@ import { Version } from './version.model'; import { VERSION } from './version.resource-type'; import { BITSTREAM } from './bitstream.resource-type'; import { Bitstream } from './bitstream.model'; +import { ACCESS_STATUS } from 'src/app/shared/object-list/access-status-badge/access-status.resource-type'; +import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model'; /** * Class representing a DSpace Item @@ -72,6 +74,7 @@ export class Item extends DSpaceObject implements ChildHALResource { templateItemOf: HALLink; version: HALLink; thumbnail: HALLink; + accessStatus: HALLink; self: HALLink; }; @@ -110,6 +113,13 @@ export class Item extends DSpaceObject implements ChildHALResource { @link(BITSTREAM, false, 'thumbnail') thumbnail?: Observable>; + /** + * The access status for this Item + * Will be undefined unless the access status {@link HALLink} has been resolved. + */ + @link(ACCESS_STATUS) + accessStatus?: Observable>; + /** * Method that returns as which type of object this object should be rendered */ 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">
-