diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..fca18c64ee --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security Policy + +## Supported Versions + +For information regarding which versions of DSpace are currently under support, please see our DSpace Software Support Policy: + +https://wiki.lyrasis.org/display/DSPACE/DSpace+Software+Support+Policy + +## Reporting a Vulnerability + +If you believe you have found a security vulnerability in a supported version of DSpace, we encourage you to let us know right away. +We will investigate all legitimate reports and do our best to quickly fix the problem. Please see our DSpace Software Support Policy +for information on privately reporting vulnerabilities: + +https://wiki.lyrasis.org/display/DSPACE/DSpace+Software+Support+Policy diff --git a/docker/README.md b/docker/README.md index 809a150d86..747db22143 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,5 +1,9 @@ # Docker Compose files +*** +:warning: **NOT PRODUCTION READY** The below Docker Compose resources are not guaranteed "production ready" at this time. They have been built for development/testing only. Therefore, DSpace Docker images may not be fully secured or up-to-date. While you are welcome to base your own images on these DSpace images/resources, these should not be used "as is" in any production scenario. +*** + ## docker directory - docker-compose.yml - Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker. diff --git a/e2e/protractor.conf.js b/e2e/protractor.conf.js index 51180c8044..93bf7f3301 100644 --- a/e2e/protractor.conf.js +++ b/e2e/protractor.conf.js @@ -69,7 +69,6 @@ exports.config = { plugins: [{ path: '../node_modules/protractor-istanbul-plugin' }], - framework: 'jasmine', jasmineNodeOpts: { showColors: true, @@ -85,7 +84,7 @@ exports.config = { onPrepare: function () { jasmine.getEnv().addReporter(new SpecReporter({ spec: { - displayStacktrace: true + displayStacktrace: 'pretty' } })); } diff --git a/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html b/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html index 4c13c7c570..f6bff2dc8f 100644 --- a/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html +++ b/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html @@ -1,11 +1,10 @@ diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.html b/src/app/+admin/admin-sidebar/admin-sidebar.component.html index 4dddc89920..d3d8031994 100644 --- a/src/app/+admin/admin-sidebar/admin-sidebar.component.html +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.html @@ -10,14 +10,14 @@ +
+ class="search-filter-wrapper" [ngClass]="{ 'closed' : closed, 'notab': notab }"> 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 518e7c9d5f..7e2631b55f 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,36 @@ :host .facet-filter { - border: 1px solid var(--bs-light); - cursor: pointer; - .search-filter-wrapper.closed { - overflow: hidden; + border: 1px solid var(--bs-light); + cursor: pointer; + line-height: 0; + + .search-filter-wrapper { + line-height: var(--bs-line-height-base); + &.closed { + overflow: hidden; } - .filter-toggle { - line-height: var(--bs-line-height-base); + &.notab { + visibility: hidden; } + } + + .filter-toggle { + line-height: var(--bs-line-height-base); + text-align: right; + position: relative; + top: -0.125rem; // Fix weird outline shape in Chrome + } + + > button { + appearance: none; + border: 0; + padding: 0; + background: transparent; + width: 100%; + outline: none !important; + } + + &.focus { + outline: none; + box-shadow: var(--bs-input-btn-focus-box-shadow); + } } diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts b/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts index 228eef9a20..5e0077e11d 100644 --- a/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts @@ -12,6 +12,7 @@ import { SearchFilterConfig } from '../../search-filter-config.model'; import { FilterType } from '../../filter-type.model'; import { SearchConfigurationServiceStub } from '../../../testing/search-configuration-service.stub'; import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; +import { SequenceService } from '../../../../core/shared/sequence.service'; describe('SearchFilterComponent', () => { let comp: SearchFilterComponent; @@ -50,12 +51,15 @@ describe('SearchFilterComponent', () => { }; let filterService; + let sequenceService; const mockResults = observableOf(['test', 'data']); const searchServiceStub = { getFacetValuesFor: (filter) => mockResults }; beforeEach(waitForAsync(() => { + sequenceService = jasmine.createSpyObj('sequenceService', { next: 17 }); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule], declarations: [SearchFilterComponent], @@ -65,7 +69,8 @@ describe('SearchFilterComponent', () => { provide: SearchFilterService, useValue: mockFilterService }, - { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() } + { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }, + { provide: SequenceService, useValue: sequenceService }, ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(SearchFilterComponent, { @@ -81,6 +86,12 @@ describe('SearchFilterComponent', () => { filterService = (comp as any).filterService; }); + it('should generate unique IDs', () => { + expect(sequenceService.next).toHaveBeenCalled(); + expect(comp.toggleId).toContain('17'); + expect(comp.regionId).toContain('17'); + }); + describe('when the toggle method is triggered', () => { beforeEach(() => { spyOn(filterService, 'toggle'); diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.ts b/src/app/shared/search/search-filters/search-filter/search-filter.component.ts index 31ace10a7d..0f7f763b45 100644 --- a/src/app/shared/search/search-filters/search-filter/search-filter.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.ts @@ -10,6 +10,7 @@ import { isNotEmpty } from '../../../empty.util'; import { SearchService } from '../../../../core/shared/search/search.service'; import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; +import { SequenceService } from '../../../../core/shared/sequence.service'; @Component({ selector: 'ds-search-filter', @@ -37,6 +38,16 @@ export class SearchFilterComponent implements OnInit { */ closed: boolean; + /** + * True when the filter controls should be hidden & removed from the tablist + */ + notab: boolean; + + /** + * True when the filter toggle button is focused + */ + focusBox = false; + /** * Emits true when the filter is currently collapsed in the store */ @@ -52,10 +63,15 @@ export class SearchFilterComponent implements OnInit { */ active$: Observable; + private readonly sequenceId: number; + constructor( private filterService: SearchFilterService, private searchService: SearchService, - @Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService) { + @Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService, + private sequenceService: SequenceService, + ) { + this.sequenceId = this.sequenceService.next(); } /** @@ -112,6 +128,9 @@ export class SearchFilterComponent implements OnInit { if (event.fromState === 'collapsed') { this.closed = false; } + if (event.toState === 'collapsed') { + this.notab = true; + } } /** @@ -122,6 +141,17 @@ export class SearchFilterComponent implements OnInit { if (event.toState === 'collapsed') { this.closed = true; } + if (event.fromState === 'collapsed') { + this.notab = false; + } + } + + get regionId(): string { + return `search-filter-region-${this.sequenceId}`; + } + + get toggleId(): string { + return `search-filter-toggle-${this.sequenceId}`; } /** diff --git a/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html index 06b60b5ecd..49ca6fe3fd 100644 --- a/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html @@ -8,15 +8,18 @@
{{"search.filters.filter.show-more" - | translate}} + (click)="showMore()" href="javascript:void(0);"> + {{"search.filters.filter.show-more" | translate}} + {{"search.filters.filter.show-less" - | translate}} + (click)="showFirstPageOnly()" href="javascript:void(0);"> + {{"search.filters.filter.show-less" | translate}} +
-
- +
+
- +
- + - + [dsDebounce]="250" (onDebounce)="onSubmit()" + (keydown)="startKeyboardControl()" (keyup)="stopKeyboardControl()" + [(ngModel)]="range" ngDefaultControl> +
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 2c98280e7f..f26806abfb 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 @@ -21,6 +21,7 @@ } &:focus { outline: none; + box-shadow: var(--bs-input-btn-focus-box-shadow); } } diff --git a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.ts b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.ts index 62b1cb98a6..b23a2d8224 100644 --- a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.ts @@ -68,6 +68,12 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple */ sub: Subscription; + /** + * Whether the sider is being controlled by the keyboard. + * Supresses any changes until the key is released. + */ + keyboardControl: boolean; + constructor(protected searchService: SearchService, protected filterService: SearchFilterService, protected router: Router, @@ -104,6 +110,10 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple * Submits new custom range values to the range filter from the widget */ onSubmit() { + if (this.keyboardControl) { + return; // don't submit if a key is being held down + } + const newMin = this.range[0] !== this.min ? [this.range[0]] : null; const newMax = this.range[1] !== this.max ? [this.range[1]] : null; this.router.navigate(this.getSearchLinkParts(), { @@ -117,6 +127,14 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple this.filter = ''; } + startKeyboardControl(): void { + this.keyboardControl = true; + } + + stopKeyboardControl(): void { + this.keyboardControl = false; + } + /** * TODO when upgrading nouislider, verify that this check is still needed. * Prevents AoT bug diff --git a/src/app/shared/search/search-filters/search-filter/search-text-filter/search-text-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-text-filter/search-text-filter.component.html index 43134014e1..fdf154bc04 100644 --- a/src/app/shared/search/search-filters/search-filter/search-text-filter/search-text-filter.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-text-filter/search-text-filter.component.html @@ -8,15 +8,18 @@
{{"search.filters.filter.show-more" - | translate}} + (click)="showMore()" href="javascript:void(0);"> + {{"search.filters.filter.show-more" | translate}} + {{"search.filters.filter.show-less" - | translate}} + (click)="showFirstPageOnly()" href="javascript:void(0);"> + {{"search.filters.filter.show-less" | translate}} +
{ * Defaults to true */ reRequestOnStale? = true; + + /** + * If this is false an error will be thrown if the link doesn't exist on the model it is used on + * Defaults to false + */ + isOptional? = false; } /** @@ -57,23 +64,35 @@ export class FollowLinkConfig { * no valid cached version. Defaults * @param reRequestOnStale: Whether or not the link should automatically be re-requested after the * response becomes stale + * @param isOptional: Whether or not to fail if the link doesn't exist * @param linksToFollow: a list of {@link FollowLinkConfig}s to * use on the retrieved object. */ export const followLink = ( linkName: keyof R['_links'], - findListOptions?: FindListOptions, - shouldEmbed = true, - useCachedVersionIfAvailable = true, - reRequestOnStale = true, - ...linksToFollow: FollowLinkConfig[] -): FollowLinkConfig => { - return { - name: linkName, + { findListOptions, shouldEmbed, useCachedVersionIfAvailable, reRequestOnStale, + isOptional + }: { + findListOptions?: FindListOptions, + shouldEmbed?: boolean, + useCachedVersionIfAvailable?: boolean, + reRequestOnStale?: boolean, + isOptional?: boolean, + } = {}, + ...linksToFollow: FollowLinkConfig[] +): FollowLinkConfig => { + const followLinkConfig = { + name: linkName, + findListOptions: hasValue(findListOptions) ? findListOptions : new FindListOptions(), + shouldEmbed: hasValue(shouldEmbed) ? shouldEmbed : true, + useCachedVersionIfAvailable: hasValue(useCachedVersionIfAvailable) ? useCachedVersionIfAvailable : true, + reRequestOnStale: hasValue(reRequestOnStale) ? reRequestOnStale : true, + isOptional: hasValue(isOptional) ? isOptional : false, linksToFollow }; + return followLinkConfig; }; diff --git a/src/app/submission/edit/submission-edit.component.spec.ts b/src/app/submission/edit/submission-edit.component.spec.ts index 7b730b7a73..8013162d85 100644 --- a/src/app/submission/edit/submission-edit.component.spec.ts +++ b/src/app/submission/edit/submission-edit.component.spec.ts @@ -18,6 +18,8 @@ import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; import { mockSubmissionObject } from '../../shared/mocks/submission.mock'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { ItemDataService } from '../../core/data/item-data.service'; +import { SubmissionJsonPatchOperationsServiceStub } from '../../shared/testing/submission-json-patch-operations-service.stub'; +import { SubmissionJsonPatchOperationsService } from '../../core/submission/submission-json-patch-operations.service'; describe('SubmissionEditComponent Component', () => { @@ -25,6 +27,7 @@ describe('SubmissionEditComponent Component', () => { let fixture: ComponentFixture; let submissionServiceStub: SubmissionServiceStub; let itemDataService: ItemDataService; + let submissionJsonPatchOperationsServiceStub: SubmissionJsonPatchOperationsServiceStub; let router: RouterStub; const submissionId = '826'; @@ -46,6 +49,7 @@ describe('SubmissionEditComponent Component', () => { providers: [ { provide: NotificationsService, useClass: NotificationsServiceStub }, { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: SubmissionJsonPatchOperationsService, useClass: SubmissionJsonPatchOperationsServiceStub }, { provide: ItemDataService, useValue: itemDataService }, { provide: TranslateService, useValue: getMockTranslateService() }, { provide: Router, useValue: new RouterStub() }, @@ -60,6 +64,7 @@ describe('SubmissionEditComponent Component', () => { fixture = TestBed.createComponent(SubmissionEditComponent); comp = fixture.componentInstance; submissionServiceStub = TestBed.inject(SubmissionService as any); + submissionJsonPatchOperationsServiceStub = TestBed.inject(SubmissionJsonPatchOperationsService as any); router = TestBed.inject(Router as any); }); @@ -112,4 +117,16 @@ describe('SubmissionEditComponent Component', () => { expect(comp.submissionDefinition).toBeUndefined(); }); + describe('ngOnDestroy', () => { + it('should call delete pending json patch operations', fakeAsync(() => { + + submissionJsonPatchOperationsServiceStub.deletePendingJsonPatchOperations.and.callFake(() => { /* */ }); + comp.ngOnDestroy(); + + expect(submissionJsonPatchOperationsServiceStub.deletePendingJsonPatchOperations).toHaveBeenCalled(); + })); + + }); + + }); diff --git a/src/app/submission/edit/submission-edit.component.ts b/src/app/submission/edit/submission-edit.component.ts index 34fdcba104..154e73a765 100644 --- a/src/app/submission/edit/submission-edit.component.ts +++ b/src/app/submission/edit/submission-edit.component.ts @@ -17,6 +17,7 @@ import { Item } from '../../core/shared/item.model'; import { getAllSucceededRemoteData } from '../../core/shared/operators'; import { ItemDataService } from '../../core/data/item-data.service'; import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { SubmissionJsonPatchOperationsService } from '../../core/submission/submission-json-patch-operations.service'; /** * This component allows to edit an existing workspaceitem/workflowitem. @@ -92,7 +93,8 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { private router: Router, private itemDataService: ItemDataService, private submissionService: SubmissionService, - private translate: TranslateService) { + private translate: TranslateService, + private submissionJsonPatchOperationsService: SubmissionJsonPatchOperationsService) { } /** @@ -149,5 +151,7 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { this.subs .filter((sub) => hasValue(sub)) .forEach((sub) => sub.unsubscribe()); + + this.submissionJsonPatchOperationsService.deletePendingJsonPatchOperations(); } } diff --git a/src/app/submission/form/collection/submission-form-collection.component.spec.ts b/src/app/submission/form/collection/submission-form-collection.component.spec.ts index b4ff31aaf6..14dfcef864 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.spec.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.spec.ts @@ -261,9 +261,10 @@ describe('SubmissionFormCollectionComponent Component', () => { expect(comp.toggled).toHaveBeenCalled(); }); - it('should ', () => { + it('should change collection properly', () => { spyOn(comp.collectionChange, 'emit').and.callThrough(); jsonPatchOpServiceStub.jsonPatchByResourceID.and.returnValue(of(submissionRestResponse)); + submissionServiceStub.retrieveSubmission.and.returnValue(createSuccessfulRemoteDataObject$(submissionRestResponse[0])); comp.ngOnInit(); comp.onSelect(mockCollectionList[1]); fixture.detectChanges(); diff --git a/src/app/submission/form/collection/submission-form-collection.component.ts b/src/app/submission/form/collection/submission-form-collection.component.ts index 854a2f1db0..ba7eb88c6f 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.ts @@ -13,7 +13,7 @@ import { import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; import { find, - map + map, mergeMap } from 'rxjs/operators'; import { Collection } from '../../../core/shared/collection.model'; @@ -27,6 +27,7 @@ import { SubmissionJsonPatchOperationsService } from '../../../core/submission/s import { CollectionDataService } from '../../../core/data/collection-data.service'; import { CollectionDropdownComponent } from '../../../shared/collection-dropdown/collection-dropdown.component'; import { SectionsService } from '../../sections/sections.service'; +import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; /** * This component allows to show the current collection the submission belonging to and to change it. @@ -164,11 +165,17 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { this.submissionService.getSubmissionObjectLinkName(), this.submissionId, 'sections', - 'collection') - .subscribe((submissionObject: SubmissionObject[]) => { + 'collection').pipe( + mergeMap((submissionObject: SubmissionObject[]) => { + // retrieve the full submission object with embeds + return this.submissionService.retrieveSubmission(submissionObject[0].id).pipe( + getFirstSucceededRemoteDataPayload() + ); + }) + ).subscribe((submissionObject: SubmissionObject) => { this.selectedCollectionId = event.collection.id; this.selectedCollectionName$ = observableOf(event.collection.name); - this.collectionChange.emit(submissionObject[0]); + this.collectionChange.emit(submissionObject); this.submissionService.changeSubmissionCollection(this.submissionId, event.collection.id); this.processingChange$.next(false); this.cdr.detectChanges(); diff --git a/src/app/submission/form/submission-form.component.ts b/src/app/submission/form/submission-form.component.ts index 8df0ab1658..6d4ddb4ca0 100644 --- a/src/app/submission/form/submission-form.component.ts +++ b/src/app/submission/form/submission-form.component.ts @@ -122,7 +122,7 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { * Initialize all instance variables and retrieve form configuration */ ngOnChanges(changes: SimpleChanges) { - if (this.collectionId && this.submissionId) { + if ((changes.collectionId && this.collectionId) && (changes.submissionId && this.submissionId)) { this.isActive = true; // retrieve submission's section list diff --git a/src/app/submission/sections/form/section-form-operations.service.spec.ts b/src/app/submission/sections/form/section-form-operations.service.spec.ts index c76a15abcb..d5798b82c8 100644 --- a/src/app/submission/sections/form/section-form-operations.service.spec.ts +++ b/src/app/submission/sections/form/section-form-operations.service.spec.ts @@ -4,7 +4,8 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { DYNAMIC_FORM_CONTROL_TYPE_ARRAY, DYNAMIC_FORM_CONTROL_TYPE_GROUP, - DynamicFormControlEvent + DynamicFormControlEvent, + DynamicInputModel } from '@ng-dynamic-forms/core'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; @@ -28,6 +29,7 @@ import { } from '../../../shared/mocks/form-models.mock'; import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; import { VocabularyEntry } from '../../../core/submission/vocabularies/models/vocabulary-entry.model'; +import { DynamicRowArrayModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; describe('SectionFormOperationsService test suite', () => { let formBuilderService: any; @@ -83,6 +85,11 @@ describe('SectionFormOperationsService test suite', () => { formBuilderService = TestBed.inject(FormBuilderService); }); + afterEach(() => { + jsonPatchOpBuilder.add.calls.reset(); + jsonPatchOpBuilder.remove.calls.reset(); + }); + describe('dispatchOperationsFromEvent', () => { it('should call dispatchOperationsFromRemoveEvent on remove event', () => { const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); @@ -567,7 +574,7 @@ describe('SectionFormOperationsService test suite', () => { }); it('should dispatch a json-path remove operation when has a stored value', () => { - const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); + let previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); const event = Object.assign({}, dynamicFormControlChangeEvent, { model: { parent: mockRowGroupModel @@ -590,6 +597,7 @@ describe('SectionFormOperationsService test suite', () => { spyIndex.and.returnValue(1); spyPath.and.returnValue('path/1'); + previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, true); expect(jsonPatchOpBuilder.remove).toHaveBeenCalledWith(pathCombiner.getPath('path/1')); @@ -620,6 +628,32 @@ describe('SectionFormOperationsService test suite', () => { new FormFieldMetadataValueObject('test')); }); + it('should dispatch a json-path add operation when has a stored value but previous value is empty', () => { + const previousValue = new FormFieldPreviousValueObject(['path', 'test'], null); + const event = Object.assign({}, dynamicFormControlChangeEvent, { + model: { + parent: mockRowGroupModel + } + }); + spyOn(service, 'getFieldPathFromEvent').and.returnValue('path/0'); + spyOn(service, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); + spyOn(service, 'getFieldValueFromChangeEvent').and.returnValue(new FormFieldMetadataValueObject('test')); + spyOn(service, 'getArrayIndexFromEvent').and.returnValue(0); + spyOn(serviceAsAny, 'getValueMap'); + spyOn(serviceAsAny, 'dispatchOperationsFromMap'); + formBuilderService.isQualdropGroup.and.returnValue(false); + formBuilderService.isRelationGroup.and.returnValue(false); + formBuilderService.hasArrayGroupValue.and.returnValue(false); + spyOn(previousValue, 'isPathEqual').and.returnValue(false); + + serviceAsAny.dispatchOperationsFromChangeEvent(pathCombiner, event, previousValue, true); + + expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith( + pathCombiner.getPath('path'), + new FormFieldMetadataValueObject('test'), + true); + }); + it('should dispatch a json-path add operation when has a value and field index is zero or undefined', () => { const previousValue = new FormFieldPreviousValueObject(['path', 'test'], 'value'); const event = Object.assign({}, dynamicFormControlChangeEvent, { @@ -760,4 +794,86 @@ describe('SectionFormOperationsService test suite', () => { }); }); + describe('handleArrayGroupPatch', () => { + let arrayModel; + let previousValue; + beforeEach(() => { + arrayModel = new DynamicRowArrayModel( + { + id: 'testFormRowArray', + initialCount: 5, + notRepeatable: false, + relationshipConfig: undefined, + submissionId: '1234', + isDraggable: true, + groupFactory: () => { + return [ + new DynamicInputModel({ id: 'testFormRowArrayGroupInput' }) + ]; + }, + required: false, + metadataKey: 'dc.contributor.author', + metadataFields: ['dc.contributor.author'], + hasSelectableMetadata: true + } + ); + spyOn(serviceAsAny, 'getFieldPathSegmentedFromChangeEvent').and.returnValue('path'); + previousValue = new FormFieldPreviousValueObject(['path'], null); + }); + + it('should not dispatch a json-path operation when a array value is empty', () => { + formBuilderService.getValueFromModel.and.returnValue({}); + spyOn(previousValue, 'isPathEqual').and.returnValue(false); + + serviceAsAny.handleArrayGroupPatch( + pathCombiner, + dynamicFormControlChangeEvent, + arrayModel, + previousValue + ); + + expect(jsonPatchOpBuilder.add).not.toHaveBeenCalled(); + expect(jsonPatchOpBuilder.remove).not.toHaveBeenCalled(); + }); + + it('should dispatch a json-path add operation when a array value is not empty', () => { + const pathValue = [ + new FormFieldMetadataValueObject('test'), + new FormFieldMetadataValueObject('test two') + ]; + formBuilderService.getValueFromModel.and.returnValue({ + path:pathValue + }); + spyOn(previousValue, 'isPathEqual').and.returnValue(false); + + serviceAsAny.handleArrayGroupPatch( + pathCombiner, + dynamicFormControlChangeEvent, + arrayModel, + previousValue + ); + + expect(jsonPatchOpBuilder.add).toHaveBeenCalledWith( + pathCombiner.getPath('path'), + pathValue, + false + ); + expect(jsonPatchOpBuilder.remove).not.toHaveBeenCalled(); + }); + + it('should dispatch a json-path remove operation when a array value is empty and has previous value', () => { + formBuilderService.getValueFromModel.and.returnValue({}); + spyOn(previousValue, 'isPathEqual').and.returnValue(true); + + serviceAsAny.handleArrayGroupPatch( + pathCombiner, + dynamicFormControlChangeEvent, + arrayModel, + previousValue + ); + + expect(jsonPatchOpBuilder.add).not.toHaveBeenCalled(); + expect(jsonPatchOpBuilder.remove).toHaveBeenCalledWith(pathCombiner.getPath('path')); + }); + }); }); diff --git a/src/app/submission/sections/form/section-form-operations.service.ts b/src/app/submission/sections/form/section-form-operations.service.ts index a1bb99e3cd..adba46bf3a 100644 --- a/src/app/submission/sections/form/section-form-operations.service.ts +++ b/src/app/submission/sections/form/section-form-operations.service.ts @@ -6,7 +6,8 @@ import { DYNAMIC_FORM_CONTROL_TYPE_GROUP, DynamicFormArrayGroupModel, DynamicFormControlEvent, - DynamicFormControlModel, isDynamicFormControlEvent + DynamicFormControlModel, + isDynamicFormControlEvent } from '@ng-dynamic-forms/core'; import { hasValue, isNotEmpty, isNotNull, isNotUndefined, isNull, isUndefined } from '../../../shared/empty.util'; @@ -297,17 +298,14 @@ export class SectionFormOperationsService { event: DynamicFormControlEvent, previousValue: FormFieldPreviousValueObject): void { - if (event.context && event.context instanceof DynamicFormArrayGroupModel) { - // Model is a DynamicRowArrayModel - this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context); - return; - } - const path = this.getFieldPathFromEvent(event); const value = this.getFieldValueFromChangeEvent(event); console.log(value); if (this.formBuilder.isQualdropGroup(event.model as DynamicFormControlModel)) { this.dispatchOperationsFromMap(this.getQualdropValueMap(event), pathCombiner, event, previousValue); + } else if (event.context && event.context instanceof DynamicFormArrayGroupModel) { + // Model is a DynamicRowArrayModel + this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context, previousValue); } else if ((isNotEmpty(value) && typeof value === 'string') || (isNotEmpty(value) && value instanceof FormFieldMetadataValueObject && value.hasValue())) { this.operationsBuilder.remove(pathCombiner.getPath(path)); } @@ -368,7 +366,7 @@ export class SectionFormOperationsService { if (event.context && event.context instanceof DynamicFormArrayGroupModel) { // Model is a DynamicRowArrayModel - this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context); + this.handleArrayGroupPatch(pathCombiner, event, (event as any).context.context, previousValue); return; } @@ -388,7 +386,7 @@ export class SectionFormOperationsService { this.operationsBuilder.add( pathCombiner.getPath(segmentedPath), value, true); - } else if (previousValue.isPathEqual(this.formBuilder.getPath(event.model)) || hasStoredValue) { + } else if (previousValue.isPathEqual(this.formBuilder.getPath(event.model)) || (hasStoredValue && isNotEmpty(previousValue.value)) ) { // Here model has a previous value changed or stored in the server if (hasValue(event.$event) && hasValue(event.$event.previousIndex)) { if (event.$event.previousIndex < 0) { @@ -421,7 +419,7 @@ export class SectionFormOperationsService { previousValue.delete(); } else if (value.hasValue()) { // Here model has no previous value but a new one - if (isUndefined(this.getArrayIndexFromEvent(event)) || this.getArrayIndexFromEvent(event) === 0) { + if (isUndefined(this.getArrayIndexFromEvent(event)) || this.getArrayIndexFromEvent(event) === 0) { // Model is single field or is part of an array model but is the first item, // so dispatch an add operation that initialize the values of a specific metadata this.operationsBuilder.add( @@ -498,23 +496,37 @@ export class SectionFormOperationsService { event: DynamicFormControlEvent, previousValue: FormFieldPreviousValueObject) { - return this.handleArrayGroupPatch(pathCombiner, event.$event, (event as any).$event.arrayModel); + return this.handleArrayGroupPatch(pathCombiner, event.$event, (event as any).$event.arrayModel, previousValue); } /** * Specific patch handler for a DynamicRowArrayModel. * Configure a Patch ADD with the current array value. * @param pathCombiner + * the [[JsonPatchOperationPathCombiner]] object for the specified operation * @param event + * the [[DynamicFormControlEvent]] for the specified operation * @param model + * the [[DynamicRowArrayModel]] model + * @param previousValue + * the [[FormFieldPreviousValueObject]] for the specified operation */ private handleArrayGroupPatch(pathCombiner: JsonPatchOperationPathCombiner, event, - model: DynamicRowArrayModel) { + model: DynamicRowArrayModel, + previousValue: FormFieldPreviousValueObject) { + const arrayValue = this.formBuilder.getValueFromModel([model]); - const segmentedPath2 = this.getFieldPathSegmentedFromChangeEvent(event); - this.operationsBuilder.add( - pathCombiner.getPath(segmentedPath2), - arrayValue[segmentedPath2], false); + const segmentedPath = this.getFieldPathSegmentedFromChangeEvent(event); + if (isNotEmpty(arrayValue)) { + this.operationsBuilder.add( + pathCombiner.getPath(segmentedPath), + arrayValue[segmentedPath], + false + ); + } else if (previousValue.isPathEqual(this.formBuilder.getPath(event.model))) { + this.operationsBuilder.remove(pathCombiner.getPath(segmentedPath)); + } + } } diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.scss b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.scss new file mode 100644 index 0000000000..b443db711b --- /dev/null +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.scss @@ -0,0 +1,6 @@ + +::ng-deep .access-condition-group { + position: relative; + top: -2.3rem; + margin-bottom: -2.3rem; +} 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 512453d84e..3275787984 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 @@ -18,6 +18,8 @@ import { import { WorkspaceitemSectionUploadFileObject } from '../../../../../core/submission/models/workspaceitem-section-upload-file.model'; import { FormBuilderService } from '../../../../../shared/form/builder/form-builder.service'; import { + BITSTREAM_ACCESS_CONDITION_GROUP_CONFIG, + BITSTREAM_ACCESS_CONDITION_GROUP_LAYOUT, BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_CONFIG, BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG, @@ -43,6 +45,7 @@ import { FormComponent } from '../../../../../shared/form/form.component'; */ @Component({ selector: 'ds-submission-section-upload-file-edit', + styleUrls: ['./section-upload-file-edit.component.scss'], templateUrl: './section-upload-file-edit.component.html', }) export class SubmissionSectionUploadFileEditComponent implements OnChanges { @@ -209,8 +212,9 @@ export class SubmissionSectionUploadFileEditComponent implements OnChanges { const startDate = new DynamicDatePickerModel(startDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT); const endDate = new DynamicDatePickerModel(endDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT); - - return [type, startDate, endDate]; + const accessConditionGroupConfig = Object.assign({}, BITSTREAM_ACCESS_CONDITION_GROUP_CONFIG); + accessConditionGroupConfig.group = [type, startDate, endDate]; + return [new DynamicFormGroupModel(accessConditionGroupConfig, BITSTREAM_ACCESS_CONDITION_GROUP_LAYOUT)]; }; // Number of access conditions blocks in form @@ -233,7 +237,7 @@ export class SubmissionSectionUploadFileEditComponent implements OnChanges { public initModelData(formModel: DynamicFormControlModel[]) { this.fileData.accessConditions.forEach((accessCondition, index) => { Array.of('name', 'startDate', 'endDate') - .filter((key) => accessCondition.hasOwnProperty(key)) + .filter((key) => accessCondition.hasOwnProperty(key) && isNotEmpty(accessCondition[key])) .forEach((key) => { const metadataModel: any = this.formBuilderService.findById(key, formModel, index); if (metadataModel) { 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 096954659e..300a4b461f 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 @@ -15,12 +15,24 @@ export const BITSTREAM_METADATA_FORM_GROUP_CONFIG: DynamicFormGroupModelConfig = export const BITSTREAM_METADATA_FORM_GROUP_LAYOUT: DynamicFormControlLayout = { element: { container: 'form-group', - label: 'col-form-label' + label: 'col-form-label' }, grid: { label: 'col-sm-3' } }; +export const BITSTREAM_ACCESS_CONDITION_GROUP_CONFIG: DynamicFormGroupModelConfig = { + id: 'accessConditionGroup', + group: [] +}; + +export const BITSTREAM_ACCESS_CONDITION_GROUP_LAYOUT: DynamicFormControlLayout = { + element: { + host: 'form-group flex-fill access-condition-group', + container: 'pl-1 pr-1', + control: 'form-row ' + } +}; export const BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_CONFIG: DynamicFormArrayModelConfig = { id: 'accessConditions', @@ -28,7 +40,7 @@ export const BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_CONFIG: DynamicFormArrayMode }; export const BITSTREAM_ACCESS_CONDITIONS_FORM_ARRAY_LAYOUT: DynamicFormControlLayout = { grid: { - group: 'form-row' + group: 'form-row pt-4', } }; @@ -39,11 +51,8 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_TYPE_CONFIG: DynamicSelectModelConf }; export const BITSTREAM_FORM_ACCESS_CONDITION_TYPE_LAYOUT: DynamicFormControlLayout = { element: { - container: 'p-0', - label: 'col-form-label' - }, - grid: { - host: 'col-md-10' + host: 'col-12', + label: 'col-form-label name-label' } }; @@ -70,11 +79,10 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_CONFIG: DynamicDatePicke }; export const BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT: DynamicFormControlLayout = { element: { - container: 'p-0', label: 'col-form-label' }, grid: { - host: 'col-md-4' + host: 'col-6' } }; @@ -101,10 +109,9 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG: DynamicDatePickerM }; export const BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT: DynamicFormControlLayout = { element: { - container: 'p-0', label: 'col-form-label' }, grid: { - host: 'col-md-4' + host: 'col-6' } }; 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 5a97140a70..d4c901b290 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 @@ -255,6 +255,7 @@ export class SubmissionSectionUploadFileComponent implements OnChanges, OnInit { }); const accessConditionsToSave = []; formData.accessConditions + .map((accessConditions) => accessConditions.accessConditionGroup) .filter((accessCondition) => isNotEmpty(accessCondition)) .forEach((accessCondition) => { let accessConditionOpt; diff --git a/src/app/submission/submit/submission-submit.component.html b/src/app/submission/submit/submission-submit.component.html index a7680f07c2..e69de29bb2 100644 --- a/src/app/submission/submit/submission-submit.component.html +++ b/src/app/submission/submit/submission-submit.component.html @@ -1,10 +0,0 @@ -
-
- -
-
diff --git a/src/app/submission/submit/submission-submit.component.spec.ts b/src/app/submission/submit/submission-submit.component.spec.ts index 16e26e3b33..1b76082d1b 100644 --- a/src/app/submission/submit/submission-submit.component.spec.ts +++ b/src/app/submission/submit/submission-submit.component.spec.ts @@ -26,7 +26,6 @@ describe('SubmissionSubmitComponent Component', () => { let itemDataService: ItemDataService; let router: RouterStub; - const submissionId = '826'; const submissionObject: any = mockSubmissionObject; beforeEach(waitForAsync(() => { @@ -67,27 +66,23 @@ describe('SubmissionSubmitComponent Component', () => { router = null; }); - it('should init properly when a valid SubmissionObject has been retrieved',() => { - - submissionServiceStub.createSubmission.and.returnValue(observableOf(submissionObject)); - - fixture.detectChanges(); - - expect(comp.submissionId.toString()).toEqual(submissionId); - expect(comp.collectionId).toBe(submissionObject.collection.id); - expect(comp.selfUrl).toBe(submissionObject._links.self.href); - expect(comp.sections).toBe(submissionObject.sections); - expect(comp.submissionDefinition).toBe(submissionObject.submissionDefinition); - - }); - it('should redirect to mydspace when an empty SubmissionObject has been retrieved',() => { submissionServiceStub.createSubmission.and.returnValue(observableOf({})); fixture.detectChanges(); - expect(router.navigate).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['/mydspace']); + + }); + + it('should redirect to workspaceitem edit when a not empty SubmissionObject has been retrieved',() => { + + submissionServiceStub.createSubmission.and.returnValue(observableOf({ id: '1234'})); + + fixture.detectChanges(); + + expect(router.navigate).toHaveBeenCalledWith(['/workspaceitems', '1234', 'edit'], { replaceUrl: true}); }); diff --git a/src/app/submission/submit/submission-submit.component.ts b/src/app/submission/submit/submission-submit.component.ts index 0c2172368a..af1bf38539 100644 --- a/src/app/submission/submit/submission-submit.component.ts +++ b/src/app/submission/submit/submission-submit.component.ts @@ -122,13 +122,7 @@ export class SubmissionSubmitComponent implements OnDestroy, OnInit { this.notificationsService.info(null, this.translate.get('submission.general.cannot_submit')); this.router.navigate(['/mydspace']); } else { - this.collectionId = (submissionObject.collection as Collection).id; - this.sections = submissionObject.sections; - this.selfUrl = submissionObject._links.self.href; - this.submissionDefinition = (submissionObject.submissionDefinition as SubmissionDefinitionsModel); - this.submissionId = submissionObject.id; - this.itemLink$.next(submissionObject._links.item.href); - this.item = submissionObject.item as Item; + this.router.navigate(['/workspaceitems', submissionObject.id, 'edit'], { replaceUrl: true}); } } }), diff --git a/src/app/thumbnail/thumbnail.component.html b/src/app/thumbnail/thumbnail.component.html index dbf8f6732c..bf70928392 100644 --- a/src/app/thumbnail/thumbnail.component.html +++ b/src/app/thumbnail/thumbnail.component.html @@ -1,4 +1,14 @@ -
- +
+ + text-content + + + +
+
+
{{ placeholder | translate }}
+
+
+
- diff --git a/src/app/thumbnail/thumbnail.component.scss b/src/app/thumbnail/thumbnail.component.scss index e2718bac06..b15238afac 100644 --- a/src/app/thumbnail/thumbnail.component.scss +++ b/src/app/thumbnail/thumbnail.component.scss @@ -1,3 +1,35 @@ +.limit-width { + max-width: var(--ds-thumbnail-max-width); +} + img { max-width: 100%; } + +.outer { // .outer/.inner generated ~ https://ratiobuddy.com/ + position: relative; + &:before { + display: block; + content: ""; + width: 100%; + padding-top: (297 / 210) * 100%; // A4 ratio + } + > .inner { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + + > .thumbnail-placeholder { + background: var(--ds-thumbnail-placeholder-background); + border: var(--ds-thumbnail-placeholder-border); + color: var(--ds-thumbnail-placeholder-color); + font-weight: bold; + display: flex; + justify-content: center; + align-items: center; + text-align: center; + } + } +} diff --git a/src/app/thumbnail/thumbnail.component.spec.ts b/src/app/thumbnail/thumbnail.component.spec.ts index 21678c9162..eea585f9f8 100644 --- a/src/app/thumbnail/thumbnail.component.spec.ts +++ b/src/app/thumbnail/thumbnail.component.spec.ts @@ -1,10 +1,22 @@ -import { DebugElement } from '@angular/core'; +import { DebugElement, Pipe, PipeTransform } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Bitstream } from '../core/shared/bitstream.model'; import { SafeUrlPipe } from '../shared/utils/safe-url-pipe'; -import { THUMBNAIL_PLACEHOLDER, ThumbnailComponent } from './thumbnail.component'; +import { ThumbnailComponent } from './thumbnail.component'; +import { RemoteData } from '../core/data/remote-data'; +import { + createFailedRemoteDataObject, createPendingRemoteDataObject, createSuccessfulRemoteDataObject, +} from '../shared/remote-data.utils'; + +// tslint:disable-next-line:pipe-prefix +@Pipe({ name: 'translate' }) +class MockTranslatePipe implements PipeTransform { + transform(key: string): string { + return 'TRANSLATED ' + key; + } +} describe('ThumbnailComponent', () => { let comp: ThumbnailComponent; @@ -14,33 +26,18 @@ describe('ThumbnailComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [ThumbnailComponent, SafeUrlPipe] + declarations: [ThumbnailComponent, SafeUrlPipe, MockTranslatePipe], }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(ThumbnailComponent); - comp = fixture.componentInstance; // BannerComponent test instance + comp = fixture.componentInstance; // ThumbnailComponent test instance de = fixture.debugElement.query(By.css('div.thumbnail')); el = de.nativeElement; }); - describe('when the thumbnail exists', () => { - it('should display an image', () => { - const thumbnail = new Bitstream(); - thumbnail._links = { - self: { href: 'self.url' }, - bundle: { href: 'bundle.url' }, - format: { href: 'format.url' }, - content: { href: 'content.url' }, - }; - comp.thumbnail = thumbnail; - fixture.detectChanges(); - const image: HTMLElement = de.query(By.css('img')).nativeElement; - expect(image.getAttribute('src')).toBe(comp.thumbnail._links.content.href); - }); - }); - describe(`when the thumbnail doesn't exist`, () => { + const withoutThumbnail = () => { describe('and there is a default image', () => { it('should display the default image', () => { comp.src = 'http://bit.stream'; @@ -48,14 +45,116 @@ describe('ThumbnailComponent', () => { comp.errorHandler(); expect(comp.src).toBe(comp.defaultImage); }); + it('should include the alt text', () => { + comp.src = 'http://bit.stream'; + comp.defaultImage = 'http://default.img'; + comp.errorHandler(); + comp.ngOnChanges(); + fixture.detectChanges(); + const image: HTMLElement = de.query(By.css('img')).nativeElement; + expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt); + }); }); describe('and there is no default image', () => { it('should display the placeholder', () => { comp.src = 'http://default.img'; - comp.defaultImage = 'http://default.img'; comp.errorHandler(); - expect(comp.src).toBe(THUMBNAIL_PLACEHOLDER); + expect(comp.src).toBe(null); + + comp.ngOnChanges(); + fixture.detectChanges(); + const placeholder = fixture.debugElement.query(By.css('div.thumbnail-placeholder')).nativeElement; + expect(placeholder.innerHTML).toBe('TRANSLATED ' + comp.placeholder); }); }); + }; + + describe('with thumbnail as Bitstream', () => { + let thumbnail: Bitstream; + beforeEach(() => { + thumbnail = new Bitstream(); + thumbnail._links = { + self: { href: 'self.url' }, + bundle: { href: 'bundle.url' }, + format: { href: 'format.url' }, + content: { href: 'content.url' }, + thumbnail: undefined, + }; + }); + + it('should display an image', () => { + comp.thumbnail = thumbnail; + comp.ngOnChanges(); + fixture.detectChanges(); + const image: HTMLElement = de.query(By.css('img')).nativeElement; + expect(image.getAttribute('src')).toBe(comp.thumbnail._links.content.href); + }); + + it('should include the alt text', () => { + comp.thumbnail = thumbnail; + comp.ngOnChanges(); + fixture.detectChanges(); + const image: HTMLElement = de.query(By.css('img')).nativeElement; + expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt); + }); + + describe('when there is no thumbnail', () => { + withoutThumbnail(); + }); + }); + + describe('with thumbnail as RemoteData', () => { + let thumbnail: RemoteData; + + describe('while loading', () => { + beforeEach(() => { + thumbnail = createPendingRemoteDataObject(); + }); + + it('should show a loading animation', () => { + comp.thumbnail = thumbnail; + comp.ngOnChanges(); + fixture.detectChanges(); + expect(de.query(By.css('ds-loading'))).toBeTruthy(); + }); + }); + + describe('when there is a thumbnail', () => { + beforeEach(() => { + const bitstream = new Bitstream(); + bitstream._links = { + self: { href: 'self.url' }, + bundle: { href: 'bundle.url' }, + format: { href: 'format.url' }, + content: { href: 'content.url' }, + thumbnail: undefined, + }; + thumbnail = createSuccessfulRemoteDataObject(bitstream); + }); + + it('should display an image', () => { + comp.thumbnail = thumbnail; + comp.ngOnChanges(); + fixture.detectChanges(); + const image: HTMLElement = de.query(By.css('img')).nativeElement; + expect(image.getAttribute('src')).toBe(comp.thumbnail.payload._links.content.href); + }); + + it('should display the alt text', () => { + comp.thumbnail = thumbnail; + comp.ngOnChanges(); + fixture.detectChanges(); + const image: HTMLElement = de.query(By.css('img')).nativeElement; + expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt); + }); + }); + + describe('when there is no thumbnail', () => { + beforeEach(() => { + thumbnail = createFailedRemoteDataObject(); + }); + + withoutThumbnail(); + }); }); }); diff --git a/src/app/thumbnail/thumbnail.component.ts b/src/app/thumbnail/thumbnail.component.ts index 7e981d5fe6..3e122cde78 100644 --- a/src/app/thumbnail/thumbnail.component.ts +++ b/src/app/thumbnail/thumbnail.component.ts @@ -1,61 +1,93 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input, OnChanges } from '@angular/core'; import { Bitstream } from '../core/shared/bitstream.model'; import { hasValue } from '../shared/empty.util'; - -/** - * A fallback placeholder image as a base64 string - */ -export const THUMBNAIL_PLACEHOLDER = 'data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2293%22%20height%3D%22120%22%20viewBox%3D%220%200%2093%20120%22%20preserveAspectRatio%3D%22none%22%3E%3C!--%0ASource%20URL%3A%20holder.js%2F93x120%3Ftext%3DNo%20Thumbnail%0ACreated%20with%20Holder.js%202.8.2.%0ALearn%20more%20at%20http%3A%2F%2Fholderjs.com%0A(c)%202012-2015%20Ivan%20Malopinsky%20-%20http%3A%2F%2Fimsky.co%0A--%3E%3Cdefs%3E%3Cstyle%20type%3D%22text%2Fcss%22%3E%3C!%5BCDATA%5B%23holder_1543e460b05%20text%20%7B%20fill%3A%23AAAAAA%3Bfont-weight%3Abold%3Bfont-family%3AArial%2C%20Helvetica%2C%20Open%20Sans%2C%20sans-serif%2C%20monospace%3Bfont-size%3A10pt%20%7D%20%5D%5D%3E%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%20id%3D%22holder_1543e460b05%22%3E%3Crect%20width%3D%2293%22%20height%3D%22120%22%20fill%3D%22%23FFFFFF%22%2F%3E%3Cg%3E%3Ctext%20x%3D%2235.6171875%22%20y%3D%2257%22%3ENo%3C%2Ftext%3E%3Ctext%20x%3D%2210.8125%22%20y%3D%2272%22%3EThumbnail%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E'; +import { RemoteData } from '../core/data/remote-data'; /** * This component renders a given Bitstream as a thumbnail. * One input parameter of type Bitstream is expected. - * If no Bitstream is provided, a holderjs image will be rendered instead. + * If no Bitstream is provided, a HTML placeholder will be rendered instead. */ @Component({ selector: 'ds-thumbnail', styleUrls: ['./thumbnail.component.scss'], - templateUrl: './thumbnail.component.html' + templateUrl: './thumbnail.component.html', }) -export class ThumbnailComponent implements OnInit { - +export class ThumbnailComponent implements OnChanges { /** * The thumbnail Bitstream */ - @Input() thumbnail: Bitstream; + @Input() thumbnail: Bitstream | RemoteData; /** - * The default image, used if the thumbnail isn't set or can't be downloaded + * The default image, used if the thumbnail isn't set or can't be downloaded. + * If defaultImage is null, a HTML placeholder is used instead. */ - @Input() defaultImage? = THUMBNAIL_PLACEHOLDER; + @Input() defaultImage? = null; /** * The src attribute used in the template to render the image. */ - src: string; + src: string = null; /** - * Initialize the thumbnail. + * i18n key of thumbnail alt text + */ + @Input() alt? = 'thumbnail.default.alt'; + + /** + * i18n key of HTML placeholder text + */ + @Input() placeholder? = 'thumbnail.default.placeholder'; + + /** + * Limit thumbnail width to --ds-thumbnail-max-width + */ + @Input() limitWidth? = true; + + isLoading: boolean; + + /** + * Resolve the thumbnail. * Use a default image if no actual image is available. */ - ngOnInit(): void { - if (hasValue(this.thumbnail) && hasValue(this.thumbnail._links) && hasValue(this.thumbnail._links.content) && this.thumbnail._links.content.href) { - this.src = this.thumbnail._links.content.href; + ngOnChanges(): void { + if (this.thumbnail === undefined || this.thumbnail === null) { + return; + } + if (this.thumbnail instanceof Bitstream) { + this.resolveThumbnail(this.thumbnail as Bitstream); + } else { + const thumbnailRD = this.thumbnail as RemoteData; + if (thumbnailRD.isLoading) { + this.isLoading = true; + } else { + this.resolveThumbnail(thumbnailRD.payload as Bitstream); + } + } + } + + private resolveThumbnail(thumbnail: Bitstream): void { + if (hasValue(thumbnail) && hasValue(thumbnail._links) + && hasValue(thumbnail._links.content) + && thumbnail._links.content.href) { + this.src = thumbnail._links.content.href; } else { this.src = this.defaultImage; } + this.isLoading = false; } /** * Handle image download errors. * If the image can't be found, use the defaultImage instead. - * If that also can't be found, use the base64 placeholder. + * If that also can't be found, use null to fall back to the HTML placeholder. */ errorHandler() { if (this.src !== this.defaultImage) { this.src = this.defaultImage; } else { - this.src = THUMBNAIL_PLACEHOLDER; + this.src = null; } } } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 4c3317a0c0..5fc2063474 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -238,6 +238,8 @@ "admin.access-control.epeople.table.edit.buttons.edit": "Edit \"{{name}}\"", + "admin.access-control.epeople.table.edit.buttons.edit-disabled": "You are not authorized to edit this group", + "admin.access-control.epeople.table.edit.buttons.remove": "Delete \"{{name}}\"", "admin.access-control.epeople.no-items": "No EPeople to show.", @@ -530,6 +532,12 @@ + "bitstream.edit.authorizations.link": "Edit bitstream's Policies", + + "bitstream.edit.authorizations.title": "Edit bitstream's Policies", + + "bitstream.edit.return": "Back", + "bitstream.edit.bitstream": "Bitstream: ", "bitstream.edit.form.description.hint": "Optionally, provide a brief description of the file, for example \"Main article\" or \"Experiment data readings\".", @@ -661,6 +669,8 @@ "collection.delete.confirm": "Confirm", + "collection.delete.processing": "Deleting", + "collection.delete.head": "Delete Collection", "collection.delete.notification.fail": "Collection could not be deleted", @@ -899,6 +909,8 @@ "community.delete.confirm": "Confirm", + "community.delete.processing": "Deleting...", + "community.delete.head": "Delete Community", "community.delete.notification.fail": "Community could not be deleted", @@ -1623,7 +1635,11 @@ - "item.edit.move.cancel": "Cancel", + "item.edit.move.cancel": "Back", + + "item.edit.move.save-button": "Save", + + "item.edit.move.discard-button": "Discard", "item.edit.move.description": "Select the collection you wish to move this item to. To narrow down the list of displayed collections, you can enter a search query in the box.", @@ -1817,8 +1833,6 @@ "item.page.description": "Description", - "item.page.edit": "Edit this item", - "item.page.journal-issn": "Journal ISSN", "item.page.journal-title": "Journal Title", @@ -2353,6 +2367,8 @@ "nav.stop-impersonating": "Stop impersonating EPerson", + "nav.toggle" : "Toggle navigation", + "nav.user.description" : "User profile bar", @@ -2878,38 +2894,56 @@ "search.filters.filter.author.placeholder": "Author name", + "search.filters.filter.author.label": "Search author name", + "search.filters.filter.birthDate.head": "Birth Date", "search.filters.filter.birthDate.placeholder": "Birth Date", + "search.filters.filter.birthDate.label": "Search birth date", + "search.filters.filter.collapse": "Collapse filter", "search.filters.filter.creativeDatePublished.head": "Date Published", "search.filters.filter.creativeDatePublished.placeholder": "Date Published", + "search.filters.filter.creativeDatePublished.label": "Search date published", + "search.filters.filter.creativeWorkEditor.head": "Editor", "search.filters.filter.creativeWorkEditor.placeholder": "Editor", + "search.filters.filter.creativeWorkEditor.label": "Search editor", + "search.filters.filter.creativeWorkKeywords.head": "Subject", "search.filters.filter.creativeWorkKeywords.placeholder": "Subject", + "search.filters.filter.creativeWorkKeywords.label": "Search subject", + "search.filters.filter.creativeWorkPublisher.head": "Publisher", "search.filters.filter.creativeWorkPublisher.placeholder": "Publisher", + "search.filters.filter.creativeWorkPublisher.label": "Search publisher", + "search.filters.filter.dateIssued.head": "Date", - "search.filters.filter.dateIssued.max.placeholder": "Minimum Date", + "search.filters.filter.dateIssued.max.placeholder": "Maximum Date", - "search.filters.filter.dateIssued.min.placeholder": "Maximum Date", + "search.filters.filter.dateIssued.max.label": "End", + + "search.filters.filter.dateIssued.min.placeholder": "Minimum Date", + + "search.filters.filter.dateIssued.min.label": "Start", "search.filters.filter.dateSubmitted.head": "Date submitted", "search.filters.filter.dateSubmitted.placeholder": "Date submitted", + "search.filters.filter.dateSubmitted.label": "Search date submitted", + "search.filters.filter.discoverable.head": "Private", "search.filters.filter.withdrawn.head": "Withdrawn", @@ -2918,6 +2952,8 @@ "search.filters.filter.entityType.placeholder": "Item Type", + "search.filters.filter.entityType.label": "Search item type", + "search.filters.filter.expand": "Expand filter", "search.filters.filter.has_content_in_original_bundle.head": "Has files", @@ -2926,38 +2962,56 @@ "search.filters.filter.itemtype.placeholder": "Type", + "search.filters.filter.itemtype.label": "Search type", + "search.filters.filter.jobTitle.head": "Job Title", "search.filters.filter.jobTitle.placeholder": "Job Title", + "search.filters.filter.jobTitle.label": "Search job title", + "search.filters.filter.knowsLanguage.head": "Known language", "search.filters.filter.knowsLanguage.placeholder": "Known language", + "search.filters.filter.knowsLanguage.label": "Search known language", + "search.filters.filter.namedresourcetype.head": "Status", "search.filters.filter.namedresourcetype.placeholder": "Status", + "search.filters.filter.namedresourcetype.label": "Search status", + "search.filters.filter.objectpeople.head": "People", "search.filters.filter.objectpeople.placeholder": "People", + "search.filters.filter.objectpeople.label": "Search people", + "search.filters.filter.organizationAddressCountry.head": "Country", "search.filters.filter.organizationAddressCountry.placeholder": "Country", + "search.filters.filter.organizationAddressCountry.label": "Search country", + "search.filters.filter.organizationAddressLocality.head": "City", "search.filters.filter.organizationAddressLocality.placeholder": "City", + "search.filters.filter.organizationAddressLocality.label": "Search city", + "search.filters.filter.organizationFoundingDate.head": "Date Founded", "search.filters.filter.organizationFoundingDate.placeholder": "Date Founded", + "search.filters.filter.organizationFoundingDate.label": "Search date founded", + "search.filters.filter.scope.head": "Scope", "search.filters.filter.scope.placeholder": "Scope filter", + "search.filters.filter.scope.label": "Search scope filter", + "search.filters.filter.show-less": "Collapse", "search.filters.filter.show-more": "Show more", @@ -2966,10 +3020,14 @@ "search.filters.filter.subject.placeholder": "Subject", + "search.filters.filter.subject.label": "Search subject", + "search.filters.filter.submitter.head": "Submitter", "search.filters.filter.submitter.placeholder": "Submitter", + "search.filters.filter.submitter.label": "Search submitter", + "search.filters.entityType.JournalIssue": "Journal Issue", @@ -2995,6 +3053,8 @@ "search.filters.reset": "Reset filters", + "search.filters.search.submit": "Submit", + "search.form.search": "Search", @@ -3134,6 +3194,8 @@ "submission.import-external.source.loading": "Loading ...", "submission.import-external.source.sherpaJournal": "SHERPA Journals", + + "submission.import-external.source.sherpaJournalIssn": "SHERPA Journals by ISSN", "submission.import-external.source.sherpaPublisher": "SHERPA Publishers", @@ -3540,6 +3602,24 @@ + "thumbnail.default.alt": "Thumbnail Image", + + "thumbnail.default.placeholder": "No Thumbnail Available", + + "thumbnail.project.alt": "Project Logo", + + "thumbnail.project.placeholder": "Project Placeholder Image", + + "thumbnail.orgunit.alt": "OrgUnit Logo", + + "thumbnail.orgunit.placeholder": "OrgUnit Placeholder Image", + + "thumbnail.person.alt": "Profile Picture", + + "thumbnail.person.placeholder": "No Profile Picture Available", + + + "title": "DSpace", diff --git a/src/styles/_bootstrap_variables.scss b/src/styles/_bootstrap_variables.scss index 58f599c516..4c631a294a 100644 --- a/src/styles/_bootstrap_variables.scss +++ b/src/styles/_bootstrap_variables.scss @@ -12,12 +12,8 @@ $image-path: "../assets/images" !default; /** Bootstrap Variables **/ /* Colors */ -$gray-base: #000 !default; -$gray-900: lighten($gray-base, 13.5%) !default; // #222 -$gray-800: lighten($gray-base, 26.6%) !default; // #444 -$gray-700: lighten($gray-base, 46.6%) !default; // #777 -$gray-600: lighten($gray-base, 73.3%) !default; // #bbb -$gray-100: lighten($gray-base, 93.5%) !default; // #eee +$gray-700: #495057 !default; // Bootstrap $gray-700 +$gray-100: #f8f9fa !default; // $gray-100 /* Reassign color vars to semantic color scheme */ $blue: #2B4E72 !default; diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index 298be09f67..657737cc58 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -20,7 +20,7 @@ --ds-sidebar-z-index: 20; --ds-header-bg: #{$white}; - --ds-header-logo-height: 40px; + --ds-header-logo-height: 50px; --ds-header-logo-height-xs: 50px; --ds-header-icon-color: #{$cyan}; --ds-header-icon-color-hover: #{darken($white, 15%)}; @@ -46,6 +46,9 @@ --ds-edit-item-language-field-width: 43px; --ds-thumbnail-max-width: 175px; + --ds-thumbnail-placeholder-background: #{$gray-100}; + --ds-thumbnail-placeholder-border: 1px solid #{$gray-300}; + --ds-thumbnail-placeholder-color: #{lighten($gray-800, 7%)}; --ds-dso-selector-list-max-height: 475px; --ds-dso-selector-current-background-color: #eeeeee; @@ -61,12 +64,12 @@ --ds-sidebar-items-width: #{$sidebar-items-width}; --ds-total-sidebar-width: #{$total-sidebar-width}; - --ds-top-footer-bg: #{$gray-200} !important; - --ds-footer-bg: #{theme-color('primary')} !important; + --ds-top-footer-bg: #{$gray-200}; + --ds-footer-bg: #{theme-color('primary')}; --ds-footer-border: 1px solid var(--bs-gray-400); - --ds-footer-padding: 0 !important; - --ds-footer-padding-bottom: 0 !important; - --ds-footer-logo-height: 50px !important; + --ds-footer-padding: 0; + --ds-footer-padding-bottom: 0; + --ds-footer-logo-height: 50px; $home-news-link-color: $cyan; --ds-home-news-link-color: #{$home-news-link-color}; diff --git a/src/styles/_truncatable-part.component.scss b/src/styles/_truncatable-part.component.scss new file mode 100644 index 0000000000..b938f3a199 --- /dev/null +++ b/src/styles/_truncatable-part.component.scss @@ -0,0 +1,80 @@ +@mixin clamp($lines, $bg, $size-factor: 1, $line-height: $line-height-base) { + $height: $line-height * $font-size-base * $size-factor; + &.fixedHeight { + height: $lines * $height; + } + .content { + max-height: $lines * $height; + position: relative; + overflow: hidden; + line-height: $line-height; + overflow-wrap: break-word; + &:after { + content: ""; + position: absolute; + padding-right: 15px; + top: ($lines - 1) * $height; + right: 0; + width: 30%; + min-width: 75px; + max-width: 150px; + height: $height; + background: linear-gradient(to right, rgba(255, 255, 255, 0), $bg 70%); + pointer-events: none; + } + } + +} + +@mixin min($lines, $size-factor: 1, $line-height: $line-height-base) { + $height: $line-height * $font-size-base * $size-factor; + min-height: $lines * $height; +} + +$h4-factor: strip-unit($h4-font-size); + +@mixin clamp-with-titles($i, $bg) { + transition: height 1s; + @include clamp($i, $bg); + &.title { + @include clamp($i, $bg, 1.25); + } + &.h4 { + @include clamp($i, $bg, $h4-factor, $headings-line-height); + } +} + +@for $i from 1 through 15 { + .clamp-default-#{$i} { + @include clamp-with-titles($i, $body-bg); + } + :focus .clamp-default-#{$i}, + .ds-hover .clamp-default-#{$i} { + @include clamp-with-titles($i, $list-group-hover-bg); + } + + .clamp-primary-#{$i} { + @include clamp-with-titles($i, $primary); + } + + :focus .clamp-primary-#{$i}, + .ds-hover .clamp-primary-#{$i} { + @include clamp-with-titles($i, darken($primary, 10%)); + } +} + +.clamp-none { + overflow: hidden; + @for $i from 1 through 15 { + &.fixedHeight.min-#{$i} { + transition: height 1s; + @include min($i); + &.title { + @include min($i, 1.25); + } + &.h4 { + @include min($i, $h4-factor, $headings-line-height); + } + } + } +} diff --git a/src/styles/base-theme.scss b/src/styles/base-theme.scss index 068c2ece26..bde50bcfd7 100644 --- a/src/styles/base-theme.scss +++ b/src/styles/base-theme.scss @@ -3,4 +3,5 @@ @import '../../node_modules/nouislider/distribute/nouislider.min'; @import './_custom_variables.scss'; @import './bootstrap_variables_mapping.scss'; +@import './_truncatable-part.component.scss'; @import './_global-styles.scss'; diff --git a/src/app/shared/object-grid/grid-thumbnail/grid-thumbnail.component.scss b/src/themes/custom/app/header-nav-wrapper/header-navbar-wrapper.component.html similarity index 100% rename from src/app/shared/object-grid/grid-thumbnail/grid-thumbnail.component.scss rename to src/themes/custom/app/header-nav-wrapper/header-navbar-wrapper.component.html diff --git a/src/themes/custom/app/header-nav-wrapper/header-navbar-wrapper.component.scss b/src/themes/custom/app/header-nav-wrapper/header-navbar-wrapper.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/header-nav-wrapper/header-navbar-wrapper.component.ts b/src/themes/custom/app/header-nav-wrapper/header-navbar-wrapper.component.ts new file mode 100644 index 0000000000..875b5f69b8 --- /dev/null +++ b/src/themes/custom/app/header-nav-wrapper/header-navbar-wrapper.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { HeaderNavbarWrapperComponent as BaseComponent } from '../../../../app/header-nav-wrapper/header-navbar-wrapper.component'; + +/** + * This component represents a wrapper for the horizontal navbar and the header + */ +@Component({ + selector: 'ds-header-navbar-wrapper', + // styleUrls: ['header-navbar-wrapper.component.scss'], + styleUrls: ['../../../../app/header-nav-wrapper/header-navbar-wrapper.component.scss'], + // templateUrl: 'header-navbar-wrapper.component.html', + templateUrl: '../../../../app/header-nav-wrapper/header-navbar-wrapper.component.html', +}) +export class HeaderNavbarWrapperComponent extends BaseComponent { +} diff --git a/src/themes/custom/styles/_theme_sass_variable_overrides.scss b/src/themes/custom/styles/_theme_sass_variable_overrides.scss index 1e5d0f0520..f1387aa619 100644 --- a/src/themes/custom/styles/_theme_sass_variable_overrides.scss +++ b/src/themes/custom/styles/_theme_sass_variable_overrides.scss @@ -3,14 +3,27 @@ // 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; - +// $font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !default; +// +// $gray-700: #495057 !default; // Bootstrap $gray-700 +// $gray-100: #f8f9fa !default; // $gray-100 +// +// $blue: #2B4E72 !default; +// $green: #94BA65 !default; +// $cyan: #006666 !default; +// $yellow: #ec9433 !default; +// $red: #CF4444 !default; +// $dark: darken($blue, 17%) !default; +// +// $theme-colors: ( +// primary: $blue, +// secondary: $gray-700, +// success: $green, +// info: $cyan, +// warning: $yellow, +// danger: $red, +// light: $gray-100, +// dark: $dark +// ) !default; +// +// $link-color: map-get($theme-colors, info) !default; diff --git a/src/themes/custom/styles/theme.scss b/src/themes/custom/styles/theme.scss index e4cc9e45ed..35810b15a6 100644 --- a/src/themes/custom/styles/theme.scss +++ b/src/themes/custom/styles/theme.scss @@ -9,4 +9,5 @@ @import '../../../styles/_custom_variables.scss'; @import './_theme_css_variable_overrides.scss'; @import '../../../styles/bootstrap_variables_mapping.scss'; +@import '../../../styles/_truncatable-part.component.scss'; @import './_global-styles.scss'; diff --git a/src/themes/custom/theme.module.ts b/src/themes/custom/theme.module.ts index 23fcf4c325..49b54cd850 100644 --- a/src/themes/custom/theme.module.ts +++ b/src/themes/custom/theme.module.ts @@ -78,6 +78,7 @@ import { NavbarComponent } from './app/navbar/navbar.component'; import { HeaderComponent } from './app/header/header.component'; import { FooterComponent } from './app/footer/footer.component'; import { BreadcrumbsComponent } from './app/breadcrumbs/breadcrumbs.component'; +import { HeaderNavbarWrapperComponent } from './app/header-nav-wrapper/header-navbar-wrapper.component'; const DECLARATIONS = [ HomePageComponent, @@ -117,6 +118,7 @@ const DECLARATIONS = [ FooterComponent, HeaderComponent, NavbarComponent, + HeaderNavbarWrapperComponent, BreadcrumbsComponent ]; diff --git a/src/themes/dspace/app/+home-page/home-news/home-news.component.scss b/src/themes/dspace/app/+home-page/home-news/home-news.component.scss index b5a070e51e..5e89f6b62f 100644 --- a/src/themes/dspace/app/+home-page/home-news/home-news.component.scss +++ b/src/themes/dspace/app/+home-page/home-news/home-news.component.scss @@ -6,12 +6,8 @@ color: white; background-color: var(--bs-info); position: relative; - background-position-y: -200px; background-image: url('/assets/dspace/images/banner.jpg'); background-size: cover; - @media screen and (max-width: map-get($grid-breakpoints, lg)) { - background-position-y: 0; - } .container { position: relative; diff --git a/src/themes/dspace/app/header-nav-wrapper/header-navbar-wrapper.component.html b/src/themes/dspace/app/header-nav-wrapper/header-navbar-wrapper.component.html new file mode 100644 index 0000000000..091d152258 --- /dev/null +++ b/src/themes/dspace/app/header-nav-wrapper/header-navbar-wrapper.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/themes/dspace/app/header-nav-wrapper/header-navbar-wrapper.component.scss b/src/themes/dspace/app/header-nav-wrapper/header-navbar-wrapper.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/dspace/app/header-nav-wrapper/header-navbar-wrapper.component.ts b/src/themes/dspace/app/header-nav-wrapper/header-navbar-wrapper.component.ts new file mode 100644 index 0000000000..36e23e174a --- /dev/null +++ b/src/themes/dspace/app/header-nav-wrapper/header-navbar-wrapper.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { HeaderNavbarWrapperComponent as BaseComponent } from '../../../../app/header-nav-wrapper/header-navbar-wrapper.component'; + +/** + * This component represents a wrapper for the horizontal navbar and the header + */ +@Component({ + selector: 'ds-header-navbar-wrapper', + styleUrls: ['header-navbar-wrapper.component.scss'], + templateUrl: 'header-navbar-wrapper.component.html', +}) +export class HeaderNavbarWrapperComponent extends BaseComponent { +} diff --git a/src/themes/dspace/app/header/header.component.html b/src/themes/dspace/app/header/header.component.html new file mode 100644 index 0000000000..cf691ea6c4 --- /dev/null +++ b/src/themes/dspace/app/header/header.component.html @@ -0,0 +1,24 @@ +
+ + + +
diff --git a/src/themes/dspace/app/header/header.component.scss b/src/themes/dspace/app/header/header.component.scss new file mode 100644 index 0000000000..ab418865f1 --- /dev/null +++ b/src/themes/dspace/app/header/header.component.scss @@ -0,0 +1,19 @@ +@media screen and (min-width: map-get($grid-breakpoints, md)) { + nav.navbar { + display: none; + } + .header { + background-color: var(--ds-header-bg); + } +} + +.navbar-brand img { + @media screen and (max-width: map-get($grid-breakpoints, md)) { + height: var(--ds-header-logo-height-xs); + } +} +.navbar-toggler .navbar-toggler-icon { + background-image: none !important; + line-height: 1.5; + color: var(--bs-link-color); +} diff --git a/src/themes/dspace/app/header/header.component.ts b/src/themes/dspace/app/header/header.component.ts new file mode 100644 index 0000000000..6da89b47d5 --- /dev/null +++ b/src/themes/dspace/app/header/header.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { HeaderComponent as BaseComponent } from '../../../../app/header/header.component'; + +/** + * Represents the header with the logo and simple navigation + */ +@Component({ + selector: 'ds-header', + styleUrls: ['header.component.scss'], + templateUrl: 'header.component.html', +}) +export class HeaderComponent extends BaseComponent { +} diff --git a/src/themes/dspace/app/navbar/navbar.component.html b/src/themes/dspace/app/navbar/navbar.component.html new file mode 100644 index 0000000000..5af30db632 --- /dev/null +++ b/src/themes/dspace/app/navbar/navbar.component.html @@ -0,0 +1,24 @@ + + diff --git a/src/themes/dspace/app/navbar/navbar.component.scss b/src/themes/dspace/app/navbar/navbar.component.scss index 463a4269ee..210847c1d9 100644 --- a/src/themes/dspace/app/navbar/navbar.component.scss +++ b/src/themes/dspace/app/navbar/navbar.component.scss @@ -1,5 +1,57 @@ -@import 'src/app/navbar/navbar.component.scss'; - nav.navbar { + border-top: 1px var(--ds-header-navbar-border-top-color) solid; border-bottom: 5px var(--bs-green) solid; + align-items: baseline; + color: var(--ds-header-icon-color); +} + +/** Mobile menu styling **/ +@media screen and (max-width: map-get($grid-breakpoints, md)) { + .navbar { + width: 100%; + background-color: var(--bs-white); + position: absolute; + overflow: hidden; + height: 0; + &.open { + height: 100vh; //doesn't matter because wrapper is sticky + } + } +} + +@media screen and (min-width: map-get($grid-breakpoints, md)) { + .reset-padding-md { + margin-left: calc(var(--bs-spacer) / -2); + margin-right: calc(var(--bs-spacer) / -2); + } +} + +/* TODO remove when https://github.com/twbs/bootstrap/issues/24726 is fixed */ +.navbar-expand-md.navbar-container { + @media screen and (max-width: map-get($grid-breakpoints, md)) { + > .container { + padding: 0 var(--bs-spacer); + a.navbar-brand { + display: none; + } + .navbar-collapsed { + display: none; + } + } + padding: 0; + } + height: 80px; +} + +a.navbar-brand img { + max-height: var(--ds-header-logo-height); +} + +.navbar-nav { + ::ng-deep a.nav-link { + color: var(--ds-navbar-link-color); + } + ::ng-deep a.nav-link:hover { + color: var(--ds-navbar-link-color-hover); + } } diff --git a/src/themes/dspace/app/navbar/navbar.component.ts b/src/themes/dspace/app/navbar/navbar.component.ts index e375011683..321351a933 100644 --- a/src/themes/dspace/app/navbar/navbar.component.ts +++ b/src/themes/dspace/app/navbar/navbar.component.ts @@ -8,7 +8,7 @@ import { slideMobileNav } from '../../../../app/shared/animations/slide'; @Component({ selector: 'ds-navbar', styleUrls: ['./navbar.component.scss'], - templateUrl: '../../../../app/navbar/navbar.component.html', + templateUrl: './navbar.component.html', animations: [slideMobileNav] }) export class NavbarComponent extends BaseComponent { diff --git a/src/themes/dspace/styles/_global-styles.scss b/src/themes/dspace/styles/_global-styles.scss index 1fb60b64a2..72fac11156 100644 --- a/src/themes/dspace/styles/_global-styles.scss +++ b/src/themes/dspace/styles/_global-styles.scss @@ -3,7 +3,7 @@ // imports the base global style @import '../../../styles/_global-styles.scss'; -.facet-filter,.setting-option { +.facet-filter, .setting-option { background-color: var(--bs-light); border-radius: var(--bs-border-radius); @@ -21,3 +21,13 @@ font-size: 1.1rem } } + +header { + ds-navbar-section > li, + ds-expandable-navbar-section > li { + display: flex; + flex-direction: column; + justify-content: center; + height: 100%; + } +} diff --git a/src/themes/dspace/styles/_theme_css_variable_overrides.scss b/src/themes/dspace/styles/_theme_css_variable_overrides.scss index 2a61babdb7..e4b4b61f45 100644 --- a/src/themes/dspace/styles/_theme_css_variable_overrides.scss +++ b/src/themes/dspace/styles/_theme_css_variable_overrides.scss @@ -1,6 +1,7 @@ // Override or add CSS variables for your theme here :root { + --ds-header-logo-height: 40px; --ds-banner-text-background: rgba(0, 0, 0, 0.45); --ds-banner-background-gradient-width: 300px; --ds-home-news-link-color: #{$green}; diff --git a/src/themes/dspace/styles/_theme_sass_variable_overrides.scss b/src/themes/dspace/styles/_theme_sass_variable_overrides.scss index 70aa0b1850..9257bc46dd 100644 --- a/src/themes/dspace/styles/_theme_sass_variable_overrides.scss +++ b/src/themes/dspace/styles/_theme_sass_variable_overrides.scss @@ -6,10 +6,6 @@ @import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200;0,300;0,400;0,600;0,700;0,800;1,200;1,300;1,400;1,600;1,700;1,800&display=swap'); $font-family-sans-serif: 'Nunito', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; -$gray-100: #e8ebf3 !default; -$gray-400: #ced4da !default; -$gray-600: #959595 !default; -$gray-800: #444444 !default; $navbar-dark-color: #FFFFFF; @@ -21,10 +17,13 @@ $yellow: #ec9433 !default; $red: #CF4444 !default; $dark: #43515f !default; -$body-color: $gray-800 !default; +$gray-800: #343a40 !default; +$gray-400: #ced4da !default; +$gray-100: #f8f9fa !default; -$table-accent-bg: $gray-100 !default; -$table-hover-bg: $gray-400 !default; +$body-color: $gray-800 !default; // Bootstrap $gray-800 + +$table-accent-bg: $gray-100 !default; // Bootstrap $gray-100 +$table-hover-bg: $gray-400 !default; // Bootstrap $gray-400 $yiq-contrasted-threshold: 170 !default; - diff --git a/src/themes/dspace/styles/theme.scss b/src/themes/dspace/styles/theme.scss index e4cc9e45ed..35810b15a6 100644 --- a/src/themes/dspace/styles/theme.scss +++ b/src/themes/dspace/styles/theme.scss @@ -9,4 +9,5 @@ @import '../../../styles/_custom_variables.scss'; @import './_theme_css_variable_overrides.scss'; @import '../../../styles/bootstrap_variables_mapping.scss'; +@import '../../../styles/_truncatable-part.component.scss'; @import './_global-styles.scss'; diff --git a/src/themes/dspace/theme.module.ts b/src/themes/dspace/theme.module.ts index ed840c2e25..40138aa490 100644 --- a/src/themes/dspace/theme.module.ts +++ b/src/themes/dspace/theme.module.ts @@ -41,9 +41,13 @@ import { CollectionPageModule } from '../../app/+collection-page/collection-page import { SubmissionModule } from '../../app/submission/submission.module'; import { MyDSpacePageModule } from '../../app/+my-dspace-page/my-dspace-page.module'; import { NavbarComponent } from './app/navbar/navbar.component'; +import { HeaderComponent } from './app/header/header.component'; +import { HeaderNavbarWrapperComponent } from './app/header-nav-wrapper/header-navbar-wrapper.component'; const DECLARATIONS = [ HomeNewsComponent, + HeaderComponent, + HeaderNavbarWrapperComponent, NavbarComponent ]; diff --git a/yarn.lock b/yarn.lock index d6388ddb53..2d7427bd97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2663,15 +2663,15 @@ browserify-zlib@^0.2.0: pako "~1.0.5" browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.15.0, browserslist@^4.6.4, browserslist@^4.9.1: - version "4.16.0" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.0.tgz#410277627500be3cb28a1bfe037586fbedf9488b" - integrity sha512-/j6k8R0p3nxOC6kx5JGAxsnhc9ixaWJfYc+TNTzxg6+ARaESAvQGV7h0uNOB4t+pLQJZWzcrMxXOxjgsCj3dqQ== + version "4.16.6" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.6.tgz#d7901277a5a88e554ed305b183ec9b0c08f66fa2" + integrity sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ== dependencies: - caniuse-lite "^1.0.30001165" - colorette "^1.2.1" - electron-to-chromium "^1.3.621" + caniuse-lite "^1.0.30001219" + colorette "^1.2.2" + electron-to-chromium "^1.3.723" escalade "^3.1.1" - node-releases "^1.1.67" + node-releases "^1.1.71" browserstack@^1.5.1: version "1.6.0" @@ -2905,10 +2905,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001032, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001165: - version "1.0.30001165" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001165.tgz#32955490d2f60290bb186bb754f2981917fa744f" - integrity sha512-8cEsSMwXfx7lWSUMA2s08z9dIgsnR5NAqjXP23stdsU3AUWkCr/rr4s4OFtHXn5XXr6+7kam3QFVoYyXNPdJPA== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001032, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001165, caniuse-lite@^1.0.30001219: + version "1.0.30001237" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001237.tgz#4b7783661515b8e7151fc6376cfd97f0e427b9e5" + integrity sha512-pDHgRndit6p1NR2GhzMbQ6CkRrp4VKuSsqbcLeOQppYPKOYkKT/6ZvZDvKJUqcmtyWIAHuZq3SVS2vc1egCZzw== canonical-path@1.0.0: version "1.0.0" @@ -3220,10 +3220,10 @@ color@^3.0.0: color-convert "^1.9.1" color-string "^1.5.4" -colorette@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b" - integrity sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw== +colorette@^1.2.1, colorette@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" + integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== colors@1.4.0, colors@^1.1.2, colors@^1.4.0: version "1.4.0" @@ -4208,9 +4208,9 @@ dns-equal@^1.0.0: integrity sha1-s55/HabrCnW6nBcySzR1PEfgZU0= dns-packet@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-1.3.1.tgz#12aa426981075be500b910eedcd0b47dd7deda5a" - integrity sha512-0UxfQkMhYAUaZI+xrNZOz/as5KgDU0M/fQ9b6SpkyLbk3GEswDi6PADJVaYJradtRVsRIlF1zLyOodbcTCDzUg== + version "1.3.4" + resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-1.3.4.tgz#e3455065824a2507ba886c55a89963bb107dec6f" + integrity sha512-BQ6F4vycLXBvdrJZ6S3gZewt6rcrks9KBgM9vrhW+knGRqc8uEdT7fuCwloc7nny5xNoMJ17HGH0R/6fpo8ECA== dependencies: ip "^1.1.0" safe-buffer "^5.0.1" @@ -4364,10 +4364,10 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= -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" - integrity sha512-AJT0Fm1W0uZlMVVkkJrcCVvczDuF8tPm3bwzQf5WO8AaASB2hwTRP7B8pU5rqjireH+ib6am8+hH5/QkXzzYKw== +electron-to-chromium@^1.3.723: + version "1.3.752" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.752.tgz#0728587f1b9b970ec9ffad932496429aef750d09" + integrity sha512-2Tg+7jSl3oPxgsBsWKh5H83QazTkmWG/cnNwJplmyZc7KcN61+I10oUgaXSVk/NwfvN3BdkKDR4FYuRBQQ2v0A== elliptic@^6.5.3: version "6.5.4" @@ -7710,10 +7710,10 @@ node-libs-browser@^2.2.1: util "^0.11.0" vm-browserify "^1.0.1" -node-releases@^1.1.67: - version "1.1.67" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.67.tgz#28ebfcccd0baa6aad8e8d4d8fe4cbc49ae239c12" - integrity sha512-V5QF9noGFl3EymEwUYzO+3NTDpGfQB4ve6Qfnzf3UNydMhjQRVPR1DZTuvWiLzaFJYw2fmDwAfnRNEVb64hSIg== +node-releases@^1.1.71: + version "1.1.73" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.73.tgz#dd4e81ddd5277ff846b80b52bb40c49edf7a7b20" + integrity sha512-uW7fodD6pyW2FZNZnp/Z3hvWKeEW1Y8R1+1CnErE8cXFXzl5blBOoVB41CvMer6P6Q0S5FXDwcHgFd1Wj0U9zg== nodemon@^2.0.2: version "2.0.6"