diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0495f51fc2..fb17a43575 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: strategy: # Create a matrix of Node versions to test against (in parallel) matrix: - node-version: [10.x, 12.x] + node-version: [12.x, 14.x] # Do NOT exit immediately if one matrix job fails fail-fast: false # These are the actual CI steps to perform per job diff --git a/README.md b/README.md index 9a7bdbbbdb..3f5351ec82 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ You can find additional information on the DSpace 7 Angular UI on the [wiki](htt Quick start ----------- -**Ensure you're running [Node](https://nodejs.org) `v10.x` or `v12.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) >= `v1.x`** +**Ensure you're running [Node](https://nodejs.org) `v12.x` or `v14.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) >= `v1.x`** ```bash # clone the repo @@ -65,7 +65,7 @@ Requirements ------------ - [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com) -- Ensure you're running node `v10.x` or `v12.x` and yarn >= `v1.x` +- Ensure you're running node `v12.x` or `v14.x` and yarn >= `v1.x` If you have [`nvm`](https://github.com/creationix/nvm#install-script) or [`nvm-windows`](https://github.com/coreybutler/nvm-windows) installed, which is highly recommended, you can run `nvm install --lts && nvm use` to install and start using the latest Node LTS. @@ -339,7 +339,6 @@ dspace-angular ├── tslint.json * TSLint (https://palantir.github.io/tslint/) configuration ├── typedoc.json * TYPEDOC configuration ├── webpack * Webpack (https://webpack.github.io/) config directory -│   ├── helpers.js * │   ├── webpack.aot.js * Webpack (https://webpack.github.io/) config for AoT build │   ├── webpack.client.js * Webpack (https://webpack.github.io/) config for client build │   ├── webpack.common.js * diff --git a/angular.json b/angular.json index 8555fe9c2d..19cbe94be6 100644 --- a/angular.json +++ b/angular.json @@ -17,6 +17,7 @@ "build": { "builder": "@angular-builders/custom-webpack:browser", "options": { + "extractCss": true, "preserveSymlinks": true, "customWebpackConfig": { "path": "./webpack/webpack.browser.ts", @@ -46,7 +47,16 @@ "src/robots.txt" ], "styles": [ - "src/styles.scss" + { + "input": "src/styles/base-theme.scss", + "inject": false, + "bundleName": "base-theme" + }, + { + "input": "src/themes/custom/styles/theme.scss", + "inject": false, + "bundleName": "custom-theme" + } ], "scripts": [] }, @@ -116,7 +126,11 @@ "src/assets" ], "styles": [ - "src/styles.scss" + { + "input": "src/styles/base-theme.scss", + "inject": false, + "bundleName": "base-theme" + } ], "scripts": [] } diff --git a/package.json b/package.json index d92afb4a40..3aa7c04ad3 100644 --- a/package.json +++ b/package.json @@ -20,16 +20,17 @@ "serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts", "start:dev": "npm-run-all --parallel config:dev:watch serve", "start:prod": "yarn run build:prod && yarn run serve:ssr", + "analyze": "webpack-bundle-analyzer dist/browser/stats.json", "build": "ng build", + "build:stats": "ng build --stats-json", "build:prod": "yarn run build:ssr", "build:ssr": "yarn run build:client-and-server-bundles && yarn run compile:server", - "ng-high-memory": "node --max_old_space_size=8192 node_modules/@angular/cli/bin/ng", - "build:client-and-server-bundles": "npm run ng-high-memory -- build --prod && npm run ng-high-memory -- run dspace-angular:server:production --bundleDependencies true", + "build:client-and-server-bundles": "ng build --prod && ng run dspace-angular:server:production --bundleDependencies true", "test:watch": "npm-run-all --parallel config:test:watch test", - "test": "npm run ng-high-memory -- test --sourceMap=true --watch=true", - "test:headless": "npm run ng-high-memory -- test --watch=false --sourceMap=true --browsers=ChromeHeadless --code-coverage", + "test": "ng test --sourceMap=true --watch=true", + "test:headless": "ng test --watch=false --sourceMap=true --browsers=ChromeHeadless --code-coverage", "lint": "ng lint", - "lint-fix": "npm run ng-high-memory -- lint --fix=true", + "lint-fix": "ng lint --fix=true", "e2e": "ng e2e", "e2e:ci": "ng e2e --protractor-config=./e2e/protractor-ci.conf.js", "compile:server": "webpack --config webpack.server.config.js --progress --color", @@ -143,7 +144,7 @@ "@types/node": "^14.14.9", "codelyzer": "^6.0.1", "compression-webpack-plugin": "^3.0.1", - "copy-webpack-plugin": "^5.1.1", + "copy-webpack-plugin": "^6.4.1", "css-loader": "3.4.0", "cssnano": "^4.1.10", "deep-freeze": "0.0.1", @@ -180,7 +181,7 @@ "tslint": "^6.1.3", "typescript": "~4.0.5", "webpack": "^4.44.2", - "webpack-bundle-analyzer": "^3.3.2", + "webpack-bundle-analyzer": "^4.4.0", "webpack-cli": "^4.2.0", "webpack-node-externals": "1.7.2" } diff --git a/postcss.config.js b/postcss.config.js index 1c46e245ea..df092d1d39 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,7 +1,7 @@ module.exports = { plugins: [ require('postcss-import')(), - require('postcss-cssnext')(), + require('postcss-preset-env')(), require('postcss-apply')(), require('postcss-responsive-type')() ] diff --git a/scripts/sync-build-dir.js b/scripts/sync-build-dir.js deleted file mode 100644 index c147f139a5..0000000000 --- a/scripts/sync-build-dir.js +++ /dev/null @@ -1,22 +0,0 @@ -const syncBuildDir = require('copyfiles'); -const path = require('path'); -const { - projectRoot, - theme, - themePath, -} = require('../webpack/helpers'); - -const projectDepth = projectRoot('./').split(path.sep).length; - -let callback; - -if (theme !== null && theme !== undefined) { - callback = () => { - syncBuildDir([path.join(themePath, '**/*'), 'build'], { up: projectDepth + 2 }, () => {}) - } -} -else { - callback = () => {}; -} - -syncBuildDir([projectRoot('src/**/*'), 'build'], { up: projectDepth + 1 }, callback); 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 ccd893e6f0..dedada5f5f 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 @@ -16,6 +16,8 @@ import { RouterTestingModule } from '@angular/router/testing'; import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; import { ItemAdminSearchResultGridElementComponent } from './item-admin-search-result-grid-element.component'; import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; +import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; +import { ThemeService } from '../../../../../shared/theme-support/theme.service'; describe('ItemAdminSearchResultGridElementComponent', () => { let component: ItemAdminSearchResultGridElementComponent; @@ -29,6 +31,8 @@ describe('ItemAdminSearchResultGridElementComponent', () => { } }; + const mockThemeService = getMockThemeService(); + function init() { id = '780b2588-bda5-4112-a1cd-0b15000a5339'; searchResult = new ItemSearchResult(); @@ -50,6 +54,7 @@ describe('ItemAdminSearchResultGridElementComponent', () => { providers: [ { provide: TruncatableService, useValue: mockTruncatableService }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, + { provide: ThemeService, useValue: mockThemeService }, ], schemas: [NO_ERRORS_SCHEMA] }) 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.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.ts index 68ef5ffc5e..13158204c5 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.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.ts @@ -12,6 +12,7 @@ import { TruncatableService } from '../../../../../shared/truncatable/truncatabl import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; import { GenericConstructor } from '../../../../../core/shared/generic-constructor'; import { ListableObjectDirective } from '../../../../../shared/object-collection/shared/listable-object/listable-object.directive'; +import { ThemeService } from '../../../../../shared/theme-support/theme.service'; @listableObjectComponent(ItemSearchResult, ViewMode.GridElement, Context.AdminSearch) @Component({ @@ -29,6 +30,7 @@ export class ItemAdminSearchResultGridElementComponent extends SearchResultGridE constructor(protected truncatableService: TruncatableService, protected bitstreamDataService: BitstreamDataService, + private themeService: ThemeService, private componentFactoryResolver: ComponentFactoryResolver ) { super(truncatableService, bitstreamDataService); @@ -63,6 +65,6 @@ export class ItemAdminSearchResultGridElementComponent extends SearchResultGridE * @returns {GenericConstructor} */ private getComponent(): GenericConstructor { - return getListableObjectComponent(this.object.getRenderTypes(), ViewMode.GridElement, undefined); + return getListableObjectComponent(this.object.getRenderTypes(), ViewMode.GridElement, undefined, this.themeService.getThemeName()); } } diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.scss b/src/app/+admin/admin-sidebar/admin-sidebar.component.scss index 36355fcab9..e6eb4a7037 100644 --- a/src/app/+admin/admin-sidebar/admin-sidebar.component.scss +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.scss @@ -1,12 +1,12 @@ -$icon-z-index: 10; - :host { + --ds-icon-z-index: 10; + left: 0; top: 0; height: 100vh; flex: 1 1 auto; nav { - background-color: $admin-sidebar-bg; + background-color: var(--ds-admin-sidebar-bg); height: 100%; flex-direction: column; > div { @@ -19,12 +19,12 @@ $icon-z-index: 10; } &.inactive ::ng-deep .sidebar-collapsible { - margin-left: -#{$sidebar-items-width}; + margin-left: calc(-1 * var(--ds-sidebar-items-width)); } .navbar-nav { .admin-menu-header { - background-color: $admin-sidebar-header-bg; + background-color: var(--ds-admin-sidebar-header-bg); .logo-wrapper { img { height: 20px; @@ -43,29 +43,29 @@ $icon-z-index: 10; .sidebar-section { display: flex; align-content: stretch; - background-color: $admin-sidebar-bg; + background-color: var(--ds-admin-sidebar-bg); .nav-item { - padding-top: $spacer; - padding-bottom: $spacer; + padding-top: var(--bs-spacer); + padding-bottom: var(--bs-spacer); } .shortcut-icon { - padding-left: $icon-padding; - padding-right: $icon-padding; + padding-left: var(--ds-icon-padding); + padding-right: var(--ds-icon-padding); } .shortcut-icon, .icon-wrapper { background-color: inherit; - z-index: $icon-z-index; + z-index: var(--ds-icon-z-index); } .sidebar-collapsible { - width: $sidebar-items-width; + width: var(--ds-sidebar-items-width); position: relative; a { - padding-right: $spacer; + padding-right: var(--bs-spacer); width: 100%; } } &.active > .sidebar-collapsible > .nav-link { - color: $navbar-dark-active-color; + color: var(--bs-navbar-dark-active-color); } } } @@ -73,4 +73,4 @@ $icon-z-index: 10; } -} \ No newline at end of file +} diff --git a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss index 1f6e288608..e3a807a14f 100644 --- a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss +++ b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss @@ -1,13 +1,13 @@ :host ::ng-deep { .fa-chevron-right { - padding-left: $spacer/2; + padding-left: calc(var(--bs-spacer) / 2); font-size: 0.5rem; line-height: 3; } .sidebar-sub-level-items { list-style: disc; - color: $navbar-dark-color; + color: var(--bs-navbar-dark-color); overflow: hidden; } diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.spec.ts b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.spec.ts index fe3e0ff3de..0b933d0859 100644 --- a/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.spec.ts +++ b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.spec.ts @@ -19,6 +19,8 @@ import { BitstreamDataService } from '../../../../../core/data/bitstream-data.se import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; import { getMockLinkService } from '../../../../../shared/mocks/link-service.mock'; import { of as observableOf } from 'rxjs'; +import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; +import { ThemeService } from '../../../../../shared/theme-support/theme.service'; describe('WorkflowItemAdminWorkflowGridElementComponent', () => { let component: WorkflowItemSearchResultAdminWorkflowGridElementComponent; @@ -28,6 +30,7 @@ describe('WorkflowItemAdminWorkflowGridElementComponent', () => { let itemRD$; let linkService; let object; + let themeService; function init() { itemRD$ = createSuccessfulRemoteDataObject$(new Item()); @@ -37,6 +40,7 @@ describe('WorkflowItemAdminWorkflowGridElementComponent', () => { wfi.item = itemRD$; object.indexableObject = wfi; linkService = getMockLinkService(); + themeService = getMockThemeService(); } beforeEach(waitForAsync(() => { @@ -51,6 +55,7 @@ describe('WorkflowItemAdminWorkflowGridElementComponent', () => { ], providers: [ { provide: LinkService, useValue: linkService }, + { provide: ThemeService, useValue: themeService }, { provide: TruncatableService, useValue: { isCollapsed: () => observableOf(true), diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.ts b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.ts index 67a961b330..cf5746391c 100644 --- a/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.ts +++ b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.ts @@ -1,7 +1,10 @@ import { Component, ComponentFactoryResolver, ElementRef, ViewChild } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; import { ViewMode } from '../../../../../core/shared/view-mode.model'; -import { getListableObjectComponent, listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { + getListableObjectComponent, + listableObjectComponent +} from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { Context } from '../../../../../core/shared/context.model'; import { SearchResultGridElementComponent } from '../../../../../shared/object-grid/search-result-grid-element/search-result-grid-element.component'; import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; @@ -13,9 +16,13 @@ import { Observable } from 'rxjs'; import { LinkService } from '../../../../../core/cache/builders/link.service'; import { followLink } from '../../../../../shared/utils/follow-link-config.model'; import { RemoteData } from '../../../../../core/data/remote-data'; -import { getAllSucceededRemoteData, getRemoteDataPayload } from '../../../../../core/shared/operators'; +import { + getAllSucceededRemoteData, + getRemoteDataPayload +} from '../../../../../core/shared/operators'; import { take } from 'rxjs/operators'; import { WorkflowItemSearchResult } from '../../../../../shared/object-collection/shared/workflow-item-search-result.model'; +import { ThemeService } from '../../../../../shared/theme-support/theme.service'; @listableObjectComponent(WorkflowItemSearchResult, ViewMode.GridElement, Context.AdminWorkflowSearch) @Component({ @@ -51,6 +58,7 @@ export class WorkflowItemSearchResultAdminWorkflowGridElementComponent extends S private componentFactoryResolver: ComponentFactoryResolver, private linkService: LinkService, protected truncatableService: TruncatableService, + private themeService: ThemeService, protected bitstreamDataService: BitstreamDataService ) { super(truncatableService, bitstreamDataService); @@ -92,7 +100,7 @@ export class WorkflowItemSearchResultAdminWorkflowGridElementComponent extends S * @returns {GenericConstructor} */ private getComponent(item: Item): GenericConstructor { - return getListableObjectComponent(item.getRenderTypes(), ViewMode.GridElement, undefined); + return getListableObjectComponent(item.getRenderTypes(), ViewMode.GridElement, undefined, this.themeService.getThemeName()); } } diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.scss b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.scss index d212b5347c..13de59700c 100644 --- a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.scss +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.scss @@ -2,7 +2,7 @@ ::ng-deep { .switch { position: absolute; - top: $spacer*2.5; + top: calc(var(--bs-spacer) * 2.5); } } } diff --git a/src/app/+collection-page/collection-page.resolver.spec.ts b/src/app/+collection-page/collection-page.resolver.spec.ts index 5ded339fb8..4b1ea9834c 100644 --- a/src/app/+collection-page/collection-page.resolver.spec.ts +++ b/src/app/+collection-page/collection-page.resolver.spec.ts @@ -6,17 +6,21 @@ describe('CollectionPageResolver', () => { describe('resolve', () => { let resolver: CollectionPageResolver; let collectionService: any; + let store: any; const uuid = '1234-65487-12354-1235'; beforeEach(() => { collectionService = { findById: (id: string) => createSuccessfulRemoteDataObject$({ id }) }; - resolver = new CollectionPageResolver(collectionService); + store = jasmine.createSpyObj('store', { + dispatch: {}, + }); + resolver = new CollectionPageResolver(collectionService, store); }); it('should resolve a collection with the correct id', (done) => { - resolver.resolve({ params: { id: uuid } } as any, undefined) + resolver.resolve({ params: { id: uuid } } as any, { url: 'current-url' } as any) .pipe(first()) .subscribe( (resolved) => { diff --git a/src/app/+collection-page/collection-page.resolver.ts b/src/app/+collection-page/collection-page.resolver.ts index 44d238fc97..f6f87f117c 100644 --- a/src/app/+collection-page/collection-page.resolver.ts +++ b/src/app/+collection-page/collection-page.resolver.ts @@ -4,15 +4,31 @@ import { Collection } from '../core/shared/collection.model'; import { Observable } from 'rxjs'; import { CollectionDataService } from '../core/data/collection-data.service'; import { RemoteData } from '../core/data/remote-data'; -import { followLink } from '../shared/utils/follow-link-config.model'; +import { followLink, FollowLinkConfig } from '../shared/utils/follow-link-config.model'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; +import { Store } from '@ngrx/store'; +import { ResolvedAction } from '../core/resolving/resolver.actions'; + +/** + * 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 COLLECTION_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ + followLink('parentCommunity', undefined, true, true, true, + followLink('parentCommunity') + ), + followLink('logo') +]; /** * This class represents a resolver that requests a specific collection before the route is activated */ @Injectable() export class CollectionPageResolver implements Resolve> { - constructor(private collectionService: CollectionDataService) { + constructor( + private collectionService: CollectionDataService, + private store: Store + ) { } /** @@ -23,8 +39,19 @@ export class CollectionPageResolver implements Resolve> { * or an error if something went wrong */ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { - return this.collectionService.findById(route.params.id, true, false, followLink('logo')).pipe( + const collectionRD$ = this.collectionService.findById( + route.params.id, + true, + false, + ...COLLECTION_PAGE_LINKS_TO_FOLLOW + ).pipe( getFirstCompletedRemoteData() ); + + collectionRD$.subscribe((collectionRD: RemoteData) => { + this.store.dispatch(new ResolvedAction(state.url, collectionRD.payload)); + }); + + return collectionRD$; } } diff --git a/src/app/+community-page/community-page.resolver.spec.ts b/src/app/+community-page/community-page.resolver.spec.ts index e75f5ad57e..f181dbfff6 100644 --- a/src/app/+community-page/community-page.resolver.spec.ts +++ b/src/app/+community-page/community-page.resolver.spec.ts @@ -6,17 +6,21 @@ describe('CommunityPageResolver', () => { describe('resolve', () => { let resolver: CommunityPageResolver; let communityService: any; + let store: any; const uuid = '1234-65487-12354-1235'; beforeEach(() => { communityService = { findById: (id: string) => createSuccessfulRemoteDataObject$({ id }) }; - resolver = new CommunityPageResolver(communityService); + store = jasmine.createSpyObj('store', { + dispatch: {}, + }); + resolver = new CommunityPageResolver(communityService, store); }); it('should resolve a community with the correct id', (done) => { - resolver.resolve({ params: { id: uuid } } as any, undefined) + resolver.resolve({ params: { id: uuid } } as any, { url: 'current-url' } as any) .pipe(first()) .subscribe( (resolved) => { diff --git a/src/app/+community-page/community-page.resolver.ts b/src/app/+community-page/community-page.resolver.ts index c5780f0a0f..01de9294f3 100644 --- a/src/app/+community-page/community-page.resolver.ts +++ b/src/app/+community-page/community-page.resolver.ts @@ -4,15 +4,31 @@ import { Observable } from 'rxjs'; import { RemoteData } from '../core/data/remote-data'; import { Community } from '../core/shared/community.model'; import { CommunityDataService } from '../core/data/community-data.service'; -import { followLink } from '../shared/utils/follow-link-config.model'; +import { followLink, FollowLinkConfig } from '../shared/utils/follow-link-config.model'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; +import { ResolvedAction } from '../core/resolving/resolver.actions'; +import { Store } from '@ngrx/store'; + +/** + * 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 COMMUNITY_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ + followLink('logo'), + followLink('subcommunities'), + followLink('collections'), + followLink('parentCommunity') +]; /** * This class represents a resolver that requests a specific community before the route is activated */ @Injectable() export class CommunityPageResolver implements Resolve> { - constructor(private communityService: CommunityDataService) { + constructor( + private communityService: CommunityDataService, + private store: Store + ) { } /** @@ -23,15 +39,19 @@ export class CommunityPageResolver implements Resolve> { * or an error if something went wrong */ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { - return this.communityService.findById( + const communityRD$ = this.communityService.findById( route.params.id, true, false, - followLink('logo'), - followLink('subcommunities'), - followLink('collections') + ...COMMUNITY_PAGE_LINKS_TO_FOLLOW ).pipe( getFirstCompletedRemoteData(), ); + + communityRD$.subscribe((communityRD: RemoteData) => { + this.store.dispatch(new ResolvedAction(state.url, communityRD.payload)); + }); + + return communityRD$; } } 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 73e96fcc9c..13a91563c8 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 @@ -18,11 +18,14 @@ import { PageInfo } from '../../core/shared/page-info.model'; import { HostWindowService } from '../../shared/host-window.service'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; +import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; +import { ThemeService } from '../../shared/theme-support/theme.service'; describe('CommunityPageSubCollectionList Component', () => { let comp: CommunityPageSubCollectionListComponent; let fixture: ComponentFixture; let collectionDataServiceStub: any; + let themeService; let subCollList = []; const collections = [Object.assign(new Community(), { @@ -110,6 +113,8 @@ describe('CommunityPageSubCollectionList Component', () => { } }; + themeService = getMockThemeService(); + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ @@ -124,6 +129,7 @@ describe('CommunityPageSubCollectionList Component', () => { { provide: CollectionDataService, useValue: collectionDataServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: SelectableListService, useValue: {} }, + { provide: ThemeService, useValue: themeService }, ], 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 b52df64db7..21ba1b28b0 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 @@ -18,11 +18,14 @@ import { HostWindowService } from '../../shared/host-window.service'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; import { CommunityDataService } from '../../core/data/community-data.service'; import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; +import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; +import { ThemeService } from '../../shared/theme-support/theme.service'; describe('CommunityPageSubCommunityListComponent Component', () => { let comp: CommunityPageSubCommunityListComponent; let fixture: ComponentFixture; let communityDataServiceStub: any; + let themeService; let subCommList = []; const subcommunities = [Object.assign(new Community(), { @@ -111,6 +114,8 @@ describe('CommunityPageSubCommunityListComponent Component', () => { } }; + themeService = getMockThemeService(); + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ @@ -125,6 +130,7 @@ describe('CommunityPageSubCommunityListComponent Component', () => { { provide: CommunityDataService, useValue: communityDataServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: SelectableListService, useValue: {} }, + { provide: ThemeService, useValue: themeService }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/+home-page/home-news/home-news.component.scss b/src/app/+home-page/home-news/home-news.component.scss index 50d3619ad1..e58dd08ccf 100644 --- a/src/app/+home-page/home-news/home-news.component.scss +++ b/src/app/+home-page/home-news/home-news.component.scss @@ -1,7 +1,7 @@ :host { display: block; - margin-top: -$content-spacing; - margin-bottom: -$content-spacing; + margin-top: calc(-1 * var(--ds-content-spacing)); + margin-bottom: calc(-1 * var(--ds-content-spacing)); } .display-3 { @@ -11,4 +11,4 @@ .dspace-logo { height: 110px; width: 110px; -} \ No newline at end of file +} diff --git a/src/app/+home-page/home-news/home-news.component.ts b/src/app/+home-page/home-news/home-news.component.ts index cebe217623..62c8e94671 100644 --- a/src/app/+home-page/home-news/home-news.component.ts +++ b/src/app/+home-page/home-news/home-news.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, } from '@angular/core'; @Component({ selector: 'ds-home-news', @@ -10,5 +10,4 @@ import { Component } from '@angular/core'; * Component to render the news section on the home page */ export class HomeNewsComponent { - } diff --git a/themes/default/styles/_themed_bootstrap_variables.scss b/src/app/+home-page/home-news/themed-home-news.component.scss similarity index 100% rename from themes/default/styles/_themed_bootstrap_variables.scss rename to src/app/+home-page/home-news/themed-home-news.component.scss diff --git a/src/app/+home-page/home-news/themed-home-news.component.ts b/src/app/+home-page/home-news/themed-home-news.component.ts new file mode 100644 index 0000000000..09abbf8935 --- /dev/null +++ b/src/app/+home-page/home-news/themed-home-news.component.ts @@ -0,0 +1,27 @@ +import { Component } from '@angular/core'; +import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { HomeNewsComponent } from './home-news.component'; + +@Component({ + selector: 'ds-themed-home-news', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) + +/** + * Component to render the news section on the home page + */ +export class ThemedHomeNewsComponent extends ThemedComponent { + protected getComponentName(): string { + return 'HomeNewsComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/+home-page/home-news/home-news.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./home-news.component`); + } + +} diff --git a/src/app/+home-page/home-page-routing.module.ts b/src/app/+home-page/home-page-routing.module.ts index c94df56643..ec6a547359 100644 --- a/src/app/+home-page/home-page-routing.module.ts +++ b/src/app/+home-page/home-page-routing.module.ts @@ -1,17 +1,17 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; -import { HomePageComponent } from './home-page.component'; import { HomePageResolver } from './home-page.resolver'; import { MenuItemType } from '../shared/menu/initial-menus-state'; import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; +import { ThemedHomePageComponent } from './themed-home-page.component'; @NgModule({ imports: [ RouterModule.forChild([ { path: '', - component: HomePageComponent, + component: ThemedHomePageComponent, pathMatch: 'full', data: { title: 'home.title', diff --git a/src/app/+home-page/home-page.component.html b/src/app/+home-page/home-page.component.html index 5515df595b..5902fa30af 100644 --- a/src/app/+home-page/home-page.component.html +++ b/src/app/+home-page/home-page.component.html @@ -1,4 +1,4 @@ - +
diff --git a/src/app/+home-page/home-page.module.ts b/src/app/+home-page/home-page.module.ts index 51e978bbfe..d304c78696 100644 --- a/src/app/+home-page/home-page.module.ts +++ b/src/app/+home-page/home-page.module.ts @@ -7,6 +7,16 @@ import { HomePageRoutingModule } from './home-page-routing.module'; import { HomePageComponent } from './home-page.component'; import { TopLevelCommunityListComponent } from './top-level-community-list/top-level-community-list.component'; import { StatisticsModule } from '../statistics/statistics.module'; +import { ThemedHomeNewsComponent } from './home-news/themed-home-news.component'; +import { ThemedHomePageComponent } from './themed-home-page.component'; + +const DECLARATIONS = [ + HomePageComponent, + ThemedHomePageComponent, + TopLevelCommunityListComponent, + ThemedHomeNewsComponent, + HomeNewsComponent, +]; @NgModule({ imports: [ @@ -16,10 +26,11 @@ import { StatisticsModule } from '../statistics/statistics.module'; StatisticsModule.forRoot() ], declarations: [ - HomePageComponent, - TopLevelCommunityListComponent, - HomeNewsComponent, - ] + ...DECLARATIONS, + ], + exports: [ + ...DECLARATIONS, + ], }) export class HomePageModule { diff --git a/src/app/+home-page/themed-home-page.component.ts b/src/app/+home-page/themed-home-page.component.ts new file mode 100644 index 0000000000..69d6a60908 --- /dev/null +++ b/src/app/+home-page/themed-home-page.component.ts @@ -0,0 +1,26 @@ +import { ThemedComponent } from '../shared/theme-support/themed.component'; +import { HomePageComponent } from './home-page.component'; +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-themed-home-page', + styleUrls: [], + templateUrl: '../shared/theme-support/themed.component.html', +}) +export class ThemedHomePageComponent extends ThemedComponent { + protected inAndOutputNames: (keyof HomePageComponent & keyof this)[]; + + + protected getComponentName(): string { + return 'HomePageComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../themes/${themeName}/app/+home-page/home-page.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./home-page.component`); + } + +} 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 4e0b8d4d22..0daa0a0ae0 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 @@ -18,11 +18,14 @@ import { HostWindowService } from '../../shared/host-window.service'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; import { CommunityDataService } from '../../core/data/community-data.service'; import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; +import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; +import { ThemeService } from '../../shared/theme-support/theme.service'; describe('TopLevelCommunityList Component', () => { let comp: TopLevelCommunityListComponent; let fixture: ComponentFixture; let communityDataServiceStub: any; + let themeService; const topCommList = [Object.assign(new Community(), { id: '123456789-1', @@ -101,6 +104,8 @@ describe('TopLevelCommunityList Component', () => { } }; + themeService = getMockThemeService(); + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ @@ -115,6 +120,7 @@ describe('TopLevelCommunityList Component', () => { { provide: CommunityDataService, useValue: communityDataServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: SelectableListService, useValue: {} }, + { provide: ThemeService, useValue: themeService }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.scss b/src/app/+item-page/edit-item-page/edit-item-page.component.scss index bb3bdaaeb0..7fd08c83af 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.component.scss +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.scss @@ -1,3 +1,3 @@ .btn { - min-width: $edit-item-button-min-width; + min-width: var(--ds-edit-item-button-min-width); } diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts index 20056a9ea4..da10d33add 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts @@ -74,18 +74,18 @@ import { ItemPageWithdrawGuard } from './item-page-withdraw.guard'; component: ItemRelationshipsComponent, data: { title: 'item.edit.tabs.relationships.title', showBreadcrumbs: true } }, + /* TODO - uncomment & fix when view page exists { path: 'view', - /* TODO - change when view page exists */ component: ItemBitstreamsComponent, data: { title: 'item.edit.tabs.view.title', showBreadcrumbs: true } - }, + }, */ + /* TODO - uncomment & fix when curate page exists { path: 'curate', - /* TODO - change when curate page exists */ component: ItemBitstreamsComponent, data: { title: 'item.edit.tabs.curate.title', showBreadcrumbs: true } - }, + }, */ { path: 'versionhistory', component: ItemVersionHistoryComponent, diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss index 0400e765de..1fcbe99702 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss @@ -1,19 +1,19 @@ .header-row { - color: $table-dark-color; - background-color: $table-dark-bg; - border-color: $table-dark-border-color; + color: var(--bs-table-dark-color); + background-color: var(--bs-table-dark-bg); + border-color: var(--bs-table-dark-border-color); } .bundle-row { - color: $table-head-color; - background-color: $table-head-bg; - border-color: $table-border-color; + color: var(--bs-table-head-color); + background-color: var(--bs-table-head-bg); + border-color: var(--bs-table-border-color); } .row-element { padding: 12px; padding: 0.75em; - border-bottom: $table-border-width solid $table-border-color; + border-bottom: var(--bs-table-border-width) solid var(--bs-table-border-color); } .drag-handle { diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss index 1790157fa5..a2a6786b36 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.scss @@ -1,13 +1,13 @@ .btn[disabled] { - color: $gray-600; - border-color: $gray-600; + color: var(--bs-gray-600); + border-color: var(--bs-gray-600); z-index: 0; // prevent border colors jumping on hover } .metadata-field { - width: $edit-item-metadata-field-width; + width: var(--ds-edit-item-metadata-field-width); } .language-field { - width: $edit-item-language-field-width; -} \ No newline at end of file + width: var(--ds-edit-item-language-field-width); +} diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss index a74ecb8f47..46ce1a6f4b 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.scss @@ -1,20 +1,20 @@ .button-row { .btn { - margin-right: 0.5 * $spacer; + margin-right: calc(0.5 * var(--bs-spacer)); &:last-child { margin-right: 0; } @media screen and (min-width: map-get($grid-breakpoints, sm)) { - min-width: $edit-item-button-min-width; + min-width: var(--ds-edit-item-button-min-width); } } &.top .btn { - margin-top: $spacer/2; - margin-bottom: $spacer/2; + margin-top: calc(var(--bs-spacer) / 2); + margin-bottom: calc(var(--bs-spacer) / 2); } -} \ No newline at end of file +} diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.scss b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.scss index 54498499d7..1466447116 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.scss +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.scss @@ -1,10 +1,10 @@ .relationship-row:not(.alert) { - padding: $alert-padding-y 0; + padding: var(--bs-alert-padding-y) 0; } .relationship-row.alert { - margin-left: -$alert-padding-x; - margin-right: -$alert-padding-x; + margin-left: calc(-1 * var(--bs-alert-padding-x)); + margin-right: calc(-1 * var(--bs-alert-padding-x)); margin-top: -1px; margin-bottom: -1px; } diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.scss b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.scss index a974ec9a17..b1b8ca22a3 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.scss +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.scss @@ -1,6 +1,6 @@ .btn[disabled] { - color: $gray-600; - border-color: $gray-600; + color: var(--bs-gray-600); + border-color: var(--bs-gray-600); z-index: 0; // prevent border colors jumping on hover } diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.scss b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.scss index 39ad631342..91c1423ba3 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.scss +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.scss @@ -1,19 +1,19 @@ .button-row { .btn { - margin-right: 0.5 * $spacer; + margin-right: calc(0.5 * var(--bs-spacer)); &:last-child { margin-right: 0; } @media screen and (min-width: map-get($grid-breakpoints, sm)) { - min-width: $edit-item-button-min-width; + min-width: var(--ds-edit-item-button-min-width); } } &.top .btn { - margin-top: $spacer/2; - margin-bottom: $spacer/2; + margin-top: calc(var(--bs-spacer) / 2); + margin-bottom: calc(var(--bs-spacer) / 2); } diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.scss b/src/app/+item-page/full/field-components/file-section/full-file-section.component.scss index 5bb04cac2f..5384f90cec 100644 --- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.scss +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.scss @@ -2,4 +2,4 @@ dt { text-align: right; } -} \ No newline at end of file +} diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts index 299048e094..ed8d872b36 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -32,6 +32,26 @@ const ENTRY_COMPONENTS = [ UntypedItemComponent ]; +const DECLARATIONS = [ + ItemPageComponent, + FullItemPageComponent, + MetadataUriValuesComponent, + ItemPageAuthorFieldComponent, + ItemPageDateFieldComponent, + ItemPageAbstractFieldComponent, + ItemPageUriFieldComponent, + ItemPageTitleFieldComponent, + ItemPageFieldComponent, + FileSectionComponent, + CollectionsComponent, + FullFileSectionComponent, + PublicationComponent, + UntypedItemComponent, + ItemComponent, + UploadBitstreamComponent, + AbstractIncrementalListComponent, +]; + @NgModule({ imports: [ CommonModule, @@ -43,23 +63,10 @@ const ENTRY_COMPONENTS = [ ResearchEntitiesModule.withEntryComponents() ], declarations: [ - ItemPageComponent, - FullItemPageComponent, - MetadataUriValuesComponent, - ItemPageAuthorFieldComponent, - ItemPageDateFieldComponent, - ItemPageAbstractFieldComponent, - ItemPageUriFieldComponent, - ItemPageTitleFieldComponent, - ItemPageFieldComponent, - FileSectionComponent, - CollectionsComponent, - FullFileSectionComponent, - PublicationComponent, - UntypedItemComponent, - ItemComponent, - UploadBitstreamComponent, - AbstractIncrementalListComponent, + ...DECLARATIONS + ], + exports: [ + ...DECLARATIONS ] }) export class ItemPageModule { diff --git a/src/app/+item-page/item-page.resolver.ts b/src/app/+item-page/item-page.resolver.ts index d90806bfc3..4bf353fffd 100644 --- a/src/app/+item-page/item-page.resolver.ts +++ b/src/app/+item-page/item-page.resolver.ts @@ -7,9 +7,18 @@ import { Item } from '../core/shared/item.model'; import { followLink, FollowLinkConfig } from '../shared/utils/follow-link-config.model'; import { FindListOptions } from '../core/data/request.models'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; +import { Store } from '@ngrx/store'; +import { ResolvedAction } from '../core/resolving/resolver.actions'; +/** + * 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 ITEM_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ - followLink('owningCollection'), + followLink('owningCollection', undefined, true, true, true, + followLink('parentCommunity', undefined, true, true, true, + followLink('parentCommunity')) + ), followLink('bundles', new FindListOptions(), true, true, true, followLink('bitstreams')), followLink('relationships'), followLink('version', undefined, true, true, true, followLink('versionhistory')), @@ -20,7 +29,10 @@ export const ITEM_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ */ @Injectable() export class ItemPageResolver implements Resolve> { - constructor(private itemService: ItemDataService) { + constructor( + private itemService: ItemDataService, + private store: Store + ) { } /** @@ -31,12 +43,18 @@ export class ItemPageResolver implements Resolve> { * or an error if something went wrong */ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { - return this.itemService.findById(route.params.id, + const itemRD$ = this.itemService.findById(route.params.id, true, false, ...ITEM_PAGE_LINKS_TO_FOLLOW ).pipe( getFirstCompletedRemoteData(), ); + + itemRD$.subscribe((itemRD: RemoteData) => { + this.store.dispatch(new ResolvedAction(state.url, itemRD.payload)); + }); + + return itemRD$; } } diff --git a/src/app/+login-page/login-page.component.scss b/src/app/+login-page/login-page.component.scss index 58e7272e5f..d628a5089f 100644 --- a/src/app/+login-page/login-page.component.scss +++ b/src/app/+login-page/login-page.component.scss @@ -1,4 +1,4 @@ .login-logo { - height: $login-logo-height; - width: $login-logo-width; + height: var(--ds-login-logo-height); + width: var(--ds-login-logo-width); } diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts index 4ff31d67cf..7c6d8918cb 100644 --- a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts @@ -21,6 +21,10 @@ import { UploaderService } from '../../shared/uploader/uploader.service'; import { HostWindowService } from '../../shared/host-window.service'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; import { UploaderComponent } from '../../shared/uploader/uploader.component'; +import { HttpXsrfTokenExtractor } from '@angular/common/http'; +import { CookieService } from '../../core/services/cookie.service'; +import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; +import { HttpXsrfTokenExtractorMock } from '../../shared/mocks/http-xsrf-token-extractor.mock'; describe('MyDSpaceNewSubmissionComponent test', () => { @@ -55,6 +59,8 @@ describe('MyDSpaceNewSubmissionComponent test', () => { ChangeDetectorRef, MyDSpaceNewSubmissionComponent, UploaderService, + { provide: HttpXsrfTokenExtractor, useValue: new HttpXsrfTokenExtractorMock('mock-token') }, + { provide: CookieService, useValue: new CookieServiceMock() }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/+my-dspace-page/my-dspace-page.component.html b/src/app/+my-dspace-page/my-dspace-page.component.html index 6f1cc41a1e..55d1e304d0 100644 --- a/src/app/+my-dspace-page/my-dspace-page.component.html +++ b/src/app/+my-dspace-page/my-dspace-page.component.html @@ -6,6 +6,7 @@ [configurationList]="(configurationList$ | async)" [resultCount]="(resultsRD$ | async)?.payload.totalElements" [viewModeList]="viewModeList" + [refreshFilters]="refreshFilters.asObservable()" [inPlaceSearch]="inPlaceSearch">
@@ -39,7 +41,8 @@
+ [context]="context$ | async" + (contentChange)="onResultsContentChange()">
diff --git a/src/app/+my-dspace-page/my-dspace-page.component.ts b/src/app/+my-dspace-page/my-dspace-page.component.ts index 0f08795cdc..5ee2a47d9f 100644 --- a/src/app/+my-dspace-page/my-dspace-page.component.ts +++ b/src/app/+my-dspace-page/my-dspace-page.component.ts @@ -7,7 +7,7 @@ import { OnInit } from '@angular/core'; -import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; import { map, switchMap, tap, } from 'rxjs/operators'; import { PaginatedList } from '../core/data/paginated-list.model'; @@ -101,6 +101,11 @@ export class MyDSpacePageComponent implements OnInit { */ context$: Observable; + /** + * Emit an event every time search sidebars must refresh their contents. + */ + refreshFilters: Subject = new Subject(); + constructor(private service: SearchService, private sidebarService: SidebarService, private windowService: HostWindowService, @@ -148,6 +153,14 @@ export class MyDSpacePageComponent implements OnInit { } + /** + * Handle the contentChange event from within the my dspace content. + * Notify search sidebars to refresh their content. + */ + onResultsContentChange() { + this.refreshFilters.next(); + } + /** * Set the sidebar to a collapsed state */ @@ -184,5 +197,6 @@ export class MyDSpacePageComponent implements OnInit { if (hasValue(this.sub)) { this.sub.unsubscribe(); } + this.refreshFilters.complete(); } } diff --git a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html index 3a829e6ece..2710285f0d 100644 --- a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html +++ b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html @@ -5,7 +5,8 @@ [sortConfig]="searchConfig.sort" [objects]="searchResults" [hideGear]="true" - [context]="context"> + [context]="context" + (contentChange)="contentChange.emit()"> diff --git a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts index 35b13c8bae..32b6d9c9f7 100644 --- a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts +++ b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from '@angular/core'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { fadeIn, fadeInOut } from '../../shared/animations/fade'; @@ -41,6 +41,12 @@ export class MyDSpaceResultsComponent { * The current context for the search results */ @Input() context: Context; + + /** + * Emit when one of the results has changed. + */ + @Output() contentChange = new EventEmitter(); + /** * A boolean representing if search results entry are separated by a line */ diff --git a/src/app/+my-dspace-page/my-dspace-search.module.ts b/src/app/+my-dspace-page/my-dspace-search.module.ts index 2fe1cd2a55..a97f2207e7 100644 --- a/src/app/+my-dspace-page/my-dspace-search.module.ts +++ b/src/app/+my-dspace-page/my-dspace-search.module.ts @@ -14,12 +14,16 @@ import { ClaimedTaskSearchResultDetailElementComponent } from '../shared/object- import { ItemSearchResultListElementSubmissionComponent } from '../shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component'; import { WorkflowItemSearchResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component'; import { PoolSearchResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component'; +import { ClaimedApprovedSearchResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component'; +import { ClaimedDeclinedSearchResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator WorkspaceItemSearchResultListElementComponent, WorkflowItemSearchResultListElementComponent, ClaimedSearchResultListElementComponent, + ClaimedApprovedSearchResultListElementComponent, + ClaimedDeclinedSearchResultListElementComponent, PoolSearchResultListElementComponent, ItemSearchResultDetailElementComponent, WorkspaceItemSearchResultDetailElementComponent, diff --git a/src/app/+search-page/configuration-search-page.component.spec.ts b/src/app/+search-page/configuration-search-page.component.spec.ts index f49d329edd..8ce4154c66 100644 --- a/src/app/+search-page/configuration-search-page.component.spec.ts +++ b/src/app/+search-page/configuration-search-page.component.spec.ts @@ -2,20 +2,63 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { configureSearchComponentTestingModule } from './search.component.spec'; import { ConfigurationSearchPageComponent } from './configuration-search-page.component'; import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; +import { Component, ViewChild } from '@angular/core'; +import { Router } from '@angular/router'; +import { RouteService } from '../core/services/route.service'; +import createSpy = jasmine.createSpy; + +const CONFIGURATION = 'test-configuration'; +const QUERY = 'test query'; + +@Component({ + template: ` + + + `, +}) +class HostComponent { + @ViewChild('configurationSearchPage') configurationSearchPage: ConfigurationSearchPageComponent; +} describe('ConfigurationSearchPageComponent', () => { let comp: ConfigurationSearchPageComponent; - let fixture: ComponentFixture; + let fixture: ComponentFixture; let searchConfigService: SearchConfigurationService; + let routeService: RouteService; beforeEach(waitForAsync(() => { - configureSearchComponentTestingModule(ConfigurationSearchPageComponent); + configureSearchComponentTestingModule(ConfigurationSearchPageComponent, [HostComponent]); })); beforeEach(() => { - fixture = TestBed.createComponent(ConfigurationSearchPageComponent); - comp = fixture.componentInstance; - searchConfigService = (comp as any).searchConfigService; + fixture = TestBed.createComponent(HostComponent); + + // Set router url to a dummy value for SearchComponent#ngOnInit + spyOnProperty(TestBed.inject(Router), 'url', 'get').and.returnValue('some/url/here'); + + routeService = TestBed.inject(RouteService); + routeService.setParameter = createSpy('setParameter'); + fixture.detectChanges(); + + comp = fixture.componentInstance.configurationSearchPage; + searchConfigService = (comp as any).searchConfigService; + }); + + it('should set route parameters on init', () => { + expect(comp.configuration).toBe(CONFIGURATION); + expect(comp.fixedFilterQuery).toBe(QUERY); + + expect(routeService.setParameter).toHaveBeenCalledWith('configuration', CONFIGURATION); + expect(routeService.setParameter).toHaveBeenCalledWith('fixedFilterQuery', QUERY); + }); + + it('should reset route parameters on destroy', () => { + fixture.destroy(); + + expect(routeService.setParameter).toHaveBeenCalledWith('configuration', undefined); + expect(routeService.setParameter).toHaveBeenCalledWith('fixedFilterQuery', undefined); }); }); diff --git a/src/app/+search-page/configuration-search-page.component.ts b/src/app/+search-page/configuration-search-page.component.ts index befac7f331..a1286ec3cf 100644 --- a/src/app/+search-page/configuration-search-page.component.ts +++ b/src/app/+search-page/configuration-search-page.component.ts @@ -1,7 +1,7 @@ import { HostWindowService } from '../shared/host-window.service'; import { SidebarService } from '../shared/sidebar/sidebar.service'; import { SearchComponent } from './search.component'; -import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Inject, Input, OnDestroy, OnInit } from '@angular/core'; import { pushInOut } from '../shared/animations/push'; import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; @@ -27,7 +27,7 @@ import { Router } from '@angular/router'; ] }) -export class ConfigurationSearchPageComponent extends SearchComponent implements OnInit { +export class ConfigurationSearchPageComponent extends SearchComponent implements OnInit, OnDestroy { /** * The configuration to use for the search options * If empty, the configuration will be determined by the route parameter called 'configuration' @@ -65,4 +65,17 @@ export class ConfigurationSearchPageComponent extends SearchComponent implements this.routeService.setParameter('fixedFilterQuery', this.fixedFilterQuery); } } + + /** + * Reset the updated query/configuration set in ngOnInit() + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + if (hasValue(this.configuration)) { + this.routeService.setParameter('configuration', undefined); + } + if (hasValue(this.fixedFilterQuery)) { + this.routeService.setParameter('fixedFilterQuery', undefined); + } + } } diff --git a/src/app/+search-page/search.component.scss b/src/app/+search-page/search.component.scss index 44d4aabd13..82de1f4772 100644 --- a/src/app/+search-page/search.component.scss +++ b/src/app/+search-page/search.component.scss @@ -6,5 +6,5 @@ } ::ng-deep .search-controls { - margin-bottom: $spacer; + margin-bottom: var(--bs-spacer); } diff --git a/src/app/+search-page/search.component.spec.ts b/src/app/+search-page/search.component.spec.ts index 2ad497aa49..989aed403d 100644 --- a/src/app/+search-page/search.component.spec.ts +++ b/src/app/+search-page/search.component.spec.ts @@ -84,10 +84,10 @@ const routeServiceStub = { } }; -export function configureSearchComponentTestingModule(compType) { +export function configureSearchComponentTestingModule(compType, additionalDeclarations: any[] = []) { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, NgbCollapseModule], - declarations: [compType], + declarations: [compType, ...additionalDeclarations], providers: [ { provide: SearchService, useValue: searchServiceStub }, { diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index 8db4ba5aa7..64c7e8e0a6 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -5,6 +5,7 @@ import { Item } from './core/shared/item.model'; import { getCommunityPageRoute } from './+community-page/community-page-routing-paths'; import { getCollectionPageRoute } from './+collection-page/collection-page-routing-paths'; import { getItemPageRoute } from './+item-page/item-page-routing-paths'; +import { hasValue } from './shared/empty.util'; export const BITSTREAM_MODULE_PATH = 'bitstreams'; @@ -45,13 +46,15 @@ export function getWorkflowItemModuleRoute() { } export function getDSORoute(dso: DSpaceObject): string { - switch ((dso as any).type) { - case Community.type.value: - return getCommunityPageRoute(dso.uuid); - case Collection.type.value: - return getCollectionPageRoute(dso.uuid); - case Item.type.value: - return getItemPageRoute(dso.uuid); + if (hasValue(dso)) { + switch ((dso as any).type) { + case Community.type.value: + return getCommunityPageRoute(dso.uuid); + case Collection.type.value: + return getCollectionPageRoute(dso.uuid); + case Item.type.value: + return getItemPageRoute(dso.uuid); + } } } diff --git a/src/app/app.component.html b/src/app/app.component.html index fa534855e7..a40e5ea163 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,30 +1 @@ -
- -
- - - - -
-
- -
- -
- -
- -
- - -
-
- -
- -
-
+ diff --git a/src/app/app.component.scss b/src/app/app.component.scss index b18e7e1402..e69de29bb2 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -1,53 +0,0 @@ -@import '../styles/helpers/font_awesome_imports.scss'; -@import '../../node_modules/bootstrap/scss/bootstrap.scss'; -@import '../../node_modules/nouislider/distribute/nouislider.min'; - -html { - position: relative; - min-height: 100%; -} - -body { - overflow-x: hidden; -} - -// Sticky Footer -.outer-wrapper { - display: flex; - margin: 0; -} - -.inner-wrapper { - flex: 1 1 auto; - flex-flow: column nowrap; - display: flex; - min-height: 100vh; - flex-direction: column; - width: 100%; - position: relative; -} - -.main-content { - z-index: $main-z-index; - flex: 1 1 100%; - margin-top: $content-spacing; - margin-bottom: $content-spacing; -} - -.alert.hide { - padding: 0; - margin: 0; -} - -ds-header-navbar-wrapper { - z-index: $nav-z-index; -} - -ds-admin-sidebar { - position: fixed; - z-index: $sidebar-z-index; -} - -.ds-full-screen-loader { - height: 100vh; -} diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 233f15ccea..c092362c7e 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,7 +1,7 @@ import { Store, StoreModule } from '@ngrx/store'; import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { CommonModule, DOCUMENT } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; @@ -32,7 +32,9 @@ import { storeModuleConfig } from './app.reducer'; import { LocaleService } from './core/locale/locale.service'; import { authReducer } from './core/auth/auth.reducer'; import { provideMockStore } from '@ngrx/store/testing'; -import {GoogleAnalyticsService} from './statistics/google-analytics.service'; +import { GoogleAnalyticsService } from './statistics/google-analytics.service'; +import { ThemeService } from './shared/theme-support/theme.service'; +import { getMockThemeService } from './shared/mocks/theme-service.mock'; let comp: AppComponent; let fixture: ComponentFixture; @@ -73,6 +75,7 @@ describe('App component', () => { { provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, { provide: LocaleService, useValue: getMockLocaleService() }, + { provide: ThemeService, useValue: getMockThemeService() }, provideMockStore({ initialState }), AppComponent, RouteService @@ -143,4 +146,32 @@ describe('App component', () => { }); }); }); + + describe('when ThemeService returns a custom theme', () => { + let document; + let headSpy; + + beforeEach(() => { + // NOTE: Cannot override providers once components have been compiled, so TestBed needs to be reset + TestBed.resetTestingModule(); + TestBed.configureTestingModule(defaultTestBedConf); + TestBed.overrideProvider(ThemeService, {useValue: getMockThemeService('custom')}); + document = TestBed.inject(DOCUMENT); + headSpy = jasmine.createSpyObj('head', ['appendChild']); + spyOn(document, 'getElementsByTagName').and.returnValue([headSpy]); + fixture = TestBed.createComponent(AppComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should append a link element with the correct attributes to the head element', () => { + const link = document.createElement('link'); + link.setAttribute('rel', 'stylesheet'); + link.setAttribute('type', 'text/css'); + link.setAttribute('class', 'theme-css'); + link.setAttribute('href', '/custom-theme.css'); + + expect(headSpy.appendChild).toHaveBeenCalledWith(link); + }); + }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 1ef5c868a6..8a38d14fe3 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -5,12 +5,12 @@ import { Component, HostListener, Inject, - OnInit, Optional, - ViewEncapsulation + OnInit, + Optional, } from '@angular/core'; import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; -import { BehaviorSubject, combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; +import { BehaviorSubject, Observable, of } from 'rxjs'; import { select, Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; @@ -23,25 +23,25 @@ import { isAuthenticationBlocking } from './core/auth/selectors'; import { AuthService } from './core/auth/auth.service'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { MenuService } from './shared/menu/menu.service'; -import { MenuID } from './shared/menu/initial-menus-state'; -import { slideSidebarPadding } from './shared/animations/slide'; import { HostWindowService } from './shared/host-window.service'; -import { Theme } from '../config/theme.inferface'; +import { ThemeConfig } from '../config/theme.model'; import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; import { environment } from '../environments/environment'; import { models } from './core/core.module'; import { LocaleService } from './core/locale/locale.service'; -import { hasValue } from './shared/empty.util'; +import { hasValue, isNotEmpty } from './shared/empty.util'; import { KlaroService } from './shared/cookies/klaro.service'; -import {GoogleAnalyticsService} from './statistics/google-analytics.service'; +import { GoogleAnalyticsService } from './statistics/google-analytics.service'; +import { DOCUMENT } from '@angular/common'; +import { ThemeService } from './shared/theme-support/theme.service'; +import { BASE_THEME_NAME } from './shared/theme-support/theme.constants'; +import { DEFAULT_THEME_CONFIG } from './shared/theme-support/theme.effects'; @Component({ selector: 'ds-app', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, - animations: [slideSidebarPadding] }) export class AppComponent implements OnInit, AfterViewInit { isLoading$: BehaviorSubject = new BehaviorSubject(true); @@ -49,7 +49,7 @@ export class AppComponent implements OnInit, AfterViewInit { slideSidebarOver: Observable; collapsedSidebarWidth: Observable; totalSidebarWidth: Observable; - theme: Observable = of({} as any); + theme: Observable = of({} as any); notificationOptions = environment.notifications; models; @@ -60,6 +60,8 @@ export class AppComponent implements OnInit, AfterViewInit { constructor( @Inject(NativeWindowService) private _window: NativeWindowRef, + @Inject(DOCUMENT) private document: any, + private themeService: ThemeService, private translate: TranslateService, private store: Store, private metadata: MetadataService, @@ -77,6 +79,17 @@ export class AppComponent implements OnInit, AfterViewInit { /* Use models object so all decorators are actually called */ this.models = models; + + this.themeService.getThemeName$().subscribe((themeName: string) => { + if (hasValue(themeName)) { + this.setThemeCss(themeName); + } else if (hasValue(DEFAULT_THEME_CONFIG)) { + this.setThemeCss(DEFAULT_THEME_CONFIG.name); + } else { + this.setThemeCss(BASE_THEME_NAME); + } + }); + // Load all the languages that are defined as active from the config file translate.addLangs(environment.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code)); @@ -116,17 +129,6 @@ export class AppComponent implements OnInit, AfterViewInit { const color: string = environment.production ? 'red' : 'green'; console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`); this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight); - - this.sidebarVisible = this.menuService.isMenuVisible(MenuID.ADMIN); - - this.collapsedSidebarWidth = this.cssService.getVariable('collapsedSidebarWidth'); - this.totalSidebarWidth = this.cssService.getVariable('totalSidebarWidth'); - - const sidebarCollapsed = this.menuService.isMenuCollapsed(MenuID.ADMIN); - this.slideSidebarOver = combineLatestObservable(sidebarCollapsed, this.windowService.isXsOrSm()) - .pipe( - map(([collapsed, mobile]) => collapsed || mobile) - ); } private storeCSSVariables() { @@ -177,4 +179,34 @@ export class AppComponent implements OnInit, AfterViewInit { this.cookiesService.initialize(); } } + + /** + * Update the theme css file in + * + * @param themeName The name of the new theme + * @private + */ + private setThemeCss(themeName: string): void { + const head = this.document.getElementsByTagName('head')[0]; + // Array.from to ensure we end up with an array, not an HTMLCollection, which would be + // automatically updated if we add nodes later + const currentThemeLinks = Array.from(this.document.getElementsByClassName('theme-css')); + const link = this.document.createElement('link'); + link.setAttribute('rel', 'stylesheet'); + link.setAttribute('type', 'text/css'); + link.setAttribute('class', 'theme-css'); + link.setAttribute('href', `/${encodeURIComponent(themeName)}-theme.css`); + // wait for the new css to download before removing the old one to prevent a + // flash of unstyled content + link.onload = () => { + if (isNotEmpty(currentThemeLinks)) { + currentThemeLinks.forEach((currentThemeLink: any) => { + if (hasValue(currentThemeLink)) { + currentThemeLink.remove(); + } + }); + } + }; + head.appendChild(link); + } } diff --git a/src/app/app.effects.ts b/src/app/app.effects.ts index 64573609c7..871fae8d6b 100644 --- a/src/app/app.effects.ts +++ b/src/app/app.effects.ts @@ -3,11 +3,13 @@ import { NotificationsEffects } from './shared/notifications/notifications.effec import { NavbarEffects } from './navbar/navbar.effects'; import { SidebarEffects } from './shared/sidebar/sidebar-effects.service'; import { RelationshipEffects } from './shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects'; +import { ThemeEffects } from './shared/theme-support/theme.effects'; export const appEffects = [ StoreEffects, NavbarEffects, NotificationsEffects, SidebarEffects, + ThemeEffects, RelationshipEffects ]; diff --git a/src/app/app.module.ts b/src/app/app.module.ts index be1233fd98..cc428483c9 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -43,6 +43,9 @@ 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 { RootComponent } from './root/root.component'; +import { ThemedRootComponent } from './root/themed-root.component'; +import { ThemedEntryComponentModule } from '../themes/themed-entry-component.module'; export function getBase() { return environment.ui.nameSpace; @@ -65,6 +68,7 @@ const IMPORTS = [ EffectsModule.forRoot(appEffects), StoreModule.forRoot(appReducers, storeModuleConfig), StoreRouterConnectingModule.forRoot(), + ThemedEntryComponentModule.withEntryComponents(), ]; IMPORTS.push( @@ -120,6 +124,8 @@ const PROVIDERS = [ const DECLARATIONS = [ AppComponent, + RootComponent, + ThemedRootComponent, HeaderComponent, HeaderNavbarWrapperComponent, AdminSidebarComponent, @@ -135,7 +141,6 @@ const DECLARATIONS = [ ]; const EXPORTS = [ - AppComponent ]; @NgModule({ @@ -150,7 +155,8 @@ const EXPORTS = [ ...DECLARATIONS, ], exports: [ - ...EXPORTS + ...EXPORTS, + ...DECLARATIONS, ] }) export class AppModule { diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index 813b8d0f4f..5dffda5e94 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -12,7 +12,10 @@ import { metadataRegistryReducer, MetadataRegistryState } from './+admin/admin-registries/metadata-registry/metadata-registry.reducers'; -import { CommunityListReducer, CommunityListState } from './community-list-page/community-list.reducer'; +import { + CommunityListReducer, + CommunityListState +} from './community-list-page/community-list.reducer'; import { hasValue } from './shared/empty.util'; import { NameVariantListsState, @@ -20,19 +23,32 @@ import { } from './shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; import { formReducer, FormState } from './shared/form/form.reducer'; import { menusReducer, MenusState } from './shared/menu/menu.reducer'; -import { notificationsReducer, NotificationsState } from './shared/notifications/notifications.reducers'; +import { + notificationsReducer, + NotificationsState +} from './shared/notifications/notifications.reducers'; import { selectableListReducer, SelectableListsState } from './shared/object-list/selectable-list/selectable-list.reducer'; -import { ObjectSelectionListState, objectSelectionReducer } from './shared/object-select/object-select.reducer'; +import { + ObjectSelectionListState, + objectSelectionReducer +} from './shared/object-select/object-select.reducer'; import { cssVariablesReducer, CSSVariablesState } from './shared/sass-helper/sass-helper.reducer'; import { hostWindowReducer, HostWindowState } from './shared/search/host-window.reducer'; -import { filterReducer, SearchFiltersState } from './shared/search/search-filters/search-filter/search-filter.reducer'; -import { sidebarFilterReducer, SidebarFiltersState } from './shared/sidebar/filter/sidebar-filter.reducer'; +import { + filterReducer, + SearchFiltersState +} from './shared/search/search-filters/search-filter/search-filter.reducer'; +import { + sidebarFilterReducer, + SidebarFiltersState +} from './shared/sidebar/filter/sidebar-filter.reducer'; import { sidebarReducer, SidebarState } from './shared/sidebar/sidebar.reducer'; import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer'; +import { ThemeState, themeReducer } from './shared/theme-support/theme.reducer'; export interface AppState { router: fromRouter.RouterReducerState; @@ -45,6 +61,7 @@ export interface AppState { searchFilter: SearchFiltersState; truncatable: TruncatablesState; cssVariables: CSSVariablesState; + theme: ThemeState; menus: MenusState; objectSelection: ObjectSelectionListState; selectableLists: SelectableListsState; @@ -65,6 +82,7 @@ export const appReducers: ActionReducerMap = { searchFilter: filterReducer, truncatable: truncatableReducer, cssVariables: cssVariablesReducer, + theme: themeReducer, menus: menusReducer, objectSelection: objectSelectionReducer, selectableLists: selectableListReducer, diff --git a/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts index 8e0638cddb..d41446c185 100644 --- a/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts @@ -3,7 +3,8 @@ import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; import { Collection } from '../shared/collection.model'; import { CollectionDataService } from '../data/collection-data.service'; -import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { COLLECTION_PAGE_LINKS_TO_FOLLOW } from '../../+collection-page/collection-page.resolver'; /** * The class that resolves the BreadcrumbConfig object for a Collection @@ -22,10 +23,6 @@ export class CollectionBreadcrumbResolver extends DSOBreadcrumbResolver[] { - return [ - followLink('parentCommunity', undefined, true, true, true, - followLink('parentCommunity') - ) - ]; + return COLLECTION_PAGE_LINKS_TO_FOLLOW; } } diff --git a/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts index e9d8b9879c..27cc207c58 100644 --- a/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts @@ -3,7 +3,8 @@ import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; import { CommunityDataService } from '../data/community-data.service'; import { Community } from '../shared/community.model'; -import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { COMMUNITY_PAGE_LINKS_TO_FOLLOW } from '../../+community-page/community-page.resolver'; /** * The class that resolves the BreadcrumbConfig object for a Community @@ -22,8 +23,6 @@ export class CommunityBreadcrumbResolver extends DSOBreadcrumbResolver[] { - return [ - followLink('parentCommunity') - ]; + return COMMUNITY_PAGE_LINKS_TO_FOLLOW; } } diff --git a/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts index cfc04ff513..f822a953a8 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts @@ -28,7 +28,7 @@ export class DSOBreadcrumbsService implements BreadcrumbsService { return `${dso.firstMetadataValue('person.familyName')}, ${dso.firstMetadataValue('person.givenName')}`; }, diff --git a/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts index e4d7e81e98..2b9bbd6b3d 100644 --- a/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts @@ -3,7 +3,8 @@ import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; import { ItemDataService } from '../data/item-data.service'; import { Item } from '../shared/item.model'; import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver'; -import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { ITEM_PAGE_LINKS_TO_FOLLOW } from '../../+item-page/item-page.resolver'; /** * The class that resolves the BreadcrumbConfig object for an Item @@ -22,13 +23,6 @@ export class ItemBreadcrumbResolver extends DSOBreadcrumbResolver { * Requesting them as embeds will limit the number of requests */ get followLinks(): FollowLinkConfig[] { - return [ - followLink('owningCollection', undefined, true, true, true, - followLink('parentCommunity', undefined, true, true, true, - followLink('parentCommunity')) - ), - followLink('bundles'), - followLink('relationships') - ]; + return ITEM_PAGE_LINKS_TO_FOLLOW; } } diff --git a/src/app/core/cache/builders/link.service.ts b/src/app/core/cache/builders/link.service.ts index d340eec9d5..56a1154b77 100644 --- a/src/app/core/cache/builders/link.service.ts +++ b/src/app/core/cache/builders/link.service.ts @@ -3,7 +3,15 @@ import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { GenericConstructor } from '../../shared/generic-constructor'; import { HALResource } from '../../shared/hal-resource.model'; -import { getDataServiceFor, getLinkDefinition, getLinkDefinitions, LinkDefinition } from './build-decorators'; +import { + getDataServiceFor, + getLinkDefinition, + getLinkDefinitions, + LinkDefinition +} from './build-decorators'; +import { RemoteData } from '../../data/remote-data'; +import { Observable } from 'rxjs/internal/Observable'; +import { EMPTY } from 'rxjs'; /** * A Service to handle the resolving and removing @@ -33,12 +41,14 @@ export class LinkService { } /** - * Resolve the given {@link FollowLinkConfig} for the given model + * Resolve the given {@link FollowLinkConfig} for the given model and return the result. This does + * not attach the link result to the property on the model. Useful when you're working with a + * readonly object * * @param model the {@link HALResource} to resolve the link for * @param linkToFollow the {@link FollowLinkConfig} to resolve */ - public resolveLink(model, linkToFollow: FollowLinkConfig): T { + public resolveLinkWithoutAttaching(model, linkToFollow: FollowLinkConfig): Observable> { const matchingLinkDef = getLinkDefinition(model.constructor, linkToFollow.name); if (hasNoValue(matchingLinkDef)) { @@ -61,9 +71,9 @@ export class LinkService { try { if (matchingLinkDef.isList) { - model[linkToFollow.name] = service.findAllByHref(href, linkToFollow.findListOptions, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); + return service.findAllByHref(href, linkToFollow.findListOptions, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); } else { - model[linkToFollow.name] = service.findByHref(href, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); + return service.findByHref(href, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); } } catch (e) { console.error(`Something went wrong when using @dataService(${matchingLinkDef.resourceType.value}) ${hasValue(service) ? '' : '(undefined) '}to resolve link ${linkToFollow.name} at ${href}`); @@ -71,6 +81,18 @@ export class LinkService { } } } + return EMPTY; + } + + /** + * Resolve the given {@link FollowLinkConfig} for the given model and return the model with the + * link property attached. + * + * @param model the {@link HALResource} to resolve the link for + * @param linkToFollow the {@link FollowLinkConfig} to resolve + */ + public resolveLink(model, linkToFollow: FollowLinkConfig): T { + model[linkToFollow.name] = this.resolveLinkWithoutAttaching(model, linkToFollow); return model; } diff --git a/src/app/core/cache/server-sync-buffer.effects.spec.ts b/src/app/core/cache/server-sync-buffer.effects.spec.ts index a236d3bb71..a53c6af982 100644 --- a/src/app/core/cache/server-sync-buffer.effects.spec.ts +++ b/src/app/core/cache/server-sync-buffer.effects.spec.ts @@ -13,9 +13,14 @@ import { RestRequestMethod } from '../data/rest-request-method'; import { DSpaceObject } from '../shared/dspace-object.model'; import { ApplyPatchObjectCacheAction } from './object-cache.actions'; import { ObjectCacheService } from './object-cache.service'; -import { CommitSSBAction, EmptySSBAction, ServerSyncBufferActionTypes } from './server-sync-buffer.actions'; +import { + CommitSSBAction, + EmptySSBAction, + ServerSyncBufferActionTypes +} from './server-sync-buffer.actions'; import { ServerSyncBufferEffects } from './server-sync-buffer.effects'; import { storeModuleConfig } from '../../app.reducer'; +import { NoOpAction } from '../../shared/ngrx/no-op.action'; describe('ServerSyncBufferEffects', () => { let ssbEffects: ServerSyncBufferEffects; @@ -143,7 +148,7 @@ describe('ServerSyncBufferEffects', () => { payload: { method: RestRequestMethod.PATCH } } }); - const expected = cold('b', { b: { type: 'NO_ACTION' } }); + const expected = cold('b', { b: new NoOpAction() }); expect(ssbEffects.commitServerSyncBuffer).toBeObservable(expected); }); diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts index 363d566e00..d8ed88e12c 100644 --- a/src/app/core/cache/server-sync-buffer.effects.ts +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -21,6 +21,7 @@ import { RestRequestMethod } from '../data/rest-request-method'; import { environment } from '../../../environments/environment'; import { ObjectCacheEntry } from './object-cache.reducer'; import { Operation } from 'fast-json-patch'; +import { NoOpAction } from '../../shared/ngrx/no-op.action'; @Injectable() export class ServerSyncBufferEffects { @@ -80,7 +81,7 @@ export class ServerSyncBufferEffects { switchMap((array) => [...array, new EmptySSBAction(action.payload)]) ); } else { - return observableOf({ type: 'NO_ACTION' }); + return observableOf(new NoOpAction()); } }) ); diff --git a/src/app/core/config/models/config-access-condition-option.model.ts b/src/app/core/config/models/config-access-condition-option.model.ts index 46bf1b60ce..334ff8f34e 100644 --- a/src/app/core/config/models/config-access-condition-option.model.ts +++ b/src/app/core/config/models/config-access-condition-option.model.ts @@ -8,16 +8,6 @@ export class AccessConditionOption { */ name: string; - /** - * The uuid of the Group this Access Condition applies to - */ - groupUUID: string; - - /** - * The uuid of the Group that contains set of groups this Resource Policy applies to - */ - selectGroupUUID: string; - /** * A boolean representing if this Access Condition has a start date */ diff --git a/src/app/core/data/object-updates/object-updates.effects.spec.ts b/src/app/core/data/object-updates/object-updates.effects.spec.ts index 8c4467c0c0..ffd20a7300 100644 --- a/src/app/core/data/object-updates/object-updates.effects.spec.ts +++ b/src/app/core/data/object-updates/object-updates.effects.spec.ts @@ -11,10 +11,14 @@ import { RemoveFieldUpdateAction, RemoveObjectUpdatesAction } from './object-updates.actions'; -import { INotification, Notification } from '../../../shared/notifications/models/notification.model'; +import { + INotification, + Notification +} from '../../../shared/notifications/models/notification.model'; import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { filter } from 'rxjs/operators'; import { hasValue } from '../../../shared/empty.util'; +import { NoOpAction } from '../../../shared/ngrx/no-op.action'; describe('ObjectUpdatesEffects', () => { let updatesEffects: ObjectUpdatesEffects; @@ -97,7 +101,7 @@ describe('ObjectUpdatesEffects', () => { actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); actions = hot('b', { b: new ReinstateObjectUpdatesAction(testURL) }); updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => { - expect(t).toEqual({ type: 'NO_ACTION' }); + expect(t).toEqual(new NoOpAction()); } ); }); diff --git a/src/app/core/data/object-updates/object-updates.effects.ts b/src/app/core/data/object-updates/object-updates.effects.ts index 169fa89cd3..c9c3237ef5 100644 --- a/src/app/core/data/object-updates/object-updates.effects.ts +++ b/src/app/core/data/object-updates/object-updates.effects.ts @@ -3,7 +3,8 @@ import { Actions, Effect, ofType } from '@ngrx/effects'; import { DiscardObjectUpdatesAction, ObjectUpdatesAction, - ObjectUpdatesActionTypes, RemoveAllObjectUpdatesAction, + ObjectUpdatesActionTypes, + RemoveAllObjectUpdatesAction, RemoveObjectUpdatesAction } from './object-updates.actions'; import { delay, filter, map, switchMap, take, tap } from 'rxjs/operators'; @@ -17,6 +18,7 @@ import { RemoveNotificationAction } from '../../../shared/notifications/notifications.actions'; import { Action } from '@ngrx/store'; +import { NoOpAction } from '../../../shared/ngrx/no-op.action'; /** * NGRX effects for ObjectUpdatesActions @@ -111,7 +113,7 @@ export class ObjectUpdatesEffects { map((updateAction: ObjectUpdatesAction) => { if (updateAction.type === ObjectUpdatesActionTypes.REINSTATE) { // If someone reinstated, do nothing, just let the reinstating happen - return { type: 'NO_ACTION' }; + return new NoOpAction(); } // If someone performed another action, assume the user does not want to reinstate and remove all changes return removeAction; diff --git a/src/app/core/index/index.effects.spec.ts b/src/app/core/index/index.effects.spec.ts index 6e2bcac24f..efca0ae364 100644 --- a/src/app/core/index/index.effects.spec.ts +++ b/src/app/core/index/index.effects.spec.ts @@ -8,6 +8,7 @@ import { Item } from '../shared/item.model'; import { AddToIndexAction } from './index.actions'; import { IndexName } from './index.reducer'; import { provideMockStore } from '@ngrx/store/testing'; +import { NoOpAction } from '../../shared/ngrx/no-op.action'; describe('ObjectUpdatesEffects', () => { let indexEffects: UUIDIndexEffects; @@ -79,14 +80,14 @@ describe('ObjectUpdatesEffects', () => { it('should emit NO_ACTION when a AddToObjectCacheAction without an alternativeLink is dispatched', () => { action = new AddToObjectCacheAction(objectToCache, timeCompleted, msToLive, requestUUID, undefined); actions = hot('--a-', { a: action }); - const expected = cold('--b-', { b: { type: 'NO_ACTION' } }); + const expected = cold('--b-', { b: new NoOpAction() }); expect(indexEffects.addAlternativeObjectLink$).toBeObservable(expected); }); it('should emit NO_ACTION when a AddToObjectCacheAction with an alternativeLink that\'s the same as the objectToCache\'s selfLink is dispatched', () => { action = new AddToObjectCacheAction(objectToCache, timeCompleted, msToLive, requestUUID, objectToCache._links.self.href); actions = hot('--a-', { a: action }); - const expected = cold('--b-', { b: { type: 'NO_ACTION' } }); + const expected = cold('--b-', { b: new NoOpAction() }); expect(indexEffects.addAlternativeObjectLink$).toBeObservable(expected); }); }); diff --git a/src/app/core/index/index.effects.ts b/src/app/core/index/index.effects.ts index 61abe90be9..a1ab0b20a8 100644 --- a/src/app/core/index/index.effects.ts +++ b/src/app/core/index/index.effects.ts @@ -19,6 +19,7 @@ import { RestRequestMethod } from '../data/rest-request-method'; import { getUrlWithoutEmbedParams, uuidFromHrefSelector } from './index.selectors'; import { Store, select } from '@ngrx/store'; import { CoreState } from '../core.reducers'; +import { NoOpAction } from '../../shared/ngrx/no-op.action'; @Injectable() export class UUIDIndexEffects { @@ -53,7 +54,7 @@ export class UUIDIndexEffects { selfLink ); } else { - return { type: 'NO_ACTION' }; + return new NoOpAction(); } }) ); diff --git a/src/app/core/resolving/resolver.actions.ts b/src/app/core/resolving/resolver.actions.ts new file mode 100644 index 0000000000..db41d20b6e --- /dev/null +++ b/src/app/core/resolving/resolver.actions.ts @@ -0,0 +1,27 @@ +import { type } from '../../shared/ngrx/type'; +import { Action } from '@ngrx/store'; +import { DSpaceObject } from '../shared/dspace-object.model'; + +export const ResolverActionTypes = { + RESOLVED: type('dspace/resolver/RESOLVED') +}; + +/** + * An action that indicates a route object has been resolved. + * + * It isn't used in a reducer for now. Its purpose is to be able to be notified that an object + * has been resolved in an effect. + */ +export class ResolvedAction implements Action { + type = ResolverActionTypes.RESOLVED; + payload: { + url: string, + dso: DSpaceObject + }; + + constructor(url: string, dso: DSpaceObject) { + this.payload = { url, dso }; + } +} + +export type ResolverAction = ResolvedAction; diff --git a/src/app/core/shared/context.model.ts b/src/app/core/shared/context.model.ts index 3613a143bd..126896e3e1 100644 --- a/src/app/core/shared/context.model.ts +++ b/src/app/core/shared/context.model.ts @@ -3,7 +3,7 @@ */ export enum Context { - Undefined = 'undefined', + Any = 'undefined', ItemPage = 'itemPage', Search = 'search', Workflow = 'workflow', diff --git a/src/app/core/submission/models/submission-upload-file-access-condition.model.ts b/src/app/core/submission/models/submission-upload-file-access-condition.model.ts index 8b89397f24..fa4d9b9062 100644 --- a/src/app/core/submission/models/submission-upload-file-access-condition.model.ts +++ b/src/app/core/submission/models/submission-upload-file-access-condition.model.ts @@ -13,11 +13,6 @@ export class SubmissionUploadFileAccessConditionObject { */ name: string; - /** - * The access group UUID defined in this access condition - */ - groupUUID: string; - /** * Possible start date of the access condition */ diff --git a/src/app/core/tasks/claimed-task-data.service.spec.ts b/src/app/core/tasks/claimed-task-data.service.spec.ts index 98a0f5f51e..ab9727592e 100644 --- a/src/app/core/tasks/claimed-task-data.service.spec.ts +++ b/src/app/core/tasks/claimed-task-data.service.spec.ts @@ -8,9 +8,16 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { NotificationsService } from '../../shared/notifications/notifications.service'; import { CoreState } from '../core.reducers'; import { ClaimedTaskDataService } from './claimed-task-data.service'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { FindListOptions } from '../data/request.models'; +import { RequestParam } from '../cache/models/request-param.model'; +import { getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; describe('ClaimedTaskDataService', () => { + let scheduler: TestScheduler; let service: ClaimedTaskDataService; let options: HttpOptions; const taskEndpoint = 'https://rest.api/task'; @@ -45,6 +52,7 @@ describe('ClaimedTaskDataService', () => { } beforeEach(() => { + scheduler = getTestScheduler(); service = initTestService(); options = Object.create({}); let headers = new HttpHeaders(); @@ -68,6 +76,24 @@ describe('ClaimedTaskDataService', () => { }); }); + describe('claimTask', () => { + + it('should call postToEndpoint method', () => { + + spyOn(service, 'postToEndpoint').and.returnValue(observableOf(null)); + + scheduler.schedule(() => service.claimTask('scopeId', 'poolTaskHref').subscribe()); + scheduler.flush(); + + const postToEndpointOptions: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + postToEndpointOptions.headers = headers; + + expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, 'poolTaskHref', null, postToEndpointOptions); + }); + }); + describe('returnToPoolTask', () => { it('should call deleteById method', () => { const scopeId = '1234'; @@ -79,4 +105,21 @@ describe('ClaimedTaskDataService', () => { expect(service.deleteById).toHaveBeenCalledWith(linkPath, scopeId, options); }); }); + + describe('findByItem', () => { + + it('should call searchTask method', () => { + spyOn((service as any), 'searchTask').and.returnValue(observableOf(createSuccessfulRemoteDataObject$({}))); + + scheduler.schedule(() => service.findByItem('a0db0fde-1d12-4d43-bd0d-0f43df8d823c').subscribe()); + scheduler.flush(); + + const findListOptions = new FindListOptions(); + findListOptions.searchParams = [ + new RequestParam('uuid', 'a0db0fde-1d12-4d43-bd0d-0f43df8d823c') + ]; + + expect(service.searchTask).toHaveBeenCalledWith('findByItem', findListOptions); + }); + }); }); diff --git a/src/app/core/tasks/claimed-task-data.service.ts b/src/app/core/tasks/claimed-task-data.service.ts index 5815dad6e5..9cfd5a44d6 100644 --- a/src/app/core/tasks/claimed-task-data.service.ts +++ b/src/app/core/tasks/claimed-task-data.service.ts @@ -1,4 +1,4 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; @@ -15,6 +15,11 @@ import { ClaimedTask } from './models/claimed-task-object.model'; import { CLAIMED_TASK } from './models/claimed-task-object.resource-type'; import { ProcessTaskResponse } from './models/process-task-response'; import { TasksService } from './tasks.service'; +import { RemoteData } from '../data/remote-data'; +import { FindListOptions } from '../data/request.models'; +import { RequestParam } from '../cache/models/request-param.model'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { getFirstSucceededRemoteData } from '../shared/operators'; /** * The service handling all REST requests for ClaimedTask @@ -23,7 +28,7 @@ import { TasksService } from './tasks.service'; @dataService(CLAIMED_TASK) export class ClaimedTaskDataService extends TasksService { - protected responseMsToLive = 10 * 1000; + protected responseMsToLive = 1000; /** * The endpoint link name @@ -54,6 +59,24 @@ export class ClaimedTaskDataService extends TasksService { super(); } + /** + * Make a request to claim the given task + * + * @param scopeId + * The task id + * @param poolTaskHref + * The pool task Href + * @return {Observable} + * Emit the server response + */ + public claimTask(scopeId: string, poolTaskHref: string): Observable { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + return this.postToEndpoint(this.linkPath, poolTaskHref, null, options); + } + /** * Make a request for the given task * @@ -80,4 +103,19 @@ export class ClaimedTaskDataService extends TasksService { return this.deleteById(this.linkPath, scopeId, this.makeHttpOptions()); } + /** + * Search a claimed task by item uuid. + * @param uuid + * The item uuid + * @return {Observable>} + * The server response + */ + public findByItem(uuid: string): Observable> { + const options = new FindListOptions(); + options.searchParams = [ + new RequestParam('uuid', uuid) + ]; + return this.searchTask('findByItem', options).pipe(getFirstSucceededRemoteData()); + } + } diff --git a/src/app/core/tasks/pool-task-data.service.spec.ts b/src/app/core/tasks/pool-task-data.service.spec.ts index 75255d3e0a..7279c96e5c 100644 --- a/src/app/core/tasks/pool-task-data.service.spec.ts +++ b/src/app/core/tasks/pool-task-data.service.spec.ts @@ -8,9 +8,16 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { NotificationsService } from '../../shared/notifications/notifications.service'; import { CoreState } from '../core.reducers'; import { PoolTaskDataService } from './pool-task-data.service'; +import { getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { FindListOptions } from '../data/request.models'; +import { RequestParam } from '../cache/models/request-param.model'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; describe('PoolTaskDataService', () => { + let scheduler: TestScheduler; let service: PoolTaskDataService; let options: HttpOptions; const taskEndpoint = 'https://rest.api/task'; @@ -45,6 +52,7 @@ describe('PoolTaskDataService', () => { } beforeEach(() => { + scheduler = getTestScheduler(); service = initTestService(); options = Object.create({}); let headers = new HttpHeaders(); @@ -52,14 +60,33 @@ describe('PoolTaskDataService', () => { options.headers = headers; }); - describe('claimTask', () => { + describe('findByItem', () => { - it('should call postToEndpoint method', () => { - spyOn(service, 'postToEndpoint'); - const scopeId = '1234'; - service.claimTask(scopeId); + it('should call searchTask method', () => { + spyOn((service as any), 'searchTask').and.returnValue(observableOf(createSuccessfulRemoteDataObject$({}))); + + scheduler.schedule(() => service.findByItem('a0db0fde-1d12-4d43-bd0d-0f43df8d823c').subscribe()); + scheduler.flush(); + + const findListOptions = new FindListOptions(); + findListOptions.searchParams = [ + new RequestParam('uuid', 'a0db0fde-1d12-4d43-bd0d-0f43df8d823c') + ]; + + expect(service.searchTask).toHaveBeenCalledWith('findByItem', findListOptions); + }); + }); + + describe('getPoolTaskEndpointById', () => { + + it('should call getEndpointById method', () => { + spyOn(service, 'getEndpointById').and.returnValue(observableOf(null)); + + scheduler.schedule(() => service.getPoolTaskEndpointById('a0db0fde-1d12-4d43-bd0d-0f43df8d823c').subscribe()); + scheduler.flush(); + + expect(service.getEndpointById).toHaveBeenCalledWith('a0db0fde-1d12-4d43-bd0d-0f43df8d823c'); - expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, {}, scopeId, options); }); }); }); diff --git a/src/app/core/tasks/pool-task-data.service.ts b/src/app/core/tasks/pool-task-data.service.ts index f08274b5f1..d44e402e7f 100644 --- a/src/app/core/tasks/pool-task-data.service.ts +++ b/src/app/core/tasks/pool-task-data.service.ts @@ -13,8 +13,11 @@ import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { PoolTask } from './models/pool-task-object.model'; import { POOL_TASK } from './models/pool-task-object.resource-type'; -import { ProcessTaskResponse } from './models/process-task-response'; import { TasksService } from './tasks.service'; +import { RemoteData } from '../data/remote-data'; +import { FindListOptions } from '../data/request.models'; +import { RequestParam } from '../cache/models/request-param.model'; +import { getFirstCompletedRemoteData } from '../shared/operators'; /** * The service handling all REST requests for PoolTask @@ -28,7 +31,7 @@ export class PoolTaskDataService extends TasksService { */ protected linkPath = 'pooltasks'; - protected responseMsToLive = 10 * 1000; + protected responseMsToLive = 1000; /** * Initialize instance variables @@ -56,14 +59,30 @@ export class PoolTaskDataService extends TasksService { } /** - * Make a request to claim the given task - * - * @param scopeId - * The task id - * @return {Observable} - * Emit the server response + * Search a pool task by item uuid. + * @param uuid + * The item uuid + * @return {Observable>} + * The server response */ - public claimTask(scopeId: string): Observable { - return this.postToEndpoint(this.linkPath, {}, scopeId, this.makeHttpOptions()); + public findByItem(uuid: string): Observable> { + const options = new FindListOptions(); + options.searchParams = [ + new RequestParam('uuid', uuid) + ]; + return this.searchTask('findByItem', options).pipe(getFirstCompletedRemoteData()); } + + /** + * Get the Href of the pool task + * + * @param poolTaskId + * the poolTask id + * @return {Observable>} + * the Href + */ + public getPoolTaskEndpointById(poolTaskId): Observable { + return this.getEndpointById(poolTaskId); + } + } diff --git a/src/app/core/tasks/tasks.service.spec.ts b/src/app/core/tasks/tasks.service.spec.ts index c6f965b79f..f0c86d2abf 100644 --- a/src/app/core/tasks/tasks.service.spec.ts +++ b/src/app/core/tasks/tasks.service.spec.ts @@ -4,7 +4,7 @@ import { TestScheduler } from 'rxjs/testing'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { TasksService } from './tasks.service'; import { RequestService } from '../data/request.service'; -import { TaskDeleteRequest, TaskPostRequest } from '../data/request.models'; +import { FindListOptions, TaskDeleteRequest, TaskPostRequest } from '../data/request.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { TaskObject } from './models/task-object.model'; @@ -17,8 +17,11 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { ChangeAnalyzer } from '../data/change-analyzer'; import { compare, Operation } from 'fast-json-patch'; +import { of as observableOf } from 'rxjs/internal/observable/of'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { of } from 'rxjs'; const LINK_NAME = 'test'; @@ -59,7 +62,7 @@ describe('TasksService', () => { const requestService = getMockRequestService(); const halService: any = new HALEndpointServiceStub(taskEndpoint); const rdbService = getMockRemoteDataBuildService(); - const notificationsService = {} as NotificationsService; + const notificationsService = new NotificationsServiceStub() as any; const http = {} as HttpClient; const comparator = new DummyChangeAnalyzer() as any; const objectCache = { @@ -118,4 +121,38 @@ describe('TasksService', () => { }); }); + describe('searchTask', () => { + + it('should call findByHref with the href generated by getSearchByHref', () => { + + spyOn(service, 'getSearchByHref').and.returnValue(observableOf('generatedHref')); + spyOn(service, 'findByHref').and.returnValue(of(null)); + + const followLinks = {}; + const options = new FindListOptions(); + options.searchParams = []; + + scheduler.schedule(() => service.searchTask('method', options, followLinks as any).subscribe()); + scheduler.flush(); + + expect(service.getSearchByHref).toHaveBeenCalledWith('method', options, followLinks as any); + expect(service.findByHref).toHaveBeenCalledWith('generatedHref', false, true); + }); + }); + + describe('getEndpointById', () => { + + it('should call halService.getEndpoint and then getEndpointByIDHref', () => { + + spyOn(halService, 'getEndpoint').and.returnValue(observableOf('generatedHref')); + spyOn(service, 'getEndpointByIDHref').and.returnValue(null); + + scheduler.schedule(() => service.getEndpointById('scopeId').subscribe()); + scheduler.flush(); + + expect(halService.getEndpoint).toHaveBeenCalledWith(service.getLinkPath()); + expect(service.getEndpointByIDHref).toHaveBeenCalledWith('generatedHref', 'scopeId'); + }); + }); + }); diff --git a/src/app/core/tasks/tasks.service.ts b/src/app/core/tasks/tasks.service.ts index 8f337b7bd2..f23c71e65e 100644 --- a/src/app/core/tasks/tasks.service.ts +++ b/src/app/core/tasks/tasks.service.ts @@ -1,43 +1,29 @@ import { HttpHeaders } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; +import { distinctUntilChanged, filter, find, map, mergeMap, tap } from 'rxjs/operators'; import { DataService } from '../data/data.service'; -import { DeleteRequest, PostRequest, TaskDeleteRequest, TaskPostRequest } from '../data/request.models'; -import { isNotEmpty } from '../../shared/empty.util'; +import { + DeleteRequest, + FindListOptions, + PostRequest, + TaskDeleteRequest, + TaskPostRequest +} from '../data/request.models'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { ProcessTaskResponse } from './models/process-task-response'; -import { getFirstCompletedRemoteData } from '../shared/operators'; +import { getAllCompletedRemoteData, getFirstCompletedRemoteData } from '../shared/operators'; import { CacheableObject } from '../cache/object-cache.reducer'; import { RemoteData } from '../data/remote-data'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; /** * An abstract class that provides methods to handle task requests. */ export abstract class TasksService extends DataService { - /** - * Fetch a RestRequest - * - * @param requestId - * The base endpoint for the type of object - * @return Observable - * server response - */ - protected fetchRequest(requestId: string): Observable { - return this.rdbService.buildFromRequestUUID(requestId).pipe( - getFirstCompletedRemoteData(), - map((response: RemoteData) => { - if (response.hasFailed) { - return new ProcessTaskResponse(false, response.statusCode, response.errorMessage); - } else { - return new ProcessTaskResponse(true, response.statusCode); - } - }) - ); - } - /** * Create the HREF for a specific submission object based on its identifier * @@ -46,7 +32,7 @@ export abstract class TasksService extends DataServic * @param resourceID * The identifier for the object */ - protected getEndpointByIDHref(endpoint, resourceID): string { + getEndpointByIDHref(endpoint, resourceID): string { return isNotEmpty(resourceID) ? `${endpoint}/${resourceID}` : `${endpoint}`; } @@ -90,16 +76,67 @@ export abstract class TasksService extends DataServic */ public deleteById(linkPath: string, scopeId: string, options?: HttpOptions): Observable { const requestId = this.requestService.generateRequestId(); - return this.halService.getEndpoint(linkPath || this.linkPath).pipe( - filter((href: string) => isNotEmpty(href)), - distinctUntilChanged(), - map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)), + return this.getEndpointById(scopeId, linkPath).pipe( map((endpointURL: string) => new TaskDeleteRequest(requestId, endpointURL, null, options)), tap((request: DeleteRequest) => this.requestService.send(request)), mergeMap((request: DeleteRequest) => this.fetchRequest(requestId)), distinctUntilChanged()); } + /** + * Get the endpoint of a task by scopeId. + * @param linkPath + * @param scopeId + */ + public getEndpointById(scopeId: string, linkPath?: string): Observable { + return this.halService.getEndpoint(linkPath || this.linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId))); + } + + /** + * Search a task. + * @param searchMethod + * the search method + * @param options + * the find list options + * @param linksToFollow + * links to follow + */ + public searchTask(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable> { + const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow); + return hrefObs.pipe( + find((href: string) => hasValue(href)), + mergeMap((href) => this.findByHref(href, false, true).pipe( + getAllCompletedRemoteData(), + filter((rd: RemoteData) => !rd.isSuccessStale), + tap(() => this.requestService.setStaleByHrefSubstring(href))) + ) + ); + } + + /** + * Fetch a RestRequest + * + * @param requestId + * The base endpoint for the type of object + * @return Observable + * server response + */ + protected fetchRequest(requestId: string): Observable { + return this.rdbService.buildFromRequestUUID(requestId).pipe( + getFirstCompletedRemoteData(), + map((response: RemoteData) => { + if (response.hasFailed) { + return new ProcessTaskResponse(false, response.statusCode, response.errorMessage); + } else { + return new ProcessTaskResponse(true, response.statusCode); + } + }) + ); + } + /** * Create a new HttpOptions */ diff --git a/src/app/core/xsrf/xsrf.interceptor.spec.ts b/src/app/core/xsrf/xsrf.interceptor.spec.ts index 84f10b9e13..742c4a4a45 100644 --- a/src/app/core/xsrf/xsrf.interceptor.spec.ts +++ b/src/app/core/xsrf/xsrf.interceptor.spec.ts @@ -1,32 +1,20 @@ import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; -import { HttpHeaders, HTTP_INTERCEPTORS, HttpResponse, HttpXsrfTokenExtractor, HttpErrorResponse } from '@angular/common/http'; +import { HttpHeaders, HTTP_INTERCEPTORS, HttpXsrfTokenExtractor } from '@angular/common/http'; import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; import { RestRequestMethod } from '../data/rest-request-method'; import { CookieService } from '../services/cookie.service'; import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; import { XsrfInterceptor } from './xsrf.interceptor'; - -/** - * A Mock TokenExtractor which just returns whatever token it is initialized with. - * This mock object is injected into our XsrfInterceptor, so that it always finds - * the same fake XSRF token. - */ -class MockTokenExtractor extends HttpXsrfTokenExtractor { - constructor(private token: string | null) { super(); } - - getToken(): string | null { return this.token; } -} +import { HttpXsrfTokenExtractorMock } from '../../shared/mocks/http-xsrf-token-extractor.mock'; describe(`XsrfInterceptor`, () => { let service: DspaceRestService; let httpMock: HttpTestingController; let cookieService: CookieService; - // Create a MockTokenExtractor which always returns "test-token". This will - // be used as the test HttpXsrfTokenExtractor, see below. + // mock XSRF token const testToken = 'test-token'; - const mockTokenExtractor = new MockTokenExtractor(testToken); // Mock payload/statuses are dummy content as we are not testing the results // of any below requests. We are only testing for X-XSRF-TOKEN header. @@ -46,7 +34,7 @@ describe(`XsrfInterceptor`, () => { useClass: XsrfInterceptor, multi: true, }, - { provide: HttpXsrfTokenExtractor, useValue: mockTokenExtractor }, + { provide: HttpXsrfTokenExtractor, useValue: new HttpXsrfTokenExtractorMock(testToken) }, { provide: CookieService, useValue: new CookieServiceMock() } ], }); diff --git a/src/app/core/xsrf/xsrf.interceptor.ts b/src/app/core/xsrf/xsrf.interceptor.ts index 7b5a66f27a..0301a70994 100644 --- a/src/app/core/xsrf/xsrf.interceptor.ts +++ b/src/app/core/xsrf/xsrf.interceptor.ts @@ -6,6 +6,13 @@ import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { CookieService } from '../services/cookie.service'; import { throwError } from 'rxjs'; +// Name of XSRF header we may send in requests to backend (this is a standard name defined by Angular) +export const XSRF_REQUEST_HEADER = 'X-XSRF-TOKEN'; +// Name of XSRF header we may receive in responses from backend +export const XSRF_RESPONSE_HEADER = 'DSPACE-XSRF-TOKEN'; +// Name of cookie where we store the XSRF token +export const XSRF_COOKIE = 'XSRF-TOKEN'; + /** * Custom Http Interceptor intercepting Http Requests & Responses to * exchange XSRF/CSRF tokens with the backend. @@ -43,11 +50,6 @@ export class XsrfInterceptor implements HttpInterceptor { * @param next */ intercept(req: HttpRequest, next: HttpHandler): Observable> { - // Name of XSRF header we may send in requests to backend (this is a standard name defined by Angular) - const requestCsrfHeader = 'X-XSRF-TOKEN'; - // Name of XSRF header we may receive in responses from backend - const responseCsrfHeader = 'DSPACE-XSRF-TOKEN'; - // Ensure EVERY request from Angular includes "withCredentials: true". // This allows Angular to receive & send cookies via a CORS request (to // the backend). ONLY requests with credentials will: @@ -71,8 +73,8 @@ export class XsrfInterceptor implements HttpInterceptor { const token = this.tokenExtractor.getToken() as string; // send token in request's X-XSRF-TOKEN header (anti-CSRF security) to backend - if (token !== null && !req.headers.has(requestCsrfHeader)) { - req = req.clone({ headers: req.headers.set(requestCsrfHeader, token) }); + if (token !== null && !req.headers.has(XSRF_REQUEST_HEADER)) { + req = req.clone({ headers: req.headers.set(XSRF_REQUEST_HEADER, token) }); } } // Pass to next interceptor, but intercept EVERY response event as well @@ -82,9 +84,9 @@ export class XsrfInterceptor implements HttpInterceptor { if (response instanceof HttpResponse) { // For every response that comes back, check for the custom // DSPACE-XSRF-TOKEN header sent from the backend. - if (response.headers.has(responseCsrfHeader)) { + if (response.headers.has(XSRF_RESPONSE_HEADER)) { // value of header is a new XSRF token - this.saveXsrfToken(response.headers.get(responseCsrfHeader)); + this.saveXsrfToken(response.headers.get(XSRF_RESPONSE_HEADER)); } } }), @@ -92,9 +94,9 @@ export class XsrfInterceptor implements HttpInterceptor { if (error instanceof HttpErrorResponse) { // For every error that comes back, also check for the custom // DSPACE-XSRF-TOKEN header sent from the backend. - if (error.headers.has(responseCsrfHeader)) { + if (error.headers.has(XSRF_RESPONSE_HEADER)) { // value of header is a new XSRF token - this.saveXsrfToken(error.headers.get(responseCsrfHeader)); + this.saveXsrfToken(error.headers.get(XSRF_RESPONSE_HEADER)); } } // Return error response as is. @@ -111,7 +113,7 @@ export class XsrfInterceptor implements HttpInterceptor { // Save token value as a *new* value of our client-side XSRF-TOKEN cookie. // This is the cookie that is parsed by Angular's tokenExtractor(), // which we will send back in the X-XSRF-TOKEN header per Angular best practices. - this.cookieService.remove('XSRF-TOKEN'); - this.cookieService.set('XSRF-TOKEN', token); + this.cookieService.remove(XSRF_COOKIE); + this.cookieService.set(XSRF_COOKIE, token); } } diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.scss b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.scss index 78cc32591b..2272183e51 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.scss +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.scss @@ -1,5 +1,3 @@ -$submission-relationship-thumbnail-width: 80px; - .person-thumbnail { - width: $submission-relationship-thumbnail-width; + width: var(--ds-submission-relationship-thumbnail-width); } diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.scss b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.scss index 78cc32591b..28da0b74cd 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.scss +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.scss @@ -1,5 +1,3 @@ -$submission-relationship-thumbnail-width: 80px; - .person-thumbnail { - width: $submission-relationship-thumbnail-width; + width: var(--ds-submission-relationship-thumbnail-width); } diff --git a/src/app/footer/footer.component.scss b/src/app/footer/footer.component.scss index fb51fc258d..5020ae17b1 100644 --- a/src/app/footer/footer.component.scss +++ b/src/app/footer/footer.component.scss @@ -1,40 +1,42 @@ -$footer-bg: $gray-100; -$footer-border: 1px solid darken($footer-bg, 10%); -$footer-padding: $spacer * 1.5; -$footer-logo-height: 55px; +:host { + --ds-footer-bg: var(--bs-gray-100); + --ds-footer-border: 1px solid var(--bs-gray-300); + --ds-footer-padding: calc(var(--bs-spacer) * 1.5); + --ds-footer-logo-height: 55px; -.footer { - background-color: $footer-bg; - border-top: $footer-border; + .footer { + background-color: var(--ds-footer-bg); + border-top: var(--ds-footer-border); text-align: center; - padding: $footer-padding; - padding-bottom: $spacer; + padding: var(--ds-footer-padding); + padding-bottom: var(--bs-spacer); p { - margin: 0; + margin: 0; } img { - height: $footer-logo-height; + height: var(--ds-footer-logo-height); } ul { - padding-top: $spacer * 0.5; + padding-top: calc(var(--bs-spacer) * 0.5); - li { - display: inline-flex; - a { - padding: 0 $spacer/2; - color: inherit - } - - &:not(:last-child) { - &:after { - content: ''; - border-right: 1px map-get($theme-colors, secondary) solid; - } - - } + li { + display: inline-flex; + a { + padding: 0 calc(var(--bs-spacer) / 2); + color: inherit } + + &:not(:last-child) { + &:after { + content: ''; + border-right: 1px var(--bs-secondary) solid; + } + + } + } } + } } diff --git a/src/app/header-nav-wrapper/header-navbar-wrapper.component.scss b/src/app/header-nav-wrapper/header-navbar-wrapper.component.scss index c3eba35b79..b297979fd0 100644 --- a/src/app/header-nav-wrapper/header-navbar-wrapper.component.scss +++ b/src/app/header-nav-wrapper/header-navbar-wrapper.component.scss @@ -1,7 +1,7 @@ @media screen and (max-width: map-get($grid-breakpoints, md)) { :host.open { - background-color: $white; + background-color: var(--bs-white); top: 0; position: sticky; } -} \ No newline at end of file +} diff --git a/src/app/header/header.component.scss b/src/app/header/header.component.scss index 70c66f119d..0263a566cc 100644 --- a/src/app/header/header.component.scss +++ b/src/app/header/header.component.scss @@ -1,7 +1,7 @@ .navbar-brand img { - height: $header-logo-height; + height: var(--ds-header-logo-height); @media screen and (max-width: map-get($grid-breakpoints, sm)) { - height: $header-logo-height-xs; + height: var(--ds-header-logo-height-xs); } } .navbar-toggler .navbar-toggler-icon { @@ -11,10 +11,10 @@ .navbar ::ng-deep { a { - color: $header-icon-color; + color: var(--ds-header-icon-color); &:hover, &focus { - color: darken($header-icon-color, 15%); + color: var(--ds-header-icon-color-hover); } } } diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss index f724c3e751..8059a4b027 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.scss @@ -4,8 +4,8 @@ border-top-left-radius: 0; border-top-right-radius: 0; ::ng-deep a.nav-link { - padding-right: $spacer; - padding-left: $spacer; + padding-right: var(--bs-spacer); + padding-left: var(--bs-spacer); white-space: nowrap; } } @@ -15,7 +15,7 @@ .dropdown-toggle { &:after { float: right; - margin-top: $spacer/2; + margin-top: calc(var(--bs-spacer) / 2); } } .dropdown-menu { diff --git a/src/app/navbar/navbar.component.scss b/src/app/navbar/navbar.component.scss index d0fa04991d..cca6284b72 100644 --- a/src/app/navbar/navbar.component.scss +++ b/src/app/navbar/navbar.component.scss @@ -1,5 +1,5 @@ nav.navbar { - border-bottom: 1px $gray-400 solid; + border-bottom: 1px var(--bs-gray-400) solid; align-items: baseline; } @@ -7,7 +7,7 @@ nav.navbar { @media screen and (max-width: map-get($grid-breakpoints, md)) { .navbar { width: 100%; - background-color: $white; + background-color: var(--bs-white); position: absolute; overflow: hidden; height: 0; @@ -19,8 +19,8 @@ nav.navbar { @media screen and (min-width: map-get($grid-breakpoints, md)) { .reset-padding-md { - margin-left: -$spacer/2; - margin-right: -$spacer/2; + margin-left: calc(var(--bs-spacer) / -2); + margin-right: calc(var(--bs-spacer) / -2); } } @@ -28,8 +28,8 @@ nav.navbar { .navbar-expand-md.navbar-container { @media screen and (max-width: map-get($grid-breakpoints, md)) { > .container { - padding: 0 $spacer; + padding: 0 var(--bs-spacer); } padding: 0; } -} \ No newline at end of file +} diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts index 06b493c80a..1a306ea932 100644 --- a/src/app/navbar/navbar.component.ts +++ b/src/app/navbar/navbar.component.ts @@ -85,5 +85,4 @@ export class NavbarComponent extends MenuComponent { }))); } - } diff --git a/src/app/navbar/navbar.effects.ts b/src/app/navbar/navbar.effects.ts index a50b2df5ad..6cb11f21c0 100644 --- a/src/app/navbar/navbar.effects.ts +++ b/src/app/navbar/navbar.effects.ts @@ -12,6 +12,7 @@ import { import { MenuID } from '../shared/menu/initial-menus-state'; import { MenuService } from '../shared/menu/menu.service'; import { MenuState } from '../shared/menu/menu.reducer'; +import { NoOpAction } from '../shared/ngrx/no-op.action'; @Injectable() export class NavbarEffects { @@ -51,7 +52,7 @@ export class NavbarEffects { return new CollapseMenuAction(MenuID.PUBLIC); } } - return { type: 'NO_ACTION' }; + return new NoOpAction(); })); }) ); diff --git a/src/app/navbar/navbar.module.ts b/src/app/navbar/navbar.module.ts index 795b0541e7..5e1e11d10f 100644 --- a/src/app/navbar/navbar.module.ts +++ b/src/app/navbar/navbar.module.ts @@ -9,6 +9,7 @@ import { NavbarSectionComponent } from './navbar-section/navbar-section.componen import { ExpandableNavbarSectionComponent } from './expandable-navbar-section/expandable-navbar-section.component'; import { NavbarComponent } from './navbar.component'; import { MenuModule } from '../shared/menu/menu.module'; +import { FormsModule } from '@angular/forms'; const effects = [ NavbarEffects @@ -24,6 +25,7 @@ const ENTRY_COMPONENTS = [ imports: [ CommonModule, MenuModule, + FormsModule, EffectsModule.forFeature(effects), CoreModule.forRoot() ], diff --git a/src/app/root/root.component.html b/src/app/root/root.component.html new file mode 100644 index 0000000000..96439cd999 --- /dev/null +++ b/src/app/root/root.component.html @@ -0,0 +1,30 @@ +
+ +
+ + + + +
+
+ +
+ +
+ +
+ +
+ + +
+
+ +
+ +
+
diff --git a/themes/default/styles/_themed_custom_variables.scss b/src/app/root/root.component.scss similarity index 100% rename from themes/default/styles/_themed_custom_variables.scss rename to src/app/root/root.component.scss diff --git a/src/app/root/root.component.spec.ts b/src/app/root/root.component.spec.ts new file mode 100644 index 0000000000..c945a35764 --- /dev/null +++ b/src/app/root/root.component.spec.ts @@ -0,0 +1,77 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RootComponent } from './root.component'; +import { CommonModule } from '@angular/common'; +import { StoreModule } from '@ngrx/store'; +import { authReducer } from '../core/auth/auth.reducer'; +import { storeModuleConfig } from '../app.reducer'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; +import { NativeWindowRef, NativeWindowService } from '../core/services/window.service'; +import { MetadataService } from '../core/metadata/metadata.service'; +import { MetadataServiceMock } from '../shared/mocks/metadata-service.mock'; +import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; +import { AngularticsProviderMock } from '../shared/mocks/angulartics-provider.service.mock'; +import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider'; +import { AuthService } from '../core/auth/auth.service'; +import { AuthServiceMock } from '../shared/mocks/auth.service.mock'; +import { ActivatedRoute, Router } from '@angular/router'; +import { RouterMock } from '../shared/mocks/router.mock'; +import { MockActivatedRoute } from '../shared/mocks/active-router.mock'; +import { MenuService } from '../shared/menu/menu.service'; +import { CSSVariableService } from '../shared/sass-helper/sass-helper.service'; +import { CSSVariableServiceStub } from '../shared/testing/css-variable-service.stub'; +import { HostWindowService } from '../shared/host-window.service'; +import { HostWindowServiceStub } from '../shared/testing/host-window-service.stub'; +import { LocaleService } from '../core/locale/locale.service'; +import { provideMockStore } from '@ngrx/store/testing'; +import { RouteService } from '../core/services/route.service'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { MenuServiceStub } from '../shared/testing/menu-service.stub'; + +describe('RootComponent', () => { + let component: RootComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + StoreModule.forRoot(authReducer, storeModuleConfig), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + declarations: [RootComponent], // declare the test component + providers: [ + { provide: NativeWindowService, useValue: new NativeWindowRef() }, + { provide: MetadataService, useValue: new MetadataServiceMock() }, + { provide: Angulartics2GoogleAnalytics, useValue: new AngularticsProviderMock() }, + { provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() }, + { provide: AuthService, useValue: new AuthServiceMock() }, + { provide: Router, useValue: new RouterMock() }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + { provide: MenuService, useValue: new MenuServiceStub() }, + { provide: CSSVariableService, useClass: CSSVariableServiceStub }, + { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, + { provide: LocaleService, useValue: {} }, + provideMockStore({ core: { auth: { loading: false } } } as any), + RootComponent, + RouteService + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RootComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts new file mode 100644 index 0000000000..f9c475a8fa --- /dev/null +++ b/src/app/root/root.component.ts @@ -0,0 +1,79 @@ +import { map } from 'rxjs/operators'; +import { Component, Inject, OnInit, Optional, Input } from '@angular/core'; +import { Router } from '@angular/router'; + +import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; + +import { MetadataService } from '../core/metadata/metadata.service'; +import { HostWindowState } from '../shared/search/host-window.reducer'; +import { NativeWindowRef, NativeWindowService } from '../core/services/window.service'; +import { AuthService } from '../core/auth/auth.service'; +import { CSSVariableService } from '../shared/sass-helper/sass-helper.service'; +import { MenuService } from '../shared/menu/menu.service'; +import { MenuID } from '../shared/menu/initial-menus-state'; +import { HostWindowService } from '../shared/host-window.service'; +import { ThemeConfig } from '../../config/theme.model'; +import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider'; +import { environment } from '../../environments/environment'; +import { LocaleService } from '../core/locale/locale.service'; +import { KlaroService } from '../shared/cookies/klaro.service'; +import { slideSidebarPadding } from '../shared/animations/slide'; + +@Component({ + selector: 'ds-root', + templateUrl: './root.component.html', + styleUrls: ['./root.component.scss'], + animations: [slideSidebarPadding], +}) +export class RootComponent implements OnInit { + sidebarVisible: Observable; + slideSidebarOver: Observable; + collapsedSidebarWidth: Observable; + totalSidebarWidth: Observable; + theme: Observable = of({} as any); + notificationOptions = environment.notifications; + models; + + /** + * Whether or not the authentication is currently blocking the UI + */ + @Input() isNotAuthBlocking: boolean; + + /** + * Whether or not the the application is loading; + */ + @Input() isLoading: boolean; + + constructor( + @Inject(NativeWindowService) private _window: NativeWindowRef, + private translate: TranslateService, + private store: Store, + private metadata: MetadataService, + private angulartics2GoogleAnalytics: Angulartics2GoogleAnalytics, + private angulartics2DSpace: Angulartics2DSpace, + private authService: AuthService, + private router: Router, + private cssService: CSSVariableService, + private menuService: MenuService, + private windowService: HostWindowService, + private localeService: LocaleService, + @Optional() private cookiesService: KlaroService + ) { + } + + ngOnInit() { + this.sidebarVisible = this.menuService.isMenuVisible(MenuID.ADMIN); + + this.collapsedSidebarWidth = this.cssService.getVariable('collapsedSidebarWidth'); + this.totalSidebarWidth = this.cssService.getVariable('totalSidebarWidth'); + + const sidebarCollapsed = this.menuService.isMenuCollapsed(MenuID.ADMIN); + this.slideSidebarOver = combineLatestObservable(sidebarCollapsed, this.windowService.isXsOrSm()) + .pipe( + map(([collapsed, mobile]) => collapsed || mobile) + ); + } +} diff --git a/src/app/root/themed-root.component.ts b/src/app/root/themed-root.component.ts new file mode 100644 index 0000000000..43aacc416f --- /dev/null +++ b/src/app/root/themed-root.component.ts @@ -0,0 +1,35 @@ +import { ThemedComponent } from '../shared/theme-support/themed.component'; +import { RootComponent } from './root.component'; +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'ds-themed-root', + styleUrls: [], + templateUrl: '../shared/theme-support/themed.component.html', +}) +export class ThemedRootComponent extends ThemedComponent { + /** + * Whether or not the authentication is currently blocking the UI + */ + @Input() isNotAuthBlocking: boolean; + + /** + * Whether or not the the application is loading; + */ + @Input() isLoading: boolean; + + protected inAndOutputNames: (keyof RootComponent & keyof this)[] = ['isLoading', 'isNotAuthBlocking']; + + protected getComponentName(): string { + return 'RootComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../themes/${themeName}/app/root/root.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./root.component`); + } + +} diff --git a/src/app/search-navbar/search-navbar.component.scss b/src/app/search-navbar/search-navbar.component.scss index 3606c47afc..dfebd2c94d 100644 --- a/src/app/search-navbar/search-navbar.component.scss +++ b/src/app/search-navbar/search-navbar.component.scss @@ -1,5 +1,5 @@ input[type="text"] { - margin-top: -0.5 * $font-size-base; + margin-top: calc(-0.5 * var(--bs-font-size-base)); &:focus { background-color: rgba(255, 255, 255, 0.5) !important; diff --git a/src/app/shared/chips/chips.component.scss b/src/app/shared/chips/chips.component.scss index 76be755920..a79deb0f2f 100644 --- a/src/app/shared/chips/chips.component.scss +++ b/src/app/shared/chips/chips.component.scss @@ -1,5 +1,5 @@ .chip-selected { - background-color: map-get($theme-colors, info) !important; + background-color: var(--bs-info) !important; } .chip-label { diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.scss b/src/app/shared/collection-dropdown/collection-dropdown.component.scss index deecc39510..907a111900 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.scss +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.scss @@ -1,15 +1,15 @@ .scrollable-menu { height: auto; - max-height: $dropdown-menu-max-height; + max-height: var(--ds-dropdown-menu-max-height); overflow-x: hidden; } .collection-item { - border-bottom: $dropdown-border-width solid $dropdown-border-color; + border-bottom: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color); } #collectionControlsDropdownMenu { outline: 0; left: 0 !important; - box-shadow: $btn-focus-box-shadow; + box-shadow: var(--bs-btn-focus-box-shadow); } diff --git a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss index c308aa71b9..e8b7d689a3 100644 --- a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss +++ b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss @@ -1,3 +1,3 @@ .btn-dark { - background-color: $admin-sidebar-bg; + background-color: var(--ds-admin-sidebar-bg); } diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.scss b/src/app/shared/dso-selector/dso-selector/dso-selector.component.scss index 37d2ebeca7..6bc70532df 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.scss +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.scss @@ -1,5 +1,5 @@ .scrollable-menu { height: auto; - max-height: $dso-selector-list-max-height; + max-height: var(--ds-dso-selector-list-max-height); overflow-x: hidden; } diff --git a/src/app/shared/file-dropzone-no-uploader/file-dropzone-no-uploader.scss b/src/app/shared/file-dropzone-no-uploader/file-dropzone-no-uploader.scss index 94b0fefb4b..5d06cd5718 100644 --- a/src/app/shared/file-dropzone-no-uploader/file-dropzone-no-uploader.scss +++ b/src/app/shared/file-dropzone-no-uploader/file-dropzone-no-uploader.scss @@ -1,5 +1,5 @@ .ds-base-drop-zone { - border: 2px dashed $gray-600; + border: 2px dashed var(--bs-gray-600); } .ds-document-drop-zone { @@ -9,21 +9,21 @@ } .ds-document-drop-zone-active { - z-index: $drop-zone-area-z-index !important; + z-index: var(--ds-drop-zone-area-z-index) !important; } .ds-document-drop-zone-inner { - background-color: rgba($white, 0.7); - z-index: $drop-zone-area-inner-z-index; + background-color: rgba(255, 255, 255, 0.7); + z-index: var(--ds-drop-zone-area-inner-z-index); top: 0; left: 0; } .ds-document-drop-zone-inner-content { - border: 4px dashed map-get($theme-colors, primary); - z-index: $drop-zone-area-inner-z-index; + border: 4px dashed var(--bs-primary); + z-index: var(--ds-drop-zone-area-inner-z-index); } .ds-document-drop-zone-inner-content p { - font-size: ($font-size-lg * 2.5); + font-size: calc(var(--bs-font-size-lg) * 2.5); } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.scss index ab63e324bd..1146e55750 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.scss @@ -1,3 +1,3 @@ span.text-contents{ - padding: $btn-padding-y 0; + padding: var(--bs-btn-padding-y) 0; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.scss index ab63e324bd..1146e55750 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.scss @@ -1,3 +1,3 @@ span.text-contents{ - padding: $btn-padding-y 0; + padding: var(--bs-btn-padding-y) 0; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.scss index 0fec18df1e..36e5fc2131 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component.scss @@ -5,16 +5,16 @@ } .cdk-drag { - margin-left: -(2.3 * $spacer); - margin-right: -(0.5 * $spacer); - padding-right: (0.5 * $spacer); + margin-left: calc(-2 * var(--bs-spacer)); + margin-right: calc(-0.5 * var(--bs-spacer)); + padding-right: calc(0.5 * var(--bs-spacer)); .drag-icon { visibility: hidden; - width: (2 * $spacer); - color: $gray-600; - margin: $btn-padding-y 0; - line-height: $btn-line-height; - text-indent: 0.5 * $spacer; + width: calc(2 * var(--bs-spacer)); + color: var(--bs-gray-600); + margin: var(--bs-btn-padding-y) 0; + line-height: var(--bs-btn-line-height); + text-indent: calc(0.5 * var(--bs-spacer)); cursor: grab; } @@ -41,7 +41,6 @@ padding: 0px; } - .cdk-drag-placeholder { opacity: 0; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.scss index e1ba2442e5..dee03647de 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.scss @@ -5,7 +5,7 @@ :host ::ng-deep .dropdown-menu { left: 0 !important; width: 100% !important; - max-height: $dropdown-menu-max-height; + max-height: var(--ds-dropdown-menu-max-height); overflow-y: auto !important; overflow-x: hidden; } @@ -14,8 +14,8 @@ :host ::ng-deep .dropdown-item:active, :host ::ng-deep .dropdown-item:focus, :host ::ng-deep .dropdown-item:hover { - color: $dropdown-link-hover-color !important; - background-color: $dropdown-link-hover-bg !important; + color: var(--bs-dropdown-link-hover-color) !important; + background-color: var(--bs-dropdown-link-hover-bg) !important; } div { @@ -23,5 +23,5 @@ div { } .lookup-item { - border-bottom: $dropdown-border-width solid $dropdown-border-color; + border-bottom: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color); } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.scss index d6ce88eed9..e4a5a64ea3 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.scss @@ -1,20 +1,20 @@ :host ::ng-deep .dropdown-menu { width: 100% !important; - max-height: $dropdown-menu-max-height; + max-height: var(--ds-dropdown-menu-max-height); overflow-y: auto !important; overflow-x: hidden; } :host ::ng-deep .dropdown-item { - border-bottom: $dropdown-border-width solid $dropdown-border-color; + border-bottom: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color); } :host ::ng-deep .dropdown-item.active, :host ::ng-deep .dropdown-item:active, :host ::ng-deep .dropdown-item:focus, :host ::ng-deep .dropdown-item:hover { - color: $dropdown-link-hover-color !important; - background-color: $dropdown-link-hover-bg !important; + color: var(--bs-dropdown-link-hover-color) !important; + background-color: var(--bs-dropdown-link-hover-bg) !important; } .treeview .modal-body { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.scss index 34c5a84220..021453c74b 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.scss @@ -2,25 +2,25 @@ .scrollable-menu { height: auto; - max-height: $dropdown-menu-max-height; + max-height: var(--ds-dropdown-menu-max-height); overflow-x: hidden; } .collection-item { - border-bottom: $dropdown-border-width solid $dropdown-border-color; + border-bottom: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color); } .scrollable-dropdown-loading { - background-color: map-get($theme-colors, primary); + background-color: var(--bs-primary); color: white; - height: $spacer * 2 !important; - line-height: $spacer * 2; + height: calc(var(--bs-spacer) * 2) !important; + line-height: calc(var(--bs-spacer) * 2); position: sticky; bottom: 0; } .scrollable-dropdown-menu { left: 0 !important; - margin-bottom: $spacer; + margin-bottom: var(--bs-spacer); z-index: 1000; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.scss index 032596207a..e87b8ffdb4 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.scss @@ -10,19 +10,19 @@ :host ::ng-deep .dropdown-menu { width: 100% !important; - max-height: $dropdown-menu-max-height; + max-height: var(--ds-dropdown-menu-max-height); overflow-y: scroll; overflow-x: hidden; left: 0 !important; - margin-top: $spacer !important; + margin-top: var(--bs-spacer) !important; } :host ::ng-deep .dropdown-item.active, :host ::ng-deep .dropdown-item:active, :host ::ng-deep .dropdown-item:focus, :host ::ng-deep .dropdown-item:hover { - color: $dropdown-link-hover-color !important; - background-color: $dropdown-link-hover-bg !important; + color: var(--bs-dropdown-link-hover-color) !important; + background-color: var(--bs-dropdown-link-hover-bg) !important; } .tag-input { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.scss index 4562a95080..f73f8b8c34 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.scss @@ -1,3 +1,3 @@ .position-absolute { - right: $spacer; -} \ No newline at end of file + right: var(--bs-spacer); +} diff --git a/src/app/shared/form/form.component.scss b/src/app/shared/form/form.component.scss index aafafc4a88..dc37d46515 100644 --- a/src/app/shared/form/form.component.scss +++ b/src/app/shared/form/form.component.scss @@ -5,7 +5,7 @@ } .ds-form-input-btn { - border: $input-btn-border-width solid $input-border-color; + border: var(--bs-input-btn-border-width) solid var(--bs-input-border-color); border-top-left-radius: 0; border-bottom-left-radius: 0; border-left: 0; @@ -41,9 +41,9 @@ button.ds-form-add-more:focus { /* add padding */ .left-addon input { - padding-left: $spacer * 2.25; + padding-left: calc(var(--bs-spacer) * 2.25); } .right-addon input { - padding-right: $spacer * 2.25; + padding-right: calc(var(--bs-spacer) * 2.25); } diff --git a/src/app/shared/input-suggestions/input-suggestions.component.scss b/src/app/shared/input-suggestions/input-suggestions.component.scss index 28cc7e8a49..fd7f26a29c 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.scss +++ b/src/app/shared/input-suggestions/input-suggestions.component.scss @@ -4,7 +4,7 @@ .dropdown-item { white-space: normal; word-break: break-word; - padding: $input-padding-y $input-padding-x; + padding: var(--bs-input-padding-y) var(--bs-input-padding-x); &:focus { outline: none; } diff --git a/src/app/shared/log-in/container/log-in-container.component.scss b/src/app/shared/log-in/container/log-in-container.component.scss index 0255b71dac..f3e0ab6cf4 100644 --- a/src/app/shared/log-in/container/log-in-container.component.scss +++ b/src/app/shared/log-in/container/log-in-container.component.scss @@ -1,18 +1,18 @@ :host ::ng-deep .card { - margin-bottom: $submission-sections-margin-bottom; + margin-bottom: var(--ds-submission-sections-margin-bottom); overflow: unset; } .section-focus { - border-radius: $border-radius; - box-shadow: $btn-focus-box-shadow; + border-radius: var(--bs-border-radius); + box-shadow: var(--bs-btn-focus-box-shadow); } // TODO to remove the following when upgrading @ng-bootstrap :host ::ng-deep .card:first-of-type { - border-bottom: $card-border-width solid $card-border-color !important; - border-bottom-left-radius: $card-border-radius !important; - border-bottom-right-radius: $card-border-radius !important; + border-bottom: var(--bs-card-border-width) solid var(--bs-card-border-color) !important; + border-bottom-left-radius: var(--bs-card-border-radius) !important; + border-bottom-right-radius: var(--bs-card-border-radius) !important; } :host ::ng-deep .card-header button { diff --git a/src/app/shared/metadata-representation/metadata-representation-loader.component.ts b/src/app/shared/metadata-representation/metadata-representation-loader.component.ts index fd094f7797..40fae2e5a6 100644 --- a/src/app/shared/metadata-representation/metadata-representation-loader.component.ts +++ b/src/app/shared/metadata-representation/metadata-representation-loader.component.ts @@ -6,6 +6,7 @@ import { GenericConstructor } from '../../core/shared/generic-constructor'; import { MetadataRepresentationListElementComponent } from '../object-list/metadata-representation-list-element/metadata-representation-list-element.component'; import { MetadataRepresentationDirective } from './metadata-representation.directive'; import { hasValue } from '../empty.util'; +import { ThemeService } from '../theme-support/theme.service'; @Component({ selector: 'ds-metadata-representation-loader', @@ -42,7 +43,10 @@ export class MetadataRepresentationLoaderComponent implements OnInit { */ @ViewChild(MetadataRepresentationDirective, {static: true}) mdRepDirective: MetadataRepresentationDirective; - constructor(private componentFactoryResolver: ComponentFactoryResolver) { + constructor( + private componentFactoryResolver: ComponentFactoryResolver, + private themeService: ThemeService + ) { } /** @@ -64,6 +68,6 @@ export class MetadataRepresentationLoaderComponent implements OnInit { * @returns {string} */ private getComponent(): GenericConstructor { - return getMetadataRepresentationComponent(this.mdRepresentation.itemType, this.mdRepresentation.representationType, this.context); + return getMetadataRepresentationComponent(this.mdRepresentation.itemType, this.mdRepresentation.representationType, this.context, this.themeService.getThemeName()); } } diff --git a/src/app/shared/metadata-representation/metadata-representation.decorator.ts b/src/app/shared/metadata-representation/metadata-representation.decorator.ts index a74c75385d..30bb507b49 100644 --- a/src/app/shared/metadata-representation/metadata-representation.decorator.ts +++ b/src/app/shared/metadata-representation/metadata-representation.decorator.ts @@ -6,15 +6,17 @@ export const map = new Map(); export const DEFAULT_ENTITY_TYPE = 'Publication'; export const DEFAULT_REPRESENTATION_TYPE = MetadataRepresentationType.PlainText; -export const DEFAULT_CONTEXT = Context.Undefined; +export const DEFAULT_CONTEXT = Context.Any; +export const DEFAULT_THEME = '*'; /** * Decorator function to store metadata representation mapping * @param entityType The entity type the component represents * @param mdRepresentationType The metadata representation type the component represents * @param context The optional context the component represents + * @param theme The optional theme for the component */ -export function metadataRepresentationComponent(entityType: string, mdRepresentationType: MetadataRepresentationType, context: Context = DEFAULT_CONTEXT) { +export function metadataRepresentationComponent(entityType: string, mdRepresentationType: MetadataRepresentationType, context: Context = DEFAULT_CONTEXT, theme = DEFAULT_THEME) { return function decorator(component: any) { if (hasNoValue(map.get(entityType))) { map.set(entityType, new Map()); @@ -23,10 +25,14 @@ export function metadataRepresentationComponent(entityType: string, mdRepresenta map.get(entityType).set(mdRepresentationType, new Map()); } - if (hasValue(map.get(entityType).get(mdRepresentationType).get(context))) { + if (hasNoValue(map.get(entityType).get(mdRepresentationType).get(context))) { + map.get(entityType).get(mdRepresentationType).set(context, new Map()); + } + + if (hasValue(map.get(entityType).get(mdRepresentationType).get(context).get(theme))) { throw new Error(`There can't be more than one component to render Entity of type "${entityType}" in MetadataRepresentation "${mdRepresentationType}" with context "${context}"`); } - map.get(entityType).get(mdRepresentationType).set(context, component); + map.get(entityType).get(mdRepresentationType).get(context).set(theme, component); }; } @@ -35,22 +41,32 @@ export function metadataRepresentationComponent(entityType: string, mdRepresenta * @param entityType The entity type to match * @param mdRepresentationType The metadata representation to match * @param context The context to match + * @param theme the theme to match */ -export function getMetadataRepresentationComponent(entityType: string, mdRepresentationType: MetadataRepresentationType, context: Context = DEFAULT_CONTEXT) { +export function getMetadataRepresentationComponent(entityType: string, mdRepresentationType: MetadataRepresentationType, context: Context = DEFAULT_CONTEXT, theme = DEFAULT_THEME) { const mapForEntity = map.get(entityType); if (hasValue(mapForEntity)) { const entityAndMDRepMap = mapForEntity.get(mdRepresentationType); if (hasValue(entityAndMDRepMap)) { - if (hasValue(entityAndMDRepMap.get(context))) { - return entityAndMDRepMap.get(context); + const contextMap = entityAndMDRepMap.get(context); + if (hasValue(contextMap)) { + if (hasValue(contextMap.get(theme))) { + return contextMap.get(theme); + } + if (hasValue(contextMap.get(DEFAULT_THEME))) { + return contextMap.get(DEFAULT_THEME); + } } - if (hasValue(entityAndMDRepMap.get(DEFAULT_CONTEXT))) { - return entityAndMDRepMap.get(DEFAULT_CONTEXT); + if (hasValue(entityAndMDRepMap.get(DEFAULT_CONTEXT)) && + hasValue(entityAndMDRepMap.get(DEFAULT_CONTEXT).get(DEFAULT_THEME))) { + return entityAndMDRepMap.get(DEFAULT_CONTEXT).get(DEFAULT_THEME); } } - if (hasValue(mapForEntity.get(DEFAULT_REPRESENTATION_TYPE))) { - return mapForEntity.get(DEFAULT_REPRESENTATION_TYPE).get(DEFAULT_CONTEXT); + if (hasValue(mapForEntity.get(DEFAULT_REPRESENTATION_TYPE)) && + hasValue(mapForEntity.get(DEFAULT_REPRESENTATION_TYPE).get(DEFAULT_CONTEXT)) && + hasValue(mapForEntity.get(DEFAULT_REPRESENTATION_TYPE).get(DEFAULT_CONTEXT).get(DEFAULT_THEME))) { + return mapForEntity.get(DEFAULT_REPRESENTATION_TYPE).get(DEFAULT_CONTEXT).get(DEFAULT_THEME); } } - return map.get(DEFAULT_ENTITY_TYPE).get(DEFAULT_REPRESENTATION_TYPE).get(DEFAULT_CONTEXT); + return map.get(DEFAULT_ENTITY_TYPE).get(DEFAULT_REPRESENTATION_TYPE).get(DEFAULT_CONTEXT).get(DEFAULT_THEME); } diff --git a/src/app/shared/mocks/http-xsrf-token-extractor.mock.ts b/src/app/shared/mocks/http-xsrf-token-extractor.mock.ts new file mode 100644 index 0000000000..78766a0b31 --- /dev/null +++ b/src/app/shared/mocks/http-xsrf-token-extractor.mock.ts @@ -0,0 +1,12 @@ +import { HttpXsrfTokenExtractor } from '@angular/common/http'; + +/** + * A Mock TokenExtractor which just returns whatever token it is initialized with. + * This mock object is injected into our XsrfInterceptor, so that it always finds + * the same fake XSRF token. + */ +export class HttpXsrfTokenExtractorMock extends HttpXsrfTokenExtractor { + constructor(private token: string | null) { super(); } + + getToken(): string | null { return this.token; } +} diff --git a/src/app/shared/mocks/submission.mock.ts b/src/app/shared/mocks/submission.mock.ts index 036df92d2e..16cc9b6262 100644 --- a/src/app/shared/mocks/submission.mock.ts +++ b/src/app/shared/mocks/submission.mock.ts @@ -1284,27 +1284,23 @@ export const mockUploadConfigResponse = { accessConditionOptions: [ { name: 'openaccess', - groupUUID: '123456-g', hasStartDate: false, hasEndDate: false }, { name: 'lease', - groupUUID: '123456-g', hasStartDate: false, hasEndDate: true, maxEndDate: '2019-07-12T14:40:06.308+0000' }, { name: 'embargo', - groupUUID: '123456-g', hasStartDate: true, hasEndDate: false, maxStartDate: '2022-01-12T14:40:06.308+0000' }, { name: 'administrator', - groupUUID: '0f2773dd-1741-475f-80e7-ccdef153d655', hasStartDate: false, hasEndDate: false } @@ -1323,35 +1319,6 @@ export const mockUploadConfigResponse = { // Clone the object and change one property export const mockUploadConfigResponseNotRequired = Object.assign({}, mockUploadConfigResponse, { required: false }); -export const mockAccessConditionOptions = [ - { - name: 'openaccess', - groupUUID: '123456-g', - hasStartDate: false, - hasEndDate: false - }, - { - name: 'lease', - groupUUID: '123456-g', - hasStartDate: false, - hasEndDate: true, - maxEndDate: '2019-07-12T14:40:06.308+0000' - }, - { - name: 'embargo', - groupUUID: '123456-g', - hasStartDate: true, - hasEndDate: false, - maxStartDate: '2022-01-12T14:40:06.308+0000' - }, - { - name: 'administrator', - groupUUID: '0f2773dd-1741-475f-80e7-ccdef153d655', - hasStartDate: false, - hasEndDate: false - } -]; - export const mockGroup = Object.assign(new Group(), { handle: null, permanent: true, @@ -1478,17 +1445,6 @@ export const mockFileFormData = { otherInformation: null } ], - groupUUID: [ - { - value: '123456-g', - language: null, - authority: null, - display: '123456-g', - confidence: -1, - place: 0, - otherInformation: null - } - ] } , { @@ -1522,17 +1478,6 @@ export const mockFileFormData = { otherInformation: null } ], - groupUUID: [ - { - value: '123456-g', - language: null, - authority: null, - display: '123456-g', - confidence: -1, - place: 0, - otherInformation: null - } - ] } , { @@ -1566,17 +1511,6 @@ export const mockFileFormData = { otherInformation: null } ], - groupUUID: [ - { - value: '123456-g', - language: null, - authority: null, - display: '123456-g', - confidence: -1, - place: 0, - otherInformation: null - } - ] } ] }; diff --git a/src/app/shared/mocks/theme-service.mock.ts b/src/app/shared/mocks/theme-service.mock.ts new file mode 100644 index 0000000000..3594270807 --- /dev/null +++ b/src/app/shared/mocks/theme-service.mock.ts @@ -0,0 +1,9 @@ +import { ThemeService } from '../theme-support/theme.service'; +import { of as observableOf } from 'rxjs'; + +export function getMockThemeService(themeName = 'base'): ThemeService { + return jasmine.createSpyObj('themeService', { + getThemeName: themeName, + getThemeName$: observableOf(themeName) + }); +} diff --git a/src/app/shared/mydspace-actions/claimed-task/abstract/claimed-task-actions-abstract.component.ts b/src/app/shared/mydspace-actions/claimed-task/abstract/claimed-task-actions-abstract.component.ts index da03fbc3ad..fb23b4feb1 100644 --- a/src/app/shared/mydspace-actions/claimed-task/abstract/claimed-task-actions-abstract.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/abstract/claimed-task-actions-abstract.component.ts @@ -1,8 +1,20 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component, Injector, OnDestroy } from '@angular/core'; import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; -import { BehaviorSubject } from 'rxjs'; import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; -import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response'; +import { DSpaceObject} from '../../../../core/shared/dspace-object.model'; +import { Router} from '@angular/router'; +import { NotificationsService} from '../../../notifications/notifications.service'; +import { TranslateService} from '@ngx-translate/core'; +import { SearchService} from '../../../../core/shared/search/search.service'; +import { RequestService} from '../../../../core/data/request.service'; +import { Observable} from 'rxjs'; +import { RemoteData} from '../../../../core/data/remote-data'; +import { WorkflowItem} from '../../../../core/submission/models/workflowitem.model'; +import { switchMap, take } from 'rxjs/operators'; +import { CLAIMED_TASK } from '../../../../core/tasks/models/claimed-task-object.resource-type'; +import { getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators'; +import { Item } from '../../../../core/shared/item.model'; +import { MyDSpaceReloadableActionsComponent } from '../../mydspace-reloadable-actions'; /** * Abstract component for rendering a claimed task's action @@ -12,31 +24,39 @@ import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task- * - Optionally overwrite createBody if the request body requires more than just the option */ @Component({ - selector: 'ds-calim-task-action-abstract', + selector: 'ds-claimed-task-action-abstract', template: '' }) -export abstract class ClaimedTaskActionsAbstractComponent { +export abstract class ClaimedTaskActionsAbstractComponent extends MyDSpaceReloadableActionsComponent implements OnDestroy { + /** * The workflow task option the child component represents */ abstract option: string; - /** - * The Claimed Task to display an action for - */ - @Input() object: ClaimedTask; + object: ClaimedTask; /** - * Emits the success or failure of a processed action + * Anchor used to reload the pool task. */ - @Output() processCompleted: EventEmitter = new EventEmitter(); + itemUuid: string; + + subs = []; + + protected constructor(protected injector: Injector, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected searchService: SearchService, + protected requestService: RequestService) { + super(CLAIMED_TASK, injector, router, notificationsService, translate, searchService, requestService); + } /** - * A boolean representing if the operation is pending + * Submit the action on the claimed object. */ - processing$ = new BehaviorSubject(false); - - constructor(protected claimedTaskService: ClaimedTaskDataService) { + submitTask() { + this.subs.push(this.startActionExecution().pipe(take(1)).subscribe()); } /** @@ -49,17 +69,36 @@ export abstract class ClaimedTaskActionsAbstractComponent { }; } - /** - * Submit the task for this option - * While the task is submitting, processing$ is set to true and processCompleted emits the response's status when - * completed - */ - submitTask() { - this.processing$.next(true); - this.claimedTaskService.submitTask(this.object.id, this.createbody()) - .subscribe((res: ProcessTaskResponse) => { - this.processing$.next(false); - this.processCompleted.emit(res.hasSucceeded); - }); + reloadObjectExecution(): Observable | DSpaceObject> { + return this.objectDataService.findByItem(this.itemUuid as string); } + + actionExecution(): Observable { + return this.objectDataService.submitTask(this.object.id, this.createbody()); + } + + initObjects(object: ClaimedTask) { + this.object = object; + } + + /** + * Retrieve the itemUuid. + */ + initReloadAnchor() { + if (!(this.object as any).workflowitem) { + return; + } + this.subs.push(this.object.workflowitem.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((workflowItem: WorkflowItem) => workflowItem.item.pipe(getFirstSucceededRemoteDataPayload()) + )) + .subscribe((item: Item) => { + this.itemUuid = item.uuid; + })); + } + + ngOnDestroy() { + this.subs.forEach((sub) => sub.unsubscribe()); + } + } diff --git a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts index 622c0bf18d..a785e3b26d 100644 --- a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts @@ -1,18 +1,33 @@ -import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { of as observableOf } from 'rxjs'; +import { of, of as observableOf } from 'rxjs'; import { ClaimedTaskActionsApproveComponent } from './claimed-task-actions-approve.component'; import { TranslateLoaderMock } from '../../../mocks/translate-loader.mock'; import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response'; +import { getMockSearchService } from '../../../mocks/search-service.mock'; +import { getMockRequestService } from '../../../mocks/request.service.mock'; +import { PoolTaskDataService } from '../../../../core/tasks/pool-task-data.service'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../testing/notifications-service.stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../testing/router.stub'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; let component: ClaimedTaskActionsApproveComponent; let fixture: ComponentFixture; +const searchService = getMockSearchService(); + +const requestService = getMockRequestService(); + +let mockPoolTaskDataService: PoolTaskDataService; + describe('ClaimedTaskActionsApproveComponent', () => { const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' }); const claimedTaskService = jasmine.createSpyObj('claimedTaskService', { @@ -20,6 +35,7 @@ describe('ClaimedTaskActionsApproveComponent', () => { }); beforeEach(waitForAsync(() => { + mockPoolTaskDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot({ @@ -30,7 +46,13 @@ describe('ClaimedTaskActionsApproveComponent', () => { }) ], providers: [ - { provide: ClaimedTaskDataService, useValue: claimedTaskService } + { provide: ClaimedTaskDataService, useValue: claimedTaskService }, + { provide: Injector, useValue: {} }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: Router, useValue: new RouterStub() }, + { provide: SearchService, useValue: searchService }, + { provide: RequestService, useValue: requestService }, + { provide: PoolTaskDataService, useValue: mockPoolTaskDataService }, ], declarations: [ClaimedTaskActionsApproveComponent], schemas: [NO_ERRORS_SCHEMA] @@ -43,6 +65,7 @@ describe('ClaimedTaskActionsApproveComponent', () => { fixture = TestBed.createComponent(ClaimedTaskActionsApproveComponent); component = fixture.componentInstance; component.object = object; + spyOn(component, 'initReloadAnchor').and.returnValue(undefined); fixture.detectChanges(); }); @@ -66,6 +89,7 @@ describe('ClaimedTaskActionsApproveComponent', () => { beforeEach(() => { spyOn(component.processCompleted, 'emit'); + spyOn(component, 'startActionExecution').and.returnValue(of(null)); expectedBody = { [component.option]: 'true' @@ -75,12 +99,34 @@ describe('ClaimedTaskActionsApproveComponent', () => { fixture.detectChanges(); }); - it('should call claimedTaskService\'s submitTask with the expected body', () => { - expect(claimedTaskService.submitTask).toHaveBeenCalledWith(object.id, expectedBody); + it('should start the action execution', () => { + expect(component.startActionExecution).toHaveBeenCalled(); + }); + }); + + describe('actionExecution', () => { + + it('should call claimedTaskService\'s submitTask', (done) => { + + const expectedBody = { + [component.option]: 'true' + }; + + component.actionExecution().subscribe(() => { + expect(claimedTaskService.submitTask).toHaveBeenCalledWith(object.id, expectedBody); + done(); + }); }); - it('should emit a successful processCompleted event', () => { - expect(component.processCompleted.emit).toHaveBeenCalledWith(true); + }); + + describe('reloadObjectExecution', () => { + + it('should return the component object itself', (done) => { + component.reloadObjectExecution().subscribe((val) => { + expect(val).toEqual(component.object); + done(); + }); }); }); diff --git a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts index 8f51ac393c..d73da460b7 100644 --- a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts @@ -1,7 +1,16 @@ -import { Component } from '@angular/core'; +import { Component, Injector } from '@angular/core'; import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component'; import { rendersWorkflowTaskOption } from '../switcher/claimed-task-actions-decorator'; -import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { Router } from '@angular/router'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { ClaimedApprovedTaskSearchResult } from '../../../object-collection/shared/claimed-approved-task-search-result.model'; +import { of } from 'rxjs/internal/observable/of'; export const WORKFLOW_TASK_OPTION_APPROVE = 'submit_approve'; @@ -20,7 +29,24 @@ export class ClaimedTaskActionsApproveComponent extends ClaimedTaskActionsAbstra */ option = WORKFLOW_TASK_OPTION_APPROVE; - constructor(protected claimedTaskService: ClaimedTaskDataService) { - super(claimedTaskService); + constructor(protected injector: Injector, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected searchService: SearchService, + protected requestService: RequestService) { + super(injector, router, notificationsService, translate, searchService, requestService); } + + reloadObjectExecution(): Observable | DSpaceObject> { + return of(this.object); + } + + convertReloadedObject(dso: DSpaceObject): DSpaceObject { + const reloadedObject = Object.assign(new ClaimedApprovedTaskSearchResult(), dso, { + indexableObject: dso + }); + return reloadedObject; + } + } diff --git a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html index aa569bbfc8..6a39fd44ca 100644 --- a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html +++ b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html @@ -3,11 +3,11 @@ + (processCompleted)="this.processCompleted.emit($event)"> + (processCompleted)="this.processCompleted.emit($event)"> diff --git a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts index c82154af09..b610232279 100644 --- a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts @@ -2,7 +2,7 @@ import { Component, Injector, Input, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { Observable } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; +import { filter, map, take } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service'; @@ -87,7 +87,8 @@ export class ClaimedTaskActionsComponent extends MyDSpaceActionsComponent>).pipe( filter((rd: RemoteData) => ((!rd.isRequestPending) && isNotUndefined(rd.payload))), - map((rd: RemoteData) => rd.payload)); + map((rd: RemoteData) => rd.payload), + take(1)); } /** diff --git a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.html b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.html index 4a42378f7e..3f3670e17f 100644 --- a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.html +++ b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.html @@ -1,7 +1,7 @@ {{'submission.workflow.tasks.claimed.edit' | translate}} diff --git a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.spec.ts index cdbb699ad0..1a958f5a49 100644 --- a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.spec.ts @@ -1,5 +1,5 @@ -import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; @@ -7,13 +7,28 @@ import { ClaimedTaskActionsEditMetadataComponent } from './claimed-task-actions- import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; import { TranslateLoaderMock } from '../../../testing/translate-loader.mock'; +import { getMockSearchService } from '../../../mocks/search-service.mock'; +import { getMockRequestService } from '../../../mocks/request.service.mock'; +import { PoolTaskDataService } from '../../../../core/tasks/pool-task-data.service'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../testing/notifications-service.stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../testing/router.stub'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; let component: ClaimedTaskActionsEditMetadataComponent; let fixture: ComponentFixture; +const searchService = getMockSearchService(); + +const requestService = getMockRequestService(); + +let mockPoolTaskDataService: PoolTaskDataService; + describe('ClaimedTaskActionsEditMetadataComponent', () => { const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' }); - + mockPoolTaskDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ @@ -25,7 +40,13 @@ describe('ClaimedTaskActionsEditMetadataComponent', () => { }) ], providers: [ - { provide: ClaimedTaskDataService, useValue: {} } + { provide: ClaimedTaskDataService, useValue: {} }, + { provide: Injector, useValue: {} }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: Router, useValue: new RouterStub() }, + { provide: SearchService, useValue: searchService }, + { provide: RequestService, useValue: requestService }, + { provide: PoolTaskDataService, useValue: mockPoolTaskDataService }, ], declarations: [ClaimedTaskActionsEditMetadataComponent], schemas: [NO_ERRORS_SCHEMA] @@ -38,6 +59,7 @@ describe('ClaimedTaskActionsEditMetadataComponent', () => { fixture = TestBed.createComponent(ClaimedTaskActionsEditMetadataComponent); component = fixture.componentInstance; component.object = object; + spyOn(component, 'initReloadAnchor').and.returnValue(undefined); fixture.detectChanges(); }); diff --git a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.ts b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.ts index c0ce9cd4e5..7da189dddd 100644 --- a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.ts @@ -1,7 +1,11 @@ -import { Component } from '@angular/core'; +import { Component, Injector } from '@angular/core'; import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component'; import { rendersWorkflowTaskOption } from '../switcher/claimed-task-actions-decorator'; -import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; +import { Router } from '@angular/router'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; export const WORKFLOW_TASK_OPTION_EDIT_METADATA = 'submit_edit_metadata'; @@ -20,7 +24,12 @@ export class ClaimedTaskActionsEditMetadataComponent extends ClaimedTaskActionsA */ option = WORKFLOW_TASK_OPTION_EDIT_METADATA; - constructor(protected claimedTaskService: ClaimedTaskDataService) { - super(claimedTaskService); + constructor(protected injector: Injector, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected searchService: SearchService, + protected requestService: RequestService) { + super(injector, router, notificationsService, translate, searchService, requestService); } } diff --git a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts index 7f5dcc04d3..5d3a2ec127 100644 --- a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; @@ -9,22 +9,39 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { ClaimedTaskActionsRejectComponent } from './claimed-task-actions-reject.component'; import { TranslateLoaderMock } from '../../../mocks/translate-loader.mock'; import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; -import { of as observableOf } from 'rxjs'; import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response'; import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../testing/notifications-service.stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../testing/router.stub'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { PoolTaskDataService } from '../../../../core/tasks/pool-task-data.service'; +import { getMockSearchService } from '../../../mocks/search-service.mock'; +import { getMockRequestService } from '../../../mocks/request.service.mock'; +import { of } from 'rxjs'; +import { ClaimedDeclinedTaskSearchResult } from '../../../object-collection/shared/claimed-declined-task-search-result.model'; let component: ClaimedTaskActionsRejectComponent; let fixture: ComponentFixture; let formBuilder: FormBuilder; let modalService: NgbModal; +const searchService = getMockSearchService(); + +const requestService = getMockRequestService(); + +let mockPoolTaskDataService: PoolTaskDataService; + describe('ClaimedTaskActionsRejectComponent', () => { const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' }); const claimedTaskService = jasmine.createSpyObj('claimedTaskService', { - submitTask: observableOf(new ProcessTaskResponse(true)) + submitTask: of(new ProcessTaskResponse(true)) }); beforeEach(waitForAsync(() => { + mockPoolTaskDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); TestBed.configureTestingModule({ imports: [ NgbModule, @@ -39,6 +56,12 @@ describe('ClaimedTaskActionsRejectComponent', () => { declarations: [ClaimedTaskActionsRejectComponent], providers: [ { provide: ClaimedTaskDataService, useValue: claimedTaskService }, + { provide: Injector, useValue: {} }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: Router, useValue: new RouterStub() }, + { provide: SearchService, useValue: searchService }, + { provide: RequestService, useValue: requestService }, + { provide: PoolTaskDataService, useValue: mockPoolTaskDataService }, FormBuilder, NgbModal ], @@ -55,6 +78,7 @@ describe('ClaimedTaskActionsRejectComponent', () => { modalService = TestBed.inject(NgbModal); component.object = object; component.modalRef = modalService.open('ok'); + spyOn(component, 'initReloadAnchor').and.returnValue(undefined); fixture.detectChanges(); }); @@ -96,6 +120,7 @@ describe('ClaimedTaskActionsRejectComponent', () => { beforeEach(() => { spyOn(component.processCompleted, 'emit'); + spyOn(component, 'startActionExecution').and.returnValue(of(null)); expectedBody = { [component.option]: 'true', @@ -113,12 +138,48 @@ describe('ClaimedTaskActionsRejectComponent', () => { fixture.detectChanges(); }); - it('should call claimedTaskService\'s submitTask with the expected body', () => { - expect(claimedTaskService.submitTask).toHaveBeenCalledWith(object.id, expectedBody); + it('should start the action execution', () => { + expect(component.startActionExecution).toHaveBeenCalled(); }); - it('should emit a successful processCompleted event', () => { - expect(component.processCompleted.emit).toHaveBeenCalledWith(true); + }); + + describe('actionExecution', () => { + + let expectedBody; + + beforeEach(() => { + spyOn((component.rejectForm as any), 'get').and.returnValue({value: 'required'}); + expectedBody = { + [component.option]: 'true', + reason: 'required' + }; + }); + + it('should call claimedTaskService\'s submitTask with the proper reason', (done) => { + component.actionExecution().subscribe(() => { + expect(claimedTaskService.submitTask).toHaveBeenCalledWith(object.id, expectedBody); + done(); + }); }); }); + + describe('reloadObjectExecution', () => { + + it('should return the component object itself', (done) => { + component.reloadObjectExecution().subscribe((val) => { + expect(val).toEqual(component.object); + done(); + }); + }); + }); + + describe('convertReloadedObject', () => { + + it('should return a ClaimedDeclinedTaskSearchResult instance', () => { + const reloadedObject = component.convertReloadedObject(component.object); + expect(reloadedObject instanceof ClaimedDeclinedTaskSearchResult).toEqual(true); + }); + }); + }); diff --git a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts index 46d40cbb64..911bd385f4 100644 --- a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts @@ -1,10 +1,19 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, Injector, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component'; -import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; import { rendersWorkflowTaskOption } from '../switcher/claimed-task-actions-decorator'; +import { Router } from '@angular/router'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { of } from 'rxjs/internal/observable/of'; +import { ClaimedDeclinedTaskSearchResult } from '../../../object-collection/shared/claimed-declined-task-search-result.model'; export const WORKFLOW_TASK_OPTION_REJECT = 'submit_reject'; @@ -33,17 +42,15 @@ export class ClaimedTaskActionsRejectComponent extends ClaimedTaskActionsAbstrac */ public modalRef: NgbModalRef; - /** - * Initialize instance variables - * - * @param {FormBuilder} formBuilder - * @param {NgbModal} modalService - * @param claimedTaskService - */ - constructor(protected claimedTaskService: ClaimedTaskDataService, + constructor(protected injector: Injector, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected searchService: SearchService, + protected requestService: RequestService, private formBuilder: FormBuilder, private modalService: NgbModal) { - super(claimedTaskService); + super(injector, router, notificationsService, translate, searchService, requestService); } /** @@ -55,6 +62,14 @@ export class ClaimedTaskActionsRejectComponent extends ClaimedTaskActionsAbstrac }); } + /** + * Submit a reject option for the task + */ + submitTask() { + this.modalRef.close('Send Button'); + super.submitTask(); + } + /** * Create the request body for rejecting a workflow task * Includes the reason from the form @@ -64,14 +79,6 @@ export class ClaimedTaskActionsRejectComponent extends ClaimedTaskActionsAbstrac return Object.assign(super.createbody(), { reason }); } - /** - * Submit a reject option for the task - */ - submitTask() { - this.modalRef.close('Send Button'); - super.submitTask(); - } - /** * Open modal * @@ -81,4 +88,15 @@ export class ClaimedTaskActionsRejectComponent extends ClaimedTaskActionsAbstrac this.rejectForm.reset(); this.modalRef = this.modalService.open(content); } + + reloadObjectExecution(): Observable | DSpaceObject> { + return of(this.object); + } + + convertReloadedObject(dso: DSpaceObject): DSpaceObject { + const reloadedObject = Object.assign(new ClaimedDeclinedTaskSearchResult(), dso, { + indexableObject: dso + }); + return reloadedObject; + } } diff --git a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts index 76155e1903..e53daccf0d 100644 --- a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts @@ -1,25 +1,41 @@ -import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { ClaimedTaskActionsReturnToPoolComponent } from './claimed-task-actions-return-to-pool.component'; import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; -import { of as observableOf } from 'rxjs'; import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response'; import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; import { TranslateLoaderMock } from '../../../mocks/translate-loader.mock'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../testing/notifications-service.stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../testing/router.stub'; +import { PoolTaskDataService } from '../../../../core/tasks/pool-task-data.service'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { getMockSearchService } from '../../../mocks/search-service.mock'; +import { getMockRequestService } from '../../../mocks/request.service.mock'; +import { of } from 'rxjs'; let component: ClaimedTaskActionsReturnToPoolComponent; let fixture: ComponentFixture; +const searchService = getMockSearchService(); + +const requestService = getMockRequestService(); + +let mockPoolTaskDataService: PoolTaskDataService; + describe('ClaimedTaskActionsReturnToPoolComponent', () => { const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' }); const claimedTaskService = jasmine.createSpyObj('claimedTaskService', { - returnToPoolTask: observableOf(new ProcessTaskResponse(true)) + returnToPoolTask: of(new ProcessTaskResponse(true)) }); beforeEach(waitForAsync(() => { + mockPoolTaskDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot({ @@ -30,7 +46,13 @@ describe('ClaimedTaskActionsReturnToPoolComponent', () => { }) ], providers: [ - { provide: ClaimedTaskDataService, useValue: claimedTaskService } + { provide: ClaimedTaskDataService, useValue: claimedTaskService }, + { provide: Injector, useValue: {} }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: Router, useValue: new RouterStub() }, + { provide: SearchService, useValue: searchService }, + { provide: RequestService, useValue: requestService }, + { provide: PoolTaskDataService, useValue: mockPoolTaskDataService }, ], declarations: [ClaimedTaskActionsReturnToPoolComponent], schemas: [NO_ERRORS_SCHEMA] @@ -39,12 +61,13 @@ describe('ClaimedTaskActionsReturnToPoolComponent', () => { }).compileComponents(); })); - beforeEach(() => { + beforeEach(fakeAsync(() => { fixture = TestBed.createComponent(ClaimedTaskActionsReturnToPoolComponent); component = fixture.componentInstance; component.object = object; + spyOn(component, 'initReloadAnchor').and.returnValue(undefined); fixture.detectChanges(); - }); + })); it('should display return to pool button', () => { const btn = fixture.debugElement.query(By.css('.btn-secondary')); @@ -61,11 +84,9 @@ describe('ClaimedTaskActionsReturnToPoolComponent', () => { expect(span).toBeDefined(); }); - describe('submitTask', () => { + describe('actionExecution', () => { beforeEach(() => { - spyOn(component.processCompleted, 'emit'); - - component.submitTask(); + component.actionExecution().subscribe(); fixture.detectChanges(); }); @@ -73,8 +94,19 @@ describe('ClaimedTaskActionsReturnToPoolComponent', () => { expect(claimedTaskService.returnToPoolTask).toHaveBeenCalledWith(object.id); }); - it('should emit a successful processCompleted event', () => { - expect(component.processCompleted.emit).toHaveBeenCalledWith(true); + }); + + describe('reloadObjectExecution', () => { + beforeEach(() => { + spyOn(mockPoolTaskDataService, 'findByItem').and.returnValue(of(null)); + + component.itemUuid = 'uuid'; + component.reloadObjectExecution().subscribe(); + fixture.detectChanges(); + }); + + it('should call poolTaskDataService findItem with itemUuid', () => { + expect(mockPoolTaskDataService.findByItem).toHaveBeenCalledWith('uuid'); }); }); diff --git a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts index c53bf30fad..f4a79db888 100644 --- a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts @@ -1,8 +1,16 @@ -import { Component } from '@angular/core'; +import {Component, Injector} from '@angular/core'; import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component'; import { rendersWorkflowTaskOption } from '../switcher/claimed-task-actions-decorator'; -import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; -import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response'; +import { Observable } from 'rxjs'; +import { Router } from '@angular/router'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { PoolTaskDataService } from '../../../../core/tasks/pool-task-data.service'; +import { take } from 'rxjs/operators'; export const WORKFLOW_TASK_OPTION_RETURN_TO_POOL = 'return_to_pool'; @@ -21,19 +29,22 @@ export class ClaimedTaskActionsReturnToPoolComponent extends ClaimedTaskActionsA */ option = WORKFLOW_TASK_OPTION_RETURN_TO_POOL; - constructor(protected claimedTaskService: ClaimedTaskDataService) { - super(claimedTaskService); + constructor(protected injector: Injector, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected searchService: SearchService, + protected requestService: RequestService, + private poolTaskService: PoolTaskDataService) { + super(injector, router, notificationsService, translate, searchService, requestService); } - /** - * Submit a return to pool option for the task - */ - submitTask() { - this.processing$.next(true); - this.claimedTaskService.returnToPoolTask(this.object.id) - .subscribe((res: ProcessTaskResponse) => { - this.processing$.next(false); - this.processCompleted.emit(res.hasSucceeded); - }); + reloadObjectExecution(): Observable | DSpaceObject> { + return this.poolTaskService.findByItem(this.itemUuid).pipe(take(1)); } + + actionExecution(): Observable { + return this.objectDataService.returnToPoolTask(this.object.id); + } + } diff --git a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.spec.ts index 331a8586b9..6de2056fe8 100644 --- a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.spec.ts @@ -1,15 +1,26 @@ import { ClaimedTaskActionsLoaderComponent } from './claimed-task-actions-loader.component'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { ChangeDetectionStrategy, ComponentFactoryResolver, NO_ERRORS_SCHEMA } from '@angular/core'; -import * as decorators from './claimed-task-actions-decorator'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; import { ClaimedTaskActionsDirective } from './claimed-task-actions.directive'; import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; import { TranslateModule } from '@ngx-translate/core'; import { ClaimedTaskActionsEditMetadataComponent } from '../edit-metadata/claimed-task-actions-edit-metadata.component'; import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; -import { spyOnExported } from '../../../testing/utils.test'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../testing/notifications-service.stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../testing/router.stub'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { PoolTaskDataService } from '../../../../core/tasks/pool-task-data.service'; +import { getMockSearchService } from '../../../mocks/search-service.mock'; +import { getMockRequestService } from '../../../mocks/request.service.mock'; -xdescribe('ClaimedTaskActionsLoaderComponent', () => { +const searchService = getMockSearchService(); + +const requestService = getMockRequestService(); + +describe('ClaimedTaskActionsLoaderComponent', () => { let comp: ClaimedTaskActionsLoaderComponent; let fixture: ComponentFixture; @@ -23,7 +34,12 @@ xdescribe('ClaimedTaskActionsLoaderComponent', () => { schemas: [NO_ERRORS_SCHEMA], providers: [ { provide: ClaimedTaskDataService, useValue: {} }, - ComponentFactoryResolver + { provide: Injector, useValue: {} }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: Router, useValue: new RouterStub() }, + { provide: SearchService, useValue: searchService }, + { provide: RequestService, useValue: requestService }, + { provide: PoolTaskDataService, useValue: {} } ] }).overrideComponent(ClaimedTaskActionsLoaderComponent, { set: { @@ -36,16 +52,16 @@ xdescribe('ClaimedTaskActionsLoaderComponent', () => { beforeEach(waitForAsync(() => { fixture = TestBed.createComponent(ClaimedTaskActionsLoaderComponent); comp = fixture.componentInstance; - comp.object = object; comp.option = option; - spyOnExported(decorators, 'getComponentByWorkflowTaskOption').and.returnValue(ClaimedTaskActionsEditMetadataComponent); + spyOn(comp, 'getComponentByWorkflowTaskOption').and.returnValue(ClaimedTaskActionsEditMetadataComponent); + fixture.detectChanges(); })); describe('When the component is rendered', () => { it('should call the getComponentByWorkflowTaskOption function with the right option', () => { - expect(decorators.getComponentByWorkflowTaskOption).toHaveBeenCalledWith(option); + expect(comp.getComponentByWorkflowTaskOption).toHaveBeenCalledWith(option); }); }); }); diff --git a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.ts b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.ts index b553eff206..68c597a41c 100644 --- a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.ts @@ -14,6 +14,7 @@ import { ClaimedTaskActionsDirective } from './claimed-task-actions.directive'; import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component'; import { hasValue } from '../../../empty.util'; import { Subscription } from 'rxjs'; +import { MyDSpaceActionsResult } from '../../mydspace-actions'; @Component({ selector: 'ds-claimed-task-actions-loader', @@ -38,7 +39,7 @@ export class ClaimedTaskActionsLoaderComponent implements OnInit, OnDestroy { /** * Emits the success or failure of a processed action */ - @Output() processCompleted: EventEmitter = new EventEmitter(); + @Output() processCompleted = new EventEmitter(); /** * Directive to determine where the dynamic child component is located @@ -58,7 +59,8 @@ export class ClaimedTaskActionsLoaderComponent implements OnInit, OnDestroy { * Fetch, create and initialize the relevant component */ ngOnInit(): void { - const comp = getComponentByWorkflowTaskOption(this.option); + + const comp = this.getComponentByWorkflowTaskOption(this.option); if (hasValue(comp)) { const componentFactory = this.componentFactoryResolver.resolveComponentFactory(comp); @@ -69,11 +71,15 @@ export class ClaimedTaskActionsLoaderComponent implements OnInit, OnDestroy { const componentInstance = (componentRef.instance as ClaimedTaskActionsAbstractComponent); componentInstance.object = this.object; if (hasValue(componentInstance.processCompleted)) { - this.subs.push(componentInstance.processCompleted.subscribe((success) => this.processCompleted.emit(success))); + this.subs.push(componentInstance.processCompleted.subscribe((result) => this.processCompleted.emit(result))); } } } + getComponentByWorkflowTaskOption(option: string) { + return getComponentByWorkflowTaskOption(option); + } + /** * Unsubscribe from open subscriptions */ diff --git a/src/app/shared/mydspace-actions/mydspace-actions.ts b/src/app/shared/mydspace-actions/mydspace-actions.ts index 550b437b0b..2ddc5990b4 100644 --- a/src/app/shared/mydspace-actions/mydspace-actions.ts +++ b/src/app/shared/mydspace-actions/mydspace-actions.ts @@ -1,5 +1,5 @@ import { Router } from '@angular/router'; -import { Component, Injector, Input } from '@angular/core'; +import { Component, EventEmitter, Injector, Input, Output } from '@angular/core'; import { take, tap } from 'rxjs/operators'; @@ -12,10 +12,15 @@ import { NotificationOptions } from '../notifications/models/notification-option import { NotificationsService } from '../notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; import { RequestService } from '../../core/data/request.service'; -import { Subscription } from 'rxjs'; +import { BehaviorSubject, Subscription } from 'rxjs'; import { SearchService } from '../../core/shared/search/search.service'; import { getFirstSucceededRemoteData } from '../../core/shared/operators'; +export interface MyDSpaceActionsResult { + result: boolean; + reloadedObject: DSpaceObject; +} + /** * Abstract class for all different representations of mydspace actions */ @@ -31,7 +36,18 @@ export abstract class MyDSpaceActionsComponent(); + + /** + * A boolean representing if an operation is pending + * @type {BehaviorSubject} + */ + public processing$ = new BehaviorSubject(false); + + /** + * Instance of DataService related to mydspace object */ protected objectDataService: TService; @@ -71,6 +87,7 @@ export abstract class MyDSpaceActionsComponent; + +let mockObject: PoolTask; +let notificationsServiceStub: NotificationsServiceStub; +let router: RouterStub; + +const searchService = getMockSearchService(); + +const requestService = getMockRequestService(); + +const item = Object.assign(new Item(), { + bundles: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rdItem = createSuccessfulRemoteDataObject(item); +const workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) }); +const rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem); +mockObject = Object.assign(new PoolTask(), { workflowitem: observableOf(rdWorkflowitem), id: '1234' }); + +describe('MyDSpaceReloadableActionsComponent', () => { + beforeEach(fakeAsync(() => { + mockDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); + mockClaimedTaskDataService = new ClaimedTaskDataService(null, null, null, null, null, null, null, null); + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [PoolTaskActionsComponent], + providers: [ + { provide: Injector, useValue: {} }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: Router, useValue: new RouterStub() }, + { provide: PoolTaskDataService, useValue: mockDataService }, + { provide: ClaimedTaskDataService, useValue: mockClaimedTaskDataService }, + { provide: SearchService, useValue: searchService }, + { provide: RequestService, useValue: requestService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(PoolTaskActionsComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PoolTaskActionsComponent); + component = fixture.componentInstance; + component.object = mockObject; + notificationsServiceStub = TestBed.get(NotificationsService); + router = TestBed.get(Router); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture = null; + component = null; + }); + + describe('on reload action init', () => { + + beforeEach(() => { + spyOn(component, 'initReloadAnchor').and.returnValue(null); + spyOn(component, 'initObjects'); + }); + + it('should call initReloadAnchor and initObjects on init', fakeAsync(() => { + component.ngOnInit(); + + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(component.initReloadAnchor).toHaveBeenCalled(); + expect(component.initObjects).toHaveBeenCalled(); + }); + + })); + + }); + + describe('on action execution fail', () => { + + let remoteClaimTaskErrorResponse; + + beforeEach(() => { + + mockDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); + + const poolTaskHref = 'poolTaskHref'; + remoteClaimTaskErrorResponse = new ProcessTaskResponse(false, null, null); + const remoteReloadedObjectResponse: any = createSuccessfulRemoteDataObject(new PoolTask()); + + spyOn(mockDataService, 'getPoolTaskEndpointById').and.returnValue(observableOf(poolTaskHref)); + spyOn(mockClaimedTaskDataService, 'findByItem').and.returnValue(observableOf(remoteReloadedObjectResponse)); + spyOn(mockClaimedTaskDataService, 'claimTask').and.returnValue(observableOf(remoteClaimTaskErrorResponse)); + spyOn(component, 'reloadObjectExecution').and.callThrough(); + spyOn(component.processCompleted, 'emit').and.callThrough(); + + (component as any).objectDataService = mockDataService; + }); + + it('should show error notification', (done) => { + + component.startActionExecution().subscribe( (result) => { + expect(notificationsServiceStub.error).toHaveBeenCalled(); + done(); + }); + }); + + it('should not call reloadObject', (done) => { + + component.startActionExecution().subscribe( (result) => { + expect(component.reloadObjectExecution).not.toHaveBeenCalled(); + done(); + }); + + }); + + it('should not emit processCompleted', (done) => { + + component.startActionExecution().subscribe( (result) => { + expect(component.processCompleted.emit).not.toHaveBeenCalled(); + done(); + }); + + }); + + }); + + describe('on action execution success', () => { + + beforeEach(() => { + + mockDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); + + const poolTaskHref = 'poolTaskHref'; + const remoteClaimTaskResponse: any = new ProcessTaskResponse(true, null, null); + const remoteReloadedObjectResponse: any = createSuccessfulRemoteDataObject(new PoolTask()); + + spyOn(mockDataService, 'getPoolTaskEndpointById').and.returnValue(observableOf(poolTaskHref)); + spyOn(mockClaimedTaskDataService, 'findByItem').and.returnValue(observableOf(remoteReloadedObjectResponse)); + spyOn(mockClaimedTaskDataService, 'claimTask').and.returnValue(observableOf(remoteClaimTaskResponse)); + spyOn(component, 'reloadObjectExecution').and.callThrough(); + spyOn(component, 'convertReloadedObject').and.callThrough(); + spyOn(component.processCompleted, 'emit').and.callThrough(); + + (component as any).objectDataService = mockDataService; + }); + + it('should reloadObject in case of action execution success', (done) => { + + component.startActionExecution().subscribe( (result) => { + expect(component.reloadObjectExecution).toHaveBeenCalled(); + done(); + }); + }); + + it('should convert the reloaded object', (done) => { + + component.startActionExecution().subscribe( (result) => { + expect(component.convertReloadedObject).toHaveBeenCalled(); + done(); + }); + }); + + it('should emit the reloaded object in case of success', (done) => { + + component.startActionExecution().subscribe( (result) => { + expect(component.processCompleted.emit).toHaveBeenCalledWith({result: true, reloadedObject: result as any}); + done(); + }); + }); + + }); + + describe('on action execution success but without a reloadedObject', () => { + + beforeEach(() => { + + mockDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); + + const poolTaskHref = 'poolTaskHref'; + const remoteClaimTaskResponse: any = new ProcessTaskResponse(true, null, null); + const remoteReloadedObjectResponse: any = createFailedRemoteDataObject(); + + spyOn(mockDataService, 'getPoolTaskEndpointById').and.returnValue(observableOf(poolTaskHref)); + spyOn(mockClaimedTaskDataService, 'findByItem').and.returnValue(observableOf(remoteReloadedObjectResponse)); + spyOn(mockClaimedTaskDataService, 'claimTask').and.returnValue(observableOf(remoteClaimTaskResponse)); + + spyOn(component, 'convertReloadedObject').and.returnValue(null); + spyOn(component, 'reload').and.returnValue(null); + + (component as any).objectDataService = mockDataService; + }); + + it('should call reload method', (done) => { + + component.startActionExecution().subscribe( (result) => { + expect(component.reload).toHaveBeenCalled(); + done(); + }); + }); + + }); + +}); diff --git a/src/app/shared/mydspace-actions/mydspace-reloadable-actions.ts b/src/app/shared/mydspace-actions/mydspace-reloadable-actions.ts new file mode 100644 index 0000000000..7043191915 --- /dev/null +++ b/src/app/shared/mydspace-actions/mydspace-reloadable-actions.ts @@ -0,0 +1,145 @@ +import { Router } from '@angular/router'; +import { Component, Injector, OnInit } from '@angular/core'; + +import { map, switchMap, take, tap } from 'rxjs/operators'; + +import { RemoteData } from '../../core/data/remote-data'; +import { DataService } from '../../core/data/data.service'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { ResourceType } from '../../core/shared/resource-type'; +import { NotificationOptions } from '../notifications/models/notification-options.model'; +import { NotificationsService } from '../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { RequestService } from '../../core/data/request.service'; +import { SearchService } from '../../core/shared/search/search.service'; +import { Observable} from 'rxjs/internal/Observable'; +import { of} from 'rxjs/internal/observable/of'; +import { ProcessTaskResponse } from '../../core/tasks/models/process-task-response'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { getSearchResultFor } from '../search/search-result-element-decorator'; +import { MyDSpaceActionsComponent } from './mydspace-actions'; + +/** + * Abstract class for all different representations of mydspace actions + */ +@Component({ + selector: 'ds-mydspace-reloadable-actions', + template: '' +}) +export abstract class MyDSpaceReloadableActionsComponent> + extends MyDSpaceActionsComponent implements OnInit { + + protected constructor( + protected objectType: ResourceType, + protected injector: Injector, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected searchService: SearchService, + protected requestService: RequestService) { + super(objectType, injector, router, notificationsService, translate, searchService, requestService); + } + + /** + * Perform the actual implementation of this reloadable action. + */ + abstract actionExecution(): Observable; + + /** + * Reload the object (typically by a remote call). + */ + abstract reloadObjectExecution(): Observable | DSpaceObject>; + + ngOnInit() { + this.initReloadAnchor(); + this.initObjects(this.object); + } + + /** + * Start the execution of the action. + * 1. performAction + * 2. reload of the object + * 3. notification + */ + startActionExecution(): Observable { + this.processing$.next(true); + return this.actionExecution().pipe( + take(1), + switchMap((res: ProcessTaskResponse) => { + if (res.hasSucceeded) { + return this._reloadObject().pipe( + tap( + (reloadedObject) => { + this.processing$.next(false); + this.handleReloadableActionResponse(res.hasSucceeded, reloadedObject); + }) + ); + } else { + this.processing$.next(false); + this.handleReloadableActionResponse(res.hasSucceeded, null); + return of(null); + } + })); + } + + /** + * Handle the action response and show properly notifications. + * + * @param result + * true on success, false otherwise + * @param reloadedObject + * the reloadedObject + */ + handleReloadableActionResponse(result: boolean, reloadedObject: DSpaceObject): void { + if (result) { + if (reloadedObject) { + this.processCompleted.emit({result, reloadedObject}); + } else { + this.reload(); + } + this.notificationsService.success(null, + this.translate.get('submission.workflow.tasks.generic.success'), + new NotificationOptions(5000, false)); + } else { + this.notificationsService.error(null, + this.translate.get('submission.workflow.tasks.generic.error'), + new NotificationOptions(20000, true)); + } + } + + /** + * Hook called on init to initialized the required information used to reload the object. + */ + // tslint:disable-next-line:no-empty + initReloadAnchor() {} + + /** + * Convert the reloadedObject to the Type required by this action. + * @param dso + */ + convertReloadedObject(dso: DSpaceObject): DSpaceObject { + const constructor = getSearchResultFor((dso as any).constructor); + const reloadedObject = Object.assign(new constructor(), dso, { + indexableObject: dso + }); + return reloadedObject; + } + + /** + * Retrieve the refreshed object and transform it to a reloadedObject. + * @param dso + */ + private _reloadObject(): Observable { + return this.reloadObjectExecution().pipe( + switchMap((res) => { + if (res instanceof RemoteData) { + return of(res).pipe(getFirstCompletedRemoteData(), map((completed) => completed.payload)); + } else { + return of(res); + } + })).pipe(map((dso) => { + return dso ? this.convertReloadedObject(dso) : dso; + })); + } + +} diff --git a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.html b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.html index 6f4ffffad3..214f85ed5b 100644 --- a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.html +++ b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.html @@ -1,8 +1,8 @@ diff --git a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.spec.ts b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.spec.ts index d3285dcb63..bce1f1a467 100644 --- a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.spec.ts +++ b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.spec.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { By } from '@angular/platform-browser'; @@ -21,6 +21,12 @@ import { getMockRequestService } from '../../mocks/request.service.mock'; import { RequestService } from '../../../core/data/request.service'; import { getMockSearchService } from '../../mocks/search-service.mock'; import { SearchService } from '../../../core/shared/search/search.service'; +import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service'; +import { PoolTaskSearchResult } from '../../object-collection/shared/pool-task-search-result.model'; +import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response'; + +let mockDataService: PoolTaskDataService; +let mockClaimedTaskDataService: ClaimedTaskDataService; let component: PoolTaskActionsComponent; let fixture: ComponentFixture; @@ -29,13 +35,9 @@ let mockObject: PoolTask; let notificationsServiceStub: NotificationsServiceStub; let router: RouterStub; -const mockDataService = jasmine.createSpyObj('PoolTaskDataService', { - claimTask: jasmine.createSpy('claimTask') -}); - const searchService = getMockSearchService(); -const requestServce = getMockRequestService(); +const requestService = getMockRequestService(); const item = Object.assign(new Item(), { bundles: observableOf({}), @@ -73,6 +75,8 @@ mockObject = Object.assign(new PoolTask(), { workflowitem: observableOf(rdWorkfl describe('PoolTaskActionsComponent', () => { beforeEach(waitForAsync(() => { + mockDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); + mockClaimedTaskDataService = new ClaimedTaskDataService(null, null, null, null, null, null, null, null); TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot({ @@ -88,8 +92,9 @@ describe('PoolTaskActionsComponent', () => { { provide: NotificationsService, useValue: new NotificationsServiceStub() }, { provide: Router, useValue: new RouterStub() }, { provide: PoolTaskDataService, useValue: mockDataService }, + { provide: ClaimedTaskDataService, useValue: mockClaimedTaskDataService }, { provide: SearchService, useValue: searchService }, - { provide: RequestService, useValue: requestServce } + { provide: RequestService, useValue: requestService } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(PoolTaskActionsComponent, { @@ -128,63 +133,35 @@ describe('PoolTaskActionsComponent', () => { expect(btn).toBeDefined(); }); - it('should call claimTask method on claim', fakeAsync(() => { - spyOn(component, 'reload'); - mockDataService.claimTask.and.returnValue(observableOf({ hasSucceeded: true })); + it('should call claim task with href of getPoolTaskEndpointById', ((done) => { - component.claim(); - fixture.detectChanges(); + const poolTaskHref = 'poolTaskHref'; + const remoteClaimTaskResponse: any = new ProcessTaskResponse(true, null, null); + const remoteReloadedObjectResponse: any = createSuccessfulRemoteDataObject(new PoolTask()); - fixture.whenStable().then(() => { - expect(mockDataService.claimTask).toHaveBeenCalledWith(mockObject.id); - }); + spyOn(mockDataService, 'getPoolTaskEndpointById').and.returnValue(observableOf(poolTaskHref)); + spyOn(mockClaimedTaskDataService, 'claimTask').and.returnValue(observableOf(remoteClaimTaskResponse)); + spyOn(mockClaimedTaskDataService, 'findByItem').and.returnValue(observableOf(remoteReloadedObjectResponse)); - })); + (component as any).objectDataService = mockDataService; - it('should display a success notification on claim success', waitForAsync(() => { - spyOn(component, 'reload'); - mockDataService.claimTask.and.returnValue(observableOf({ hasSucceeded: true })); + spyOn(component, 'handleReloadableActionResponse').and.callThrough(); - component.claim(); - fixture.detectChanges(); + component.startActionExecution().subscribe( (result) => { + + expect(mockDataService.getPoolTaskEndpointById).toHaveBeenCalledWith(mockObject.id); + expect(mockClaimedTaskDataService.claimTask).toHaveBeenCalledWith(mockObject.id, poolTaskHref); + expect(mockClaimedTaskDataService.findByItem).toHaveBeenCalledWith(component.itemUuid); + + expect(result instanceof PoolTaskSearchResult).toBeTrue(); + + expect(component.handleReloadableActionResponse).toHaveBeenCalledWith(true, result); - fixture.whenStable().then(() => { expect(notificationsServiceStub.success).toHaveBeenCalled(); + + done(); }); - })); - it('should reload page on claim success', waitForAsync(() => { - spyOn(router, 'navigateByUrl'); - router.url = 'test.url/test'; - mockDataService.claimTask.and.returnValue(observableOf({ hasSucceeded: true })); - - component.claim(); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(router.navigateByUrl).toHaveBeenCalledWith('test.url/test'); - }); - })); - - it('should display an error notification on claim failure', waitForAsync(() => { - mockDataService.claimTask.and.returnValue(observableOf({ hasSucceeded: false })); - - component.claim(); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(notificationsServiceStub.error).toHaveBeenCalled(); - }); - })); - - it('should clear the object cache by href', waitForAsync(() => { - component.reload(); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(searchService.getEndpoint).toHaveBeenCalled(); - expect(requestServce.removeByHrefSubstring).toHaveBeenCalledWith('discover/search/objects'); - }); })); }); diff --git a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.ts b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.ts index 892298d2f7..92086ac817 100644 --- a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.ts +++ b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.ts @@ -1,20 +1,24 @@ -import { Component, Injector, Input } from '@angular/core'; +import { Component, Injector, Input, OnDestroy } from '@angular/core'; import { Router } from '@angular/router'; -import { BehaviorSubject, Observable } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import {filter, map, switchMap, take} from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; import { WorkflowItem } from '../../../core/submission/models/workflowitem.model'; -import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response'; import { RemoteData } from '../../../core/data/remote-data'; import { PoolTask } from '../../../core/tasks/models/pool-task-object.model'; import { PoolTaskDataService } from '../../../core/tasks/pool-task-data.service'; import { isNotUndefined } from '../../empty.util'; -import { MyDSpaceActionsComponent } from '../mydspace-actions'; import { NotificationsService } from '../../notifications/notifications.service'; import { RequestService } from '../../../core/data/request.service'; import { SearchService } from '../../../core/shared/search/search.service'; +import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service'; +import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; +import { Item } from '../../../core/shared/item.model'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { MyDSpaceReloadableActionsComponent } from '../mydspace-reloadable-actions'; +import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response'; /** * This component represents mydspace actions related to PoolTask object. @@ -24,24 +28,25 @@ import { SearchService } from '../../../core/shared/search/search.service'; styleUrls: ['./pool-task-actions.component.scss'], templateUrl: './pool-task-actions.component.html', }) -export class PoolTaskActionsComponent extends MyDSpaceActionsComponent { +export class PoolTaskActionsComponent extends MyDSpaceReloadableActionsComponent implements OnDestroy { /** * The PoolTask object */ @Input() object: PoolTask; - /** - * A boolean representing if a claim operation is pending - * @type {BehaviorSubject} - */ - public processingClaim$ = new BehaviorSubject(false); - /** * The workflowitem object that belonging to the PoolTask object */ public workflowitem$: Observable; + /** + * Anchor used to reload the pool task. + */ + public itemUuid: string; + + subs = []; + /** * Initialize instance variables * @@ -55,6 +60,7 @@ export class PoolTaskActionsComponent extends MyDSpaceActionsComponent>).pipe( filter((rd: RemoteData) => ((!rd.isRequestPending) && isNotUndefined(rd.payload))), - map((rd: RemoteData) => rd.payload)); + map((rd: RemoteData) => rd.payload), + take(1)); + } + + actionExecution(): Observable { + return this.objectDataService.getPoolTaskEndpointById(this.object.id) + .pipe(switchMap((poolTaskHref) => { + return this.claimedTaskService.claimTask(this.object.id, poolTaskHref); + })); + } + + reloadObjectExecution(): Observable | DSpaceObject> { + return this.claimedTaskService.findByItem(this.itemUuid).pipe(take(1)); } /** - * Claim the task. + * Retrieve the itemUuid. */ - claim() { - this.processingClaim$.next(true); - this.objectDataService.claimTask(this.object.id) - .subscribe((res: ProcessTaskResponse) => { - this.handleActionResponse(res.hasSucceeded); - this.processingClaim$.next(false); - }); + initReloadAnchor() { + (this.object as any).workflowitem.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((workflowItem: WorkflowItem) => workflowItem.item.pipe(getFirstSucceededRemoteDataPayload()) + )) + .subscribe((item: Item) => { + this.itemUuid = item.uuid; + }); } + + ngOnDestroy() { + this.subs.forEach((sub) => sub.unsubscribe()); + } + } diff --git a/src/app/shared/ngrx/no-op.action.ts b/src/app/shared/ngrx/no-op.action.ts new file mode 100644 index 0000000000..cd05c9c05f --- /dev/null +++ b/src/app/shared/ngrx/no-op.action.ts @@ -0,0 +1,14 @@ +import { Action } from '@ngrx/store'; +import { type } from './type'; + +export const NO_OP_ACTION_TYPE = type('dspace/ngrx/NO_OP_ACTION'); + +/** + * An action to use when nothing needs to happen, but you're forced to dispatch an action anyway. + * e.g. an effect that needs to do something if a certain check succeeds, and nothing otherwise. + * + * It should not be used in any reducer or listened for in any effect. + */ +export class NoOpAction implements Action { + public readonly type = NO_OP_ACTION_TYPE; +} diff --git a/src/app/shared/notifications/notification/notification.component.scss b/src/app/shared/notifications/notification/notification.component.scss index a5ebb72b0b..0321644585 100644 --- a/src/app/shared/notifications/notification/notification.component.scss +++ b/src/app/shared/notifications/notification/notification.component.scss @@ -1,6 +1,6 @@ .alert { display: inline-block; - min-width: $modal-sm; + min-width: var(--bs-modal-sm); text-align: left; } @@ -19,14 +19,14 @@ } .alert-success .notification-progress-loader span { - background: darken(adjust-hue(map-get($theme-colors, success), -10), 10%); + background: var(--ds-notification-bg-success); } .alert-danger .notification-progress-loader span { - background: darken(adjust-hue(map-get($theme-colors, danger), -10), 10%); + background: var(--ds-notification-bg-danger); } .alert-info .notification-progress-loader span { - background: darken(adjust-hue(map-get($theme-colors, info), -10), 10%); + background: var(--ds-notification-bg-info); } .alert-warning .notification-progress-loader span { - background: darken(adjust-hue(map-get($theme-colors, warning), -10), 10%); + background: var(--ds-notification-bg-warning); } diff --git a/src/app/shared/notifications/notifications-board/notifications-board.component.scss b/src/app/shared/notifications/notifications-board/notifications-board.component.scss index 1101393e59..ab58313867 100644 --- a/src/app/shared/notifications/notifications-board/notifications-board.component.scss +++ b/src/app/shared/notifications/notifications-board/notifications-board.component.scss @@ -1,5 +1,5 @@ .notifications-wrapper { - z-index: $zindex-popover; + z-index: var(--bs-zindex-popover); text-align: right; @include word-wrap; .notification { diff --git a/src/app/shared/object-collection/object-collection.component.html b/src/app/shared/object-collection/object-collection.component.html index e696170a6f..f2778757ef 100644 --- a/src/app/shared/object-collection/object-collection.component.html +++ b/src/app/shared/object-collection/object-collection.component.html @@ -18,6 +18,7 @@ [importable]="importable" [importConfig]="importConfig" (importObject)="importObject.emit($event)" + (contentChange)="contentChange.emit()" *ngIf="(currentMode$ | async) === viewModeEnum.ListElement"> diff --git a/src/app/shared/object-collection/object-collection.component.ts b/src/app/shared/object-collection/object-collection.component.ts index ffb5c42880..52881f5eaf 100644 --- a/src/app/shared/object-collection/object-collection.component.ts +++ b/src/app/shared/object-collection/object-collection.component.ts @@ -53,6 +53,11 @@ export class ObjectCollectionComponent implements OnInit { @Output() deselectObject: EventEmitter = new EventEmitter(); @Output() selectObject: EventEmitter = new EventEmitter(); + /** + * Emit when one of the collection's object has changed. + */ + @Output() contentChange = new EventEmitter(); + /** * Whether or not to add an import button to the object elements */ diff --git a/src/app/shared/object-collection/shared/claimed-approved-task-search-result.model.ts b/src/app/shared/object-collection/shared/claimed-approved-task-search-result.model.ts new file mode 100644 index 0000000000..7cacd87048 --- /dev/null +++ b/src/app/shared/object-collection/shared/claimed-approved-task-search-result.model.ts @@ -0,0 +1,8 @@ +import { ClaimedTask } from '../../../core/tasks/models/claimed-task-object.model'; +import { SearchResult } from '../../search/search-result.model'; + +/** + * Represents a search result object of an Approved ClaimedTask object + */ +export class ClaimedApprovedTaskSearchResult extends SearchResult { +} diff --git a/src/app/shared/object-collection/shared/claimed-declined-task-search-result.model.ts b/src/app/shared/object-collection/shared/claimed-declined-task-search-result.model.ts new file mode 100644 index 0000000000..ff775be909 --- /dev/null +++ b/src/app/shared/object-collection/shared/claimed-declined-task-search-result.model.ts @@ -0,0 +1,8 @@ +import { ClaimedTask } from '../../../core/tasks/models/claimed-task-object.model'; +import { SearchResult } from '../../search/search-result.model'; + +/** + * Represents a search result object of a Declined ClaimedTask object + */ +export class ClaimedDeclinedTaskSearchResult extends SearchResult { +} diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.scss b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.scss new file mode 100644 index 0000000000..b9bc65ea45 --- /dev/null +++ b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.scss @@ -0,0 +1,3 @@ +:host { + width: 100%; +} diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts index 9cbd8ce6ce..3442b044a2 100644 --- a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts +++ b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts @@ -1,17 +1,16 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { waitForAsync, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { ChangeDetectionStrategy, ComponentFactoryResolver, NO_ERRORS_SCHEMA } from '@angular/core'; import { ListableObjectComponentLoaderComponent } from './listable-object-component-loader.component'; import { ListableObject } from '../listable-object.model'; import { GenericConstructor } from '../../../../core/shared/generic-constructor'; import { Context } from '../../../../core/shared/context.model'; import { ViewMode } from '../../../../core/shared/view-mode.model'; -import * as listableObjectDecorators from './listable-object.decorator'; import { ItemListElementComponent } from '../../../object-list/item-list-element/item-types/item/item-list-element.component'; import { ListableObjectDirective } from './listable-object.directive'; -import { spyOnExported } from '../../../testing/utils.test'; import { TranslateModule } from '@ngx-translate/core'; import { By } from '@angular/platform-browser'; import { Item } from '../../../../core/shared/item.model'; +import { provideMockStore } from '@ngrx/store/testing'; const testType = 'TestType'; const testContext = Context.Search; @@ -23,7 +22,7 @@ class TestType extends ListableObject { } } -xdescribe('ListableObjectComponentLoaderComponent', () => { +describe('ListableObjectComponentLoaderComponent', () => { let comp: ListableObjectComponentLoaderComponent; let fixture: ComponentFixture; @@ -32,7 +31,7 @@ xdescribe('ListableObjectComponentLoaderComponent', () => { imports: [TranslateModule.forRoot()], declarations: [ListableObjectComponentLoaderComponent, ItemListElementComponent, ListableObjectDirective], schemas: [NO_ERRORS_SCHEMA], - providers: [ComponentFactoryResolver] + providers: [provideMockStore({})] }).overrideComponent(ListableObjectComponentLoaderComponent, { set: { changeDetection: ChangeDetectionStrategy.Default, @@ -48,14 +47,14 @@ xdescribe('ListableObjectComponentLoaderComponent', () => { comp.object = new TestType(); comp.viewMode = testViewMode; comp.context = testContext; - spyOnExported(listableObjectDecorators, 'getListableObjectComponent').and.returnValue(ItemListElementComponent); + spyOn(comp, 'getComponent').and.returnValue(ItemListElementComponent as any); fixture.detectChanges(); })); describe('When the component is rendered', () => { it('should call the getListableObjectComponent function with the right types, view mode and context', () => { - expect(listableObjectDecorators.getListableObjectComponent).toHaveBeenCalledWith([testType], testViewMode, testContext); + expect(comp.getComponent).toHaveBeenCalledWith([testType], testViewMode, testContext); }); }); @@ -117,4 +116,20 @@ xdescribe('ListableObjectComponentLoaderComponent', () => { }); }); + describe('When a reloadedObject is emitted', () => { + + it('should re-instantiate the listable component ', fakeAsync(() => { + + spyOn((comp as any), 'instantiateComponent').and.returnValue(null); + + const listableComponent = fixture.debugElement.query(By.css('ds-item-list-element')).componentInstance; + const reloadedObject: any = 'object'; + (listableComponent as any).reloadedObject.emit(reloadedObject); + tick(); + + expect((comp as any).instantiateComponent).toHaveBeenCalledWith(reloadedObject); + })); + + }); + }); diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts index 5e367654a7..b02ab3cfeb 100644 --- a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts +++ b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts @@ -1,4 +1,13 @@ -import { Component, ComponentFactoryResolver, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; +import { + Component, + ComponentFactoryResolver, + ElementRef, + Input, + OnDestroy, OnInit, + Output, ViewChild +, + EventEmitter +} from '@angular/core'; import { ListableObject } from '../listable-object.model'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { Context } from '../../../../core/shared/context.model'; @@ -7,16 +16,20 @@ import { GenericConstructor } from '../../../../core/shared/generic-constructor' import { ListableObjectDirective } from './listable-object.directive'; import { CollectionElementLinkType } from '../../collection-element-link.type'; import { hasValue } from '../../../empty.util'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { take } from 'rxjs/operators'; +import { ThemeService } from '../../../theme-support/theme.service'; @Component({ selector: 'ds-listable-object-component-loader', - // styleUrls: ['./listable-object-component-loader.component.scss'], + styleUrls: ['./listable-object-component-loader.component.scss'], templateUrl: './listable-object-component-loader.component.html' }) /** * Component for determining what component to use depending on the item's relationship type (relationship.type) */ -export class ListableObjectComponentLoaderComponent implements OnInit { +export class ListableObjectComponentLoaderComponent implements OnInit, OnDestroy { /** * The item or metadata to determine the component for */ @@ -73,6 +86,11 @@ export class ListableObjectComponentLoaderComponent implements OnInit { */ @ViewChild('badges', { static: true }) badges: ElementRef; + /** + * Emit when the listable object has been reloaded. + */ + @Output() contentChange = new EventEmitter(); + /** * Whether or not the "Private" badge should be displayed for this listable object */ @@ -83,16 +101,38 @@ export class ListableObjectComponentLoaderComponent implements OnInit { */ withdrawnBadge = false; - constructor(private componentFactoryResolver: ComponentFactoryResolver) { + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subs: Subscription[] = []; + + constructor( + private componentFactoryResolver: ComponentFactoryResolver, + private themeService: ThemeService + ) { } /** * Setup the dynamic child component */ ngOnInit(): void { + this.instantiateComponent(this.object); + } + + ngOnDestroy() { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + + private instantiateComponent(object) { + this.initBadges(); - const componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.getComponent()); + const component = this.getComponent(object.getRenderTypes(), this.viewMode, this.context); + + const componentFactory = this.componentFactoryResolver.resolveComponentFactory(component); const viewContainerRef = this.listableObjectDirective.viewContainerRef; viewContainerRef.clear(); @@ -104,7 +144,7 @@ export class ListableObjectComponentLoaderComponent implements OnInit { [ [this.badges.nativeElement], ]); - (componentRef.instance as any).object = this.object; + (componentRef.instance as any).object = object; (componentRef.instance as any).index = this.index; (componentRef.instance as any).linkType = this.linkType; (componentRef.instance as any).listID = this.listID; @@ -112,6 +152,17 @@ export class ListableObjectComponentLoaderComponent implements OnInit { (componentRef.instance as any).context = this.context; (componentRef.instance as any).viewMode = this.viewMode; (componentRef.instance as any).value = this.value; + + if ((componentRef.instance as any).reloadedObject) { + (componentRef.instance as any).reloadedObject.pipe(take(1)).subscribe((reloadedObject: DSpaceObject) => { + if (reloadedObject) { + componentRef.destroy(); + this.object = reloadedObject; + this.instantiateComponent(reloadedObject); + this.contentChange.emit(reloadedObject); + } + }); + } } /** @@ -131,7 +182,9 @@ export class ListableObjectComponentLoaderComponent implements OnInit { * Fetch the component depending on the item's relationship type, view mode and context * @returns {GenericConstructor} */ - private getComponent(): GenericConstructor { - return getListableObjectComponent(this.object.getRenderTypes(), this.viewMode, this.context); + getComponent(renderTypes: (string | GenericConstructor)[], + viewMode: ViewMode, + context: Context): GenericConstructor { + return getListableObjectComponent(renderTypes, viewMode, context, this.themeService.getThemeName()); } } diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts b/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts index 4e4567eaff..91140f0ea1 100644 --- a/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts +++ b/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts @@ -1,7 +1,10 @@ import { ViewMode } from '../../../../core/shared/view-mode.model'; import { Context } from '../../../../core/shared/context.model'; import { hasNoValue, hasValue } from '../../../empty.util'; -import { DEFAULT_CONTEXT } from '../../../metadata-representation/metadata-representation.decorator'; +import { + DEFAULT_CONTEXT, + DEFAULT_THEME +} from '../../../metadata-representation/metadata-representation.decorator'; import { GenericConstructor } from '../../../../core/shared/generic-constructor'; import { ListableObject } from '../listable-object.model'; @@ -11,11 +14,12 @@ const map = new Map(); /** * Decorator used for rendering a listable object - * @param type The object type or entity type the component represents + * @param objectType The object type or entity type the component represents * @param viewMode The view mode the component represents * @param context The optional context the component represents + * @param theme The optional theme for the component */ -export function listableObjectComponent(objectType: string | GenericConstructor, viewMode: ViewMode, context: Context = DEFAULT_CONTEXT) { +export function listableObjectComponent(objectType: string | GenericConstructor, viewMode: ViewMode, context: Context = DEFAULT_CONTEXT, theme = DEFAULT_THEME) { return function decorator(component: any) { if (hasNoValue(objectType)) { return; @@ -26,7 +30,10 @@ export function listableObjectComponent(objectType: string | GenericConstructor< if (hasNoValue(map.get(objectType).get(viewMode))) { map.get(objectType).set(viewMode, new Map()); } - map.get(objectType).get(viewMode).set(context, component); + if (hasNoValue(map.get(objectType).get(viewMode).get(context))) { + map.get(objectType).get(viewMode).set(context, new Map()); + } + map.get(objectType).get(viewMode).get(context).set(theme, component); }; } @@ -35,8 +42,9 @@ export function listableObjectComponent(objectType: string | GenericConstructor< * @param types The types of which one should match the listable component * @param viewMode The view mode that should match the components * @param context The context that should match the components + * @param theme The theme that should match the components */ -export function getListableObjectComponent(types: (string | GenericConstructor)[], viewMode: ViewMode, context: Context = DEFAULT_CONTEXT) { +export function getListableObjectComponent(types: (string | GenericConstructor)[], viewMode: ViewMode, context: Context = DEFAULT_CONTEXT, theme: string = DEFAULT_THEME) { let bestMatch; let bestMatchValue = 0; for (const type of types) { @@ -44,17 +52,29 @@ export function getListableObjectComponent(types: (string | GenericConstructor { + /** * The object to render in this list element */ @@ -49,6 +51,11 @@ export class AbstractListableElementComponent { */ @Input() viewMode: ViewMode; + /** + * Emit when the object has been reloaded. + */ + @Output() reloadedObject = new EventEmitter(); + /** * The available link types */ diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.html b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.html index a03d8c96fe..1d8f599e65 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.html +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.html @@ -6,5 +6,5 @@ [status]="status"> - + diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.spec.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.spec.ts index f88a46204f..a6a3e2020b 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.spec.ts +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.spec.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, TestBed, tick, waitForAsync, fakeAsync} from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { of as observableOf } from 'rxjs'; @@ -14,6 +14,7 @@ import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claim import { VarDirective } from '../../../utils/var.directive'; import { LinkService } from '../../../../core/cache/builders/link.service'; import { getMockLinkService } from '../../../mocks/link-service.mock'; +import { By } from '@angular/platform-browser'; let component: ClaimedTaskSearchResultDetailElementComponent; let fixture: ComponentFixture; @@ -98,4 +99,16 @@ describe('ClaimedTaskSearchResultDetailElementComponent', () => { it('should have properly status', () => { expect(component.status).toEqual(MyDspaceItemStatusType.VALIDATION); }); + + it('should forward claimed-task-actions processComplete event to reloadObject event emitter', fakeAsync(() => { + spyOn(component.reloadedObject, 'emit').and.callThrough(); + const actionPayload: any = { reloadedObject: {}}; + + const actionsComponent = fixture.debugElement.query(By.css('ds-claimed-task-actions')); + actionsComponent.triggerEventHandler('processCompleted', actionPayload); + tick(); + + expect(component.reloadedObject.emit).toHaveBeenCalledWith(actionPayload.reloadedObject); + + })); }); diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.html b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.html index 61c897e8d5..232b54d4d9 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.html +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.html @@ -5,5 +5,5 @@ [showSubmitter]="showSubmitter" [status]="status"> - + diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.spec.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.spec.ts index 68eb398f13..f5f19fc041 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.spec.ts +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.spec.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { waitForAsync, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { of as observableOf } from 'rxjs'; @@ -14,6 +14,7 @@ import { PoolTaskSearchResult } from '../../../object-collection/shared/pool-tas import { VarDirective } from '../../../utils/var.directive'; import { LinkService } from '../../../../core/cache/builders/link.service'; import { getMockLinkService } from '../../../mocks/link-service.mock'; +import { By } from '@angular/platform-browser'; let component: PoolSearchResultDetailElementComponent; let fixture: ComponentFixture; @@ -99,4 +100,15 @@ describe('PoolSearchResultDetailElementComponent', () => { it('should have properly status', () => { expect(component.status).toEqual(MyDspaceItemStatusType.WAITING_CONTROLLER); }); + + it('should forward pool-task-actions processCompleted event to the reloadedObject event emitter', fakeAsync(() => { + spyOn(component.reloadedObject, 'emit').and.callThrough(); + const actionPayload: any = { reloadedObject: {}}; + const actionsComponents = fixture.debugElement.query(By.css('ds-pool-task-actions')); + actionsComponents.triggerEventHandler('processCompleted', actionPayload); + tick(); + + expect(component.reloadedObject.emit).toHaveBeenCalledWith(actionPayload.reloadedObject); + + })); }); diff --git a/src/app/shared/object-detail/object-detail.component.scss b/src/app/shared/object-detail/object-detail.component.scss index 5fca9d386f..ed85e9bf54 100644 --- a/src/app/shared/object-detail/object-detail.component.scss +++ b/src/app/shared/object-detail/object-detail.component.scss @@ -1,7 +1,7 @@ :host::ng-deep { div.thumbnail > img { - height: $card-thumbnail-height; + height: var(--ds-card-thumbnail-height); width: 100%; } } diff --git a/src/app/shared/object-grid/object-grid.component.scss b/src/app/shared/object-grid/object-grid.component.scss index 53d64bc60c..46675615f0 100644 --- a/src/app/shared/object-grid/object-grid.component.scss +++ b/src/app/shared/object-grid/object-grid.component.scss @@ -1,8 +1,8 @@ -$ds-wrapper-grid-spacing: $spacer/2; - :host ::ng-deep { + --ds-wrapper-grid-spacing: calc(var(--bs-spacer) / 2); + div.thumbnail > img { - height: $card-thumbnail-height; + height: var(--ds-card-thumbnail-height); width: 100%; display: block; min-width: 100%; @@ -11,19 +11,19 @@ $ds-wrapper-grid-spacing: $spacer/2; object-position: 50% 15%; } div.card { - margin-top: $ds-wrapper-grid-spacing; - margin-bottom: $ds-wrapper-grid-spacing; + margin-top: var(--ds-wrapper-grid-spacing); + margin-bottom: var(--ds-wrapper-grid-spacing); } } .card-columns { - margin-left: -$ds-wrapper-grid-spacing; - margin-right: -$ds-wrapper-grid-spacing; + margin-left: calc(-1 * var(--ds-wrapper-grid-spacing)); + margin-right: calc(-1 * var(--ds-wrapper-grid-spacing)); column-gap: 0; .card-column { - padding-left: $ds-wrapper-grid-spacing; - padding-right: $ds-wrapper-grid-spacing; + padding-left: var(--ds-wrapper-grid-spacing); + padding-right: var(--ds-wrapper-grid-spacing); } } diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.html new file mode 100644 index 0000000000..8ebcdbd69a --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.html @@ -0,0 +1,10 @@ + + + diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.spec.ts new file mode 100644 index 0000000000..e56999472e --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.spec.ts @@ -0,0 +1,101 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; + +import { Item } from '../../../../../core/shared/item.model'; +import { createSuccessfulRemoteDataObject } from '../../../../remote-data.utils'; +import { WorkflowItem } from '../../../../../core/submission/models/workflowitem.model'; +import { ClaimedTask } from '../../../../../core/tasks/models/claimed-task-object.model'; +import { getMockLinkService } from '../../../../mocks/link-service.mock'; +import { VarDirective } from '../../../../utils/var.directive'; +import { TruncatableService } from '../../../../truncatable/truncatable.service'; +import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { MyDspaceItemStatusType } from '../../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { ClaimedApprovedTaskSearchResult } from '../../../../object-collection/shared/claimed-approved-task-search-result.model'; +import { ClaimedApprovedSearchResultListElementComponent } from './claimed-approved-search-result-list-element.component'; + +let component: ClaimedApprovedSearchResultListElementComponent; +let fixture: ComponentFixture; + +const mockResultObject: ClaimedApprovedTaskSearchResult = new ClaimedApprovedTaskSearchResult(); +mockResultObject.hitHighlights = {}; + +const item = Object.assign(new Item(), { + bundles: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rdItem = createSuccessfulRemoteDataObject(item); +const workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) }); +const rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem); +mockResultObject.indexableObject = Object.assign(new ClaimedTask(), { workflowitem: observableOf(rdWorkflowitem) }); +const linkService = getMockLinkService(); + +describe('ClaimedApprovedSearchResultListElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + declarations: [ClaimedApprovedSearchResultListElementComponent, VarDirective], + providers: [ + { provide: TruncatableService, useValue: {} }, + { provide: LinkService, useValue: linkService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ClaimedApprovedSearchResultListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ClaimedApprovedSearchResultListElementComponent); + component = fixture.componentInstance; + })); + + beforeEach(() => { + component.dso = mockResultObject.indexableObject; + fixture.detectChanges(); + }); + + it('should init workflowitem properly', (done) => { + component.workflowitemRD$.subscribe((workflowitemRD) => { + expect(linkService.resolveLinks).toHaveBeenCalledWith( + component.dso, + jasmine.objectContaining({ name: 'workflowitem' }), + jasmine.objectContaining({ name: 'action' }) + ); + expect(workflowitemRD.payload).toEqual(workflowitem); + done(); + }); + }); + + it('should have properly status', () => { + expect(component.status).toEqual(MyDspaceItemStatusType.APPROVED); + }); + +}); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.ts new file mode 100644 index 0000000000..5423722160 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.ts @@ -0,0 +1,68 @@ +import { Component } from '@angular/core'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { ClaimedApprovedTaskSearchResult } from '../../../../object-collection/shared/claimed-approved-task-search-result.model'; +import { listableObjectComponent } from '../../../../object-collection/shared/listable-object/listable-object.decorator'; +import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { TruncatableService } from '../../../../truncatable/truncatable.service'; +import { MyDspaceItemStatusType } from '../../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../../../core/data/remote-data'; +import { WorkflowItem } from '../../../../../core/submission/models/workflowitem.model'; +import { followLink } from '../../../../utils/follow-link-config.model'; +import { SearchResultListElementComponent } from '../../../search-result-list-element/search-result-list-element.component'; +import { ClaimedTaskSearchResult} from '../../../../object-collection/shared/claimed-task-search-result.model'; +import { ClaimedTask } from '../../../../../core/tasks/models/claimed-task-object.model'; + +/** + * This component renders claimed task approved object for the search result in the list view. + */ +@Component({ + selector: 'ds-claimed-approved-search-result-list-element', + styleUrls: ['../../../search-result-list-element/search-result-list-element.component.scss'], + templateUrl: './claimed-approved-search-result-list-element.component.html' +}) +@listableObjectComponent(ClaimedApprovedTaskSearchResult, ViewMode.ListElement) +export class ClaimedApprovedSearchResultListElementComponent extends SearchResultListElementComponent { + + /** + * A boolean representing if to show submitter information + */ + public showSubmitter = true; + + /** + * Represent item's status + */ + public status = MyDspaceItemStatusType.APPROVED; + + /** + * The workflowitem object that belonging to the result object + */ + public workflowitemRD$: Observable>; + + public constructor( + protected linkService: LinkService, + protected truncatableService: TruncatableService + ) { + super(truncatableService); + } + + /** + * Initialize all instance variables + */ + ngOnInit() { + super.ngOnInit(); + this.linkService.resolveLinks(this.dso, + followLink('workflowitem', + null, + true, + false, + true, + followLink('item'), + followLink('submitter') + ), + followLink('action') + ); + this.workflowitemRD$ = this.dso.workflowitem as Observable>; + } + +} diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declided-search-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declided-search-result-list-element.component.spec.ts new file mode 100644 index 0000000000..8a8d542063 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declided-search-result-list-element.component.spec.ts @@ -0,0 +1,101 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; + +import { ClaimedDeclinedSearchResultListElementComponent } from './claimed-declined-search-result-list-element.component'; +import { ClaimedDeclinedTaskSearchResult } from '../../../../object-collection/shared/claimed-declined-task-search-result.model'; +import { Item } from '../../../../../core/shared/item.model'; +import { createSuccessfulRemoteDataObject } from '../../../../remote-data.utils'; +import { WorkflowItem } from '../../../../../core/submission/models/workflowitem.model'; +import { ClaimedTask } from '../../../../../core/tasks/models/claimed-task-object.model'; +import { getMockLinkService } from '../../../../mocks/link-service.mock'; +import { VarDirective } from '../../../../utils/var.directive'; +import { TruncatableService } from '../../../../truncatable/truncatable.service'; +import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { MyDspaceItemStatusType } from '../../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; + +let component: ClaimedDeclinedSearchResultListElementComponent; +let fixture: ComponentFixture; + +const mockResultObject: ClaimedDeclinedTaskSearchResult = new ClaimedDeclinedTaskSearchResult(); +mockResultObject.hitHighlights = {}; + +const item = Object.assign(new Item(), { + bundles: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rdItem = createSuccessfulRemoteDataObject(item); +const workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) }); +const rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem); +mockResultObject.indexableObject = Object.assign(new ClaimedTask(), { workflowitem: observableOf(rdWorkflowitem) }); +const linkService = getMockLinkService(); + +describe('ClaimedDeclinedSearchResultListElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + declarations: [ClaimedDeclinedSearchResultListElementComponent, VarDirective], + providers: [ + { provide: TruncatableService, useValue: {} }, + { provide: LinkService, useValue: linkService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ClaimedDeclinedSearchResultListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ClaimedDeclinedSearchResultListElementComponent); + component = fixture.componentInstance; + })); + + beforeEach(() => { + component.dso = mockResultObject.indexableObject; + fixture.detectChanges(); + }); + + it('should init workflowitem properly', (done) => { + component.workflowitemRD$.subscribe((workflowitemRD) => { + expect(linkService.resolveLinks).toHaveBeenCalledWith( + component.dso, + jasmine.objectContaining({ name: 'workflowitem' }), + jasmine.objectContaining({ name: 'action' }) + ); + expect(workflowitemRD.payload).toEqual(workflowitem); + done(); + }); + }); + + it('should have properly status', () => { + expect(component.status).toEqual(MyDspaceItemStatusType.DECLINED); + }); + +}); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component.html new file mode 100644 index 0000000000..f20696823c --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component.html @@ -0,0 +1,10 @@ + + + diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component.ts new file mode 100644 index 0000000000..7db12c1725 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component.ts @@ -0,0 +1,68 @@ +import { Component } from '@angular/core'; + +import { listableObjectComponent } from '../../../../object-collection/shared/listable-object/listable-object.decorator'; +import { ClaimedDeclinedTaskSearchResult } from '../../../../object-collection/shared/claimed-declined-task-search-result.model'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { TruncatableService } from '../../../../truncatable/truncatable.service'; +import { MyDspaceItemStatusType } from '../../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../../../core/data/remote-data'; +import { WorkflowItem } from '../../../../../core/submission/models/workflowitem.model'; +import { followLink } from '../../../../utils/follow-link-config.model'; +import { SearchResultListElementComponent } from '../../../search-result-list-element/search-result-list-element.component'; +import { ClaimedTaskSearchResult } from '../../../../object-collection/shared/claimed-task-search-result.model'; +import { ClaimedTask } from '../../../../../core/tasks/models/claimed-task-object.model'; + +/** + * This component renders claimed task declined object for the search result in the list view. + */ +@Component({ + selector: 'ds-claimed-declined-search-result-list-element', + styleUrls: ['../../../search-result-list-element/search-result-list-element.component.scss'], + templateUrl: './claimed-declined-search-result-list-element.component.html' +}) +@listableObjectComponent(ClaimedDeclinedTaskSearchResult, ViewMode.ListElement) +export class ClaimedDeclinedSearchResultListElementComponent extends SearchResultListElementComponent { + + /** + * A boolean representing if to show submitter information + */ + public showSubmitter = true; + + /** + * Represent item's status + */ + public status = MyDspaceItemStatusType.DECLINED; + + /** + * The workflowitem object that belonging to the result object + */ + public workflowitemRD$: Observable>; + + public constructor( + protected linkService: LinkService, + protected truncatableService: TruncatableService + ) { + super(truncatableService); + } + + /** + * Initialize all instance variables + */ + ngOnInit() { + super.ngOnInit(); + this.linkService.resolveLinks(this.dso, + followLink('workflowitem', + null, + true, + false, + true, + followLink('item'), + followLink('submitter') + ), + followLink('action')); + this.workflowitemRD$ = this.dso.workflowitem as Observable>; + } + +} diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html index b35a4f8741..30aac357a4 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html @@ -4,7 +4,6 @@ [object]="object" [showSubmitter]="showSubmitter" [status]="status"> - - + diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts index 151d205273..5dad421f68 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { of as observableOf } from 'rxjs'; @@ -15,12 +15,11 @@ import { TruncatableService } from '../../../truncatable/truncatable.service'; import { VarDirective } from '../../../utils/var.directive'; import { LinkService } from '../../../../core/cache/builders/link.service'; import { getMockLinkService } from '../../../mocks/link-service.mock'; +import { By } from '@angular/platform-browser'; let component: ClaimedSearchResultListElementComponent; let fixture: ComponentFixture; -const compIndex = 1; - const mockResultObject: ClaimedTaskSearchResult = new ClaimedTaskSearchResult(); mockResultObject.hitHighlights = {}; @@ -99,4 +98,16 @@ describe('ClaimedSearchResultListElementComponent', () => { it('should have properly status', () => { expect(component.status).toEqual(MyDspaceItemStatusType.VALIDATION); }); + + it('should forward claimed-task-actions processComplete event to reloadObject event emitter', fakeAsync(() => { + spyOn(component.reloadedObject, 'emit').and.callThrough(); + const actionPayload: any = { reloadedObject: {}}; + + const actionsComponent = fixture.debugElement.query(By.css('ds-claimed-task-actions')); + actionsComponent.triggerEventHandler('processCompleted', actionPayload); + tick(); + + expect(component.reloadedObject.emit).toHaveBeenCalledWith(actionPayload.reloadedObject); + + })); }); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts index 788a373f83..cef1056401 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts @@ -1,30 +1,23 @@ import { Component } from '@angular/core'; -import { Location, LocationStrategy, PathLocationStrategy } from '@angular/common'; - -import { Observable } from 'rxjs'; import { ViewMode } from '../../../../core/shared/view-mode.model'; -import { RemoteData } from '../../../../core/data/remote-data'; -import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model'; -import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; -import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; import { listableObjectComponent } from '../../../object-collection/shared/listable-object/listable-object.decorator'; import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claimed-task-search-result.model'; -import { SearchResultListElementComponent } from '../../search-result-list-element/search-result-list-element.component'; -import { followLink } from '../../../utils/follow-link-config.model'; import { LinkService } from '../../../../core/cache/builders/link.service'; import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model'; +import { followLink } from '../../../utils/follow-link-config.model'; +import { SearchResultListElementComponent } from '../../search-result-list-element/search-result-list-element.component'; +import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; -/** - * This component renders claimed task object for the search result in the list view. - */ @Component({ selector: 'ds-claimed-search-result-list-element', styleUrls: ['../../search-result-list-element/search-result-list-element.component.scss'], - templateUrl: './claimed-search-result-list-element.component.html', - providers: [Location, { provide: LocationStrategy, useClass: PathLocationStrategy }] + templateUrl: './claimed-search-result-list-element.component.html' }) - @listableObjectComponent(ClaimedTaskSearchResult, ViewMode.ListElement) export class ClaimedSearchResultListElementComponent extends SearchResultListElementComponent { @@ -43,7 +36,7 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle */ public workflowitemRD$: Observable>; - constructor( + public constructor( protected linkService: LinkService, protected truncatableService: TruncatableService ) { @@ -60,4 +53,5 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle ), followLink('action')); this.workflowitemRD$ = this.dso.workflowitem as Observable>; } + } diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component.html b/src/app/shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component.html index e1b1435481..e422a84641 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component.html @@ -2,4 +2,4 @@ [object]="object" [status]="status"> - + diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component.spec.ts index 07174ac74f..e2017e8748 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component.spec.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { waitForAsync, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { of as observableOf } from 'rxjs'; @@ -9,12 +9,11 @@ import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspa import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model'; import { ItemSearchResultListElementSubmissionComponent } from './item-search-result-list-element-submission.component'; import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { By } from '@angular/platform-browser'; let component: ItemSearchResultListElementSubmissionComponent; let fixture: ComponentFixture; -const compIndex = 1; - const mockResultObject: ItemSearchResult = new ItemSearchResult(); mockResultObject.hitHighlights = {}; @@ -75,4 +74,16 @@ describe('ItemMyDSpaceResultListElementComponent', () => { it('should have properly status', () => { expect(component.status).toEqual(MyDspaceItemStatusType.ARCHIVED); }); + + it('should forward item-actions processComplete event to reloadObject event emitter', fakeAsync(() => { + spyOn(component.reloadedObject, 'emit').and.callThrough(); + const actionPayload: any = { reloadedObject: {}}; + + const actionsComponent = fixture.debugElement.query(By.css('ds-item-actions')); + actionsComponent.triggerEventHandler('processCompleted', actionPayload); + tick(); + + expect(component.reloadedObject.emit).toHaveBeenCalledWith(actionPayload.reloadedObject); + + })); }); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html index 9358e35bed..25e2c4f8c4 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html @@ -4,6 +4,5 @@ [object]="object" [showSubmitter]="showSubmitter" [status]="status"> - - + diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts index 74c64ca254..e55b45aed7 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { waitForAsync, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { of as observableOf } from 'rxjs'; @@ -15,12 +15,11 @@ import { TruncatableService } from '../../../truncatable/truncatable.service'; import { VarDirective } from '../../../utils/var.directive'; import { LinkService } from '../../../../core/cache/builders/link.service'; import { getMockLinkService } from '../../../mocks/link-service.mock'; +import { By } from '@angular/platform-browser'; let component: PoolSearchResultListElementComponent; let fixture: ComponentFixture; -const compIndex = 1; - const mockResultObject: PoolTaskSearchResult = new PoolTaskSearchResult(); mockResultObject.hitHighlights = {}; @@ -99,4 +98,15 @@ describe('PoolSearchResultListElementComponent', () => { it('should have properly status', () => { expect(component.status).toEqual(MyDspaceItemStatusType.WAITING_CONTROLLER); }); + + it('should forward pool-task-actions processCompleted event to the reloadedObject event emitter', fakeAsync(() => { + spyOn(component.reloadedObject, 'emit').and.callThrough(); + const actionPayload: any = { reloadedObject: {}}; + const actionsComponents = fixture.debugElement.query(By.css('ds-pool-task-actions')); + actionsComponents.triggerEventHandler('processCompleted', actionPayload); + tick(); + + expect(component.reloadedObject.emit).toHaveBeenCalledWith(actionPayload.reloadedObject); + + })); }); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts index bf101e651c..b130d5001c 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts @@ -63,4 +63,5 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen ), followLink('action')); this.workflowitemRD$ = this.dso.workflowitem as Observable>; } + } diff --git a/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.html index ced2846b4b..74fc5fd06d 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.html @@ -4,7 +4,7 @@ [object]="object" [status]="status"> - + ; -const compIndex = 1; - const mockResultObject: WorkflowItemSearchResult = new WorkflowItemSearchResult(); mockResultObject.hitHighlights = {}; @@ -96,4 +95,16 @@ describe('WorkflowItemSearchResultListElementComponent', () => { it('should have properly status', () => { expect(component.status).toEqual(MyDspaceItemStatusType.WORKFLOW); }); + + it('should forward workflowitem-actions processCompleted event to the reloadedObject event emitter', fakeAsync(() => { + spyOn(component.reloadedObject, 'emit').and.callThrough(); + const actionPayload: any = { reloadedObject: {}}; + + const actionsComponent = fixture.debugElement.query(By.css('ds-workflowitem-actions')); + actionsComponent.triggerEventHandler('processCompleted', actionPayload); + tick(); + + expect(component.reloadedObject.emit).toHaveBeenCalledWith(actionPayload.reloadedObject); + + })); }); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.html index 8966b4b1d8..41d95b87af 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.html @@ -4,7 +4,7 @@ [object]="object" [status]="status"> - + ; -const compIndex = 1; - const mockResultObject: WorkflowItemSearchResult = new WorkflowItemSearchResult(); mockResultObject.hitHighlights = {}; @@ -95,4 +94,17 @@ describe('WorkspaceItemSearchResultListElementComponent', () => { it('should have properly status', () => { expect(component.status).toEqual(MyDspaceItemStatusType.WORKSPACE); }); + + it('should forward workspaceitem-actions processCompleted event to the reloadedObject event emitter', fakeAsync(() => { + + spyOn(component.reloadedObject, 'emit').and.callThrough(); + const actionPayload: any = { reloadedObject: {}}; + + const actionsComponent = fixture.debugElement.query(By.css('ds-workspaceitem-actions')); + actionsComponent.triggerEventHandler('processCompleted', actionPayload); + tick(); + + expect(component.reloadedObject.emit).toHaveBeenCalledWith(actionPayload.reloadedObject); + + })); }); diff --git a/src/app/shared/object-list/object-list.component.html b/src/app/shared/object-list/object-list.component.html index 4aecaaac8f..331ff1cb28 100644 --- a/src/app/shared/object-list/object-list.component.html +++ b/src/app/shared/object-list/object-list.component.html @@ -22,7 +22,9 @@ [importConfig]="importConfig" (importObject)="importObject.emit($event)"> + [listID]="selectionConfig?.listId" + (contentChange)="contentChange.emit()" + > diff --git a/src/app/shared/object-list/object-list.component.ts b/src/app/shared/object-list/object-list.component.ts index b58c8b358e..6f4caae939 100644 --- a/src/app/shared/object-list/object-list.component.ts +++ b/src/app/shared/object-list/object-list.component.ts @@ -76,6 +76,11 @@ export class ObjectListComponent { */ @Input() importConfig: { importLabel: string }; + /** + * Emit when one of the listed object has changed. + */ + @Output() contentChange = new EventEmitter(); + /** * The current listable objects */ diff --git a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts index 82bd21cec6..3ad4f5e7e6 100644 --- a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts +++ b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts @@ -12,7 +12,6 @@ import { Metadata } from '../../../core/shared/metadata.utils'; selector: 'ds-search-result-list-element', template: `` }) - export class SearchResultListElementComponent, K extends DSpaceObject> extends AbstractListableElementComponent implements OnInit { /** * The DSpaceObject of the search result diff --git a/src/app/shared/page-size-selector/page-size-selector.component.scss b/src/app/shared/page-size-selector/page-size-selector.component.scss index cd18456888..b1a3c083ef 100644 --- a/src/app/shared/page-size-selector/page-size-selector.component.scss +++ b/src/app/shared/page-size-selector/page-size-selector.component.scss @@ -1,3 +1,3 @@ .setting-option { - border: 1px solid map-get($theme-colors, light); + border: 1px solid var(--bs-light); } diff --git a/src/app/shared/search-form/search-form.component.scss b/src/app/shared/search-form/search-form.component.scss index 64b97aebd8..4576be4b28 100644 --- a/src/app/shared/search-form/search-form.component.scss +++ b/src/app/shared/search-form/search-form.component.scss @@ -1,5 +1,5 @@ // temporary fix for bootstrap 4 beta btn color issue .btn-secondary { - background-color: $input-bg; - color: $input-color; + background-color: var(--bs-input-bg); + color: var(--bs-input-color); } diff --git a/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.scss b/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.scss index 4bf21aa33f..8f61f5ebf1 100644 --- a/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.scss +++ b/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.scss @@ -1,6 +1,6 @@ .filters { a { - color: $body-color; + color: var(--bs-body-color); &:hover, &focus { text-decoration: none; } @@ -9,7 +9,7 @@ } } .toggle-more-filters a { - color: $link-color; + color: var(--bs-link-color); text-decoration: underline; cursor: pointer; } diff --git a/src/app/shared/search/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.scss b/src/app/shared/search/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.scss index 9bfd380441..005b9b2581 100644 --- a/src/app/shared/search/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.scss +++ b/src/app/shared/search/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.scss @@ -1,6 +1,6 @@ .filters { .toggle-more-filters a { - color: $link-color; + color: var(--bs-link-color); text-decoration: underline; cursor: pointer; } diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.scss b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.scss index c5a38f24a7..74cede54a8 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.scss +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.scss @@ -1,9 +1,9 @@ a { - color: $body-color; + color: var(--bs-body-color); &:hover, &focus { text-decoration: none; } span.badge { vertical-align: text-top; } -} \ No newline at end of file +} diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.scss b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.scss index 93d85fcba8..1e151b14ce 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.scss +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.scss @@ -1,11 +1,11 @@ a { - color: $link-color; + color: var(--bs-link-color); &:hover { text-decoration: underline; - color: $link-hover-color; + color: var(--bs-link-hover-color); } span.badge { vertical-align: text-top; } -} \ No newline at end of file +} diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.scss b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.scss index c5a38f24a7..74cede54a8 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.scss +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.scss @@ -1,9 +1,9 @@ a { - color: $body-color; + color: var(--bs-body-color); &:hover, &focus { text-decoration: none; } span.badge { vertical-align: text-top; } -} \ No newline at end of file +} diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.scss b/src/app/shared/search/search-filters/search-filter/search-filter.component.scss index c94edb01bb..518e7c9d5f 100644 --- a/src/app/shared/search/search-filters/search-filter/search-filter.component.scss +++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.scss @@ -1,10 +1,10 @@ :host .facet-filter { - border: 1px solid map-get($theme-colors, light); + border: 1px solid var(--bs-light); cursor: pointer; .search-filter-wrapper.closed { overflow: hidden; } .filter-toggle { - line-height: $line-height-base; + line-height: var(--bs-line-height-base); } } diff --git a/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.scss b/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.scss index 489b7bab63..4e2df779a2 100644 --- a/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.scss +++ b/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.scss @@ -1,6 +1,6 @@ .filters { .toggle-more-filters a { - color: $link-color; + color: var(--bs-link-color); text-decoration: underline; cursor: pointer; } diff --git a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss index 9e536626b0..71d19dbdf8 100644 --- a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss +++ b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss @@ -1,27 +1,28 @@ .filters { .toggle-more-filters a { - color: $link-color; + color: var(--bs-link-color); text-decoration: underline; cursor: pointer; } } -$slider-handle-width: 18px; ::ng-deep { + --ds-slider-handle-width: 18px; + html:not([dir=rtl]) .noUi-horizontal .noUi-handle { - right: -$slider-handle-width/2; + right: calc(var(--ds-slider-handle-width) / -2); } .noUi-horizontal .noUi-handle { - width: $slider-handle-width; + width: var(--ds-slider-handle-width); &:before { - left: ($slider-handle-width - 2)/2 - 2; + left: calc(calc(calc(var(--ds-slider-handle-width) - 2) / 2) - 2); } &:after { - left: ($slider-handle-width - 2)/2 + 2; + left: calc(calc(calc(var(--ds-slider-handle-width) - 2) / 2) + 2); } &:focus { outline: none; } } -} \ No newline at end of file +} diff --git a/src/app/shared/search/search-filters/search-filter/search-text-filter/search-text-filter.component.scss b/src/app/shared/search/search-filters/search-filter/search-text-filter/search-text-filter.component.scss index 1d062960c7..815ff35642 100644 --- a/src/app/shared/search/search-filters/search-filter/search-text-filter/search-text-filter.component.scss +++ b/src/app/shared/search/search-filters/search-filter/search-text-filter/search-text-filter.component.scss @@ -1,7 +1,7 @@ .filters { .toggle-more-filters a { - color: $link-color; + color: var(--bs-link-color); text-decoration: underline; cursor: pointer; } diff --git a/src/app/shared/search/search-filters/search-filters.component.spec.ts b/src/app/shared/search/search-filters/search-filters.component.spec.ts index aaea82df27..2dd810db63 100644 --- a/src/app/shared/search/search-filters/search-filters.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filters.component.spec.ts @@ -7,7 +7,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { SearchFilterService } from '../../../core/shared/search/search-filter.service'; import { SearchFiltersComponent } from './search-filters.component'; import { SearchService } from '../../../core/shared/search/search.service'; -import { of as observableOf } from 'rxjs'; +import { of as observableOf, Subject } from 'rxjs'; import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component'; import { SearchConfigurationServiceStub } from '../../testing/search-configuration-service.stub'; @@ -66,4 +66,26 @@ describe('SearchFiltersComponent', () => { }); }); + describe('when refreshSearch observable is present and emit events', () => { + + let refreshFiltersEmitter: Subject; + + beforeEach(() => { + spyOn(comp, 'initFilters').and.callFake(() => { /****/}); + + refreshFiltersEmitter = new Subject(); + comp.refreshFilters = refreshFiltersEmitter.asObservable(); + comp.ngOnInit(); + }); + + it('should reinitialize search filters', () => { + + expect(comp.initFilters).toHaveBeenCalledTimes(1); + + refreshFiltersEmitter.next(); + + expect(comp.initFilters).toHaveBeenCalledTimes(2); + }); + }); + }); diff --git a/src/app/shared/search/search-filters/search-filters.component.ts b/src/app/shared/search/search-filters/search-filters.component.ts index 5daa0f17e0..348af6743d 100644 --- a/src/app/shared/search/search-filters/search-filters.component.ts +++ b/src/app/shared/search/search-filters/search-filters.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, Input, OnInit } from '@angular/core'; +import { Component, Inject, Input, OnDestroy, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; @@ -12,17 +12,19 @@ import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component'; import { currentPath } from '../../utils/route.utils'; import { Router } from '@angular/router'; +import { hasValue } from '../../empty.util'; @Component({ selector: 'ds-search-filters', styleUrls: ['./search-filters.component.scss'], templateUrl: './search-filters.component.html', + }) /** * This component represents the part of the search sidebar that contains filters. */ -export class SearchFiltersComponent implements OnInit { +export class SearchFiltersComponent implements OnInit, OnDestroy { /** * An observable containing configuration about which filters are shown and how they are shown */ @@ -39,11 +41,18 @@ export class SearchFiltersComponent implements OnInit { */ @Input() inPlaceSearch; + /** + * Emits when the search filters values may be stale, and so they must be refreshed. + */ + @Input() refreshFilters: Observable; + /** * Link to the search page */ searchLink: string; + subs = []; + /** * Initialize instance variables * @param {SearchService} searchService @@ -58,9 +67,12 @@ export class SearchFiltersComponent implements OnInit { } ngOnInit(): void { - this.filters = this.searchConfigService.searchOptions.pipe( - switchMap((options) => this.searchService.getConfig(options.scope, options.configuration).pipe(getFirstSucceededRemoteData())), - ); + + this.initFilters(); + + if (this.refreshFilters) { + this.subs.push(this.refreshFilters.subscribe(() => this.initFilters())); + } this.clearParams = this.searchConfigService.getCurrentFrontendFilters().pipe(map((filters) => { Object.keys(filters).forEach((f) => filters[f] = null); @@ -69,6 +81,12 @@ export class SearchFiltersComponent implements OnInit { this.searchLink = this.getSearchLink(); } + initFilters() { + this.filters = this.searchConfigService.searchOptions.pipe( + switchMap((options) => this.searchService.getConfig(options.scope, options.configuration).pipe(getFirstSucceededRemoteData())), + ); + } + /** * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ @@ -85,4 +103,12 @@ export class SearchFiltersComponent implements OnInit { trackUpdate(index, config: SearchFilterConfig) { return config ? config.name : undefined; } + + ngOnDestroy() { + this.subs.forEach((sub) => { + if (hasValue(sub)) { + sub.unsubscribe(); + } + }); + } } diff --git a/src/app/shared/search/search-settings/search-settings.component.scss b/src/app/shared/search/search-settings/search-settings.component.scss index cd18456888..b1a3c083ef 100644 --- a/src/app/shared/search/search-settings/search-settings.component.scss +++ b/src/app/shared/search/search-settings/search-settings.component.scss @@ -1,3 +1,3 @@ .setting-option { - border: 1px solid map-get($theme-colors, light); + border: 1px solid var(--bs-light); } diff --git a/src/app/shared/search/search-sidebar/search-sidebar.component.html b/src/app/shared/search/search-sidebar/search-sidebar.component.html index 638aed7834..74abeadfd8 100644 --- a/src/app/shared/search/search-sidebar/search-sidebar.component.html +++ b/src/app/shared/search/search-sidebar/search-sidebar.component.html @@ -11,7 +11,7 @@ diff --git a/src/app/shared/search/search-sidebar/search-sidebar.component.scss b/src/app/shared/search/search-sidebar/search-sidebar.component.scss index 38e742bca4..130ec91aec 100644 --- a/src/app/shared/search/search-sidebar/search-sidebar.component.scss +++ b/src/app/shared/search/search-sidebar/search-sidebar.component.scss @@ -1,16 +1,16 @@ :host { .results { - line-height: $button-height; + line-height: var(--ds-button-height); } ds-view-mode-switch { - margin-bottom: $spacer; + margin-bottom: var(--bs-spacer); } .sidebar-content > *:not(:last-child):not(ds-search-switch-configuration) { - margin-bottom: 4*$spacer; + margin-bottom: calc(4 * var(--bs-spacer)); display: block; } ds-search-switch-configuration { - margin-bottom: 2*$spacer; + margin-bottom: calc(2 * var(--bs-spacer)); display: block; } } diff --git a/src/app/shared/search/search-sidebar/search-sidebar.component.ts b/src/app/shared/search/search-sidebar/search-sidebar.component.ts index 42e8a444bc..2060e0f345 100644 --- a/src/app/shared/search/search-sidebar/search-sidebar.component.ts +++ b/src/app/shared/search/search-sidebar/search-sidebar.component.ts @@ -1,6 +1,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { SearchConfigurationOption } from '../search-switch-configuration/search-configuration-option.model'; +import { Observable } from 'rxjs'; /** * This component renders a simple item page. @@ -44,6 +45,11 @@ export class SearchSidebarComponent { */ @Input() inPlaceSearch; + /** + * Emits when the search filters values may be stale, and so they must be refreshed. + */ + @Input() refreshFilters: Observable; + /** * Emits event when the user clicks a button to open or close the sidebar */ diff --git a/src/app/shared/sidebar/filter/sidebar-filter-selected-option.component.scss b/src/app/shared/sidebar/filter/sidebar-filter-selected-option.component.scss index b4e9cd340c..30ab8912d3 100644 --- a/src/app/shared/sidebar/filter/sidebar-filter-selected-option.component.scss +++ b/src/app/shared/sidebar/filter/sidebar-filter-selected-option.component.scss @@ -1,5 +1,5 @@ a { - color: $body-color; + color: var(--bs-body-color); &:hover, &focus { text-decoration: none; diff --git a/src/app/shared/sidebar/filter/sidebar-filter.component.scss b/src/app/shared/sidebar/filter/sidebar-filter.component.scss index 68949f3450..bf7a089cb1 100644 --- a/src/app/shared/sidebar/filter/sidebar-filter.component.scss +++ b/src/app/shared/sidebar/filter/sidebar-filter.component.scss @@ -1,5 +1,5 @@ :host .facet-filter { - border: 1px solid map-get($theme-colors, light); + border: 1px solid var(--bs-light); cursor: pointer; .sidebar-filter-wrapper.closed { @@ -7,6 +7,6 @@ } .filter-toggle { - line-height: $line-height-base; + line-height: var(--bs-line-height-base); } } diff --git a/src/app/shared/sidebar/page-with-sidebar.component.scss b/src/app/shared/sidebar/page-with-sidebar.component.scss index 8be48cea2b..4949694313 100644 --- a/src/app/shared/sidebar/page-with-sidebar.component.scss +++ b/src/app/shared/sidebar/page-with-sidebar.component.scss @@ -43,9 +43,9 @@ position: sticky; position: -webkit-sticky; top: 0; - z-index: $zindex-sticky; - padding-top: $content-spacing; - margin-top: -$content-spacing; + z-index: var(--bs-zindex-sticky); + padding-top: var(--ds-content-spacing); + margin-top: calc(-1 * var(--ds-content-spacing)); align-self: flex-start; display: block; } diff --git a/src/app/shared/sidebar/sidebar-dropdown.component.scss b/src/app/shared/sidebar/sidebar-dropdown.component.scss index 1c025095dd..2da7be11b5 100644 --- a/src/app/shared/sidebar/sidebar-dropdown.component.scss +++ b/src/app/shared/sidebar/sidebar-dropdown.component.scss @@ -1,3 +1,3 @@ .setting-option { - border: 1px solid map-get($theme-colors, light); + border: 1px solid var(--bs-light); } diff --git a/src/app/shared/starts-with/date/starts-with-date.component.scss b/src/app/shared/starts-with/date/starts-with-date.component.scss index 64b97aebd8..4576be4b28 100644 --- a/src/app/shared/starts-with/date/starts-with-date.component.scss +++ b/src/app/shared/starts-with/date/starts-with-date.component.scss @@ -1,5 +1,5 @@ // temporary fix for bootstrap 4 beta btn color issue .btn-secondary { - background-color: $input-bg; - color: $input-color; + background-color: var(--bs-input-bg); + color: var(--bs-input-color); } diff --git a/src/app/shared/starts-with/text/starts-with-text.component.scss b/src/app/shared/starts-with/text/starts-with-text.component.scss index 64b97aebd8..4576be4b28 100644 --- a/src/app/shared/starts-with/text/starts-with-text.component.scss +++ b/src/app/shared/starts-with/text/starts-with-text.component.scss @@ -1,5 +1,5 @@ // temporary fix for bootstrap 4 beta btn color issue .btn-secondary { - background-color: $input-bg; - color: $input-color; + background-color: var(--bs-input-bg); + color: var(--bs-input-color); } diff --git a/src/app/shared/theme-support/test/custom/themed-test.component.spec.ts b/src/app/shared/theme-support/test/custom/themed-test.component.spec.ts new file mode 100644 index 0000000000..6df7fe52e6 --- /dev/null +++ b/src/app/shared/theme-support/test/custom/themed-test.component.spec.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +// noinspection AngularMissingOrInvalidDeclarationInModule +@Component({ + selector: 'ds-test-component', + template: '' +}) +export class TestComponent { + type = 'themed'; + testInput = 'unset'; +} diff --git a/src/app/shared/theme-support/test/test.component.spec.ts b/src/app/shared/theme-support/test/test.component.spec.ts new file mode 100644 index 0000000000..85e2e5aaa7 --- /dev/null +++ b/src/app/shared/theme-support/test/test.component.spec.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +// noinspection AngularMissingOrInvalidDeclarationInModule +@Component({ + selector: 'ds-test-component', + template: '' +}) +export class TestComponent { + type = 'default'; + testInput = 'unset'; +} diff --git a/src/app/shared/theme-support/theme.actions.ts b/src/app/shared/theme-support/theme.actions.ts new file mode 100644 index 0000000000..dbc885772d --- /dev/null +++ b/src/app/shared/theme-support/theme.actions.ts @@ -0,0 +1,22 @@ +import { Action } from '@ngrx/store'; +import { type } from '../ngrx/type'; + +export const ThemeActionTypes = { + SET: type('dspace/theme/SET'), +}; + +/** + * An action to set the current theme + */ +export class SetThemeAction implements Action { + type = ThemeActionTypes.SET; + payload: { + name: string + }; + + constructor(name: string) { + this.payload = { name }; + } +} + +export type ThemeAction = SetThemeAction; diff --git a/src/app/shared/theme-support/theme.constants.ts b/src/app/shared/theme-support/theme.constants.ts new file mode 100644 index 0000000000..2304461d92 --- /dev/null +++ b/src/app/shared/theme-support/theme.constants.ts @@ -0,0 +1 @@ +export const BASE_THEME_NAME = 'base'; diff --git a/src/app/shared/theme-support/theme.effects.spec.ts b/src/app/shared/theme-support/theme.effects.spec.ts new file mode 100644 index 0000000000..7a0e9c8f19 --- /dev/null +++ b/src/app/shared/theme-support/theme.effects.spec.ts @@ -0,0 +1,314 @@ +import { ThemeEffects } from './theme.effects'; +import { of as observableOf } from 'rxjs'; +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { LinkService } from '../../core/cache/builders/link.service'; +import { cold, hot } from 'jasmine-marbles'; +import { ROOT_EFFECTS_INIT } from '@ngrx/effects'; +import { SetThemeAction } from './theme.actions'; +import { Theme } from '../../../config/theme.model'; +import { provideMockStore } from '@ngrx/store/testing'; +import { ROUTER_NAVIGATED } from '@ngrx/router-store'; +import { ResolverActionTypes } from '../../core/resolving/resolver.actions'; +import { Community } from '../../core/shared/community.model'; +import { COMMUNITY } from '../../core/shared/community.resource-type'; +import { NoOpAction } from '../ngrx/no-op.action'; +import { ITEM } from '../../core/shared/item.resource-type'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { Item } from '../../core/shared/item.model'; +import { Collection } from '../../core/shared/collection.model'; +import { COLLECTION } from '../../core/shared/collection.resource-type'; +import { + createNoContentRemoteDataObject$, + createSuccessfulRemoteDataObject$ +} from '../remote-data.utils'; +import { BASE_THEME_NAME } from './theme.constants'; + +/** + * LinkService able to mock recursively resolving DSO parent links + * Every time resolveLinkWithoutAttaching is called, it returns the next object in the array of ancestorDSOs until + * none are left, after which it returns a no-content remote-date + */ +class MockLinkService { + index = -1; + + constructor(private ancestorDSOs: DSpaceObject[]) { + } + + resolveLinkWithoutAttaching() { + if (this.index >= this.ancestorDSOs.length - 1) { + return createNoContentRemoteDataObject$(); + } else { + this.index++; + return createSuccessfulRemoteDataObject$(this.ancestorDSOs[this.index]); + } + } +} + +describe('ThemeEffects', () => { + let themeEffects: ThemeEffects; + let linkService: LinkService; + let initialState; + + let ancestorDSOs: DSpaceObject[]; + + function init() { + ancestorDSOs = [ + Object.assign(new Collection(), { + type: COLLECTION.value, + uuid: 'collection-uuid', + _links: { owningCommunity: { href: 'owning-community-link' } } + }), + Object.assign(new Community(), { + type: COMMUNITY.value, + uuid: 'sub-community-uuid', + _links: { parentCommunity: { href: 'parent-community-link' } } + }), + Object.assign(new Community(), { + type: COMMUNITY.value, + uuid: 'top-community-uuid', + }), + ]; + linkService = new MockLinkService(ancestorDSOs) as any; + initialState = { + theme: { + currentTheme: 'custom', + }, + }; + } + + function setupEffectsWithActions(mockActions) { + init(); + TestBed.configureTestingModule({ + providers: [ + ThemeEffects, + { provide: LinkService, useValue: linkService }, + provideMockStore({ initialState }), + provideMockActions(() => mockActions) + ] + }); + + themeEffects = TestBed.inject(ThemeEffects); + } + + describe('initTheme$', () => { + beforeEach(() => { + setupEffectsWithActions( + hot('--a-', { + a: { + type: ROOT_EFFECTS_INIT + } + }) + ); + }); + + it('should set the default theme', () => { + const expected = cold('--b-', { + b: new SetThemeAction(BASE_THEME_NAME) + }); + + expect(themeEffects.initTheme$).toBeObservable(expected); + }); + }); + + describe('updateThemeOnRouteChange$', () => { + const url = '/test/route'; + const dso = Object.assign(new Community(), { + type: COMMUNITY.value, + uuid: '0958c910-2037-42a9-81c7-dca80e3892b4', + }); + + function spyOnPrivateMethods() { + spyOn((themeEffects as any), 'getAncestorDSOs').and.returnValue(() => observableOf([dso])); + spyOn((themeEffects as any), 'matchThemeToDSOs').and.returnValue(new Theme({ name: 'custom' })); + spyOn((themeEffects as any), 'getActionForMatch').and.returnValue(new SetThemeAction('custom')); + } + + describe('when a resolved action is present', () => { + beforeEach(() => { + setupEffectsWithActions( + hot('--ab-', { + a: { + type: ROUTER_NAVIGATED, + payload: { routerState: { url } }, + }, + b: { + type: ResolverActionTypes.RESOLVED, + payload: { url, dso }, + } + }) + ); + spyOnPrivateMethods(); + }); + + it('should set the theme it receives from the DSO', () => { + const expected = cold('--b-', { + b: new SetThemeAction('custom') + }); + + expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected); + }); + }); + + describe('when no resolved action is present', () => { + beforeEach(() => { + setupEffectsWithActions( + hot('--a-', { + a: { + type: ROUTER_NAVIGATED, + payload: { routerState: { url } }, + }, + }) + ); + spyOnPrivateMethods(); + }); + + it('should set the theme it receives from the route url', () => { + const expected = cold('--b-', { + b: new SetThemeAction('custom') + }); + + expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected); + }); + }); + + describe('when no themes are present', () => { + beforeEach(() => { + setupEffectsWithActions( + hot('--a-', { + a: { + type: ROUTER_NAVIGATED, + payload: { routerState: { url } }, + }, + }) + ); + (themeEffects as any).themes = []; + }); + + it('should return an empty action', () => { + const expected = cold('--b-', { + b: new NoOpAction() + }); + + expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected); + }); + }); + }); + + describe('private functions', () => { + beforeEach(() => { + setupEffectsWithActions(hot('-', {})); + }); + + describe('getActionForMatch', () => { + it('should return a SET action if the new theme differs from the current theme', () => { + const theme = new Theme({ name: 'new-theme' }); + expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new SetThemeAction('new-theme')); + }); + + it('should return an empty action if the new theme equals the current theme', () => { + const theme = new Theme({ name: 'old-theme' }); + expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new NoOpAction()); + }); + }); + + describe('matchThemeToDSOs', () => { + let themes: Theme[]; + let nonMatchingTheme: Theme; + let itemMatchingTheme: Theme; + let communityMatchingTheme: Theme; + let dsos: DSpaceObject[]; + + beforeEach(() => { + nonMatchingTheme = Object.assign(new Theme({ name: 'non-matching-theme' }), { + matches: () => false + }); + itemMatchingTheme = Object.assign(new Theme({ name: 'item-matching-theme' }), { + matches: (url, dso) => (dso as any).type === ITEM.value + }); + communityMatchingTheme = Object.assign(new Theme({ name: 'community-matching-theme' }), { + matches: (url, dso) => (dso as any).type === COMMUNITY.value + }); + dsos = [ + Object.assign(new Item(), { + type: ITEM.value, + uuid: 'item-uuid', + }), + Object.assign(new Collection(), { + type: COLLECTION.value, + uuid: 'collection-uuid', + }), + Object.assign(new Community(), { + type: COMMUNITY.value, + uuid: 'community-uuid', + }), + ]; + }); + + describe('when no themes match any of the DSOs', () => { + beforeEach(() => { + themes = [ nonMatchingTheme ]; + themeEffects.themes = themes; + }); + + it('should return undefined', () => { + expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toBeUndefined(); + }); + }); + + describe('when one of the themes match a DSOs', () => { + beforeEach(() => { + themes = [ nonMatchingTheme, itemMatchingTheme ]; + themeEffects.themes = themes; + }); + + it('should return the matching theme', () => { + expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme); + }); + }); + + describe('when multiple themes match some of the DSOs', () => { + it('should return the first matching theme', () => { + themes = [ nonMatchingTheme, itemMatchingTheme, communityMatchingTheme ]; + themeEffects.themes = themes; + expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme); + + themes = [ nonMatchingTheme, communityMatchingTheme, itemMatchingTheme ]; + themeEffects.themes = themes; + expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(communityMatchingTheme); + }); + }); + }); + + describe('getAncestorDSOs', () => { + it('should return an array of the provided DSO and its ancestors', (done) => { + const dso = Object.assign(new Item(), { + type: ITEM.value, + uuid: 'item-uuid', + _links: { owningCollection: { href: 'owning-collection-link' } }, + }); + + observableOf(dso).pipe( + (themeEffects as any).getAncestorDSOs() + ).subscribe((result) => { + expect(result).toEqual([dso, ...ancestorDSOs]); + done(); + }); + }); + + it('should return an array of just the provided DSO if it doesn\'t have any parents', (done) => { + const dso = { + type: ITEM.value, + uuid: 'item-uuid', + }; + + observableOf(dso).pipe( + (themeEffects as any).getAncestorDSOs() + ).subscribe((result) => { + expect(result).toEqual([dso]); + done(); + }); + }); + }); + }); +}); diff --git a/src/app/shared/theme-support/theme.effects.ts b/src/app/shared/theme-support/theme.effects.ts new file mode 100644 index 0000000000..894cfeca75 --- /dev/null +++ b/src/app/shared/theme-support/theme.effects.ts @@ -0,0 +1,185 @@ +import { Injectable } from '@angular/core'; +import { createEffect, Actions, ofType, ROOT_EFFECTS_INIT } from '@ngrx/effects'; +import { ROUTER_NAVIGATED, RouterNavigatedAction } from '@ngrx/router-store'; +import { map, withLatestFrom, expand, switchMap, toArray, startWith, filter } from 'rxjs/operators'; +import { SetThemeAction } from './theme.actions'; +import { environment } from '../../../environments/environment'; +import { ThemeConfig, themeFactory, Theme, } from '../../../config/theme.model'; +import { hasValue, isNotEmpty, hasNoValue } from '../empty.util'; +import { NoOpAction } from '../ngrx/no-op.action'; +import { Store, select } from '@ngrx/store'; +import { ThemeState } from './theme.reducer'; +import { currentThemeSelector } from './theme.service'; +import { of as observableOf, EMPTY, Observable } from 'rxjs'; +import { ResolverActionTypes, ResolvedAction } from '../../core/resolving/resolver.actions'; +import { followLink } from '../utils/follow-link-config.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { LinkService } from '../../core/cache/builders/link.service'; +import { BASE_THEME_NAME } from './theme.constants'; + +export const DEFAULT_THEME_CONFIG = environment.themes.find((themeConfig: any) => + hasNoValue(themeConfig.regex) && + hasNoValue(themeConfig.handle) && + hasNoValue(themeConfig.uuid) +); + +@Injectable() +export class ThemeEffects { + /** + * The list of configured themes + */ + themes: Theme[]; + + /** + * True if at least one theme depends on the route + */ + hasDynamicTheme: boolean; + + /** + * Initialize with a theme that doesn't depend on the route. + */ + initTheme$ = createEffect(() => + this.actions$.pipe( + ofType(ROOT_EFFECTS_INIT), + map(() => { + if (hasValue(DEFAULT_THEME_CONFIG)) { + return new SetThemeAction(DEFAULT_THEME_CONFIG.name); + } else { + return new SetThemeAction(BASE_THEME_NAME); + } + }) + ) + ); + + /** + * An effect that fires when a route change completes, + * and determines whether or not the theme should change + */ + updateThemeOnRouteChange$ = createEffect(() => this.actions$.pipe( + // Listen for when a route change ends + ofType(ROUTER_NAVIGATED), + withLatestFrom( + // Pull in the latest resolved action, or undefined if none was dispatched yet + this.actions$.pipe(ofType(ResolverActionTypes.RESOLVED), startWith(undefined)), + // and the current theme from the store + this.store.pipe(select(currentThemeSelector)) + ), + switchMap(([navigatedAction, resolvedAction, currentTheme]: [RouterNavigatedAction, ResolvedAction, string]) => { + if (this.hasDynamicTheme === true && isNotEmpty(this.themes)) { + const currentRouteUrl = navigatedAction.payload.routerState.url; + // If resolvedAction exists, and deals with the current url + if (hasValue(resolvedAction) && resolvedAction.payload.url === currentRouteUrl) { + // Start with the resolved dso and go recursively through its parents until you reach the top-level community + return observableOf(resolvedAction.payload.dso).pipe( + this.getAncestorDSOs(), + map((dsos: DSpaceObject[]) => { + const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl); + return this.getActionForMatch(dsoMatch, currentTheme); + }) + ); + } + + // check whether the route itself matches + const routeMatch = this.themes.find((theme: Theme) => theme.matches(currentRouteUrl, undefined)); + + return [this.getActionForMatch(routeMatch, currentTheme)]; + } + + // If there are no themes configured, do nothing + return [new NoOpAction()]; + }) + ) + ); + + /** + * return the action to dispatch based on the given matching theme + * + * @param newTheme The theme to create an action for + * @param currentThemeName The name of the currently active theme + * @private + */ + private getActionForMatch(newTheme: Theme, currentThemeName: string): SetThemeAction | NoOpAction { + if (hasValue(newTheme) && newTheme.config.name !== currentThemeName) { + // If we have a match, and it isn't already the active theme, set it as the new theme + return new SetThemeAction(newTheme.config.name); + } else { + // Otherwise, do nothing + return new NoOpAction(); + } + } + + /** + * Check the given DSpaceObjects in order to see if they match the configured themes in order. + * If a match is found, the matching theme is returned + * + * @param dsos The DSpaceObjects to check + * @param currentRouteUrl The url for the current route + * @private + */ + private matchThemeToDSOs(dsos: DSpaceObject[], currentRouteUrl: string): Theme { + // iterate over the themes in order, and return the first one that matches + return this.themes.find((theme: Theme) => { + // iterate over the dsos's in order (most specific one first, so Item, Collection, + // Community), and return the first one that matches the current theme + const match = dsos.find((dso: DSpaceObject) => theme.matches(currentRouteUrl, dso)); + return hasValue(match); + }); + + } + + /** + * An rxjs operator that will return an array of all the ancestors of the DSpaceObject used as + * input. The initial DSpaceObject will be the first element of the output array, followed by + * its parent, its grandparent etc + * + * @private + */ + private getAncestorDSOs() { + return (source: Observable): Observable => + source.pipe( + expand((dso: DSpaceObject) => { + // Check if the dso exists and has a parent link + if (hasValue(dso) && typeof (dso as any).getParentLinkKey === 'function') { + const linkName = (dso as any).getParentLinkKey(); + // If it does, retrieve it. + return this.linkService.resolveLinkWithoutAttaching(dso, followLink(linkName)).pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData) => { + if (hasValue(rd.payload)) { + // If there's a parent, use it for the next iteration + return rd.payload; + } else { + // If there's no parent, or an error, return null, which will stop recursion + // in the next iteration + return null; + } + }), + ); + } + + // The current dso has no value, or no parent. Return EMPTY to stop recursion + return EMPTY; + }), + // only allow through DSOs that have a value + filter((dso: DSpaceObject) => hasValue(dso)), + // Wait for recursion to complete, and emit all results at once, in an array + toArray() + ); + } + + constructor( + private actions$: Actions, + private store: Store, + private linkService: LinkService, + ) { + // Create objects from the theme configs in the environment file + this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig)); + this.hasDynamicTheme = environment.themes.some((themeConfig: any) => + hasValue(themeConfig.regex) || + hasValue(themeConfig.handle) || + hasValue(themeConfig.uuid) + ); + } +} diff --git a/src/app/shared/theme-support/theme.reducer.spec.ts b/src/app/shared/theme-support/theme.reducer.spec.ts new file mode 100644 index 0000000000..8733b88cda --- /dev/null +++ b/src/app/shared/theme-support/theme.reducer.spec.ts @@ -0,0 +1,18 @@ +import { SetThemeAction } from './theme.actions'; +import { themeReducer } from './theme.reducer'; + +describe('themeReducer', () => { + const testState = { + currentTheme: 'test' + }; + + it('should set the current theme in response to the SET action', () => { + const expectedState = { + currentTheme: 'newTheme' + }; + const action = new SetThemeAction('newTheme'); + const newState = themeReducer(testState, action); + + expect(newState).toEqual(expectedState); + }); +}); diff --git a/src/app/shared/theme-support/theme.reducer.ts b/src/app/shared/theme-support/theme.reducer.ts new file mode 100644 index 0000000000..26083a0cef --- /dev/null +++ b/src/app/shared/theme-support/theme.reducer.ts @@ -0,0 +1,22 @@ +import { ThemeAction, ThemeActionTypes } from './theme.actions'; + +export interface ThemeState { + currentTheme: string; +} + +const initialState: ThemeState = { + currentTheme: null +}; + +export function themeReducer(state: ThemeState = initialState, action: ThemeAction): ThemeState { + switch (action.type) { + case ThemeActionTypes.SET: { + return { + currentTheme: action.payload.name + }; + } + default: { + return state; + } + } +} diff --git a/src/app/shared/theme-support/theme.service.ts b/src/app/shared/theme-support/theme.service.ts new file mode 100644 index 0000000000..7b0af93e04 --- /dev/null +++ b/src/app/shared/theme-support/theme.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@angular/core'; +import { Store, createFeatureSelector, createSelector, select } from '@ngrx/store'; +import { Observable } from 'rxjs/internal/Observable'; +import { ThemeState } from './theme.reducer'; +import { SetThemeAction } from './theme.actions'; +import { take } from 'rxjs/operators'; +import { hasValue } from '../empty.util'; + +export const themeStateSelector = createFeatureSelector('theme'); + +export const currentThemeSelector = createSelector( + themeStateSelector, + (state: ThemeState): string => hasValue(state) ? state.currentTheme : undefined +); + +@Injectable({ + providedIn: 'root' +}) +export class ThemeService { + constructor( + private store: Store, + ) { + } + + setTheme(newName: string) { + this.store.dispatch(new SetThemeAction(newName)); + } + + getThemeName(): string { + let currentTheme: string; + this.store.pipe( + select(currentThemeSelector), + take(1) + ).subscribe((name: string) => + currentTheme = name + ); + return currentTheme; + } + + getThemeName$(): Observable { + return this.store.pipe( + select(currentThemeSelector) + ); + } + +} diff --git a/src/app/shared/theme-support/themed.component.html b/src/app/shared/theme-support/themed.component.html new file mode 100644 index 0000000000..4256518a10 --- /dev/null +++ b/src/app/shared/theme-support/themed.component.html @@ -0,0 +1 @@ + diff --git a/src/app/shared/theme-support/themed.component.scss b/src/app/shared/theme-support/themed.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/theme-support/themed.component.spec.ts b/src/app/shared/theme-support/themed.component.spec.ts new file mode 100644 index 0000000000..abaee28a29 --- /dev/null +++ b/src/app/shared/theme-support/themed.component.spec.ts @@ -0,0 +1,97 @@ +import { ThemedComponent } from './themed.component'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VarDirective } from '../utils/var.directive'; +import { ThemeService } from './theme.service'; +import { getMockThemeService } from '../mocks/theme-service.mock'; +import { TestComponent } from './test/test.component.spec'; + +/* tslint:disable:max-classes-per-file */ +@Component({ + selector: 'ds-test-themed-component', + templateUrl: './themed.component.html' +}) +class TestThemedComponent extends ThemedComponent { + protected inAndOutputNames: (keyof TestComponent & keyof this)[] = ['testInput']; + + testInput = 'unset'; + + protected getComponentName(): string { + return 'TestComponent'; + } + protected importThemedComponent(themeName: string): Promise { + return import(`./test/${themeName}/themed-test.component.spec`); + } + protected importUnthemedComponent(): Promise { + return import('./test/test.component.spec'); + } +} + +describe('ThemedComponent', () => { + let component: TestThemedComponent; + let fixture: ComponentFixture; + let themeService: ThemeService; + + function setupTestingModuleForTheme(theme: string) { + themeService = getMockThemeService(theme); + TestBed.configureTestingModule({ + imports: [], + declarations: [TestThemedComponent, VarDirective], + providers: [ + { provide: ThemeService, useValue: themeService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + } + + describe('when the current theme matches a themed component', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('custom'); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestThemedComponent); + component = fixture.componentInstance; + component.testInput = 'changed'; + fixture.detectChanges(); + }); + + it('should set compRef to the themed component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.type).toEqual('themed'); + }); + })); + + it('should sync up this component\'s input with the themed component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); + }); + + describe('when the current theme doesn\'t match a themed component', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('non-existing-theme'); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestThemedComponent); + component = fixture.componentInstance; + component.testInput = 'changed'; + fixture.detectChanges(); + }); + + it('should set compRef to the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.type).toEqual('default'); + }); + })); + + it('should sync up this component\'s input with the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); + }); +}); +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/shared/theme-support/themed.component.ts b/src/app/shared/theme-support/themed.component.ts new file mode 100644 index 0000000000..1a41327209 --- /dev/null +++ b/src/app/shared/theme-support/themed.component.ts @@ -0,0 +1,116 @@ +import { + Component, + ViewChild, + ViewContainerRef, + ComponentRef, + SimpleChanges, + OnInit, + OnDestroy, + ComponentFactoryResolver, + ChangeDetectorRef, + OnChanges +} from '@angular/core'; +import { hasValue, isNotEmpty } from '../empty.util'; +import { Subscription } from 'rxjs'; +import { ThemeService } from './theme.service'; +import { fromPromise } from 'rxjs/internal-compatibility'; +import { catchError, switchMap, map } from 'rxjs/operators'; +import { GenericConstructor } from '../../core/shared/generic-constructor'; + +@Component({ + selector: 'ds-themed', + styleUrls: ['./themed.component.scss'], + templateUrl: './themed.component.html', +}) +export abstract class ThemedComponent implements OnInit, OnDestroy, OnChanges { + @ViewChild('vcr', { read: ViewContainerRef }) vcr: ViewContainerRef; + protected compRef: ComponentRef; + + protected lazyLoadSub: Subscription; + protected themeSub: Subscription; + + protected inAndOutputNames: (keyof T & keyof this)[] = []; + + constructor( + protected resolver: ComponentFactoryResolver, + protected cdr: ChangeDetectorRef, + protected themeService: ThemeService + ) { + } + + protected abstract getComponentName(): string; + + protected abstract importThemedComponent(themeName: string): Promise; + protected abstract importUnthemedComponent(): Promise; + + ngOnChanges(changes: SimpleChanges): void { + // if an input or output has changed + if (this.inAndOutputNames.some((name: any) => hasValue(changes[name]))) { + this.connectInputsAndOutputs(); + } + } + + ngOnInit(): void { + this.destroyComponentInstance(); + this.themeSub = this.themeService.getThemeName$().subscribe(() => { + this.renderComponentInstance(); + }); + } + + ngOnDestroy(): void { + [this.themeSub, this.lazyLoadSub].filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + this.destroyComponentInstance(); + } + + protected renderComponentInstance(): void { + this.destroyComponentInstance(); + + if (hasValue(this.lazyLoadSub)) { + this.lazyLoadSub.unsubscribe(); + } + + this.lazyLoadSub = + fromPromise(this.importThemedComponent(this.themeService.getThemeName())).pipe( + // if there is no themed version of the component an exception is thrown, + // catch it and return null instead + catchError(() => [null]), + switchMap((themedFile: any) => { + if (hasValue(themedFile) && hasValue(themedFile[this.getComponentName()])) { + // if the file is not null, and exports a component with the specified name, + // return that component + return [themedFile[this.getComponentName()]]; + } else { + // otherwise import and return the default component + return fromPromise(this.importUnthemedComponent()).pipe( + map((unthemedFile: any) => { + return unthemedFile[this.getComponentName()]; + }) + ); + } + }), + ).subscribe((constructor: GenericConstructor) => { + const factory = this.resolver.resolveComponentFactory(constructor); + this.compRef = this.vcr.createComponent(factory); + this.connectInputsAndOutputs(); + this.cdr.markForCheck(); + }); + } + + protected destroyComponentInstance(): void { + if (hasValue(this.compRef)) { + this.compRef.destroy(); + this.compRef = null; + } + if (hasValue(this.vcr)) { + this.vcr.clear(); + } + } + + protected connectInputsAndOutputs(): void { + if (isNotEmpty(this.inAndOutputNames) && hasValue(this.compRef) && hasValue(this.compRef.instance)) { + this.inAndOutputNames.forEach((name: any) => { + this.compRef.instance[name] = this[name]; + }); + } + } +} diff --git a/src/app/shared/truncatable/truncatable-part/truncatable-part.component.scss b/src/app/shared/truncatable/truncatable-part/truncatable-part.component.scss index d2ddfc7383..186bb9be48 100644 --- a/src/app/shared/truncatable/truncatable-part/truncatable-part.component.scss +++ b/src/app/shared/truncatable/truncatable-part/truncatable-part.component.scss @@ -1,3 +1,4 @@ +//TODO switch to css variables @mixin clamp($lines, $bg, $size-factor: 1, $line-height: $line-height-base) { $height: $line-height * $font-size-base * $size-factor; &.fixedHeight { diff --git a/src/app/shared/uploader/uploader.component.scss b/src/app/shared/uploader/uploader.component.scss index 8835b87b1e..634b91c0e5 100644 --- a/src/app/shared/uploader/uploader.component.scss +++ b/src/app/shared/uploader/uploader.component.scss @@ -1,14 +1,14 @@ .ds-base-drop-zone { - border: 2px dashed $gray-600; + border: 2px dashed var(--bs-gray-600); } /* Default class applied to drop zones on over */ .ds-base-drop-zone-file-over { - border: 2px dashed map-get($theme-colors, primary); + border: 2px dashed var(--bs-primary); } .ds-base-drop-zone p { - min-height: $drop-zone-area-height; + min-height: var(--ds-drop-zone-area-height); } .ds-document-drop-zone { @@ -18,21 +18,21 @@ } .ds-document-drop-zone-active { - z-index: $drop-zone-area-z-index !important; + z-index: var(--ds-drop-zone-area-z-index) !important; } .ds-document-drop-zone-inner { - background-color: rgba($white, 0.7); - z-index: $drop-zone-area-inner-z-index; + background-color: rgba(255, 255, 255, 0.7); + z-index: var(--ds-drop-zone-area-inner-z-index); top: 0; left: 0; } .ds-document-drop-zone-inner-content { - border: 4px dashed map-get($theme-colors, primary); - z-index: $drop-zone-area-inner-z-index; + border: 4px dashed var(--bs-primary); + z-index: var(--ds-drop-zone-area-inner-z-index); } .ds-document-drop-zone-inner-content p { - font-size: ($font-size-lg * 2.5); + font-size: calc(var(--bs-font-size-lg) * 2.5); } diff --git a/src/app/shared/uploader/uploader.component.spec.ts b/src/app/shared/uploader/uploader.component.spec.ts index d33c27b897..6ff54578b5 100644 --- a/src/app/shared/uploader/uploader.component.spec.ts +++ b/src/app/shared/uploader/uploader.component.spec.ts @@ -10,6 +10,10 @@ import { UploaderComponent } from './uploader.component'; import { FileUploadModule } from 'ng2-file-upload'; import { TranslateModule } from '@ngx-translate/core'; import { createTestComponent } from '../testing/utils.test'; +import { HttpXsrfTokenExtractor } from '@angular/common/http'; +import { CookieService } from '../../core/services/cookie.service'; +import { CookieServiceMock } from '../mocks/cookie.service.mock'; +import { HttpXsrfTokenExtractorMock } from '../mocks/http-xsrf-token-extractor.mock'; describe('Chips component', () => { @@ -33,7 +37,9 @@ describe('Chips component', () => { ChangeDetectorRef, ScrollToService, UploaderComponent, - UploaderService + UploaderService, + { provide: HttpXsrfTokenExtractor, useValue: new HttpXsrfTokenExtractorMock('mock-token') }, + { provide: CookieService, useValue: new CookieServiceMock() }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }); diff --git a/src/app/shared/uploader/uploader.component.ts b/src/app/shared/uploader/uploader.component.ts index ac5c5c65a2..4ee17ac87b 100644 --- a/src/app/shared/uploader/uploader.component.ts +++ b/src/app/shared/uploader/uploader.component.ts @@ -18,6 +18,9 @@ import { UploaderOptions } from './uploader-options.model'; import { hasValue, isNotEmpty, isUndefined } from '../empty.util'; import { UploaderService } from './uploader.service'; import { UploaderProperties } from './uploader-properties.model'; +import { HttpXsrfTokenExtractor } from '@angular/common/http'; +import { XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER, XSRF_COOKIE } from '../../core/xsrf/xsrf.interceptor'; +import { CookieService } from '../../core/services/cookie.service'; @Component({ selector: 'ds-uploader', @@ -91,7 +94,9 @@ export class UploaderComponent { } } - constructor(private cdr: ChangeDetectorRef, private scrollToService: ScrollToService, private uploaderService: UploaderService) { + constructor(private cdr: ChangeDetectorRef, private scrollToService: ScrollToService, + private uploaderService: UploaderService, private tokenExtractor: HttpXsrfTokenExtractor, + private cookieService: CookieService) { } /** @@ -108,7 +113,7 @@ export class UploaderComponent { removeAfterUpload: true, autoUpload: this.uploadFilesOptions.autoUpload, method: this.uploadFilesOptions.method, - queueLimit: this.uploadFilesOptions.maxFileNumber + queueLimit: this.uploadFilesOptions.maxFileNumber, }); if (isUndefined(this.enableDragOverDocument)) { @@ -123,10 +128,6 @@ export class UploaderComponent { } ngAfterViewInit() { - // Maybe to remove: needed to avoid CORS issue with our temp upload server - this.uploader.onAfterAddingFile = ((item) => { - item.withCredentials = false; - }); this.uploader.onAfterAddingAll = ((items) => { this.onFileSelected.emit(items); }); @@ -137,6 +138,8 @@ export class UploaderComponent { if (item.url !== this.uploader.options.url) { item.url = this.uploader.options.url; } + // Ensure the current XSRF token is included in every upload request (token may change between items uploaded) + this.uploader.options.headers = [{ name: XSRF_REQUEST_HEADER, value: this.tokenExtractor.getToken() }]; this.onBeforeUpload(); this.isOverDocumentDropZone = observableOf(false); @@ -152,12 +155,30 @@ export class UploaderComponent { }; } this.uploader.onCompleteItem = (item: any, response: any, status: any, headers: any) => { + // Check for a changed XSRF token in response & save new token if found (to both cookie & header for next request) + // NOTE: this is only necessary because ng2-file-upload doesn't use an Http service and therefore never + // triggers our xsrf.interceptor.ts. See this bug: https://github.com/valor-software/ng2-file-upload/issues/950 + const token = headers[XSRF_RESPONSE_HEADER.toLowerCase()]; + if (isNotEmpty(token)) { + this.saveXsrfToken(token); + this.uploader.options.headers = [{ name: XSRF_REQUEST_HEADER, value: this.tokenExtractor.getToken() }]; + } + if (isNotEmpty(response)) { const responsePath = JSON.parse(response); this.onCompleteItem.emit(responsePath); } }; this.uploader.onErrorItem = (item: any, response: any, status: any, headers: any) => { + // Check for a changed XSRF token in response & save new token if found (to both cookie & header for next request) + // NOTE: this is only necessary because ng2-file-upload doesn't use an Http service and therefore never + // triggers our xsrf.interceptor.ts. See this bug: https://github.com/valor-software/ng2-file-upload/issues/950 + const token = headers[XSRF_RESPONSE_HEADER.toLowerCase()]; + if (isNotEmpty(token)) { + this.saveXsrfToken(token); + this.uploader.options.headers = [{ name: XSRF_REQUEST_HEADER, value: this.tokenExtractor.getToken() }]; + } + this.onUploadError.emit({ item: item, response: response, status: status, headers: headers }); this.uploader.cancelAll(); }; @@ -201,4 +222,18 @@ export class UploaderComponent { } } + /** + * Save XSRF token found in response. This is a temporary copy of the method in xsrf.interceptor.ts + * It can be removed once ng2-file-upload supports interceptors (see https://github.com/valor-software/ng2-file-upload/issues/950), + * or we switch to a new upload library (see https://github.com/DSpace/dspace-angular/issues/820) + * @param token token found + */ + private saveXsrfToken(token: string) { + // Save token value as a *new* value of our client-side XSRF-TOKEN cookie. + // This is the cookie that is parsed by Angular's tokenExtractor(), + // which we will send back in the X-XSRF-TOKEN header per Angular best practices. + this.cookieService.remove(XSRF_COOKIE); + this.cookieService.set(XSRF_COOKIE, token); + } + } diff --git a/src/app/store.actions.ts b/src/app/store.actions.ts index be36012ce5..aaeb319107 100644 --- a/src/app/store.actions.ts +++ b/src/app/store.actions.ts @@ -4,7 +4,7 @@ import { AppState } from './app.reducer'; export const StoreActionTypes = { REHYDRATE: type('dspace/ngrx/REHYDRATE'), - REPLAY: type('dspace/ngrx/REPLAY') + REPLAY: type('dspace/ngrx/REPLAY'), }; export class StoreAction implements Action { diff --git a/src/app/submission/form/collection/submission-form-collection.component.scss b/src/app/submission/form/collection/submission-form-collection.component.scss index deecc39510..907a111900 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.scss +++ b/src/app/submission/form/collection/submission-form-collection.component.scss @@ -1,15 +1,15 @@ .scrollable-menu { height: auto; - max-height: $dropdown-menu-max-height; + max-height: var(--ds-dropdown-menu-max-height); overflow-x: hidden; } .collection-item { - border-bottom: $dropdown-border-width solid $dropdown-border-color; + border-bottom: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color); } #collectionControlsDropdownMenu { outline: 0; left: 0 !important; - box-shadow: $btn-focus-box-shadow; + box-shadow: var(--bs-btn-focus-box-shadow); } diff --git a/src/app/submission/form/section-add/submission-form-section-add.component.scss b/src/app/submission/form/section-add/submission-form-section-add.component.scss index c8fd2073d1..36e7d8e5dd 100644 --- a/src/app/submission/form/section-add/submission-form-section-add.component.scss +++ b/src/app/submission/form/section-add/submission-form-section-add.component.scss @@ -3,5 +3,5 @@ } .sections-dropdown-menu { - z-index: $submission-header-z-index; + z-index: var(--ds-submission-header-z-index); } diff --git a/src/app/submission/form/submission-form.component.scss b/src/app/submission/form/submission-form.component.scss index 44551162cc..56d6288764 100644 --- a/src/app/submission/form/submission-form.component.scss +++ b/src/app/submission/form/submission-form.component.scss @@ -1,8 +1,8 @@ .submission-form-header { - background-color: rgba($white, .97); - padding: ($spacer / 2) 0 ($spacer / 2) 0; + background-color: rgba(255, 255, 255, .97); + padding: calc(var(--bs-spacer) / 2) 0 calc(var(--bs-spacer) / 2) 0; top: 0; - z-index: $submission-header-z-index; + z-index: var(--ds-submission-header-z-index); } .submission-form-header-item { @@ -10,10 +10,10 @@ } .submission-form-footer { - border-radius: $card-border-radius; + border-radius: var(--bs-card-border-radius); bottom: 0; - background-color: $gray-400; - padding: $spacer / 2; - z-index: $submission-footer-z-index; + background-color: var(--bs-gray-400); + padding: calc(var(--bs-spacer) / 2); + z-index: var(--ds-submission-footer-z-index); } diff --git a/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.scss b/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.scss index 136bf12e0e..97f49459c7 100644 --- a/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.scss +++ b/src/app/submission/import-external/import-external-searchbar/submission-import-external-searchbar.component.scss @@ -13,15 +13,15 @@ .scrollable-menu { height: auto; - max-height: $dropdown-menu-max-height / 2; + max-height: calc(var(--ds-dropdown-menu-max-height) / 2); overflow-x: hidden; } .scrollable-dropdown-loading { - background-color: map-get($theme-colors, primary); + background-color: var(--bs-primary); color: white; - height: $spacer * 2 !important; - line-height: $spacer * 2; + height: calc(var(--bs-spacer) * 2) !important; + line-height: calc(var(--bs-spacer) * 2); position: sticky; bottom: 0; } diff --git a/src/app/submission/sections/container/section-container.component.scss b/src/app/submission/sections/container/section-container.component.scss index 0255b71dac..f3e0ab6cf4 100644 --- a/src/app/submission/sections/container/section-container.component.scss +++ b/src/app/submission/sections/container/section-container.component.scss @@ -1,18 +1,18 @@ :host ::ng-deep .card { - margin-bottom: $submission-sections-margin-bottom; + margin-bottom: var(--ds-submission-sections-margin-bottom); overflow: unset; } .section-focus { - border-radius: $border-radius; - box-shadow: $btn-focus-box-shadow; + border-radius: var(--bs-border-radius); + box-shadow: var(--bs-btn-focus-box-shadow); } // TODO to remove the following when upgrading @ng-bootstrap :host ::ng-deep .card:first-of-type { - border-bottom: $card-border-width solid $card-border-color !important; - border-bottom-left-radius: $card-border-radius !important; - border-bottom-right-radius: $card-border-radius !important; + border-bottom: var(--bs-card-border-width) solid var(--bs-card-border-color) !important; + border-bottom-left-radius: var(--bs-card-border-radius) !important; + border-bottom-right-radius: var(--bs-card-border-radius) !important; } :host ::ng-deep .card-header button { diff --git a/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.html b/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.html index ae4c74e2eb..7c5a979eed 100644 --- a/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.html +++ b/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.html @@ -2,7 +2,6 @@ {{accessCondition.name}} {{accessCondition.startDate}} {{accessCondition.endDate}} - {{accessCondition.name}} {{accessCondition.name}} from {{accessCondition.endDate}} {{accessCondition.name}} until {{accessCondition.startDate}}
diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.spec.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.spec.ts index 919752f89f..a644cf8270 100644 --- a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.spec.ts +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.spec.ts @@ -43,10 +43,6 @@ describe('SubmissionSectionUploadFileEditComponent test suite', () => { const sectionId = 'upload'; const collectionId = mockSubmissionCollectionId; const availableAccessConditionOptions = mockUploadConfigResponse.accessConditionOptions; - const availableGroupsMap: Map = new Map([ - [mockUploadConfigResponse.accessConditionOptions[1].name, [mockGroup as any]], - [mockUploadConfigResponse.accessConditionOptions[2].name, [mockGroup as any]], - ]); const collectionPolicyType = POLICY_DEFAULT_WITH_LIST; const configMetadataForm: any = mockUploadConfigResponseMetadata; const fileIndex = '0'; @@ -123,7 +119,6 @@ describe('SubmissionSectionUploadFileEditComponent test suite', () => { comp.collectionId = collectionId; comp.sectionId = sectionId; comp.availableAccessConditionOptions = availableAccessConditionOptions; - comp.availableAccessConditionGroups = availableGroupsMap; comp.collectionPolicyType = collectionPolicyType; comp.fileIndex = fileIndex; comp.fileId = fileId; @@ -180,18 +175,15 @@ describe('SubmissionSectionUploadFileEditComponent test suite', () => { control.value = 'openaccess'; comp.setOptions(model, control); - expect(formbuilderService.findById).not.toHaveBeenCalledWith('groupUUID', (model.parent as DynamicFormArrayGroupModel).group); expect(formbuilderService.findById).not.toHaveBeenCalledWith('endDate', (model.parent as DynamicFormArrayGroupModel).group); expect(formbuilderService.findById).not.toHaveBeenCalledWith('startDate', (model.parent as DynamicFormArrayGroupModel).group); control.value = 'lease'; comp.setOptions(model, control); - expect(formbuilderService.findById).toHaveBeenCalledWith('groupUUID', (model.parent as DynamicFormArrayGroupModel).group); expect(formbuilderService.findById).toHaveBeenCalledWith('endDate', (model.parent as DynamicFormArrayGroupModel).group); control.value = 'embargo'; comp.setOptions(model, control); - expect(formbuilderService.findById).toHaveBeenCalledWith('groupUUID', (model.parent as DynamicFormArrayGroupModel).group); expect(formbuilderService.findById).toHaveBeenCalledWith('startDate', (model.parent as DynamicFormArrayGroupModel).group); }); }); @@ -208,7 +200,6 @@ class TestComponent { availableAccessConditionOptions; collectionId = mockSubmissionCollectionId; collectionPolicyType; - configMetadataForm$; fileIndexes = []; fileList = []; fileNames = []; diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts index 89a70f626f..512453d84e 100644 --- a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts @@ -22,8 +22,6 @@ import { BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT, - BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_CONFIG, - BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_LAYOUT, BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_CONFIG, BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT, BITSTREAM_FORM_ACCESS_CONDITION_TYPE_CONFIG, @@ -32,14 +30,13 @@ import { BITSTREAM_METADATA_FORM_GROUP_LAYOUT } from './section-upload-file-edit.model'; import { POLICY_DEFAULT_WITH_LIST } from '../../section-upload.component'; -import { isNotEmpty, isNotUndefined } from '../../../../../shared/empty.util'; +import { isNotEmpty } from '../../../../../shared/empty.util'; import { SubmissionFormsModel } from '../../../../../core/config/models/config-submission-forms.model'; import { FormFieldModel } from '../../../../../shared/form/builder/models/form-field.model'; import { AccessConditionOption } from '../../../../../core/config/models/config-access-condition-option.model'; import { SubmissionService } from '../../../../submission.service'; import { FormService } from '../../../../../shared/form/form.service'; import { FormComponent } from '../../../../../shared/form/form.component'; -import { Group } from '../../../../../core/eperson/models/group.model'; /** * This component represents the edit form for bitstream @@ -56,12 +53,6 @@ export class SubmissionSectionUploadFileEditComponent implements OnChanges { */ @Input() availableAccessConditionOptions: any[]; - /** - * The list of available groups for an access condition - * @type {Array} - */ - @Input() availableAccessConditionGroups: Map; - /** * The submission id * @type {string} @@ -210,19 +201,16 @@ export class SubmissionSectionUploadFileEditComponent implements OnChanges { }); const confStart = { relations: [{ match: MATCH_ENABLED, operator: OR_OPERATOR, when: hasStart }] }; const confEnd = { relations: [{ match: MATCH_ENABLED, operator: OR_OPERATOR, when: hasEnd }] }; - const confGroup = { relations: [{ match: MATCH_ENABLED, operator: OR_OPERATOR, when: hasGroups }] }; accessConditionsArrayConfig.groupFactory = () => { const type = new DynamicSelectModel(accessConditionTypeModelConfig, BITSTREAM_FORM_ACCESS_CONDITION_TYPE_LAYOUT); const startDateConfig = Object.assign({}, BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_CONFIG, confStart); const endDateConfig = Object.assign({}, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG, confEnd); - const groupsConfig = Object.assign({}, BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_CONFIG, confGroup); const startDate = new DynamicDatePickerModel(startDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT); const endDate = new DynamicDatePickerModel(endDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT); - const groups = new DynamicSelectModel(groupsConfig, BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_LAYOUT); - return [type, startDate, endDate, groups]; + return [type, startDate, endDate]; }; // Number of access conditions blocks in form @@ -244,19 +232,11 @@ export class SubmissionSectionUploadFileEditComponent implements OnChanges { */ public initModelData(formModel: DynamicFormControlModel[]) { this.fileData.accessConditions.forEach((accessCondition, index) => { - Array.of('name', 'groupUUID', 'startDate', 'endDate') + Array.of('name', 'startDate', 'endDate') .filter((key) => accessCondition.hasOwnProperty(key)) .forEach((key) => { const metadataModel: any = this.formBuilderService.findById(key, formModel, index); if (metadataModel) { - if (key === 'groupUUID' && this.availableAccessConditionGroups.get(accessCondition.name)) { - this.availableAccessConditionGroups.get(accessCondition.name).forEach((group) => { - metadataModel.options.push({ - label: group.name, - value: group.uuid - }); - }); - } if (metadataModel.type === DYNAMIC_FORM_CONTROL_TYPE_DATEPICKER) { const date = new Date(accessCondition[key]); metadataModel.value = { @@ -299,51 +279,18 @@ export class SubmissionSectionUploadFileEditComponent implements OnChanges { if (isNotEmpty(accessCondition)) { const showGroups: boolean = accessCondition.hasStartDate === true || accessCondition.hasEndDate === true; - const groupControl: FormControl = control.parent.get('groupUUID') as FormControl; const startDateControl: FormControl = control.parent.get('startDate') as FormControl; const endDateControl: FormControl = control.parent.get('endDate') as FormControl; // Clear previous state - groupControl.markAsUntouched(); startDateControl.markAsUntouched(); endDateControl.markAsUntouched(); - // Clear previous values - if (showGroups) { - groupControl.setValue(null); - } else { - groupControl.clearValidators(); - groupControl.setValue(accessCondition.groupUUID); - } startDateControl.setValue(null); control.parent.markAsDirty(); endDateControl.setValue(null); if (showGroups) { - if (isNotUndefined(accessCondition.groupUUID) || isNotUndefined(accessCondition.selectGroupUUID)) { - - const groupOptions = []; - if (isNotUndefined(this.availableAccessConditionGroups.get(accessCondition.name))) { - const groupModel = this.formBuilderService.findById( - 'groupUUID', - (model.parent as DynamicFormArrayGroupModel).group) as DynamicSelectModel; - - this.availableAccessConditionGroups.get(accessCondition.name).forEach((group) => { - groupOptions.push({ - label: group.name, - value: group.uuid - }); - }); - - // Due to a bug can't dynamically change the select options, so replace the model with a new one - const confGroup = { relation: groupModel.relations }; - const groupsConfig = Object.assign({}, BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_CONFIG, confGroup); - groupsConfig.options = groupOptions; - (model.parent as DynamicFormGroupModel).group.pop(); - (model.parent as DynamicFormGroupModel).group.push(new DynamicSelectModel(groupsConfig, BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_LAYOUT)); - } - - } if (accessCondition.hasStartDate) { const startDateModel = this.formBuilderService.findById( 'startDate', diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts index dd2ac7a2a7..096954659e 100644 --- a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts @@ -108,32 +108,3 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT: DynamicFormControl host: 'col-md-4' } }; - -export const BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_CONFIG: DynamicSelectModelConfig = { - id: 'groupUUID', - label: 'submission.sections.upload.form.group-label', - options: [], - relations: [ - { - match: MATCH_ENABLED, - operator: OR_OPERATOR, - when: [] - } - ], - required: true, - validators: { - required: null - }, - errorMessages: { - required: 'submission.sections.upload.form.group-required' - } -}; -export const BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_LAYOUT: DynamicFormControlLayout = { - element: { - container: 'p-0', - label: 'col-form-label' - }, - grid: { - host: 'col-sm-10' - } -}; diff --git a/src/app/submission/sections/upload/file/section-upload-file.component.html b/src/app/submission/sections/upload/file/section-upload-file.component.html index ea23340e9a..64df1155bf 100644 --- a/src/app/submission/sections/upload/file/section-upload-file.component.html +++ b/src/app/submission/sections/upload/file/section-upload-file.component.html @@ -37,7 +37,6 @@ { comp.collectionId = collectionId; comp.sectionId = sectionId; comp.availableAccessConditionOptions = availableAccessConditionOptions; - comp.availableAccessConditionGroups = availableGroupsMap; comp.collectionPolicyType = collectionPolicyType; comp.fileIndex = fileIndex; comp.fileId = fileId; @@ -258,9 +257,9 @@ describe('SubmissionSectionUploadFileComponent test suite', () => { operationsService.jsonPatchByResourceID.and.returnValue(observableOf(response)); const accessConditionsToSave = [ - { name: 'openaccess', groupUUID: '123456-g' }, - { name: 'lease', endDate: '2019-01-16T00:00:00Z', groupUUID: '123456-g' }, - { name: 'embargo', startDate: '2019-01-16T00:00:00Z', groupUUID: '123456-g' } + { name: 'openaccess' }, + { name: 'lease', endDate: '2019-01-16T00:00:00Z' }, + { name: 'embargo', startDate: '2019-01-16T00:00:00Z' }, ]; comp.saveBitstreamData(event); tick(); diff --git a/src/app/submission/sections/upload/file/section-upload-file.component.ts b/src/app/submission/sections/upload/file/section-upload-file.component.ts index ebaf438125..80945bc1fd 100644 --- a/src/app/submission/sections/upload/file/section-upload-file.component.ts +++ b/src/app/submission/sections/upload/file/section-upload-file.component.ts @@ -12,7 +12,6 @@ import { JsonPatchOperationsBuilder } from '../../../../core/json-patch/builder/ import { JsonPatchOperationPathCombiner } from '../../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { WorkspaceitemSectionUploadFileObject } from '../../../../core/submission/models/workspaceitem-section-upload-file.model'; import { SubmissionFormsModel } from '../../../../core/config/models/config-submission-forms.model'; -import { deleteProperty } from '../../../../shared/object.util'; import { dateToISOFormat } from '../../../../shared/date.util'; import { SubmissionService } from '../../../submission.service'; import { FileService } from '../../../../core/shared/file.service'; @@ -21,7 +20,6 @@ import { SubmissionJsonPatchOperationsService } from '../../../../core/submissio import { SubmissionObject } from '../../../../core/submission/models/submission-object.model'; import { WorkspaceitemSectionUploadObject } from '../../../../core/submission/models/workspaceitem-section-upload.model'; import { SubmissionSectionUploadFileEditComponent } from './edit/section-upload-file-edit.component'; -import { Group } from '../../../../core/eperson/models/group.model'; /** * This component represents a single bitstream contained in the submission @@ -39,12 +37,6 @@ export class SubmissionSectionUploadFileComponent implements OnChanges, OnInit { */ @Input() availableAccessConditionOptions: any[]; - /** - * The list of available groups for an access condition - * @type {Array} - */ - @Input() availableAccessConditionGroups: Map; - /** * The submission id * @type {string} @@ -172,7 +164,7 @@ export class SubmissionSectionUploadFileComponent implements OnChanges, OnInit { * Retrieve bitstream's metadata */ ngOnChanges() { - if (this.availableAccessConditionOptions && this.availableAccessConditionGroups) { + if (this.availableAccessConditionOptions) { // Retrieve file state this.subscriptions.push( this.uploadService @@ -272,28 +264,17 @@ export class SubmissionSectionUploadFileComponent implements OnChanges, OnInit { .forEach((element) => accessConditionOpt = element); if (accessConditionOpt) { - - if (accessConditionOpt.hasStartDate !== true && accessConditionOpt.hasEndDate !== true) { - accessConditionOpt = deleteProperty(accessConditionOpt, 'hasStartDate'); - - accessConditionOpt = deleteProperty(accessConditionOpt, 'hasEndDate'); - accessConditionsToSave.push(accessConditionOpt); - } else { accessConditionOpt = Object.assign({}, accessCondition); accessConditionOpt.name = this.retrieveValueFromField(accessCondition.name); - accessConditionOpt.groupUUID = this.retrieveValueFromField(accessCondition.groupUUID); if (accessCondition.startDate) { const startDate = this.retrieveValueFromField(accessCondition.startDate); accessConditionOpt.startDate = dateToISOFormat(startDate); - accessConditionOpt = deleteProperty(accessConditionOpt, 'endDate'); } if (accessCondition.endDate) { const endDate = this.retrieveValueFromField(accessCondition.endDate); accessConditionOpt.endDate = dateToISOFormat(endDate); - accessConditionOpt = deleteProperty(accessConditionOpt, 'startDate'); } accessConditionsToSave.push(accessConditionOpt); - } } }); diff --git a/src/app/submission/sections/upload/section-upload.component.html b/src/app/submission/sections/upload/section-upload.component.html index f07c524b8a..8a19f66220 100644 --- a/src/app/submission/sections/upload/section-upload.component.html +++ b/src/app/submission/sections/upload/section-upload.component.html @@ -28,7 +28,7 @@ - { expect(comp.availableAccessConditionOptions).toEqual(mockUploadConfigResponse.accessConditionOptions as any); expect(comp.required$.getValue()).toBe(true); expect(compAsAny.subs.length).toBe(2); - expect(compAsAny.availableGroups.size).toBe(2); - expect(compAsAny.availableGroups).toEqual(expectedGroupsMap); expect(compAsAny.fileList).toEqual([]); expect(compAsAny.fileIndexes).toEqual([]); expect(compAsAny.fileNames).toEqual([]); @@ -298,8 +296,6 @@ describe('SubmissionSectionUploadComponent test suite', () => { expect(comp.availableAccessConditionOptions).toEqual(mockUploadConfigResponse.accessConditionOptions as any); expect(comp.required$.getValue()).toBe(true); expect(compAsAny.subs.length).toBe(2); - expect(compAsAny.availableGroups.size).toBe(2); - expect(compAsAny.availableGroups).toEqual(expectedGroupsMap); expect(compAsAny.fileList).toEqual(mockUploadFiles); expect(compAsAny.fileIndexes).toEqual(['123456-test-upload']); expect(compAsAny.fileNames).toEqual(['123456-test-upload.jpg']); diff --git a/src/app/submission/sections/upload/section-upload.component.ts b/src/app/submission/sections/upload/section-upload.component.ts index deffc93466..07a2cb11fa 100644 --- a/src/app/submission/sections/upload/section-upload.component.ts +++ b/src/app/submission/sections/upload/section-upload.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectorRef, Component, Inject } from '@angular/core'; import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; -import { distinctUntilChanged, filter, find, map, mergeMap, reduce, switchMap, tap } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators'; import { SectionModelComponent } from '../models/section.model'; import { hasValue, isNotEmpty, isNotUndefined, isUndefined } from '../../../shared/empty.util'; @@ -23,7 +23,6 @@ import { SectionsService } from '../sections.service'; import { SubmissionService } from '../../submission.service'; import { Collection } from '../../../core/shared/collection.model'; import { AccessConditionOption } from '../../../core/config/models/config-access-condition-option.model'; -import { PaginatedList } from '../../../core/data/paginated-list.model'; import { followLink } from '../../../shared/utils/follow-link-config.model'; import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; @@ -100,11 +99,6 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { */ public availableAccessConditionOptions: AccessConditionOption[]; // List of accessConditions that an user can select - /** - * List of Groups available for every access condition - */ - public availableGroups: Map; // Groups for any policy - /** * Is the upload required * @type {boolean} @@ -184,53 +178,12 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { } }),*/ mergeMap(() => config$), - mergeMap((config: SubmissionUploadsModel) => { - this.required$.next(config.required); - this.availableAccessConditionOptions = isNotEmpty(config.accessConditionOptions) ? config.accessConditionOptions : []; - - this.collectionPolicyType = this.availableAccessConditionOptions.length > 0 - ? POLICY_DEFAULT_WITH_LIST - : POLICY_DEFAULT_NO_LIST; - - this.availableGroups = new Map(); - const mapGroups$: Observable[] = []; - // Retrieve Groups for accessCondition Policies - this.availableAccessConditionOptions.forEach((accessCondition: AccessConditionOption) => { - if (accessCondition.hasEndDate === true || accessCondition.hasStartDate === true) { - if (accessCondition.groupUUID) { - mapGroups$.push( - this.groupService.findById(accessCondition.groupUUID).pipe( - find((rd: RemoteData) => !rd.isResponsePending && rd.hasSucceeded), - map((rd: RemoteData) => ({ - accessCondition: accessCondition.name, - groups: [rd.payload] - } as AccessConditionGroupsMapEntry))) - ); - } else if (accessCondition.selectGroupUUID) { - mapGroups$.push( - this.groupService.findById(accessCondition.selectGroupUUID).pipe( - find((rd: RemoteData) => !rd.isResponsePending && rd.hasSucceeded), - mergeMap((group: RemoteData) => group.payload.subgroups), - find((rd: RemoteData>) => !rd.isResponsePending && rd.hasSucceeded), - map((rd: RemoteData>) => ({ - accessCondition: accessCondition.name, - groups: rd.payload.page - } as AccessConditionGroupsMapEntry)) - )); - } - } - }); - return mapGroups$; - }), - mergeMap((entry) => entry), - reduce((acc: any[], entry: AccessConditionGroupsMapEntry) => { - acc.push(entry); - return acc; - }, []), - ).subscribe((entries: AccessConditionGroupsMapEntry[]) => { - entries.forEach((entry: AccessConditionGroupsMapEntry) => { - this.availableGroups.set(entry.accessCondition, entry.groups); - }); + ).subscribe((config: SubmissionUploadsModel) => { + this.required$.next(config.required); + this.availableAccessConditionOptions = isNotEmpty(config.accessConditionOptions) ? config.accessConditionOptions : []; + this.collectionPolicyType = this.availableAccessConditionOptions.length > 0 + ? POLICY_DEFAULT_WITH_LIST + : POLICY_DEFAULT_NO_LIST; this.changeDetectorRef.detectChanges(); }), diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index b58ef087cb..5126058cba 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -468,6 +468,8 @@ "admin.search.title": "Administrative Search", + "administrativeView.search.results.head": "Administrative Search", + @@ -2492,64 +2494,6 @@ - "profile.breadcrumbs": "Update Profile", - - "profile.card.identify": "Identify", - - "profile.card.security": "Security", - - "profile.form.submit": "Update Profile", - - "profile.groups.head": "Authorization groups you belong to", - - "profile.head": "Update Profile", - - "profile.metadata.form.error.firstname.required": "First Name is required", - - "profile.metadata.form.error.lastname.required": "Last Name is required", - - "profile.metadata.form.label.email": "Email Address", - - "profile.metadata.form.label.firstname": "First Name", - - "profile.metadata.form.label.language": "Language", - - "profile.metadata.form.label.lastname": "Last Name", - - "profile.metadata.form.label.phone": "Contact Telephone", - - "profile.metadata.form.notifications.success.content": "Your changes to the profile were saved.", - - "profile.metadata.form.notifications.success.title": "Profile saved", - - "profile.notifications.warning.no-changes.content": "No changes were made to the Profile.", - - "profile.notifications.warning.no-changes.title": "No changes", - - "profile.security.form.error.matching-passwords": "The passwords do not match.", - - "profile.security.form.error.password-length": "The password should be at least 6 characters long.", - - "profile.security.form.info": "Optionally, you can enter a new password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.", - - "profile.security.form.label.password": "Password", - - "profile.security.form.label.passwordrepeat": "Retype to confirm", - - "profile.security.form.notifications.success.content": "Your changes to the password were saved.", - - "profile.security.form.notifications.success.title": "Password saved", - - "profile.security.form.notifications.error.title": "Error changing passwords", - - "profile.security.form.notifications.error.not-long-enough": "The password has to be at least 6 characters long.", - - "profile.security.form.notifications.error.not-same": "The provided passwords are not the same.", - - "profile.title": "Update Profile", - - - "project.listelement.badge": "Research Project", "project.page.contributor": "Contributors", @@ -3488,7 +3432,6 @@ - "vocabulary-treeview.header": "Hierarchical tree view", "vocabulary-treeview.load-more": "Load more", @@ -3505,12 +3448,6 @@ - "administrativeView.search.results.head": "Administrative Search", - - "menu.section.admin_search": "Admin Search", - - - "uploader.browse": "browse", "uploader.drag-message": "Drag & Drop your files here", @@ -3566,4 +3503,3 @@ "workflow-item.send-back.button.confirm": "Send back" } - diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts index 5c6e56babb..c197a1407c 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/global-config.interface.ts @@ -5,12 +5,12 @@ import { UniversalConfig } from './universal-config.interface'; import { INotificationBoardOptions } from './notifications-config.interfaces'; import { SubmissionConfig } from './submission-config.interface'; import { FormConfig } from './form-config.interfaces'; -import {LangConfig} from './lang-config.interface'; +import { LangConfig } from './lang-config.interface'; import { BrowseByConfig } from './browse-by-config.interface'; import { ItemPageConfig } from './item-page-config.interface'; import { CollectionPageConfig } from './collection-page-config.interface'; -import { Theme } from './theme.inferface'; -import {AuthConfig} from './auth-config.interfaces'; +import { ThemeConfig } from './theme.model'; +import { AuthConfig } from './auth-config.interfaces'; import { UIServerConfig } from './ui-server-config.interface'; export interface GlobalConfig extends Config { @@ -30,6 +30,6 @@ export interface GlobalConfig extends Config { browseBy: BrowseByConfig; item: ItemPageConfig; collection: CollectionPageConfig; - theme: Theme; + themes: ThemeConfig[]; rewriteDownloadUrls: boolean; } diff --git a/src/config/theme.inferface.ts b/src/config/theme.inferface.ts deleted file mode 100644 index d0cf1d77d8..0000000000 --- a/src/config/theme.inferface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Config } from './config.interface'; - -export interface Theme extends Config { - name: string; -} diff --git a/src/config/theme.model.spec.ts b/src/config/theme.model.spec.ts new file mode 100644 index 0000000000..fce61bd718 --- /dev/null +++ b/src/config/theme.model.spec.ts @@ -0,0 +1,122 @@ +import { HandleTheme, RegExTheme, Theme, UUIDTheme } from './theme.model'; +import { getCommunityModuleRoute } from '../app/+community-page/community-page-routing-paths'; +import { Community } from '../app/core/shared/community.model'; +import { COMMUNITY } from '../app/core/shared/community.resource-type'; +import { getCollectionModuleRoute } from '../app/+collection-page/collection-page-routing-paths'; +import { COLLECTION } from '../app/core/shared/collection.resource-type'; +import { Collection } from '../app/core/shared/collection.model'; +import { Item } from '../app/core/shared/item.model'; +import { ITEM } from '../app/core/shared/item.resource-type'; +import { getItemModuleRoute } from '../app/+item-page/item-page-routing-paths'; + +describe('Theme Models', () => { + let theme: Theme; + + describe('RegExTheme', () => { + it('should return true when the regex matches the community\'s DSO route', () => { + theme = new RegExTheme({ + name: 'community', + regex: getCommunityModuleRoute() + '/.*', + }); + const dso = Object.assign(new Community(), { + type: COMMUNITY.value, + uuid: 'community-uuid', + }); + expect(theme.matches('', dso)).toEqual(true); + }); + + it('should return true when the regex matches the collection\'s DSO route', () => { + theme = new RegExTheme({ + name: 'collection', + regex: getCollectionModuleRoute() + '/.*', + }); + const dso = Object.assign(new Collection(), { + type: COLLECTION.value, + uuid: 'collection-uuid', + }); + expect(theme.matches('', dso)).toEqual(true); + }); + + it('should return true when the regex matches the item\'s DSO route', () => { + theme = new RegExTheme({ + name: 'item', + regex: getItemModuleRoute() + '/.*', + }); + const dso = Object.assign(new Item(), { + type: ITEM.value, + uuid: 'item-uuid', + }); + expect(theme.matches('', dso)).toEqual(true); + }); + + it('should return true when the regex matches the url', () => { + theme = new RegExTheme({ + name: 'url', + regex: '.*partial.*', + }); + expect(theme.matches('theme/partial/url/match', null)).toEqual(true); + }); + + it('should return false when the regex matches neither the url, nor the DSO route', () => { + theme = new RegExTheme({ + name: 'no-match', + regex: '.*no/match.*', + }); + expect(theme.matches('theme/partial/url/match', null)).toEqual(false); + }); + }); + + describe('HandleTheme', () => { + it('should return true when the DSO\'s handle matches the theme\'s handle', () => { + theme = new HandleTheme({ + name: 'matching-handle', + handle: '1234/5678', + }); + const dso = Object.assign(new Item(), { + type: ITEM.value, + uuid: 'item-uuid', + handle: '1234/5678', + }); + expect(theme.matches('', dso)).toEqual(true); + }); + + it('should return false when the handles don\'t match', () => { + theme = new HandleTheme({ + name: 'no-matching-handle', + handle: '1234/5678', + }); + const dso = Object.assign(new Item(), { + type: ITEM.value, + uuid: 'item-uuid', + handle: '1234/6789', + }); + expect(theme.matches('', dso)).toEqual(false); + }); + }); + + describe('UUIDTheme', () => { + it('should return true when the DSO\'s UUID matches the theme\'s UUID', () => { + theme = new UUIDTheme({ + name: 'matching-uuid', + uuid: 'item-uuid', + }); + const dso = Object.assign(new Item(), { + type: ITEM.value, + uuid: 'item-uuid', + }); + expect(theme.matches('', dso)).toEqual(true); + }); + + it('should return true when the UUIDs don\'t match', () => { + theme = new UUIDTheme({ + name: 'matching-uuid', + uuid: 'item-uuid', + }); + const dso = Object.assign(new Collection(), { + type: COLLECTION.value, + uuid: 'collection-uuid', + }); + expect(theme.matches('', dso)).toEqual(false); + }); + }); +}); diff --git a/src/config/theme.model.ts b/src/config/theme.model.ts new file mode 100644 index 0000000000..908589c71c --- /dev/null +++ b/src/config/theme.model.ts @@ -0,0 +1,92 @@ +import { Config } from './config.interface'; +import { hasValue, hasNoValue, isNotEmpty } from '../app/shared/empty.util'; +import { DSpaceObject } from '../app/core/shared/dspace-object.model'; +import { getDSORoute } from '../app/app-routing-paths'; + +// tslint:disable:max-classes-per-file +export interface NamedThemeConfig extends Config { + name: string; +} + +export interface RegExThemeConfig extends NamedThemeConfig { + regex: string; +} + +export interface HandleThemeConfig extends NamedThemeConfig { + handle: string; +} + +export interface UUIDThemeConfig extends NamedThemeConfig { + uuid: string; +} + +export class Theme { + constructor(public config: NamedThemeConfig) { + } + + matches(url: string, dso: DSpaceObject): boolean { + return true; + } +} + +export class RegExTheme extends Theme { + regex: RegExp; + + constructor(public config: RegExThemeConfig) { + super(config); + this.regex = new RegExp(this.config.regex); + } + + matches(url: string, dso: DSpaceObject): boolean { + let match; + const route = getDSORoute(dso); + + if (isNotEmpty(route)) { + match = route.match(this.regex); + } + + if (hasNoValue(match)) { + match = url.match(this.regex); + } + + return hasValue(match); + } +} + +export class HandleTheme extends Theme { + constructor(public config: HandleThemeConfig) { + super(config); + } + + matches(url: string, dso: any): boolean { + return hasValue(dso) && hasValue(dso.handle) && dso.handle.includes(this.config.handle); + } +} + +export class UUIDTheme extends Theme { + constructor(public config: UUIDThemeConfig) { + super(config); + } + + matches(url: string, dso: DSpaceObject): boolean { + return hasValue(dso) && dso.uuid === this.config.uuid; + } +} + +export const themeFactory = (config: ThemeConfig): Theme => { + if (hasValue((config as RegExThemeConfig).regex)) { + return new RegExTheme(config as RegExThemeConfig); + } else if (hasValue((config as HandleThemeConfig).handle)) { + return new HandleTheme(config as HandleThemeConfig); + } else if (hasValue((config as UUIDThemeConfig).uuid)) { + return new UUIDTheme(config as UUIDThemeConfig); + } else { + return new Theme(config as NamedThemeConfig); + } +}; + +export type ThemeConfig + = NamedThemeConfig + | RegExThemeConfig + | HandleThemeConfig + | UUIDThemeConfig; diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts index 4a87be38d3..36d1a06c43 100644 --- a/src/environments/environment.common.ts +++ b/src/environments/environment.common.ts @@ -2,6 +2,7 @@ import { GlobalConfig } from '../config/global-config.interface'; import { NotificationAnimationsType } from '../app/shared/notifications/models/notification-animations-type'; import { BrowseByType } from '../app/+browse-by/+browse-by-switcher/browse-by-decorator'; import { RestRequestMethod } from '../app/core/data/rest-request-method'; +import { BASE_THEME_NAME } from '../app/shared/theme-support/theme.constants'; export const environment: GlobalConfig = { production: true, @@ -222,9 +223,40 @@ export const environment: GlobalConfig = { undoTimeout: 10000 // 10 seconds } }, - theme: { - name: 'default', - }, + themes: [ + // Add additional themes here. In the case where multiple themes match a route, the first one + // in this list will get priority. It is advisable to always have a theme that matches + // every route as the last one + + // { + // // A theme with a handle property will match the community, collection or item with the given + // // handle, and all collections and/or items within it + // name: 'custom', + // handle: '10673/1233' + // }, + // { + // // A theme with a regex property will match the route using a regular expression. If it + // // matches the route for a community or collection it will also apply to all collections + // // and/or items within it + // name: 'custom', + // regex: 'collections\/e8043bc2.*' + // }, + // { + // // A theme with a uuid property will match the community, collection or item with the given + // // ID, and all collections and/or items within it + // name: 'custom', + // uuid: '0958c910-2037-42a9-81c7-dca80e3892b4' + // }, + // { + // // A theme with only a name will match every route + // name: 'custom' + // }, + + { + // This theme will use the default bootstrap styling for DSpace components + name: BASE_THEME_NAME + }, + ], // Whether the UI should rewrite file download URLs to match its domain. Only necessary to enable when running UI and REST API on separate domains rewriteDownloadUrls: false, }; diff --git a/src/environments/mock-environment.ts b/src/environments/mock-environment.ts index ef3eb86cc2..4f321c01d4 100644 --- a/src/environments/mock-environment.ts +++ b/src/environments/mock-environment.ts @@ -192,7 +192,29 @@ export const environment: Partial = { undoTimeout: 10000 // 10 seconds } }, - theme: { - name: 'default', - }, + themes: [ + { + name: 'full-item-page-theme', + regex: 'items/aa6c6c83-3a83-4953-95d1-2bc2e67854d2/full' + }, + { + name: 'error-theme', + regex: 'collections/aaaa.*' + }, + { + name: 'handle-theme', + handle: '10673/1233' + }, + { + name: 'regex-theme', + regex: 'collections\/e8043bc2.*' + }, + { + name: 'uuid-theme', + uuid: '0958c910-2037-42a9-81c7-dca80e3892b4' + }, + { + name: 'base', + }, + ], }; diff --git a/src/index.html b/src/index.html index 491a2d319c..072938b5ef 100644 --- a/src/index.html +++ b/src/index.html @@ -7,6 +7,7 @@ DSpace + diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index e0bd7b5ca1..4b6c5c813e 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -2,7 +2,6 @@ import { HttpClient, HttpClientModule } from '@angular/common/http'; import { NgModule } from '@angular/core'; import { BrowserModule, makeStateKey, TransferState } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { NoPreloading, RouterModule } from '@angular/router'; import { REQUEST } from '@nguniversal/express-engine/tokens'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; @@ -30,7 +29,8 @@ import { LocationToken } from '../../app/core/services/browser-hard-redirect.service'; import { LocaleService } from '../../app/core/locale/locale.service'; -import {GoogleAnalyticsService} from '../../app/statistics/google-analytics.service'; +import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service'; +import { RouterModule, NoPreloading } from '@angular/router'; export const REQ_KEY = makeStateKey('req'); diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index fbe7333a90..906d4c5f35 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -2,7 +2,6 @@ import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ServerModule } from '@angular/platform-server'; -import { RouterModule } from '@angular/router'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; @@ -31,6 +30,7 @@ import { HardRedirectService } from '../../app/core/services/hard-redirect.servi import { ServerHardRedirectService } from '../../app/core/services/server-hard-redirect.service'; import { Angulartics2 } from 'angulartics2'; import { Angulartics2Mock } from '../../app/shared/mocks/angulartics2.service.mock'; +import { RouterModule } from '@angular/router'; export function createTranslateLoader() { return new TranslateJson5UniversalLoader('dist/server/assets/i18n/', '.json5'); diff --git a/src/styles.scss b/src/styles.scss deleted file mode 100644 index 90d4ee0072..0000000000 --- a/src/styles.scss +++ /dev/null @@ -1 +0,0 @@ -/* You can add global styles to this file, and also import other style files */ diff --git a/src/styles/_bootstrap_variables_mapping.scss b/src/styles/_bootstrap_variables_mapping.scss new file mode 100644 index 0000000000..5a64be7e2a --- /dev/null +++ b/src/styles/_bootstrap_variables_mapping.scss @@ -0,0 +1,826 @@ +// this file maps all bootstrap variables to css variables. + +:root { + // Variables + // + // Variables should follow the `$component-state-property-size` formula for + // consistent naming. Ex: $nav-link-disabled-color and $modal-content-box-shadow-xs. + + // Color system + --bs-white: #{$white}; + --bs-gray-100: #{$gray-100}; + --bs-gray-200: #{$gray-200}; + --bs-gray-300: #{$gray-300}; + --bs-gray-400: #{$gray-400}; + --bs-gray-500: #{$gray-500}; + --bs-gray-600: #{$gray-600}; + --bs-gray-700: #{$gray-700}; + --bs-gray-800: #{$gray-800}; + --bs-gray-900: #{$gray-900}; + --bs-black: #{$black}; + --bs-blue: #{$blue}; + --bs-indigo: #{$indigo}; + --bs-purple: #{$purple}; + --bs-pink: #{$pink}; + --bs-red: #{$red}; + --bs-orange: #{$orange}; + --bs-yellow: #{$yellow}; + --bs-green: #{$green}; + --bs-teal: #{$teal}; + --bs-cyan: #{$cyan}; + --bs-primary: #{$primary}; + --bs-secondary: #{$secondary}; + --bs-success: #{$success}; + --bs-info: #{$info}; + --bs-warning: #{$warning}; + --bs-danger: #{$danger}; + --bs-light: #{$light}; + --bs-dark: #{$dark}; + + + // Set a specific jump point for requesting color jumps + --bs-theme-color-interval: #{$theme-color-interval}; + + // The yiq lightness value that determines when the lightness of color changes from "dark" to "light". Acceptable values are between 0 and 255. + --bs-yiq-contrasted-threshold: #{$yiq-contrasted-threshold}; + + // Customize the light and dark text colors for use in our YIQ color contrast function. + --bs-yiq-text-dark: #{$yiq-text-dark}; + --bs-yiq-text-light: #{$yiq-text-light}; + + + // Options + // + // Quickly modify global styling by enabling or disabling optional features. + --bs-enable-caret: #{$enable-caret}; + --bs-enable-rounded: #{$enable-rounded}; + --bs-enable-shadows: #{$enable-shadows}; + --bs-enable-gradients: #{$enable-gradients}; + --bs-enable-transitions: #{$enable-transitions}; + --bs-enable-prefers-reduced-motion-media-query: #{$enable-prefers-reduced-motion-media-query}; + --bs-enable-grid-classes: #{$enable-grid-classes}; + --bs-enable-pointer-cursor-for-buttons: #{$enable-pointer-cursor-for-buttons}; + --bs-enable-print-styles: #{$enable-print-styles}; + --bs-enable-responsive-font-sizes: #{$enable-responsive-font-sizes}; + --bs-enable-validation-icons: #{$enable-validation-icons}; + --bs-enable-deprecation-messages: #{$enable-deprecation-messages}; + --bs-spacer: #{$spacer}; + --bs-spacer-0: #{map-get($spacers, 0)}; + --bs-spacer-1: #{map-get($spacers, 1)}; + --bs-spacer-2: #{map-get($spacers, 2)}; + --bs-spacer-3: #{map-get($spacers, 3)}; + --bs-spacer-4: #{map-get($spacers, 4)}; + --bs-spacer-5: #{map-get($spacers, 5)}; + --bs-sizes-25: #{map-get($sizes, 25)}; + --bs-sizes-50: #{map-get($sizes, 50)}; + --bs-sizes-75: #{map-get($sizes, 75)}; + --bs-sizes-100: #{map-get($sizes, 100)}; + --bs-sizes-auto: #{map-get($sizes, auto)}; + + // Body + // + // Settings for the `` element. + --bsbody-bg: #{$body-bg}; + --bs-body-color: #{$body-color}; + + + // Links + // + // Style anchor elements. + --bs-link-color: #{$link-color}; + --bs-link-decoration: #{$link-decoration}; + --bs-link-hover-color: #{$link-hover-color}; + --bs-link-hover-decoration: #{$link-hover-decoration}; + // Darken percentage for links with `.text-*` class (e.g. `.text-success`) + --bs-emphasized-link-hover-darken-percentage: #{$emphasized-link-hover-darken-percentage}; + + // Paragraphs + // + // Style p element. + --bs-paragraph-margin-bottom: #{$paragraph-margin-bottom}; + + + // Grid breakpoints + // + // Define the minimum dimensions at which your layout will change, + // adapting to different screen sizes, for use in media queries. + + --bs-xs-min: #{map-get($grid-breakpoints, xs)}; + --bs-sm-min: #{map-get($grid-breakpoints, sm)}; + --bs-md-min: #{map-get($grid-breakpoints, md)}; + --bs-lg-min: #{map-get($grid-breakpoints, lg)}; + --bs-xl-min: #{map-get($grid-breakpoints, xl)}; + + --bs-xs-max: #{map-get($container-max-widths, xs)}; + --bs-sm-max: #{map-get($container-max-widths, sm)}; + --bs-md-max: #{map-get($container-max-widths, md)}; + --bs-lg-max: #{map-get($container-max-widths, lg)}; + --bs-xl-max: #{map-get($container-max-widths, xl)}; + + // Grid columns + // + // Set the number of columns and specify the width of the gutters. + --bs-grid-columns: #{$grid-columns}; + --bs-grid-gutter-width: #{$grid-gutter-width}; + + + // Components + // + // Define common padding and border radius sizes and more. + --bs-line-height-lg: #{$line-height-lg}; + --bs-line-height-sm: #{$line-height-sm}; + --bs-border-width: #{$border-width}; + --bs-border-color: #{$border-color}; + --bs-border-radius: #{$border-radius}; + --bs-border-radius-lg: #{$border-radius-lg}; + --bs-border-radius-sm: #{$border-radius-sm}; + --bs-rounded-pill: #{$rounded-pill}; + --bs-box-shadow-sm: #{$box-shadow-sm}; + --bs-box-shadow: #{$box-shadow}; + --bs-box-shadow-lg: #{$box-shadow-lg}; + --bs-component-active-color: #{$component-active-color}; + --bs-component-active-bg: #{$component-active-bg}; + --bs-caret-width: #{$caret-width}; + --bs-caret-vertical-align: #{$caret-vertical-align}; + --bs-caret-spacing: #{$caret-spacing}; + --bs-transition-base: #{$transition-base}; + --bs-transition-fade: #{$transition-fade}; + --bs-transition-collapse: #{$transition-collapse}; + + --bs-font-family-sans-serif: #{$font-family-sans-serif}; + --bs-font-family-monospace: #{$font-family-monospace}; + --bs-font-family-base: #{$font-family-base}; + // stylelint-enable value-keyword-case + --bs-font-size-base: #{$font-size-base}; + --bs-font-size-lg: #{$font-size-lg}; + --bs-font-size-sm: #{$font-size-sm}; + --bs-font-weight-lighter: #{$font-weight-lighter}; + --bs-font-weight-light: #{$font-weight-light}; + --bs-font-weight-normal: #{$font-weight-normal}; + --bs-font-weight-bold: #{$font-weight-bold}; + --bs-font-weight-bolder: #{$font-weight-bolder}; + --bs-font-weight-base: #{$font-weight-base}; + --bs-line-height-base: #{$line-height-base}; + --bs-h1-font-size: #{$h1-font-size}; + --bs-h2-font-size: #{$h2-font-size}; + --bs-h3-font-size: #{$h3-font-size}; + --bs-h4-font-size: #{$h4-font-size}; + --bs-h5-font-size: #{$h5-font-size}; + --bs-h6-font-size: #{$h6-font-size}; + --bs-headings-margin-bottom: #{$headings-margin-bottom}; + --bs-headings-font-family: #{$headings-font-family}; + --bs-headings-font-weight: #{$headings-font-weight}; + --bs-headings-line-height: #{$headings-line-height}; + --bs-headings-color: #{$headings-color}; + --bs-display1-size: #{$display1-size}; + --bs-display2-size: #{$display2-size}; + --bs-display3-size: #{$display3-size}; + --bs-display4-size: #{$display4-size}; + --bs-display1-weight: #{$display1-weight}; + --bs-display2-weight: #{$display2-weight}; + --bs-display3-weight: #{$display3-weight}; + --bs-display4-weight: #{$display4-weight}; + --bs-display-line-height: #{$display-line-height}; + --bs-lead-font-size: #{$lead-font-size}; + --bs-lead-font-weight: #{$lead-font-weight}; + --bs-small-font-size: #{$small-font-size}; + --bs-text-muted: #{$text-muted}; + --bs-blockquote-small-color: #{$blockquote-small-color}; + --bs-blockquote-small-font-size: #{$blockquote-small-font-size}; + --bs-blockquote-font-size: #{$blockquote-font-size}; + --bs-hr-border-color: #{$hr-border-color}; + --bs-hr-border-width: #{$hr-border-width}; + --bs-mark-padding: #{$mark-padding}; + --bs-dt-font-weight: #{$dt-font-weight}; + --bs-kbd-box-shadow: #{$kbd-box-shadow}; + --bs-nested-kbd-font-weight: #{$nested-kbd-font-weight}; + --bs-list-inline-padding: #{$list-inline-padding}; + --bs-mark-bg: #{$mark-bg}; + --bs-hr-margin-y: #{$hr-margin-y}; + + + // Tables + // + // Customizes the `.table` component with basic values, each used across all table variations. + --bs-table-cell-padding: #{$table-cell-padding}; + --bs-table-cell-padding-sm: #{$table-cell-padding-sm}; + --bs-table-color: #{$table-color}; + --bs-table-bg: #{$table-bg}; + --bs-table-accent-bg: #{$table-accent-bg}; + --bs-table-hover-color: #{$table-hover-color}; + --bs-table-hover-bg: #{$table-hover-bg}; + --bs-table-active-bg: #{$table-active-bg}; + --bs-table-border-width: #{$table-border-width}; + --bs-table-border-color: #{$table-border-color}; + --bs-table-head-bg: #{$table-head-bg}; + --bs-table-head-color: #{$table-head-color}; + --bs-table-dark-color: #{$table-dark-color}; + --bs-table-dark-bg: #{$table-dark-bg}; + --bs-table-dark-accent-bg: #{$table-dark-accent-bg}; + --bs-table-dark-hover-color: #{$table-dark-hover-color}; + --bs-table-dark-hover-bg: #{$table-dark-hover-bg}; + --bs-table-dark-border-color: #{$table-dark-border-color}; + --bs-table-dark-color: #{$table-dark-color}; + --bs-table-striped-order: #{$table-striped-order}; + --bs-table-caption-color: #{$table-caption-color}; + --bs-table-bg-level: #{$table-bg-level}; + --bs-table-border-level: #{$table-border-level}; + + + // Buttons + Forms + // + // Shared variables that are reassigned to `$input-` and `$btn-` specific variables. + --bs-input-btn-padding-y: #{$input-btn-padding-y}; + --bs-input-btn-padding-x: #{$input-btn-padding-x}; + --bs-input-btn-font-family: #{$input-btn-font-family}; + --bs-input-btn-font-size: #{$input-btn-font-size}; + --bs-input-btn-line-height: #{$input-btn-line-height}; + --bs-input-btn-focus-width: #{$input-btn-focus-width}; + --bs-input-btn-focus-color: #{$input-btn-focus-color}; + --bs-input-btn-focus-box-shadow: #{$input-btn-focus-box-shadow}; + --bs-input-btn-padding-y-sm: #{$input-btn-padding-y-sm}; + --bs-input-btn-padding-x-sm: #{$input-btn-padding-x-sm}; + --bs-input-btn-font-size-sm: #{$input-btn-font-size-sm}; + --bs-input-btn-line-height-sm: #{$input-btn-line-height-sm}; + --bs-input-btn-padding-y-lg: #{$input-btn-padding-y-lg}; + --bs-input-btn-padding-x-lg: #{$input-btn-padding-x-lg}; + --bs-input-btn-font-size-lg: #{$input-btn-font-size-lg}; + --bs-input-btn-line-height-lg: #{$input-btn-line-height-lg}; + --bs-input-btn-border-width: #{$input-btn-border-width}; + + + // Buttons + // + // For each of Bootstrap's buttons, define text, background, and border color. + --bs-btn-padding-y: #{$btn-padding-y}; + --bs-btn-padding-x: #{$btn-padding-x}; + --bs-btn-font-family: #{$btn-font-family}; + --bs-btn-font-size: #{$btn-font-size}; + --bs-btn-line-height: #{$btn-line-height}; + --bs-btn-padding-y-sm: #{$btn-padding-y-sm}; + --bs-btn-padding-x-sm: #{$btn-padding-x-sm}; + --bs-btn-font-size-sm: #{$btn-font-size-sm}; + --bs-btn-line-height-sm: #{$btn-line-height-sm}; + --bs-btn-padding-y-lg: #{$btn-padding-y-lg}; + --bs-btn-padding-x-lg: #{$btn-padding-x-lg}; + --bs-btn-font-size-lg: #{$btn-font-size-lg}; + --bs-btn-line-height-lg: #{$btn-line-height-lg}; + --bs-btn-border-width: #{$btn-border-width}; + --bs-btn-font-weight: #{$btn-font-weight}; + --bs-btn-box-shadow: #{$btn-box-shadow}; + --bs-btn-focus-width: #{$btn-focus-width}; + --bs-btn-focus-box-shadow: #{$btn-focus-box-shadow}; + --bs-btn-disabled-opacity: #{$btn-disabled-opacity}; + --bs-btn-active-box-shadow: #{$btn-active-box-shadow}; + --bs-btn-link-disabled-color: #{$btn-link-disabled-color}; + --bs-btn-block-spacing-y: #{$btn-block-spacing-y}; + + // Allows for customizing button radius independently from global border radius + --bs-btn-border-radius: #{$btn-border-radius}; + --bs-btn-border-radius-lg: #{$btn-border-radius-lg}; + --bs-btn-border-radius-sm: #{$btn-border-radius-sm}; + --bs-btn-transition: #{$btn-transition}; + + + // Forms + --bs-label-margin-bottom: #{$label-margin-bottom}; + --bs-input-padding-y: #{$input-padding-y}; + --bs-input-padding-x: #{$input-padding-x}; + --bs-input-font-family: #{$input-font-family}; + --bs-input-font-size: #{$input-font-size}; + --bs-input-font-weight: #{$input-font-weight}; + --bs-input-line-height: #{$input-line-height}; + --bs-input-padding-y-sm: #{$input-padding-y-sm}; + --bs-input-padding-x-sm: #{$input-padding-x-sm}; + --bs-input-font-size-sm: #{$input-font-size-sm}; + --bs-input-line-height-sm: #{$input-line-height-sm}; + --bs-input-padding-y-lg: #{$input-padding-y-lg}; + --bs-input-padding-x-lg: #{$input-padding-x-lg}; + --bs-input-font-size-lg: #{$input-font-size-lg}; + --bs-input-line-height-lg: #{$input-line-height-lg}; + --bs-input-bg: #{$input-bg}; + --bs-input-disabled-bg: #{$input-disabled-bg}; + --bs-input-color: #{$input-color}; + --bs-input-border-color: #{$input-border-color}; + --bs-input-border-width: #{$input-border-width}; + --bs-input-box-shadow: #{$input-box-shadow}; + --bs-input-border-radius: #{$input-border-radius}; + --bs-input-border-radius-lg: #{$input-border-radius-lg}; + --bs-input-border-radius-sm: #{$input-border-radius-sm}; + --bs-input-focus-bg: #{$input-focus-bg}; + --bs-input-focus-border-color: #{$input-focus-border-color}; + --bs-input-focus-color: #{$input-focus-color}; + --bs-input-focus-width: #{$input-focus-width}; + --bs-input-focus-box-shadow: #{$input-focus-box-shadow}; + --bs-input-placeholder-color: #{$input-placeholder-color}; + --bs-input-plaintext-color: #{$input-plaintext-color}; + --bs-input-height-border: #{$input-height-border}; + --bs-input-height-inner: #{$input-height-inner}; + --bs-input-height-inner-half: #{$input-height-inner-half}; + --bs-input-height-inner-quarter: #{$input-height-inner-quarter}; + --bs-input-height: #{$input-height}; + --bs-input-height-sm: #{$input-height-sm}; + --bs-input-height-lg: #{$input-height-lg}; + --bs-input-transition: #{$input-transition}; + --bs-form-text-margin-top: #{$form-text-margin-top}; + --bs-form-check-input-gutter: #{$form-check-input-gutter}; + --bs-form-check-input-margin-y: #{$form-check-input-margin-y}; + --bs-form-check-input-margin-x: #{$form-check-input-margin-x}; + --bs-form-check-inline-margin-x: #{$form-check-inline-margin-x}; + --bs-form-check-inline-input-margin-x: #{$form-check-inline-input-margin-x}; + --bs-form-grid-gutter-width: #{$form-grid-gutter-width}; + --bs-form-group-margin-bottom: #{$form-group-margin-bottom}; + --bs-input-group-addon-color: #{$input-group-addon-color}; + --bs-input-group-addon-bg: #{$input-group-addon-bg}; + --bs-input-group-addon-border-color: #{$input-group-addon-border-color}; + --bs-custom-forms-transition: #{$custom-forms-transition}; + --bs-custom-control-gutter: #{$custom-control-gutter}; + --bs-custom-control-spacer-x: #{$custom-control-spacer-x}; + --bs-custom-control-indicator-size: #{$custom-control-indicator-size}; + --bs-custom-control-indicator-bg: #{$custom-control-indicator-bg}; + --bs-custom-control-indicator-bg-size: #{$custom-control-indicator-bg-size}; + --bs-custom-control-indicator-box-shadow: #{$custom-control-indicator-box-shadow}; + --bs-custom-control-indicator-border-color: #{$custom-control-indicator-border-color}; + --bs-custom-control-indicator-border-width: #{$custom-control-indicator-border-width}; + --bs-custom-control-indicator-disabled-bg: #{$custom-control-indicator-disabled-bg}; + --bs-custom-control-label-disabled-color: #{$custom-control-label-disabled-color}; + --bs-custom-control-indicator-checked-color: #{$custom-control-indicator-checked-color}; + --bs-custom-control-indicator-checked-bg: #{$custom-control-indicator-checked-bg}; + --bs-custom-control-indicator-checked-disabled-bg: #{$custom-control-indicator-checked-disabled-bg}; + --bs-custom-control-indicator-checked-box-shadow: #{$custom-control-indicator-checked-box-shadow}; + --bs-custom-control-indicator-checked-border-color: #{$custom-control-indicator-checked-border-color}; + --bs-custom-control-indicator-focus-box-shadow: #{$custom-control-indicator-focus-box-shadow}; + --bs-custom-control-indicator-focus-border-color: #{$custom-control-indicator-focus-border-color}; + --bs-custom-control-indicator-active-color: #{$custom-control-indicator-active-color}; + --bs-custom-control-indicator-active-bg: #{$custom-control-indicator-active-bg}; + --bs-custom-control-indicator-active-box-shadow: #{$custom-control-indicator-active-box-shadow}; + --bs-custom-control-indicator-active-border-color: #{$custom-control-indicator-active-border-color}; + --bs-custom-checkbox-indicator-border-radius: #{$custom-checkbox-indicator-border-radius}; + --bs-custom-checkbox-indicator-icon-checked: #{$custom-checkbox-indicator-icon-checked}; + --bs-custom-checkbox-indicator-indeterminate-bg: #{$custom-checkbox-indicator-indeterminate-bg}; + --bs-custom-checkbox-indicator-indeterminate-color: #{$custom-checkbox-indicator-indeterminate-color}; + --bs-custom-checkbox-indicator-icon-indeterminate: #{$custom-checkbox-indicator-icon-indeterminate}; + --bs-custom-checkbox-indicator-indeterminate-box-shadow: #{$custom-checkbox-indicator-indeterminate-box-shadow}; + --bs-custom-checkbox-indicator-indeterminate-border-color: #{$custom-checkbox-indicator-indeterminate-border-color}; + --bs-custom-radio-indicator-border-radius: #{$custom-radio-indicator-border-radius}; + --bs-custom-radio-indicator-icon-checked: #{$custom-radio-indicator-icon-checked}; + --bs-custom-switch-width: #{$custom-switch-width}; + --bs-custom-switch-indicator-border-radius: #{$custom-switch-indicator-border-radius}; + --bs-custom-switch-indicator-size: #{$custom-switch-indicator-size}; + --bs-custom-select-padding-y: #{$custom-select-padding-y}; + --bs-custom-select-padding-x: #{$custom-select-padding-x}; + --bs-custom-select-font-family: #{$custom-select-font-family}; + --bs-custom-select-font-size: #{$custom-select-font-size}; + --bs-custom-select-height: #{$custom-select-height}; + --bs-custom-select-indicator-padding: #{$custom-select-indicator-padding}; + --bs-custom-select-font-weight: #{$custom-select-font-weight}; + --bs-custom-select-line-height: #{$custom-select-line-height}; + --bs-custom-select-color: #{$custom-select-color}; + --bs-custom-select-disabled-color: #{$custom-select-disabled-color}; + --bs-custom-select-bg: #{$custom-select-bg}; + --bs-custom-select-disabled-bg: #{$custom-select-disabled-bg}; + --bs-custom-select-bg-size: #{$custom-select-bg-size}; + --bs-custom-select-indicator-color: #{$custom-select-indicator-color}; + --bs-custom-select-indicator: #{$custom-select-indicator}; + --bs-custom-select-background: #{$custom-select-background}; + --bs-custom-select-feedback-icon-padding-right: #{$custom-select-feedback-icon-padding-right}; + --bs-custom-select-feedback-icon-position: #{$custom-select-feedback-icon-position}; + --bs-custom-select-feedback-icon-size: #{$custom-select-feedback-icon-size}; + --bs-custom-select-border-width: #{$custom-select-border-width}; + --bs-custom-select-border-color: #{$custom-select-border-color}; + --bs-custom-select-border-radius: #{$custom-select-border-radius}; + --bs-custom-select-box-shadow: #{$custom-select-box-shadow}; + --bs-custom-select-focus-border-color: #{$custom-select-focus-border-color}; + --bs-custom-select-focus-width: #{$custom-select-focus-width}; + --bs-custom-select-focus-box-shadow: #{$custom-select-focus-box-shadow}; + --bs-custom-select-padding-y-sm: #{$custom-select-padding-y-sm}; + --bs-custom-select-padding-x-sm: #{$custom-select-padding-x-sm}; + --bs-custom-select-font-size-sm: #{$custom-select-font-size-sm}; + --bs-custom-select-height-sm: #{$custom-select-height-sm}; + --bs-custom-select-padding-y-lg: #{$custom-select-padding-y-lg}; + --bs-custom-select-padding-x-lg: #{$custom-select-padding-x-lg}; + --bs-custom-select-font-size-lg: #{$custom-select-font-size-lg}; + --bs-custom-select-height-lg: #{$custom-select-height-lg}; + --bs-custom-range-track-width: #{$custom-range-track-width}; + --bs-custom-range-track-height: #{$custom-range-track-height}; + --bs-custom-range-track-cursor: #{$custom-range-track-cursor}; + --bs-custom-range-track-bg: #{$custom-range-track-bg}; + --bs-custom-range-track-border-radius: #{$custom-range-track-border-radius}; + --bs-custom-range-track-box-shadow: #{$custom-range-track-box-shadow}; + --bs-custom-range-thumb-width: #{$custom-range-thumb-width}; + --bs-custom-range-thumb-height: #{$custom-range-thumb-height}; + --bs-custom-range-thumb-bg: #{$custom-range-thumb-bg}; + --bs-custom-range-thumb-border: #{$custom-range-thumb-border}; + --bs-custom-range-thumb-border-radius: #{$custom-range-thumb-border-radius}; + --bs-custom-range-thumb-box-shadow: #{$custom-range-thumb-box-shadow}; + --bs-custom-range-thumb-focus-box-shadow: #{$custom-range-thumb-focus-box-shadow}; + --bs-custom-range-thumb-focus-box-shadow-width: #{$custom-range-thumb-focus-box-shadow-width}; + --bs-custom-range-thumb-active-bg: #{$custom-range-thumb-active-bg}; + --bs-custom-range-thumb-disabled-bg: #{$custom-range-thumb-disabled-bg}; + --bs-custom-file-height: #{$custom-file-height}; + --bs-custom-file-height-inner: #{$custom-file-height-inner}; + --bs-custom-file-focus-border-color: #{$custom-file-focus-border-color}; + --bs-custom-file-focus-box-shadow: #{$custom-file-focus-box-shadow}; + --bs-custom-file-disabled-bg: #{$custom-file-disabled-bg}; + --bs-custom-file-padding-y: #{$custom-file-padding-y}; + --bs-custom-file-padding-x: #{$custom-file-padding-x}; + --bs-custom-file-line-height: #{$custom-file-line-height}; + --bs-custom-file-font-family: #{$custom-file-font-family}; + --bs-custom-file-font-weight: #{$custom-file-font-weight}; + --bs-custom-file-color: #{$custom-file-color}; + --bs-custom-file-bg: #{$custom-file-bg}; + --bs-custom-file-border-width: #{$custom-file-border-width}; + --bs-custom-file-border-color: #{$custom-file-border-color}; + --bs-custom-file-border-radius: #{$custom-file-border-radius}; + --bs-custom-file-box-shadow: #{$custom-file-box-shadow}; + --bs-custom-file-button-color: #{$custom-file-button-color}; + --bs-custom-file-button-bg: #{$custom-file-button-bg}; + //--bs-custom-file-text: #{$custom-file-text}; //mapping doesn't work + + + // Form validation + --bs-form-feedback-margin-top: #{$form-feedback-margin-top}; + --bs-form-feedback-font-size: #{$form-feedback-font-size}; + --bs-form-feedback-valid-color: #{$form-feedback-valid-color}; + --bs-form-feedback-invalid-color: #{$form-feedback-invalid-color}; + --bs-form-feedback-icon-valid-color: #{$form-feedback-icon-valid-color}; + --bs-form-feedback-icon-valid: #{$form-feedback-icon-valid}; + --bs-form-feedback-icon-invalid-color: #{$form-feedback-icon-invalid-color}; + --bs-form-feedback-icon-invalid: #{$form-feedback-icon-invalid}; + // Z-index master list + // + // Warning: Avoid customizing these values. They're used for a bird's eye view + // of components dependent on the z-axis and are designed to all work together. + --bs-zindex-dropdown: #{$zindex-dropdown}; + --bs-zindex-sticky: #{$zindex-sticky}; + --bs-zindex-fixed: #{$zindex-fixed}; + --bs-zindex-modal-backdrop: #{$zindex-modal-backdrop}; + --bs-zindex-modal: #{$zindex-modal}; + --bs-zindex-popover: #{$zindex-popover}; + --bs-zindex-tooltip: #{$zindex-tooltip}; + + + // Navs + --bs-nav-link-padding-y: #{$nav-link-padding-y}; + --bs-nav-link-padding-x: #{$nav-link-padding-x}; + --bs-nav-link-disabled-color: #{$nav-link-disabled-color}; + --bs-nav-tabs-border-color: #{$nav-tabs-border-color}; + --bs-nav-tabs-border-width: #{$nav-tabs-border-width}; + --bs-nav-tabs-border-radius: #{$nav-tabs-border-radius}; + --bs-nav-tabs-link-hover-border-color: #{$nav-tabs-link-hover-border-color}; + --bs-nav-tabs-link-active-color: #{$nav-tabs-link-active-color}; + --bs-nav-tabs-link-active-bg: #{$nav-tabs-link-active-bg}; + --bs-nav-tabs-link-active-border-color: #{$nav-tabs-link-active-border-color}; + --bs-nav-pills-border-radius: #{$nav-pills-border-radius}; + --bs-nav-pills-link-active-color: #{$nav-pills-link-active-color}; + --bs-nav-pills-link-active-bg: #{$nav-pills-link-active-bg}; + --bs-nav-divider-color: #{$nav-divider-color}; + --bs-nav-divider-margin-y: #{$nav-divider-margin-y}; + + + // Navbar + --bs-navbar-padding-y: #{$navbar-padding-y}; + --bs-navbar-padding-x: #{$navbar-padding-x}; + --bs-navbar-nav-link-padding-x: #{$navbar-nav-link-padding-x}; + --bs-navbar-brand-font-size: #{$navbar-brand-font-size}; + // Compute the navbar-brand padding-y so the navbar-brand will have the same height as navbar-text and nav-link + --bs-nav-link-height: #{$nav-link-height}; + --bs-navbar-brand-height: #{$navbar-brand-height}; + --bs-navbar-brand-padding-y: #{$navbar-brand-padding-y}; + --bs-navbar-toggler-padding-y: #{$navbar-toggler-padding-y}; + --bs-navbar-toggler-padding-x: #{$navbar-toggler-padding-x}; + --bs-navbar-toggler-font-size: #{$navbar-toggler-font-size}; + --bs-navbar-toggler-border-radius: #{$navbar-toggler-border-radius}; + --bs-navbar-dark-color: #{$navbar-dark-color}; + --bs-navbar-dark-hover-color: #{$navbar-dark-hover-color}; + --bs-navbar-dark-active-color: #{$navbar-dark-active-color}; + --bs-navbar-dark-disabled-color: #{$navbar-dark-disabled-color}; + --bs-navbar-dark-toggler-icon-bg: #{$navbar-dark-toggler-icon-bg}; + --bs-navbar-dark-toggler-border-color: #{$navbar-dark-toggler-border-color}; + --bs-navbar-light-color: #{$navbar-light-color}; + --bs-navbar-light-hover-color: #{$navbar-light-hover-color}; + --bs-navbar-light-active-color: #{$navbar-light-active-color}; + --bs-navbar-light-disabled-color: #{$navbar-light-disabled-color}; + --bs-navbar-light-toggler-icon-bg: #{$navbar-light-toggler-icon-bg}; + --bs-navbar-light-toggler-border-color: #{$navbar-light-toggler-border-color}; + --bs-navbar-light-brand-color: #{$navbar-light-brand-color}; + --bs-navbar-light-brand-hover-color: #{$navbar-light-brand-hover-color}; + --bs-navbar-dark-brand-color: #{$navbar-dark-brand-color}; + --bs-navbar-dark-brand-hover-color: #{$navbar-dark-brand-hover-color}; + + + // Dropdowns + // + // Dropdown menu container and contents. + --bs-dropdown-min-width: #{$dropdown-min-width}; + --bs-dropdown-padding-y: #{$dropdown-padding-y}; + --bs-dropdown-spacer: #{$dropdown-spacer}; + --bs-dropdown-font-size: #{$dropdown-font-size}; + --bs-dropdown-color: #{$dropdown-color}; + --bs-dropdown-bg: #{$dropdown-bg}; + --bs-dropdown-border-color: #{$dropdown-border-color}; + --bs-dropdown-border-radius: #{$dropdown-border-radius}; + --bs-dropdown-border-width: #{$dropdown-border-width}; + --bs-dropdown-inner-border-radius: #{$dropdown-inner-border-radius}; + --bs-dropdown-divider-bg: #{$dropdown-divider-bg}; + --bs-dropdown-divider-margin-y: #{$dropdown-divider-margin-y}; + --bs-dropdown-box-shadow: #{$dropdown-box-shadow}; + --bs-dropdown-link-color: #{$dropdown-link-color}; + --bs-dropdown-link-hover-color: #{$dropdown-link-hover-color}; + --bs-dropdown-link-hover-bg: #{$dropdown-link-hover-bg}; + --bs-dropdown-link-active-color: #{$dropdown-link-active-color}; + --bs-dropdown-link-active-bg: #{$dropdown-link-active-bg}; + --bs-dropdown-link-disabled-color: #{$dropdown-link-disabled-color}; + --bs-dropdown-item-padding-y: #{$dropdown-item-padding-y}; + --bs-dropdown-item-padding-x: #{$dropdown-item-padding-x}; + --bs-dropdown-header-color: #{$dropdown-header-color}; + + + // Pagination + --bs-pagination-padding-y: #{$pagination-padding-y}; + --bs-pagination-padding-x: #{$pagination-padding-x}; + --bs-pagination-padding-y-sm: #{$pagination-padding-y-sm}; + --bs-pagination-padding-x-sm: #{$pagination-padding-x-sm}; + --bs-pagination-padding-y-lg: #{$pagination-padding-y-lg}; + --bs-pagination-padding-x-lg: #{$pagination-padding-x-lg}; + --bs-pagination-line-height: #{$pagination-line-height}; + --bs-pagination-color: #{$pagination-color}; + --bs-pagination-bg: #{$pagination-bg}; + --bs-pagination-border-width: #{$pagination-border-width}; + --bs-pagination-border-color: #{$pagination-border-color}; + --bs-pagination-focus-box-shadow: #{$pagination-focus-box-shadow}; + --bs-pagination-focus-outline: #{$pagination-focus-outline}; + --bs-pagination-hover-color: #{$pagination-hover-color}; + --bs-pagination-hover-bg: #{$pagination-hover-bg}; + --bs-pagination-hover-border-color: #{$pagination-hover-border-color}; + --bs-pagination-active-color: #{$pagination-active-color}; + --bs-pagination-active-bg: #{$pagination-active-bg}; + --bs-pagination-active-border-color: #{$pagination-active-border-color}; + --bs-pagination-disabled-color: #{$pagination-disabled-color}; + --bs-pagination-disabled-bg: #{$pagination-disabled-bg}; + --bs-pagination-disabled-border-color: #{$pagination-disabled-border-color}; + + + // Jumbotron + --bs-jumbotron-padding: #{$jumbotron-padding}; + --bs-jumbotron-color: #{$jumbotron-color}; + --bs-jumbotron-bg: #{$jumbotron-bg}; + + + // Cards + --bs-card-spacer-y: #{$card-spacer-y}; + --bs-card-spacer-x: #{$card-spacer-x}; + --bs-card-border-width: #{$card-border-width}; + --bs-card-border-radius: #{$card-border-radius}; + --bs-card-border-color: #{$card-border-color}; + --bs-card-inner-border-radius: #{$card-inner-border-radius}; + --bs-card-cap-bg: #{$card-cap-bg}; + --bs-card-cap-color: #{$card-cap-color}; + --bs-card-color: #{$card-color}; + --bs-card-bg: #{$card-bg}; + --bs-card-img-overlay-padding: #{$card-img-overlay-padding}; + --bs-card-group-margin: #{$card-group-margin}; + --bs-card-deck-margin: #{$card-deck-margin}; + --bs-card-columns-count: #{$card-columns-count}; + --bs-card-columns-gap: #{$card-columns-gap}; + --bs-card-columns-margin: #{$card-columns-margin}; + + + // Tooltips + --bs-tooltip-font-size: #{$tooltip-font-size}; + --bs-tooltip-max-width: #{$tooltip-max-width}; + --bs-tooltip-color: #{$tooltip-color}; + --bs-tooltip-bg: #{$tooltip-bg}; + --bs-tooltip-border-radius: #{$tooltip-border-radius}; + --bs-tooltip-opacity: #{$tooltip-opacity}; + --bs-tooltip-padding-y: #{$tooltip-padding-y}; + --bs-tooltip-padding-x: #{$tooltip-padding-x}; + --bs-tooltip-margin: #{$tooltip-margin}; + --bs-tooltip-arrow-width: #{$tooltip-arrow-width}; + --bs-tooltip-arrow-height: #{$tooltip-arrow-height}; + --bs-tooltip-arrow-color: #{$tooltip-arrow-color}; + + // Form tooltips must come after regular tooltips + --bs-form-feedback-tooltip-padding-y: #{$form-feedback-tooltip-padding-y}; + --bs-form-feedback-tooltip-padding-x: #{$form-feedback-tooltip-padding-x}; + --bs-form-feedback-tooltip-font-size: #{$form-feedback-tooltip-font-size}; + --bs-form-feedback-tooltip-line-height: #{$form-feedback-tooltip-line-height}; + --bs-form-feedback-tooltip-opacity: #{$form-feedback-tooltip-opacity}; + --bs-form-feedback-tooltip-border-radius: #{$form-feedback-tooltip-border-radius}; + + + // Popovers + --bs-popover-font-size: #{$popover-font-size}; + --bs-popover-bg: #{$popover-bg}; + --bs-popover-max-width: #{$popover-max-width}; + --bs-popover-border-width: #{$popover-border-width}; + --bs-popover-border-color: #{$popover-border-color}; + --bs-popover-border-radius: #{$popover-border-radius}; + --bs-popover-box-shadow: #{$popover-box-shadow}; + --bs-popover-header-bg: #{$popover-header-bg}; + --bs-popover-header-color: #{$popover-header-color}; + --bs-popover-header-padding-y: #{$popover-header-padding-y}; + --bs-popover-header-padding-x: #{$popover-header-padding-x}; + --bs-popover-body-color: #{$popover-body-color}; + --bs-popover-body-padding-y: #{$popover-body-padding-y}; + --bs-popover-body-padding-x: #{$popover-body-padding-x}; + --bs-popover-arrow-width: #{$popover-arrow-width}; + --bs-popover-arrow-height: #{$popover-arrow-height}; + --bs-popover-arrow-color: #{$popover-arrow-color}; + --bs-popover-arrow-outer-color: #{$popover-arrow-outer-color}; + + + // Toasts + --bs-toast-max-width: #{$toast-max-width}; + --bs-toast-padding-x: #{$toast-padding-x}; + --bs-toast-padding-y: #{$toast-padding-y}; + --bs-toast-font-size: #{$toast-font-size}; + --bs-toast-color: #{$toast-color}; + --bs-toast-background-color: #{$toast-background-color}; + --bs-toast-border-width: #{$toast-border-width}; + --bs-toast-border-color: #{$toast-border-color}; + --bs-toast-border-radius: #{$toast-border-radius}; + --bs-toast-box-shadow: #{$toast-box-shadow}; + --bs-toast-header-color: #{$toast-header-color}; + --bs-toast-header-background-color: #{$toast-header-background-color}; + --bs-toast-header-border-color: #{$toast-header-border-color}; + + + // Badges + --bs-badge-font-size: #{$badge-font-size}; + --bs-badge-font-weight: #{$badge-font-weight}; + --bs-badge-padding-y: #{$badge-padding-y}; + --bs-badge-padding-x: #{$badge-padding-x}; + --bs-badge-border-radius: #{$badge-border-radius}; + --bs-badge-transition: #{$badge-transition}; + --bs-badge-focus-width: #{$badge-focus-width}; + --bs-badge-pill-padding-x: #{$badge-pill-padding-x}; + // Use a higher than normal value to ensure completely rounded edges when + // customizing padding or font-size on labels. + --bs-badge-pill-border-radius: #{$badge-pill-border-radius}; + + + // Modals + + // Padding applied to the modal body + --bs-modal-inner-padding: #{$modal-inner-padding}; + --bs-modal-dialog-margin: #{$modal-dialog-margin}; + --bs-modal-dialog-margin-y-sm-up: #{$modal-dialog-margin-y-sm-up}; + --bs-modal-title-line-height: #{$modal-title-line-height}; + --bs-modal-content-color: #{$modal-content-color}; + --bs-modal-content-bg: #{$modal-content-bg}; + --bs-modal-content-border-color: #{$modal-content-border-color}; + --bs-modal-content-border-width: #{$modal-content-border-width}; + --bs-modal-content-border-radius: #{$modal-content-border-radius}; + --bs-modal-content-box-shadow-xs: #{$modal-content-box-shadow-xs}; + --bs-modal-content-box-shadow-sm-up: #{$modal-content-box-shadow-sm-up}; + --bs-modal-backdrop-bg: #{$modal-backdrop-bg}; + --bs-modal-backdrop-opacity: #{$modal-backdrop-opacity}; + --bs-modal-header-border-color: #{$modal-header-border-color}; + --bs-modal-footer-border-color: #{$modal-footer-border-color}; + --bs-modal-header-border-width: #{$modal-header-border-width}; + --bs-modal-footer-border-width: #{$modal-footer-border-width}; + --bs-modal-header-padding-y: #{$modal-header-padding-y}; + --bs-modal-header-padding-x: #{$modal-header-padding-x}; + --bs-modal-header-padding: #{$modal-header-padding}; + --bs-modal-xl: #{$modal-xl}; + --bs-modal-lg: #{$modal-lg}; + --bs-modal-md: #{$modal-md}; + --bs-modal-sm: #{$modal-sm}; + --bs-modal-fade-transform: #{$modal-fade-transform}; + --bs-modal-show-transform: #{$modal-show-transform}; + --bs-modal-transition: #{$modal-transition}; + + + // Alerts + // + // Define alert colors, border radius, and padding. + --bs-alert-padding-y: #{$alert-padding-y}; + --bs-alert-padding-x: #{$alert-padding-x}; + --bs-alert-margin-bottom: #{$alert-margin-bottom}; + --bs-alert-border-radius: #{$alert-border-radius}; + --bs-alert-link-font-weight: #{$alert-link-font-weight}; + --bs-alert-border-width: #{$alert-border-width}; + --bs-alert-bg-level: #{$alert-bg-level}; + --bs-alert-border-level: #{$alert-border-level}; + --bs-alert-color-level: #{$alert-color-level}; + + + // Progress bars + --bs-progress-height: #{$progress-height}; + --bs-progress-font-size: #{$progress-font-size}; + --bs-progress-bg: #{$progress-bg}; + --bs-progress-border-radius: #{$progress-border-radius}; + --bs-progress-box-shadow: #{$progress-box-shadow}; + --bs-progress-bar-color: #{$progress-bar-color}; + --bs-progress-bar-bg: #{$progress-bar-bg}; + --bs-progress-bar-animation-timing: #{$progress-bar-animation-timing}; + --bs-progress-bar-transition: #{$progress-bar-transition}; + + + // List group + --bs-list-group-color: #{$list-group-color}; + --bs-list-group-bg: #{$list-group-bg}; + --bs-list-group-border-color: #{$list-group-border-color}; + --bs-list-group-border-width: #{$list-group-border-width}; + --bs-list-group-border-radius: #{$list-group-border-radius}; + --bs-list-group-item-padding-y: #{$list-group-item-padding-y}; + --bs-list-group-item-padding-x: #{$list-group-item-padding-x}; + --bs-list-group-hover-bg: #{$list-group-hover-bg}; + --bs-list-group-active-color: #{$list-group-active-color}; + --bs-list-group-active-bg: #{$list-group-active-bg}; + --bs-list-group-active-border-color: #{$list-group-active-border-color}; + --bs-list-group-disabled-color: #{$list-group-disabled-color}; + --bs-list-group-disabled-bg: #{$list-group-disabled-bg}; + --bs-list-group-action-color: #{$list-group-action-color}; + --bs-list-group-action-hover-color: #{$list-group-action-hover-color}; + --bs-list-group-action-active-color: #{$list-group-action-active-color}; + --bs-list-group-action-active-bg: #{$list-group-action-active-bg}; + + + // Image thumbnails + --bs-thumbnail-padding: #{$thumbnail-padding}; + --bs-thumbnail-bg: #{$thumbnail-bg}; + --bs-thumbnail-border-width: #{$thumbnail-border-width}; + --bs-thumbnail-border-color: #{$thumbnail-border-color}; + --bs-thumbnail-border-radius: #{$thumbnail-border-radius}; + --bs-thumbnail-box-shadow: #{$thumbnail-box-shadow}; + + + // Figures + --bs-figure-caption-font-size: #{$figure-caption-font-size}; + --bs-figure-caption-color: #{$figure-caption-color}; + + + // Breadcrumbs + --bs-breadcrumb-padding-y: #{$breadcrumb-padding-y}; + --bs-breadcrumb-padding-x: #{$breadcrumb-padding-x}; + --bs-breadcrumb-item-padding: #{$breadcrumb-item-padding}; + --bs-breadcrumb-margin-bottom: #{$breadcrumb-margin-bottom}; + --bs-breadcrumb-bg: #{$breadcrumb-bg}; + --bs-breadcrumb-divider-color: #{$breadcrumb-divider-color}; + --bs-breadcrumb-active-color: #{$breadcrumb-active-color}; + --bs-breadcrumb-divider: #{$breadcrumb-divider}; + --bs-breadcrumb-border-radius: #{$breadcrumb-border-radius}; + + + // Carousel + --bs-carousel-control-color: #{$carousel-control-color}; + --bs-carousel-control-width: #{$carousel-control-width}; + --bs-carousel-control-opacity: #{$carousel-control-opacity}; + --bs-carousel-control-hover-opacity: #{$carousel-control-hover-opacity}; + --bs-carousel-control-transition: #{$carousel-control-transition}; + --bs-carousel-indicator-width: #{$carousel-indicator-width}; + --bs-carousel-indicator-height: #{$carousel-indicator-height}; + --bs-carousel-indicator-hit-area-height: #{$carousel-indicator-hit-area-height}; + --bs-carousel-indicator-spacer: #{$carousel-indicator-spacer}; + --bs-carousel-indicator-active-bg: #{$carousel-indicator-active-bg}; + --bs-carousel-indicator-transition: #{$carousel-indicator-transition}; + --bs-carousel-caption-width: #{$carousel-caption-width}; + --bs-carousel-caption-color: #{$carousel-caption-color}; + --bs-carousel-control-icon-width: #{$carousel-control-icon-width}; + --bs-carousel-control-prev-icon-bg: #{$carousel-control-prev-icon-bg}; + --bs-carousel-control-next-icon-bg: #{$carousel-control-next-icon-bg}; + --bs-carousel-transition-duration: #{$carousel-transition-duration}; + --bs-carousel-transition: #{$carousel-transition}; + + + // Spinners + --bs-spinner-width: #{$spinner-width}; + --bs-spinner-height: #{$spinner-height}; + --bs-spinner-border-width: #{$spinner-border-width}; + --bs-spinner-width-sm: #{$spinner-width-sm}; + --bs-spinner-height-sm: #{$spinner-height-sm}; + --bs-spinner-border-width-sm: #{$spinner-border-width-sm}; + + + // Close + --bs-close-font-size: #{$close-font-size}; + --bs-close-font-weight: #{$close-font-weight}; + --bs-close-color: #{$close-color}; + --bs-close-text-shadow: #{$close-text-shadow}; + + + // Code + --bs-code-font-size: #{$code-font-size}; + --bs-code-color: #{$code-color}; + --bs-kbd-padding-y: #{$kbd-padding-y}; + --bs-kbd-padding-x: #{$kbd-padding-x}; + --bs-kbd-font-size: #{$kbd-font-size}; + --bs-kbd-color: #{$kbd-color}; + --bs-kbd-bg: #{$kbd-bg}; + --bs-pre-color: #{$pre-color}; + --bs-pre-scrollable-max-height: #{$pre-scrollable-max-height}; + + + // Utilities + --bs-displays: #{$displays}; + --bs-overflows: #{$overflows}; + --bs-positions: #{$positions}; + + + // Printing + --bs-print-page-size: #{$print-page-size}; + --bs-print-body-min-width: #{$print-body-min-width}; + +} diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index dd52adcfbe..04db184a3d 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -1,43 +1,57 @@ -// @import '_themed_custom_variables.scss'; +:root { + --ds-content-spacing: #{$spacer * 1.5}; -$content-spacing: $spacer * 1.5 !default; + --ds-button-height: #{$input-btn-padding-y * 2 + $input-btn-line-height + calculateRem($input-btn-border-width*2)}; -$button-height: $input-btn-padding-y * 2 + $input-btn-line-height + calculateRem($input-btn-border-width*2) !default; + --ds-card-height-percentage:98%; + --ds-card-thumbnail-height:240px; + --ds-dropdown-menu-max-height: 200px; + --ds-drop-zone-area-height: 44px; + --ds-drop-zone-area-z-index: 1025; + --ds-drop-zone-area-inner-z-index: 1021; + --ds-login-logo-height:72px; + --ds-login-logo-width:72px; + --ds-submission-header-z-index: 1001; + --ds-submission-footer-z-index: 999; -$card-height-percentage:98% !default; -$card-thumbnail-height:240px !default; -$dropdown-menu-max-height: 200px !default; -$drop-zone-area-height: 44px !default; -$drop-zone-area-z-index: 1025 !default; -$drop-zone-area-inner-z-index: 1021 !default; -$login-logo-height:72px !default; -$login-logo-width:72px !default; -$submission-header-z-index: 1001 !default; -$submission-footer-z-index: 999 !default; + --ds-main-z-index: 0; + --ds-nav-z-index: 10; + --ds-sidebar-z-index: 20; -$main-z-index: 0 !default; -$nav-z-index: 10 !default; -$sidebar-z-index: 20 !default; + --ds-header-logo-height: 80px; + --ds-header-logo-height-xs: 50px; + --ds-header-icon-color: #{$link-color}; + --ds-header-icon-color-hover: #{darken($link-color, 15%)}; -$header-logo-height: 80px !default; -$header-logo-height-xs: 50px !default; -$header-icon-color: $link-color !default; + $admin-sidebar-bg: darken(#2B4E72, 17%); + $admin-sidebar-active-bg: darken($admin-sidebar-bg, 3%); + --ds-admin-sidebar-bg: #{$admin-sidebar-bg}; + --ds-admin-sidebar-active-bg: #{$admin-sidebar-active-bg}; + --ds-admin-sidebar-header-bg: #{darken($admin-sidebar-bg, 7%)}; -$admin-sidebar-bg: darken(#2B4E72, 17%) !default; -$admin-sidebar-active-bg: darken($admin-sidebar-bg, 3%) !default; -$admin-sidebar-header-bg: darken($admin-sidebar-bg, 7%) !default; + --ds-dark-scrollbar-bg: #{$admin-sidebar-active-bg}; + --ds-dark-scrollbar-alt-bg: #{lighten($admin-sidebar-active-bg, 2%)}; + --ds-dark-scrollbar-fg: #47495d; -$dark-scrollbar-background: $admin-sidebar-active-bg !default; -$dark-scrollbar-foreground: #47495d !default; + --ds-submission-sections-margin-bottom: .5rem; -$submission-sections-margin-bottom: .5rem !default; + --ds-edit-item-button-min-width: 100px; + --ds-edit-item-metadata-field-width: 190px; + --ds-edit-item-language-field-width: 43px; -$edit-item-button-min-width: 100px !default; -$edit-item-metadata-field-width: 190px !default; -$edit-item-language-field-width: 43px !default; + --ds-thumbnail-max-width: 175px; -$thumbnail-max-width: 175px !default; + --ds-dso-selector-list-max-height: 475px; + --ds-dso-selector-current-background-color: #eeeeee; + --ds-dso-selector-current-background-hover-color: #{darken(#eeeeee, 10%)}; + --ds-notification-bg-success: #{darken(adjust-hue($success, -10), 10%)}; + --ds-notification-bg-danger: #{darken(adjust-hue($danger, -10), 10%)}; + --ds-notification-bg-info: #{darken(adjust-hue($info, -10), 10%)}; + --ds-notification-bg-warning: #{darken(adjust-hue($warning, -10), 10%)}; -$dso-selector-list-max-height: 475px !default; -$dso-selector-current-background-color: #eeeeee; -$dso-selector-current-background-hover-color: darken($dso-selector-current-background-color, 10%); + --ds-fa-fixed-width: #{$fa-fixed-width}; + --ds-icon-padding: #{$icon-padding}; + --ds-collapsed-sidebar-width: #{$collapsed-sidebar-width}; + --ds-sidebar-items-width: #{$sidebar-items-width}; + --ds-total-sidebar-width: #{$total-sidebar-width}; +} diff --git a/src/styles/_exposed_variables.scss b/src/styles/_exposed_variables.scss index 1ab67e709d..d01544649b 100644 --- a/src/styles/_exposed_variables.scss +++ b/src/styles/_exposed_variables.scss @@ -1,12 +1,14 @@ @import '_variables.scss'; +// These CSS variables are picked up by CSSVariableService to and made available as JS variables as +// well. :export { - xlMin: map-get($grid-breakpoints, xl); - mdMin: map-get($grid-breakpoints, md); - lgMin: map-get($grid-breakpoints, lg); - smMin: map-get($grid-breakpoints, sm); - adminSidebarActiveBg: $admin-sidebar-active-bg; - sidebarItemsWidth: $sidebar-items-width; - collapsedSidebarWidth: $collapsed-sidebar-width; - totalSidebarWidth: $total-sidebar-width; + xlMin: var(--bs-xl); + mdMin: var(--bs-md); + lgMin: var(--bs-lg); + smMin: var(--bs-sm); + adminSidebarActiveBg: var(--ds-admin-sidebar-active-bg); + sidebarItemsWidth: var(--ds-sidebar-items-width); + collapsedSidebarWidth: var(--ds-collapsed-sidebar-width); + totalSidebarWidth: var(--ds-total-sidebar-width); } diff --git a/src/styles/_global-styles.scss b/src/styles/_global-styles.scss new file mode 100644 index 0000000000..b79cf52fbb --- /dev/null +++ b/src/styles/_global-styles.scss @@ -0,0 +1,49 @@ +html { + position: relative; + min-height: 100%; +} + +body { + overflow-x: hidden; +} + +// Sticky Footer +.outer-wrapper { + display: flex; + margin: 0; +} + +.inner-wrapper { + flex: 1 1 auto; + flex-flow: column nowrap; + display: flex; + min-height: 100vh; + flex-direction: column; + width: 100%; + position: relative; +} + +.main-content { + z-index: var(--ds-main-z-index); + flex: 1 1 100%; + margin-top: var(--ds-content-spacing); + margin-bottom: var(--ds-content-spacing); +} + +.alert.hide { + padding: 0; + margin: 0; +} + +ds-header-navbar-wrapper { + z-index: var(--ds-nav-z-index); +} + +ds-admin-sidebar { + position: fixed; + z-index: var(--ds-sidebar-z-index); +} + +.ds-full-screen-loader { + height: 100vh; +} diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss index e72af304cd..04347b3131 100644 --- a/src/styles/_mixins.scss +++ b/src/styles/_mixins.scss @@ -19,23 +19,23 @@ height: 3px; } &::-webkit-scrollbar-button { - background-color: $dark-scrollbar-background; + background-color: var(--ds-dark-scrollbar-bg); } &::-webkit-scrollbar-track { - background-color: lighten($dark-scrollbar-background, 2%); + background-color: var(--ds-dark-scrollbar-alt-bg); } &::-webkit-scrollbar-track-piece { - background-color: $dark-scrollbar-background; + background-color: var(--ds-dark-scrollbar-bg); } &::-webkit-scrollbar-thumb { height: 50px; - background-color: $dark-scrollbar-foreground; + background-color: var(--ds-dark-scrollbar-fg); border-radius: 3px; } &::-webkit-scrollbar-corner { - background-color: lighten($dark-scrollbar-background, 2%); + background-color: var(--ds-dark-scrollbar-alt-bg); } &::-webkit-resizer { - background-color: $dark-scrollbar-background; + background-color: var(--ds-dark-scrollbar-bg); } } diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 2632dbfd09..badc159747 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -3,5 +3,3 @@ @import '_bootstrap_variables.scss'; @import '../../node_modules/bootstrap/scss/variables.scss'; - -@import '_custom_variables.scss'; diff --git a/src/styles/base-theme.scss b/src/styles/base-theme.scss new file mode 100644 index 0000000000..068c2ece26 --- /dev/null +++ b/src/styles/base-theme.scss @@ -0,0 +1,6 @@ +@import './helpers/font_awesome_imports.scss'; +@import '../../node_modules/bootstrap/scss/bootstrap.scss'; +@import '../../node_modules/nouislider/distribute/nouislider.min'; +@import './_custom_variables.scss'; +@import './bootstrap_variables_mapping.scss'; +@import './_global-styles.scss'; diff --git a/src/themes/custom/app/+home-page/home-news/home-news.component.html b/src/themes/custom/app/+home-page/home-news/home-news.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/+home-page/home-news/home-news.component.scss b/src/themes/custom/app/+home-page/home-news/home-news.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/+home-page/home-news/home-news.component.ts b/src/themes/custom/app/+home-page/home-news/home-news.component.ts new file mode 100644 index 0000000000..eaf788e599 --- /dev/null +++ b/src/themes/custom/app/+home-page/home-news/home-news.component.ts @@ -0,0 +1,16 @@ +import { Component } from '@angular/core'; +import { HomeNewsComponent as BaseComponent } from '../../../../../app/+home-page/home-news/home-news.component'; + +@Component({ + selector: 'ds-home-news', + // styleUrls: ['./home-news.component.scss'], + styleUrls: ['../../../../../app/+home-page/home-news/home-news.component.scss'], + // templateUrl: './home-news.component.html' + templateUrl: '../../../../../app/+home-page/home-news/home-news.component.html' +}) + +/** + * Component to render the news section on the home page + */ +export class HomeNewsComponent extends BaseComponent {} + diff --git a/src/themes/custom/app/+home-page/home-page.component.html b/src/themes/custom/app/+home-page/home-page.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/+home-page/home-page.component.scss b/src/themes/custom/app/+home-page/home-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/+home-page/home-page.component.ts b/src/themes/custom/app/+home-page/home-page.component.ts new file mode 100644 index 0000000000..602492e9bd --- /dev/null +++ b/src/themes/custom/app/+home-page/home-page.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { HomePageComponent as BaseComponent } from '../../../../app/+home-page/home-page.component'; + +@Component({ + selector: 'ds-home-page', + // styleUrls: ['./home-page.component.scss'], + styleUrls: ['../../../../app/+home-page/home-page.component.scss'], + // templateUrl: './home-page.component.html' + templateUrl: '../../../../app/+home-page/home-page.component.html' +}) +export class HomePageComponent extends BaseComponent { + +} diff --git a/src/themes/custom/app/+item-page/simple/item-types/publication/publication.component.html b/src/themes/custom/app/+item-page/simple/item-types/publication/publication.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/+item-page/simple/item-types/publication/publication.component.scss b/src/themes/custom/app/+item-page/simple/item-types/publication/publication.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/+item-page/simple/item-types/publication/publication.component.ts b/src/themes/custom/app/+item-page/simple/item-types/publication/publication.component.ts new file mode 100644 index 0000000000..4eda82a10a --- /dev/null +++ b/src/themes/custom/app/+item-page/simple/item-types/publication/publication.component.ts @@ -0,0 +1,22 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ViewMode } from '../../../../../../../app/core/shared/view-mode.model'; +import { listableObjectComponent } from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; +import { Context } from '../../../../../../../app/core/shared/context.model'; +import { PublicationComponent as BaseComponent } from '../../../../../../../app/+item-page/simple/item-types/publication/publication.component'; + +/** + * Component that represents a publication Item page + */ + +@listableObjectComponent('Publication', ViewMode.StandalonePage, Context.Any, 'custom') +@Component({ + selector: 'ds-publication', + // styleUrls: ['./publication.component.scss'], + styleUrls: ['../../../../../../../app/+item-page/simple/item-types/publication/publication.component.scss'], + // templateUrl: './publication.component.html', + templateUrl: '../../../../../../../app/+item-page/simple/item-types/publication/publication.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PublicationComponent extends BaseComponent { + +} diff --git a/src/themes/custom/app/root/root.component.html b/src/themes/custom/app/root/root.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/root/root.component.scss b/src/themes/custom/app/root/root.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/root/root.component.ts b/src/themes/custom/app/root/root.component.ts new file mode 100644 index 0000000000..6b5b0c106f --- /dev/null +++ b/src/themes/custom/app/root/root.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { slideSidebarPadding } from '../../../../app/shared/animations/slide'; +import { RootComponent as BaseComponent } from '../../../../app/root/root.component'; + +@Component({ + selector: 'ds-root', + // styleUrls: ['./root.component.scss'], + styleUrls: ['../../../../app/root/root.component.scss'], + // templateUrl: './root.component.html', + templateUrl: '../../../../app/root/root.component.html', + animations: [slideSidebarPadding], +}) +export class RootComponent extends BaseComponent { + +} diff --git a/src/themes/custom/assets/fonts/.gitkeep b/src/themes/custom/assets/fonts/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/assets/images/.gitkeep b/src/themes/custom/assets/images/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/entry-components.ts b/src/themes/custom/entry-components.ts new file mode 100644 index 0000000000..d859b56333 --- /dev/null +++ b/src/themes/custom/entry-components.ts @@ -0,0 +1,5 @@ +import { PublicationComponent } from './app/+item-page/simple/item-types/publication/publication.component'; + +export const ENTRY_COMPONENTS = [ + PublicationComponent +]; diff --git a/src/themes/custom/styles/_global-styles.scss b/src/themes/custom/styles/_global-styles.scss new file mode 100644 index 0000000000..3857f1ad0a --- /dev/null +++ b/src/themes/custom/styles/_global-styles.scss @@ -0,0 +1,4 @@ +// Add any global css for the theme here + +// imports the base global style +@import '../../../styles/_global-styles.scss'; diff --git a/src/themes/custom/styles/_theme_css_variable_overrides.scss b/src/themes/custom/styles/_theme_css_variable_overrides.scss new file mode 100644 index 0000000000..3ff54cbf8f --- /dev/null +++ b/src/themes/custom/styles/_theme_css_variable_overrides.scss @@ -0,0 +1,8 @@ +// Override or add CSS variables for your theme here + +:root { + //--ds-header-logo-height: 80px; + //--ds-header-logo-height-xs: 50px; + //--ds-header-icon-color: #{$link-color}; + //--ds-header-icon-color-hover: #{darken($link-color, 15%)}; +} diff --git a/src/themes/custom/styles/_theme_sass_variable_overrides.scss b/src/themes/custom/styles/_theme_sass_variable_overrides.scss new file mode 100644 index 0000000000..1e5d0f0520 --- /dev/null +++ b/src/themes/custom/styles/_theme_sass_variable_overrides.scss @@ -0,0 +1,16 @@ +// DSpace works with CSS variables for its own components, and has a mapping of all bootstrap Sass +// variables to CSS equivalents (see src/styles/_bootstrap_variables_mapping.scss). However Bootstrap +// still uses Sass variables internally. So if you want to override bootstrap (or other sass +// variables) you can do so here. Their CSS counterparts will include the changes you make here + +// $blue: #007bff !default; +// $indigo: #6610f2 !default; +// $purple: #6f42c1 !default; +// $pink: #e83e8c !default; +// $red: #dc3545 !default; +// $orange: #fd7e14 !default; +// $yellow: #ffc107 !default; +// $green: #28a745 !default; +// $teal: #20c997 !default; +// $cyan: #17a2b8 !default; + diff --git a/src/themes/custom/styles/theme.scss b/src/themes/custom/styles/theme.scss new file mode 100644 index 0000000000..e4cc9e45ed --- /dev/null +++ b/src/themes/custom/styles/theme.scss @@ -0,0 +1,12 @@ +// This file combines the other scss files in to one. You usually shouldn't edit this file directly + +@import './_theme_sass_variable_overrides.scss'; +@import '../../../styles/_variables.scss'; +@import '../../../styles/_mixins.scss'; +@import '../../../styles/helpers/font_awesome_imports.scss'; +@import '../../../../node_modules/bootstrap/scss/bootstrap.scss'; +@import '../../../../node_modules/nouislider/distribute/nouislider.min'; +@import '../../../styles/_custom_variables.scss'; +@import './_theme_css_variable_overrides.scss'; +@import '../../../styles/bootstrap_variables_mapping.scss'; +@import './_global-styles.scss'; diff --git a/src/themes/custom/theme.module.ts b/src/themes/custom/theme.module.ts new file mode 100644 index 0000000000..1f451df1bc --- /dev/null +++ b/src/themes/custom/theme.module.ts @@ -0,0 +1,96 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AdminAccessControlModule } from '../../app/+admin/admin-access-control/admin-access-control.module'; +import { AdminRegistriesModule } from '../../app/+admin/admin-registries/admin-registries.module'; +import { AdminSearchModule } from '../../app/+admin/admin-search-page/admin-search.module'; +import { AdminWorkflowModuleModule } from '../../app/+admin/admin-workflow-page/admin-workflow.module'; +import { BitstreamFormatsModule } from '../../app/+admin/admin-registries/bitstream-formats/bitstream-formats.module'; +import { BrowseByModule } from '../../app/+browse-by/browse-by.module'; +import { CollectionFormModule } from '../../app/+collection-page/collection-form/collection-form.module'; +import { CommunityFormModule } from '../../app/+community-page/community-form/community-form.module'; +import { CoreModule } from '../../app/core/core.module'; +import { DragDropModule } from '@angular/cdk/drag-drop'; +import { EditItemPageModule } from '../../app/+item-page/edit-item-page/edit-item-page.module'; +import { FormsModule } from '@angular/forms'; +import { HttpClientModule } from '@angular/common/http'; +import { IdlePreloadModule } from 'angular-idle-preload'; +import { JournalEntitiesModule } from '../../app/entity-groups/journal-entities/journal-entities.module'; +import { MyDspaceSearchModule } from '../../app/+my-dspace-page/my-dspace-search.module'; +import { MenuModule } from '../../app/shared/menu/menu.module'; +import { NavbarModule } from '../../app/navbar/navbar.module'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ProfilePageModule } from '../../app/profile-page/profile-page.module'; +import { RegisterEmailFormModule } from '../../app/register-email-form/register-email-form.module'; +import { ResearchEntitiesModule } from '../../app/entity-groups/research-entities/research-entities.module'; +import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; +import { SearchPageModule } from '../../app/+search-page/search-page.module'; +import { SharedModule } from '../../app/shared/shared.module'; +import { StatisticsModule } from '../../app/statistics/statistics.module'; +import { StoreModule } from '@ngrx/store'; +import { StoreRouterConnectingModule } from '@ngrx/router-store'; +import { TranslateModule } from '@ngx-translate/core'; +import { HomeNewsComponent } from './app/+home-page/home-news/home-news.component'; +import { HomePageComponent } from './app/+home-page/home-page.component'; +import { HomePageModule } from '../../app/+home-page/home-page.module'; +import { RootComponent } from './app/root/root.component'; +import { AppModule } from '../../app/app.module'; +import { PublicationComponent } from './app/+item-page/simple/item-types/publication/publication.component'; +import { ItemPageModule } from '../../app/+item-page/item-page.module'; +import { RouterModule } from '@angular/router'; + +const DECLARATIONS = [ + HomePageComponent, + HomeNewsComponent, + RootComponent, + PublicationComponent +]; + +@NgModule({ + imports: [ + AdminAccessControlModule, + AdminRegistriesModule, + AdminSearchModule, + AdminWorkflowModuleModule, + AppModule, + BitstreamFormatsModule, + BrowseByModule, + CollectionFormModule, + CommonModule, + CommunityFormModule, + CoreModule, + DragDropModule, + ItemPageModule, + EditItemPageModule, + FormsModule, + HomePageModule, + HttpClientModule, + IdlePreloadModule, + JournalEntitiesModule, + MenuModule, + MyDspaceSearchModule, + NavbarModule, + NgbModule, + ProfilePageModule, + RegisterEmailFormModule, + ResearchEntitiesModule, + RouterModule, + ScrollToModule, + SearchPageModule, + SharedModule, + StatisticsModule, + StoreModule, + StoreRouterConnectingModule, + TranslateModule, + ], + declarations: DECLARATIONS +}) + +/** + * This module serves as an index for all the components in this theme. + * It should import all other modules, so the compiler knows where to find any components referenced + * from a component in this theme + * It is purposefully not exported, it should never be imported anywhere else, its only purpose is + * to give lazily loaded components a context in which they can be compiled successfully + */ +class ThemeModule { +} diff --git a/themes/mantis/app/+home-page/home-news/home-news.component.html b/src/themes/mantis/app/+home-page/home-news/home-news.component.html similarity index 100% rename from themes/mantis/app/+home-page/home-news/home-news.component.html rename to src/themes/mantis/app/+home-page/home-news/home-news.component.html diff --git a/src/themes/mantis/app/+home-page/home-news/home-news.component.scss b/src/themes/mantis/app/+home-page/home-news/home-news.component.scss new file mode 100644 index 0000000000..b82d84a71e --- /dev/null +++ b/src/themes/mantis/app/+home-page/home-news/home-news.component.scss @@ -0,0 +1,17 @@ +@import 'src/app/+home-page/home-news/home-news.component.scss'; +:host { + --ds-home-news-link-color: #{$green}; + --ds-home-news-link-hover-color: #{darken($green, 15%)}; + + .jumbotron { + background-color: transparent; + } + + a { + color: var(--ds-home-news-link-color); + + @include hover { + color: var(--ds-home-news-link-hover-color); + } + } +} diff --git a/themes/mantis/app/+home-page/home-page.component.html b/src/themes/mantis/app/+home-page/home-page.component.html similarity index 100% rename from themes/mantis/app/+home-page/home-page.component.html rename to src/themes/mantis/app/+home-page/home-page.component.html diff --git a/themes/mantis/app/+home-page/home-page.component.scss b/src/themes/mantis/app/+home-page/home-page.component.scss similarity index 60% rename from themes/mantis/app/+home-page/home-page.component.scss rename to src/themes/mantis/app/+home-page/home-page.component.scss index 64bd786cd5..d4350c1e13 100644 --- a/themes/mantis/app/+home-page/home-page.component.scss +++ b/src/themes/mantis/app/+home-page/home-page.component.scss @@ -2,7 +2,7 @@ div.background-image { color: white; - background-color: $info; + background-color: var(--bs-info); position: relative; background-position-y: -200px; background-image: url('/assets/images/banner.jpg'); @@ -18,24 +18,24 @@ div.background-image { &:before, &:after { content: ''; display: block; - width: $banner-background-gradient-width; + width: var(--ds-banner-background-gradient-width); height: 100%; top: 0; position: absolute; } &:before { - background: linear-gradient(to left, $banner-text-background, transparent); - left: -$banner-background-gradient-width; + background: linear-gradient(to left, var(--ds-banner-text-background), transparent); + left: calc(-1 * var(--ds-banner-background-gradient-width)); } &:after { - background: linear-gradient(to right, $banner-text-background, transparent); - right: -$banner-background-gradient-width; + background: linear-gradient(to right, var(--ds-banner-text-background), transparent); + right: calc(-1 * var(--ds-banner-background-gradient-width)); } - background-color: $banner-text-background; + background-color: var(--ds-banner-text-background); } @@ -46,7 +46,7 @@ div.background-image { opacity: 0.3; position: absolute; - right: $spacer; + right: var(--bs-spacer); bottom: 0; } } diff --git a/themes/mantis/app/+item-page/simple/item-page.component.html b/src/themes/mantis/app/+item-page/simple/item-page.component.html similarity index 100% rename from themes/mantis/app/+item-page/simple/item-page.component.html rename to src/themes/mantis/app/+item-page/simple/item-page.component.html diff --git a/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.html b/src/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.html similarity index 100% rename from themes/mantis/app/+item-page/simple/item-types/publication/publication.component.html rename to src/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.html diff --git a/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.scss b/src/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.scss similarity index 51% rename from themes/mantis/app/+item-page/simple/item-types/publication/publication.component.scss rename to src/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.scss index f65d9a00d5..6d12fcec71 100644 --- a/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.scss +++ b/src/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.scss @@ -3,28 +3,28 @@ :host { > * { display: block; - padding-top: $content-spacing; - padding-bottom: $content-spacing; + padding-top: var(--ds-content-spacing); + padding-bottom: var(--ds-content-spacing); } .top-item-page { - background-color: $gray-100; - margin-top: -$content-spacing; + background-color: var(--bs-gray-100); + margin-top: calc(-1 * var(--ds-content-spacing)); } .relationships-item-page { - padding-bottom: $content-spacing - $spacer; + padding-bottom: calc(var(--ds-content-spacing) - var(--bs-spacer)); } ds-metadata-field-wrapper { @media screen and (max-width: map-get($grid-breakpoints, md)) { flex: 1; - padding-right: $spacer/2; + padding-right: calc(var(--bs-spacer) / 2); } ds-thumbnail { display: block; - max-width: $thumbnail-max-width; + max-width: var(--ds-thumbnail-max-width); } } } diff --git a/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html b/src/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html similarity index 100% rename from themes/mantis/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html rename to src/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html diff --git a/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.scss b/src/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.scss similarity index 52% rename from themes/mantis/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.scss rename to src/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.scss index 3caa55f533..c0a314d715 100644 --- a/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.scss +++ b/src/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.scss @@ -3,28 +3,28 @@ :host { > * { display: block; - padding-top: $content-spacing; - padding-bottom: $content-spacing; + padding-top: var(--ds-content-spacing); + padding-bottom: var(--ds-content-spacing); } .top-item-page { - background-color: $gray-100; - margin-top: -$content-spacing; + background-color: var(--bs-gray-100); + margin-top: calc(-1 * var(--ds-content-spacing)); } .relationships-item-page { - padding-bottom: $content-spacing - $spacer; + padding-bottom: calc(var(--ds-content-spacing) - var(--bs-spacer)); } ds-metadata-field-wrapper { @media screen and (max-width: map-get($grid-breakpoints, md)) { flex: 1; - padding-right: $spacer/2; + padding-right: calc(var(--bs-spacer) / 2); } ds-thumbnail { display: block; - max-width: $thumbnail-max-width; + max-width: var(--ds-thumbnail-max-width); } } } diff --git a/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html b/src/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html similarity index 100% rename from themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html rename to src/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html diff --git a/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.scss b/src/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.scss similarity index 52% rename from themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.scss rename to src/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.scss index 5c2534b318..6e418258d3 100644 --- a/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.scss +++ b/src/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.scss @@ -3,28 +3,28 @@ :host { > * { display: block; - padding-top: $content-spacing; - padding-bottom: $content-spacing; + padding-top: var(--ds-content-spacing); + padding-bottom: var(--ds-content-spacing); } .top-item-page { - background-color: $gray-100; - margin-top: -$content-spacing; + background-color: var(--bs-gray-100); + margin-top: calc(-1 * var(--ds-content-spacing)); } .relationships-item-page { - padding-bottom: $content-spacing - $spacer; + padding-bottom: calc(var(--ds-content-spacing) - var(--bs-spacer)); } ds-metadata-field-wrapper { @media screen and (max-width: map-get($grid-breakpoints, md)) { flex: 1; - padding-right: $spacer/2; + padding-right: calc(var(--bs-spacer) / 2); } ds-thumbnail { display: block; - max-width: $thumbnail-max-width; + max-width: var(--ds-thumbnail-max-width); } } } diff --git a/themes/mantis/app/entity-groups/journal-entities/item-pages/journal/journal.component.html b/src/themes/mantis/app/entity-groups/journal-entities/item-pages/journal/journal.component.html similarity index 100% rename from themes/mantis/app/entity-groups/journal-entities/item-pages/journal/journal.component.html rename to src/themes/mantis/app/entity-groups/journal-entities/item-pages/journal/journal.component.html diff --git a/themes/mantis/app/entity-groups/journal-entities/item-pages/journal/journal.component.scss b/src/themes/mantis/app/entity-groups/journal-entities/item-pages/journal/journal.component.scss similarity index 57% rename from themes/mantis/app/entity-groups/journal-entities/item-pages/journal/journal.component.scss rename to src/themes/mantis/app/entity-groups/journal-entities/item-pages/journal/journal.component.scss index 5c0d1c44b8..d2a7c8ca46 100644 --- a/themes/mantis/app/entity-groups/journal-entities/item-pages/journal/journal.component.scss +++ b/src/themes/mantis/app/entity-groups/journal-entities/item-pages/journal/journal.component.scss @@ -3,33 +3,33 @@ :host { > * { display: block; - padding-top: $content-spacing; - padding-bottom: $content-spacing; + padding-top: var(--ds-content-spacing); + padding-bottom: var(--ds-content-spacing); } .top-item-page { - background-color: $gray-100; - margin-top: -$content-spacing; + background-color: var(--bs-gray-100); + margin-top: calc(-1 * var(--ds-content-spacing)); } .relationships-item-page { - padding-bottom: $content-spacing - $spacer; + padding-bottom: calc(var(--ds-content-spacing) - var(--bs-spacer)); } ds-metadata-field-wrapper { @media screen and (max-width: map-get($grid-breakpoints, md)) { flex: 1; - padding-right: $spacer/2; + padding-right: calc(var(--bs-spacer) / 2); } ds-thumbnail { display: block; - max-width: $thumbnail-max-width; + max-width: var(--ds-thumbnail-max-width); } } .search-container { - margin-bottom: $spacer; + margin-bottom: var(--bs-spacer); @media screen and (max-width: map-get($grid-breakpoints, lg)) { width: 100%; max-width: none; diff --git a/themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html b/src/themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html similarity index 100% rename from themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html rename to src/themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html diff --git a/themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.scss b/src/themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.scss similarity index 51% rename from themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.scss rename to src/themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.scss index 4a1d2516da..aff2622323 100644 --- a/themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.scss +++ b/src/themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.scss @@ -3,28 +3,28 @@ :host { > * { display: block; - padding-top: $content-spacing; - padding-bottom: $content-spacing; + padding-top: var(--ds-content-spacing); + padding-bottom: var(--ds-content-spacing); } .top-item-page { - background-color: $gray-100; - margin-top: -$content-spacing; + background-color: var(--bs-gray-100); + margin-top: calc(-1 * var(--ds-content-spacing)); } .relationships-item-page { - padding-bottom: $content-spacing - $spacer; + padding-bottom: calc(var(--ds-content-spacing) - var(--bs-spacer)); } ds-metadata-field-wrapper { @media screen and (max-width: map-get($grid-breakpoints, md)) { flex: 1; - padding-right: $spacer/2; + padding-right: calc(var(--bs-spacer) / 2); } ds-thumbnail { display: block; - max-width: $thumbnail-max-width; + max-width: var(--ds-thumbnail-max-width); } } } diff --git a/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.html b/src/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.html similarity index 100% rename from themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.html rename to src/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.html diff --git a/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.scss b/src/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.scss similarity index 57% rename from themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.scss rename to src/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.scss index 48571b05b2..51c15716f9 100644 --- a/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.scss +++ b/src/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.scss @@ -3,33 +3,33 @@ :host { > * { display: block; - padding-top: $content-spacing; - padding-bottom: $content-spacing; + padding-top: var(--ds-content-spacing); + padding-bottom: var(--ds-content-spacing); } .top-item-page { - background-color: $gray-100; - margin-top: -$content-spacing; + background-color: var(--bs-gray-100); + margin-top: calc(-1 * var(--ds-content-spacing)); } .relationships-item-page { - padding-bottom: $content-spacing - $spacer; + padding-bottom: calc(var(--ds-content-spacing) - var(--bs-spacer)); } ds-metadata-field-wrapper { @media screen and (max-width: map-get($grid-breakpoints, md)) { flex: 1; - padding-right: $spacer/2; + padding-right: calc(var(--bs-spacer) / 2); } ds-thumbnail { display: block; - max-width: $thumbnail-max-width; + max-width: var(--ds-thumbnail-max-width); } } .search-container { - margin-bottom: $spacer; + margin-bottom: var(--bs-spacer); @media screen and (max-width: map-get($grid-breakpoints, lg)) { width: 100%; max-width: none; diff --git a/themes/mantis/app/entity-groups/research-entities/item-pages/project/project.component.html b/src/themes/mantis/app/entity-groups/research-entities/item-pages/project/project.component.html similarity index 100% rename from themes/mantis/app/entity-groups/research-entities/item-pages/project/project.component.html rename to src/themes/mantis/app/entity-groups/research-entities/item-pages/project/project.component.html diff --git a/themes/mantis/app/entity-groups/research-entities/item-pages/project/project.component.scss b/src/themes/mantis/app/entity-groups/research-entities/item-pages/project/project.component.scss similarity index 51% rename from themes/mantis/app/entity-groups/research-entities/item-pages/project/project.component.scss rename to src/themes/mantis/app/entity-groups/research-entities/item-pages/project/project.component.scss index d2707d30cc..076baad1a0 100644 --- a/themes/mantis/app/entity-groups/research-entities/item-pages/project/project.component.scss +++ b/src/themes/mantis/app/entity-groups/research-entities/item-pages/project/project.component.scss @@ -3,28 +3,28 @@ :host { > * { display: block; - padding-top: $content-spacing; - padding-bottom: $content-spacing; + padding-top: var(--ds-content-spacing); + padding-bottom: var(--ds-content-spacing); } .top-item-page { - background-color: $gray-100; - margin-top: -$content-spacing; + background-color: var(--bs-gray-100); + margin-top: calc(-1 * var(--ds-content-spacing)); } .relationships-item-page { - padding-bottom: $content-spacing - $spacer; + padding-bottom: calc(var(--ds-content-spacing) - var(--bs-spacer)); } ds-metadata-field-wrapper { @media screen and (max-width: map-get($grid-breakpoints, md)) { flex: 1; - padding-right: $spacer/2; + padding-right: calc(var(--bs-spacer) / 2); } ds-thumbnail { display: block; - max-width: $thumbnail-max-width; + max-width: var(--ds-thumbnail-max-width); } } } diff --git a/themes/mantis/app/navbar/navbar.component.html b/src/themes/mantis/app/navbar/navbar.component.html similarity index 100% rename from themes/mantis/app/navbar/navbar.component.html rename to src/themes/mantis/app/navbar/navbar.component.html diff --git a/themes/mantis/app/navbar/navbar.component.scss b/src/themes/mantis/app/navbar/navbar.component.scss similarity index 100% rename from themes/mantis/app/navbar/navbar.component.scss rename to src/themes/mantis/app/navbar/navbar.component.scss diff --git a/themes/mantis/app/shared/search-form/search-form.component.html b/src/themes/mantis/app/shared/search-form/search-form.component.html similarity index 100% rename from themes/mantis/app/shared/search-form/search-form.component.html rename to src/themes/mantis/app/shared/search-form/search-form.component.html diff --git a/themes/mantis/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html b/src/themes/mantis/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html similarity index 100% rename from themes/mantis/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html rename to src/themes/mantis/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html diff --git a/themes/mantis/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html b/src/themes/mantis/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html similarity index 100% rename from themes/mantis/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html rename to src/themes/mantis/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html diff --git a/themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.html b/src/themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.html similarity index 100% rename from themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.html rename to src/themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.html diff --git a/themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.scss b/src/themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.scss similarity index 63% rename from themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.scss rename to src/themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.scss index 4d2d29ae41..0e78c64629 100644 --- a/themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.scss +++ b/src/themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.scss @@ -1,8 +1,8 @@ @import 'src/app/shared/search/search-filters/search-filter/search-filter.component.scss'; .facet-filter { - background-color: map-get($theme-colors, light); - border-radius: $border-radius; + background-color: var(--bs-light); + border-radius: var(--bs-border-radius); h5 { font-size: 1.1rem diff --git a/themes/mantis/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss b/src/themes/mantis/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss similarity index 82% rename from themes/mantis/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss rename to src/themes/mantis/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss index 7edcb8f063..158a0d3b4e 100644 --- a/themes/mantis/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss +++ b/src/themes/mantis/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss @@ -1,5 +1,5 @@ @import 'src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss'; ::ng-deep .noUi-connect { - background: $info; + background: var(--bs-info); } diff --git a/themes/mantis/app/shared/search/search-filters/search-filters.component.html b/src/themes/mantis/app/shared/search/search-filters/search-filters.component.html similarity index 100% rename from themes/mantis/app/shared/search/search-filters/search-filters.component.html rename to src/themes/mantis/app/shared/search/search-filters/search-filters.component.html diff --git a/themes/mantis/app/shared/search/search-settings/search-settings.component.html b/src/themes/mantis/app/shared/search/search-settings/search-settings.component.html similarity index 100% rename from themes/mantis/app/shared/search/search-settings/search-settings.component.html rename to src/themes/mantis/app/shared/search/search-settings/search-settings.component.html diff --git a/themes/mantis/app/shared/search/search-settings/search-settings.component.scss b/src/themes/mantis/app/shared/search/search-settings/search-settings.component.scss similarity index 61% rename from themes/mantis/app/shared/search/search-settings/search-settings.component.scss rename to src/themes/mantis/app/shared/search/search-settings/search-settings.component.scss index 073039dae8..b3ee0ba60e 100644 --- a/themes/mantis/app/shared/search/search-settings/search-settings.component.scss +++ b/src/themes/mantis/app/shared/search/search-settings/search-settings.component.scss @@ -1,8 +1,8 @@ @import 'src/app/shared/search/search-settings/search-settings.component.scss'; .setting-option { - background-color: map-get($theme-colors, light); - border-radius: $border-radius; + background-color: var(--bs-light); + border-radius: var(--bs-border-radius); h5 { font-size: 1.1rem } diff --git a/src/themes/mantis/readme.md b/src/themes/mantis/readme.md new file mode 100644 index 0000000000..93bdf36f47 --- /dev/null +++ b/src/themes/mantis/readme.md @@ -0,0 +1,2 @@ +#Note +For now the existing mantis theme has only been moved to the new themes folder, it has not yet been adapted to work as a dynamic theme. \ No newline at end of file diff --git a/themes/mantis/styles/_themed_bootstrap_variables.scss b/src/themes/mantis/styles/_themed_bootstrap_variables.scss similarity index 100% rename from themes/mantis/styles/_themed_bootstrap_variables.scss rename to src/themes/mantis/styles/_themed_bootstrap_variables.scss diff --git a/src/themes/mantis/styles/_themed_custom_variables.scss b/src/themes/mantis/styles/_themed_custom_variables.scss new file mode 100644 index 0000000000..60d248c4ae --- /dev/null +++ b/src/themes/mantis/styles/_themed_custom_variables.scss @@ -0,0 +1,4 @@ +:root { + --ds-banner-text-background: rgba(0, 0, 0, 0.35); + --ds-banner-background-gradient-width: 300px; +} diff --git a/src/themes/themed-entry-component.module.ts b/src/themes/themed-entry-component.module.ts new file mode 100644 index 0000000000..41cdf62269 --- /dev/null +++ b/src/themes/themed-entry-component.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { ENTRY_COMPONENTS as CUSTOM } from './custom/entry-components'; + +const ENTRY_COMPONENTS = [ + ...CUSTOM, +]; + + +/** + * This module only serves to ensure themed entry components are discoverable. It's kept separate + * from the theme modules to ensure only the minimal number of theme components is loaded ahead of + * time + */ +@NgModule() +export class ThemedEntryComponentModule { + static withEntryComponents() { + return { + ngModule: ThemedEntryComponentModule, + providers: ENTRY_COMPONENTS.map((component) => ({provide: component})) + }; + } + +} diff --git a/themes/mantis/app/+home-page/home-news/home-news.component.scss b/themes/mantis/app/+home-page/home-news/home-news.component.scss deleted file mode 100644 index c693e9a493..0000000000 --- a/themes/mantis/app/+home-page/home-news/home-news.component.scss +++ /dev/null @@ -1,15 +0,0 @@ -@import 'src/app/+home-page/home-news/home-news.component.scss'; -$home-news-link-color: $green !default; -$home-news-link-color: darken($home-news-link-color, 15%) !default; - -.jumbotron { - background-color: transparent; -} - -a { - color: $home-news-link-color; - - @include hover { - color: $home-news-link-color; - } -} diff --git a/themes/mantis/styles/_themed_custom_variables.scss b/themes/mantis/styles/_themed_custom_variables.scss deleted file mode 100644 index 1be25e953f..0000000000 --- a/themes/mantis/styles/_themed_custom_variables.scss +++ /dev/null @@ -1,2 +0,0 @@ -$banner-text-background: rgba(0, 0, 0, 0.35); -$banner-background-gradient-width: 300px; diff --git a/tsconfig.app.json b/tsconfig.app.json index 097a41d2b6..8700bbe0b3 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -9,7 +9,8 @@ "src/polyfills.ts" ], "include": [ - "src/**/*.d.ts" + "src/**/*.d.ts", + "src/themes/**/*.module.ts" ], "exclude": [ "src/test.ts", diff --git a/webpack/helpers.ts b/webpack/helpers.ts index 66580832ac..532b170bf3 100644 --- a/webpack/helpers.ts +++ b/webpack/helpers.ts @@ -1,79 +1,18 @@ const path = require('path'); -const fs = require('fs'); -const environment = require('../src/environments/environment.ts').environment; export const projectRoot = (relativePath) => { return path.resolve(__dirname, '..', relativePath); }; -export const srcPath = projectRoot('src'); - -export const buildRoot = (relativePath) => { - if (environment.aot) { - return path.resolve(projectRoot('./build'), relativePath); - } else { - return path.resolve(projectRoot('src'), relativePath); - } -}; - -export const theme = environment.theme.name; - -export let themePath; - -if (theme !== null && theme !== undefined) { - themePath = path.normalize(path.join(__dirname, '..', 'themes', theme)); -} else { - themePath = srcPath; -} - export const globalCSSImports = () => { return [ - buildRoot('styles/_variables.scss'), - buildRoot('styles/_mixins.scss'), + projectRoot('src/styles/_variables.scss'), + projectRoot('src/styles/_mixins.scss'), ]; }; -const getThemedPath = (componentPath, ext) => { - const parsedPath = path.parse(componentPath); - const relativePath = path.relative(srcPath, parsedPath.dir); - return path.join(themePath, relativePath, `${parsedPath.name}.${ext}`); -}; - -export const themedTest = (origPath, extension) => { - if (/\.component.ts$/.test(origPath)) { // only match components - const themedPath = getThemedPath(origPath, extension); - return fs.existsSync(themedPath); - } else { - return false; - } -}; - -export const themedUse = (resource, extension) => { - const origPath = path.parse(resource); - let themedPath = getThemedPath(resource, extension); - - /* Make sure backslashes are escaped twice, because the replace unescapes those again */ - themedPath = themedPath.replace(/\\/g, '\\\\'); - - return [ - { - loader: 'string-replace-loader', - options: { - search: `\.\/${origPath.name}\.${extension}`, - replace: themedPath, - flags: 'g' - } - } - ]; -}; module.exports = { projectRoot, - buildRoot, - theme: theme, - themePath, - getThemedPath, - themedTest, - themedUse, globalCSSImports }; diff --git a/webpack/webpack.common.ts b/webpack/webpack.common.ts index 48fb0808bc..926241e0bb 100644 --- a/webpack/webpack.common.ts +++ b/webpack/webpack.common.ts @@ -1,36 +1,65 @@ -import { buildRoot, globalCSSImports, projectRoot, themedTest, themedUse, themePath } from './helpers'; +import { globalCSSImports, projectRoot } from './helpers'; const CopyWebpackPlugin = require('copy-webpack-plugin'); const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const ScriptExtPlugin = require('script-ext-html-webpack-plugin'); -export const copyWebpackOptions = [ - { - from: path.join(__dirname, '..', 'node_modules', '@fortawesome', 'fontawesome-free', 'webfonts'), - to: path.join('assets', 'fonts'), - force: undefined - }, - { - from: path.join(__dirname, '..', 'src', 'assets', 'fonts'), - to: path.join('assets', 'fonts') - }, { - from: path.join(__dirname, '..', 'src', 'assets', 'images'), - to: path.join('assets', 'images') - }, { - from: path.join(__dirname, '..', 'src', 'assets', 'i18n'), - to: path.join('assets', 'i18n') - }, { - from: path.join(__dirname, '..', 'src', 'robots.txt'), - to: path.join('robots.txt') +export const copyWebpackOptions = { + patterns: [ + { + from: path.join(__dirname, '..', 'node_modules', '@fortawesome', 'fontawesome-free', 'webfonts'), + to: path.join('assets', 'fonts'), + force: undefined + }, + { + from: path.join(__dirname, '..', 'src', 'assets'), + to: 'assets', + }, + { + from: path.join(__dirname, '..', 'src', 'themes', '*', 'assets', '**', '*'), + to: 'assets', + noErrorOnMissing: true, + transformPath(targetPath, absolutePath) { + // use [\/|\\] to match both POSIX and Windows separators + const matches = absolutePath.match(/.*[\/|\\]themes[\/|\\]([^\/|^\\]+)[\/|\\]assets[\/|\\](.+)$/); + if (matches) { + // matches[1] is the theme name + // matches[2] is the rest of the path relative to the assets folder + // e.g. themes/custom/assets/images/logo.png will end up in assets/custom/images/logo.png + return path.join('assets', matches[1], matches[2]); + } + }, + }, + { + from: path.join(__dirname, '..', 'src', 'robots.txt'), + to: 'robots.txt' + } + ] +}; + +const SCSS_LOADERS = [{ + loader: 'postcss-loader', + options: { + sourceMap: true } +}, + { + loader: 'sass-loader', + options: { + sourceMap: true, + sassOptions: { + includePaths: [projectRoot('./')] + } + } + }, ]; export const commonExports = { plugins: [ new CopyWebpackPlugin(copyWebpackOptions), new HtmlWebpackPlugin({ - template: buildRoot('./index.html', ), + template: projectRoot('./src/index.html', ), output: projectRoot('dist'), inject: 'head' }), @@ -40,14 +69,6 @@ export const commonExports = { ], module: { rules: [ - { - test: (filePath) => themedTest(filePath, 'scss'), - use: (info) => themedUse(info.resource, 'scss') - }, - { - test: (filePath) => themedTest(filePath, 'html'), - use: (info) => themedUse(info.resource, 'html') - }, { test: /\.ts$/, loader: '@ngtools/webpack' @@ -56,19 +77,10 @@ export const commonExports = { test: /\.scss$/, exclude: [ /node_modules/, - buildRoot('styles/_exposed_variables.scss'), - buildRoot('styles/_variables.scss') + /(_exposed)?_variables.scss$|\/src\/themes\/[^/]+\/styles\/.+.scss$/ ], use: [ - { - loader: 'sass-loader', - options: { - sourceMap: true, - sassOptions: { - includePaths: [projectRoot('./'), path.join(themePath, 'styles')] - } - } - }, + ...SCSS_LOADERS, { loader: 'sass-resources-loader', options: { @@ -78,24 +90,10 @@ export const commonExports = { ] }, { - test: /(_exposed)?_variables.scss$/, + test: /(_exposed)?_variables.scss$|\/src\/themes\/[^/]+\/styles\/.+.scss$/, exclude: [/node_modules/], use: [ - { - loader: 'postcss-loader', - options: { - sourceMap: true - } - }, - { - loader: 'sass-loader', - options: { - sourceMap: true, - sassOptions: { - includePaths: [projectRoot('./'), path.join(themePath, 'styles')] - } - } - } + ...SCSS_LOADERS, ] }, ], diff --git a/webpack/webpack.prod.ts b/webpack/webpack.prod.ts index e9fba2e65e..40e551610b 100644 --- a/webpack/webpack.prod.ts +++ b/webpack/webpack.prod.ts @@ -1,5 +1,5 @@ import { commonExports } from './webpack.common'; -import { buildRoot, projectRoot } from './helpers'; +import { projectRoot } from './helpers'; const webpack = require('webpack'); const nodeExternals = require('webpack-node-externals'); @@ -16,7 +16,7 @@ module.exports = Object.assign({}, commonExports, { ], mode: 'production', recordsOutputPath: projectRoot('webpack.records.json'), - entry: buildRoot('./main.server.ts'), + entry: projectRoot('./src/main.server.ts'), target: 'node', externals: [nodeExternals({ whitelist: [ diff --git a/yarn.lock b/yarn.lock index 29a03624ab..f042076b0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -252,9 +252,9 @@ tslib "^2.0.0" "@angular/language-service@~10.2.3": - version "10.2.3" - resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-10.2.3.tgz#bb678c11822b9bf7ca007703a31dae090dc2c0ab" - integrity sha512-8rtNG3HjBdUMlKcakh6gDfFvYSS5X16ymbVR0i2L/Nc4d9HuqgKrIrsNY4We/jSBoAjo/CGS8AvbscMa8oW4Eg== + version "10.2.4" + resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-10.2.4.tgz#0c533149c110445ae67a7fd833c5aa03182fcec0" + integrity sha512-WmaX2lst7LOPVdCLdgR2Yddvy+DrQ5fhaXexZ1mYFnWBwW8gDXcRP+sHAkvTu752mF5c0C4OEMR9UJdwrHntrg== "@angular/localize@10.2.3": version "10.2.3" @@ -1373,6 +1373,11 @@ dependencies: mkdirp "^1.0.4" +"@polka/url@^1.0.0-next.9": + version "1.0.0-next.11" + resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.11.tgz#aeb16f50649a91af79dbe36574b66d0f9e4d9f71" + integrity sha512-3NsZsJIA/22P3QUyrEDNA2D133H4j224twJrdipXN38dpnIOzAbUDtOwkcJ5pXmn75w7LSQDjA4tO9dm1XlqlA== + "@scarf/scarf@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@scarf/scarf/-/scarf-1.1.0.tgz#b84b4a91cd938a688d36245b7a7db6fbc476a499" @@ -1509,6 +1514,11 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0" integrity sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw== +"@types/json-schema@^7.0.6": + version "7.0.7" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad" + integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA== + "@types/lodash@^4.14.165": version "4.14.165" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.165.tgz#74d55d947452e2de0742bad65270433b63a8c30f" @@ -1840,6 +1850,11 @@ acorn-walk@^7.1.1: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== +acorn-walk@^8.0.0: + version "8.0.2" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.0.2.tgz#d4632bfc63fd93d0f15fd05ea0e984ffd3f5a8c3" + integrity sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A== + acorn@^6.4.1: version "6.4.2" resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6" @@ -1850,6 +1865,11 @@ acorn@^7.1.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== +acorn@^8.0.4: + version "8.0.5" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.0.5.tgz#a3bfb872a74a6a7f661bc81b9849d9cac12601b7" + integrity sha512-v+DieK/HJkJOpFBETDJioequtc3PfxsWMaxIdIwujtF7FEV/MAyDQLlm6/zPvr7Mix07mLh6ccVwIsloceodlg== + adjust-sourcemap-loader@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/adjust-sourcemap-loader/-/adjust-sourcemap-loader-3.0.0.tgz#5ae12fb5b7b1c585e80bbb5a63ec163a1a45e61e" @@ -1917,7 +1937,7 @@ ajv@6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^6.1.0, ajv@^6.10.2, ajv@^6.12.2, ajv@^6.12.3, ajv@^6.12.4: +ajv@^6.1.0, ajv@^6.10.2, ajv@^6.12.2, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.5: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -2371,16 +2391,6 @@ better-assert@~1.0.0: dependencies: callsite "1.0.0" -bfj@^6.1.1: - version "6.1.2" - resolved "https://registry.yarnpkg.com/bfj/-/bfj-6.1.2.tgz#325c861a822bcb358a41c78a33b8e6e2086dde7f" - integrity sha512-BmBJa4Lip6BPRINSZ0BPEIfB1wUY/9rwbwvIHQA1KjX9om29B6id0wnWXq7m3bn5JrUVjeOTnVuhPT1FiHwPGw== - dependencies: - bluebird "^3.5.5" - check-types "^8.0.3" - hoopy "^0.1.4" - tryer "^1.0.1" - big.js@^3.1.3: version "3.2.0" resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e" @@ -2752,7 +2762,7 @@ cacache@15.0.5, cacache@^15.0.4, cacache@^15.0.5: tar "^6.0.2" unique-filename "^1.1.1" -cacache@^12.0.0, cacache@^12.0.2, cacache@^12.0.3: +cacache@^12.0.0, cacache@^12.0.2: version "12.0.4" resolved "https://registry.yarnpkg.com/cacache/-/cacache-12.0.4.tgz#668bcbd105aeb5f1d92fe25570ec9525c8faa40c" integrity sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ== @@ -2958,11 +2968,6 @@ charenc@0.0.2: resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc= -check-types@^8.0.3: - version "8.0.3" - resolved "https://registry.yarnpkg.com/check-types/-/check-types-8.0.3.tgz#3356cca19c889544f2d7a95ed49ce508a0ecf552" - integrity sha512-YpeKZngUmG65rLudJ4taU7VLkOCTMhNl/u4ctNC56LQS/zJTyNH0Lrtwm1tfTsbLlwvlfsA2d1c8vCf/Kh2KwQ== - "chokidar@>=2.0.0 <4.0.0", chokidar@^3.0.0, chokidar@^3.2.2, chokidar@^3.3.0, chokidar@^3.4.1, chokidar@^3.4.2: version "3.4.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.3.tgz#c1df38231448e45ca4ac588e6c79573ba6a57d5b" @@ -3237,7 +3242,7 @@ command-line-usage@^6.1.0: table-layout "^1.0.1" typical "^5.2.0" -commander@^2.11.0, commander@^2.12.1, commander@^2.18.0, commander@^2.2.0, commander@^2.20.0: +commander@^2.11.0, commander@^2.12.1, commander@^2.2.0, commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -3459,23 +3464,22 @@ copy-webpack-plugin@6.0.3: serialize-javascript "^4.0.0" webpack-sources "^1.4.3" -copy-webpack-plugin@^5.1.1: - version "5.1.2" - resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-5.1.2.tgz#8a889e1dcafa6c91c6cd4be1ad158f1d3823bae2" - integrity sha512-Uh7crJAco3AjBvgAy9Z75CjK8IG+gxaErro71THQ+vv/bl4HaQcpkexAY8KVW/T6D2W2IRr+couF/knIRkZMIQ== +copy-webpack-plugin@^6.4.1: + version "6.4.1" + resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-6.4.1.tgz#138cd9b436dbca0a6d071720d5414848992ec47e" + integrity sha512-MXyPCjdPVx5iiWyl40Va3JGh27bKzOTNY3NjUTrosD2q7dR/cLD0013uqJ3BpFbUjyONINjb6qI7nDIJujrMbA== dependencies: - cacache "^12.0.3" - find-cache-dir "^2.1.0" - glob-parent "^3.1.0" - globby "^7.1.1" - is-glob "^4.0.1" - loader-utils "^1.2.3" - minimatch "^3.0.4" + cacache "^15.0.5" + fast-glob "^3.2.4" + find-cache-dir "^3.3.1" + glob-parent "^5.1.1" + globby "^11.0.1" + loader-utils "^2.0.0" normalize-path "^3.0.0" - p-limit "^2.2.1" - schema-utils "^1.0.0" - serialize-javascript "^4.0.0" - webpack-log "^2.0.0" + p-limit "^3.0.2" + schema-utils "^3.0.0" + serialize-javascript "^5.0.1" + webpack-sources "^1.4.3" core-js-compat@^3.6.2: version "3.8.1" @@ -4181,13 +4185,6 @@ diffie-hellman@^5.0.0: miller-rabin "^4.0.0" randombytes "^2.0.0" -dir-glob@^2.0.0: - version "2.2.2" - resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.2.2.tgz#fa09f0694153c8918b18ba0deafae94769fc50c4" - integrity sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw== - dependencies: - path-type "^3.0.0" - dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -4320,7 +4317,7 @@ duplexer3@^0.1.4: resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI= -duplexer@^0.1.1: +duplexer@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== @@ -4362,11 +4359,6 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= -ejs@^2.6.1: - version "2.7.4" - resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba" - integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA== - electron-to-chromium@^1.3.621: version "1.3.622" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.622.tgz#9726bd2e67a5462154750ce9701ca6af07d07877" @@ -4801,7 +4793,7 @@ express-rate-limit@^5.1.3: resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-5.2.3.tgz#ae73b3dc723decd697797611bd96e9b34a912f6c" integrity sha512-cjQH+oDrEPXxc569XvxhHC6QXqJiuBT6BhZ70X3bdAImcnHnTNMVuMAJaT0TXPoRiEErUrVPRcOTpZpM36VbOQ== -express@^4.16.3, express@^4.17.1: +express@^4.17.1: version "4.17.1" resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g== @@ -4985,11 +4977,6 @@ file-uri-to-path@1.0.0: resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== -filesize@^3.6.1: - version "3.6.1" - resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317" - integrity sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg== - filesize@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/filesize/-/filesize-6.1.0.tgz#e81bdaa780e2451d714d71c0d7a4f3238d37ad00" @@ -5348,7 +5335,7 @@ glob@7.1.2: once "^1.3.0" path-is-absolute "^1.0.0" -glob@7.1.6, glob@^7.0.3, glob@^7.0.6, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@~7.1.6: +glob@7.1.6, glob@^7.0.3, glob@^7.0.6, glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@~7.1.6: version "7.1.6" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== @@ -5407,18 +5394,6 @@ globby@^6.1.0: pify "^2.0.0" pinkie-promise "^2.0.0" -globby@^7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/globby/-/globby-7.1.1.tgz#fb2ccff9401f8600945dfada97440cca972b8680" - integrity sha1-+yzP+UAfhgCUXfral0QMypcrhoA= - dependencies: - array-union "^1.0.1" - dir-glob "^2.0.0" - glob "^7.1.2" - ignore "^3.3.5" - pify "^3.0.0" - slash "^1.0.0" - got@^9.6.0: version "9.6.0" resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85" @@ -5448,13 +5423,12 @@ guess-parser@^0.4.12: dependencies: "@wessberg/ts-evaluator" "0.0.27" -gzip-size@^5.0.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-5.1.1.tgz#cb9bee692f87c0612b232840a873904e4c135274" - integrity sha512-FNHi6mmoHvs1mxZAds4PpdCS6QG8B4C1krxJsMutgxl5t3+GlRTzzI3NEkifXx2pVsOvJdOGSmIgDhQ55FwdPA== +gzip-size@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462" + integrity sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q== dependencies: - duplexer "^0.1.1" - pify "^4.0.1" + duplexer "^0.1.2" handle-thing@^2.0.0: version "2.0.1" @@ -5587,11 +5561,6 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoopy@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/hoopy/-/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d" - integrity sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ== - hosted-git-info@^2.1.4, hosted-git-info@^2.7.1: version "2.8.8" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" @@ -5858,11 +5827,6 @@ ignore-walk@^3.0.1: dependencies: minimatch "^3.0.4" -ignore@^3.3.5: - version "3.3.10" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043" - integrity sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug== - ignore@^5.1.4: version "5.1.8" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" @@ -7389,6 +7353,11 @@ mime@1.6.0, mime@^1.4.1: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mime@^2.3.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.0.tgz#2b4af934401779806ee98026bb42e8c1ae1876b1" + integrity sha512-ft3WayFSFUVBuJj7BMLKAQcSlItKtfjsKDDsii3rqFDAZ7t11zRe8ASw/GlmivGwVUYtwkQrxiGGpL6gFvB0ag== + mime@^2.4.4, mime@^2.4.5: version "2.4.6" resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.6.tgz#e5b407c90db442f2beb5b162373d07b69affa4d1" @@ -8083,7 +8052,7 @@ opencollective-postinstall@^2.0.2: resolved "https://registry.yarnpkg.com/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz#7a0fff978f6dbfa4d006238fbac98ed4198c3259" integrity sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q== -opener@^1.5.1: +opener@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== @@ -8181,7 +8150,7 @@ p-finally@^1.0.0: resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= -p-limit@^2.0.0, p-limit@^2.2.0, p-limit@^2.2.1, p-limit@^2.3.0: +p-limit@^2.0.0, p-limit@^2.2.0, p-limit@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== @@ -10176,6 +10145,15 @@ schema-utils@^2.6.0, schema-utils@^2.6.1, schema-utils@^2.6.5, schema-utils@^2.6 ajv "^6.12.4" ajv-keywords "^3.5.2" +schema-utils@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.0.0.tgz#67502f6aa2b66a2d4032b4279a2944978a0913ef" + integrity sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA== + dependencies: + "@types/json-schema" "^7.0.6" + ajv "^6.12.5" + ajv-keywords "^3.5.2" + script-ext-html-webpack-plugin@2.1.5: version "2.1.5" resolved "https://registry.yarnpkg.com/script-ext-html-webpack-plugin/-/script-ext-html-webpack-plugin-2.1.5.tgz#d4a57e43b04aaf531f675c935688c3971bfafc6c" @@ -10303,6 +10281,13 @@ serialize-javascript@^4.0.0: dependencies: randombytes "^2.1.0" +serialize-javascript@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-5.0.1.tgz#7886ec848049a462467a97d3d918ebb2aaf934f4" + integrity sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA== + dependencies: + randombytes "^2.1.0" + serve-index@1.9.1, serve-index@^1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" @@ -10425,16 +10410,20 @@ simple-swizzle@^0.2.2: dependencies: is-arrayish "^0.3.1" +sirv@^1.0.7: + version "1.0.11" + resolved "https://registry.yarnpkg.com/sirv/-/sirv-1.0.11.tgz#81c19a29202048507d6ec0d8ba8910fda52eb5a4" + integrity sha512-SR36i3/LSWja7AJNRBz4fF/Xjpn7lQFI30tZ434dIy+bitLYSP+ZEenHg36i23V2SGEz+kqjksg0uOGZ5LPiqg== + dependencies: + "@polka/url" "^1.0.0-next.9" + mime "^2.3.1" + totalist "^1.0.0" + sisteransi@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== -slash@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" - integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU= - slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -11370,6 +11359,11 @@ toidentifier@1.0.0: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== +totalist@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df" + integrity sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g== + touch@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b" @@ -11406,11 +11400,6 @@ tree-kill@1.2.2, tree-kill@^1.2.1: resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== -tryer@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" - integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA== - ts-loader@^5.2.0: version "5.4.5" resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-5.4.5.tgz#a0c1f034b017a9344cef0961bfd97cc192492b8b" @@ -11971,24 +11960,20 @@ webidl-conversions@^6.1.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== -webpack-bundle-analyzer@^3.3.2: - version "3.9.0" - resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-3.9.0.tgz#f6f94db108fb574e415ad313de41a2707d33ef3c" - integrity sha512-Ob8amZfCm3rMB1ScjQVlbYYUEJyEjdEtQ92jqiFUYt5VkEeO2v5UMbv49P/gnmCZm3A6yaFQzCBvpZqN4MUsdA== +webpack-bundle-analyzer@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.4.0.tgz#74013106e7e2b07cbd64f3a5ae847f7e814802c7" + integrity sha512-9DhNa+aXpqdHk8LkLPTBU/dMfl84Y+WE2+KnfI6rSpNRNVKa0VGLjPd2pjFubDeqnWmulFggxmWBxhfJXZnR0g== dependencies: - acorn "^7.1.1" - acorn-walk "^7.1.1" - bfj "^6.1.1" - chalk "^2.4.1" - commander "^2.18.0" - ejs "^2.6.1" - express "^4.16.3" - filesize "^3.6.1" - gzip-size "^5.0.0" - lodash "^4.17.19" - mkdirp "^0.5.1" - opener "^1.5.1" - ws "^6.0.0" + acorn "^8.0.4" + acorn-walk "^8.0.0" + chalk "^4.1.0" + commander "^6.2.0" + gzip-size "^6.0.0" + lodash "^4.17.20" + opener "^1.5.2" + sirv "^1.0.7" + ws "^7.3.1" webpack-cli@^4.2.0: version "4.2.0" @@ -12294,7 +12279,7 @@ write-file-atomic@^3.0.0: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" -ws@^6.0.0, ws@^6.2.1: +ws@^6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb" integrity sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA== @@ -12306,6 +12291,11 @@ ws@^7.1.2, ws@^7.2.3: resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.1.tgz#a333be02696bd0e54cea0434e21dcc8a9ac294bb" integrity sha512-pTsP8UAfhy3sk1lSk/O/s4tjD0CRwvMnzvwr4OKGX7ZvqZtUyx4KIJB5JWbkykPoc55tixMGgTNoh3k4FkNGFQ== +ws@^7.3.1: + version "7.4.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.3.tgz#1f9643de34a543b8edb124bdcbc457ae55a6e5cd" + integrity sha512-hr6vCR76GsossIRsr8OLR9acVVm1jyfEWvhbNjtgPOrfvAlKzvyeg/P6r8RuDjRyrcQoPQT7K0DGEPc7Ae6jzA== + ws@~3.3.1: version "3.3.3" resolved "https://registry.yarnpkg.com/ws/-/ws-3.3.3.tgz#f1cf84fe2d5e901ebce94efaece785f187a228f2"