Merge remote-tracking branch 'origin/main' into restore-csr-fallback

This commit is contained in:
Yury Bondarenko
2023-02-10 11:53:31 +01:00
629 changed files with 30651 additions and 17689 deletions

View File

@@ -10,6 +10,16 @@ import { MembersListComponent } from './group-registry/group-form/members-list/m
import { SubgroupsListComponent } from './group-registry/group-form/subgroup-list/subgroups-list.component';
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
import { FormModule } from '../shared/form/form.module';
import { DYNAMIC_ERROR_MESSAGES_MATCHER, DynamicErrorMessagesMatcher } from '@ng-dynamic-forms/core';
import { AbstractControl } from '@angular/forms';
/**
* Condition for displaying error messages on email form field
*/
export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
(control: AbstractControl, model: any, hasFocus: boolean) => {
return (control.touched && !hasFocus) || (control.errors?.emailTaken && hasFocus);
};
@NgModule({
imports: [
@@ -26,6 +36,12 @@ import { FormModule } from '../shared/form/form.module';
GroupFormComponent,
SubgroupsListComponent,
MembersListComponent
],
providers: [
{
provide: DYNAMIC_ERROR_MESSAGES_MATCHER,
useValue: ValidateEmailErrorStateMatcher
},
]
})
/**

View File

@@ -9,7 +9,18 @@
</ng-template>
<ng-template #editheader>
<h2 class="border-bottom pb-2">{{messagePrefix + '.head.edit' | translate}}</h2>
<h2 class="border-bottom pb-2">
<span
*dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroupPage',
id: 'edit-group-page',
iconPlacement: 'right',
tooltipPlacement: ['right', 'bottom']
}"
>
{{messagePrefix + '.head.edit' | translate}}
</span>
</h2>
</ng-template>
<ds-alert *ngIf="groupBeingEdited?.permanent" [type]="AlertTypeEnum.Warning"

View File

@@ -266,6 +266,43 @@ describe('GroupFormComponent', () => {
fixture.detectChanges();
});
it('should edit with name and description operations', () => {
const operations = [{
op: 'add',
path: '/metadata/dc.description',
value: 'testDescription'
}, {
op: 'replace',
path: '/name',
value: 'newGroupName'
}];
expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations);
});
it('should edit with description operations', () => {
component.groupName.value = null;
component.onSubmit();
fixture.detectChanges();
const operations = [{
op: 'add',
path: '/metadata/dc.description',
value: 'testDescription'
}];
expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations);
});
it('should edit with name operations', () => {
component.groupDescription.value = null;
component.onSubmit();
fixture.detectChanges();
const operations = [{
op: 'replace',
path: '/name',
value: 'newGroupName'
}];
expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations);
});
it('should emit the existing group using the correct new values', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.submitForm.emit).toHaveBeenCalledWith(expected2);

View File

@@ -46,6 +46,7 @@ import { followLink } from '../../../shared/utils/follow-link-config.model';
import { NoContent } from '../../../core/shared/NoContent.model';
import { Operation } from 'fast-json-patch';
import { ValidateGroupExists } from './validators/group-exists.validator';
import { environment } from '../../../../environments/environment';
@Component({
selector: 'ds-group-form',
@@ -194,6 +195,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
label: groupDescription,
name: 'groupDescription',
required: false,
spellCheck: environment.form.spellCheck,
});
this.formModel = [
this.groupName,
@@ -344,8 +346,8 @@ export class GroupFormComponent implements OnInit, OnDestroy {
if (hasValue(this.groupDescription.value)) {
operations = [...operations, {
op: 'replace',
path: '/metadata/dc.description/0/value',
op: 'add',
path: '/metadata/dc.description',
value: this.groupDescription.value
}];
}

View File

@@ -1,9 +1,19 @@
<ng-container>
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
<h4 id="search" class="border-bottom pb-2">{{messagePrefix + '.search.head' | translate}}
<h4 id="search" class="border-bottom pb-2">
<span
*dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroup.addEpeople',
id: 'edit-group-add-epeople',
iconPlacement: 'right',
tooltipPlacement: ['top', 'right', 'bottom']
}"
>
{{messagePrefix + '.search.head' | translate}}
</span>
</h4>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<div>
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">

View File

@@ -1,7 +1,16 @@
<ng-container>
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
<h4 id="search" class="border-bottom pb-2">{{messagePrefix + '.search.head' | translate}}
<h4 id="search" class="border-bottom pb-2">
<span *dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroup.addSubgroups',
id: 'edit-group-add-subgroups',
iconPlacement: 'right',
tooltipPlacement: ['top', 'right', 'bottom']
}"
>
{{messagePrefix + '.search.head' | translate}}
</span>
</h4>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">

View File

@@ -1,12 +1,8 @@
import { Location } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { AuthService } from '../../core/auth/auth.service';
import { METADATA_IMPORT_SCRIPT_NAME, ScriptDataService } from '../../core/data/processes/script-data.service';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { ProcessParameter } from '../../process-page/processes/process-parameter.model';
import { isNotEmpty } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service';

View File

@@ -13,32 +13,34 @@
[paginationOptions]="pageConfig"
[pageInfoState]="(bitstreamFormats | async)?.payload"
[collectionSize]="(bitstreamFormats | async)?.payload?.totalElements"
[hideGear]="true"
[hideGear]="false"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="formats" class="table table-striped table-hover">
<thead>
<tr>
<th scope="col"></th>
<th scope="col">{{'admin.registries.bitstream-formats.table.name' | translate}}</th>
<th scope="col">{{'admin.registries.bitstream-formats.table.mimetype' | translate}}</th>
<th scope="col">{{'admin.registries.bitstream-formats.table.supportLevel.head' | translate}}</th>
</tr>
<tr>
<th scope="col"></th>
<th scope="col">{{'admin.registries.bitstream-formats.table.id' | translate}}</th>
<th scope="col">{{'admin.registries.bitstream-formats.table.name' | translate}}</th>
<th scope="col">{{'admin.registries.bitstream-formats.table.mimetype' | translate}}</th>
<th scope="col">{{'admin.registries.bitstream-formats.table.supportLevel.head' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let bitstreamFormat of (bitstreamFormats | async)?.payload?.page">
<td>
<label>
<input type="checkbox"
[checked]="isSelected(bitstreamFormat) | async"
(change)="selectBitStreamFormat(bitstreamFormat, $event)"
>
</label>
</td>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.shortDescription}}</a></td>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.mimetype}} <span *ngIf="bitstreamFormat.internal">({{'admin.registries.bitstream-formats.table.internal' | translate}})</span></a></td>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{'admin.registries.bitstream-formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}}</a></td>
</tr>
<tr *ngFor="let bitstreamFormat of (bitstreamFormats | async)?.payload?.page">
<td>
<label>
<input type="checkbox"
[checked]="isSelected(bitstreamFormat) | async"
(change)="selectBitStreamFormat(bitstreamFormat, $event)"
>
</label>
</td>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.id}}</a></td>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.shortDescription}}</a></td>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.mimetype}} <span *ngIf="bitstreamFormat.internal">({{'admin.registries.bitstream-formats.table.internal' | translate}})</span></a></td>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{'admin.registries.bitstream-formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}}</a></td>
</tr>
</tbody>
</table>
</div>

View File

@@ -129,16 +129,19 @@ describe('BitstreamFormatsComponent', () => {
});
it('should contain the correct formats', () => {
const unknownName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(1) td:nth-child(2)')).nativeElement;
const unknownName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(1) td:nth-child(3)')).nativeElement;
expect(unknownName.textContent).toBe('Unknown');
const licenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(2) td:nth-child(2)')).nativeElement;
const UUID: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(1) td:nth-child(2)')).nativeElement;
expect(UUID.textContent).toBe('test-uuid-1');
const licenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(2) td:nth-child(3)')).nativeElement;
expect(licenseName.textContent).toBe('License');
const ccLicenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(3) td:nth-child(2)')).nativeElement;
const ccLicenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(3) td:nth-child(3)')).nativeElement;
expect(ccLicenseName.textContent).toBe('CC License');
const adobeName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(4) td:nth-child(2)')).nativeElement;
const adobeName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(4) td:nth-child(3)')).nativeElement;
expect(adobeName.textContent).toBe('Adobe PDF');
});
});

View File

@@ -1,12 +1,11 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { combineLatest as observableCombineLatest, Observable, zip } from 'rxjs';
import { combineLatest as observableCombineLatest, Observable} from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { BitstreamFormat } from '../../../core/shared/bitstream-format.model';
import { BitstreamFormatDataService } from '../../../core/data/bitstream-format-data.service';
import { map, mergeMap, switchMap, take, toArray } from 'rxjs/operators';
import { hasValue } from '../../../shared/empty.util';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
@@ -29,21 +28,14 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy {
*/
bitstreamFormats: Observable<RemoteData<PaginatedList<BitstreamFormat>>>;
/**
* The current pagination configuration for the page used by the FindAll method
* Currently simply renders all bitstream formats
*/
config: FindListOptions = Object.assign(new FindListOptions(), {
elementsPerPage: 20
});
/**
* The current pagination configuration for the page
* Currently simply renders all bitstream formats
*/
pageConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'rbp',
pageSize: 20
pageSize: 20,
pageSizeOptions: [20, 40, 60, 80, 100]
});
constructor(private notificationsService: NotificationsService,
@@ -51,7 +43,7 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy {
private translateService: TranslateService,
private bitstreamFormatService: BitstreamFormatDataService,
private paginationService: PaginationService,
) {
) {
}
@@ -149,7 +141,7 @@ export class BitstreamFormatsComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.bitstreamFormats = this.paginationService.getFindListOptions(this.pageConfig.id, this.config).pipe(
this.bitstreamFormats = this.paginationService.getFindListOptions(this.pageConfig.id, this.pageConfig).pipe(
switchMap((findListOptions: FindListOptions) => {
return this.bitstreamFormatService.findAll(findListOptions);
})

View File

@@ -15,6 +15,7 @@ import { Router } from '@angular/router';
import { hasValue, isEmpty } from '../../../../shared/empty.util';
import { TranslateService } from '@ngx-translate/core';
import { getBitstreamFormatsModuleRoute } from '../../admin-registries-routing-paths';
import { environment } from '../../../../../environments/environment';
/**
* The component responsible for rendering the form to create/edit a bitstream format
@@ -90,6 +91,7 @@ export class FormatFormComponent implements OnInit {
name: 'description',
label: 'admin.registries.bitstream-formats.edit.description.label',
hint: 'admin.registries.bitstream-formats.edit.description.hint',
spellCheck: environment.form.spellCheck,
}),
new DynamicSelectModel({

View File

@@ -19,10 +19,7 @@ import { RestResponse } from '../../../core/cache/response.models';
import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { PaginationService } from '../../../core/pagination/pagination.service';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
import { FindListOptions } from '../../../core/data/find-list-options.model';
describe('MetadataRegistryComponent', () => {
let comp: MetadataRegistryComponent;

View File

@@ -25,6 +25,7 @@
<thead>
<tr>
<th></th>
<th scope="col">{{'admin.registries.schema.fields.table.id' | translate}}</th>
<th scope="col">{{'admin.registries.schema.fields.table.field' | translate}}</th>
<th scope="col">{{'admin.registries.schema.fields.table.scopenote' | translate}}</th>
</tr>
@@ -39,6 +40,7 @@
(change)="selectMetadataField(field, $event)">
</label>
</td>
<td class="selectable-row" (click)="editField(field)">{{field.id}}</td>
<td class="selectable-row" (click)="editField(field)">{{schema?.prefix}}.{{field.element}}<label *ngIf="field.qualifier">.</label>{{field.qualifier}}</td>
<td class="selectable-row" (click)="editField(field)">{{field.scopeNote}}</td>
</tr>

View File

@@ -23,11 +23,8 @@ import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
import { MetadataField } from '../../../core/metadata/metadata-field.model';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { VarDirective } from '../../../shared/utils/var.directive';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
import { PaginationService } from '../../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
import { FindListOptions } from '../../../core/data/find-list-options.model';
describe('MetadataSchemaComponent', () => {
let comp: MetadataSchemaComponent;
@@ -169,10 +166,10 @@ describe('MetadataSchemaComponent', () => {
});
it('should contain the correct fields', () => {
const editorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(1) td:nth-child(2)')).nativeElement;
const editorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(1) td:nth-child(3)')).nativeElement;
expect(editorField.textContent).toBe('mock.contributor.editor');
const illustratorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(2) td:nth-child(2)')).nativeElement;
const illustratorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(2) td:nth-child(3)')).nativeElement;
expect(illustratorField.textContent).toBe('mock.contributor.illustrator');
});

View File

@@ -47,6 +47,12 @@ import { BatchImportPageComponent } from './admin-import-batch-page/batch-import
component: BatchImportPageComponent,
data: { title: 'admin.batch-import.title', breadcrumbKey: 'admin.batch-import' }
},
{
path: 'system-wide-alert',
resolve: { breadcrumb: I18nBreadcrumbResolver },
loadChildren: () => import('../system-wide-alert/system-wide-alert.module').then((m) => m.SystemWideAlertModule),
data: {title: 'admin.system-wide-alert.title', breadcrumbKey: 'admin.system-wide-alert'}
},
])
],
providers: [

View File

@@ -2,7 +2,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MenuService } from '../../../shared/menu/menu.service';
import { MenuServiceStub } from '../../../shared/testing/menu-service.stub';
import { CSSVariableService } from '../../../shared/sass-helper/sass-helper.service';
import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service';
import { CSSVariableServiceStub } from '../../../shared/testing/css-variable-service.stub';
import { Component } from '@angular/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';

View File

@@ -6,7 +6,7 @@ import { ScriptDataService } from '../../core/data/processes/script-data.service
import { AdminSidebarComponent } from './admin-sidebar.component';
import { MenuService } from '../../shared/menu/menu.service';
import { MenuServiceStub } from '../../shared/testing/menu-service.stub';
import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service';
import { CSSVariableService } from '../../shared/sass-helper/css-variable.service';
import { CSSVariableServiceStub } from '../../shared/testing/css-variable-service.stub';
import { AuthServiceStub } from '../../shared/testing/auth-service.stub';
import { AuthService } from '../../core/auth/auth.service';
@@ -16,7 +16,6 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { RouterTestingModule } from '@angular/router/testing';
import { ActivatedRoute } from '@angular/router';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import createSpy = jasmine.createSpy;
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { Item } from '../../core/shared/item.model';

View File

@@ -5,7 +5,7 @@ import { AuthService } from '../../core/auth/auth.service';
import { slideSidebar } from '../../shared/animations/slide';
import { MenuComponent } from '../../shared/menu/menu.component';
import { MenuService } from '../../shared/menu/menu.service';
import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service';
import { CSSVariableService } from '../../shared/sass-helper/css-variable.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { MenuID } from '../../shared/menu/menu-id.model';
import { ActivatedRoute } from '@angular/router';
@@ -69,7 +69,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
*/
ngOnInit(): void {
super.ngOnInit();
this.sidebarWidth = this.variableService.getVariable('sidebarItemsWidth');
this.sidebarWidth = this.variableService.getVariable('--ds-sidebar-items-width');
this.authService.isAuthenticated()
.subscribe((loggedIn: boolean) => {
if (loggedIn) {
@@ -100,6 +100,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
}
}
});
this.menuVisible = this.menuService.isMenuVisibleWithVisibleSections(this.menuID);
}
@HostListener('focusin')

View File

@@ -3,7 +3,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ExpandableAdminSidebarSectionComponent } from './expandable-admin-sidebar-section.component';
import { MenuService } from '../../../shared/menu/menu.service';
import { MenuServiceStub } from '../../../shared/testing/menu-service.stub';
import { CSSVariableService } from '../../../shared/sass-helper/sass-helper.service';
import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service';
import { CSSVariableServiceStub } from '../../../shared/testing/css-variable-service.stub';
import { of as observableOf } from 'rxjs';
import { Component } from '@angular/core';

View File

@@ -2,7 +2,7 @@ import { Component, Inject, Injector, OnInit } from '@angular/core';
import { rotate } from '../../../shared/animations/rotate';
import { AdminSidebarSectionComponent } from '../admin-sidebar-section/admin-sidebar-section.component';
import { slide } from '../../../shared/animations/slide';
import { CSSVariableService } from '../../../shared/sass-helper/sass-helper.service';
import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service';
import { bgColor } from '../../../shared/animations/bgColor';
import { MenuService } from '../../../shared/menu/menu.service';
import { combineLatest as combineLatestObservable, Observable } from 'rxjs';
@@ -65,7 +65,7 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC
*/
ngOnInit(): void {
super.ngOnInit();
this.sidebarActiveBg = this.variableService.getVariable('adminSidebarActiveBg');
this.sidebarActiveBg = this.variableService.getVariable('--ds-admin-sidebar-active-bg');
this.sidebarCollapsed = this.menuService.isMenuCollapsed(this.menuID);
this.sidebarPreviewCollapsed = this.menuService.isMenuPreviewCollapsed(this.menuID);
this.expanded = combineLatestObservable(this.active, this.sidebarCollapsed, this.sidebarPreviewCollapsed)

View File

@@ -10,6 +10,7 @@ import { AdminSearchModule } from './admin-search-page/admin-search.module';
import { AdminSidebarSectionComponent } from './admin-sidebar/admin-sidebar-section/admin-sidebar-section.component';
import { ExpandableAdminSidebarSectionComponent } from './admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component';
import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component';
import { UploadModule } from '../shared/upload/upload.module';
const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator
@@ -25,7 +26,8 @@ const ENTRY_COMPONENTS = [
AccessControlModule,
AdminSearchModule.withEntryComponents(),
AdminWorkflowModuleModule.withEntryComponents(),
SharedModule
SharedModule,
UploadModule,
],
declarations: [
AdminCurationTasksComponent,

View File

@@ -18,7 +18,7 @@ import { AngularticsProviderMock } from './shared/mocks/angulartics-provider.ser
import { AuthServiceMock } from './shared/mocks/auth.service.mock';
import { AuthService } from './core/auth/auth.service';
import { MenuService } from './shared/menu/menu.service';
import { CSSVariableService } from './shared/sass-helper/sass-helper.service';
import { CSSVariableService } from './shared/sass-helper/css-variable.service';
import { CSSVariableServiceStub } from './shared/testing/css-variable-service.stub';
import { MenuServiceStub } from './shared/testing/menu-service.stub';
import { HostWindowService } from './shared/host-window.service';

View File

@@ -25,13 +25,12 @@ import { HostWindowState } from './shared/search/host-window.reducer';
import { NativeWindowRef, NativeWindowService } from './core/services/window.service';
import { isAuthenticationBlocking } from './core/auth/selectors';
import { AuthService } from './core/auth/auth.service';
import { CSSVariableService } from './shared/sass-helper/sass-helper.service';
import { CSSVariableService } from './shared/sass-helper/css-variable.service';
import { environment } from '../environments/environment';
import { models } from './core/core.module';
import { ThemeService } from './shared/theme-support/theme.service';
import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
import { distinctNext } from './core/shared/distinct-next';
import { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.interface';
@Component({
selector: 'ds-app',
@@ -110,18 +109,8 @@ export class AppComponent implements OnInit, AfterViewInit {
}
private storeCSSVariables() {
this.cssService.addCSSVariable('xlMin', '1200px');
this.cssService.addCSSVariable('mdMin', '768px');
this.cssService.addCSSVariable('lgMin', '576px');
this.cssService.addCSSVariable('smMin', '0');
this.cssService.addCSSVariable('adminSidebarActiveBg', '#0f1b28');
this.cssService.addCSSVariable('sidebarItemsWidth', '250px');
this.cssService.addCSSVariable('collapsedSidebarWidth', '53.234px');
this.cssService.addCSSVariable('totalSidebarWidth', '303.234px');
// const vars = variables.locals || {};
// Object.keys(vars).forEach((name: string) => {
// this.cssService.addCSSVariable(name, vars[name]);
// })
this.cssService.clearCSSVariables();
this.cssService.addCSSVariables(this.cssService.getCSSVariablesFromStylesheets(this.document));
}
ngAfterViewInit() {

View File

@@ -1,14 +1,12 @@
import { APP_BASE_HREF, CommonModule, DOCUMENT } from '@angular/common';
import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { AbstractControl } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { EffectsModule } from '@ngrx/effects';
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store';
import { MetaReducer, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
import { DYNAMIC_ERROR_MESSAGES_MATCHER, DYNAMIC_MATCHER_PROVIDERS, DynamicErrorMessagesMatcher } from '@ng-dynamic-forms/core';
import { TranslateModule } from '@ngx-translate/core';
import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to';
import { AppRoutingModule } from './app-routing.module';
@@ -28,7 +26,6 @@ import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor';
import { LogInterceptor } from './core/log/log.interceptor';
import { EagerThemesModule } from '../themes/eager-themes.module';
import { APP_CONFIG, AppConfig } from '../config/app-config.interface';
import { NgxMaskModule } from 'ngx-mask';
import { StoreDevModules } from '../config/store/devtools';
import { RootModule } from './root.module';
@@ -46,14 +43,6 @@ export function getMetaReducers(appConfig: AppConfig): MetaReducer<AppState>[] {
return appConfig.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers;
}
/**
* Condition for displaying error messages on email form field
*/
export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
(control: AbstractControl, model: any, hasFocus: boolean) => {
return (control.touched && !hasFocus) || (control.errors?.emailTaken && hasFocus);
};
const IMPORTS = [
CommonModule,
SharedModule,
@@ -64,7 +53,6 @@ const IMPORTS = [
ScrollToModule.forRoot(),
NgbModule,
TranslateModule.forRoot(),
NgxMaskModule.forRoot(),
EffectsModule.forRoot(appEffects),
StoreModule.forRoot(appReducers, storeModuleConfig),
StoreRouterConnectingModule.forRoot(),
@@ -113,11 +101,6 @@ const PROVIDERS = [
useClass: LogInterceptor,
multi: true
},
{
provide: DYNAMIC_ERROR_MESSAGES_MATCHER,
useValue: ValidateEmailErrorStateMatcher
},
...DYNAMIC_MATCHER_PROVIDERS,
];
const DECLARATIONS = [

View File

@@ -1,4 +1,4 @@
import * as fromRouter from '@ngrx/router-store';
import { routerReducer, RouterReducerState } from '@ngrx/router-store';
import { ActionReducerMap, createSelector, MemoizedSelector } from '@ngrx/store';
import {
ePeopleRegistryReducer,
@@ -35,31 +35,27 @@ import {
ObjectSelectionListState,
objectSelectionReducer
} from './shared/object-select/object-select.reducer';
import { cssVariablesReducer, CSSVariablesState } from './shared/sass-helper/sass-helper.reducer';
import { cssVariablesReducer, CSSVariablesState } from './shared/sass-helper/css-variable.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 { sidebarReducer, SidebarState } from './shared/sidebar/sidebar.reducer';
import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer';
import { ThemeState, themeReducer } from './shared/theme-support/theme.reducer';
import { MenusState } from './shared/menu/menus-state.model';
import { correlationIdReducer } from './correlation-id/correlation-id.reducer';
import { contextHelpReducer, ContextHelpState } from './shared/context-help.reducer';
export interface AppState {
router: fromRouter.RouterReducerState;
router: RouterReducerState;
hostWindow: HostWindowState;
forms: FormState;
metadataRegistry: MetadataRegistryState;
notifications: NotificationsState;
sidebar: SidebarState;
sidebarFilter: SidebarFiltersState;
searchFilter: SearchFiltersState;
truncatable: TruncatablesState;
cssVariables: CSSVariablesState;
@@ -72,16 +68,16 @@ export interface AppState {
epeopleRegistry: EPeopleRegistryState;
groupRegistry: GroupRegistryState;
correlationId: string;
contextHelp: ContextHelpState;
}
export const appReducers: ActionReducerMap<AppState> = {
router: fromRouter.routerReducer,
router: routerReducer,
hostWindow: hostWindowReducer,
forms: formReducer,
metadataRegistry: metadataRegistryReducer,
notifications: notificationsReducer,
sidebar: sidebarReducer,
sidebarFilter: sidebarFilterReducer,
searchFilter: filterReducer,
truncatable: truncatableReducer,
cssVariables: cssVariablesReducer,
@@ -93,7 +89,8 @@ export const appReducers: ActionReducerMap<AppState> = {
communityList: CommunityListReducer,
epeopleRegistry: ePeopleRegistryReducer,
groupRegistry: groupRegistryReducer,
correlationId: correlationIdReducer
correlationId: correlationIdReducer,
contextHelp: contextHelpReducer,
};
export const routerStateSelector = (state: AppState) => state.router;

View File

@@ -6,7 +6,7 @@ import { Bitstream } from '../../core/shared/bitstream.model';
import { BitstreamDownloadPageComponent } from './bitstream-download-page.component';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { HardRedirectService } from '../../core/services/hard-redirect.service';
import { createSuccessfulRemoteDataObject } from '../remote-data.utils';
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { ActivatedRoute, Router } from '@angular/router';
import { getForbiddenRoute } from '../../app-routing-paths';
import { TranslateModule } from '@ngx-translate/core';

View File

@@ -1,7 +1,7 @@
import { Component, OnInit } from '@angular/core';
import { filter, map, switchMap, take } from 'rxjs/operators';
import { ActivatedRoute, Router } from '@angular/router';
import { hasValue, isNotEmpty } from '../empty.util';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { getRemoteDataPayload} from '../../core/shared/operators';
import { Bitstream } from '../../core/shared/bitstream.model';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';

View File

@@ -3,7 +3,7 @@ import { RouterModule } from '@angular/router';
import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { BitstreamPageResolver } from './bitstream-page.resolver';
import { BitstreamDownloadPageComponent } from '../shared/bitstream-download-page/bitstream-download-page.component';
import { BitstreamDownloadPageComponent } from './bitstream-download-page/bitstream-download-page.component';
import { ResourcePolicyTargetResolver } from '../shared/resource-policies/resolvers/resource-policy-target.resolver';
import { ResourcePolicyCreateComponent } from '../shared/resource-policies/create/resource-policy-create.component';
import { ResourcePolicyResolver } from '../shared/resource-policies/resolvers/resource-policy.resolver';

View File

@@ -6,6 +6,7 @@ import { BitstreamPageRoutingModule } from './bitstream-page-routing.module';
import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component';
import { FormModule } from '../shared/form/form.module';
import { ResourcePoliciesModule } from '../shared/resource-policies/resource-policies.module';
import { BitstreamDownloadPageComponent } from './bitstream-download-page/bitstream-download-page.component';
/**
* This module handles all components that are necessary for Bitstream related pages
@@ -20,7 +21,8 @@ import { ResourcePoliciesModule } from '../shared/resource-policies/resource-pol
],
declarations: [
BitstreamAuthorizationsComponent,
EditBitstreamPageComponent
EditBitstreamPageComponent,
BitstreamDownloadPageComponent,
]
})
export class BitstreamPageModule {

View File

@@ -26,7 +26,7 @@ import {
import { FormGroup } from '@angular/forms';
import { TranslateService } from '@ngx-translate/core';
import { DynamicCustomSwitchModel } from '../../shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model';
import { cloneDeep } from 'lodash';
import cloneDeep from 'lodash/cloneDeep';
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
import {
getAllSucceededRemoteDataPayload,

View File

@@ -1,5 +1,5 @@
import { LegacyBitstreamUrlResolver } from './legacy-bitstream-url.resolver';
import { of as observableOf, EMPTY } from 'rxjs';
import { EMPTY } from 'rxjs';
import { BitstreamDataService } from '../core/data/bitstream-data.service';
import { RemoteData } from '../core/data/remote-data';
import { TestScheduler } from 'rxjs/testing';

View File

@@ -10,7 +10,7 @@
</nav>
<ng-template #breadcrumb let-text="text" let-url="url">
<li class="breadcrumb-item"><div class="breadcrumb-item-limiter"><a [routerLink]="url" class="text-truncate">{{text | translate}}</a></div></li>
<li class="breadcrumb-item"><div class="breadcrumb-item-limiter"><a [routerLink]="url" class="text-truncate" [ngbTooltip]="text | translate" placement="bottom" >{{text | translate}}</a></div></li>
</ng-template>
<ng-template #activeBreadcrumb let-text="text">

View File

@@ -23,11 +23,14 @@ li.breadcrumb-item {
}
}
li.breadcrumb-item > a {
color: var(--ds-breadcrumb-link-color) !important;
li.breadcrumb-item {
a {
color: var(--ds-breadcrumb-link-color);
}
}
li.breadcrumb-item.active {
color: var(--ds-breadcrumb-link-active-color) !important;
color: var(--ds-breadcrumb-link-active-color);
}
.breadcrumb-item+ .breadcrumb-item::before {

View File

@@ -65,6 +65,7 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
const searchOptions = browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails);
this.updatePageWithItems(searchOptions, this.value, undefined);
this.updateParent(params.scope);
this.updateLogo();
this.updateStartsWithOptions(this.browseId, metadataKeys, params.scope);
}));
}

View File

@@ -1,7 +1,6 @@
import { first } from 'rxjs/operators';
import { BrowseByGuard } from './browse-by-guard';
import { of as observableOf } from 'rxjs';
import { BrowseDefinitionDataService } from '../core/browse/browse-definition-data.service';
import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
import { BrowseDefinition } from '../core/shared/browse-definition.model';
import { BrowseByDataType } from './browse-by-switcher/browse-by-decorator';

View File

@@ -5,6 +5,11 @@
<!-- Parent Name -->
<ds-comcol-page-header [name]="parentContext.name">
</ds-comcol-page-header>
<!-- Collection logo -->
<ds-comcol-page-logo *ngIf="logo$"
[logo]="(logo$ | async)?.payload"
[alternateText]="'Community or Collection Logo'">
</ds-comcol-page-logo>
<!-- Handle -->
<ds-themed-comcol-page-handle
[content]="parentContext.handle"

View File

@@ -144,6 +144,9 @@ describe('BrowseByMetadataPageComponent', () => {
route.params = observableOf(paramsWithValue);
comp.ngOnInit();
comp.updateParent('fake-scope');
comp.updateLogo();
fixture.detectChanges();
});
it('should fetch items', () => {
@@ -151,6 +154,10 @@ describe('BrowseByMetadataPageComponent', () => {
expect(result.payload.page).toEqual(mockItems);
});
});
it('should fetch the logo', () => {
expect(comp.logo$).toBeTruthy();
});
});
describe('when calling browseParamsToOptions', () => {

View File

@@ -15,7 +15,11 @@ import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.serv
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { StartsWithType } from '../../shared/starts-with/starts-with-decorator';
import { PaginationService } from '../../core/pagination/pagination.service';
import { map } from 'rxjs/operators';
import { filter, map, mergeMap } from 'rxjs/operators';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { Bitstream } from '../../core/shared/bitstream.model';
import { Collection } from '../../core/shared/collection.model';
import { Community } from '../../core/shared/community.model';
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
export const BBM_PAGINATION_ID = 'bbm';
@@ -48,6 +52,11 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy {
*/
parent$: Observable<RemoteData<DSpaceObject>>;
/**
* The logo of the current Community or Collection
*/
logo$: Observable<RemoteData<Bitstream>>;
/**
* The pagination config used to display the values
*/
@@ -151,6 +160,7 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy {
this.updatePage(browseParamsToOptions(params, currentPage, currentSort, this.browseId, false));
}
this.updateParent(params.scope);
this.updateLogo();
}));
this.updateStartsWithTextOptions();
@@ -196,12 +206,31 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy {
*/
updateParent(scope: string) {
if (hasValue(scope)) {
this.parent$ = this.dsoService.findById(scope).pipe(
const linksToFollow = () => {
return [followLink('logo')];
};
this.parent$ = this.dsoService.findById(scope,
true,
true,
...linksToFollow() as FollowLinkConfig<DSpaceObject>[]).pipe(
getFirstSucceededRemoteData()
);
}
}
/**
* Update the parent Community or Collection logo
*/
updateLogo() {
if (hasValue(this.parent$)) {
this.logo$ = this.parent$.pipe(
map((rd: RemoteData<Collection | Community>) => rd.payload),
filter((collectionOrCommunity: Collection | Community) => hasValue(collectionOrCommunity.logo)),
mergeMap((collectionOrCommunity: Collection | Community) => collectionOrCommunity.logo)
);
}
}
/**
* Navigate to the previous page
*/

View File

@@ -4,16 +4,21 @@ import { BrowseByModule } from './browse-by.module';
import { ItemDataService } from '../core/data/item-data.service';
import { BrowseService } from '../core/browse/browse.service';
import { BrowseByGuard } from './browse-by-guard';
import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module';
@NgModule({
imports: [
SharedBrowseByModule,
BrowseByRoutingModule,
BrowseByModule.withEntryComponents()
BrowseByModule.withEntryComponents(),
],
providers: [
ItemDataService,
BrowseService,
BrowseByGuard
BrowseByGuard,
],
declarations: [
]
})
export class BrowseByPageModule {

View File

@@ -49,6 +49,7 @@ export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent {
this.browseId = params.id || this.defaultBrowseId;
this.updatePageWithItems(browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails), undefined, undefined);
this.updateParent(params.scope);
this.updateLogo();
}));
this.updateStartsWithTextOptions();
}

View File

@@ -1,7 +1,6 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BrowseByTitlePageComponent } from './browse-by-title-page/browse-by-title-page.component';
import { SharedModule } from '../shared/shared.module';
import { BrowseByMetadataPageComponent } from './browse-by-metadata-page/browse-by-metadata-page.component';
import { BrowseByDatePageComponent } from './browse-by-date-page/browse-by-date-page.component';
import { BrowseBySwitcherComponent } from './browse-by-switcher/browse-by-switcher.component';
@@ -10,6 +9,7 @@ import { ComcolModule } from '../shared/comcol/comcol.module';
import { ThemedBrowseByMetadataPageComponent } from './browse-by-metadata-page/themed-browse-by-metadata-page.component';
import { ThemedBrowseByDatePageComponent } from './browse-by-date-page/themed-browse-by-date-page.component';
import { ThemedBrowseByTitlePageComponent } from './browse-by-title-page/themed-browse-by-title-page.component';
import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module';
const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator
@@ -25,9 +25,9 @@ const ENTRY_COMPONENTS = [
@NgModule({
imports: [
SharedBrowseByModule,
CommonModule,
ComcolModule,
SharedModule
],
declarations: [
BrowseBySwitcherComponent,
@@ -45,7 +45,7 @@ export class BrowseByModule {
*/
static withEntryComponents() {
return {
ngModule: SharedModule,
ngModule: SharedBrowseByModule,
providers: ENTRY_COMPONENTS.map((component) => ({provide: component}))
};
}

View File

@@ -1,5 +1,6 @@
import { DynamicFormControlModel, DynamicInputModel, DynamicTextAreaModel } from '@ng-dynamic-forms/core';
import { DynamicSelectModelConfig } from '@ng-dynamic-forms/core/lib/model/select/dynamic-select.model';
import { environment } from '../../../environments/environment';
export const collectionFormEntityTypeSelectionConfig: DynamicSelectModelConfig<string> = {
id: 'entityType',
@@ -26,21 +27,26 @@ export const collectionFormModels: DynamicFormControlModel[] = [
new DynamicTextAreaModel({
id: 'description',
name: 'dc.description',
spellCheck: environment.form.spellCheck,
}),
new DynamicTextAreaModel({
id: 'abstract',
name: 'dc.description.abstract',
spellCheck: environment.form.spellCheck,
}),
new DynamicTextAreaModel({
id: 'rights',
name: 'dc.rights',
spellCheck: environment.form.spellCheck,
}),
new DynamicTextAreaModel({
id: 'tableofcontents',
name: 'dc.description.tableofcontents',
spellCheck: environment.form.spellCheck,
}),
new DynamicTextAreaModel({
id: 'license',
name: 'dc.rights.license',
spellCheck: environment.form.spellCheck,
})
];

View File

@@ -16,7 +16,7 @@ import { Collection } from '../../core/shared/collection.model';
import { RemoteData } from '../../core/data/remote-data';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { ChangeDetectionStrategy, EventEmitter } from '@angular/core';
import { EventEmitter } from '@angular/core';
import { HostWindowService } from '../../shared/host-window.service';
import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub';
import { By } from '@angular/platform-browser';
@@ -41,7 +41,7 @@ import {
} from '../../shared/remote-data.utils';
import { createPaginatedList } from '../../shared/testing/utils.test';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { MyDSpacePageComponent, SEARCH_CONFIG_SERVICE } from '../../my-dspace-page/my-dspace-page.component';
import { SEARCH_CONFIG_SERVICE } from '../../my-dspace-page/my-dspace-page.component';
import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub';
import { GroupDataService } from '../../core/eperson/group-data.service';
import { LinkHeadService } from '../../core/services/link-head.service';

View File

@@ -72,6 +72,7 @@ import { MenuItemType } from '../shared/menu/menu-item-type.model';
id: 'statistics_collection_:id',
active: true,
visible: true,
index: 2,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',

View File

@@ -16,6 +16,7 @@ import { StatisticsModule } from '../statistics/statistics.module';
import { CollectionFormModule } from './collection-form/collection-form.module';
import { ThemedCollectionPageComponent } from './themed-collection-page.component';
import { ComcolModule } from '../shared/comcol/comcol.module';
import { DsoSharedModule } from '../dso-shared/dso-shared.module';
@NgModule({
imports: [
@@ -25,7 +26,8 @@ import { ComcolModule } from '../shared/comcol/comcol.module';
StatisticsModule.forRoot(),
EditItemPageModule,
CollectionFormModule,
ComcolModule
ComcolModule,
DsoSharedModule,
],
declarations: [
CollectionPageComponent,
@@ -38,7 +40,7 @@ import { ComcolModule } from '../shared/comcol/comcol.module';
],
providers: [
SearchService,
]
],
})
export class CollectionPageModule {

View File

@@ -11,11 +11,11 @@
</div>
<div>
<span class="font-weight-bold">{{'collection.source.controls.harvest.last' | translate}}</span>
<span>{{contentSource?.message ? contentSource?.message : 'collection.source.controls.harvest.no-information'|translate }}</span>
<span>{{contentSource?.lastHarvested ? contentSource?.lastHarvested : 'collection.source.controls.harvest.no-information'|translate }}</span>
</div>
<div>
<span class="font-weight-bold">{{'collection.source.controls.harvest.message' | translate}}</span>
<span>{{contentSource?.lastHarvested ? contentSource?.lastHarvested : 'collection.source.controls.harvest.no-information'|translate }}</span>
<span>{{contentSource?.message ? contentSource?.message: 'collection.source.controls.harvest.no-information'|translate }}</span>
</div>
<button *ngIf="!(testConfigRunning$ |async)" class="btn btn-secondary"

View File

@@ -8,8 +8,7 @@ import {
DynamicInputModel,
DynamicOptionControlModel,
DynamicRadioGroupModel,
DynamicSelectModel,
DynamicTextAreaModel
DynamicSelectModel
} from '@ng-dynamic-forms/core';
import { Location } from '@angular/common';
import { TranslateService } from '@ngx-translate/core';
@@ -23,7 +22,7 @@ import { RemoteData } from '../../../core/data/remote-data';
import { Collection } from '../../../core/shared/collection.model';
import { first, map, switchMap, take } from 'rxjs/operators';
import { ActivatedRoute, Router } from '@angular/router';
import { cloneDeep } from 'lodash';
import cloneDeep from 'lodash/cloneDeep';
import { CollectionDataService } from '../../../core/data/collection-data.service';
import { getFirstSucceededRemoteData, getFirstCompletedRemoteData } from '../../../core/shared/operators';
import { MetadataConfig } from '../../../core/shared/metadata-config.model';

View File

@@ -25,7 +25,7 @@ import { ComcolModule } from '../../shared/comcol/comcol.module';
CollectionFormModule,
ResourcePoliciesModule,
FormModule,
ComcolModule
ComcolModule,
],
declarations: [
EditCollectionPageComponent,

View File

@@ -3,7 +3,7 @@
<div class="col-12" *ngVar="(itemRD$ | async) as itemRD">
<ng-container *ngIf="itemRD?.hasSucceeded">
<h2 class="border-bottom">{{ 'collection.edit.template.head' | translate:{ collection: collection?.name } }}</h2>
<ds-themed-item-metadata [updateService]="itemTemplateService" [item]="itemRD?.payload"></ds-themed-item-metadata>
<ds-themed-dso-edit-metadata [updateDataService]="itemTemplateService" [dso]="itemRD?.payload"></ds-themed-dso-edit-metadata>
<button [routerLink]="getCollectionEditUrl(collection)" class="btn btn-outline-secondary">{{ 'collection.edit.template.cancel' | translate }}</button>
</ng-container>
<ds-themed-loading *ngIf="itemRD?.isLoading" [message]="'collection.edit.template.loading' | translate"></ds-themed-loading>

View File

@@ -6,6 +6,7 @@ import { CommunityListPageRoutingModule } from './community-list-page.routing.mo
import { CommunityListComponent } from './community-list/community-list.component';
import { ThemedCommunityListPageComponent } from './themed-community-list-page.component';
import { ThemedCommunityListComponent } from './community-list/themed-community-list.component';
import { CdkTreeModule } from '@angular/cdk/tree';
const DECLARATIONS = [
@@ -21,13 +22,15 @@ const DECLARATIONS = [
imports: [
CommonModule,
SharedModule,
CommunityListPageRoutingModule
CommunityListPageRoutingModule,
CdkTreeModule,
],
declarations: [
...DECLARATIONS
],
exports: [
...DECLARATIONS,
CdkTreeModule,
],
})
export class CommunityListPageModule {

View File

@@ -13,6 +13,7 @@ import { CommunityDataService } from '../../core/data/community-data.service';
import { AuthService } from '../../core/auth/auth.service';
import { RequestService } from '../../core/data/request.service';
import { ObjectCacheService } from '../../core/cache/object-cache.service';
import { environment } from '../../../environments/environment';
/**
* Form used for creating and editing communities
@@ -52,18 +53,22 @@ export class CommunityFormComponent extends ComColFormComponent<Community> {
new DynamicTextAreaModel({
id: 'description',
name: 'dc.description',
spellCheck: environment.form.spellCheck,
}),
new DynamicTextAreaModel({
id: 'abstract',
name: 'dc.description.abstract',
spellCheck: environment.form.spellCheck,
}),
new DynamicTextAreaModel({
id: 'rights',
name: 'dc.rights',
spellCheck: environment.form.spellCheck,
}),
new DynamicTextAreaModel({
id: 'tableofcontents',
name: 'dc.description.tableofcontents',
spellCheck: environment.form.spellCheck,
}),
];

View File

@@ -55,6 +55,7 @@ import { MenuItemType } from '../shared/menu/menu-item-type.model';
id: 'statistics_community_:id',
active: true,
visible: true,
index: 2,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',

View File

@@ -36,7 +36,7 @@ const DECLARATIONS = [CommunityPageComponent,
CommunityPageRoutingModule,
StatisticsModule.forRoot(),
CommunityFormModule,
ComcolModule
ComcolModule,
],
declarations: [
...DECLARATIONS

View File

@@ -21,7 +21,7 @@ import { ComcolModule } from '../../shared/comcol/comcol.module';
EditCommunityPageRoutingModule,
CommunityFormModule,
ComcolModule,
ResourcePoliciesModule
ResourcePoliciesModule,
],
declarations: [
EditCommunityPageComponent,

View File

@@ -17,9 +17,6 @@ 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 { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { of as observableOf } from 'rxjs';
import { PaginationService } from '../../core/pagination/pagination.service';
import { getMockThemeService } from '../../shared/mocks/theme-service.mock';
import { ThemeService } from '../../shared/theme-support/theme.service';
@@ -29,7 +26,6 @@ import { GroupDataService } from '../../core/eperson/group-data.service';
import { LinkHeadService } from '../../core/services/link-head.service';
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
import { SearchServiceStub } from '../../shared/testing/search-service.stub';
import { ConfigurationProperty } from '../../core/shared/configuration-property.model';
import { createPaginatedList } from '../../shared/testing/utils.test';
import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service.stub';

View File

@@ -17,9 +17,6 @@ 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 { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { of as observableOf } from 'rxjs';
import { PaginationService } from '../../core/pagination/pagination.service';
import { getMockThemeService } from '../../shared/mocks/theme-service.mock';
import { ThemeService } from '../../shared/theme-support/theme.service';

View File

@@ -11,6 +11,7 @@ import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import objectContaining = jasmine.objectContaining;
import { AuthStatus } from './models/auth-status.model';
import { RestRequestMethod } from '../data/rest-request-method';
import { Observable, of as observableOf } from 'rxjs';
describe(`AuthRequestService`, () => {
let halService: HALEndpointService;
@@ -34,8 +35,8 @@ describe(`AuthRequestService`, () => {
super(hes, rs, rdbs);
}
protected createShortLivedTokenRequest(href: string): PostRequest {
return new PostRequest(this.requestService.generateRequestId(), href);
protected createShortLivedTokenRequest(href: string): Observable<PostRequest> {
return observableOf(new PostRequest(this.requestService.generateRequestId(), href));
}
}

View File

@@ -100,14 +100,12 @@ export abstract class AuthRequestService {
);
}
/**
* Factory function to create the request object to send. This needs to be a POST client side and
* a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow
* only the server IP to send a GET to this endpoint.
* Factory function to create the request object to send.
*
* @param href The href to send the request to
* @protected
*/
protected abstract createShortLivedTokenRequest(href: string): GetRequest | PostRequest;
protected abstract createShortLivedTokenRequest(href: string): Observable<PostRequest>;
/**
* Send a request to retrieve a short-lived token which provides download access of restricted files
@@ -117,7 +115,7 @@ export abstract class AuthRequestService {
filter((href: string) => isNotEmpty(href)),
distinctUntilChanged(),
map((href: string) => new URLCombiner(href, this.shortlivedtokensEndpoint).toString()),
map((endpointURL: string) => this.createShortLivedTokenRequest(endpointURL)),
switchMap((endpointURL: string) => this.createShortLivedTokenRequest(endpointURL)),
tap((request: RestRequest) => this.requestService.send(request)),
switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID<ShortLivedToken>(request.uuid)),
getFirstCompletedRemoteData(),

View File

@@ -196,7 +196,24 @@ export class AuthInterceptor implements HttpInterceptor {
authStatus.token = new AuthTokenInfo(accessToken);
} else {
authStatus.authenticated = false;
authStatus.error = isNotEmpty(error) ? ((typeof error === 'string') ? JSON.parse(error) : error) : null;
if (isNotEmpty(error)) {
if (typeof error === 'string') {
try {
authStatus.error = JSON.parse(error);
} catch (e) {
console.error('Unknown auth error "', error, '" caused ', e);
authStatus.error = {
error: 'Unknown',
message: 'Unknown auth error',
status: 500,
timestamp: Date.now(),
path: ''
};
}
} else {
authStatus.error = error;
}
}
}
return authStatus;
}

View File

@@ -1,6 +1,8 @@
import { AuthRequestService } from './auth-request.service';
import { RequestService } from '../data/request.service';
import { BrowserAuthRequestService } from './browser-auth-request.service';
import { Observable } from 'rxjs';
import { PostRequest } from '../data/request.models';
describe(`BrowserAuthRequestService`, () => {
let href: string;
@@ -16,14 +18,20 @@ describe(`BrowserAuthRequestService`, () => {
});
describe(`createShortLivedTokenRequest`, () => {
it(`should return a PostRequest`, () => {
const result = (service as any).createShortLivedTokenRequest(href);
expect(result.constructor.name).toBe('PostRequest');
it(`should return a PostRequest`, (done) => {
const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
obs.subscribe((result: PostRequest) => {
expect(result.constructor.name).toBe('PostRequest');
done();
});
});
it(`should return a request with the given href`, () => {
const result = (service as any).createShortLivedTokenRequest(href);
expect(result.href).toBe(href) ;
it(`should return a request with the given href`, (done) => {
const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
obs.subscribe((result: PostRequest) => {
expect(result.href).toBe(href);
done();
});
});
});
});

View File

@@ -4,6 +4,7 @@ import { PostRequest } from '../data/request.models';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestService } from '../data/request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Observable, of as observableOf } from 'rxjs';
/**
* Client side version of the service to send authentication requests
@@ -20,15 +21,13 @@ export class BrowserAuthRequestService extends AuthRequestService {
}
/**
* Factory function to create the request object to send. This needs to be a POST client side and
* a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow
* only the server IP to send a GET to this endpoint.
* Factory function to create the request object to send.
*
* @param href The href to send the request to
* @protected
*/
protected createShortLivedTokenRequest(href: string): PostRequest {
return new PostRequest(this.requestService.generateRequestId(), href);
protected createShortLivedTokenRequest(href: string): Observable<PostRequest> {
return observableOf(new PostRequest(this.requestService.generateRequestId(), href));
}
}

View File

@@ -7,7 +7,6 @@ import { createSelector } from '@ngrx/store';
* notation packages up all of the exports into a single object.
*/
import { AuthState } from './auth.reducer';
import { AppState } from '../../app.reducer';
import { CoreState } from '../core-state.model';
import { coreSelector } from '../core.selectors';

View File

@@ -1,34 +1,68 @@
import { AuthRequestService } from './auth-request.service';
import { RequestService } from '../data/request.service';
import { ServerAuthRequestService } from './server-auth-request.service';
import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http';
import { Observable, of as observableOf } from 'rxjs';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { PostRequest } from '../data/request.models';
import {
XSRF_REQUEST_HEADER,
XSRF_RESPONSE_HEADER
} from '../xsrf/xsrf.interceptor';
describe(`ServerAuthRequestService`, () => {
let href: string;
let requestService: RequestService;
let service: AuthRequestService;
let httpClient: HttpClient;
let httpResponse: HttpResponse<any>;
let halService: HALEndpointService;
const mockToken = 'mock-token';
beforeEach(() => {
href = 'https://rest.api/auth/shortlivedtokens';
requestService = jasmine.createSpyObj('requestService', {
'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2'
});
service = new ServerAuthRequestService(null, requestService, null);
let headers = new HttpHeaders();
headers = headers.set(XSRF_RESPONSE_HEADER, mockToken);
httpResponse = {
body: { bar: false },
headers: headers,
statusText: '200'
} as HttpResponse<any>;
httpClient = jasmine.createSpyObj('httpClient', {
get: observableOf(httpResponse),
});
halService = jasmine.createSpyObj('halService', {
'getRootHref': '/api'
});
service = new ServerAuthRequestService(halService, requestService, null, httpClient);
});
describe(`createShortLivedTokenRequest`, () => {
it(`should return a GetRequest`, () => {
const result = (service as any).createShortLivedTokenRequest(href);
expect(result.constructor.name).toBe('GetRequest');
it(`should return a PostRequest`, (done) => {
const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
obs.subscribe((result: PostRequest) => {
expect(result.constructor.name).toBe('PostRequest');
done();
});
});
it(`should return a request with the given href`, () => {
const result = (service as any).createShortLivedTokenRequest(href);
expect(result.href).toBe(href) ;
it(`should return a request with the given href`, (done) => {
const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
obs.subscribe((result: PostRequest) => {
expect(result.href).toBe(href);
done();
});
});
it(`should have a responseMsToLive of 2 seconds`, () => {
const result = (service as any).createShortLivedTokenRequest(href);
expect(result.responseMsToLive).toBe(2 * 1000) ;
it(`should return a request with a xsrf header`, (done) => {
const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
obs.subscribe((result: PostRequest) => {
expect(result.options.headers.get(XSRF_REQUEST_HEADER)).toBe(mockToken);
done();
});
});
});
});

View File

@@ -1,9 +1,21 @@
import { Injectable } from '@angular/core';
import { AuthRequestService } from './auth-request.service';
import { GetRequest } from '../data/request.models';
import { PostRequest } from '../data/request.models';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestService } from '../data/request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import {
HttpHeaders,
HttpClient,
HttpResponse
} from '@angular/common/http';
import {
XSRF_REQUEST_HEADER,
XSRF_RESPONSE_HEADER,
DSPACE_XSRF_COOKIE
} from '../xsrf/xsrf.interceptor';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
/**
* Server side version of the service to send authentication requests
@@ -14,23 +26,42 @@ export class ServerAuthRequestService extends AuthRequestService {
constructor(
halService: HALEndpointService,
requestService: RequestService,
rdbService: RemoteDataBuildService
rdbService: RemoteDataBuildService,
protected httpClient: HttpClient,
) {
super(halService, requestService, rdbService);
}
/**
* Factory function to create the request object to send. This needs to be a POST client side and
* a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow
* only the server IP to send a GET to this endpoint.
* Factory function to create the request object to send.
*
* @param href The href to send the request to
* @protected
*/
protected createShortLivedTokenRequest(href: string): GetRequest {
return Object.assign(new GetRequest(this.requestService.generateRequestId(), href), {
responseMsToLive: 2 * 1000 // A short lived token is only valid for 2 seconds.
});
protected createShortLivedTokenRequest(href: string): Observable<PostRequest> {
// First do a call to the root endpoint in order to get an XSRF token
return this.httpClient.get(this.halService.getRootHref(), { observe: 'response' }).pipe(
// retrieve the XSRF token from the response header
map((response: HttpResponse<any>) => response.headers.get(XSRF_RESPONSE_HEADER)),
// Use that token to create an HttpHeaders object
map((xsrfToken: string) => new HttpHeaders()
.set('Content-Type', 'application/json; charset=utf-8')
// set the token as the XSRF header
.set(XSRF_REQUEST_HEADER, xsrfToken)
// and as the DSPACE-XSRF-COOKIE
.set('Cookie', `${DSPACE_XSRF_COOKIE}=${xsrfToken}`)),
map((headers: HttpHeaders) =>
// Create a new PostRequest using those headers and the given href
new PostRequest(
this.requestService.generateRequestId(),
href,
{},
{
headers: headers,
},
)
)
);
}
}

View File

@@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { hasValue, isEmpty } from '../../shared/empty.util';
import { DSpaceObject } from '../shared/dspace-object.model';
import { TranslateService } from '@ngx-translate/core';
import { Metadata } from '../shared/metadata.utils';
/**
* Returns a name for a {@link DSpaceObject} based
@@ -67,4 +68,45 @@ export class DSONameService {
return name;
}
/**
* Gets the Hit highlight
*
* @param object
* @param dso
*
* @returns {string} html embedded hit highlight.
*/
getHitHighlights(object: any, dso: DSpaceObject): string {
const types = dso.getRenderTypes();
const entityType = types
.filter((type) => typeof type === 'string')
.find((type: string) => (['Person', 'OrgUnit']).includes(type)) as string;
if (entityType === 'Person') {
const familyName = this.firstMetadataValue(object, dso, 'person.familyName');
const givenName = this.firstMetadataValue(object, dso, 'person.givenName');
if (isEmpty(familyName) && isEmpty(givenName)) {
return this.firstMetadataValue(object, dso, 'dc.title') || dso.name;
} else if (isEmpty(familyName) || isEmpty(givenName)) {
return familyName || givenName;
}
return `${familyName}, ${givenName}`;
} else if (entityType === 'OrgUnit') {
return this.firstMetadataValue(object, dso, 'organization.legalName');
}
return this.firstMetadataValue(object, dso, 'dc.title') || dso.name || this.translateService.instant('dso.name.untitled');
}
/**
* Gets the first matching metadata string value from hitHighlights or dso metadata, preferring hitHighlights.
*
* @param object
* @param dso
* @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]].
*
* @returns {string} the first matching string value, or `undefined`.
*/
firstMetadataValue(object: any, dso: DSpaceObject, keyOrKeys: string | string[]): string {
return Metadata.firstValue([object.hitHighlights, dso.metadata], keyOrKeys);
}
}

View File

@@ -2,27 +2,66 @@ import { BrowseDefinitionDataService } from './browse-definition-data.service';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { EMPTY } from 'rxjs';
import { FindListOptions } from '../data/find-list-options.model';
import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock';
import { RequestService } from '../data/request.service';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock';
describe(`BrowseDefinitionDataService`, () => {
let requestService: RequestService;
let service: BrowseDefinitionDataService;
const findAllDataSpy = jasmine.createSpyObj('findAllData', {
findAll: EMPTY,
});
let findAllDataSpy;
let searchDataSpy;
const browsesEndpointURL = 'https://rest.api/browses';
const halService: any = new HALEndpointServiceStub(browsesEndpointURL);
const options = new FindListOptions();
const linksToFollow = [
followLink('entries'),
followLink('items')
];
function initTestService() {
return new BrowseDefinitionDataService(
requestService,
getMockRemoteDataBuildService(),
getMockObjectCacheService(),
halService,
);
}
beforeEach(() => {
service = new BrowseDefinitionDataService(null, null, null, null);
service = initTestService();
findAllDataSpy = jasmine.createSpyObj('findAllData', {
findAll: EMPTY,
});
searchDataSpy = jasmine.createSpyObj('searchData', {
searchBy: EMPTY,
getSearchByHref: EMPTY,
});
(service as any).findAllData = findAllDataSpy;
(service as any).searchData = searchDataSpy;
});
describe('findByFields', () => {
it(`should call searchByHref on searchData`, () => {
service.findByFields(['test'], true, false, ...linksToFollow);
expect(searchDataSpy.getSearchByHref).toHaveBeenCalled();
});
});
describe('searchBy', () => {
it(`should call searchBy on searchData`, () => {
service.searchBy('test', options, true, false, ...linksToFollow);
expect(searchDataSpy.searchBy).toHaveBeenCalledWith('test', options, true, false, ...linksToFollow);
});
});
describe(`findAll`, () => {
it(`should call findAll on findAllData`, () => {
service.findAll(options, true, false, ...linksToFollow);
expect(findAllDataSpy.findAll).toHaveBeenCalledWith(options, true, false, ...linksToFollow);
});
});
});

View File

@@ -13,6 +13,8 @@ import { FindListOptions } from '../data/find-list-options.model';
import { IdentifiableDataService } from '../data/base/identifiable-data.service';
import { FindAllData, FindAllDataImpl } from '../data/base/find-all-data';
import { dataService } from '../data/base/data-service.decorator';
import { RequestParam } from '../cache/models/request-param.model';
import { SearchData, SearchDataImpl } from '../data/base/search-data';
/**
* Data service responsible for retrieving browse definitions from the REST server
@@ -21,8 +23,9 @@ import { dataService } from '../data/base/data-service.decorator';
providedIn: 'root',
})
@dataService(BROWSE_DEFINITION)
export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseDefinition> implements FindAllData<BrowseDefinition> {
export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseDefinition> implements FindAllData<BrowseDefinition>, SearchData<BrowseDefinition> {
private findAllData: FindAllDataImpl<BrowseDefinition>;
private searchData: SearchDataImpl<BrowseDefinition>;
constructor(
protected requestService: RequestService,
@@ -31,7 +34,7 @@ export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseD
protected halService: HALEndpointService,
) {
super('browses', requestService, rdbService, objectCache, halService);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
}
@@ -52,5 +55,71 @@ export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseD
findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<RemoteData<PaginatedList<BrowseDefinition>>> {
return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>}
* Return an observable that emits response from the server
*/
public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<RemoteData<PaginatedList<BrowseDefinition>>> {
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Create the HREF for a specific object's search method with given options object
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @return {Observable<string>}
* Return an observable that emits created HREF
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<string> {
return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow);
}
/**
* Get the browse URL by providing a list of metadata keys. The first matching browse index definition
* for any of the fields is returned. This is used in eg. item page field component, which can be configured
* with several fields for a component like 'Author', and needs to know if and how to link the values
* to configured browse indices.
*
* @param fields an array of field strings, eg. ['dc.contributor.author', 'dc.creator']
* @param useCachedVersionIfAvailable Override the data service useCachedVersionIfAvailable parameter (default: true)
* @param reRequestOnStale Override the data service reRequestOnStale parameter (default: true)
* @param linksToFollow Override the data service linksToFollow parameter (default: empty array)
*/
findByFields(
fields: string[],
useCachedVersionIfAvailable = true,
reRequestOnStale = true,
...linksToFollow: FollowLinkConfig<BrowseDefinition>[]
): Observable<RemoteData<BrowseDefinition>> {
const searchParams = [];
searchParams.push(new RequestParam('fields', fields));
const hrefObs = this.getSearchByHref(
'byFields',
{ searchParams },
...linksToFollow
);
return this.findByHref(
hrefObs,
useCachedVersionIfAvailable,
reRequestOnStale,
...linksToFollow,
);
}
}

View File

@@ -19,9 +19,9 @@ import {
} from '../shared/operators';
import { URLCombiner } from '../url-combiner/url-combiner';
import { BrowseEntrySearchOptions } from './browse-entry-search-options.model';
import { BrowseDefinitionDataService } from './browse-definition-data.service';
import { HrefOnlyDataService } from '../data/href-only-data.service';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { BrowseDefinitionDataService } from './browse-definition-data.service';
export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig<BrowseEntry | Item>[] = [
@@ -35,7 +35,7 @@ export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig<BrowseEntry | Item>[] = [
export class BrowseService {
protected linkPath = 'browses';
private static toSearchKeyArray(metadataKey: string): string[] {
public static toSearchKeyArray(metadataKey: string): string[] {
const keyParts = metadataKey.split('.');
const searchFor = [];
searchFor.push('*');

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line import/no-namespace
import * as deepFreeze from 'deep-freeze';
import { Operation } from 'fast-json-patch';
import { Item } from '../shared/item.model';

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line import/no-namespace
import * as deepFreeze from 'deep-freeze';
import { RemoveFromObjectCacheAction } from './object-cache.actions';
import { serverSyncBufferReducer } from './server-sync-buffer.reducer';

View File

@@ -2,15 +2,12 @@ import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core';
import { EffectsModule } from '@ngrx/effects';
import { Action, StoreConfig, StoreModule } from '@ngrx/store';
import { MyDSpaceGuard } from '../my-dspace-page/my-dspace.guard';
import { isNotEmpty } from '../shared/empty.util';
import { FormBuilderService } from '../shared/form/builder/form-builder.service';
import { FormService } from '../shared/form/form.service';
import { HostWindowService } from '../shared/host-window.service';
import { MenuService } from '../shared/menu/menu.service';
import { EndpointMockingRestService } from '../shared/mocks/dspace-rest/endpoint-mocking-rest.service';
@@ -23,10 +20,7 @@ import { NotificationsService } from '../shared/notifications/notifications.serv
import { SelectableListService } from '../shared/object-list/selectable-list/selectable-list.service';
import { ObjectSelectService } from '../shared/object-select/object-select.service';
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
import { CSSVariableService } from '../shared/sass-helper/sass-helper.service';
import { SidebarService } from '../shared/sidebar/sidebar.service';
import { UploaderService } from '../shared/uploader/uploader.service';
import { SectionFormOperationsService } from '../submission/sections/form/section-form-operations.service';
import { AuthenticatedGuard } from './auth/authenticated.guard';
import { AuthStatus } from './auth/models/auth-status.model';
import { BrowseService } from './browse/browse.service';
@@ -138,9 +132,6 @@ import {
import { Registration } from './shared/registration.model';
import { MetadataSchemaDataService } from './data/metadata-schema-data.service';
import { MetadataFieldDataService } from './data/metadata-field-data.service';
import {
DsDynamicTypeBindRelationService
} from '../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service';
import { TokenResponseParsingService } from './auth/token-response-parsing.service';
import { SubmissionCcLicenseDataService } from './submission/submission-cc-license-data.service';
import { SubmissionCcLicence } from './submission/models/submission-cc-license.model';
@@ -150,7 +141,6 @@ import { VocabularyEntry } from './submission/vocabularies/models/vocabulary-ent
import { Vocabulary } from './submission/vocabularies/models/vocabulary.model';
import { VocabularyEntryDetail } from './submission/vocabularies/models/vocabulary-entry-detail.model';
import { VocabularyService } from './submission/vocabularies/vocabulary.service';
import { VocabularyTreeviewService } from '../shared/vocabulary-treeview/vocabulary-treeview.service';
import { ConfigurationDataService } from './data/configuration-data.service';
import { ConfigurationProperty } from './shared/configuration-property.model';
import { ReloadGuard } from './reload/reload.guard';
@@ -211,12 +201,6 @@ const PROVIDERS = [
DSOResponseParsingService,
{ provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap },
{ provide: DspaceRestService, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient] },
DynamicFormLayoutService,
DynamicFormService,
DynamicFormValidationService,
FormBuilderService,
SectionFormOperationsService,
FormService,
EPersonDataService,
LinkHeadService,
HALEndpointService,
@@ -245,19 +229,16 @@ const PROVIDERS = [
SubmissionResponseParsingService,
SubmissionJsonPatchOperationsService,
JsonPatchOperationsBuilder,
UploaderService,
UUIDService,
NotificationsService,
WorkspaceitemDataService,
WorkflowItemDataService,
UploaderService,
DSpaceObjectDataService,
ConfigurationDataService,
DSOChangeAnalyzer,
DefaultChangeAnalyzer,
ArrayMoveChangeAnalyzer,
ObjectSelectService,
CSSVariableService,
MenuService,
ObjectUpdatesService,
SearchService,
@@ -268,7 +249,6 @@ const PROVIDERS = [
ClaimedTaskDataService,
PoolTaskDataService,
BitstreamDataService,
DsDynamicTypeBindRelationService,
EntityTypeDataService,
ContentSourceResponseParsingService,
ItemTemplateDataService,
@@ -304,7 +284,6 @@ const PROVIDERS = [
VocabularyService,
VocabularyDataService,
VocabularyEntryDetailsDataService,
VocabularyTreeviewService,
SequenceService,
GroupDataService,
FeedbackDataService,

View File

@@ -41,21 +41,28 @@ describe('ArrayMoveChangeAnalyzer', () => {
], new MoveTest(0, 3));
testMove([
{ op: 'move', from: '/2', path: '/3' },
{ op: 'move', from: '/0', path: '/3' },
{ op: 'move', from: '/2', path: '/1' }
], new MoveTest(0, 3), new MoveTest(1, 2));
testMove([
{ op: 'move', from: '/3', path: '/4' },
{ op: 'move', from: '/0', path: '/1' },
{ op: 'move', from: '/3', path: '/4' }
], new MoveTest(0, 1), new MoveTest(3, 4));
testMove([], new MoveTest(0, 4), new MoveTest(4, 0));
testMove([
{ op: 'move', from: '/2', path: '/3' },
{ op: 'move', from: '/0', path: '/3' },
{ op: 'move', from: '/2', path: '/1' }
], new MoveTest(0, 4), new MoveTest(1, 3), new MoveTest(2, 4));
testMove([
{ op: 'move', from: '/3', path: '/4' },
{ op: 'move', from: '/2', path: '/4' },
{ op: 'move', from: '/1', path: '/3' },
{ op: 'move', from: '/0', path: '/3' },
], new MoveTest(4, 1), new MoveTest(4, 2), new MoveTest(0, 3));
});
describe('when some values are undefined (index 2 and 3)', () => {

View File

@@ -16,22 +16,31 @@ export class ArrayMoveChangeAnalyzer<T> {
* @param array2 The custom array to compare with the original
*/
diff(array1: T[], array2: T[]): MoveOperation[] {
const result = [];
const moved = [...array1];
array1.forEach((value: T, index: number) => {
if (hasValue(value)) {
const otherIndex = array2.indexOf(value);
const movedIndex = moved.indexOf(value);
if (index !== otherIndex && movedIndex !== otherIndex) {
moveItemInArray(moved, movedIndex, otherIndex);
result.push(Object.assign({
op: 'move',
from: '/' + movedIndex,
path: '/' + otherIndex
}) as MoveOperation);
}
return this.getMoves(array1, array2).map((move) => Object.assign({
op: 'move',
from: '/' + move[0],
path: '/' + move[1],
}) as MoveOperation);
}
/**
* Determine a set of moves required to transform array1 into array2
* The moves are returned as an array of pairs of numbers where the first number is the original index and the second
* is the new index
* It is assumed the operations are executed in the order they're returned (and not simultaneously)
* @param array1
* @param array2
*/
private getMoves(array1: any[], array2: any[]): number[][] {
const moved = [...array2];
return array1.reduce((moves, item, index) => {
if (hasValue(item) && item !== moved[index]) {
const last = moved.lastIndexOf(item);
moveItemInArray(moved, last, index);
moves.unshift([index, last]);
}
});
return result;
return moves;
}, []);
}
}

View File

@@ -10,6 +10,7 @@ import { ResourceType } from '../../shared/resource-type';
import { BaseDataService } from './base-data.service';
import { HALDataService } from './hal-data-service.interface';
import { dataService, getDataServiceFor } from './data-service.decorator';
import { v4 as uuidv4 } from 'uuid';
class TestService extends BaseDataService<any> {
}
@@ -28,7 +29,7 @@ let testType;
describe('@dataService/getDataServiceFor', () => {
beforeEach(() => {
testType = new ResourceType('testType-' + new Date().getTime());
testType = new ResourceType(`testType-${uuidv4()}`);
});
it('should register a resourcetype for a dataservice', () => {

View File

@@ -3,7 +3,7 @@ import { ChangeAnalyzer } from './change-analyzer';
import { Injectable } from '@angular/core';
import { DSpaceObject } from '../shared/dspace-object.model';
import { MetadataMap } from '../shared/metadata.models';
import { cloneDeep } from 'lodash';
import cloneDeep from 'lodash/cloneDeep';
/**
* A class to determine what differs between two

View File

@@ -14,6 +14,7 @@ import { RemoteData } from './remote-data';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import { HttpHeaders } from '@angular/common/http';
import { HttpParams } from '@angular/common/http';
@Injectable({
providedIn: 'root',
@@ -55,7 +56,7 @@ export class EpersonRegistrationService {
* @param email
* @param captchaToken the value of x-recaptcha-token header
*/
registerEmail(email: string, captchaToken: string = null): Observable<RemoteData<Registration>> {
registerEmail(email: string, captchaToken: string = null, type?: string): Observable<RemoteData<Registration>> {
const registration = new Registration();
registration.email = email;
@@ -70,6 +71,11 @@ export class EpersonRegistrationService {
}
options.headers = headers;
if (hasValue(type)) {
options.params = type ?
new HttpParams({ fromString: 'accountRequestType=' + type }) : new HttpParams();
}
href$.pipe(
find((href: string) => hasValue(href)),
map((href: string) => {

View File

@@ -29,5 +29,7 @@ export enum FeatureID {
CanViewUsageStatistics = 'canViewUsageStatistics',
CanSendFeedback = 'canSendFeedback',
CanClaimItem = 'canClaimItem',
CanSynchronizeWithORCID = 'canSynchronizeWithORCID'
CanSynchronizeWithORCID = 'canSynchronizeWithORCID',
CanSubmit = 'canSubmit',
CanEditItem = 'canEditItem',
}

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line import/no-namespace
import * as deepFreeze from 'deep-freeze';
import {
AddFieldUpdateAction,

View File

@@ -0,0 +1,33 @@
import { MetadataPatchOperation } from './metadata-patch-operation.model';
import { Operation } from 'fast-json-patch';
/**
* Wrapper object for a metadata patch move Operation
*/
export class MetadataPatchMoveOperation extends MetadataPatchOperation {
static operationType = 'move';
/**
* The original place of the metadata value to move
*/
from: number;
/**
* The new place to move the metadata value to
*/
to: number;
constructor(field: string, from: number, to: number) {
super(MetadataPatchMoveOperation.operationType, field);
this.from = from;
this.to = to;
}
/**
* Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties
* using the information provided.
*/
toOperation(): Operation {
return { op: this.op as any, from: `/metadata/${this.field}/${this.from}`, path: `/metadata/${this.field}/${this.to}` };
}
}

View File

@@ -10,13 +10,19 @@ import { DeleteRequest } from './request.models';
import { RelationshipDataService } from './relationship-data.service';
import { RequestService } from './request.service';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils';
import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { createPaginatedList } from '../../shared/testing/utils.test';
import { RequestEntry } from './request-entry.model';
import { FindListOptions } from './find-list-options.model';
import { testSearchDataImplementation } from './base/search-data.spec';
import { MetadataValue } from '../shared/metadata.models';
import { MetadataRepresentationType } from '../shared/metadata-representation/metadata-representation.model';
describe('RelationshipDataService', () => {
let service: RelationshipDataService;
@@ -233,4 +239,152 @@ describe('RelationshipDataService', () => {
});
});
});
describe('resolveMetadataRepresentation', () => {
const parentItem: Item = Object.assign(new Item(), {
id: 'parent-item',
metadata: {
'dc.contributor.author': [
Object.assign(new MetadataValue(), {
language: null,
value: 'Related Author with authority',
authority: 'virtual::related-author',
place: 2
}),
Object.assign(new MetadataValue(), {
language: null,
value: 'Author without authority',
place: 1
}),
],
'dc.creator': [
Object.assign(new MetadataValue(), {
language: null,
value: 'Related Creator with authority',
authority: 'virtual::related-creator',
place: 3,
}),
Object.assign(new MetadataValue(), {
language: null,
value: 'Related Creator with authority - unauthorized',
authority: 'virtual::related-creator-unauthorized',
place: 4,
}),
],
'dc.title': [
Object.assign(new MetadataValue(), {
language: null,
value: 'Parent Item'
}),
]
}
});
const relatedAuthor: Item = Object.assign(new Item(), {
id: 'related-author',
metadata: {
'dc.title': [
Object.assign(new MetadataValue(), {
language: null,
value: 'Related Author'
}),
]
}
});
const relatedCreator: Item = Object.assign(new Item(), {
id: 'related-creator',
metadata: {
'dc.title': [
Object.assign(new MetadataValue(), {
language: null,
value: 'Related Creator'
}),
],
'dspace.entity.type': 'Person',
}
});
const authorRelation: Relationship = Object.assign(new Relationship(), {
leftItem: createSuccessfulRemoteDataObject$(parentItem),
rightItem: createSuccessfulRemoteDataObject$(relatedAuthor)
});
const creatorRelation: Relationship = Object.assign(new Relationship(), {
leftItem: createSuccessfulRemoteDataObject$(parentItem),
rightItem: createSuccessfulRemoteDataObject$(relatedCreator),
});
const creatorRelationUnauthorized: Relationship = Object.assign(new Relationship(), {
leftItem: createSuccessfulRemoteDataObject$(parentItem),
rightItem: createFailedRemoteDataObject$('Unauthorized', 401),
});
let metadatum: MetadataValue;
beforeEach(() => {
service.findById = (id: string) => {
if (id === 'related-author') {
return createSuccessfulRemoteDataObject$(authorRelation);
}
if (id === 'related-creator') {
return createSuccessfulRemoteDataObject$(creatorRelation);
}
if (id === 'related-creator-unauthorized') {
return createSuccessfulRemoteDataObject$(creatorRelationUnauthorized);
}
};
});
describe('when the metadata isn\'t virtual', () => {
beforeEach(() => {
metadatum = parentItem.metadata['dc.contributor.author'][1];
});
it('should return a plain text MetadatumRepresentation', (done) => {
service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => {
expect(result.representationType).toEqual(MetadataRepresentationType.PlainText);
done();
});
});
});
describe('when the metadata is a virtual author', () => {
beforeEach(() => {
metadatum = parentItem.metadata['dc.contributor.author'][0];
});
it('should return a ItemMetadataRepresentation with the correct value', (done) => {
service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => {
expect(result.representationType).toEqual(MetadataRepresentationType.Item);
expect(result.getValue()).toEqual(metadatum.value);
expect((result as any).id).toEqual(relatedAuthor.id);
done();
});
});
});
describe('when the metadata is a virtual creator', () => {
beforeEach(() => {
metadatum = parentItem.metadata['dc.creator'][0];
});
it('should return a ItemMetadataRepresentation with the correct value', (done) => {
service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => {
expect(result.representationType).toEqual(MetadataRepresentationType.Item);
expect(result.getValue()).toEqual(metadatum.value);
expect((result as any).id).toEqual(relatedCreator.id);
done();
});
});
});
describe('when the metadata refers to a relationship leading to an error response', () => {
beforeEach(() => {
metadatum = parentItem.metadata['dc.creator'][1];
});
it('should return an authority controlled MetadatumRepresentation', (done) => {
service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => {
expect(result.representationType).toEqual(MetadataRepresentationType.AuthorityControlled);
done();
});
});
});
});
});

View File

@@ -1,7 +1,7 @@
import { HttpHeaders } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { MemoizedSelector, select, Store } from '@ngrx/store';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators';
import {
compareArraysUsingIds, PAGINATED_RELATIONS_TO_ITEMS_OPERATOR,
@@ -46,6 +46,11 @@ import { PutData, PutDataImpl } from './base/put-data';
import { IdentifiableDataService } from './base/identifiable-data.service';
import { dataService } from './base/data-service.decorator';
import { itemLinksToFollow } from '../../shared/utils/relation-query.utils';
import { MetadataValue } from '../shared/metadata.models';
import { MetadataRepresentation } from '../shared/metadata-representation/metadata-representation.model';
import { MetadatumRepresentation } from '../shared/metadata-representation/metadatum/metadatum-representation.model';
import { ItemMetadataRepresentation } from '../shared/metadata-representation/item/item-metadata-representation.model';
import { DSpaceObject } from '../shared/dspace-object.model';
const relationshipListsStateSelector = (state: AppState) => state.relationshipLists;
@@ -550,4 +555,40 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<Relationship>[]): Observable<RemoteData<PaginatedList<Relationship>>> {
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Resolve a {@link MetadataValue} into a {@link MetadataRepresentation} of the correct type
* @param metadatum {@link MetadataValue} to resolve
* @param parentItem Parent dspace object the metadata value belongs to
* @param itemType The type of item this metadata value represents (will only be used when no related item can be found, as a fallback)
*/
resolveMetadataRepresentation(metadatum: MetadataValue, parentItem: DSpaceObject, itemType: string): Observable<MetadataRepresentation> {
if (metadatum.isVirtual) {
return this.findById(metadatum.virtualValue, true, false, followLink('leftItem'), followLink('rightItem')).pipe(
getFirstSucceededRemoteData(),
switchMap((relRD: RemoteData<Relationship>) =>
observableCombineLatest(relRD.payload.leftItem, relRD.payload.rightItem).pipe(
filter(([leftItem, rightItem]) => leftItem.hasCompleted && rightItem.hasCompleted),
map(([leftItem, rightItem]) => {
if (!leftItem.hasSucceeded || !rightItem.hasSucceeded) {
return null;
} else if (rightItem.hasSucceeded && leftItem.payload.id === parentItem.id) {
return rightItem.payload;
} else if (rightItem.payload.id === parentItem.id) {
return leftItem.payload;
}
}),
map((item: Item) => {
if (hasValue(item)) {
return Object.assign(new ItemMetadataRepresentation(metadatum), item);
} else {
return Object.assign(new MetadatumRepresentation(itemType), metadatum);
}
})
)
));
} else {
return observableOf(Object.assign(new MetadatumRepresentation(itemType), metadatum));
}
}
}

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line import/no-namespace
import * as deepFreeze from 'deep-freeze';
import {
RequestConfigureAction,

View File

@@ -4,7 +4,7 @@ import { HttpHeaders } from '@angular/common/http';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { filter, map, take, tap } from 'rxjs/operators';
import { cloneDeep } from 'lodash';
import cloneDeep from 'lodash/cloneDeep';
import { hasValue, isEmpty, isNotEmpty, hasNoValue } from '../../shared/empty.util';
import { ObjectCacheEntry } from '../cache/object-cache.reducer';
import { ObjectCacheService } from '../cache/object-cache.service';

View File

@@ -0,0 +1,13 @@
import { SystemWideAlertDataService } from './system-wide-alert-data.service';
import { testFindAllDataImplementation } from './base/find-all-data.spec';
import { testPutDataImplementation } from './base/put-data.spec';
import { testCreateDataImplementation } from './base/create-data.spec';
describe('SystemWideAlertDataService', () => {
describe('composition', () => {
const initService = () => new SystemWideAlertDataService(null, null, null, null, null);
testFindAllDataImplementation(initService);
testPutDataImplementation(initService);
testCreateDataImplementation(initService);
});
});

View File

@@ -0,0 +1,104 @@
import { Injectable } from '@angular/core';
import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Observable } from 'rxjs';
import { PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data';
import { IdentifiableDataService } from './base/identifiable-data.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { FindAllData, FindAllDataImpl } from './base/find-all-data';
import { FindListOptions } from './find-list-options.model';
import { dataService } from './base/data-service.decorator';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { CreateData, CreateDataImpl } from './base/create-data';
import { SYSTEMWIDEALERT } from '../../system-wide-alert/system-wide-alert.resource-type';
import { SystemWideAlert } from '../../system-wide-alert/system-wide-alert.model';
import { PutData, PutDataImpl } from './base/put-data';
import { RequestParam } from '../cache/models/request-param.model';
import { SearchData, SearchDataImpl } from './base/search-data';
/**
* Dataservice representing a system-wide alert
*/
@Injectable()
@dataService(SYSTEMWIDEALERT)
export class SystemWideAlertDataService extends IdentifiableDataService<SystemWideAlert> implements FindAllData<SystemWideAlert>, CreateData<SystemWideAlert>, PutData<SystemWideAlert>, SearchData<SystemWideAlert> {
private findAllData: FindAllDataImpl<SystemWideAlert>;
private createData: CreateDataImpl<SystemWideAlert>;
private putData: PutDataImpl<SystemWideAlert>;
private searchData: SearchData<SystemWideAlert>;
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
) {
super('systemwidealerts', requestService, rdbService, objectCache, halService);
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive);
this.putData = new PutDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
}
/**
* Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded
* info should be added to the objects
*
* @param options Find list options object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>>}
* Return an observable that emits object list
*/
findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<SystemWideAlert>[]): Observable<RemoteData<PaginatedList<SystemWideAlert>>> {
return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Create a new object on the server, and store the response in the object cache
*
* @param object The object to create
* @param params Array with additional params to combine with query string
*/
create(object: SystemWideAlert, ...params: RequestParam[]): Observable<RemoteData<SystemWideAlert>> {
return this.createData.create(object, ...params);
}
/**
* Send a PUT request for the specified object
*
* @param object The object to send a put request for.
*/
put(object: SystemWideAlert): Observable<RemoteData<SystemWideAlert>> {
return this.putData.put(object);
}
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>}
* Return an observable that emits response from the server
*/
searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<SystemWideAlert>[]): Observable<RemoteData<PaginatedList<SystemWideAlert>>> {
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
}

View File

@@ -1,7 +1,17 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
@Injectable()
export class UploaderService {
@Injectable({
providedIn: 'root'
})
export class DragService {
private _overrideDragOverPage = false;
public overrideDragOverPage() {

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line import/no-namespace
import * as deepFreeze from 'deep-freeze';
import { indexReducer, MetaIndexState } from './index.reducer';

View File

@@ -3,7 +3,6 @@ import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { coreSelector } from '../core.selectors';
import { URLCombiner } from '../url-combiner/url-combiner';
import { IndexState, MetaIndexState } from './index.reducer';
import * as parse from 'url-parse';
import { IndexName } from './index-name.model';
import { CoreState } from '../core-state.model';
@@ -21,17 +20,21 @@ import { CoreState } from '../core-state.model';
*/
export const getUrlWithoutEmbedParams = (url: string): string => {
if (isNotEmpty(url)) {
const parsed = parse(url);
if (isNotEmpty(parsed.query)) {
const parts = parsed.query.split(/[?|&]/)
.filter((part: string) => isNotEmpty(part))
.filter((part: string) => !(part.startsWith('embed=') || part.startsWith('embed.size=')));
let args = '';
if (isNotEmpty(parts)) {
args = `?${parts.join('&')}`;
try {
const parsed = new URL(url);
if (isNotEmpty(parsed.search)) {
const parts = parsed.search.split(/[?|&]/)
.filter((part: string) => isNotEmpty(part))
.filter((part: string) => !(part.startsWith('embed=') || part.startsWith('embed.size=')));
let args = '';
if (isNotEmpty(parts)) {
args = `?${parts.join('&')}`;
}
url = new URLCombiner(parsed.origin, parsed.pathname, args).toString();
return url;
}
url = new URLCombiner(parsed.origin, parsed.pathname, args).toString();
return url;
} catch (e) {
// Ignore parsing errors. By default, we return the original string below.
}
}
@@ -44,15 +47,19 @@ export const getUrlWithoutEmbedParams = (url: string): string => {
*/
export const getEmbedSizeParams = (url: string): { name: string, size: number }[] => {
if (isNotEmpty(url)) {
const parsed = parse(url);
if (isNotEmpty(parsed.query)) {
return parsed.query.split(/[?|&]/)
.filter((part: string) => isNotEmpty(part))
.map((part: string) => part.match(/^embed.size=([^=]+)=(\d+)$/))
.filter((matches: RegExpMatchArray) => hasValue(matches) && hasValue(matches[1]) && hasValue(matches[2]))
.map((matches: RegExpMatchArray) => {
return { name: matches[1], size: Number(matches[2]) };
});
try {
const parsed = new URL(url);
if (isNotEmpty(parsed.search)) {
return parsed.search.split(/[?|&]/)
.filter((part: string) => isNotEmpty(part))
.map((part: string) => part.match(/^embed.size=([^=]+)=(\d+)$/))
.filter((matches: RegExpMatchArray) => hasValue(matches) && hasValue(matches[1]) && hasValue(matches[2]))
.map((matches: RegExpMatchArray) => {
return { name: matches[1], size: Number(matches[2]) };
});
}
} catch (e) {
// Ignore parsing errors. By default, we return an empty result below.
}
}

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line import/no-namespace
import * as deepFreeze from 'deep-freeze';
import {

View File

@@ -40,7 +40,7 @@ export class LocaleService {
protected translate: TranslateService,
protected authService: AuthService,
protected routeService: RouteService,
@Inject(DOCUMENT) private document: any
@Inject(DOCUMENT) protected document: any
) {
}

View File

@@ -1,12 +1,31 @@
import { LANG_ORIGIN, LocaleService } from './locale.service';
import { Injectable } from '@angular/core';
import { Inject, Injectable } from '@angular/core';
import { combineLatest, Observable, of as observableOf } from 'rxjs';
import { map, mergeMap, take } from 'rxjs/operators';
import { isEmpty, isNotEmpty } from '../../shared/empty.util';
import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
import { REQUEST } from '@nguniversal/express-engine/tokens';
import { CookieService } from '../services/cookie.service';
import { TranslateService } from '@ngx-translate/core';
import { AuthService } from '../auth/auth.service';
import { RouteService } from '../services/route.service';
import { DOCUMENT } from '@angular/common';
@Injectable()
export class ServerLocaleService extends LocaleService {
constructor(
@Inject(NativeWindowService) protected _window: NativeWindowRef,
@Inject(REQUEST) protected req: Request,
protected cookie: CookieService,
protected translate: TranslateService,
protected authService: AuthService,
protected routeService: RouteService,
@Inject(DOCUMENT) protected document: any
) {
super(_window, cookie, translate, authService, routeService, document);
}
/**
* Get the languages list of the user in Accept-Language format
*
@@ -50,6 +69,10 @@ export class ServerLocaleService extends LocaleService {
if (isNotEmpty(epersonLang)) {
languages.push(...epersonLang);
}
if (hasValue(this.req.headers['accept-language'])) {
languages.push(...this.req.headers['accept-language'].split(',')
);
}
return languages;
})
);

View File

@@ -8,7 +8,12 @@ import { Observable, of as observableOf, of } from 'rxjs';
import { RemoteData } from '../data/remote-data';
import { Item } from '../shared/item.model';
import { ItemMock, MockBitstream1, MockBitstream3 } from '../../shared/mocks/item.mock';
import {
ItemMock,
MockBitstream1,
MockBitstream3,
MockBitstream2
} from '../../shared/mocks/item.mock';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { PaginatedList } from '../data/paginated-list.model';
import { Bitstream } from '../shared/bitstream.model';
@@ -24,6 +29,7 @@ import { HardRedirectService } from '../services/hard-redirect.service';
import { getMockStore } from '@ngrx/store/testing';
import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions';
import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service';
import { AppConfig } from '../../../config/app-config.interface';
describe('MetadataService', () => {
let metadataService: MetadataService;
@@ -44,6 +50,8 @@ describe('MetadataService', () => {
let router: Router;
let store;
let appConfig: AppConfig;
const initialState = { 'core': { metaTag: { tagsInUse: ['title', 'description'] }}};
@@ -86,6 +94,14 @@ describe('MetadataService', () => {
store = getMockStore({ initialState });
spyOn(store, 'dispatch');
appConfig = {
item: {
bitstream: {
pageSize: 5
}
}
} as any;
metadataService = new MetadataService(
router,
translateService,
@@ -98,6 +114,7 @@ describe('MetadataService', () => {
rootService,
store,
hardRedirectService,
appConfig,
authorizationService
);
});
@@ -358,29 +375,66 @@ describe('MetadataService', () => {
});
}));
it('should link to first Bitstream with allowed format', fakeAsync(() => {
const bitstreams = [MockBitstream3, MockBitstream3, MockBitstream1];
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
(bitstreamDataService.findListByHref as jasmine.Spy).and.returnValues(
...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)),
);
describe(`when there's a bitstream with an allowed format on the first page`, () => {
let bitstreams;
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
beforeEach(() => {
bitstreams = [MockBitstream2, MockBitstream3, MockBitstream1];
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
(bitstreamDataService.findListByHref as jasmine.Spy).and.returnValues(
...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)),
);
});
it('should link to first Bitstream with allowed format', fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
}
}
}
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_pdf_url',
content: 'https://request.org/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/download'
});
}));
});
tick();
expect(meta.addTag).toHaveBeenCalledWith({
name: 'citation_pdf_url',
content: 'https://request.org/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download'
});
}));
});
});
});
describe(`when there's no bitstream with an allowed format on the first page`, () => {
let bitstreams;
beforeEach(() => {
bitstreams = [MockBitstream1, MockBitstream3, MockBitstream2];
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
(bitstreamDataService.findListByHref as jasmine.Spy).and.returnValues(
...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)),
);
});
it(`shouldn't add a citation_pdf_url meta tag`, fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
}
}
});
tick();
expect(meta.addTag).not.toHaveBeenCalledWith({
name: 'citation_pdf_url',
content: 'https://request.org/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download'
});
}));
});
describe('tagstore', () => {
beforeEach(fakeAsync(() => {
(metadataService as any).processRouteChange({

View File

@@ -1,14 +1,21 @@
import { Injectable } from '@angular/core';
import { Injectable, Inject } from '@angular/core';
import { Meta, MetaDefinition, Title } from '@angular/platform-browser';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, combineLatest, EMPTY, Observable, of as observableOf } from 'rxjs';
import { expand, filter, map, switchMap, take } from 'rxjs/operators';
import {
BehaviorSubject,
combineLatest,
Observable,
of as observableOf,
concat as observableConcat,
EMPTY
} from 'rxjs';
import { filter, map, switchMap, take, mergeMap } from 'rxjs/operators';
import { hasNoValue, hasValue } from '../../shared/empty.util';
import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util';
import { DSONameService } from '../breadcrumbs/dso-name.service';
import { BitstreamDataService } from '../data/bitstream-data.service';
import { BitstreamFormatDataService } from '../data/bitstream-format-data.service';
@@ -37,6 +44,7 @@ import { coreSelector } from '../core.selectors';
import { CoreState } from '../core-state.model';
import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service';
import { getDownloadableBitstream } from '../shared/bitstream.operators';
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
/**
* The base selector function to select the metaTag section in the store
@@ -87,6 +95,7 @@ export class MetadataService {
private rootService: RootDataService,
private store: Store<CoreState>,
private hardRedirectService: HardRedirectService,
@Inject(APP_CONFIG) private appConfig: AppConfig,
private authorizationService: AuthorizationDataService
) {
}
@@ -298,7 +307,13 @@ export class MetadataService {
true,
true,
followLink('primaryBitstream'),
followLink('bitstreams', {}, followLink('format')),
followLink('bitstreams', {
findListOptions: {
// limit the number of bitstreams used to find the citation pdf url to the number
// shown by default on an item page
elementsPerPage: this.appConfig.item.bitstream.pageSize
}
}, followLink('format')),
).pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((bundle: Bundle) =>
@@ -363,64 +378,45 @@ export class MetadataService {
}
/**
* For Items with more than one Bitstream (and no primary Bitstream), link to the first Bitstream with a MIME type
* For Items with more than one Bitstream (and no primary Bitstream), link to the first Bitstream
* with a MIME type.
*
* Note this will only check the current page (page size determined item.bitstream.pageSize in the
* config) of bitstreams for performance reasons.
* See https://github.com/DSpace/DSpace/issues/8648 for more info
*
* included in {@linkcode CITATION_PDF_URL_MIMETYPES}
* @param bitstreamRd
* @private
*/
private getFirstAllowedFormatBitstreamLink(bitstreamRd: RemoteData<PaginatedList<Bitstream>>): Observable<string> {
return observableOf(bitstreamRd.payload).pipe(
// Because there can be more than one page of bitstreams, this expand operator
// will retrieve them in turn. Due to the take(1) at the bottom, it will only
// retrieve pages until a match is found
expand((paginatedList: PaginatedList<Bitstream>) => {
if (hasNoValue(paginatedList.next)) {
// If there's no next page, stop.
return EMPTY;
} else {
// Otherwise retrieve the next page
return this.bitstreamDataService.findListByHref(
paginatedList.next,
undefined,
true,
true,
followLink('format')
).pipe(
getFirstCompletedRemoteData(),
map((next: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(next.payload)) {
return next.payload;
} else {
return EMPTY;
}
})
);
}
}),
// Return the array of bitstreams inside each paginated list
map((paginatedList: PaginatedList<Bitstream>) => paginatedList.page),
// Emit the bitstreams in the list one at a time
switchMap((bitstreams: Bitstream[]) => bitstreams),
// Retrieve the format for each bitstream
switchMap((bitstream: Bitstream) => bitstream.format.pipe(
getFirstSucceededRemoteDataPayload(),
// Keep the original bitstream, because it, not the format, is what we'll need
// for the link at the end
map((format: BitstreamFormat) => [bitstream, format])
)),
// Check if bitstream downloadable
switchMap(([bitstream, format]: [Bitstream, BitstreamFormat]) => observableOf(bitstream).pipe(
getDownloadableBitstream(this.authorizationService),
map((bit: Bitstream) => [bit, format])
)),
// Filter out only pairs with whitelisted formats and non-null bitstreams, null from download check
filter(([bitstream, format]: [Bitstream, BitstreamFormat]) =>
hasValue(format) && hasValue(bitstream) && this.CITATION_PDF_URL_MIMETYPES.includes(format.mimetype)),
// We only need 1
take(1),
// Emit the link of the match
map(([bitstream, ]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream))
);
if (hasValue(bitstreamRd.payload) && isNotEmpty(bitstreamRd.payload.page)) {
// Retrieve the formats of all bitstreams in the page sequentially
return observableConcat(
...bitstreamRd.payload.page.map((bitstream: Bitstream) => bitstream.format.pipe(
getFirstSucceededRemoteDataPayload(),
// Keep the original bitstream, because it, not the format, is what we'll need
// for the link at the end
map((format: BitstreamFormat) => [bitstream, format])
))
).pipe(
// Verify that the bitstream is downloadable
mergeMap(([bitstream, format]: [Bitstream, BitstreamFormat]) => observableOf(bitstream).pipe(
getDownloadableBitstream(this.authorizationService),
map((bit: Bitstream) => [bit, format])
)),
// Filter out only pairs with whitelisted formats and non-null bitstreams, null from download check
filter(([bitstream, format]: [Bitstream, BitstreamFormat]) =>
hasValue(format) && hasValue(bitstream) && this.CITATION_PDF_URL_MIMETYPES.includes(format.mimetype)),
// We only need 1
take(1),
// Emit the link of the match
// tap((v) => console.log('result', v)),
map(([bitstream, ]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream))
);
} else {
return EMPTY;
}
}
/**

View File

@@ -1,8 +1,7 @@
import { filter, map, pairwise } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import * as fromRouter from '@ngrx/router-store';
import { RouterNavigationAction } from '@ngrx/router-store';
import { RouterNavigationAction, ROUTER_NAVIGATION } from '@ngrx/router-store';
import { Router } from '@angular/router';
import { RouteUpdateAction } from './router.actions';
@@ -14,7 +13,7 @@ export class RouterEffects {
*/
routeChange$ = createEffect(() => this.actions$
.pipe(
ofType(fromRouter.ROUTER_NAVIGATION),
ofType(ROUTER_NAVIGATION),
pairwise(),
map((actions: RouterNavigationAction[]) =>
actions.map((navigateAction) => {

View File

@@ -4,7 +4,7 @@ import { ActivatedRoute, NavigationEnd, Params, Router, RouterStateSnapshot, } f
import { combineLatest, Observable } from 'rxjs';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { isEqual } from 'lodash';
import isEqual from 'lodash/isEqual';
import { AddParameterAction, SetParameterAction, SetParametersAction, SetQueryParameterAction, SetQueryParametersAction } from './route.actions';
import { coreSelector } from '../core.selectors';

View File

@@ -0,0 +1,16 @@
import { XhrFactory } from '@angular/common';
import { Injectable } from '@angular/core';
import { prototype, XMLHttpRequest } from 'xhr2';
/**
* Overrides the default XhrFactory server side, to allow us to set cookies in requests to the
* backend. This was added to be able to perform a working XSRF request from the node server, as it
* needs to set a cookie for the XSRF token
*/
@Injectable()
export class ServerXhrService implements XhrFactory {
build(): XMLHttpRequest {
prototype._restrictedHeaders.cookie = false;
return new XMLHttpRequest();
}
}

View File

@@ -3,7 +3,7 @@ import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { RequestService } from '../data/request.service';
import { HALEndpointService } from './hal-endpoint.service';
import { EndpointMapRequest } from '../data/request.models';
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
import { environment } from '../../../environments/environment';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';

View File

@@ -14,6 +14,11 @@ export class MediaViewerItem {
*/
format: string;
/**
* Incoming Bitsream format mime type
*/
mimetype: string;
/**
* Incoming Bitsream thumbnail
*/

View File

@@ -1,11 +1,14 @@
/**
* An Enum defining the representation type of metadata
*/
import { BrowseDefinition } from '../browse-definition.model';
export enum MetadataRepresentationType {
None = 'none',
Item = 'item',
AuthorityControlled = 'authority_controlled',
PlainText = 'plain_text'
PlainText = 'plain_text',
BrowseLink = 'browse_link'
}
/**
@@ -24,8 +27,14 @@ export interface MetadataRepresentation {
*/
representationType: MetadataRepresentationType;
/**
* The browse definition (optional)
*/
browseDefinition?: BrowseDefinition;
/**
* Fetches the value to be displayed
*/
getValue(): string;
}

Some files were not shown because too many files have changed in this diff Show More